From 08c8a21da09ec2c2f71e5060bb9732ade3cc228b Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 2 Nov 2023 02:54:27 +0300 Subject: [PATCH 01/69] linux bridge works as hub --- ipmininet/ipswitch.py | 2 +- ipmininet/overlay.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ipmininet/ipswitch.py b/ipmininet/ipswitch.py index b7f9893d..ce7f983a 100644 --- a/ipmininet/ipswitch.py +++ b/ipmininet/ipswitch.py @@ -31,7 +31,7 @@ def start(self, _controllers): self.cmd('brctl delbr', self) self.cmd('brctl addbr', self) if self.hub: - self.cmd('brctl setageing 0', self) + self.cmd('brctl setageing ', self, ' 0') if self.stp: self.cmd('brctl setbridgeprio', self, self.prio) self.cmd('brctl stp', self, 'on') diff --git a/ipmininet/overlay.py b/ipmininet/overlay.py index 7022fc2b..122d2014 100644 --- a/ipmininet/overlay.py +++ b/ipmininet/overlay.py @@ -249,7 +249,7 @@ def __init__(self, nodes: List['NodeDescription'] = (), interfaces: List['IntfDe """ self.base_filename = base_filename - self.interfaces = list(set(interfaces)) + self.interfaces = list(interfaces) self.extra_arguments = extra_arguments self.ongoing_captures = {} super().__init__(nodes=list(set(nodes))) @@ -274,6 +274,11 @@ def start(self, node: Optional['IPNode'] = None, intf: Optional['IPIntf'] = None self.ongoing_captures[node.name] = process return process elif intf is not None: + + if intf.name in self.ongoing_captures: + lg.info('We already run tcpdump on this interface') + return None + file_path = os.path.join(intf.node.cwd, self.base_filename + '_' + intf.name + '.pcapng') cmd = f"tcpdump -Z root -i {intf.name} -w {file_path} {self.extra_arguments}" process = intf.node.popen(shlex.split(cmd)) From 7afe5f01613e66a45f4763fea5fc77625437e8fe Mon Sep 17 00:00:00 2001 From: MinyazevR <89993880+MinyazevR@users.noreply.github.com> Date: Sun, 17 Mar 2024 17:14:02 +0300 Subject: [PATCH 02/69] Mimidump added Mimidump added --- ipmininet/overlay.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ipmininet/overlay.py b/ipmininet/overlay.py index 122d2014..491521f3 100644 --- a/ipmininet/overlay.py +++ b/ipmininet/overlay.py @@ -280,7 +280,9 @@ def start(self, node: Optional['IPNode'] = None, intf: Optional['IPIntf'] = None return None file_path = os.path.join(intf.node.cwd, self.base_filename + '_' + intf.name + '.pcapng') - cmd = f"tcpdump -Z root -i {intf.name} -w {file_path} {self.extra_arguments}" + file_path_out = os.path.join(intf.node.cwd, self.base_filename + '_' + intf.name + '_out.pcapng') + cmd = f"mimidump {intf.name} {file_path} {file_path_out} {self.extra_arguments}" + process = intf.node.popen(shlex.split(cmd)) self.ongoing_captures[intf.name] = process return process From 6382989ca1c203829bc7e0539705a2d558a5e9cf Mon Sep 17 00:00:00 2001 From: Ivan Shurekov Date: Sun, 2 Mar 2025 17:08:07 +0300 Subject: [PATCH 03/69] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20RSTP=20=D0=B4=D0=BB=D1=8F=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ipmininet/ipovs_switch.py | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 ipmininet/ipovs_switch.py diff --git a/ipmininet/ipovs_switch.py b/ipmininet/ipovs_switch.py new file mode 100644 index 00000000..f7cada0f --- /dev/null +++ b/ipmininet/ipovs_switch.py @@ -0,0 +1,71 @@ +from mininet.node import OVSSwitch +from mininet.log import info, error, warn, debug + + +class IPOVSSwitch(OVSSwitch): + def __init__(self, name: str, stp=False, hub=False, cwd='/tmp', rstp=False, + failMode="standalone", batch=False, **params): + self.cwd = cwd + self.rstp = rstp and not hub + stp = stp and not hub + OVSSwitch.__init__(self, failMode=failMode, name=name, stp=stp, batch=False, **params) + + def start(self, controllers): + """Start up a new OVS OpenFlow switch using ovs-vsctl""" + if self.inNamespace: + raise Exception('OVS kernel switch does not work in a namespace') + + self.cmd('ifconfig', self, 'down') + int(self.dpid, 16) # DPID must be a hex string + # Command to add interfaces + intfs = ''.join(' -- add-port %s %s' % (self, intf) + self.intfOpts(intf) + for intf in self.intfList() if self.ports[intf] and not intf.IP()) + # Command to create controller entries + clist = [(self.name + c.name, '%s:%s:%d' % (c.protocol, c.IP(), c.port)) for c in controllers] + if self.listenPort: + clist.append((self.name + '-listen', 'ptcp:%s' % self.listenPort)) + ccmd = '-- --id=@%s create Controller target=\\"%s\\"' + if self.reconnectms: + ccmd += ' max_backoff=%d' % self.reconnectms + cargs = ' '.join(ccmd % (name, target) for name, target in clist) + # Try to delete any existing bridges with the same name + if not self.isOldOVS(): + cargs += ' --if-exists del-br %s' % self + # One ovs-vsctl command to rule them all! + self.vsctl(cargs) + self.vsctl(' add-br %s' % self) + self.vsctl(' set bridge %s' % self + self.bridgeOpts()) + self.vsctl(intfs) + # Start the captures on this switch + for capture in self.params.get("captures", []): + capture.start(node=self) + for intf in self.intfList(): + for capture in intf.params.get("captures", []): + capture.start(intf=intf) + self.cmd('ifconfig', self, 'up') + + def bridgeOpts(self): + """Return OVS bridge options""" + opts = (' other_config:datapath-id=%s' % self.dpid + + ' fail_mode=%s' % self.failMode) + if not self.inband: + opts += ' other_config:disable-in-band=true' + if self.datapath == 'user': + opts += ' datapath_type=netdev' + if self.protocols and not self.isOldOVS(): + opts += ' protocols=%s' % self.protocols + if self.rstp and self.failMode == 'standalone': + opts += ' rstp_enable=true' + elif self.stp and self.failMode == 'standalone': + opts += ' stp_enable=true' + opts += ' other_config:dp-desc=%s' % self.name + return opts + + def stop(self, deleteIntfs=True): + # Stop the captures on this switch + for capture in self.params.get("captures", []): + capture.stop(node=self) + for intf in self.intfList(): + for capture in intf.params.get("captures", []): + capture.stop(intf=intf) + OVSSwitch.stop(self, deleteIntfs) From 62e27a241f2268510013d2971dec86979a018546 Mon Sep 17 00:00:00 2001 From: Ivan Shurekov Date: Sat, 15 Mar 2025 11:58:56 +0300 Subject: [PATCH 04/69] Add stp and rstp priority --- ipmininet/ipovs_switch.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ipmininet/ipovs_switch.py b/ipmininet/ipovs_switch.py index f7cada0f..37ad7525 100644 --- a/ipmininet/ipovs_switch.py +++ b/ipmininet/ipovs_switch.py @@ -4,8 +4,9 @@ class IPOVSSwitch(OVSSwitch): def __init__(self, name: str, stp=False, hub=False, cwd='/tmp', rstp=False, - failMode="standalone", batch=False, **params): + failMode="standalone", batch=False, priority=None, **params): self.cwd = cwd + self.priority = priority self.rstp = rstp and not hub stp = stp and not hub OVSSwitch.__init__(self, failMode=failMode, name=name, stp=stp, batch=False, **params) @@ -46,8 +47,12 @@ def start(self, controllers): def bridgeOpts(self): """Return OVS bridge options""" - opts = (' other_config:datapath-id=%s' % self.dpid + + opts = (' other_config:enable-vlan-filtering=true' + + ' other_config:datapath-id=%s' % self.dpid + ' fail_mode=%s' % self.failMode) + if self.priority is not None: + opts += (' other_config:stp-priority=%s' % self.priority + + ' other_config:rstp-priority=%s' % self.priority) if not self.inband: opts += ' other_config:disable-in-band=true' if self.datapath == 'user': From d9035f14704d19b453c68e3a3c8bb48ba5695126 Mon Sep 17 00:00:00 2001 From: Ivan Shurekov Date: Sat, 15 Mar 2025 16:16:57 +0300 Subject: [PATCH 05/69] Remove vlan filtering --- ipmininet/ipovs_switch.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ipmininet/ipovs_switch.py b/ipmininet/ipovs_switch.py index 37ad7525..ad01641a 100644 --- a/ipmininet/ipovs_switch.py +++ b/ipmininet/ipovs_switch.py @@ -47,8 +47,7 @@ def start(self, controllers): def bridgeOpts(self): """Return OVS bridge options""" - opts = (' other_config:enable-vlan-filtering=true' + - ' other_config:datapath-id=%s' % self.dpid + + opts = (' other_config:datapath-id=%s' % self.dpid + ' fail_mode=%s' % self.failMode) if self.priority is not None: opts += (' other_config:stp-priority=%s' % self.priority + From 3e1cdcf2bb5e225775de8d205be5068674e071cc Mon Sep 17 00:00:00 2001 From: Ivan Date: Fri, 10 Oct 2025 18:02:47 +0300 Subject: [PATCH 06/69] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D0=B0=20=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=B4=D0=B5=D0=BC=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ipmininet/router/__router.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ipmininet/router/__router.py b/ipmininet/router/__router.py index 56584711..3e012eeb 100644 --- a/ipmininet/router/__router.py +++ b/ipmininet/router/__router.py @@ -16,6 +16,8 @@ from ipmininet.utils import L3Router, realIntfList, otherIntf from .config import BasicRouterConfig, NodeConfig, RouterConfig, \ OpenrRouterConfig +from .config.utils import ConfigDict +from .config.base import Daemon class ProcessHelper: @@ -114,6 +116,7 @@ class or a tuple (class, kwargs) else: self.nconfig = config(self) self._processes = process_manager(self) + self._daemons = [] def start(self): """Start the node: Configure the daemons, set the relevant sysctls, @@ -166,11 +169,24 @@ def start(self): while not d.has_started(self._processes): time.sleep(.001) + def build_daemon(self, daemon: Daemon): + _cfg = ConfigDict() + _cfg.name = self.name + _cfg[daemon.NAME] = daemon.build() + cfg = daemon.render(_cfg) + daemon.write(cfg) + + def start_daemon(self, daemon: Daemon): + self._processes.popen(shlex.split(daemon.startup_line)) + self._daemons.append(daemon) + def terminate(self): """Stops this node and sets back all sysctls to their old values""" self._processes.terminate() if not DEBUG_FLAG: self.nconfig.cleanup() + for d in self._daemons: + d.cleanup() for opt, val in self._old_sysctl.items(): self._set_sysctl(opt, val) # Stop the captures on this node From ce758bd2f093c9ac6118357cd6300693f62bb69b Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 15 Oct 2025 15:07:04 +0300 Subject: [PATCH 07/69] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B4=D0=B5=D0=BC=D0=BE=D0=BD=20dnsmasq?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ipmininet/host/config/dnsmasq.py | 77 ++++++++++++++++++++ ipmininet/host/config/templates/dnsmasq.mako | 25 +++++++ 2 files changed, 102 insertions(+) create mode 100644 ipmininet/host/config/dnsmasq.py create mode 100644 ipmininet/host/config/templates/dnsmasq.mako diff --git a/ipmininet/host/config/dnsmasq.py b/ipmininet/host/config/dnsmasq.py new file mode 100644 index 00000000..5733e608 --- /dev/null +++ b/ipmininet/host/config/dnsmasq.py @@ -0,0 +1,77 @@ +from .base import HostDaemon +from ipmininet.router.config.utils import ConfigDict +from mininet.log import info +import re +import tempfile + + +class Dnsmasq(HostDaemon): + NAME = "dnsmasq" + KILL_PATTERNS = ("dnsmasq",) + + def __init__(self, node, ip_range, mask, gw, **kwargs): + self.node = node + self.pid_file = tempfile.mktemp(dir='/tmp') + self.popen = None + self.ip_range = ip_range + self.mask = mask + self.gw = gw + self.opts = { + "dhcp-range": f"{ip_range},{mask}", + "dhcp-option": f"3,{gw}", + "pid-file": self.pid_file, + "port": 0, + "log-queries": None, + "log-dhcp": None, + "bind-interfaces": None, + } + super().__init__(node, **kwargs) + + def build(self) -> ConfigDict: + cfg = super().build() + cfg.pid_file = self.pid_file + cfg.ip_range = self.ip_range + cfg.mask = self.mask + cfg.gw = self.gw + cfg.interfaces = self.node.intfNames() + self.opts["interface"] = self.node.intfNames() + cfg.opts = self.opts + return cfg + + @property + def startup_line(self): + return f"{self.NAME} --conf-file={self.cfg_filenames[0]}" + + @property + def dry_run(self): + return "" + + @property + def cfg_filenames(self): + return [self._file(suffix=f"cfg")] + + @property + def template_filenames(self): + return super().template_filenames + ["%s.mako" % self.NAME] + + def set_defaults(self, defaults): + pass + + @property + def pids(self): + output = self.node.cmd("ss -tulnp | grep dnsmasq") + + if not output: + return None + match = re.findall(r'pid=(\d+)', output) + if match: + return match + + return None + + def kill(self): + pid_str = self.node.cmd(f"cat {self.pid_file}").strip() + self.node.cmd(f"kill -9 {pid_str}") + + def cleanup(self): + self.kill() diff --git a/ipmininet/host/config/templates/dnsmasq.mako b/ipmininet/host/config/templates/dnsmasq.mako new file mode 100644 index 00000000..4a97c90d --- /dev/null +++ b/ipmininet/host/config/templates/dnsmasq.mako @@ -0,0 +1,25 @@ +# Диапазон выдаваемых IP +dhcp-range=${node.dnsmasq.ip_range},${node.dnsmasq.mask} + +# Опция: шлюз +dhcp-option=3,${node.dnsmasq.gw} + +# PID-файл +pid-file=${node.dnsmasq.pid_file} + +# Отключить DNS (порт 53) +port=0 + +# Указываем интерфейсы, на которых будет слушать +% for intf in node.dnsmasq.interfaces: +interface=${intf} +% endfor + +# Логирование DHCP-запросов +log-queries +log-dhcp + +# Слушать ТОЛЬКО на указанных интерфейсах +bind-interfaces + +no-daemon From 9c53c961d441d1e67e9d52fc01264a6a5fa38778 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 8 Nov 2025 14:56:36 +0300 Subject: [PATCH 08/69] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D0=B8=D0=B9=20setup.?= =?UTF-8?q?py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + pyproject.toml | 37 +++++++++++++++++ setup.cfg | 2 - setup.py | 111 ------------------------------------------------- 4 files changed, 38 insertions(+), 113 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 191e6a9c..35e043aa 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ tags *.ropeproject _build/ docs/api/*.rst +.idea/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..4fc1e94c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = [ "setuptools>=61.2", "pytest-runner",] +build-backend = "setuptools.build_meta" + +[project] +name = "ipmininet" +version = "1.1" +description = "A mininet extension providing components to emulate IPnetworks running multiple protocols." +keywords = [ "networking", "OSPF", "IP", "BGP", "quagga", "mininet",] +classifiers = [ "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", "Programming Language :: Python", "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: System :: Networking", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9",] +dependencies = [ "setuptools", "mako>=1.1,<1.2", "mininet @ git+https://github.com/mininet/mininet@2.3.0",] + +[[project.authors]] +name = "Olivier Tilmans" +email = "olivier.tilmans@uclouvain.be" + +[[project.authors]] +name = "Mathieu Jadin" +email = "mathieu.jadin@uclouvain.be" + +[project.license] +text = "GPLv2" + +[project.urls] +Homepage = "https://github.com/cnp3/ipmininet" + +[project.optional-dependencies] +testing = [ "pytest",] + +[tool.aliases] +test = "pytest" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +namespaces = false diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b7e47898..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[aliases] -test=pytest diff --git a/setup.py b/setup.py deleted file mode 100644 index f2aa3065..00000000 --- a/setup.py +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env python - -"Setuptools params" - -import os -import shutil -import sys - -from pkg_resources import parse_version, require -from setuptools import setup, find_packages -from setuptools.command.develop import develop -from setuptools.command.install import install - -VERSION = '1.1' - -modname = distname = 'ipmininet' - -MININET_VERSION = "2.3.0" -install_requires = [ - 'setuptools', - 'mako>=1.1,<1.2' -] -dependency_links = [] - -# Version after which we use another method to install -# dependencies from github -SPIN_PIP_VER = parse_version("18.1") - - -# Get back Pip version -try: - version = parse_version(require("pip")[0].version) -except IndexError: - version = parse_version("0") - print("We cannot find the version of pip." - "We assume that is it inferior to %s." % SPIN_PIP_VER) - -if version >= SPIN_PIP_VER: - install_requires.append('mininet @ git+https://github.com/mininet/mininet@{ver}' - .format(ver=MININET_VERSION)) -else: - print("You should run pip with --process-dependency-links to install all the dependencies") - install_requires.append('mininet=={ver}'.format(ver=MININET_VERSION)) - dependency_links.append('git+https://github.com/mininet/mininet@{ver}#egg=mininet-{ver}' - .format(ver=MININET_VERSION)) - - -def setup_mininet_dep(): - """ - Install the Mininet dependencies - """ - mn_dir = "mininet_dependencies" - if not os.path.exists(os.path.join("/opt", mn_dir)): - tmp_dir = os.path.dirname(__file__) - sys.path.insert(0, os.path.join(tmp_dir, "ipmininet/install")) - from install import install_mininet - - mininet_dir = os.path.join(tmp_dir, "mininet_dependencies") - os.mkdir(mininet_dir) - - install_mininet(mininet_dir, pip_install=False) - shutil.move(mininet_dir, "/opt") - - -class PostDevelopCommand(develop): - """Post-installation for development mode.""" - def run(self): - setup_mininet_dep() - develop.run(self) - - -class PostInstallCommand(install): - """Post-installation for installation mode.""" - def run(self): - setup_mininet_dep() - install.run(self) - - -setup( - name=distname, - version=VERSION, - description='A mininet extension providing components to emulate IP' - 'networks running multiple protocols.', - author='Olivier Tilmans, Mathieu Jadin', - author_email='olivier.tilmans@uclouvain.be, mathieu.jadin@uclouvain.be', - packages=find_packages(), - include_package_data=True, - classifiers=[ - "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", - "Programming Language :: Python", - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Topic :: System :: Networking", - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9' - ], - keywords='networking OSPF IP BGP quagga mininet', - license='GPLv2', - install_requires=install_requires, - dependency_links=dependency_links, - tests_require=['pytest'], - setup_requires=['pytest-runner'], - url='https://github.com/cnp3/ipmininet', - cmdclass={ - 'develop': PostDevelopCommand, - 'install': PostInstallCommand, - }, -) From e370829f0d073f7a93327b9395542f0e5a3fd796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D1=83=D1=80=D0=B5=D0=BD=D0=BA=D0=BE=D0=B2=20=D0=98?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD?= Date: Fri, 21 Nov 2025 13:18:21 +0300 Subject: [PATCH 09/69] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=BE=D0=B2=20=D0=B2=20Dns?= =?UTF-8?q?masq?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ipmininet/host/config/dnsmasq.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ipmininet/host/config/dnsmasq.py b/ipmininet/host/config/dnsmasq.py index 5733e608..2bde9c64 100644 --- a/ipmininet/host/config/dnsmasq.py +++ b/ipmininet/host/config/dnsmasq.py @@ -9,20 +9,21 @@ class Dnsmasq(HostDaemon): NAME = "dnsmasq" KILL_PATTERNS = ("dnsmasq",) - def __init__(self, node, ip_range, mask, gw, **kwargs): + def __init__(self, node, ip_range, mask, gw, intfs, **kwargs): self.node = node self.pid_file = tempfile.mktemp(dir='/tmp') self.popen = None self.ip_range = ip_range self.mask = mask self.gw = gw + self.intfs = intfs self.opts = { - "dhcp-range": f"{ip_range},{mask}", - "dhcp-option": f"3,{gw}", + "dhcp-range": f"{ip_range},{mask}", + "dhcp-option": f"3,{gw}", "pid-file": self.pid_file, "port": 0, - "log-queries": None, - "log-dhcp": None, + "log-queries": None, + "log-dhcp": None, "bind-interfaces": None, } super().__init__(node, **kwargs) @@ -33,8 +34,8 @@ def build(self) -> ConfigDict: cfg.ip_range = self.ip_range cfg.mask = self.mask cfg.gw = self.gw - cfg.interfaces = self.node.intfNames() - self.opts["interface"] = self.node.intfNames() + cfg.interfaces = self.intfs + self.opts["interface"] = self.intfs cfg.opts = self.opts return cfg @@ -48,7 +49,7 @@ def dry_run(self): @property def cfg_filenames(self): - return [self._file(suffix=f"cfg")] + return [self._file(suffix=f"%s.cfg" % '_'.join(self.intfs))] @property def template_filenames(self): From 238f651f50f0f675d81591db247a9976be6abbbc Mon Sep 17 00:00:00 2001 From: Victoria Ostrovskaya Date: Wed, 19 Nov 2025 18:26:55 +0300 Subject: [PATCH 10/69] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=20mininet=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ipmininet/ipnet.py | 17 ++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/ipmininet/ipnet.py b/ipmininet/ipnet.py index 4298d2b7..5264453d 100644 --- a/ipmininet/ipnet.py +++ b/ipmininet/ipnet.py @@ -121,6 +121,7 @@ def addLink(self, node1: Node, node2: Node, igp_area: Optional[str] = None, igp_passive=False, v4_width=1, v6_width=1, + duplicate: Optional[Union[str, float]] = None, *args, **params) -> IPLink: """Register a link with additional properties @@ -142,12 +143,26 @@ def addLink(self, node1: Node, node2: Node, igp_metric = self.igp_metric if not igp_area: igp_area = self.igp_area + + duplicate_val = None + if isinstance(duplicate, str): + duplicate_str = duplicate.strip() + if duplicate_str.endswith('%'): + duplicate_val = duplicate_str + else: + try: + duplicate_val = float(duplicate_str) + except ValueError: + duplicate_val = None + else: + duplicate_val = duplicate # Register all link properties props = {'igp_metric': igp_metric, 'igp_area': igp_area, 'igp_passive': igp_passive, 'v4_width': v4_width, - 'v6_width': v6_width} + 'v6_width': v6_width, + 'duplicate': duplicate_val} # Update interface properties with link properties for pstr in ('params1', 'params2'): try: diff --git a/pyproject.toml b/pyproject.toml index 4fc1e94c..ae206913 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "1.1" description = "A mininet extension providing components to emulate IPnetworks running multiple protocols." keywords = [ "networking", "OSPF", "IP", "BGP", "quagga", "mininet",] classifiers = [ "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", "Programming Language :: Python", "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: System :: Networking", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9",] -dependencies = [ "setuptools", "mako>=1.1,<1.2", "mininet @ git+https://github.com/mininet/mininet@2.3.0",] +dependencies = [ "setuptools", "mako>=1.1,<1.2", "mininet@git+https://github.com/vicitori/mininet.git@add_duplicate",] [[project.authors]] name = "Olivier Tilmans" From b726055889f72f98e221b529c3319760d1ee2ae9 Mon Sep 17 00:00:00 2001 From: Victoria Ostrovskaya Date: Wed, 3 Dec 2025 14:11:26 +0300 Subject: [PATCH 11/69] Update mininet link --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ae206913..ff9dcd01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "1.1" description = "A mininet extension providing components to emulate IPnetworks running multiple protocols." keywords = [ "networking", "OSPF", "IP", "BGP", "quagga", "mininet",] classifiers = [ "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", "Programming Language :: Python", "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: System :: Networking", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9",] -dependencies = [ "setuptools", "mako>=1.1,<1.2", "mininet@git+https://github.com/vicitori/mininet.git@add_duplicate",] +dependencies = [ "setuptools", "mako>=1.1,<1.2", "mininet@git+https://github.com/vicitori/mininet.git@vicitori/add_duplicate",] [[project.authors]] name = "Olivier Tilmans" From 41e35ce829f3ab41569a05a77fa03c056f5ac516 Mon Sep 17 00:00:00 2001 From: Victoria Ostrovskaya Date: Wed, 10 Dec 2025 14:40:55 +0300 Subject: [PATCH 12/69] Update mininet source --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ff9dcd01..b16d9eea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "1.1" description = "A mininet extension providing components to emulate IPnetworks running multiple protocols." keywords = [ "networking", "OSPF", "IP", "BGP", "quagga", "mininet",] classifiers = [ "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", "Programming Language :: Python", "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: System :: Networking", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9",] -dependencies = [ "setuptools", "mako>=1.1,<1.2", "mininet@git+https://github.com/vicitori/mininet.git@vicitori/add_duplicate",] +dependencies = [ "setuptools", "mako>=1.1,<1.2", "mininet @ git+https://github.com/mimi-net/mininet.git",] [[project.authors]] name = "Olivier Tilmans" From db603e6d2f1f0294cfaa1f11fb34849a32209fe9 Mon Sep 17 00:00:00 2001 From: igor Date: Mon, 13 Apr 2026 00:52:02 +0300 Subject: [PATCH 13/69] First version of dhcp-helper --- ipmininet/router/config/dhcphelper.py | 36 +++++++++++++++++++ .../router/config/templates/dhcp-helper.mako | 0 2 files changed, 36 insertions(+) create mode 100644 ipmininet/router/config/dhcphelper.py create mode 100644 ipmininet/router/config/templates/dhcp-helper.mako diff --git a/ipmininet/router/config/dhcphelper.py b/ipmininet/router/config/dhcphelper.py new file mode 100644 index 00000000..69aefa84 --- /dev/null +++ b/ipmininet/router/config/dhcphelper.py @@ -0,0 +1,36 @@ +from .base import RouterDaemon +import tempfile + +class DHCPHelper(RouterDaemon): + NAME = "dhcp-helper" + KILL_PATTERNS = ("dhcp-helper",) + + def __init__(self, node, dhcp_server_ip, dhcp_server_mask, intf, **kwargs): + self.node = node + self.dhcp_server_ip = dhcp_server_ip + self.dhcp_server_mask = dhcp_server_mask + self.intf = intf + self.pid_file = tempfile.mktemp(dir='/tmp') + super().__init__(node, **kwargs) + + @property + def startup_line(self): + return f"{self.NAME} -s {self.dhcp_server_ip} -i {self.intf} -r {self.pid_file}" + + @property + def dry_run(self): + return "" + + @property + def cfg_filenames(self): + return [self._file(suffix=f"%s.cfg" % self.intf)] + + def set_defaults(self, defaults): + pass + + def kill(self): + pid_str = self.node.cmd(f"cat {self.pid_file}").strip() + self.node.cmd(f"kill -9 {pid_str}") + + def cleanup(self): + self.kill() diff --git a/ipmininet/router/config/templates/dhcp-helper.mako b/ipmininet/router/config/templates/dhcp-helper.mako new file mode 100644 index 00000000..e69de29b From 5dff29930500713f2a2b68de1f81d354a8c2cf24 Mon Sep 17 00:00:00 2001 From: igor Date: Wed, 15 Apr 2026 14:04:54 +0300 Subject: [PATCH 14/69] Garbage commit to check if dnsmasq works as dhcp relay --- .../config/{dhcphelper.py => dhcprelay.py} | 19 +++++++++++++++---- .../{dhcp-helper.mako => dhcprelay.mako} | 0 2 files changed, 15 insertions(+), 4 deletions(-) rename ipmininet/router/config/{dhcphelper.py => dhcprelay.py} (62%) rename ipmininet/router/config/templates/{dhcp-helper.mako => dhcprelay.mako} (100%) diff --git a/ipmininet/router/config/dhcphelper.py b/ipmininet/router/config/dhcprelay.py similarity index 62% rename from ipmininet/router/config/dhcphelper.py rename to ipmininet/router/config/dhcprelay.py index 69aefa84..58b9da4f 100644 --- a/ipmininet/router/config/dhcphelper.py +++ b/ipmininet/router/config/dhcprelay.py @@ -1,9 +1,10 @@ from .base import RouterDaemon +from ipmininet.router.config.utils import ConfigDict import tempfile -class DHCPHelper(RouterDaemon): - NAME = "dhcp-helper" - KILL_PATTERNS = ("dhcp-helper",) +class DHCPRelay(RouterDaemon): + NAME = "dhcprelay" + KILL_PATTERNS = ("dnsmasq",) def __init__(self, node, dhcp_server_ip, dhcp_server_mask, intf, **kwargs): self.node = node @@ -13,9 +14,19 @@ def __init__(self, node, dhcp_server_ip, dhcp_server_mask, intf, **kwargs): self.pid_file = tempfile.mktemp(dir='/tmp') super().__init__(node, **kwargs) + ''' + def build(self) -> ConfigDict: + cfg = super().build() + cfg.pid_file = self.pid_file + cfg.dhcp_server_ip = self.dhcp_server_ip + cfg.dhcp_server_mask = self.dhcp_server_mask + cfg.intf = self.intf + return cfg + ''' + @property def startup_line(self): - return f"{self.NAME} -s {self.dhcp_server_ip} -i {self.intf} -r {self.pid_file}" + return f"dnsmasq --dhcp-relay=172.16.10.3,192.168.10.2" @property def dry_run(self): diff --git a/ipmininet/router/config/templates/dhcp-helper.mako b/ipmininet/router/config/templates/dhcprelay.mako similarity index 100% rename from ipmininet/router/config/templates/dhcp-helper.mako rename to ipmininet/router/config/templates/dhcprelay.mako From db07d141e49e4d8fe533e4d169b973dec6657478 Mon Sep 17 00:00:00 2001 From: igor Date: Wed, 15 Apr 2026 15:55:11 +0300 Subject: [PATCH 15/69] Dnsmasq saga is still going --- ipmininet/router/config/dhcprelay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ipmininet/router/config/dhcprelay.py b/ipmininet/router/config/dhcprelay.py index 58b9da4f..9ee9ff61 100644 --- a/ipmininet/router/config/dhcprelay.py +++ b/ipmininet/router/config/dhcprelay.py @@ -26,7 +26,7 @@ def build(self) -> ConfigDict: @property def startup_line(self): - return f"dnsmasq --dhcp-relay=172.16.10.3,192.168.10.2" + return f"dnsmasq --dhcp-relay=172.16.10.3,192.168.10.2,{self.intf}" @property def dry_run(self): From 4cff7aa0c6ca99f802d31434a0fc310e34a28d57 Mon Sep 17 00:00:00 2001 From: igor Date: Wed, 15 Apr 2026 20:02:58 +0300 Subject: [PATCH 16/69] Now everything should be okay --- ipmininet/router/config/dhcprelay.py | 14 +++++--------- ipmininet/router/config/templates/dhcprelay.mako | 3 +++ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/ipmininet/router/config/dhcprelay.py b/ipmininet/router/config/dhcprelay.py index 9ee9ff61..348024a8 100644 --- a/ipmininet/router/config/dhcprelay.py +++ b/ipmininet/router/config/dhcprelay.py @@ -6,27 +6,23 @@ class DHCPRelay(RouterDaemon): NAME = "dhcprelay" KILL_PATTERNS = ("dnsmasq",) - def __init__(self, node, dhcp_server_ip, dhcp_server_mask, intf, **kwargs): + def __init__(self, node, dhcp_server_ip, listening_ip, **kwargs): self.node = node self.dhcp_server_ip = dhcp_server_ip - self.dhcp_server_mask = dhcp_server_mask - self.intf = intf + self.listening_ip = listening_ip self.pid_file = tempfile.mktemp(dir='/tmp') super().__init__(node, **kwargs) - ''' def build(self) -> ConfigDict: cfg = super().build() cfg.pid_file = self.pid_file cfg.dhcp_server_ip = self.dhcp_server_ip - cfg.dhcp_server_mask = self.dhcp_server_mask - cfg.intf = self.intf + cfg.listening_ip = self.listening_ip return cfg - ''' @property def startup_line(self): - return f"dnsmasq --dhcp-relay=172.16.10.3,192.168.10.2,{self.intf}" + return f"dnsmasq --conf-file={self.cfg_filenames[0]}" @property def dry_run(self): @@ -34,7 +30,7 @@ def dry_run(self): @property def cfg_filenames(self): - return [self._file(suffix=f"%s.cfg" % self.intf)] + return [self._file(suffix=f"%s.cfg" % self.listening_ip)] def set_defaults(self, defaults): pass diff --git a/ipmininet/router/config/templates/dhcprelay.mako b/ipmininet/router/config/templates/dhcprelay.mako index e69de29b..b86c4f36 100644 --- a/ipmininet/router/config/templates/dhcprelay.mako +++ b/ipmininet/router/config/templates/dhcprelay.mako @@ -0,0 +1,3 @@ +dhcp-relay=${node.dhcprelay.listening_ip},${node.dhcprelay.dhcp_server_ip} + +pid-file=${node.dhcprelay.pid_file} From 3ac310fcef71c3c2799de0e414efe10e712edf53 Mon Sep 17 00:00:00 2001 From: iakov Date: Fri, 28 Aug 2026 20:51:37 +0300 Subject: [PATCH 17/69] Fix tests and add rootless testing (#11) * build: migrate to uv package manager uv replaces pip/setuptools for dependency management and virtualenv handling. .python-version pins the Python version and uv.lock is the lockfile. The build backend stays setuptools; no other tooling changes are introduced here. * test: add rootless local test runner and pure unit tests - scripts/run-tests-local.sh: run the suite as a non-root user; root-only tests are skipped via the existing require_root marker - tests/test_pure.py: new rootless tests for _parse_addresses, is_subnet_of, is_container, get_set, ConfigDict and ip_statement - test_misc: mark test_ip_address_format as requiring root (it calls 'ip link set dev lo up') * fix(install): make -a work with uv and inside containers - install_mininet: add a minimal path used by `-a` that only builds and installs the mnexec binary; the full install.sh -a installs packages that no longer exist on Ubuntu 24.04 and the Python package is provided by uv. OVS now comes from the openvswitch-switch apt package. - create /run/sshd for openssh-server - skip the IPv6 grub configuration when /etc/default/grub is absent, as it is inside a container - parse pip versions with packaging.version instead of pkg_resources * fix(tests): fix pre-existing failures and skip daemon-dependent tests only when missing - test_link: 2002::/16 (6to4) is now classified private by ipaddress in Python 3.12, so the global 2001::/16 sorts first - test_exabgp: fix a wrong self-comparison (asn_received == asn_received) - Replace the unconditional @pytest.mark.skip on the openr, exabgp and network_capture tests with conditional markers (require_openr, require_exabgp, require_mimidump) in tests/__init__.py. The tests now run wherever the daemon is available and skip with a reason otherwise; ExaBGP additionally requires the daemon to actually start (4.2.11 ships a broken vendored six). - Document the skip policy in tests/__init__.py and point to it from docs/contribute.rst. * ci: add containerized, rootless and heavy test workflows Three workflows replace the single test entry point: - test.yaml: bare-metal CI with a rootless job (runs the pure/rootless tests as the non-root runner user via the require_root marker) and a root job running the full suite after 'ipmininet.install -a'. - container-test.yaml: builds the self-contained Containerfile (uv venv in /opt/venv + full install) and runs a test subset inside it. - heavy-test.yaml: full suite on master and on demand. The full-suite jobs collect every test file; daemon-dependent tests skip themselves via the conditional markers in tests/__init__.py, so their reasons show up in pytest -rs output instead of being dropped with --ignore. Lint and format checks are intentionally not included; they will be added with the follow-up reformatting PR. * chore: add dependabot configuration Monthly grouped updates for uv (Python deps via uv.lock), GitHub Actions and Docker (Containerfile) ecosystems. * fix: remove -t / from named startup line for userns compatibility The '-t /' chroot makes named's working directory '/' which a user-namespace root cannot write, causing 'loading configuration: permission denied' inside rootless podman containers. The chroot to / is a no-op in rootful environments; removing it fixes named in podman while preserving rootful behavior. * fix: run node daemons in the node working directory The DNS tests (test_dns.py) were flaky in CI: 6 tests failed with "connection refused" on port 53 because named exited at startup. Root cause, reproduced locally in Docker and podman: - Daemons are launched via node.popen() (mininet), which runs the command in the node network namespace but keeps the *pytest process* cwd. On CI that cwd is the runner checkout / mounted workspace, owned by a non-root user (runner / ubuntu). - named is started with `-u root`, which makes BIND drop CAP_DAC_OVERRIDE. A uid-0 process without CAP_DAC_OVERRIDE can only write directories it owns or that are world-writable; it cannot write session.key / _default.tsigkeys into a 755 non-root-owned cwd, so named dies with "loading configuration: permission denied". - This never reproduced locally under podman because rootless podman maps the container root onto the host user, making the workspace root-owned. - The earlier `-t /` removal (commit 0fb8522) was correct: named chdirs to "/" after chroot, and a user-namespace root cannot write "/", so `-t /` breaks rootless podman. But in rootful CI the chroot made the effective working directory "/" (root-owned), which masked the bug above; without it the real failure surfaced. Changes ------- - ipmininet/router/__router.py: spawn node daemons with cwd=self.cwd (the node working directory, world-writable /tmp by default). named now has a writable cwd even after dropping CAP_DAC_OVERRIDE, in all environments. - .github/workflows/container-test.yaml: add ipmininet/tests/test_dns.py to the containerized job so DNS tests are covered there too (~5.5 min added; the job stays far below the 60 min timeout). A previous attempt installed apparmor-utils and changed the named comment, based on a wrong AppArmor hypothesis (that a confined named was the cause). It changed nothing in CI results and is reverted here; the daemon cwd was the real issue. Noted so the decision can be re-validated later. * fix(ci): create /run/sshd before running the container test suite sshd -t (the SSHd daemon dry_run check) requires the privilege separation directory /run/sshd. It is created by install -a at image build time but is lost between the image build and the container start, so test_sshd failed in the container with SystemExit 1. Create it in the test entrypoint; bare-metal CI was unaffected because install -a runs on the live runner. --- .github/dependabot.yml | 31 ++++ .github/workflows/container-test.yaml | 35 +++++ .github/workflows/heavy-test.yaml | 39 +++++ .github/workflows/test.yaml | 63 +++++++++ .gitignore | 3 + .python-version | 1 + Containerfile | 38 +++++ docs/contribute.rst | 5 + ipmininet/host/config/named.py | 2 +- ipmininet/install/__main__.py | 5 +- ipmininet/install/install.py | 18 +++ ipmininet/install/utils.py | 2 +- ipmininet/router/__router.py | 4 +- ipmininet/tests/__init__.py | 48 ++++++- ipmininet/tests/test_exabgp.py | 5 +- ipmininet/tests/test_link.py | 4 +- ipmininet/tests/test_misc.py | 1 + ipmininet/tests/test_network_capture.py | 3 +- ipmininet/tests/test_openr.py | 5 +- ipmininet/tests/test_pure.py | 118 ++++++++++++++++ pyproject.toml | 3 +- scripts/ci-install.sh | 9 ++ scripts/ci-test.sh | 28 ++++ scripts/run-tests-local.sh | 18 +++ uv.lock | 180 ++++++++++++++++++++++++ 25 files changed, 654 insertions(+), 14 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/container-test.yaml create mode 100644 .github/workflows/heavy-test.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .python-version create mode 100644 Containerfile create mode 100644 ipmininet/tests/test_pure.py create mode 100755 scripts/ci-install.sh create mode 100755 scripts/ci-test.sh create mode 100755 scripts/run-tests-local.sh create mode 100644 uv.lock diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..b03ab6c9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 + groups: + python-dependencies: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 + groups: + actions-dependencies: + patterns: + - "*" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 3 + groups: + docker-dependencies: + patterns: + - "*" diff --git a/.github/workflows/container-test.yaml b/.github/workflows/container-test.yaml new file mode 100644 index 00000000..2c4ad130 --- /dev/null +++ b/.github/workflows/container-test.yaml @@ -0,0 +1,35 @@ +# Containerized CI. +# +# Builds a self-contained image with the full install (-a: FRRouting, libyang, +# mininet, radvd, sshd, named) plus the uv venv, then runs a subset of tests +# inside it with root privileges. +name: Container test +on: [push, pull_request] +jobs: + container-test: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and cache test container + uses: docker/build-push-action@v6 + with: + file: Containerfile + target: final + push: false + load: true + tags: ipmininet-dev + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Run test subset with coverage + run: | + docker run --rm --privileged \ + -v $PWD:/workspace:Z \ + -e UV_PROJECT_ENVIRONMENT=/opt/venv \ + -e DEBIAN_FRONTEND=noninteractive \ + ipmininet-dev \ + scripts/ci-test.sh \ + ipmininet/tests/test_misc.py ipmininet/tests/test_pure.py \ + ipmininet/tests/test_dns.py diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml new file mode 100644 index 00000000..2fd45175 --- /dev/null +++ b/.github/workflows/heavy-test.yaml @@ -0,0 +1,39 @@ +# Full test suite, run on master and on demand. +# +# Performs the full `ipmininet.install -a` and runs every test except the +# known-broken daemons (exabgp, openr), which are skipped in their own test +# modules. Lint/format checks are intentionally not part of this workflow +# yet; they will be added together with the reformatting PR. +name: Heavy test +on: + workflow_dispatch: + push: + branches: [master] +jobs: + heavy-test: + runs-on: ubuntu-latest + timeout-minutes: 200 + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: Cache venv + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ hashFiles('uv.lock') }} + - run: uv sync --all-extras + - name: Install dependencies (FRRouting, libyang, mininet) + timeout-minutes: 10 + run: | + # PEP 668 on Ubuntu 24.04 blocks pip3 system installs; relax it. + export PIP_BREAK_SYSTEM_PACKAGES=1 + sudo env "PATH=$PATH" PIP_BREAK_SYSTEM_PACKAGES=1 uv run python -m ipmininet.install -a + - name: Run full test suite + timeout-minutes: 170 + run: | + sudo env "PATH=$PATH" uv run python -m pytest \ + -v \ + ipmininet/tests/ diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..a3174559 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,63 @@ +# Bare-metal CI. +# +# Two jobs: +# - rootless: installs the venv with uv and runs the test suite as the +# default (non-root) runner user. Tests that require root are skipped via +# the `require_root` marker, so this job validates the rootless tests. +# - test: performs the full `ipmininet.install -a` (FRRouting, libyang, +# mininet, ...) and runs the complete test suite with root privileges. +# +# Lint/format checks are intentionally not part of this workflow yet; they +# will be added together with the reformatting PR. +name: Test IPMininet +on: [push, pull_request] +jobs: + rootless: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: Mininet dependency must be mimi-net fork + run: grep -q 'mimi-net/mininet' pyproject.toml || (echo "mininet dependency must point to mimi-net/mininet.git fork" && exit 1) + - run: uv sync --all-extras + - name: Run rootless tests + run: | + uv run python -m pytest \ + -v \ + ipmininet/tests/test_pure.py \ + ipmininet/tests/test_misc.py \ + ipmininet/tests/test_link.py + + test: + runs-on: ubuntu-latest + timeout-minutes: 200 + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: Cache venv + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ hashFiles('uv.lock') }} + - name: Mininet dependency must be mimi-net fork + run: grep -q 'mimi-net/mininet' pyproject.toml || (echo "mininet dependency must point to mimi-net/mininet.git fork" && exit 1) + - run: uv sync --all-extras + - name: Install dependencies (FRRouting, libyang, mininet) + timeout-minutes: 10 + run: | + # PEP 668 on Ubuntu 24.04 blocks pip3 system installs; relax it. + export PIP_BREAK_SYSTEM_PACKAGES=1 + sudo env "PATH=$PATH" PIP_BREAK_SYSTEM_PACKAGES=1 uv run python -m ipmininet.install -a + - name: Run test suite + timeout-minutes: 170 + run: | + sudo env "PATH=$PATH" uv run python -m pytest \ + -v \ + ipmininet/tests/ diff --git a/.gitignore b/.gitignore index 35e043aa..3118673e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ tags _build/ docs/api/*.rst .idea/ +.venv/ +/.tmp/ +.coverage diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Containerfile b/Containerfile new file mode 100644 index 00000000..54fd9d01 --- /dev/null +++ b/Containerfile @@ -0,0 +1,38 @@ +# Self-contained test container. +# +# Installs the runtime/build dependencies, the uv-managed virtualenv +# (UV_PROJECT_ENVIRONMENT=/opt/venv, which survives the bind-mount in +# container-test.yaml) and then runs the full `ipmininet.install -a` so that +# FRRouting, libyang, mininet, radvd, sshd and named are available. +# +# PEP 668 blocks pip3 system-wide installs on Ubuntu 24.04; the deferred +# install.py refactor will make this explicit in-code, for now we relax it. +FROM ubuntu:24.04 AS final + +ENV DEBIAN_FRONTEND=noninteractive +ENV PIP_BREAK_SYSTEM_PACKAGES=1 +ENV UV_PROJECT_ENVIRONMENT=/opt/venv + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git sudo ca-certificates wget build-essential \ + autoconf automake libtool make gcc groff patch bison flex gawk \ + texinfo libreadline-dev libc-ares-dev libjson-c-dev \ + perl python3-dev libpam0g-dev libsystemd-dev libsnmp-dev pkg-config \ + libcap-dev cmake libpcre3-dev socat psmisc xterm openssh-client iperf3 \ + ethtool help2man net-tools python3-pexpect python3-tk iproute2 \ + cgroup-tools autotools-dev libc6-dev tcpdump python3-scapy \ + libpcap-dev libconfig-dev openvswitch-switch radvd bind9 dnsutils \ + bridge-utils traceroute nmap netcat-openbsd tshark \ + python3-pip grub-common \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /workspace +COPY uv.lock pyproject.toml .python-version /workspace/ +RUN uv sync --all-extras + +COPY . /workspace +RUN mkdir -p /workspace/.tmp && scripts/ci-install.sh + +CMD ["bash"] diff --git a/docs/contribute.rst b/docs/contribute.rst index 13fafc87..c689d0ae 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -190,6 +190,11 @@ You can also run a single test by passing options to pytest: sudo pytest ipmininet/tests/test_sshd.py --fulltrace +Some tests are skipped conditionally based on the environment (e.g. running +as a non-root user, or a missing routing daemon). See the "Test-skip policy" +comment at the top of ``ipmininet/tests/__init__.py`` for the full matrix and +reasons; skipped tests and their reasons are shown with ``pytest -rs``. + Building the documentation -------------------------- diff --git a/ipmininet/host/config/named.py b/ipmininet/host/config/named.py index 27b74f7b..d0e6fd7d 100644 --- a/ipmininet/host/config/named.py +++ b/ipmininet/host/config/named.py @@ -45,7 +45,7 @@ def __init__(self, node, **kwargs): @property def startup_line(self): # This runs the daemon outside of AppArmor's restrictions - return '{apparmor}{name} -c {cfg} -f -u root -t / -p {port}' \ + return '{apparmor}{name} -c {cfg} -f -u root -p {port}' \ .format(apparmor="aa-exec -p unconfined " if self.apparmor else "", name=self.NAME, cfg=self.cfg_filename, diff --git a/ipmininet/install/__main__.py b/ipmininet/install/__main__.py index d0fc0755..7ad35fa8 100644 --- a/ipmininet/install/__main__.py +++ b/ipmininet/install/__main__.py @@ -10,8 +10,8 @@ dist.require_pip() - if args.install_mininet: - install_mininet(args.output_dir) + if args.all or args.install_mininet: + install_mininet(args.output_dir, pip_install=not args.all) if args.all or args.install_frrouting: install_frrouting(args.output_dir) @@ -26,6 +26,7 @@ if args.all or args.install_sshd: dist.install("openssh-server") + os.makedirs("/run/sshd", exist_ok=True) if args.all or args.install_named: if dist.NAME == "Ubuntu" or dist.NAME == "Debian": diff --git a/ipmininet/install/install.py b/ipmininet/install/install.py index f6901116..6536d086 100644 --- a/ipmininet/install/install.py +++ b/ipmininet/install/install.py @@ -59,6 +59,8 @@ def parse_args(): def install_mininet(output_dir: str, pip_install=True): dist.install("git") + if dist.NAME in {"Ubuntu", "Debian"}: + dist.install("openvswitch-switch") if dist.NAME == "Fedora": mininet_opts = "-fnp" @@ -69,6 +71,18 @@ def install_mininet(output_dir: str, pip_install=True): mininet_opts = "-a" sh("git clone https://github.com/mininet/mininet.git", cwd=output_dir) + if not pip_install: + # Minimal install: only build the mnexec binary. The full + # install.sh -a installs packages (e.g. pep8) that no longer exist + # on Ubuntu 24.04. The Python package itself is provided by uv + # (see pyproject.toml) and OVS comes from the openvswitch-switch + # apt package installed above. + sh("git checkout %s" % MininetVersion, + cwd=os.path.join(output_dir, "mininet")) + sh("make mnexec", cwd=os.path.join(output_dir, "mininet")) + sh("cp mnexec /usr/local/bin/", + cwd=os.path.join(output_dir, "mininet")) + return # Save valid version of mininet install script sh("git checkout %s" % MininetInstallCommit, cwd=os.path.join(output_dir, "mininet/util")) @@ -205,6 +219,10 @@ def enable_ipv6(): dist.install("grub-common") grub_cfg = "/etc/default/grub" + if not os.path.exists(grub_cfg): + print("Skipping IPv6 grub configuration: %s not present" + " (e.g. inside a container)" % grub_cfg, file=sys.stderr) + return with open(grub_cfg, "r+") as f: data = f.read() f.seek(0) diff --git a/ipmininet/install/utils.py b/ipmininet/install/utils.py index 799b904b..42702b0e 100644 --- a/ipmininet/install/utils.py +++ b/ipmininet/install/utils.py @@ -52,7 +52,7 @@ def __init__(self): self.pip_args = self.check_pip_version(self.PIP_CMD) def check_pip_version(self, pip: str) -> str: - from pkg_resources import parse_version + from packaging.version import parse as parse_version if find_executable(pip) is None: return "" diff --git a/ipmininet/router/__router.py b/ipmininet/router/__router.py index 3e012eeb..6207287b 100644 --- a/ipmininet/router/__router.py +++ b/ipmininet/router/__router.py @@ -164,7 +164,7 @@ def start(self): # Fire up all daemons for d in self.nconfig.daemons: - self._processes.popen(shlex.split(d.startup_line)) + self._processes.popen(shlex.split(d.startup_line), cwd=self.cwd) # Busy-wait if the daemon needs some time before being started while not d.has_started(self._processes): time.sleep(.001) @@ -177,7 +177,7 @@ def build_daemon(self, daemon: Daemon): daemon.write(cfg) def start_daemon(self, daemon: Daemon): - self._processes.popen(shlex.split(daemon.startup_line)) + self._processes.popen(shlex.split(daemon.startup_line), cwd=self.cwd) self._daemons.append(daemon) def terminate(self): diff --git a/ipmininet/tests/__init__.py b/ipmininet/tests/__init__.py index 78561dc3..dddace05 100644 --- a/ipmininet/tests/__init__.py +++ b/ipmininet/tests/__init__.py @@ -1,5 +1,51 @@ -import pytest +"""Test-skip policy. + +Tests that require root are marked with `require_root`; they are skipped in +the rootless CI job (see .github/workflows/test.yaml) and run everywhere root +is available (the bare-metal `test`/`heavy` jobs run with sudo, the container +job runs as root). + +Tests that depend on a daemon binary that may be absent use the conditional +markers below (require_openr, require_exabgp, require_mimidump). They run +wherever the daemon is available and are skipped with a reason otherwise. The +reasons are surfaced in CI via `pytest -rs`; these files are intentionally not +--ignore'd at the workflow level so the skip reason stays next to the test. +""" import os +import subprocess + +import pytest + +from ipmininet.utils import has_cmd require_root = pytest.mark.skipif( os.getuid() != 0, reason='Running this test requires to be root') + + +def _exabgp_usable() -> bool: + """Return whether a working ExaBGP daemon is available. + + ExaBGP 4.2.11 ships a broken vendored six dependency, so simply being on + PATH is not enough: the daemon must also start (--version) cleanly. + """ + if not has_cmd('exabgp'): + return False + try: + return subprocess.run(['exabgp', '--version'], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10).returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +require_openr = pytest.mark.skipif( + not has_cmd('openr'), + reason='OpenR daemon not available (needs build, not run in CI)') + +require_exabgp = pytest.mark.skipif( + not _exabgp_usable(), + reason='ExaBGP 4.2.11 has broken vendored six dependency') + +require_mimidump = pytest.mark.skipif( + not has_cmd('mimidump'), + reason='interface captures rely on mimidump which is not shipped') diff --git a/ipmininet/tests/test_exabgp.py b/ipmininet/tests/test_exabgp.py index ebb75702..e69f890d 100644 --- a/ipmininet/tests/test_exabgp.py +++ b/ipmininet/tests/test_exabgp.py @@ -13,7 +13,7 @@ from ipmininet.examples.exabgp_prefix_injector import ExaBGPTopoInjectPrefixes from ipmininet.ipnet import IPNet from ipmininet.router.config import BGPRoute, BGPAttribute, ExaList -from ipmininet.tests import require_root +from ipmininet.tests import require_root, require_exabgp exa_routes = { 'ipv4': [ @@ -152,11 +152,12 @@ def check_as_path(as_path_rib: str, as_path_us: ExaList): format(expected=as_rib_us, received=as_path_rib) for idx, asn_received, asn_expected in zip(range(len(as_rib)), as_rib, as_rib_us): - assert asn_received == asn_received, "Bad ASN at index {index}. Expected AS{expected}. Received AS{received}". \ + assert asn_received == asn_expected, "Bad ASN at index {index}. Expected AS{expected}. Received AS{received}". \ format(index=idx, expected=asn_expected, received=asn_received) @require_root +@require_exabgp @pytest.mark.parametrize('topo_test,frr_bgp_node', [ (ExaBGPTopoInjectPrefixes(routes=exa_routes), 'as2'), # default IPs, custom routes, (ExaBGPTopoInjectPrefixes(), 'as2'), # default IPs, random routes diff --git a/ipmininet/tests/test_link.py b/ipmininet/tests/test_link.py index be96d61e..0a17798a 100644 --- a/ipmininet/tests/test_link.py +++ b/ipmininet/tests/test_link.py @@ -26,9 +26,9 @@ (["100.64.0.1/32", "10.0.0.1/8"], ["100.64.0.1/32", "10.0.0.1/8"]), # Public before private (["2001::1/16", "2002::1/16"], - ["2002::1/16", "2001::1/16"]), # Bigger network value first + ["2001::1/16", "2002::1/16"]), # Global before private (2002::/16 is 6to4) (["2002::1/16", "2001::1/16"], - ["2002::1/16", "2001::1/16"]), # Bigger network value first + ["2001::1/16", "2002::1/16"]), # Global before private (2002::/16 is 6to4) (["2001::1/16", "2001::2/16"], ["2001::2/16", "2001::1/16"]), # Bigger IP value first (["2001::2/16", "2001::1/16"], diff --git a/ipmininet/tests/test_misc.py b/ipmininet/tests/test_misc.py index 1524eb45..3f6f2448 100644 --- a/ipmininet/tests/test_misc.py +++ b/ipmininet/tests/test_misc.py @@ -52,6 +52,7 @@ def test_ipaddress_endianness(test_input, expected): assert int(ipaddress.ip_address(test_input)) == expected +@require_root def test_ip_address_format(): """ Check that the output of ip address conforms to what we expect in order diff --git a/ipmininet/tests/test_network_capture.py b/ipmininet/tests/test_network_capture.py index 5b20897f..482312dc 100644 --- a/ipmininet/tests/test_network_capture.py +++ b/ipmininet/tests/test_network_capture.py @@ -3,11 +3,12 @@ from ipmininet.clean import cleanup from ipmininet.ipnet import IPNet -from . import require_root +from . import require_root, require_mimidump from .utils import assert_connectivity from ..examples.network_capture import NetworkCaptureTopo +@require_mimidump @require_root def test_network_capture_example(): capture_files = ["/tmp/capture_r1.pcapng", "/tmp/capture_r2-eth0.pcapng", diff --git a/ipmininet/tests/test_openr.py b/ipmininet/tests/test_openr.py index 599dad6e..27b6aecf 100644 --- a/ipmininet/tests/test_openr.py +++ b/ipmininet/tests/test_openr.py @@ -10,7 +10,7 @@ from ipmininet.router.config import OpenrRouterConfig from ipmininet.tests.utils import assert_connectivity -from . import require_root +from . import require_root, require_openr class SimpleOpenrTopo(IPTopo): @@ -34,6 +34,7 @@ def build(self, *args, **kwargs): @require_root +@require_openr def test_openr_connectivity(): try: net = IPNet(topo=SimpleOpenrTopo()) @@ -45,6 +46,7 @@ def test_openr_connectivity(): @require_root +@require_openr def test_logdir_creation(): try: topo = SimpleOpenrTopo() @@ -64,6 +66,7 @@ def test_logdir_creation(): @require_root +@require_openr def test_tmp_isolation(): try: net = IPNet(topo=SimpleOpenrTopo()) diff --git a/ipmininet/tests/test_pure.py b/ipmininet/tests/test_pure.py new file mode 100644 index 00000000..24deb786 --- /dev/null +++ b/ipmininet/tests/test_pure.py @@ -0,0 +1,118 @@ +"""Rootless unit tests for pure logic. + +These tests never require root privileges and never start a network, so they +can be run locally by any user (see scripts/run-tests-local.sh) and will run +in a rootless CI job. Tests that require root are marked with `require_root` +in the other test modules. +""" +from ipaddress import ip_network +from ipaddress import IPv4Network, IPv6Network + +import pytest + +from ipmininet.link import _parse_addresses +from ipmininet.router.config.utils import ConfigDict, ip_statement +from ipmininet.utils import get_set, is_container, is_subnet_of + + +class TestParseAddresses: + """Tests for the pure `ip address` output parser.""" + + def test_parse_full_output(self): + out = """1: lo: mtu 65536 qdisc noqueue state UNKNOWN group default + link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 + inet 127.0.0.1/8 scope host lo + valid_lft forever preferred_lft forever + inet6 ::1/128 scope host + valid_lft forever preferred_lft forever +""" + mac, v4, v6 = _parse_addresses(out) + assert mac == "00:00:00:00:00:00" + assert [a.with_prefixlen for a in v4] == ["127.0.0.1/8"] + assert [a.with_prefixlen for a in v6] == ["::1/128"] + + def test_parse_empty(self): + mac, v4, v6 = _parse_addresses("") + assert mac is None + assert v4 == [] + assert v6 == [] + + def test_parse_ignores_malformed_lines(self): + out = """2: eth0: mtu 1500 qdisc noqueue + link/ether 00:11:22:33:44:55 brd ff:ff:ff:ff:ff:ff + this is not a valid ip line + inet 192.168.0.2/24 brd 192.168.0.255 scope global eth0 +""" + mac, v4, v6 = _parse_addresses(out) + assert mac == "00:11:22:33:44:55" + assert [a.with_prefixlen for a in v4] == ["192.168.0.2/24"] + assert v6 == [] + + +class TestIsSubnetOf: + def test_v4_subnet(self): + assert is_subnet_of(ip_network("10.0.0.0/24"), ip_network("10.0.0.0/16")) + assert not is_subnet_of(ip_network("10.1.0.0/16"), ip_network("10.0.0.0/16")) + + def test_v4_self(self): + assert is_subnet_of(ip_network("10.0.0.0/16"), ip_network("10.0.0.0/16")) + + def test_v6_subnet(self): + assert is_subnet_of(ip_network("2001:db8::/32"), ip_network("2001::/16")) + assert not is_subnet_of(ip_network("2002::/16"), ip_network("2001::/16")) + + def test_cross_version_raises(self): + with pytest.raises(TypeError): + is_subnet_of(IPv4Network("10.0.0.0/8"), IPv6Network("2001:db8::/32")) + + def test_non_network_raises(self): + with pytest.raises(TypeError): + is_subnet_of("10.0.0.0/8", IPv4Network("10.0.0.0/8")) + + +class TestIsContainer: + def test_sequences(self): + assert is_container([1, 2, 3]) + assert is_container((1, 2, 3)) + + def test_not_container(self): + assert not is_container("a string") + assert not is_container(42) + + +class TestGetSet: + def test_existing_key(self): + d = {"a": 1} + assert get_set(d, "a", list) == 1 + assert d == {"a": 1} + + def test_missing_key(self): + d = {} + assert get_set(d, "b", list) == [] + assert d == {"b": []} + + +class TestConfigDict: + def test_attribute_access(self): + d = ConfigDict(foo="bar") + assert d.foo == "bar" + assert d["foo"] == "bar" + + def test_missing_attribute_is_none(self): + d = ConfigDict() + assert d.missing is None + + def test_attribute_assignment(self): + d = ConfigDict() + d.foo = "bar" + assert d["foo"] == "bar" + + +@pytest.mark.parametrize("ip,expected", [ + ("10.0.0.0/8", "ip"), + (4, "ip"), + ("2001:db8::/32", "ipv6"), + (6, "ipv6"), +]) +def test_ip_statement(ip, expected): + assert ip_statement(ip) == expected diff --git a/pyproject.toml b/pyproject.toml index b16d9eea..55303246 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ [build-system] -requires = [ "setuptools>=61.2", "pytest-runner",] +requires = [ "setuptools>=61.2",] build-backend = "setuptools.build_meta" [project] name = "ipmininet" version = "1.1" +requires-python = ">=3.12" description = "A mininet extension providing components to emulate IPnetworks running multiple protocols." keywords = [ "networking", "OSPF", "IP", "BGP", "quagga", "mininet",] classifiers = [ "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", "Programming Language :: Python", "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: System :: Networking", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9",] diff --git a/scripts/ci-install.sh b/scripts/ci-install.sh new file mode 100755 index 00000000..0a5e215d --- /dev/null +++ b/scripts/ci-install.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +LOGFILE="${LOGFILE:-/workspace/.tmp/install.log}" +exec > >(tee "$LOGFILE") 2>&1 +# PEP 668 on Ubuntu 24.04 blocks pip3 system installs; relax it. +export PIP_BREAK_SYSTEM_PACKAGES="${PIP_BREAK_SYSTEM_PACKAGES:-1}" +exec sudo env "PATH=$PATH" PIP_BREAK_SYSTEM_PACKAGES="$PIP_BREAK_SYSTEM_PACKAGES" \ + ${UV_PROJECT_ENVIRONMENT:+"UV_PROJECT_ENVIRONMENT=$UV_PROJECT_ENVIRONMENT"} \ + uv run python -m ipmininet.install -a diff --git a/scripts/ci-test.sh b/scripts/ci-test.sh new file mode 100755 index 00000000..cdb3806c --- /dev/null +++ b/scripts/ci-test.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail +mkdir -p /workspace/.tmp +LOGFILE="${LOGFILE:-/workspace/.tmp/pytest.log}" +exec > >(tee "$LOGFILE") 2>&1 + +mkdir -p /workspace/htmlcov + +# sshd -t requires the privilege separation directory; it is created at +# install time but lost between the image build and the container start. +mkdir -p /run/sshd + +# Start Open vSwitch if not already running +if [ ! -e /var/run/openvswitch/db.sock ]; then + mkdir -p /var/run/openvswitch /etc/openvswitch + ovsdb-tool create /etc/openvswitch/conf.db \ + /usr/share/openvswitch/vswitch.ovsschema 2>/dev/null || true + ovsdb-server --remote=punix:/var/run/openvswitch/db.sock \ + --pidfile --detach 2>/dev/null || true + ovs-vswitchd --detach --pidfile 2>/dev/null || true + sleep 0.5 +fi + +exec sudo env "PATH=$PATH" ${UV_PROJECT_ENVIRONMENT:+"UV_PROJECT_ENVIRONMENT=$UV_PROJECT_ENVIRONMENT"} uv run python -m pytest \ + -v \ + -p faulthandler \ + --showlocals --capture=tee-sys --tb=long \ + "$@" diff --git a/scripts/run-tests-local.sh b/scripts/run-tests-local.sh new file mode 100755 index 00000000..fe87e075 --- /dev/null +++ b/scripts/run-tests-local.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Run the ipmininet test suite without root privileges. +# +# Tests that require root are automatically skipped thanks to the +# `require_root` marker (see ipmininet/tests/__init__.py), so this script can +# be run by any user to exercise the rootless (pure unit) tests locally. +set -euo pipefail + +if [ "$(id -u)" -eq 0 ]; then + echo "WARNING: you are running as root; root-requiring tests will run." >&2 + echo " Run as a non-root user to run only the rootless tests." >&2 +fi + +cd "$(dirname "$0")/.." +if command -v uv >/dev/null 2>&1; then + exec uv run python -m pytest "$@" +fi +exec python3 -m pytest "$@" diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..6d628b6b --- /dev/null +++ b/uv.lock @@ -0,0 +1,180 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipmininet" +version = "1.1" +source = { editable = "." } +dependencies = [ + { name = "mako" }, + { name = "mininet" }, + { name = "setuptools" }, +] + +[package.optional-dependencies] +testing = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "mako", specifier = ">=1.1,<1.2" }, + { name = "mininet", git = "https://github.com/mimi-net/mininet.git" }, + { name = "pytest", marker = "extra == 'testing'" }, + { name = "setuptools" }, +] +provides-extras = ["testing"] + +[[package]] +name = "mako" +version = "1.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/b6/42cd322ae555aa770d49e31b8c5c28a243ba1bbb57ad927e1a5f5b064811/Mako-1.1.6.tar.gz", hash = "sha256:4e9e345a41924a954251b95b4b28e14a301145b544901332e658907a7464b6b2", size = 495132, upload-time = "2021-11-17T15:40:46.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/4d/e03d08f16ee10e688bde9016bc80af8b78c7f36a8b37c7194da48f72207e/Mako-1.1.6-py2.py3-none-any.whl", hash = "sha256:afaf8e515d075b22fad7d7b8b30e4a1c90624ff2f3733a06ec125f5a5f043a57", size = 75702, upload-time = "2021-11-17T15:40:48.625Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mininet" +version = "2.3.1b4" +source = { git = "https://github.com/mimi-net/mininet.git#a282742add51e7cdb990adbc5eedddda46afd4dc" } +dependencies = [ + { name = "setuptools" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] From b862e98f605817a041c29480ea1531ca62b817b9 Mon Sep 17 00:00:00 2001 From: iakov Date: Sat, 29 Aug 2026 00:29:06 +0300 Subject: [PATCH 18/69] fix: faster and more reliable startup for IPv4-only networks (#12) * fix: skip IPv6 DAD wait for IPv4-only nodes (use_v6=False) * fix: start interface captures only once the interface is up --- ipmininet/link.py | 16 ++++++++++++++++ ipmininet/router/__router.py | 26 +++++++++++++++----------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/ipmininet/link.py b/ipmininet/link.py index cf3e5160..0c11e4ea 100644 --- a/ipmininet/link.py +++ b/ipmininet/link.py @@ -4,6 +4,7 @@ from copy import deepcopy from itertools import chain import subprocess +import time from ipaddress import ip_interface, IPv4Interface, IPv6Interface import functools from typing import Union, Tuple, Optional, Generator, Sequence, List, Type @@ -33,10 +34,25 @@ def __init__(self, *args, **kwargs): self.backup_addresses = {4: [], 6: []} self.restore_cmds = [] + # A live pcap handle activated on a not-yet-up interface can block in + # the capture tool's internal ifup wait and exit without producing the + # outbound capture file. Only start captures once the interface is + # confirmed up. + self._wait_interface_up() + # Start the captures on this interface for capture in self.get("captures", []): capture.start(intf=self) + def _wait_interface_up(self, timeout: float = 5.0, poll: float = 0.1) -> bool: + """Wait until the interface reports UP (best-effort, bounded).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self.isUp(): + return True + time.sleep(poll) + return self.isUp() + @property def igp_area(self) -> str: """Return the igp area associated to this interface""" diff --git a/ipmininet/router/__router.py b/ipmininet/router/__router.py index 6207287b..d6e96652 100644 --- a/ipmininet/router/__router.py +++ b/ipmininet/router/__router.py @@ -149,18 +149,22 @@ def start(self): # wait until NDP has finished to check each IPv6 addresses assigned # to the interface of the node. # The command lists addresses failing duplicate address detection (IPv6) - # If any, it waits until all addresses has been checked - lg.debug(self._processes.node.name, 'Checking for any "tentative" addresses') - tentative_cmd = "ip -6 addr show tentative" - tentative_chk = self._processes.call(tentative_cmd) - while tentative_chk is not None and tentative_chk != '': - if tentative_chk.find("dadfailed") != -1: - lg.error('At least two nodes have the same IPv6 address!\n') - mininet.clean.cleanup() - sys.exit(1) - time.sleep(.5) + # If any, it waits until all addresses has been checked. + # Only IPv6 networks have addresses that go through DAD; for IPv4-only + # nodes (use_v6=False) this poll would wait ~0.5 s per iteration on + # nothing, so skip it entirely. + if self.use_v6: + lg.debug(self._processes.node.name, 'Checking for any "tentative" addresses') + tentative_cmd = "ip -6 addr show tentative" tentative_chk = self._processes.call(tentative_cmd) - lg.debug(self._processes.node.name, 'All IPv6 addresses has passed the Duplicate address detection mechanism') + while tentative_chk is not None and tentative_chk != '': + if tentative_chk.find("dadfailed") != -1: + lg.error('At least two nodes have the same IPv6 address!\n') + mininet.clean.cleanup() + sys.exit(1) + time.sleep(.5) + tentative_chk = self._processes.call(tentative_cmd) + lg.debug(self._processes.node.name, 'All IPv6 addresses has passed the Duplicate address detection mechanism') # Fire up all daemons for d in self.nconfig.daemons: From f1fb617393ff34747c94eb2e6e3d903f3897ddd8 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sat, 29 Aug 2026 10:38:23 +0300 Subject: [PATCH 19/69] perf(tests): speed up the test suite Poll for interface and topology convergence faster and probe hosts in parallel; tune RIPng and STP timers in test topologies; and skip IPv6 address allocation where it buys nothing (GRE and OSPF parameter tests). --- ipmininet/examples/ripng_network.py | 16 ++++-- ipmininet/examples/ripng_network_adjust.py | 16 +++--- ipmininet/ipswitch.py | 18 ++++++- ipmininet/tests/test_gre.py | 2 +- ipmininet/tests/test_ospf.py | 2 +- ipmininet/tests/test_ripng.py | 22 ++++++--- ipmininet/tests/test_switch.py | 10 +++- ipmininet/tests/utils.py | 57 ++++++++++++++-------- 8 files changed, 100 insertions(+), 43 deletions(-) diff --git a/ipmininet/examples/ripng_network.py b/ipmininet/examples/ripng_network.py index c8977354..4bb3a46f 100644 --- a/ipmininet/examples/ripng_network.py +++ b/ipmininet/examples/ripng_network.py @@ -7,6 +7,12 @@ class RIPngNetwork(IPTopo): + def __init__(self, ripng_timers=None, *args, **kwargs): + """:param ripng_timers: optional dict of RIPng daemon options applied + to every router (e.g. {'update_timer': 2} to speed up convergence)""" + self.ripng_timers = ripng_timers or {} + super().__init__(*args, **kwargs) + def build(self, *args, **kwargs): """ +-----+ +-----+ 2 +-----+ +-----+ +-----+ @@ -56,10 +62,10 @@ def build(self, *args, **kwargs): self.addSubnet(nodes=[r4, h4], subnets=["2042:44::/64"]) self.addSubnet(nodes=[r5, h5], subnets=["2042:55::/64"]) - r1.addDaemon(RIPng) - r2.addDaemon(RIPng) - r3.addDaemon(RIPng) - r4.addDaemon(RIPng) - r5.addDaemon(RIPng) + r1.addDaemon(RIPng, **self.ripng_timers) + r2.addDaemon(RIPng, **self.ripng_timers) + r3.addDaemon(RIPng, **self.ripng_timers) + r4.addDaemon(RIPng, **self.ripng_timers) + r5.addDaemon(RIPng, **self.ripng_timers) super().build(*args, **kwargs) diff --git a/ipmininet/examples/ripng_network_adjust.py b/ipmininet/examples/ripng_network_adjust.py index 926363b4..6bbbd9d1 100644 --- a/ipmininet/examples/ripng_network_adjust.py +++ b/ipmininet/examples/ripng_network_adjust.py @@ -9,7 +9,8 @@ class RIPngNetworkAdjust(IPTopo): def __init__(self, lr1r2_cost=1, lr1r3_cost=1, lr1r5_cost=1, lr2r3_cost=1, - lr2r4_cost=1, lr2r5_cost=1, lr4r5_cost=1, *args, **kwargs): + lr2r4_cost=1, lr2r5_cost=1, lr4r5_cost=1, + ripng_timers=None, *args, **kwargs): """ :param lr1r2_cost: Cost of link between R1 and R2 :param lr1r3_cost: Cost of link between R1 and R3 @@ -18,6 +19,8 @@ def __init__(self, lr1r2_cost=1, lr1r3_cost=1, lr1r5_cost=1, lr2r3_cost=1, :param lr2r4_cost: Cost of link between R2 and R4 :param lr2r5_cost: Cost of link between R2 and R5 :param lr4r5_cost: Cost of link between R4 and R5 + :param ripng_timers: optional dict of RIPng daemon options applied + to every router (e.g. {'update_timer': 2}) """ self.lr1r2_cost = int(lr1r2_cost) @@ -27,6 +30,7 @@ def __init__(self, lr1r2_cost=1, lr1r3_cost=1, lr1r5_cost=1, lr2r3_cost=1, self.lr2r4_cost = int(lr2r4_cost) self.lr2r5_cost = int(lr2r5_cost) self.lr4r5_cost = int(lr4r5_cost) + self.ripng_timers = ripng_timers or {} super().__init__(*args, **kwargs) def build(self, *args, **kwargs): @@ -57,11 +61,11 @@ def build(self, *args, **kwargs): r1, r2, r3, r4, r5 = self.addRouters('r1', 'r2', 'r3', 'r4', 'r5', use_v4=False, use_v6=True, config=RouterConfig) - r1.addDaemon(RIPng) - r2.addDaemon(RIPng) - r3.addDaemon(RIPng) - r4.addDaemon(RIPng) - r5.addDaemon(RIPng) + r1.addDaemon(RIPng, **self.ripng_timers) + r2.addDaemon(RIPng, **self.ripng_timers) + r3.addDaemon(RIPng, **self.ripng_timers) + r4.addDaemon(RIPng, **self.ripng_timers) + r5.addDaemon(RIPng, **self.ripng_timers) h1 = self.addHost('h1') h3 = self.addHost('h3') diff --git a/ipmininet/ipswitch.py b/ipmininet/ipswitch.py index ce7f983a..2b969744 100644 --- a/ipmininet/ipswitch.py +++ b/ipmininet/ipswitch.py @@ -11,14 +11,22 @@ class IPSwitch(LinuxBridge): the hubs""" def __init__(self, name: str, stp=True, hub=False, - prio: Optional[int] = None, cwd='/tmp', **kwargs): + prio: Optional[int] = None, cwd='/tmp', + stp_forward_delay: Optional[int] = None, + stp_hello_time: Optional[int] = None, **kwargs): """:param name: the name of the node :param stp: whether to use spanning tree protocol :param hub: whether this switch behaves as a hub (this disable stp) :param prio: optional explicit bridge priority for STP - :param cwd: The base directory for temporary files such as configs""" + :param cwd: The base directory for temporary files such as configs + :param stp_forward_delay: optional STP forward delay in seconds + (kernel default is 15) + :param stp_hello_time: optional STP hello time in seconds + (kernel default is 2)""" self.hub = hub self.cwd = cwd + self.stp_forward_delay = stp_forward_delay + self.stp_hello_time = stp_hello_time stp = stp and not hub LinuxBridge.__init__(self, name, stp=stp, prio=prio, **kwargs) @@ -35,6 +43,12 @@ def start(self, _controllers): if self.stp: self.cmd('brctl setbridgeprio', self, self.prio) self.cmd('brctl stp', self, 'on') + # Accelerate convergence when requested (kernel requires the + # forward delay to be at least twice the hello time) + if self.stp_forward_delay is not None: + self.cmd('brctl setfd', self, self.stp_forward_delay) + if self.stp_hello_time is not None: + self.cmd('brctl sethello', self, self.stp_hello_time) for i in self.intfList(): if self.name in i.name: self.cmd('brctl addif', self, i) diff --git a/ipmininet/tests/test_gre.py b/ipmininet/tests/test_gre.py index 15314cab..a11a7fed 100644 --- a/ipmininet/tests/test_gre.py +++ b/ipmininet/tests/test_gre.py @@ -10,7 +10,7 @@ @require_root def test_gre_example(): try: - net = IPNet(topo=GRETopo()) + net = IPNet(topo=GRETopo(), use_v6=False) net.start() t = 0 diff --git a/ipmininet/tests/test_ospf.py b/ipmininet/tests/test_ospf.py index ec1a2ee4..e632e4f5 100644 --- a/ipmininet/tests/test_ospf.py +++ b/ipmininet/tests/test_ospf.py @@ -139,7 +139,7 @@ def test_ospf_example(): def test_ospf_daemon_params(node_params, ospf_params, link_params, exp_cfg, exp_paths): try: net = IPNet(topo=MinimalOSPFNet(node_params, ospf_params, link_params), - allocate_IPs=False) + allocate_IPs=False, use_v6=False) net.start() # Check generated configuration diff --git a/ipmininet/tests/test_ripng.py b/ipmininet/tests/test_ripng.py index 027795bc..79e17ddc 100644 --- a/ipmininet/tests/test_ripng.py +++ b/ipmininet/tests/test_ripng.py @@ -25,9 +25,10 @@ class MinimalRIPngNet(IPTopo): h3 """ - def __init__(self, is_test_flush=False, *args, **kwargs): + def __init__(self, is_test_flush=False, ripng_timers=None, *args, **kwargs): self.args_test_2 = [100, 1, 1] self.is_test_flush = is_test_flush + self.ripng_timers = ripng_timers or {} super().__init__(*args, **kwargs) def build(self, *args, **kwargs): @@ -61,9 +62,9 @@ def build(self, *args, **kwargs): timeout_timer=self.args_test_2[1], garbage_timer=self.args_test_2[2]) else: - r1.addDaemon(RIPng) - r2.addDaemon(RIPng) - r3.addDaemon(RIPng) + r1.addDaemon(RIPng, **self.ripng_timers) + r2.addDaemon(RIPng, **self.ripng_timers) + r3.addDaemon(RIPng, **self.ripng_timers) super().build(*args, **kwargs) @@ -131,15 +132,21 @@ def addRouter_v6(self, name): } +# Fast RIPng timers keep route propagation in the seconds instead of the +# default 30s update cycle. The flush test below deliberately uses the slow +# timers to exercise route timeout/removal. +RIPNG_FAST_TIMERS = {"update_timer": 2, "timeout_timer": 6, "garbage_timer": 6} + + @require_root @pytest.mark.parametrize("topo", [ MinimalRIPngNet, RIPngNetwork, RIPngNetworkAdjust -]) +], ids=lambda v: v.__name__) def test_ripng_examples(topo): try: - net = IPNet(topo=topo()) + net = IPNet(topo=topo(ripng_timers=RIPNG_FAST_TIMERS)) net.start() assert_connectivity(net, v6=True) for path in expected_paths[topo.__name__]: @@ -153,7 +160,8 @@ def test_ripng_examples(topo): @require_root def test_ripng_adjust(): try: - net = IPNet(topo=RIPngNetworkAdjust(lr1r5_cost=5)) + net = IPNet(topo=RIPngNetworkAdjust(lr1r5_cost=5, + ripng_timers=RIPNG_FAST_TIMERS)) net.start() assert_connectivity(net, v6=True) for path in expected_paths["RIPngNetworkAdjust-mod"]: diff --git a/ipmininet/tests/test_switch.py b/ipmininet/tests/test_switch.py index 94bf7859..5bddb2f6 100644 --- a/ipmininet/tests/test_switch.py +++ b/ipmininet/tests/test_switch.py @@ -1,5 +1,6 @@ """This module tests the switches, hubs and STP""" +import functools import pytest from ipmininet.clean import cleanup @@ -13,9 +14,13 @@ from ipmininet.examples.spanning_tree_cost import SpanningTreeCost from ipmininet.ipnet import IPNet from ipmininet.iptopo import IPTopo +from ipmininet.ipswitch import IPSwitch from ipmininet.tests import require_root from ipmininet.tests.utils import assert_stp_state, assert_connectivity +# Accelerate STP convergence (kernel forward delay is 15s by default). +FastSTPSwitch = functools.partial(IPSwitch, stp_forward_delay=4) + class SimpleSpanningTree(IPTopo): @@ -157,7 +162,7 @@ def build(self, *args, **kwargs): ]) def test_stp(topo): try: - net = IPNet(topo=topo()) + net = IPNet(topo=topo(), switch=FastSTPSwitch) net.start() assert_connectivity(net, v6=False) @@ -175,7 +180,8 @@ def test_stp_adjust(): net = IPNet(topo=SpanningTreeAdjust(l1_start="s2-eth1", l1_end="s1-eth1", l1_cost=2, l2_start="s1-eth3", - l2_end="s3-eth1", l2_cost=3)) + l2_end="s3-eth1", l2_cost=3), + switch=FastSTPSwitch) net.start() assert_connectivity(net, v6=False) diff --git a/ipmininet/tests/utils.py b/ipmininet/tests/utils.py index 69f78a50..e5c5177a 100644 --- a/ipmininet/tests/utils.py +++ b/ipmininet/tests/utils.py @@ -2,6 +2,7 @@ import re import signal import time +from concurrent.futures import ThreadPoolExecutor from typing import List, Tuple, Dict, Pattern, Match, Optional import mininet.log @@ -14,14 +15,15 @@ from ipmininet.host.config.named import DNSRecord -def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300) -> List[str]: +def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300, + poll=1.0) -> List[str]: require_cmd("traceroute", help_str="traceroute is required to run tests") t = 0 old_path_ips = [] # type: List[str] same_path_count = 0 white_space = re.compile(r" +") - while t != timeout / 5.: + while t != timeout / poll: out = net[src].cmd(["traceroute", "-w", "0.05", "-q", "1", "-n", "-m", len(net.routers) + len(net.hosts), dst_ip]) lines = out.split("\n")[1:-1] @@ -40,7 +42,9 @@ def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300) -> List[str]: else: same_path_count = 0 old_path_ips = [] - time.sleep(5) + # Only sleep before the next poll; the iteration above returns as soon + # as the path is stable, so this does not add latency on success. + time.sleep(poll) t += 1 return [] @@ -80,21 +84,29 @@ def assert_path(net: IPNet, expected_path: List[str], v6=False, retry=5, % (src, dst, expected_path[1:-1], path[1:-1]) -def host_connected(net: IPNet, v6=False, timeout=0.5, translate_address=True) \ +def host_connected(net: IPNet, v6=False, timeout=0.2, translate_address=True) \ -> bool: require_cmd("nmap", help_str="nmap is required to run tests") - for src in net.hosts: - for dst in net.hosts: + hosts = list(net.hosts) + if not hosts: + # Nothing to probe (e.g. router-only topologies); the sequential + # loop also returns True immediately in this case. + return True + # Refresh the target addresses once, before probing + for dst in hosts: + dst.defaultIntf().updateIP() + dst.defaultIntf().updateIP6() + + def _check_src(src) -> bool: + for dst in hosts: if src != dst: - dst.defaultIntf().updateIP() - dst.defaultIntf().updateIP6() if translate_address: dst_ip = dst.defaultIntf().ip6 if v6 \ else dst.defaultIntf().ip else: dst_ip = dst - cmd = "nmap%s -sn -n --max-retries 5 --max-rtt-timeout %dms %s"\ + cmd = "nmap%s -sn -n --max-retries 0 --max-rtt-timeout %dms %s"\ % (" -6" if v6 else "", int(timeout * 1000), dst_ip) out = src.cmd(cmd.split(" ")) if "0 hosts up" in out: @@ -102,16 +114,21 @@ def host_connected(net: IPNet, v6=False, timeout=0.5, translate_address=True) \ # In case of flooding, hosts might not answer # So, we wait a bit before testing the next pair of hosts time.sleep(0.1) - return True + return True + # Each source is probed by its own worker (never two threads touch the + # same node), which collapses the sweep to the cost of a single source. + with ThreadPoolExecutor(max_workers=min(len(hosts), 32)) as pool: + return all(pool.map(_check_src, hosts)) -def assert_node_not_connected(src: IPNode, dst: IPNode, v6=False, timeout=0.5): + +def assert_node_not_connected(src: IPNode, dst: IPNode, v6=False, timeout=0.2): require_cmd("nmap", help_str="nmap is required to run tests") dst.defaultIntf().updateIP() dst.defaultIntf().updateIP6() dst_ip = dst.defaultIntf().ip6 if v6 else dst.defaultIntf().ip - cmd = "nmap%s -sn -n --max-retries 5 --max-rtt-timeout %dms %s" \ + cmd = "nmap%s -sn -n --max-retries 0 --max-rtt-timeout %dms %s" \ % (" -6" if v6 else "", int(timeout * 1000), dst_ip) out = src.cmd(cmd.split(" ")) @@ -119,15 +136,17 @@ def assert_node_not_connected(src: IPNode, dst: IPNode, v6=False, timeout=0.5): .format(src.name, dst.name, "IPv4" if not v6 else "IPv6") -def assert_connectivity(net: IPNet, v6=False, attempts=300, +def assert_connectivity(net: IPNet, v6=False, attempts=1500, translate_address=True): t = 0 - while t != attempts \ - and not host_connected(net, v6=v6, - translate_address=translate_address): - t += 1 - time.sleep(5) - assert host_connected(net, v6=v6, translate_address=translate_address),\ + connected = False + while t != attempts and not connected: + connected = host_connected(net, v6=v6, + translate_address=translate_address) + if not connected: + t += 1 + time.sleep(1) + assert connected, \ "Cannot ping all hosts over %s" % ("IPv4" if not v6 else "IPv6") From 74a6bc8b44bf84fccf347137e9de33236d1f4764 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sat, 29 Aug 2026 10:38:23 +0300 Subject: [PATCH 20/69] fix(tests): wait for OSPF reconvergence after restoring links Make route assertions deterministic: assert_routing_table() waits for prefixes to be present or absent within a bounded deadline and dumps the actual table on failure. The link-restoration tests wait for each router to relearn the far host subnet in both address families before probing connectivity. --- ipmininet/tests/test_linkfailure.py | 30 +++++++++++++++++++++- ipmininet/tests/test_ripng.py | 2 +- ipmininet/tests/utils.py | 39 +++++++++++++++++------------ 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/ipmininet/tests/test_linkfailure.py b/ipmininet/tests/test_linkfailure.py index c886462e..8047095d 100644 --- a/ipmininet/tests/test_linkfailure.py +++ b/ipmininet/tests/test_linkfailure.py @@ -2,14 +2,33 @@ import pytest +from ipaddress import ip_network from ipmininet.clean import cleanup from ipmininet.ipnet import IPNet from ipmininet.iptopo import IPTopo from . import require_root -from .utils import assert_connectivity, assert_node_not_connected +from .utils import assert_connectivity, assert_node_not_connected, \ + assert_routing_table from ..examples.link_failure import FailureTopo +def _wait_reconvergence(net: IPNet, timeout=120): + """Wait for OSPF to recompute routes after link restoration. + + The connectivity probe can false-negative while the routing tables are + still empty right after `restoreIntfs`, so wait for each router to learn + a route to the far host (both address families) before probing. + """ + for v6 in (False, True): + for router, far in (("r1", net["h2"]), ("r2", net["h1"])): + itf = far.defaultIntf() + far_ip = itf.ip6 if v6 else itf.ip + plen = itf.prefixLen6 if v6 else itf.prefixLen + prefix = str(ip_network("%s/%s" % (far_ip, plen), strict=False)) + assert_routing_table(net[router], [prefix], present=True, + timeout=timeout) + + class Topo(IPTopo): def build(self, *args, **kwargs): @@ -61,6 +80,9 @@ def test_failurePlan(plan): net.restoreIntfs(interface_down) + # Wait for OSPF reconvergence before probing connectivity + _wait_reconvergence(net) + # Check link restoration assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) @@ -88,6 +110,9 @@ def test_randomFailure(downed_links): net.restoreIntfs(interface_down) + # Wait for OSPF reconvergence before probing connectivity + _wait_reconvergence(net) + # Check link restoration assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) @@ -115,6 +140,9 @@ def test_randomFailureOnTargetedLink(): net.restoreIntfs(itfs) + # Wait for OSPF reconvergence before probing connectivity + _wait_reconvergence(net) + # Check link restoration assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) diff --git a/ipmininet/tests/test_ripng.py b/ipmininet/tests/test_ripng.py index 79e17ddc..4079cfbf 100644 --- a/ipmininet/tests/test_ripng.py +++ b/ipmininet/tests/test_ripng.py @@ -185,7 +185,7 @@ def test_ripng_flush_routing_tables(): "r3": ["2042:11::/64", "2042:22::/64", "2042:12::/64"] } for router, expected_ipv6 in routing_tables.items(): - assert_routing_table(net[router], expected_ipv6) + assert_routing_table(net[router], expected_ipv6, present=False) net.stop() finally: cleanup() diff --git a/ipmininet/tests/utils.py b/ipmininet/tests/utils.py index e5c5177a..7d156400 100644 --- a/ipmininet/tests/utils.py +++ b/ipmininet/tests/utils.py @@ -223,27 +223,34 @@ def assert_stp_state(switch: IPSwitch, expected_states: Dict[str, str], def assert_routing_table(router: IPNode, expected_prefixes: List[str], - timeout=120): + present: bool = True, timeout: int = 120): """ + Wait until all expected prefixes are (present=True) or none are + (present=False) in the router's IPv4/IPv6 routing table. + :param router: The router to test - :param expected_prefixes: The list of prefixes to be in the routing table + :param expected_prefixes: The list of prefixes to look for + :param present: Whether to wait for the prefixes to be present (True) or + absent (False) from the routing table :param timeout: Time to wait for the routing convergence - :return: """ - - cmd = "ip -%d route" % ip_network(str(expected_prefixes[0])).version - out = router.cmd(cmd) - prefixes = re.findall(r"|".join(expected_prefixes), out) - count = 0 - while any(item in prefixes for item in expected_prefixes): - if count == timeout: - pytest.fail("Cannot get all expected prefixes (%s) from routing " - "table (%s)" % (expected_prefixes, prefixes)) + expected = set(expected_prefixes) + cmd = "ip -%d route" % ip_network(str(next(iter(expected)))).version + t = 0 + while True: + found = set(re.findall(r"|".join(re.escape(p) for p in expected), + router.cmd(cmd))) + satisfied = (expected <= found) if present else not (expected & found) + if satisfied: + return + if t >= timeout: + pytest.fail("Expected prefixes %s to be %s in the routing table " + "of %s within %ds (found: %s)" + % (expected_prefixes, + "present" if present else "absent", + router.name, timeout, sorted(found))) time.sleep(1) - count += 1 - out = router.cmd(cmd) - prefixes = re.findall(r"|".join(expected_prefixes), out) - assert len(prefixes) == 0 + t += 1 def search_dns_reply(reply: str, regex: Pattern) \ From b6c007ca4e87ea7d529376893e41622ec317de8c Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sat, 29 Aug 2026 10:38:23 +0300 Subject: [PATCH 21/69] test: run the suite in parallel via isolated xdist workers scripts/run-tests-parallel.sh distributes the suite across pytest-xdist workers, each exec'd through scripts/py-unshare.sh into private mount/net/pid namespaces. The CI test job uses the runner. Results are identical to serial: 185 passed, 8 skipped. --- .github/workflows/container-test.yaml | 5 +++- .github/workflows/heavy-test.yaml | 4 +-- .github/workflows/test.yaml | 9 ++++--- pyproject.toml | 2 +- scripts/py-unshare.sh | 17 ++++++++++++ scripts/run-tests-parallel.sh | 38 +++++++++++++++++++++++++++ uv.lock | 24 +++++++++++++++++ 7 files changed, 90 insertions(+), 9 deletions(-) create mode 100755 scripts/py-unshare.sh create mode 100755 scripts/run-tests-parallel.sh diff --git a/.github/workflows/container-test.yaml b/.github/workflows/container-test.yaml index 2c4ad130..371f073f 100644 --- a/.github/workflows/container-test.yaml +++ b/.github/workflows/container-test.yaml @@ -4,7 +4,10 @@ # mininet, radvd, sshd, named) plus the uv venv, then runs a subset of tests # inside it with root privileges. name: Container test -on: [push, pull_request] +on: + pull_request: + push: + branches: [master] jobs: container-test: runs-on: ubuntu-latest diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index 2fd45175..85a15acc 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -34,6 +34,4 @@ jobs: - name: Run full test suite timeout-minutes: 170 run: | - sudo env "PATH=$PATH" uv run python -m pytest \ - -v \ - ipmininet/tests/ + sudo env "PATH=$PATH" scripts/run-tests-parallel.sh diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a3174559..fd37f6af 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,7 +10,10 @@ # Lint/format checks are intentionally not part of this workflow yet; they # will be added together with the reformatting PR. name: Test IPMininet -on: [push, pull_request] +on: + pull_request: + push: + branches: [master] jobs: rootless: runs-on: ubuntu-latest @@ -58,6 +61,4 @@ jobs: - name: Run test suite timeout-minutes: 170 run: | - sudo env "PATH=$PATH" uv run python -m pytest \ - -v \ - ipmininet/tests/ + sudo env "PATH=$PATH" scripts/run-tests-parallel.sh diff --git a/pyproject.toml b/pyproject.toml index 55303246..90e0993a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ text = "GPLv2" Homepage = "https://github.com/cnp3/ipmininet" [project.optional-dependencies] -testing = [ "pytest",] +testing = [ "pytest", "pytest-xdist",] [tool.aliases] test = "pytest" diff --git a/scripts/py-unshare.sh b/scripts/py-unshare.sh new file mode 100755 index 00000000..9a7a7c4a --- /dev/null +++ b/scripts/py-unshare.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# execnet popen wrapper: re-exec the project Python inside an isolated +# namespace so each pytest-xdist worker gets a private network, PID and +# mount stack (with a private /tmp). Needed so concurrent IPMininet test +# networks cannot interfere: bridges/veths/iptables (netns), cleanup()'s +# pkill/pgrep (PID ns) and node configs/zebra sockets under /tmp (mount ns) +# are all scoped per worker. Invoked by execnet as: -u [-c] bootstrap +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REAL_PY="${PYTHON:-$ROOT/.venv/bin/python}" +if [ ! -x "$REAL_PY" ]; then + REAL_PY="$(command -v python3)" +fi +exec unshare --mount --pid --net --uts --fork --mount-proc bash -c ' + mount -t tmpfs tmpfs /tmp + exec "$@" +' _ "$REAL_PY" "$@" diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh new file mode 100755 index 00000000..ca70873a --- /dev/null +++ b/scripts/run-tests-parallel.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Run the test suite in parallel with pytest-xdist, every worker inside its +# own isolated namespace (see py-unshare.sh). Usage: +# run-tests-parallel.sh [-j N] [pytest args...] +# -j N overrides the worker count; the default is the number of CPUs. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +N="${XDIST_WORKERS:-}" +ARGS=() +while [ $# -gt 0 ]; do + case "$1" in + -j) shift; N="${1:?missing worker count after -j}"; shift ;; + -j*) N="${1#-j}"; shift ;; + *) ARGS+=("$1"); shift ;; + esac +done +if [ -z "$N" ]; then + N="$(nproc)" +fi +if [ ${#ARGS[@]} -eq 0 ]; then + ARGS=(ipmininet/tests/) +fi + +WRAPPER="$ROOT/scripts/py-unshare.sh" +if [ ! -x "$WRAPPER" ]; then + echo "error: isolation wrapper not found or not executable: $WRAPPER" >&2 + exit 1 +fi + +echo "==> pytest-xdist workers: $N (isolated namespaces)" +exec uv run python -m pytest \ + -p no:cacheprovider \ + -v \ + --dist=load \ + --tx "${N}*popen//python=${WRAPPER}" \ + "${ARGS[@]}" diff --git a/uv.lock b/uv.lock index 6d628b6b..1b64f655 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -33,6 +42,7 @@ dependencies = [ [package.optional-dependencies] testing = [ { name = "pytest" }, + { name = "pytest-xdist" }, ] [package.metadata] @@ -40,6 +50,7 @@ requires-dist = [ { name = "mako", specifier = ">=1.1,<1.2" }, { name = "mininet", git = "https://github.com/mimi-net/mininet.git" }, { name = "pytest", marker = "extra == 'testing'" }, + { name = "pytest-xdist", marker = "extra == 'testing'" }, { name = "setuptools" }, ] provides-extras = ["testing"] @@ -170,6 +181,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "setuptools" version = "84.0.0" From 3801a428a18832b4d4c820a84717b7198482cc96 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sat, 29 Aug 2026 10:38:23 +0300 Subject: [PATCH 22/69] ci: dedupe triggers, cancel stale runs, cache compiled deps Workflows run on PRs and pushes to master only. Stale in-flight runs on the same branch or PR are cancelled. The install step is idempotent (skips already-built FRRouting, libyang, mininet and exabgp; IPMININET_FORCE_INSTALL=1 forces a rebuild) and its compiled artifacts plus apt archives are cached (keyed on uv.lock and the install sources), so repeat installs skip recompiling. --- .github/workflows/container-test.yaml | 3 ++ .github/workflows/heavy-test.yaml | 17 ++++++++ .github/workflows/test.yaml | 17 ++++++++ ipmininet/install/install.py | 62 ++++++++++++++++++--------- scripts/ci-restore-deps.sh | 21 +++++++++ scripts/ci-save-deps.sh | 29 +++++++++++++ 6 files changed, 129 insertions(+), 20 deletions(-) create mode 100755 scripts/ci-restore-deps.sh create mode 100755 scripts/ci-save-deps.sh diff --git a/.github/workflows/container-test.yaml b/.github/workflows/container-test.yaml index 371f073f..d81ed841 100644 --- a/.github/workflows/container-test.yaml +++ b/.github/workflows/container-test.yaml @@ -8,6 +8,9 @@ on: pull_request: push: branches: [master] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: container-test: runs-on: ubuntu-latest diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index 85a15acc..fa990b29 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -9,6 +9,9 @@ on: workflow_dispatch: push: branches: [master] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: heavy-test: runs-on: ubuntu-latest @@ -25,12 +28,26 @@ jobs: path: .venv key: venv-${{ runner.os }}-${{ hashFiles('uv.lock') }} - run: uv sync --all-extras + - name: Record OS version for the deps cache key + run: echo "OS=$(. /etc/os-release && echo $VERSION_ID)" >> "$GITHUB_ENV" + - name: Cache compiled deps + id: cache-deps + uses: actions/cache@v4 + with: + path: /home/runner/ci-deps + key: ci-deps-${{ runner.os }}-${{ env.OS }}-${{ hashFiles('uv.lock', 'pyproject.toml', 'ipmininet/install/install.py', 'ipmininet/install/utils.py', 'ipmininet/install/__main__.py') }} + - name: Restore compiled deps + if: steps.cache-deps.outputs.cache-hit == 'true' + run: scripts/ci-restore-deps.sh - name: Install dependencies (FRRouting, libyang, mininet) timeout-minutes: 10 run: | # PEP 668 on Ubuntu 24.04 blocks pip3 system installs; relax it. export PIP_BREAK_SYSTEM_PACKAGES=1 sudo env "PATH=$PATH" PIP_BREAK_SYSTEM_PACKAGES=1 uv run python -m ipmininet.install -a + - name: Save compiled deps + if: steps.cache-deps.outputs.cache-hit != 'true' + run: scripts/ci-save-deps.sh - name: Run full test suite timeout-minutes: 170 run: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fd37f6af..c5c08575 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,6 +14,9 @@ on: pull_request: push: branches: [master] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: rootless: runs-on: ubuntu-latest @@ -52,12 +55,26 @@ jobs: - name: Mininet dependency must be mimi-net fork run: grep -q 'mimi-net/mininet' pyproject.toml || (echo "mininet dependency must point to mimi-net/mininet.git fork" && exit 1) - run: uv sync --all-extras + - name: Record OS version for the deps cache key + run: echo "OS=$(. /etc/os-release && echo $VERSION_ID)" >> "$GITHUB_ENV" + - name: Cache compiled deps + id: cache-deps + uses: actions/cache@v4 + with: + path: /home/runner/ci-deps + key: ci-deps-${{ runner.os }}-${{ env.OS }}-${{ hashFiles('uv.lock', 'pyproject.toml', 'ipmininet/install/install.py', 'ipmininet/install/utils.py', 'ipmininet/install/__main__.py') }} + - name: Restore compiled deps + if: steps.cache-deps.outputs.cache-hit == 'true' + run: scripts/ci-restore-deps.sh - name: Install dependencies (FRRouting, libyang, mininet) timeout-minutes: 10 run: | # PEP 668 on Ubuntu 24.04 blocks pip3 system installs; relax it. export PIP_BREAK_SYSTEM_PACKAGES=1 sudo env "PATH=$PATH" PIP_BREAK_SYSTEM_PACKAGES=1 uv run python -m ipmininet.install -a + - name: Save compiled deps + if: steps.cache-deps.outputs.cache-hit != 'true' + run: scripts/ci-save-deps.sh - name: Run test suite timeout-minutes: 170 run: | diff --git a/ipmininet/install/install.py b/ipmininet/install/install.py index 6536d086..8db8bbba 100644 --- a/ipmininet/install/install.py +++ b/ipmininet/install/install.py @@ -19,6 +19,14 @@ os.environ["PATH"] = "%s:/sbin:/usr/sbin/:/usr/local/sbin" % os.environ["PATH"] +def _needs_rebuild(*paths: str) -> bool: + """Return True when a component must be (re)built: one of its artifacts is + missing or the user requested a forced rebuild (IPMININET_FORCE_INSTALL=1). + """ + return os.environ.get("IPMININET_FORCE_INSTALL") == "1" or \ + not all(os.path.exists(p) for p in paths) + + def parse_args(): parser = argparse.ArgumentParser(description="Install IPMininet with" " its dependencies") @@ -70,19 +78,23 @@ def install_mininet(output_dir: str, pip_install=True): else: mininet_opts = "-a" - sh("git clone https://github.com/mininet/mininet.git", cwd=output_dir) if not pip_install: # Minimal install: only build the mnexec binary. The full # install.sh -a installs packages (e.g. pep8) that no longer exist # on Ubuntu 24.04. The Python package itself is provided by uv # (see pyproject.toml) and OVS comes from the openvswitch-switch # apt package installed above. - sh("git checkout %s" % MininetVersion, - cwd=os.path.join(output_dir, "mininet")) - sh("make mnexec", cwd=os.path.join(output_dir, "mininet")) - sh("cp mnexec /usr/local/bin/", - cwd=os.path.join(output_dir, "mininet")) + if _needs_rebuild("/usr/local/bin/mnexec"): + sh("rm -rf mininet", + "git clone https://github.com/mininet/mininet.git", cwd=output_dir) + sh("git checkout %s" % MininetVersion, + cwd=os.path.join(output_dir, "mininet")) + sh("make mnexec", cwd=os.path.join(output_dir, "mininet")) + sh("cp mnexec /usr/local/bin/", + cwd=os.path.join(output_dir, "mininet")) return + if _needs_rebuild(os.path.join(output_dir, "mininet")): + sh("git clone https://github.com/mininet/mininet.git", cwd=output_dir) # Save valid version of mininet install script sh("git checkout %s" % MininetInstallCommit, cwd=os.path.join(output_dir, "mininet/util")) @@ -101,13 +113,17 @@ def install_mininet(output_dir: str, pip_install=True): def install_libyang(output_dir: str): + if not _needs_rebuild("/usr/bin/yanglint"): + print("IPMininet: libyang already installed; skipping build") + return dist.install("git", "cmake") if dist.NAME == "Ubuntu" or dist.NAME == "Debian": dist.install("libpcre3-dev") elif dist.NAME == "Fedora": dist.install("pcre-devel") - sh("git clone https://github.com/CESNET/libyang.git", cwd=output_dir) + sh("rm -rf libyang", + "git clone https://github.com/CESNET/libyang.git", cwd=output_dir) cloned_repo = os.path.join(output_dir, "libyang") sh("git checkout %s" % LibyangVersion, "mkdir build", cwd=cloned_repo) sh("cmake -DENABLE_LYD_PRIV=ON -DCMAKE_INSTALL_PREFIX:PATH=/usr -D CMAKE_BUILD_TYPE:String=\"Release\" ..", @@ -140,20 +156,21 @@ def install_frrouting(output_dir: str): install_libyang(output_dir) - frrouting_src = os.path.join(output_dir, "frr-%s" % FRRoutingVersion) - frrouting_tar = frrouting_src + ".tar.gz" - sh("wget https://github.com/FRRouting/frr/releases/download/frr-{v}/" - "frr-{v}.tar.gz".format(v=FRRoutingVersion), - "tar -zxvf '%s'" % frrouting_tar, - cwd=output_dir) - frrouting_install = os.path.join(output_dir, "frr") - sh("./configure '--prefix=%s'" % frrouting_install, - "make", - "make install", - cwd=frrouting_src) + if _needs_rebuild(os.path.join(frrouting_install, "sbin", "zebra")): + frrouting_src = os.path.join(output_dir, "frr-%s" % FRRoutingVersion) + frrouting_tar = frrouting_src + ".tar.gz" + sh("wget https://github.com/FRRouting/frr/releases/download/frr-{v}/" + "frr-{v}.tar.gz".format(v=FRRoutingVersion), + "tar -zxvf '%s'" % frrouting_tar, + cwd=output_dir) - sh("rm -r '%s' '%s'" % (frrouting_src, frrouting_tar)) + sh("./configure '--prefix=%s'" % frrouting_install, + "make", + "make install", + cwd=frrouting_src) + + sh("rm -r '%s' '%s'" % (frrouting_src, frrouting_tar)) sh("groupadd frr", may_fail=True) sh("groupadd frrvty", may_fail=True) @@ -190,7 +207,12 @@ def install_exabgp(output_dir: str, may_fail=False): exabgp_self_executable = os.path.join(output_dir, "exabgp") final_link = "/usr/sbin/exabgp" - sh("git clone {url} {src_dir}".format(url=git_url, src_dir=exabgp_src_folder), + if not _needs_rebuild(exabgp_self_executable, final_link): + print("IPMininet: ExaBGP already installed; skipping build") + return + + sh("rm -rf {src_dir}".format(src_dir=exabgp_src_folder), + "git clone {url} {src_dir}".format(url=git_url, src_dir=exabgp_src_folder), cwd=output_dir, may_fail=may_fail) sh("git checkout %s" % ExaBGPVersion, cwd=exabgp_path_src_dir, may_fail=may_fail) diff --git a/scripts/ci-restore-deps.sh b/scripts/ci-restore-deps.sh new file mode 100755 index 00000000..40e66cba --- /dev/null +++ b/scripts/ci-restore-deps.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Restore compiled system dependencies produced by `ipmininet.install -a` from +# $CI_DEPS_DIR (default: $HOME/ci-deps) into their install locations so the +# install step can skip recompiling (see the guards in ipmininet/install/install.py). +# The FRRouting symlinks under /usr/sbin and /usr/bin are recreated by the +# install step itself. Run as the (non-root) runner user before the install step. +set -euo pipefail +SRC="${CI_DEPS_DIR:-$HOME/ci-deps}" +[ -d "$SRC" ] || { echo "no compiled deps cache at $SRC"; exit 0; } +if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi +echo "==> Restoring compiled dependencies from $SRC" + +# apt archives first, so apt-get reuses .debs instead of downloading +[ -d "$SRC/apt-archives" ] && $SUDO cp -a "$SRC/apt-archives/." /var/cache/apt/archives/ + +[ -f "$SRC/usr.tar" ] && (cd / && $SUDO tar -xf "$SRC/usr.tar") +[ -f "$SRC/libyang.tar" ] && (cd / && $SUDO tar -xf "$SRC/libyang.tar") +[ -f "$SRC/root.tar" ] && $SUDO tar -C /root -xf "$SRC/root.tar" + +# Recreate the /usr/sbin/exabgp symlink (points at the /root/exabgp zipapp) +$SUDO ln -sfn /root/exabgp /usr/sbin/exabgp \ No newline at end of file diff --git a/scripts/ci-save-deps.sh b/scripts/ci-save-deps.sh new file mode 100755 index 00000000..b02e918f --- /dev/null +++ b/scripts/ci-save-deps.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Snapshot the compiled system dependencies produced by `ipmininet.install -a` +# into $CI_DEPS_DIR (default: $HOME/ci-deps) so a later CI run can restore them +# (ci-restore-deps.sh) and the install step skips recompiling FRRouting, +# libyang, mininet and exabgp (see the guards in ipmininet/install/install.py). +# Run as the (non-root) runner user after the install step. +set -euo pipefail +shopt -s nullglob +DEST="${CI_DEPS_DIR:-$HOME/ci-deps}" +if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi +echo "==> Saving compiled dependencies to $DEST" +rm -rf "$DEST" +mkdir -p "$DEST" + +# apt archives, so apt-get in the install step reuses .debs instead of downloading +$SUDO cp -a /var/cache/apt/archives "$DEST/apt-archives" + +# System-installed artifacts: libyang, the mnexec binary and the /usr/sbin/exabgp symlink. +# Glob expansion happens in a subshell rooted at / so tar stores relative members. +( + cd / + $SUDO tar -cf "$DEST/usr.tar" usr/bin/yanglint usr/local/bin/mnexec usr/sbin/exabgp || true + $SUDO tar -cf "$DEST/libyang.tar" \ + usr/lib/libyang* usr/lib/*/libyang* \ + usr/include/libyang* usr/share/libyang* || true +) + +# Per-run install prefix ($HOME of the sudo install step, e.g. /root) +$SUDO tar -C /root -cf "$DEST/root.tar" frr exabgp || true \ No newline at end of file From a2ba8636962807c2cf81755247fdd89bf816eebe Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sat, 29 Aug 2026 10:54:13 +0300 Subject: [PATCH 23/69] ci: make the dev container self-sufficient for the full test suite The container is the documented local environment for full-suite verification, but two runtime dependencies of the test suite are missing: iptables (test_iptables) and ping (test_cli/test_tc). Ship them. Exclude .git, .venv, .tmp and build caches from the docker build context so the image build no longer ships tens of MB of stale files. install -a no longer aborts when regenerating the GRUB config fails, e.g. in containers where update-grub is missing or grub-probe cannot run on overlayfs. sh()'s may_fail now covers commands that fail to start (missing binary), not only non-zero exits. --- .dockerignore | 9 +++++++++ Containerfile | 2 +- ipmininet/install/install.py | 2 +- ipmininet/install/utils.py | 12 +++++++++--- 4 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f80b1adc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.venv +.tmp +.pytest_cache +.ruff_cache +.mypy_cache +**/__pycache__ +*.pyc +*.log \ No newline at end of file diff --git a/Containerfile b/Containerfile index 54fd9d01..f9918a4c 100644 --- a/Containerfile +++ b/Containerfile @@ -22,7 +22,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ethtool help2man net-tools python3-pexpect python3-tk iproute2 \ cgroup-tools autotools-dev libc6-dev tcpdump python3-scapy \ libpcap-dev libconfig-dev openvswitch-switch radvd bind9 dnsutils \ - bridge-utils traceroute nmap netcat-openbsd tshark \ + bridge-utils traceroute nmap netcat-openbsd tshark iptables iputils-ping \ python3-pip grub-common \ && rm -rf /var/lib/apt/lists/* diff --git a/ipmininet/install/install.py b/ipmininet/install/install.py index 8db8bbba..64739195 100644 --- a/ipmininet/install/install.py +++ b/ipmininet/install/install.py @@ -233,7 +233,7 @@ def update_grub(): cmd = "update-grub" else: return - sh(cmd) + sh(cmd, may_fail=True) def enable_ipv6(): diff --git a/ipmininet/install/utils.py b/ipmininet/install/utils.py index 42702b0e..003e8692 100644 --- a/ipmininet/install/utils.py +++ b/ipmininet/install/utils.py @@ -20,9 +20,15 @@ def sh(*cmds, **kwargs) -> Optional[subprocess.Popen]: p = None for cmd in cmds: print("\n*** " + cmd) - p = subprocess.Popen(shlex.split(cmd), - env=env, - **kwargs) + try: + p = subprocess.Popen(shlex.split(cmd), + env=env, + **kwargs) + except OSError as e: + if not may_fail: + raise + sys.stderr.write("*** failed to run %s: %s\n" % (cmd, e)) + continue if output_stdout: while p.poll() is None: From 363d3428d12a32b3a6683bb8f13111263bf42877 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sat, 29 Aug 2026 18:41:37 +0300 Subject: [PATCH 24/69] feat(capture): expose capture readiness via wait_until_capturing() mimidump now signals capture readiness on stderr ('READY') once all captors have entered the capture loop (mimi-net/mimidump#11). Expose that as NetworkCapture.wait_until_capturing(name, timeout) so consumers can start jobs only once captures are actually live, without magic sleep margins or the racy outbound-file poll + capture-restart workaround. The predicate returns True once the capture process is alive AND its readiness signal has been observed on stderr, falling back to 'process alive + capture file exists' for captures that do not emit a signal (plain tcpdump on nodes, older mimidump). Unknown names return False. start()/stop() are unchanged; the readiness signal is consumed by a small daemon drainer before the API is queried, which also prevents the mimidump stderr pipe from ever filling up. --- ipmininet/overlay.py | 56 +++++++++++++++++++++++++ ipmininet/tests/test_network_capture.py | 27 ++++++++++++ 2 files changed, 83 insertions(+) diff --git a/ipmininet/overlay.py b/ipmininet/overlay.py index 491521f3..dcad2b33 100644 --- a/ipmininet/overlay.py +++ b/ipmininet/overlay.py @@ -2,6 +2,8 @@ import shlex import signal import subprocess +import threading +import time from collections import defaultdict from builtins import str from subprocess import Popen @@ -252,6 +254,9 @@ def __init__(self, nodes: List['NodeDescription'] = (), interfaces: List['IntfDe self.interfaces = list(interfaces) self.extra_arguments = extra_arguments self.ongoing_captures = {} + self._output_files = {} + self._stderr_lines = {} + self._stderr_lock = threading.Lock() super().__init__(nodes=list(set(nodes))) def apply(self, topo: 'IPTopo'): @@ -271,6 +276,7 @@ def start(self, node: Optional['IPNode'] = None, intf: Optional['IPIntf'] = None file_path = os.path.join(node.cwd, self.base_filename + '_' + node.name + '.pcapng') cmd = f"tcpdump -Z root -i {' -i '.join(interfaces)} -w {file_path} {self.extra_arguments}" process = node.popen(shlex.split(cmd)) + self._output_files[node.name] = file_path self.ongoing_captures[node.name] = process return process elif intf is not None: @@ -284,6 +290,8 @@ def start(self, node: Optional['IPNode'] = None, intf: Optional['IPIntf'] = None cmd = f"mimidump {intf.name} {file_path} {file_path_out} {self.extra_arguments}" process = intf.node.popen(shlex.split(cmd)) + self._output_files[intf.name] = file_path_out + self._drain_stderr(intf.name, process) self.ongoing_captures[intf.name] = process return process else: @@ -295,3 +303,51 @@ def stop(self, node: Optional['IPNode'] = None, intf: Optional['IPIntf'] = None) process: subprocess.Popen = self.ongoing_captures.get(anchor.name) process.send_signal(signal.SIGINT) process.wait() + + def wait_until_capturing(self, intf_name: str, timeout: float = 10.0) -> bool: + """Wait until the capture on the given node/interface is actually running. + + A capture is considered live once the corresponding process is still alive + AND either the mimidump ``READY`` signal has been seen on stderr (that + signal is emitted once, when all captors enter the capture loop) OR the + capture output file already exists (the fallback used by captures that do + not signal readiness, e.g. tcpdump or older mimidump binaries). + + :param intf_name: name of the node or of the interface the capture is + attached to (the key used in ``ongoing_captures``) + :param timeout: maximum time to wait, in seconds + :return: True as soon as the capture is live, False if the capture + was not started, died or did not become live in time""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + process = self.ongoing_captures.get(intf_name) + if process is None or process.poll() is not None: + return False + with self._stderr_lock: + if any(line.strip().startswith(b"READY") + for line in self._stderr_lines.get(intf_name, ())): + return True + output_file = self._output_files.get(intf_name) + if output_file is not None and os.path.exists(output_file): + return True + time.sleep(0.05) + return False + + def _drain_stderr(self, intf_name: str, process: subprocess.Popen): + """Read the mimidump stderr pipe so that the process never blocks on a full + pipe and so that the ``READY`` readiness signal can be observed.""" + with self._stderr_lock: + self._stderr_lines[intf_name] = [] + + def drain(): + while True: + line = process.stderr.readline() + if not line: + with self._stderr_lock: + self._stderr_lines.pop(intf_name, None) + break + with self._stderr_lock: + self._stderr_lines[intf_name].append(line) + + threading.Thread(target=drain, daemon=True, + name=f"mimidump-stderr-{intf_name}").start() diff --git a/ipmininet/tests/test_network_capture.py b/ipmininet/tests/test_network_capture.py index 482312dc..63e9b109 100644 --- a/ipmininet/tests/test_network_capture.py +++ b/ipmininet/tests/test_network_capture.py @@ -3,6 +3,7 @@ from ipmininet.clean import cleanup from ipmininet.ipnet import IPNet +from ipmininet.overlay import NetworkCapture from . import require_root, require_mimidump from .utils import assert_connectivity from ..examples.network_capture import NetworkCaptureTopo @@ -34,3 +35,29 @@ def test_network_capture_example(): net.stop() finally: cleanup() + + +@require_mimidump +@require_root +def test_network_capture_wait_until_capturing(): + try: + net = IPNet(topo=NetworkCaptureTopo()) + net.start() + overlay = next(o for o in net.topo.overlays + if isinstance(o, NetworkCapture)) + + # Captures on interfaces (mimidump) become live once the READY signal + # is received + assert overlay.wait_until_capturing("r2-eth0", timeout=10) + assert overlay.wait_until_capturing("s2-eth1", timeout=10) + + # Captures on nodes (tcpdump) become live once their output file exists + assert overlay.wait_until_capturing("r1", timeout=10) + assert overlay.wait_until_capturing("s1", timeout=10) + + # Captures that were never started are not live + assert not overlay.wait_until_capturing("does-not-exist", timeout=1) + + net.stop() + finally: + cleanup() From 4ee10a9fe1a7ad2754a42234a4cd11304794eed6 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sun, 30 Aug 2026 01:41:17 +0300 Subject: [PATCH 25/69] fix: restore only missing addresses when bringing an interface back up IPIntf.up(restore=True) went through setIP(), which removes and re-adds every address on the interface. Because the kernel drops IPv6 addresses on a link flap while IPv4 survives, the saved set always differs from the current one, so every restore churned the addresses: the IPv4 was briefly removed and re-added too, leaving a 0.0.0.0 window that races with zebra's route reinstalls and can leave an OSPF route stuck missing. Restore only the addresses that are actually missing (the IPv6 ones the kernel dropped), leaving present addresses untouched, and refresh the cached address list afterwards. --- ipmininet/link.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ipmininet/link.py b/ipmininet/link.py index 0c11e4ea..d82f3bc1 100644 --- a/ipmininet/link.py +++ b/ipmininet/link.py @@ -257,7 +257,13 @@ def up(self, restore=True): restore the saved addresses""" self.isUp(setUp=True) if restore: - self.setIP(self.backup_addresses[4] + self.backup_addresses[6]) + self._refresh_addresses() + current = set(self.addresses[4] + self.addresses[6]) + for ip in self.backup_addresses[4] + self.backup_addresses[6]: + if ip not in current: + self.cmd('ip', 'address', 'add', 'dev', self.name, + ip.with_prefixlen) + self._refresh_addresses() for cmd in self.restore_cmds: self.node.cmd(cmd) From 9e10a8958128ed6fe65829fc39aa1058ffb15833 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sat, 29 Aug 2026 19:05:31 +0300 Subject: [PATCH 26/69] ci: run interface-capture tests in container and bare-metal CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture tests (test_network_capture.py) were skipped everywhere because mimidump is not shipped. Build it — pinned to the commit that ships the capture-readiness signal, mimi-net/mimidump@854a3b0 — in the container image and provision it on the bare-metal runners so the interface-capture tests actually run in every root CI job. Also switches the container job from a three-file subset to the full test suite (scripts/ci-test.sh with no arguments dispatches to the parallel xdist + isolated-namespace runner already proven in the container: 186 passed / 7 skipped in ~8 min, under the 90-min timeout). Remaining skips everywhere are documented ones: exabgp (broken vendored six) and openr (heavy 2019 build, intentionally not in install -a). --- .github/workflows/container-test.yaml | 14 +++++++------- .github/workflows/heavy-test.yaml | 2 ++ .github/workflows/test.yaml | 2 ++ Containerfile | 10 +++++++++- scripts/ci-install-mimidump.sh | 26 ++++++++++++++++++++++++++ scripts/ci-test.sh | 7 +++++++ 6 files changed, 53 insertions(+), 8 deletions(-) create mode 100755 scripts/ci-install-mimidump.sh diff --git a/.github/workflows/container-test.yaml b/.github/workflows/container-test.yaml index d81ed841..80abab85 100644 --- a/.github/workflows/container-test.yaml +++ b/.github/workflows/container-test.yaml @@ -1,8 +1,10 @@ # Containerized CI. # # Builds a self-contained image with the full install (-a: FRRouting, libyang, -# mininet, radvd, sshd, named) plus the uv venv, then runs a subset of tests -# inside it with root privileges. +# mininet, radvd, sshd, named) plus the uv venv and mimidump, then runs the +# full test suite inside it with root privileges. Every worker runs in its own +# isolated namespace (scripts/run-tests-parallel.sh), mirroring the bare-metal +# job but in a reproducible environment. name: Container test on: pull_request: @@ -14,7 +16,7 @@ concurrency: jobs: container-test: runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx @@ -29,13 +31,11 @@ jobs: tags: ipmininet-dev cache-from: type=gha cache-to: type=gha,mode=max - - name: Run test subset with coverage + - name: Run full test suite run: | docker run --rm --privileged \ -v $PWD:/workspace:Z \ -e UV_PROJECT_ENVIRONMENT=/opt/venv \ -e DEBIAN_FRONTEND=noninteractive \ ipmininet-dev \ - scripts/ci-test.sh \ - ipmininet/tests/test_misc.py ipmininet/tests/test_pure.py \ - ipmininet/tests/test_dns.py + scripts/ci-test.sh diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index fa990b29..ce7a05b5 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -48,6 +48,8 @@ jobs: - name: Save compiled deps if: steps.cache-deps.outputs.cache-hit != 'true' run: scripts/ci-save-deps.sh + - name: Install mimidump + run: scripts/ci-install-mimidump.sh - name: Run full test suite timeout-minutes: 170 run: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c5c08575..3e004c36 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -75,6 +75,8 @@ jobs: - name: Save compiled deps if: steps.cache-deps.outputs.cache-hit != 'true' run: scripts/ci-save-deps.sh + - name: Install mimidump + run: scripts/ci-install-mimidump.sh - name: Run test suite timeout-minutes: 170 run: | diff --git a/Containerfile b/Containerfile index f9918a4c..7c4e9f15 100644 --- a/Containerfile +++ b/Containerfile @@ -21,11 +21,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libcap-dev cmake libpcre3-dev socat psmisc xterm openssh-client iperf3 \ ethtool help2man net-tools python3-pexpect python3-tk iproute2 \ cgroup-tools autotools-dev libc6-dev tcpdump python3-scapy \ - libpcap-dev libconfig-dev openvswitch-switch radvd bind9 dnsutils \ + libpcap-dev libbsd-dev libconfig-dev openvswitch-switch radvd bind9 dnsutils \ bridge-utils traceroute nmap netcat-openbsd tshark iptables iputils-ping \ python3-pip grub-common \ && rm -rf /var/lib/apt/lists/* +# Build mimidump (mimi-net/mimidump) so the interface-capture tests can run in +# the container. Pinned to the commit that ships the capture-readiness signal. +RUN git clone --quiet https://github.com/mimi-net/mimidump.git /tmp/mimidump \ + && git -C /tmp/mimidump checkout --quiet 854a3b0 \ + && make -C /tmp/mimidump \ + && make -C /tmp/mimidump install \ + && rm -rf /tmp/mimidump + COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv WORKDIR /workspace diff --git a/scripts/ci-install-mimidump.sh b/scripts/ci-install-mimidump.sh new file mode 100755 index 00000000..2e9c95dd --- /dev/null +++ b/scripts/ci-install-mimidump.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Build and install mimidump (mimi-net/mimidump) so that the interface-capture +# tests run on bare-metal CI too. Pinned to the commit that ships the +# capture-readiness signal; skips the build when the binary is already present +# (e.g. restored from the compiled-deps cache). +set -euo pipefail + +if command -v mimidump >/dev/null 2>&1; then + echo "==> mimidump already installed, skipping build" + exit 0 +fi + +echo "==> Installing mimidump build dependencies" +if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi +$SUDO apt-get update -qq +$SUDO apt-get install -y -qq libpcap-dev libbsd-dev + +echo "==> Building mimidump @ mimi-net/mimidump 854a3b0" +TMPDIR_FULL="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_FULL"' EXIT +git clone --quiet https://github.com/mimi-net/mimidump.git "$TMPDIR_FULL" +git -C "$TMPDIR_FULL" checkout --quiet 854a3b0 +make -C "$TMPDIR_FULL" +$SUDO make -C "$TMPDIR_FULL" install + +echo "==> mimidump installed: $(command -v mimidump)" \ No newline at end of file diff --git a/scripts/ci-test.sh b/scripts/ci-test.sh index cdb3806c..4d3a9ba1 100755 --- a/scripts/ci-test.sh +++ b/scripts/ci-test.sh @@ -21,6 +21,13 @@ if [ ! -e /var/run/openvswitch/db.sock ]; then sleep 0.5 fi +# Without explicit test files, run the whole suite in parallel (xdist + +# isolated namespaces), mirroring the bare-metal jobs. +if [ "$#" -eq 0 ]; then + exec sudo env "PATH=$PATH" ${UV_PROJECT_ENVIRONMENT:+"UV_PROJECT_ENVIRONMENT=$UV_PROJECT_ENVIRONMENT"} \ + scripts/run-tests-parallel.sh +fi + exec sudo env "PATH=$PATH" ${UV_PROJECT_ENVIRONMENT:+"UV_PROJECT_ENVIRONMENT=$UV_PROJECT_ENVIRONMENT"} uv run python -m pytest \ -v \ -p faulthandler \ From db358a17b67cad8197bf4c168181d570d53cda7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 06:18:21 +0000 Subject: [PATCH 27/69] Bump the actions-dependencies group with 5 updates Bumps the actions-dependencies group with 5 updates: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `4` | `7` | | [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` | | [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` | | [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `5` | `7` | | [actions/cache](https://github.com/actions/cache) | `4` | `6` | Updates `actions/checkout` from 4 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v7) Updates `docker/setup-buildx-action` from 3 to 4 - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4) Updates `docker/build-push-action` from 6 to 7 - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6...v7) Updates `astral-sh/setup-uv` from 5 to 7 - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/v5...v7) Updates `actions/cache` from 4 to 6 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-dependencies - dependency-name: docker/setup-buildx-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-dependencies - dependency-name: docker/build-push-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-dependencies - dependency-name: astral-sh/setup-uv dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-dependencies - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-dependencies ... Signed-off-by: dependabot[bot] --- .github/workflows/container-test.yaml | 6 +++--- .github/workflows/heavy-test.yaml | 8 ++++---- .github/workflows/test.yaml | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/container-test.yaml b/.github/workflows/container-test.yaml index 80abab85..bd4fb440 100644 --- a/.github/workflows/container-test.yaml +++ b/.github/workflows/container-test.yaml @@ -18,11 +18,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build and cache test container - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: file: Containerfile target: final diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index ce7a05b5..f970891e 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -17,13 +17,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 200 steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" - name: Cache venv - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: .venv key: venv-${{ runner.os }}-${{ hashFiles('uv.lock') }} @@ -32,7 +32,7 @@ jobs: run: echo "OS=$(. /etc/os-release && echo $VERSION_ID)" >> "$GITHUB_ENV" - name: Cache compiled deps id: cache-deps - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: /home/runner/ci-deps key: ci-deps-${{ runner.os }}-${{ env.OS }}-${{ hashFiles('uv.lock', 'pyproject.toml', 'ipmininet/install/install.py', 'ipmininet/install/utils.py', 'ipmininet/install/__main__.py') }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3e004c36..bff09cfe 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,8 +22,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" @@ -42,13 +42,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 200 steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" - name: Cache venv - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: .venv key: venv-${{ runner.os }}-${{ hashFiles('uv.lock') }} @@ -59,7 +59,7 @@ jobs: run: echo "OS=$(. /etc/os-release && echo $VERSION_ID)" >> "$GITHUB_ENV" - name: Cache compiled deps id: cache-deps - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: /home/runner/ci-deps key: ci-deps-${{ runner.os }}-${{ env.OS }}-${{ hashFiles('uv.lock', 'pyproject.toml', 'ipmininet/install/install.py', 'ipmininet/install/utils.py', 'ipmininet/install/__main__.py') }} From cb31c4346a9c2dfe22e025ab44fd84a0a4f528a0 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sun, 30 Aug 2026 10:32:48 +0300 Subject: [PATCH 28/69] fix: bump ExaBGP to 4.2.25 and fix its un-skipped tests ExaBGP 4.2.11 (the pinned version) bundles a vendored six 1.10 whose meta-path importer only implements the PEP 302 find_module hook, which Python 3.12 removed. As a result 'exabgp' could never start on Py3.12 and all four test_exabgp.py cases were skipped. Upstream fixed the vendored six (find_spec) on the 4.2 branch (4.2.21+, latest 4.2.25), so bump ExaBGPVersion to 4.2.25. The self-contained zipapp build is kept. Also fix the re-install path: os.path.exists() is False for a broken symlink, so a stale /usr/sbin/exabgp link was never removed and re-linking raised FileExistsError; use os.path.lexists(). With the daemon finally running, two latent test bugs surfaced and are fixed: check_as_path compared FRR JSON AS-PATH strings against ExaList ints ('1' != 1), and the require_exabgp skip reason is updated to no longer name the 4.2.11 version. Validated in the dev container: install_exabgp() builds the 4.2.25 zipapp, exabgp --version reports 4.2.25, and all 4 test_exabgp.py cases pass with the injected prefixes verified in the FRR BGP RIB. --- ipmininet/install/install.py | 4 ++-- ipmininet/tests/__init__.py | 7 ++++--- ipmininet/tests/test_exabgp.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ipmininet/install/install.py b/ipmininet/install/install.py index 64739195..789b47f1 100644 --- a/ipmininet/install/install.py +++ b/ipmininet/install/install.py @@ -10,7 +10,7 @@ MininetVersion = "2.3.0" FRRoutingVersion = "7.5" LibyangVersion = "v1.0.215" -ExaBGPVersion = "4.2.11" +ExaBGPVersion = "4.2.25" # XXX: We need the explicit script until the following issue is fixed: # https://github.com/mininet/mininet/issues/1120 @@ -221,7 +221,7 @@ def install_exabgp(output_dir: str, may_fail=False): sh('python3 -m zipapp -o {executable_path} -m exabgp.application:main -p "/usr/bin/env python3" lib' .format(executable_path=exabgp_self_executable), cwd=exabgp_path_src_dir, may_fail=may_fail) - if os.path.exists(final_link): + if os.path.lexists(final_link): os.remove(final_link) os.symlink(exabgp_self_executable, final_link) diff --git a/ipmininet/tests/__init__.py b/ipmininet/tests/__init__.py index dddace05..552731c6 100644 --- a/ipmininet/tests/__init__.py +++ b/ipmininet/tests/__init__.py @@ -25,8 +25,9 @@ def _exabgp_usable() -> bool: """Return whether a working ExaBGP daemon is available. - ExaBGP 4.2.11 ships a broken vendored six dependency, so simply being on - PATH is not enough: the daemon must also start (--version) cleanly. + ExaBGP 4.2.11 shipped a vendored six too old for Python 3.12 (broken + ``six.moves``), so simply being on PATH is not enough: the daemon must + also start (--version) cleanly. """ if not has_cmd('exabgp'): return False @@ -44,7 +45,7 @@ def _exabgp_usable() -> bool: require_exabgp = pytest.mark.skipif( not _exabgp_usable(), - reason='ExaBGP 4.2.11 has broken vendored six dependency') + reason='ExaBGP daemon not available (broken vendored six on Py3.12)') require_mimidump = pytest.mark.skipif( not has_cmd('mimidump'), diff --git a/ipmininet/tests/test_exabgp.py b/ipmininet/tests/test_exabgp.py index e69f890d..dcef91c0 100644 --- a/ipmininet/tests/test_exabgp.py +++ b/ipmininet/tests/test_exabgp.py @@ -143,7 +143,7 @@ def check_next_hop(next_hops: dict, expected_nh: Dict[str, Union['IPv4Interface' def check_as_path(as_path_rib: str, as_path_us: ExaList): - as_rib = as_path_rib.split(" ") + as_rib = [int(asn) for asn in as_path_rib.split(" ")] as_rib_us = as_path_us.val error_msg = "Bad AS-PATH. Expected {expected}. Received {received}" From 0fc5b4888e4343a7c687e9e659140d1c0d70901d Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sun, 30 Aug 2026 11:44:37 +0300 Subject: [PATCH 29/69] build: bump mako to 1.4.1 and migrate docs from m2r to sphinx-mdinclude Supersedes dependabot PR #21 (python-dependencies group). Mako 1.1.6 predates Python 3.12 support; bump the runtime dependency to 1.4.1 (>=1.1,<1.5) in pyproject.toml and uv.lock. All 18 config templates still compile under 1.4.1 and the runtime test subset (test_pure, test_misc, test_sshd, test_address_alllocation: 67 tests) passes. The Sphinx docs relied on m2r (unmaintained, 'NO MORE UPDATES') and recommonmark (deprecated since 2020) for the '.. mdinclude::' directive used in docs/examples.rst. Migrate to sphinx-mdinclude, the maintained replacement, and drop the m2r/mistune<2/recommonmark pins. napoleon now comes from sphinx.ext (the standalone sphinxcontrib-napoleon 0.7 is broken on Python 3.12). Fix the 'language = None' Sphinx 7.4+ config warning while touching conf.py. The docs build is not covered by CI; note this and the sphinx-mdinclude mistune>=3,<4 constraint in docs/contribute.rst. Docs build verified locally with sphinx-build (markdown from examples/README.md renders). --- docs/conf.py | 30 ++++-------------------------- docs/contribute.rst | 6 ++++++ docs/requirements.txt | 7 ++----- pyproject.toml | 2 +- uv.lock | 8 ++++---- 5 files changed, 17 insertions(+), 36 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 575e7ab9..3702bb95 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,9 +15,6 @@ import os import sys -from m2r import MdInclude -from recommonmark.transform import AutoStructify - sys.path.insert(0, os.path.abspath('..')) # -- Project information ----------------------------------------------------- @@ -42,11 +39,11 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = ['sphinx.ext.autodoc', - 'sphinxcontrib.napoleon', + 'sphinx.ext.napoleon', 'sphinxcontrib.apidoc', 'sphinx.ext.autosectionlabel', 'sphinx.ext.doctest', - 'recommonmark'] + 'sphinx_mdinclude'] doctest_global_setup = ''' import sys, os @@ -86,10 +83,7 @@ def close_on_start_cli(self): # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # -source_suffix = ['.rst', '.md'] - -# m2r parameters -m2r_parse_relative_links = True +source_suffix = '.rst' # The master toctree document. master_doc = 'index' @@ -99,7 +93,7 @@ def close_on_start_cli(self): # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -212,19 +206,3 @@ def close_on_start_cli(self): # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] - - -def setup(app): - # AutoStructify configuration - app.add_config_value('recommonmark_config', { - 'auto_toc_tree_section': 'Contents', - 'enable_eval_rst': True, - }, True) - app.add_transform(AutoStructify) - - # from m2r to make `mdinclude` work - app.add_config_value('no_underscore_emphasis', False, 'env') - app.add_config_value('m2r_parse_relative_links', False, 'env') - app.add_config_value('m2r_anonymous_references', False, 'env') - app.add_config_value('m2r_disable_inline_math', False, 'env') - app.add_directive('mdinclude', MdInclude) diff --git a/docs/contribute.rst b/docs/contribute.rst index c689d0ae..ed984fc0 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -206,6 +206,12 @@ When at the root of the documentation, run: pip install -r requirements.txt +The documentation build is not covered by CI, so verify your changes +locally with ``make html`` (see below). The markdown included from the +example topologies is pulled in through ``sphinx-mdinclude``, which pins +``mistune`` to ``>=3,<4``; do not relax that constraint without also +replacing ``sphinx-mdinclude``. + Then you can generate the html documentation in the folder ``docs/_build/html/`` with: diff --git a/docs/requirements.txt b/docs/requirements.txt index 40fa4518..81783c89 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,8 +1,5 @@ -mako>=1.1,<1.2 +mako>=1.1,<1.5 mininet @ git+https://github.com/mininet/mininet@2.3.0 sphinx_rtd_theme sphinxcontrib-apidoc -sphinxcontrib-napoleon -m2r -mistune<2.0.0 # Needed because m2r does not support newest version of mistune: https://github.com/miyakogi/m2r/issues/66 -recommonmark +sphinx-mdinclude diff --git a/pyproject.toml b/pyproject.toml index 90e0993a..d588c8e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ requires-python = ">=3.12" description = "A mininet extension providing components to emulate IPnetworks running multiple protocols." keywords = [ "networking", "OSPF", "IP", "BGP", "quagga", "mininet",] classifiers = [ "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", "Programming Language :: Python", "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: System :: Networking", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9",] -dependencies = [ "setuptools", "mako>=1.1,<1.2", "mininet @ git+https://github.com/mimi-net/mininet.git",] +dependencies = [ "setuptools", "mako>=1.1,<1.5", "mininet @ git+https://github.com/mimi-net/mininet.git",] [[project.authors]] name = "Olivier Tilmans" diff --git a/uv.lock b/uv.lock index 1b64f655..f6d11bb2 100644 --- a/uv.lock +++ b/uv.lock @@ -47,7 +47,7 @@ testing = [ [package.metadata] requires-dist = [ - { name = "mako", specifier = ">=1.1,<1.2" }, + { name = "mako", specifier = ">=1.1,<1.5" }, { name = "mininet", git = "https://github.com/mimi-net/mininet.git" }, { name = "pytest", marker = "extra == 'testing'" }, { name = "pytest-xdist", marker = "extra == 'testing'" }, @@ -57,14 +57,14 @@ provides-extras = ["testing"] [[package]] name = "mako" -version = "1.1.6" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/b6/42cd322ae555aa770d49e31b8c5c28a243ba1bbb57ad927e1a5f5b064811/Mako-1.1.6.tar.gz", hash = "sha256:4e9e345a41924a954251b95b4b28e14a301145b544901332e658907a7464b6b2", size = 495132, upload-time = "2021-11-17T15:40:46.428Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/4d/e03d08f16ee10e688bde9016bc80af8b78c7f36a8b37c7194da48f72207e/Mako-1.1.6-py2.py3-none-any.whl", hash = "sha256:afaf8e515d075b22fad7d7b8b30e4a1c90624ff2f3733a06ec125f5a5f043a57", size = 75702, upload-time = "2021-11-17T15:40:48.625Z" }, + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, ] [[package]] From c8691c4120143f69be23ac59260473c2b9e860d2 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sun, 30 Aug 2026 12:30:49 +0300 Subject: [PATCH 30/69] docs: build with uv and add CI job for the documentation Move the Sphinx build dependencies into a 'docs' uv dependency group in pyproject.toml and drop docs/requirements.txt, so the documentation toolchain is version-locked like the rest of the project (uv.lock). Add a 'Build documentation' workflow that installs the docs group and runs sphinx-build with -W, so warnings fail the build, and uploads the generated HTML as an artifact on every PR and on master. mininet is already a locked project dependency, so autodoc can import the package without install -a. To make the -W build pass, fix the pre-existing Sphinx warnings: - drop the nonexistent html_static_path '_static' - silence duplicate autosectionlabel labels from the apidoc-generated api/*.rst files (nothing references those labels) - fix malformed docstrings that autodoc rendered as RST errors: IPCLI.do_link, IPOVSSwitch (which inherited a broken mininet __init__ docstring), IPTopo.addRouter ('"param' typo) and ZebraList.__init__ Document the new uv-based workflow in docs/contribute.rst. --- .github/workflows/docs.yaml | 34 +++ docs/conf.py | 11 +- docs/contribute.rst | 22 +- docs/requirements.txt | 5 - ipmininet/cli.py | 10 +- ipmininet/ipovs_switch.py | 18 ++ ipmininet/iptopo.py | 5 +- ipmininet/router/config/zebra.py | 6 +- pyproject.toml | 3 + uv.lock | 412 +++++++++++++++++++++++++++++++ 10 files changed, 497 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/docs.yaml delete mode 100644 docs/requirements.txt diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 00000000..744ae44c --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,34 @@ +# Documentation build. +# +# Builds the Sphinx documentation with the `docs` uv dependency group and +# uploads the resulting HTML as an artifact. The build runs with -W so Sphinx +# warnings fail the job. Runs on every PR and on master; the artifact is +# available from the workflow run page. +name: Build documentation +on: + pull_request: + push: + branches: [master] + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + docs: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: Install docs dependencies + run: uv sync --group docs + - name: Build documentation + run: uv run --group docs sphinx-build -b html -W docs docs/_build/html + - name: Upload documentation + uses: actions/upload-artifact@v4 + with: + name: ipmininet-docs + path: docs/_build/html diff --git a/docs/conf.py b/docs/conf.py index 3702bb95..7e59edf3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,6 +45,12 @@ 'sphinx.ext.doctest', 'sphinx_mdinclude'] +# The apidoc-generated api/*.rst files all contain "Subpackages"/"Submodules" +# sections, whose autosectionlabel-generated labels collide with each other and +# with section titles in the markdown included from the example topologies. +# Nothing references those labels, so silence the duplicate-label warnings. +suppress_warnings = ['autosectionlabel.*'] + doctest_global_setup = ''' import sys, os @@ -117,11 +123,6 @@ def close_on_start_cli(self): # # html_theme_options = {} -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - # Custom sidebar templates, must be a dictionary that maps document names # to template names. # diff --git a/docs/contribute.rst b/docs/contribute.rst index ed984fc0..b2b66677 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -199,32 +199,32 @@ reasons; skipped tests and their reasons are shown with ``pytest -rs``. Building the documentation -------------------------- -First, you have to install the requirements to build the project. -When at the root of the documentation, run: +First, install the documentation build dependencies (the ``docs`` uv +dependency group) at the root of the repository: .. code-block:: bash - pip install -r requirements.txt - -The documentation build is not covered by CI, so verify your changes -locally with ``make html`` (see below). The markdown included from the -example topologies is pulled in through ``sphinx-mdinclude``, which pins -``mistune`` to ``>=3,<4``; do not relax that constraint without also -replacing ``sphinx-mdinclude``. + uv sync --group docs Then you can generate the html documentation in the folder ``docs/_build/html/`` with: .. code-block:: bash - make html + uv run --group docs make -C docs html + +The documentation build is covered by CI (see ``.github/workflows/docs.yaml``), +which builds with ``sphinx-build -W`` so warnings are treated as errors. The +markdown included from the example topologies is pulled in through +``sphinx-mdinclude``, which pins ``mistune`` to ``>=3,<4``; do not relax that +constraint without also replacing ``sphinx-mdinclude``. The examples in the documentation can also be tested when changing the code base with the following command: .. code-block:: bash - sudo make doctest + sudo uv run --group docs make -C docs doctest .. _contribute_example: diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 81783c89..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -mako>=1.1,<1.5 -mininet @ git+https://github.com/mininet/mininet@2.3.0 -sphinx_rtd_theme -sphinxcontrib-apidoc -sphinx-mdinclude diff --git a/ipmininet/cli.py b/ipmininet/cli.py index 4f4bde67..504c18b1 100644 --- a/ipmininet/cli.py +++ b/ipmininet/cli.py @@ -53,10 +53,12 @@ def do_ping6pair(self, _line): self.mn.ping6Pair() def do_link(self, line: str): - """ down/up the link between 2 specified routers, can specify multiple - multiple link - :param line: the router name between which the link as to be - downed/up: r1 r2, r3 r4 [down/up] + """Down/up the links between the given routers. + + Multiple links can be specified at once. + + :param line: the routers between which the links are downed/up, + given as: r1 r2, r3 r4 [down/up] """ all_args = line.split(',') last_args = all_args[-1].split() diff --git a/ipmininet/ipovs_switch.py b/ipmininet/ipovs_switch.py index ad01641a..a0de1236 100644 --- a/ipmininet/ipovs_switch.py +++ b/ipmininet/ipovs_switch.py @@ -3,8 +3,26 @@ class IPOVSSwitch(OVSSwitch): + """A switch that supports IPMininet-specific features. + + The switch is an Open vSwitch kernel switch that can optionally enable + rapid spanning tree (RSTP) or plain spanning tree, and that records its + working directory and a configurable priority. + """ + def __init__(self, name: str, stp=False, hub=False, cwd='/tmp', rstp=False, failMode="standalone", batch=False, priority=None, **params): + """Create a new IPOVSSwitch. + + :param name: the name of the switch + :param stp: enable spanning tree + :param hub: run as a hub + :param cwd: the working directory of the switch + :param rstp: enable rapid spanning tree + :param failMode: controller loss behavior + :param batch: enable batch startup + :param priority: the switch priority + """ self.cwd = cwd self.priority = priority self.rstp = rstp and not hub diff --git a/ipmininet/iptopo.py b/ipmininet/iptopo.py index cc4a0d21..d596bce9 100644 --- a/ipmininet/iptopo.py +++ b/ipmininet/iptopo.py @@ -66,8 +66,9 @@ def addRouter(self, """Add a router to the topology :param name: the name of the node - "param routerDescription: the RouterDescription class to return - (optional)""" + :param routerDescription: the RouterDescription class to return + (optional) + """ return routerDescription(self.addNode(str(name), isRouter=True, **kwargs), self) diff --git a/ipmininet/router/config/zebra.py b/ipmininet/router/config/zebra.py index d6999a4b..0f1e05c4 100644 --- a/ipmininet/router/config/zebra.py +++ b/ipmininet/router/config/zebra.py @@ -214,12 +214,14 @@ def zebra_family(self): def __init__(self, family, entries: Sequence[Union['ZebraList.Entry', str, IPv4Network, IPv6Network]] = (), name=None): - """Setup a new zebra-list + """Setup a new zebra-list. + :param name: The name of the acl, which will default to acl## where ## is the instance number :param entries: A sequence of ZebraListEntry instance, or of ip_interface which describes which prefixes - are composing the list""" + are composing the list + """ assert family in {'ipv4', 'ipv6'}, "PrefixList unknown %s type. type must be either ipv4 or ipv6" % family diff --git a/pyproject.toml b/pyproject.toml index d588c8e1..d421529a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,9 @@ Homepage = "https://github.com/cnp3/ipmininet" [project.optional-dependencies] testing = [ "pytest", "pytest-xdist",] +[dependency-groups] +docs = [ "sphinx", "sphinx_rtd_theme", "sphinxcontrib-apidoc", "sphinx-mdinclude",] + [tool.aliases] test = "pytest" diff --git a/uv.lock b/uv.lock index f6d11bb2..7423233a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,164 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -11,6 +169,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "execnet" version = "2.1.2" @@ -20,6 +187,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/5e/513ff06670c84e7b9887c1fdf61b2d42b4f574a831f2f1d2222023049d8a/imagesize-2.0.1.tar.gz", hash = "sha256:b2ba6a4dea487a7ebcd53248d3476aca449d30db12a2dde5e0c5ca9624fd77e5", size = 1883774, upload-time = "2026-08-24T12:35:19.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl", hash = "sha256:ea0c9a0384df69ed86a943a15cde37d0360b82491b3910dc2215e202e62b5b02", size = 14794, upload-time = "2026-08-24T12:35:12.548Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -45,6 +230,14 @@ testing = [ { name = "pytest-xdist" }, ] +[package.dev-dependencies] +docs = [ + { name = "sphinx" }, + { name = "sphinx-mdinclude" }, + { name = "sphinx-rtd-theme" }, + { name = "sphinxcontrib-apidoc" }, +] + [package.metadata] requires-dist = [ { name = "mako", specifier = ">=1.1,<1.5" }, @@ -55,6 +248,26 @@ requires-dist = [ ] provides-extras = ["testing"] +[package.metadata.requires-dev] +docs = [ + { name = "sphinx" }, + { name = "sphinx-mdinclude" }, + { name = "sphinx-rtd-theme" }, + { name = "sphinxcontrib-apidoc" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "mako" version = "1.4.1" @@ -138,6 +351,15 @@ dependencies = [ { name = "setuptools" }, ] +[[package]] +name = "mistune" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/92/328a294a6de83bacb95bed01f04e0eaff4e3616ee359fc821a5dfc539b02/mistune-3.3.4.tar.gz", hash = "sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe", size = 121426, upload-time = "2026-07-22T05:22:30.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -147,6 +369,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pbr" +version = "7.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ab/1de9a4f730edde1bdbbc2b8d19f8fa326f036b4f18b2f72cfbea7dc53c26/pbr-7.0.3.tar.gz", hash = "sha256:b46004ec30a5324672683ec848aed9e8fc500b0d261d40a3229c2d2bbfcedc29", size = 135625, upload-time = "2025-11-03T17:04:56.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/db/61efa0d08a99f897ef98256b03e563092d36cc38dc4ebe4a85020fe40b31/pbr-7.0.3-py2.py3-none-any.whl", hash = "sha256:ff223894eb1cd271a98076b13d3badff3bb36c424074d26334cd25aebeecea6b", size = 131898, upload-time = "2025-11-03T17:04:54.875Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -194,6 +428,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + [[package]] name = "setuptools" version = "84.0.0" @@ -202,3 +460,157 @@ sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d3691 wheels = [ { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-mdinclude" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "mistune" }, + { name = "pygments" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/a7/c9a7888bb2187fdb06955d71e75f6f266b7e179b356ac76138d160a5b7eb/sphinx_mdinclude-0.6.2.tar.gz", hash = "sha256:447462e82cb8be61404a2204227f920769eb923d2f57608e3325f3bb88286b4c", size = 65257, upload-time = "2024-08-03T19:07:37.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/3d/6b41fe1637cd53c4b10d56e0e6f396546f837973dabf9c4b2a1de44620ac/sphinx_mdinclude-0.6.2-py3-none-any.whl", hash = "sha256:648e78edb067c0e4bffc22943278d49d54a0714494743592032fa3ad82a86984", size = 16911, upload-time = "2024-08-03T19:07:30.406Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[package]] +name = "sphinxcontrib-apidoc" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/8d/161842a1c199c1baed5f5bc0765a59a791324f962fea3b604f105c9493e7/sphinxcontrib_apidoc-0.6.0.tar.gz", hash = "sha256:329b9810d66988f48e127a6bd18cc8efbbd1cd20b8deb4691a35738af49ad88d", size = 16790, upload-time = "2025-05-08T12:55:44.463Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/ac/6b94ecdca590ad0596b83d78277f89d897539a6cfdc997d62f27651540bc/sphinxcontrib_apidoc-0.6.0-py3-none-any.whl", hash = "sha256:668592f933eee858f3bc0d0810d56d50dfa0a70f650a2faaaad501b9a3504633", size = 8765, upload-time = "2025-05-08T12:55:42.733Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] From 0e21db0633dacb93aed60eaea2c28b67aa52684e Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sun, 30 Aug 2026 14:26:23 +0300 Subject: [PATCH 31/69] test: fix flaky exabgp RIB check by polling instead of fixed sleep test_example_exabgp slept a fixed 130s before asserting that the routes injected by ExaBGP were present in the FRRouting BGP RIB. ExaBGP is configured to send its routes after a delay (2 minutes by default) and uses a passive BGP session, so under parallel CI load the session establishment plus the route-send delay can exceed 130s, making the test flaky (observed as a KeyError on '8.8.8.0/24'). Replace the fixed sleep with a poll loop that waits (up to 5 minutes, checking every 5s) until all expected routes appear in the RIB, then validates them. Also fix check_correct_rib to assert membership before indexing a route, so a missing route yields the descriptive assertion message instead of a bare KeyError. --- ipmininet/tests/test_exabgp.py | 75 ++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/ipmininet/tests/test_exabgp.py b/ipmininet/tests/test_exabgp.py index dcef91c0..f24cdad3 100644 --- a/ipmininet/tests/test_exabgp.py +++ b/ipmininet/tests/test_exabgp.py @@ -83,23 +83,62 @@ def prepare_rib_lookup_script(rib_script) -> Sequence[Tuple[str, str]]: return scripts +def get_rib_routes(node, command, family): + """Return the parsed BGP RIB routes for the given family, or None if the + RIB cannot be read yet (e.g. the router has not fully started).""" + my_output = node.popen("sh %s" % command) + my_output.wait() + out, err = my_output.communicate() + + output = out.decode(errors="ignore") + + p = re.compile( + r"(?s)as2> show bgp {family} json(?P.*)as2> exit".format(family=family) + ) + m = p.search(output) + if m is None: + return None + + return json.loads(m.group("rib")).get("routes") + + +def wait_for_expected_routes(node, rib_scripts, topo, timeout=300, poll=5): + """Wait until all the routes ExaBGP is expected to inject appear in the + FRRouting BGP RIB. + + ExaBGP is configured to only send its routes after a delay (2 minutes by + default), and the passive BGP session it uses may take a while to + establish. A fixed sleep is therefore unreliable under load; poll the RIB + instead. + """ + expected_routes = topo.routes + elapsed = 0 + while elapsed < timeout: + missing = [] + for command, family in rib_scripts: + rib_routes = get_rib_routes(node, command, family) + if rib_routes is None: + missing.append((family, "")) + else: + for route in expected_routes[family]: + if str(route.IPNetwork) not in rib_routes: + missing.append((family, str(route.IPNetwork))) + if not missing: + return + time.sleep(poll) + elapsed += poll + pytest.fail( + "The routes injected by ExaBGP did not appear in the BGP RIB within " + "{timeout}s. Missing: {missing}".format(timeout=timeout, missing=missing) + ) + + def check_correct_rib(node, rib_scripts, topo): expected_rib_routes = topo.routes for command, family in rib_scripts: - my_output = node.popen("sh %s" % command) - my_output.wait() - out, err = my_output.communicate() - - output = out.decode(errors="ignore") + rib_routes = get_rib_routes(node, command, family) - p = re.compile(r"(?s)as2> show bgp {family} json(?P.*)as2> exit".format(family=family)) - m = p.search(output) - - assert m is not None, "Unable to find the RIB" - - my_rib = m.group("rib") - parsed_rib = json.loads(my_rib) - rib_routes = parsed_rib['routes'] + assert rib_routes is not None, "Unable to find the RIB" print(expected_rib_routes) print(rib_routes) @@ -107,11 +146,13 @@ def check_correct_rib(node, rib_scripts, topo): for our_route in expected_rib_routes[family]: str_ipnet = str(our_route.IPNetwork) - rib_route = rib_routes[str_ipnet][0] # take the first one as ExaBGP sends only one route per prefix assert str_ipnet in rib_routes, \ "{route} not in FRRouting BGP RIB".format(route=our_route.IPNetwork) + # take the first one as ExaBGP sends only one route per prefix + rib_route = rib_routes[str_ipnet][0] + assert rib_route["origin"].lower() == our_route['origin'].val, \ "Bad origin for route {route}. Expected {origin_expect}. Received {origin_real}" \ .format(route=our_route.IPNetwork, origin_expect=our_route['origin'].val, @@ -173,8 +214,10 @@ def test_example_exabgp(topo_test, frr_bgp_node): net = IPNet(topo=topo_test) try: net.start() - # Must wait 130s since ExaBGP sends routes at most 120s after the startup - time.sleep(130) + # ExaBGP sends its routes at most 2 minutes after the startup, and the + # passive session may take a while to establish under load; poll the + # RIB instead of sleeping a fixed amount. + wait_for_expected_routes(net[frr_bgp_node], rib_scripts, topo_test) check_correct_rib(net[frr_bgp_node], rib_scripts, topo_test) finally: net.stop() From a45b5489b46bf75a5e8d35b837f7d44ab34c087a Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Sun, 30 Aug 2026 18:00:54 +0300 Subject: [PATCH 32/69] test: prevent flaky tests with a shared polling helper Replace fixed wall-clock sleeps with a single wait_until() primitive in ipmininet/tests/utils.py: it polls a predicate (check-then-sleep, hard timeout, pytest.fail with a lazily-evaluated description) until an asynchronous effect lands. Refactor the existing poll helpers (assert_routing_table, assert_dns_record, assert_stp_state) onto it and fix fragile loop sentinels (while t != ... -> while t < ...). Convert remaining fixed-sleep test sites to polls: - test_exabgp: wait_for_expected_routes polls the FRR BGP RIB via wait_until; nc vty reads are bounded with -w 5. - test_ripng/test_dns: drop sleeps now redundant with polling helpers. - test_srv6: wait for tshark to start and to flush captures to disk. - test_tc: wait for the iperf3 server to listen; guard the JSON deref. - test_iptables: wait for IPv6 pings to be blocked. - test_network_capture: use NetworkCapture.wait_until_capturing instead of asserting file existence right after net.start(). - test_sshd/test_gre: rewrite hand-rolled retry loops with wait_until. - test_topologydb: write the DB to a per-test temp dir. Guard the suite against hangs: enable pytest-timeout (600s per test) via [tool.pytest.ini_options] and register the test markers. Add rootless unit tests for wait_until and document the rules in contribute.rst. --- docs/contribute.rst | 63 +++++++++++ ipmininet/tests/test_dns.py | 4 - ipmininet/tests/test_exabgp.py | 29 ++--- ipmininet/tests/test_gre.py | 27 +++-- ipmininet/tests/test_iptables.py | 21 +--- ipmininet/tests/test_network_capture.py | 21 ++-- ipmininet/tests/test_pure.py | 118 ++++++++++++++++++- ipmininet/tests/test_ripng.py | 3 - ipmininet/tests/test_srv6.py | 144 +++++++++++++++++++++--- ipmininet/tests/test_sshd.py | 12 +- ipmininet/tests/test_tc.py | 14 ++- ipmininet/tests/test_topologydb.py | 8 +- ipmininet/tests/utils.py | 107 ++++++++++++------ pyproject.toml | 12 +- scripts/run-tests-parallel.sh | 33 +++++- uv.lock | 14 +++ 16 files changed, 503 insertions(+), 127 deletions(-) diff --git a/docs/contribute.rst b/docs/contribute.rst index b2b66677..27c45673 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -195,6 +195,69 @@ as a non-root user, or a missing routing daemon). See the "Test-skip policy" comment at the top of ``ipmininet/tests/__init__.py`` for the full matrix and reasons; skipped tests and their reasons are shown with ``pytest -rs``. +Writing reliable tests +---------------------- + +The test suite runs end-to-end against real daemons (FRRouting, named, +ExaBGP, ...) in parallel CI workers, so asynchronous effects (daemon startup, +routing convergence, DNS zone transfer, packet capture) are subject to load. +Follow these rules to keep the suite deterministic: + +- **Never wait a fixed wall-clock amount of time for an asynchronous + effect.** A fixed ``time.sleep()`` is either too short (flaky under load) + or too long (slow for no reason). Instead, poll for the *observable + effect* until it appears, with a hard timeout, using the + ``wait_until(predicate, timeout=..., interval=..., description=...)`` + helper in ``ipmininet/tests/utils.py`` (check-then-sleep, fails the test + with a descriptive error on timeout). The ``description`` may be a callable + so the failure can include the last observed state. +- **Reuse the existing poll helpers** built on ``wait_until`` rather than + hand-rolling loops: ``assert_routing_table``, ``assert_dns_record``, + ``assert_stp_state``, ``assert_connectivity``, + ``check_tcp_connectivity``, ``traceroute``/``assert_path``. +- **Assert before you index.** Check that a key/row exists *before* + dereferencing it; otherwise a not-ready state surfaces as a bare + ``KeyError``/``IndexError`` instead of the intended assertion message. +- **Bound every poll loop.** Never use ``while t != ...`` as a loop sentinel + (a skipped increment runs forever); prefer ``while t < bound`` or + ``wait_until``. +- **Wait for the effect's own notification, not for a proxy of it.** A + process being alive (or a file existing) does not mean the effect has + landed. This is subtle: a freshly started ``tshark`` prints "Capturing + on ..." *before* the capture is functionally live, so even that message is + a proxy. The reliable notification that a capture is live is its output + file growing past the header (packets are being written); wait for that by + probing until every node's capture has grown, then send the measurement and + stop the capture with ``SIGINT`` (which flushes) before asserting in the + read-back that the measurement actually landed. Beware of nodes that never + see the measurement (off-path hosts): never require them to record it. + When the measurement has to be spread over several probes, the analysis + must group the observations per probe (they are well apart in time) or the + path repeats once per probe. +- **Probes must not perturb the thing under test.** A readiness probe can + consume the very resource being measured (e.g. ``nc -z`` consumes the single + connection that an ``iperf3 --one-off`` server accepts, so the real client + gets nothing). Prefer a passive observation such as ``ss -ltn``. +- **Size poll timeouts to the protocol's worst case, not the median.** If a + daemon is documented to delay its actions by up to N seconds, the poll + timeout must exceed N. Raise the per-test ceiling with + ``@pytest.mark.timeout(...)`` where needed. +- **Avoid shared hard-coded paths and ports.** Prefer per-test temporary + files over fixed ``/tmp/`` paths that leak across parametrized cases. +- **Let timeouts fail fast.** ``pytest-timeout`` is configured with a + 600-second per-test limit, so a stuck test fails instead of eating the CI + budget; keep the ``timeout`` of poll helpers comfortably below it and only + raise the per-test limit with ``@pytest.mark.timeout(...)`` for the rare + test whose protocol requires more. +- **Schedule long-waiting tests first.** ``scripts/run-tests-parallel.sh`` + lists the slow modules first and runs under ``--dist=loadscope`` (one module + per worker at a time), so a module that mostly *waits* overlaps with the + CPU-bound work of the modules behind it. A waiting test is cheap to + co-schedule, so order by waiting time, not by CPU time. + +The full flaky-pattern catalog used by reviewers is in +``.tmp/FLAKY_REVIEW.md`` (local scratch, not committed). + Building the documentation -------------------------- diff --git a/ipmininet/tests/test_dns.py b/ipmininet/tests/test_dns.py index 9b3357ad..964399e2 100644 --- a/ipmininet/tests/test_dns.py +++ b/ipmininet/tests/test_dns.py @@ -1,6 +1,4 @@ """This module tests the Named daemon""" -import time - import pytest from ipmininet.clean import cleanup @@ -113,7 +111,6 @@ def test_dns_network(named_cfg, zone_args, exp_named_cfg, exp_zone_cfg): for node in [net["master"], net["slave"]]: for record in records: assert_dns_record(node, "localhost", record) - time.sleep(10) net.stop() finally: @@ -163,7 +160,6 @@ def test_zone_delegation(): for node in nodes: for record in zone_records: assert_dns_record(node, "localhost", record) - time.sleep(10) net.stop() finally: diff --git a/ipmininet/tests/test_exabgp.py b/ipmininet/tests/test_exabgp.py index f24cdad3..0c9cab74 100644 --- a/ipmininet/tests/test_exabgp.py +++ b/ipmininet/tests/test_exabgp.py @@ -1,7 +1,6 @@ import json import os import re -import time from contextlib import closing from ipaddress import IPv4Interface, IPv6Interface from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address @@ -14,6 +13,7 @@ from ipmininet.ipnet import IPNet from ipmininet.router.config import BGPRoute, BGPAttribute, ExaList from ipmininet.tests import require_root, require_exabgp +from ipmininet.tests.utils import wait_until exa_routes = { 'ipv4': [ @@ -102,18 +102,21 @@ def get_rib_routes(node, command, family): return json.loads(m.group("rib")).get("routes") -def wait_for_expected_routes(node, rib_scripts, topo, timeout=300, poll=5): +def wait_for_expected_routes(node, rib_scripts, topo, timeout=540, poll=5): """Wait until all the routes ExaBGP is expected to inject appear in the FRRouting BGP RIB. ExaBGP is configured to only send its routes after a delay (2 minutes by default), and the passive BGP session it uses may take a while to establish. A fixed sleep is therefore unreliable under load; poll the RIB - instead. + instead. The default timeout accounts for that delay plus the slow + delivery of the routes under a loaded CI runner. """ expected_routes = topo.routes - elapsed = 0 - while elapsed < timeout: + missing = [] + + def _all_routes_present(): + nonlocal missing missing = [] for command, family in rib_scripts: rib_routes = get_rib_routes(node, command, family) @@ -123,14 +126,13 @@ def wait_for_expected_routes(node, rib_scripts, topo, timeout=300, poll=5): for route in expected_routes[family]: if str(route.IPNetwork) not in rib_routes: missing.append((family, str(route.IPNetwork))) - if not missing: - return - time.sleep(poll) - elapsed += poll - pytest.fail( - "The routes injected by ExaBGP did not appear in the BGP RIB within " - "{timeout}s. Missing: {missing}".format(timeout=timeout, missing=missing) - ) + return not missing + + wait_until( + _all_routes_present, timeout=timeout, interval=poll, + description=lambda: "the routes injected by ExaBGP to appear in the " + "BGP RIB within %ds. Missing: %s" + % (timeout, missing)) def check_correct_rib(node, rib_scripts, topo): @@ -199,6 +201,7 @@ def check_as_path(as_path_rib: str, as_path_us: ExaList): @require_root @require_exabgp +@pytest.mark.timeout(1200) @pytest.mark.parametrize('topo_test,frr_bgp_node', [ (ExaBGPTopoInjectPrefixes(routes=exa_routes), 'as2'), # default IPs, custom routes, (ExaBGPTopoInjectPrefixes(), 'as2'), # default IPs, random routes diff --git a/ipmininet/tests/test_gre.py b/ipmininet/tests/test_gre.py index a11a7fed..6fdb74be 100644 --- a/ipmininet/tests/test_gre.py +++ b/ipmininet/tests/test_gre.py @@ -1,10 +1,9 @@ """This module tests GRE tunnels""" -import time - from ipmininet.clean import cleanup from ipmininet.examples.gre import GRETopo from ipmininet.ipnet import IPNet from . import require_root +from .utils import wait_until @require_root @@ -13,16 +12,22 @@ def test_gre_example(): net = IPNet(topo=GRETopo(), use_v6=False) net.start() - t = 0 cmd = "ping -W 1 -c 1 -I 10.0.1.1 10.0.1.2".split(" ") - while t < 60 and net["h1"].popen(cmd).wait() != 0: - t += 1 - time.sleep(.5) - p = net["h1"].popen(cmd) - code = p.wait() - out, err = p.communicate() - assert code == 0, "Cannot use GRE tunnel.\nThe command '%s' printed:" \ - " [stdout]\n%s\n[stderr]\n%s" % (cmd, out, err) + last_out = "" + last_err = "" + + def _gre_ping_ok(): + nonlocal last_out, last_err + p = net["h1"].popen(cmd) + code = p.wait() + last_out, last_err = p.communicate() + return code == 0 + + wait_until( + _gre_ping_ok, timeout=60, interval=0.5, + description=lambda: "the GRE tunnel from %s to 10.0.1.2 to be " + "usable\n[stdout]\n%s\n[stderr]\n%s" + % (net["h1"], last_out, last_err)) net.stop() finally: diff --git a/ipmininet/tests/test_iptables.py b/ipmininet/tests/test_iptables.py index 8e2dfb85..cc74a59b 100644 --- a/ipmininet/tests/test_iptables.py +++ b/ipmininet/tests/test_iptables.py @@ -1,17 +1,13 @@ """This module tests iptables""" -import time - from ipmininet.clean import cleanup from ipmininet.examples.iptables import IPTablesTopo from ipmininet.ipnet import IPNet -from ipmininet.tests.utils import check_tcp_connectivity +from ipmininet.tests.utils import check_tcp_connectivity, wait_until from . import require_root @require_root def test_iptables_example(): - attempts = 10 - try: net = IPNet(topo=IPTablesTopo()) net.start() @@ -26,16 +22,11 @@ def test_iptables_example(): ip6 = net["r2"].intf("r2-eth0").ip6 cmd = "ping6 -W 1 -c 1 %s" % ip6 - chk = False - - for _ in range(attempts): - time.sleep(5) - p = net["r1"].popen(cmd.split(" ")) - chk = p.wait() != 0 - if chk: - break - - assert chk, "Pings over IPv6 should be blocked" + wait_until( + lambda: net["r1"].popen(cmd.split(" ")).wait() != 0, + timeout=50, interval=5, + description="IPv6 pings from %s to %s to be blocked" + % (net["r1"], ip6)) ret, _, _ = check_tcp_connectivity(net["r1"], net["r2"], server_port=80, diff --git a/ipmininet/tests/test_network_capture.py b/ipmininet/tests/test_network_capture.py index 63e9b109..8b767cfd 100644 --- a/ipmininet/tests/test_network_capture.py +++ b/ipmininet/tests/test_network_capture.py @@ -1,6 +1,4 @@ """"This module test the Link Failure API""" -import os - from ipmininet.clean import cleanup from ipmininet.ipnet import IPNet from ipmininet.overlay import NetworkCapture @@ -12,21 +10,18 @@ @require_mimidump @require_root def test_network_capture_example(): - capture_files = ["/tmp/capture_r1.pcapng", "/tmp/capture_r2-eth0.pcapng", - "/tmp/capture_s1.pcapng", "/tmp/capture_s2-eth1.pcapng"] try: - # Delete pre-existing capture files - for file_name in capture_files: - if os.path.exists(file_name): - os.unlink(file_name) - net = IPNet(topo=NetworkCaptureTopo()) net.start() + overlay = next(o for o in net.topo.overlays + if isinstance(o, NetworkCapture)) - # Check files existence - for file_name in capture_files: - assert os.path.exists(file_name), \ - f"The capture file {file_name} was not created" + # Capture readiness is asynchronous; wait for it instead of asserting + # file existence immediately after net.start(). + assert overlay.wait_until_capturing("r2-eth0", timeout=10) + assert overlay.wait_until_capturing("s2-eth1", timeout=10) + assert overlay.wait_until_capturing("r1", timeout=10) + assert overlay.wait_until_capturing("s1", timeout=10) # Check example connectivity assert_connectivity(net, v6=False) diff --git a/ipmininet/tests/test_pure.py b/ipmininet/tests/test_pure.py index 24deb786..b0a47a0d 100644 --- a/ipmininet/tests/test_pure.py +++ b/ipmininet/tests/test_pure.py @@ -5,15 +5,129 @@ in a rootless CI job. Tests that require root are marked with `require_root` in the other test modules. """ -from ipaddress import ip_network -from ipaddress import IPv4Network, IPv6Network +import time +from ipaddress import IPv4Network, IPv6Network, ip_network import pytest from ipmininet.link import _parse_addresses from ipmininet.router.config.utils import ConfigDict, ip_statement +from ipmininet.tests.test_srv6 import _infer_sub_paths +from ipmininet.tests.utils import wait_until from ipmininet.utils import get_set, is_container, is_subnet_of +_EXPECTED_CALLS = 3 +_MAX_SETUP_WAIT = 0.1 + + +class TestWaitUntil: + """Tests for the anti-flaky polling helper.""" + + def test_immediate_success(self): + assert wait_until(lambda: True, timeout=1) is True + + def test_success_after_several_calls(self): + calls = {"n": 0} + + def _eventually(): + calls["n"] += 1 + return calls["n"] >= _EXPECTED_CALLS + + assert wait_until(_eventually, timeout=1, interval=0.01) is True + assert calls["n"] == _EXPECTED_CALLS + + def test_timeout_fails(self): + with pytest.raises(pytest.fail.Exception, + match="Timed out after 0s while waiting for the " + "condition"): + wait_until(lambda: False, timeout=0.05, interval=0.01) + + def test_timeout_evaluates_callable_description(self): + with pytest.raises(pytest.fail.Exception, match="last observed state: " + "5"): + wait_until(lambda: False, timeout=0.05, interval=0.01, + description=lambda: "last observed state: 5") + + def test_success_does_not_wait(self): + # Check-then-sleep: an immediate success must return without sleeping. + start = time.monotonic() + wait_until(lambda: True, timeout=1, interval=0.5) + assert time.monotonic() - start < _MAX_SETUP_WAIT + + def test_callable_predicate_receives_no_args(self): + assert wait_until(lambda: True, timeout=1, interval=0.01) is True + + +class TestInferSubPaths: + """Tests for the SRv6 capture-path inference (burst-of-probes aware).""" + + _NODES = ["h6", "r6", "r5", "r4", "h4"] + _DEST = "fc00:0:d::1" + _BURST = [0.3 + k for k in range(20)] + + @staticmethod + def _burst_events(gaps, probes): + """Build packet_received for a burst of probes, mirroring sr_path: a + node that is live when probe k is sent records it.""" + events = {TestInferSubPaths._DEST: []} + for pos, node in enumerate(TestInferSubPaths._NODES): + for probe_time in probes: + if probe_time >= gaps.get(node, 0.0): + events[TestInferSubPaths._DEST].append( + (probe_time + 0.005 * pos, node)) + return events + + def test_single_probe(self): + packet_received = self._burst_events({}, [0.3]) + assert _infer_sub_paths(packet_received)[self._DEST] == self._NODES + + def test_burst_preserves_path_order(self): + # Every node live from the first probe: all probes are recorded on + # every node, and the path must not repeat once per probe. + packet_received = self._burst_events({}, self._BURST) + assert _infer_sub_paths(packet_received)[self._DEST] == self._NODES + + def test_burst_with_staggered_liveness(self): + # Nodes become live at different times (the CI failure case: early + # probes are lost on the slow nodes); the path must still be recovered + # from a probe that every node eventually saw. + gaps = {"h6": 1.0, "r6": 2.0, "r5": 3.0, "r4": 4.0, "h4": 5.0} + packet_received = self._burst_events(gaps, self._BURST) + assert _infer_sub_paths(packet_received)[self._DEST] == self._NODES + + def test_last_probe_missed_on_every_node(self): + packet_received = self._burst_events({}, self._BURST) + packet_received[self._DEST] = [ + (t, n) for t, n in packet_received[self._DEST] + if t < self._BURST[-1] + ] + assert _infer_sub_paths(packet_received)[self._DEST] == self._NODES + + def test_double_capture_on_router(self): + # A router captures a forwarded packet once per interface, so a probe + # can appear twice on one node; the path must still be recovered. + packet_received = {self._DEST: [ + (0.300, "h6"), (0.3005, "r6"), (0.3010, "r6"), (0.302, "r5"), + (0.303, "r4"), (0.305, "h4"), + (0.800, "h6"), (0.8005, "r6"), (0.8010, "r6"), (0.802, "r5"), + (0.803, "r4"), (0.805, "h4"), + ]} + assert _infer_sub_paths(packet_received)[self._DEST] == self._NODES + + def test_probes_not_split_at_half_second_boundary(self): + # Probes must be grouped by time gap, not by rounding: a probe at + # .648 and the next one +0.5s later (.156) both round to the same + # integer and must not be merged into one fake probe. + packet_received = {self._DEST: [ + (176.648, "h6"), (176.6485, "r6"), (176.649, "r5"), (176.650, + "r4"), + (176.651, "h4"), + (177.156, "h6"), (177.1565, "r6"), (177.157, "r5"), (177.158, + "r4"), + (177.159, "h4"), + ]} + assert _infer_sub_paths(packet_received)[self._DEST] == self._NODES + class TestParseAddresses: """Tests for the pure `ip address` output parser.""" diff --git a/ipmininet/tests/test_ripng.py b/ipmininet/tests/test_ripng.py index 4079cfbf..05123152 100644 --- a/ipmininet/tests/test_ripng.py +++ b/ipmininet/tests/test_ripng.py @@ -1,6 +1,4 @@ """This module tests the RIPng daemon""" -import time - import pytest from ipmininet.clean import cleanup @@ -177,7 +175,6 @@ def test_ripng_flush_routing_tables(): try: net = IPNet(topo=MinimalRIPngNet(is_test_flush=True)) net.start() - time.sleep(10) routing_tables = { "r1": ["2042:22::/64", "2042:33::/64", "2042:23::/64"], diff --git a/ipmininet/tests/test_srv6.py b/ipmininet/tests/test_srv6.py index 28e6ca04..d223d75b 100644 --- a/ipmininet/tests/test_srv6.py +++ b/ipmininet/tests/test_srv6.py @@ -1,3 +1,5 @@ +import os +import select import shlex import signal import time @@ -13,7 +15,7 @@ SRv6EndTFunction, SRv6EndDX6Function, SRv6Encap, SRv6EndDT6Function, \ SRv6EndB6EncapsFunction from ipmininet.tests import require_root -from ipmininet.tests.utils import assert_connectivity, assert_path +from ipmininet.tests.utils import assert_connectivity, assert_path, wait_until from ipmininet.utils import require_cmd MAIN_TABLE = 254 @@ -51,6 +53,74 @@ def post_build(self, net): raise e +def _tshark_capturing(tsharks: list, stderr_buf: dict) -> bool: + """Return whether every tshark announced it is capturing. + + tshark prints "Capturing on " to stderr once the capture is + live. Waiting on that notification is more reliable than checking that the + process is merely alive: a just-started tshark may not have attached to the + interface yet, and the measurement ping would then be missed. + """ + for p in tsharks: + if p.poll() is not None: + return False + try: + ready, _, _ = select.select([p.stderr], [], [], 0) + if ready: + stderr_buf[p] = stderr_buf.get(p, "") + os.read( + p.stderr.fileno(), 4096).decode(errors="ignore") + except OSError: + return False + if "Capturing on" not in stderr_buf.get(p, ""): + return False + return True + + +def _infer_sub_paths(packet_received: dict[str, list[tuple[float, str]]]) \ + -> dict[str, list[str]]: + """Return, for each observed destination, the ordered nodes on its path. + + The measurement ping is spread over a burst of probes because tshark only + becomes live a moment after announcing "Capturing on ...": the probes sent + before that are lost, and every node that is live records every later + probe (a router records a forwarded packet once per interface). A + destination therefore appears once per probe on each node, and ordering + the events naively repeats the path once per probe. Group the events per + probe instead: a single probe is captured within a few milliseconds on + every node along the path, while probes are sent well over 0.25s apart. + Use the latest probe that was seen on every node: it is a single, complete + traversal, ordered by capture time. + """ + sub_paths = {} # type: Dict[str, List[str]] + for dest, events in packet_received.items(): + ordered = sorted(events) + probes = [] # type: List[List[Tuple[float, str]]] + for capture_time, node in ordered: + if probes and capture_time - probes[-1][-1][0] < 0.25: + probes[-1].append((capture_time, node)) + else: + probes.append([(capture_time, node)]) + node_count = len({node for _, node in ordered}) + complete = [group for group in probes + if len({node for _, node in group}) == node_count] + if complete: + chosen = max(complete, key=lambda group: group[-1][0]) + nodes = [] # type: List[str] + for _, node in chosen: + if not nodes or nodes[-1] != node: + nodes.append(node) + sub_paths[dest] = nodes + else: + # No single probe was seen on every node: fall back to ordering + # the nodes by their latest observation. + latest = {} # type: Dict[str, float] + for capture_time, node in ordered: + latest[node] = max(latest.get(node, 0.0), capture_time) + sub_paths[dest] = [node for node, _ in + sorted(latest.items(), key=lambda kv: kv[1])] + return sub_paths + + def sr_path(net: IPNet, src: str, dst_ip: str, timeout=1, through=()) \ -> List[str]: require_cmd("tshark", help_str="tshark is required to run tests") @@ -62,27 +132,56 @@ def sr_path(net: IPNet, src: str, dst_ip: str, timeout=1, through=()) \ if ", 0% packet loss" not in out: return [] - # Start captures of SYN packets to port 80 + # Start captures of the IPv6 traffic on every node tsharks = [] + stderr_buf = {} + nodes = net.routers + net.hosts try: - for n in net.routers + net.hosts: + for n in nodes: p = n.popen(shlex.split("tshark -n -i any -f 'ip6'" " -w /tmp/{}.pcap".format(n.name))) tsharks.append(p) - time.sleep(15) # Wait for tshark to start + # Wait for tshark to announce it started capturing. A freshly started + # tshark only becomes functionally live a moment *after* printing this + # message, so we then wait for each capture to record packets. + wait_until(lambda: _tshark_capturing(tsharks, stderr_buf), + timeout=30, interval=0.2, + description="tshark to start capturing on every node") + + probe_cmd = f"ping -6 -c 1 -W {int(timeout)} {dst_ip}" - # Launch ping - out = net[src].cmd(shlex.split(ping_cmd)) + def _pcap_size(node): + path = "/tmp/{}.pcap".format(node.name) + return os.path.getsize(path) if os.path.isfile(path) else 0 + + # Phase 1: a capture is live once its file grew past the header + # written at start. Probe until every capture is live; the + # announcement alone is not a reliable signal under load. + baseline = {n.name: _pcap_size(n) for n in nodes} + deadline = time.monotonic() + 20 + while not all(_pcap_size(n) > baseline[n.name] for n in nodes) \ + and time.monotonic() < deadline: + out = net[src].cmd(shlex.split(probe_cmd)) + assert "100% packet loss" not in out, \ + f"Connectivity from {src} to {dst_ip} is not ensured, " \ + "so we cannot infer the path." + time.sleep(0.5) + + # Phase 2: every capture is live; send the single measurement probe. + # Nodes off the measurement path never see it, so we cannot wait for + # every capture to record it: stopping with SIGINT flushes whatever + # was captured, and the read-back assert below is the confirmation + # that the measurement actually landed on the path. + out = net[src].cmd(shlex.split(probe_cmd)) assert "100% packet loss" not in out, \ - "Connectivity from %s to %s is not ensured," \ - " so we cannot infer the path." % (src, dst_ip) - time.sleep(1) # Wait for tshark to register the info + f"Connectivity from {src} to {dst_ip} is not ensured, " \ + "so we cannot infer the path." for p in tsharks: assert p.poll() is None, "tshark stopped unexpectedly:" \ "stderr '{}'".format(p.stderr.read()) finally: - # Stop captures + # Stop captures; SIGINT makes tshark flush the captured packets to disk for p in tsharks: p.send_signal(signal.SIGINT) p.wait() @@ -106,6 +205,14 @@ def sr_path(net: IPNet, src: str, dst_ip: str, timeout=1, through=()) \ captures.setdefault(n.name, []) \ .append((float(data_time), data_dst)) + # Verify the measurement actually landed in the captures: this is the + # notification that tshark was capturing when the probes were sent. + captured_dests = {dest for _, packets in captures.items() + for _, dest in packets} + assert dst_ip in captured_dests, \ + f"No capture recorded the ping to {dst_ip} (captured destinations: " \ + f"{sorted(captured_dests)}). tshark was probably not capturing yet." + # Analyze results packet_received = {} # type: Dict[str, List[Tuple[float, str]]] @@ -113,14 +220,7 @@ def sr_path(net: IPNet, src: str, dst_ip: str, timeout=1, through=()) \ for data_time, destination in packets: packet_received.setdefault(destination, []).append((data_time, n)) - sub_paths = {} # type: Dict[str, List[str]] - for dest in packet_received: - ordered_reception = sorted(packet_received[dest]) - - sub_paths[dest] = [] - for _, n in ordered_reception: - if len(sub_paths[dest]) == 0 or sub_paths[dest][-1] != n: - sub_paths[dest].append(n) + sub_paths = _infer_sub_paths(packet_received) # Order sub paths with the trough list path = [] # type: List[str] @@ -151,6 +251,14 @@ def sr_path(net: IPNet, src: str, dst_ip: str, timeout=1, through=()) \ # Remove source to get a similar output to traceroute path = [net[n].intf().ip6 for n in compressed_path][1:] + + # Clean up the per-node captures so no stale file lingers between the + # parametrized cases of this module. + for n in net.routers + net.hosts: + pcap_path = "/tmp/{}.pcap".format(n.name) + if os.path.isfile(pcap_path): + os.unlink(pcap_path) + return path diff --git a/ipmininet/tests/test_sshd.py b/ipmininet/tests/test_sshd.py index 7c53aba6..55858e14 100644 --- a/ipmininet/tests/test_sshd.py +++ b/ipmininet/tests/test_sshd.py @@ -1,11 +1,11 @@ """This module tests the SSH daemon""" import os -import time from ipmininet.clean import cleanup from ipmininet.examples.sshd import SSHTopo from ipmininet.ipnet import IPNet from . import require_root +from .utils import wait_until @require_root @@ -26,12 +26,10 @@ def test_sshd_example(): ip = net["r2"].intf("r2-eth0").ip cmd = "ssh -oStrictHostKeyChecking=no -oConnectTimeout=1" \ " -oPasswordAuthentication=no -i %s %s ls" % (ssh_key, ip) - t = 0 - while t < 60 and net["r1"].popen(cmd.split(" ")).wait() != 0: - time.sleep(0.5) - t += 1 - p = net["r1"].popen(cmd.split(" ")) - assert p.wait() == 0, "Cannot connect with SSH to the router" + wait_until( + lambda: net["r1"].popen(cmd.split(" ")).wait() == 0, + timeout=60, interval=0.5, + description="SSH from %s to %s to succeed" % (net["r1"], ip)) net.stop() finally: diff --git a/ipmininet/tests/test_tc.py b/ipmininet/tests/test_tc.py index 641dfde7..86cb9d3e 100644 --- a/ipmininet/tests/test_tc.py +++ b/ipmininet/tests/test_tc.py @@ -2,7 +2,6 @@ import json import re import subprocess -import time import pytest @@ -11,7 +10,7 @@ from ipmininet.examples.tc_network import TCNet from ipmininet.ipnet import IPNet from ipmininet.router import IPNode -from ipmininet.tests.utils import assert_connectivity +from ipmininet.tests.utils import assert_connectivity, wait_until from . import require_root from .utils import require_cmd @@ -49,8 +48,15 @@ def assert_bw(src: IPNode, dst: IPNode, bw_target: float, tolerance=1, require_cmd("iperf3", help_str="iperf3 is required to run tests") iperf = dst.popen("iperf3 -s -J --one-off", universal_newlines=True) - time.sleep(1) dst_ip = dst.intf().ip6 if v6 else dst.intf().ip + # Wait for the server to be listening. We must not probe the port (e.g. + # with nc), since iperf3 --one-off accepts a single connection and the + # probe would consume it, leaving nothing for the client below. + wait_until( + lambda: ":5201" in dst.cmd("ss -ltn"), + timeout=30, interval=0.2, + description="iperf3 server on {ip} to start listening" + .format(ip=dst_ip)) src.popen("iperf3 -c {}".format(dst_ip), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) out, err = iperf.communicate() @@ -60,6 +66,8 @@ def assert_bw(src: IPNode, dst: IPNode, bw_target: float, tolerance=1, bws = [] data = json.loads(out) + assert "intervals" in data, "No intervals in the iperf3 output:\n{}" \ + .format(out) for sample in data["intervals"]: bw = int(sample["sum"]["bits_per_second"]) / 10 ** 6 if bw_target - tolerance <= bw <= bw_target + tolerance: diff --git a/ipmininet/tests/test_topologydb.py b/ipmininet/tests/test_topologydb.py index d5c5d5ff..17b1c839 100644 --- a/ipmininet/tests/test_topologydb.py +++ b/ipmininet/tests/test_topologydb.py @@ -1,6 +1,8 @@ """This module tests the TopologyDB class""" import itertools import os +import shutil +import tempfile from typing import Type import pytest @@ -27,12 +29,11 @@ ]) def test_topologydb(topology: Type[IPTopo]): net = IPNet(topo=topology()) + db_dir = tempfile.mkdtemp() try: db = TopologyDB(net=net) - db_path = "/tmp/.test_topologydb.json" - if os.path.exists(db_path): - os.unlink(db_path) + db_path = os.path.join(db_dir, "topology.json") db.save(db_path) assert os.path.exists(db_path), \ @@ -107,3 +108,4 @@ def test_topologydb(topology: Type[IPTopo]): finally: net.stop() + shutil.rmtree(db_dir, ignore_errors=True) diff --git a/ipmininet/tests/utils.py b/ipmininet/tests/utils.py index 7d156400..b4b9da53 100644 --- a/ipmininet/tests/utils.py +++ b/ipmininet/tests/utils.py @@ -2,6 +2,7 @@ import re import signal import time +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from typing import List, Tuple, Dict, Pattern, Match, Optional @@ -15,6 +16,41 @@ from ipmininet.host.config.named import DNSRecord +def wait_until(predicate: Callable[[], bool], timeout: float = 60, + interval: float = 0.5, description=None): + """Wait until ``predicate()`` returns a truthy value. + + Polls every ``interval`` seconds up to ``timeout`` seconds and returns + ``True`` as soon as the predicate succeeds. Fails the test with a + descriptive error if the timeout is reached. + + Use this instead of a fixed ``time.sleep()`` whenever the test waits for + an asynchronous effect (daemon startup, routing convergence, packet + flush, ...): a fixed sleep is either too short (flaky under load) or too + long (slow for no reason). + + :param predicate: Zero-argument callable returning whether the awaited + condition is satisfied. + :param timeout: Maximum time, in seconds, to wait for the condition. + :param interval: Time, in seconds, between two polls. + :param description: Human-readable description of the awaited condition. + May be a callable returning a string, evaluated only + on timeout so it can include the last observed state. + """ + if description is None: + description = "the condition" + elapsed = 0 + while elapsed < timeout: + if predicate(): + return True + time.sleep(interval) + elapsed += interval + if callable(description): + description = description() + pytest.fail("Timed out after %ds while waiting for %s" + % (int(elapsed), description)) + + def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300, poll=1.0) -> List[str]: require_cmd("traceroute", help_str="traceroute is required to run tests") @@ -23,7 +59,7 @@ def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300, old_path_ips = [] # type: List[str] same_path_count = 0 white_space = re.compile(r" +") - while t != timeout / poll: + while t < timeout / poll: out = net[src].cmd(["traceroute", "-w", "0.05", "-q", "1", "-n", "-m", len(net.routers) + len(net.hosts), dst_ip]) lines = out.split("\n")[1:-1] @@ -140,7 +176,7 @@ def assert_connectivity(net: IPNet, v6=False, attempts=1500, translate_address=True): t = 0 connected = False - while t != attempts and not connected: + while t < attempts and not connected: connected = host_connected(net, v6=v6, translate_address=translate_address) if not connected: @@ -165,7 +201,7 @@ def check_tcp_connectivity(client: IPNode, server: IPNode, v6=False, client_cmd = "nc -z -w 1 -v %s %d" % (server_ip, server_port) client_p = client.popen(client_cmd.split(" ")) - while t != timeout * 2 and client_p.wait() != 0: + while t < timeout * 2 and client_p.wait() != 0: t += 1 if server_p.poll() is not None: out, err = server_p.communicate() @@ -197,18 +233,17 @@ def assert_stp_state(switch: IPSwitch, expected_states: Dict[str, str], # In these states the STP has not converged ignore_state = "listening", "learning" cmd = ("%s %s" % (partial_cmd, switch.name)) - out = switch.cmd(cmd) - states = re.findall(possible_states, out) - # wait for the ports to be bounded - count = 0 - while any(item in states for item in ignore_state): - if count == timeout: - pytest.fail("Timeout of %d seconds while waiting for the spanning" - " tree to be computed" % timeout) - time.sleep(1) - count += 1 + out = None + states = None + + def _converged(): + nonlocal out, states out = switch.cmd(cmd) states = re.findall(possible_states, out) + return not any(item in states for item in ignore_state) + + wait_until(_converged, timeout=timeout, interval=1, + description="the spanning tree to be computed") interfaces = re.findall(switch.name + r"-eth[0-9]+", out) state_map = {interfaces[i]: states[i] for i in range(len(states))} @@ -236,21 +271,21 @@ def assert_routing_table(router: IPNode, expected_prefixes: List[str], """ expected = set(expected_prefixes) cmd = "ip -%d route" % ip_network(str(next(iter(expected)))).version - t = 0 - while True: + found = set() + + def _satisfied(): + nonlocal found found = set(re.findall(r"|".join(re.escape(p) for p in expected), router.cmd(cmd))) - satisfied = (expected <= found) if present else not (expected & found) - if satisfied: - return - if t >= timeout: - pytest.fail("Expected prefixes %s to be %s in the routing table " - "of %s within %ds (found: %s)" - % (expected_prefixes, - "present" if present else "absent", - router.name, timeout, sorted(found))) - time.sleep(1) - t += 1 + return (expected <= found) if present else not (expected & found) + + wait_until( + _satisfied, timeout=timeout, interval=1, + description=lambda: "prefixes %s to be %s in the routing table " + "of %s within %ds (found: %s)" + % (expected_prefixes, + "present" if present else "absent", + router.name, timeout, sorted(found))) def search_dns_reply(reply: str, regex: Pattern) \ @@ -282,14 +317,22 @@ def assert_dns_record(node: IPNode, dns_server_address: str, record: DNSRecord, name=record.domain_name, rdata=record.rdata)) - t = 0 - out = node.cmd(server_cmd.split(" ")) - got_answer, match = search_dns_reply(out, out_regex) - while t < timeout * 2 and match is None: - t += 1 - time.sleep(.5) + out = None + got_answer = False + match = None + + def _answered(): + nonlocal out, got_answer, match out = node.cmd(server_cmd.split(" ")) got_answer, match = search_dns_reply(out, out_regex) + return match is not None + + wait_until( + _answered, timeout=timeout, interval=0.5, + description=lambda: "the expected data '%s' to be found in the DNS " + "reply of '%s' received by %s from %s:\n%s" + % (out_regex.pattern, server_cmd, node.name, + dns_server_address, out)) assert got_answer, "No answer was received in %s" \ " from server %s in the reply of '%s':\n%s" \ diff --git a/pyproject.toml b/pyproject.toml index d421529a..3d4f4dce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ text = "GPLv2" Homepage = "https://github.com/cnp3/ipmininet" [project.optional-dependencies] -testing = [ "pytest", "pytest-xdist",] +testing = [ "pytest", "pytest-timeout", "pytest-xdist",] [dependency-groups] docs = [ "sphinx", "sphinx_rtd_theme", "sphinxcontrib-apidoc", "sphinx-mdinclude",] @@ -34,6 +34,16 @@ docs = [ "sphinx", "sphinx_rtd_theme", "sphinxcontrib-apidoc", "sphinx-mdinclude [tool.aliases] test = "pytest" +[tool.pytest.ini_options] +timeout = 600 +timeout_method = "signal" +markers = [ + "require_root: test requires root privileges", + "require_exabgp: test requires a working ExaBGP daemon", + "require_openr: test requires the OpenR daemon", + "require_mimidump: test requires the mimidump capture tool", +] + [tool.setuptools] include-package-data = true diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index ca70873a..43ec6345 100755 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -20,7 +20,36 @@ if [ -z "$N" ]; then N="$(nproc)" fi if [ ${#ARGS[@]} -eq 0 ]; then - ARGS=(ipmininet/tests/) + # Schedule the slow modules first so that their waits overlap with the + # fast modules under --dist=loadscope (one module per worker at a time). + # ExaBGP must come first: its 9-minute worst-case wait dwarfs everything + # else, so start it before any other module claims a worker. + ARGS=( + ipmininet/tests/test_exabgp.py + ipmininet/tests/test_srv6.py + ipmininet/tests/test_tc.py + ipmininet/tests/test_ospf6.py + ipmininet/tests/test_ospf.py + ipmininet/tests/test_ripng.py + ipmininet/tests/test_linkfailure.py + ipmininet/tests/test_switch.py + ipmininet/tests/test_static.py + ipmininet/tests/test_bgp.py + ipmininet/tests/test_dns.py + ipmininet/tests/test_radv.py + ipmininet/tests/test_link.py + ipmininet/tests/test_sshd.py + ipmininet/tests/test_gre.py + ipmininet/tests/test_iptables.py + ipmininet/tests/test_network_capture.py + ipmininet/tests/test_topologydb.py + ipmininet/tests/test_cli.py + ipmininet/tests/test_misc.py + ipmininet/tests/test_pure.py + ipmininet/tests/test_physicalinterface.py + ipmininet/tests/test_address_alllocation.py + ipmininet/tests/test_openr.py + ) fi WRAPPER="$ROOT/scripts/py-unshare.sh" @@ -33,6 +62,6 @@ echo "==> pytest-xdist workers: $N (isolated namespaces)" exec uv run python -m pytest \ -p no:cacheprovider \ -v \ - --dist=load \ + --dist=loadscope \ --tx "${N}*popen//python=${WRAPPER}" \ "${ARGS[@]}" diff --git a/uv.lock b/uv.lock index 7423233a..dff6e245 100644 --- a/uv.lock +++ b/uv.lock @@ -227,6 +227,7 @@ dependencies = [ [package.optional-dependencies] testing = [ { name = "pytest" }, + { name = "pytest-timeout" }, { name = "pytest-xdist" }, ] @@ -243,6 +244,7 @@ requires-dist = [ { name = "mako", specifier = ">=1.1,<1.5" }, { name = "mininet", git = "https://github.com/mimi-net/mininet.git" }, { name = "pytest", marker = "extra == 'testing'" }, + { name = "pytest-timeout", marker = "extra == 'testing'" }, { name = "pytest-xdist", marker = "extra == 'testing'" }, { name = "setuptools" }, ] @@ -415,6 +417,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "pytest-xdist" version = "3.8.0" From 358c1af17a200ba7fa6174ddecf046578e56d87f Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 00:18:11 +0300 Subject: [PATCH 33/69] chore: add ruff/mdformat config, pre-commit hooks and CI lint step --- .github/workflows/heavy-test.yaml | 3 +- .github/workflows/test.yaml | 9 +- .pre-commit-config.yaml | 26 ++++ pylintrc | 3 - pyproject.toml | 33 +++++ uv.lock | 211 ++++++++++++++++++++++++++++++ 6 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 .pre-commit-config.yaml delete mode 100644 pylintrc diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index f970891e..46552c6a 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -2,8 +2,7 @@ # # Performs the full `ipmininet.install -a` and runs every test except the # known-broken daemons (exabgp, openr), which are skipped in their own test -# modules. Lint/format checks are intentionally not part of this workflow -# yet; they will be added together with the reformatting PR. +# modules. Lint/format checks are run in the `rootless` job of test.yaml. name: Heavy test on: workflow_dispatch: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index bff09cfe..c475a9c9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -7,8 +7,7 @@ # - test: performs the full `ipmininet.install -a` (FRRouting, libyang, # mininet, ...) and runs the complete test suite with root privileges. # -# Lint/format checks are intentionally not part of this workflow yet; they -# will be added together with the reformatting PR. +# Lint/format checks run in the `rootless` job below, before the light tests. name: Test IPMininet on: pull_request: @@ -30,6 +29,11 @@ jobs: - name: Mininet dependency must be mimi-net fork run: grep -q 'mimi-net/mininet' pyproject.toml || (echo "mininet dependency must point to mimi-net/mininet.git fork" && exit 1) - run: uv sync --all-extras + - name: Lint and format checks + run: | + uv run ruff check . + uv run ruff format --check . + uv run mdformat --check README.md LICENSE.md ipmininet/examples/README.md - name: Run rootless tests run: | uv run python -m pytest \ @@ -37,7 +41,6 @@ jobs: ipmininet/tests/test_pure.py \ ipmininet/tests/test_misc.py \ ipmininet/tests/test_link.py - test: runs-on: ubuntu-latest timeout-minutes: 200 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..4cd79933 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +default_install_hook_types: [pre-commit, pre-push] +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.5 + hooks: + - id: ruff + name: ruff (auto-fix) + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format + name: ruff format (auto-format) + - id: ruff + name: ruff (strict check) + stages: [pre-push] + - id: ruff-format + name: ruff format (strict check) + args: [--check] + stages: [pre-push] + - repo: https://github.com/hukkin/mdformat + rev: 1.0.0 + hooks: + - id: mdformat + name: mdformat (auto-format) + - id: mdformat + name: mdformat (strict check) + args: [--check] + stages: [pre-push] diff --git a/pylintrc b/pylintrc deleted file mode 100644 index 4ccd000f..00000000 --- a/pylintrc +++ /dev/null @@ -1,3 +0,0 @@ -[MASTER] -msg-template={path}:{module}:{line}: [{msg_id}({symbol}), {obj}] {msg} -disable=invalid-name,missing-docstring,too-many-arguments,wrong-import-order,too-many-locals,too-few-public-methods,useless-object-inheritance,cyclic-import,arguments-differ,keyword-arg-before-vararg,signature-differs,too-many-instance-attributes diff --git a/pyproject.toml b/pyproject.toml index 3d4f4dce..9afd6b8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ testing = [ "pytest", "pytest-timeout", "pytest-xdist",] [dependency-groups] docs = [ "sphinx", "sphinx_rtd_theme", "sphinxcontrib-apidoc", "sphinx-mdinclude",] +dev = [ "mdformat", "pre-commit", "ruff",] [tool.aliases] test = "pytest" @@ -44,6 +45,38 @@ markers = [ "require_mimidump: test requires the mimidump capture tool", ] +[tool.ruff] +target-version = "py312" +line-length = 88 + +[tool.ruff.lint] +select = [ + "E", + "F", + "W", + "I", + "UP", + "B", + "SIM", + "C4", + "RET", + "RUF", + "PERF", + "FURB", + "FLY", + "TRY", + "BLE", + "PLC0206", + "PLW0603", + "PLW1510", + "PLW1641", + "PLW2901", +] +ignore = [ "E501", "TRY003", "PLC0415",] + +[tool.mdformat] +wrap = "keep" + [tool.setuptools] include-package-data = true diff --git a/uv.lock b/uv.lock index dff6e245..c81ddf47 100644 --- a/uv.lock +++ b/uv.lock @@ -29,6 +29,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.5.1" @@ -169,6 +178,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + [[package]] name = "docutils" version = "0.22.4" @@ -187,6 +205,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "filelock" +version = "3.32.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/30/03b03951873a1a0ffc7e8ca0e10c15597b59e8d0e39260704cd2ea087bc4/filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30", size = 222126, upload-time = "2026-08-23T17:37:55.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.19" @@ -232,6 +268,11 @@ testing = [ ] [package.dev-dependencies] +dev = [ + { name = "mdformat" }, + { name = "pre-commit" }, + { name = "ruff" }, +] docs = [ { name = "sphinx" }, { name = "sphinx-mdinclude" }, @@ -251,6 +292,11 @@ requires-dist = [ provides-extras = ["testing"] [package.metadata.requires-dev] +dev = [ + { name = "mdformat" }, + { name = "pre-commit" }, + { name = "ruff" }, +] docs = [ { name = "sphinx" }, { name = "sphinx-mdinclude" }, @@ -282,6 +328,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -345,6 +403,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdformat" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mininet" version = "2.3.1b4" @@ -362,6 +441,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -383,6 +471,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/db/61efa0d08a99f897ef98256b03e563092d36cc38dc4ebe4a85020fe40b31/pbr-7.0.3-py2.py3-none-any.whl", hash = "sha256:ff223894eb1cd271a98076b13d3badff3bb36c424074d26334cd25aebeecea6b", size = 131898, upload-time = "2025-11-03T17:04:54.875Z" }, ] +[[package]] +name = "platformdirs" +version = "4.11.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/06/cf1564dcc2e2261c8c8c6c05628dc8b418943bdae2a4e58640ceb2f770fa/platformdirs-4.11.5.tar.gz", hash = "sha256:e8b31f4f8bcbbedef91a6b57a706255e4f148d2a4e01648382a0a47342539173", size = 34823, upload-time = "2026-08-27T21:36:37.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/12/6f3fcd5067a9cbf4f8664b32957973498da8b083455203c8d9cab83a725c/platformdirs-4.11.5-py3-none-any.whl", hash = "sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b", size = 23900, upload-time = "2026-08-27T21:36:36.227Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -392,6 +489,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -442,6 +555,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-discovery" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/96/0f93e27c9f60a650838f2118159aa115fd5732c0716247917b7ba7ede665/python_discovery-1.6.0.tar.gz", hash = "sha256:6393b4eae1be8b2182670635e7baff89ac21cb9f8e86fd1ff40c7b1144febb4c", size = 82849, upload-time = "2026-08-28T17:30:02.366Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/5e/21abf578182fb15006a57faf3711a1e659e29d600d19b6e557eae908c81d/python_discovery-1.6.0-py3-none-any.whl", hash = "sha256:d4e244cf17b8b29819ed78003d55fbacf86eda23425b075454fff9271b79377a", size = 38451, upload-time = "2026-08-28T17:30:01.236Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -466,6 +637,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +] + [[package]] name = "setuptools" version = "84.0.0" @@ -628,3 +824,18 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "virtualenv" +version = "21.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/41/c3f34799487924f2a6f43b8a8b7acd345a6c61aac2211d4bced8621ca4f1/virtualenv-21.7.7.tar.gz", hash = "sha256:6874376f99ba6b8d4e3ee8bde67f9285412400c7d5b29ba41ee6daa5e0221bdc", size = 5347022, upload-time = "2026-08-28T18:59:50.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f8/dcec8dc767b812193316c7debed1e2b53e586e59c267bfe517688ff90275/virtualenv-21.7.7-py3-none-any.whl", hash = "sha256:67a6a68fef3ad8ca16b8b89f33fd8f97996cc0bf0db31629d07ecf8dec539a2c", size = 5324620, upload-time = "2026-08-28T18:59:48.056Z" }, +] From 46083b313a0aca6d9f1f8ad9e4805044d52c1fef Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 00:20:37 +0300 Subject: [PATCH 34/69] style: apply ruff and mdformat auto-fixes --- LICENSE.md | 433 ++++++------- README.md | 3 +- docs/conf.py | 84 +-- ipmininet/__init__.py | 15 +- ipmininet/clean.py | 28 +- ipmininet/cli.py | 38 +- ipmininet/examples/README.md | 124 ++-- ipmininet/examples/__main__.py | 209 ++++--- ipmininet/examples/bgp_decision_process.py | 54 +- ipmininet/examples/bgp_full_config.py | 60 +- ipmininet/examples/bgp_local_pref.py | 54 +- ipmininet/examples/bgp_med.py | 48 +- ipmininet/examples/bgp_policies_1.py | 86 +-- ipmininet/examples/bgp_policies_2.py | 23 +- ipmininet/examples/bgp_policies_3.py | 60 +- ipmininet/examples/bgp_policies_4.py | 25 +- ipmininet/examples/bgp_policies_5.py | 105 ++-- ipmininet/examples/bgp_policies_adjust.py | 64 +- ipmininet/examples/bgp_rr.py | 52 +- ipmininet/examples/dns_advanced_network.py | 26 +- ipmininet/examples/dns_network.py | 36 +- ipmininet/examples/exabgp_prefix_injector.py | 191 ++++-- ipmininet/examples/gre.py | 19 +- ipmininet/examples/iptables.py | 57 +- ipmininet/examples/link_failure.py | 20 +- ipmininet/examples/network_capture.py | 27 +- .../partial_static_address_network.py | 48 +- ipmininet/examples/ripng_network.py | 15 +- ipmininet/examples/ripng_network_adjust.py | 35 +- ipmininet/examples/router_adv_network.py | 19 +- ipmininet/examples/simple_bgp_network.py | 37 +- ipmininet/examples/simple_openr_network.py | 34 +- ipmininet/examples/simple_ospf_network.py | 75 ++- ipmininet/examples/simple_ospfv3_network.py | 14 +- ipmininet/examples/spanning_tree.py | 15 +- ipmininet/examples/spanning_tree_adjust.py | 197 +++--- ipmininet/examples/spanning_tree_bus.py | 27 +- ipmininet/examples/spanning_tree_cost.py | 3 +- ipmininet/examples/spanning_tree_full_mesh.py | 22 +- ipmininet/examples/spanning_tree_hub.py | 53 +- .../examples/spanning_tree_intermediate.py | 6 +- ipmininet/examples/srv6.py | 79 +-- ipmininet/examples/sshd.py | 5 +- ipmininet/examples/static_address_network.py | 48 +- ipmininet/examples/static_routing.py | 33 +- ipmininet/examples/static_routing_failure.py | 133 ++-- .../examples/static_routing_network_basic.py | 56 +- .../static_routing_network_complex.py | 123 ++-- ipmininet/examples/tc_advanced_network.py | 17 +- ipmininet/examples/tc_network.py | 1 - ipmininet/host/__host.py | 47 +- ipmininet/host/__init__.py | 6 +- ipmininet/host/config/__init__.py | 19 +- ipmininet/host/config/base.py | 6 +- ipmininet/host/config/dnsmasq.py | 15 +- ipmininet/host/config/named.py | 253 ++++---- ipmininet/install/__main__.py | 18 +- ipmininet/install/install.py | 261 +++++--- ipmininet/install/utils.py | 33 +- ipmininet/ipnet.py | 548 ++++++++++------- ipmininet/ipovs_switch.py | 78 ++- ipmininet/ipswitch.py | 69 ++- ipmininet/iptopo.py | 174 +++--- ipmininet/link.py | 239 +++++--- ipmininet/node_description.py | 96 +-- ipmininet/overlay.py | 117 ++-- ipmininet/router/__init__.py | 7 +- ipmininet/router/__router.py | 172 +++--- ipmininet/router/config/__init__.py | 142 ++++- ipmininet/router/config/base.py | 237 ++++---- ipmininet/router/config/bgp.py | 567 ++++++++++++------ ipmininet/router/config/dhcprelay.py | 21 +- ipmininet/router/config/exabgp.py | 132 ++-- ipmininet/router/config/iptables.py | 158 ++--- ipmininet/router/config/openr.py | 47 +- ipmininet/router/config/openrd.py | 17 +- ipmininet/router/config/ospf.py | 73 ++- ipmininet/router/config/ospf6.py | 42 +- ipmininet/router/config/pimd.py | 21 +- ipmininet/router/config/radvd.py | 91 +-- ipmininet/router/config/ripng.py | 43 +- ipmininet/router/config/sshd.py | 27 +- ipmininet/router/config/staticd.py | 14 +- ipmininet/router/config/utils.py | 8 +- ipmininet/router/config/zebra.py | 282 +++++---- ipmininet/srv6.py | 257 +++++--- ipmininet/tests/__init__.py | 33 +- ipmininet/tests/test_address_alllocation.py | 42 +- ipmininet/tests/test_bgp.py | 290 +++++---- ipmininet/tests/test_cli.py | 131 ++-- ipmininet/tests/test_dns.py | 138 +++-- ipmininet/tests/test_exabgp.py | 316 +++++++--- ipmininet/tests/test_gre.py | 16 +- ipmininet/tests/test_iptables.py | 67 ++- ipmininet/tests/test_link.py | 91 +-- ipmininet/tests/test_linkfailure.py | 31 +- ipmininet/tests/test_misc.py | 178 +++--- ipmininet/tests/test_network_capture.py | 14 +- ipmininet/tests/test_openr.py | 42 +- ipmininet/tests/test_ospf.py | 167 ++++-- ipmininet/tests/test_ospf6.py | 110 ++-- ipmininet/tests/test_physicalinterface.py | 52 +- ipmininet/tests/test_pure.py | 84 ++- ipmininet/tests/test_radv.py | 92 ++- ipmininet/tests/test_ripng.py | 121 ++-- ipmininet/tests/test_srv6.py | 348 +++++++---- ipmininet/tests/test_sshd.py | 17 +- ipmininet/tests/test_static.py | 46 +- ipmininet/tests/test_switch.py | 237 +++++--- ipmininet/tests/test_tc.py | 65 +- ipmininet/tests/test_topologydb.py | 88 +-- ipmininet/tests/utils.py | 257 +++++--- ipmininet/topologydb.py | 56 +- ipmininet/utils.py | 53 +- 114 files changed, 6179 insertions(+), 4376 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index 23cb7903..7ab884d1 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,121 +1,130 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public +``` + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 +``` + +Copyright (C) 1989, 1991 Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + +``` + Preamble +``` + +The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This +software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to your programs, too. - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. - To protect your rights, we need to make restrictions that forbid +To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. - For example, if you distribute copies of such a program, whether +For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their rights. - We protect your rights with two steps: (1) copyright the software, and +We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. - Also, for each author's protection and ours, we want to make certain +Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we +software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free +Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any +program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. - The precise terms and conditions for copying, distribution and +The precise terms and conditions for copying, distribution and modification follow. - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION +``` + GNU GENERAL PUBLIC LICENSE +``` + +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". +0. This License applies to any program or other work which contains + a notice placed by the copyright holder saying it may be distributed + under the terms of this General Public License. The "Program", below, + refers to any such program or work, and a "work based on the Program" + means either the Program or any derivative work under copyright law: + that is to say, a work containing the Program or a portion of it, + either verbatim or with modifications and/or translated into another + language. (Hereinafter, translation is included without limitation in + the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of +covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. +1. You may copy and distribute verbatim copies of the Program's + source code as you receive it, in any medium, provided that you + conspicuously and appropriately publish on each copy an appropriate + copyright notice and disclaimer of warranty; keep intact all the + notices that refer to this License and to the absence of any warranty; + and give any other recipients of the Program a copy of this License + along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If +2. You may modify your copy or copies of the Program or any portion + of it, thus forming a work based on the Program, and copy and + distribute such modifications or work under the terms of Section 1 + above, provided that you also meet all of these conditions: + +``` +a) You must cause the modified files to carry prominent notices +stating that you changed the files and the date of any change. + +b) You must cause any work that you distribute or publish, that in +whole or in part contains or is derived from the Program or any +part thereof, to be licensed as a whole at no charge to all third +parties under the terms of this License. + +c) If the modified program normally reads commands interactively +when run, you must cause it, when started running for such +interactive use in the most ordinary way, to print or display an +announcement including an appropriate copyright notice and a +notice that there is no warranty (or else, saying that you provide +a warranty) and that users may redistribute the program under +these conditions, and telling the user how to view a copy of this +License. (Exception: if the Program itself is interactive but +does not normally print such an announcement, your work based on +the Program is not required to print an announcement.) +``` + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you +sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the @@ -131,32 +140,34 @@ with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) +3. You may copy and distribute the Program (or a work based on it, + under Section 2) in object code or executable form under the terms of + Sections 1 and 2 above provided that you also do one of the following: + +``` +a) Accompany it with the complete corresponding machine-readable +source code, which must be distributed under the terms of Sections +1 and 2 above on a medium customarily used for software interchange; or, + +b) Accompany it with a written offer, valid for at least three +years, to give any third party, for a charge no more than your +cost of physically performing source distribution, a complete +machine-readable copy of the corresponding source code, to be +distributed under the terms of Sections 1 and 2 above on a medium +customarily used for software interchange; or, + +c) Accompany it with the information you received as to the offer +to distribute corresponding source code. (This alternative is +allowed only for noncommercial distribution and only if you +received the program in object code or executable form with such +an offer, in accord with Subsection b above.) +``` The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source +making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a +control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the @@ -169,43 +180,43 @@ access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. +4. You may not copy, modify, sublicense, or distribute the Program + except as expressly provided under this License. Any attempt + otherwise to copy, modify, sublicense or distribute the Program is + void, and will automatically terminate your rights under this License. + However, parties who have received copies, or rights, from you under + this License will not have their licenses terminated so long as such + parties remain in full compliance. + +1. You are not required to accept this License, since you have not + signed it. However, nothing else grants you permission to modify or + distribute the Program or its derivative works. These actions are + prohibited by law if you do not accept this License. Therefore, by + modifying or distributing the Program (or any work based on the + Program), you indicate your acceptance of this License to do so, and + all its terms and conditions for copying, distributing or modifying + the Program or works based on it. + +1. Each time you redistribute the Program (or any work based on the + Program), the recipient automatically receives a license from the + original licensor to copy, distribute or modify the Program subject to + these terms and conditions. You may not impose any further + restrictions on the recipients' exercise of the rights granted herein. + You are not responsible for enforcing compliance by third parties to + this License. + +1. If, as a consequence of a court judgment or allegation of patent + infringement or for any other reason (not limited to patent issues), + conditions are imposed on you (whether by court order, agreement or + otherwise) that contradict the conditions of this License, they do not + excuse you from the conditions of this License. If you cannot + distribute so as to satisfy simultaneously your obligations under this + License and any other pertinent obligations, then as a consequence you + may not distribute the Program at all. For example, if a patent + license would not permit royalty-free redistribution of the Program by + all those who receive copies directly or indirectly through you, then + the only way you could satisfy both it and this License would be to + refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to @@ -216,7 +227,7 @@ It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is -implemented by public license practices. Many people have made +implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing @@ -226,114 +237,122 @@ impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. +8. If the distribution and/or use of the Program is restricted in + certain countries either by patents or by copyrighted interfaces, the + original copyright holder who places the Program under this License + may add an explicit geographical distribution limitation excluding + those countries, so that distribution is permitted only in or among + countries not thus excluded. In such case, this License incorporates + the limitation as if written in the body of this License. - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. +1. The Free Software Foundation may publish revised and/or new versions + of the General Public License from time to time. Such new versions will + be similar in spirit to the present version, but may differ in detail to + address new problems or concerns. -Each version is given a distinguishing version number. If the Program +Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of +Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest +10. If you wish to incorporate parts of the Program into other free + programs whose distribution conditions are different, write to the author + to ask for permission. For software which is copyrighted by the Free + Software Foundation, write to the Free Software Foundation; we sometimes + make exceptions for this. Our decision will be guided by the two goals + of preserving the free status of all derivatives of our free software and + of promoting the sharing and reuse of software generally. + + ``` + NO WARRANTY + ``` + +01. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY + FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN + OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES + PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED + OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS + TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE + PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, + REPAIR OR CORRECTION. + +01. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING + WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR + REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, + INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING + OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED + TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY + YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER + PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE + POSSIBILITY OF SUCH DAMAGES. + + ``` + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + ``` + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. - To do so, attach the following notices to the program. It is safest +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - {description} - Copyright (C) {year} {fullname} +``` +{description} +Copyright (C) {year} {fullname} - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +You should have received a copy of the GNU General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +``` Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. +``` +Gnomovision version 69, Copyright (C) year name of author +Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. +This is free software, and you are welcome to redistribute it +under certain conditions; type `show c' for details. +``` The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may +parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: +necessary. Here is a sample; alter the names: - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. +Yoyodyne, Inc., hereby disclaims all copyright interest in the program +\`Gnomovision' (which makes passes at compilers) written by James Hacker. - {signature of Ty Coon}, 1 April 1989 - Ty Coon, President of Vice +{signature of Ty Coon}, 1 April 1989 +Ty Coon, President of Vice This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may +proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General +library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. diff --git a/README.md b/README.md index b3351a15..9cc11089 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # IPMininet + [![Pypi Version](https://img.shields.io/pypi/v/ipmininet.svg)](https://pypi.python.org/pypi/ipmininet/) [![Build Status](https://jenkins-mininet.info.ucl.ac.be/buildStatus/icon?job=ipmininet%2Fpythonversion%3Dipmininet-py37%2Ctestname%3Dunittest&subject=Python3.7)](https://jenkins-mininet.info.ucl.ac.be/job/ipmininet/pythonversion=ipmininet-py37,testname=unittest) [![Documentation Status](https://readthedocs.org/projects/ipmininet/badge/?version=latest)](http://ipmininet.readthedocs.io/?badge=latest) [![Build Status](https://jenkins-mininet.info.ucl.ac.be/buildStatus/icon?job=ipmininet%2Fpythonversion%3Dipmininet-py37%2Ctestname%3Ddoctest&subject=doc%20examples)](https://jenkins-mininet.info.ucl.ac.be/job/ipmininet/pythonversion=ipmininet-py37,testname=doctest) [![Code Coverage](https://img.shields.io/jenkins/coverage/cobertura/https/jenkins-mininet.info.ucl.ac.be/job/ipmininet/pythonversion=ipmininet-py37,testname=unittest.svg)](https://jenkins-mininet.info.ucl.ac.be/job/ipmininet/pythonversion=ipmininet-py37,testname=unittest/cobertura) - This is a python library, extending [Mininet](http://mininet.org), in order to support emulation of (complex) IP networks. As such it provides new classes, such as Routers, auto-configures all properties not set by the user, such as @@ -13,4 +13,3 @@ IP addresses or router configuration files, ... The latest user documentation is available on https://ipmininet.readthedocs.io/ - diff --git a/docs/conf.py b/docs/conf.py index 7e59edf3..8eff41ba 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # @@ -15,18 +14,18 @@ import os import sys -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath("..")) # -- Project information ----------------------------------------------------- -project = u'IPMininet' -copyright = u'2019, Olivier Tilmans' -author = u'Olivier Tilmans' +project = "IPMininet" +copyright = "2019, Olivier Tilmans" +author = "Olivier Tilmans" # The short X.Y version -version = u'' +version = "" # The full version, including alpha/beta/rc tags -release = u'v0.6' +release = "v0.6" # -- General configuration --------------------------------------------------- @@ -38,20 +37,22 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.napoleon', - 'sphinxcontrib.apidoc', - 'sphinx.ext.autosectionlabel', - 'sphinx.ext.doctest', - 'sphinx_mdinclude'] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinxcontrib.apidoc", + "sphinx.ext.autosectionlabel", + "sphinx.ext.doctest", + "sphinx_mdinclude", +] # The apidoc-generated api/*.rst files all contain "Subpackages"/"Submodules" # sections, whose autosectionlabel-generated labels collide with each other and # with section titles in the markdown included from the example topologies. # Nothing references those labels, so silence the duplicate-label warnings. -suppress_warnings = ['autosectionlabel.*'] +suppress_warnings = ["autosectionlabel.*"] -doctest_global_setup = ''' +doctest_global_setup = """ import sys, os class MockStdIn(object): @@ -69,42 +70,42 @@ def clean(self): def close_on_start_cli(self): os.write(self.write_pipe_fd, "exit\\r\\n".encode()) -''' +""" # Mock calls to external libraries autodoc_mock_imports = ["pytest"] # API doc parameters -apidoc_module_dir = '../ipmininet' -apidoc_output_dir = 'api' -apidoc_excluded_paths = ['tests', 'examples', 'install'] +apidoc_module_dir = "../ipmininet" +apidoc_output_dir = "api" +apidoc_excluded_paths = ["tests", "examples", "install"] apidoc_separate_modules = True apidoc_module_first = True -apidoc_extra_args = ['-H', 'IPMininet API', '-d', '6'] +apidoc_extra_args = ["-H", "IPMininet API", "-d", "6"] autoclass_content = "both" # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = 'en' +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = None @@ -137,7 +138,7 @@ def close_on_start_cli(self): # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = 'IPMininetdoc' +htmlhelp_basename = "IPMininetdoc" # -- Options for LaTeX output ------------------------------------------------ @@ -146,15 +147,12 @@ def close_on_start_cli(self): # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -164,8 +162,13 @@ def close_on_start_cli(self): # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'IPMininet.tex', u'IPMininet Documentation', - u'Olivier Tilmans', 'manual'), + ( + master_doc, + "IPMininet.tex", + "IPMininet Documentation", + "Olivier Tilmans", + "manual", + ), ] @@ -173,10 +176,7 @@ def close_on_start_cli(self): # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'ipmininet', u'IPMininet Documentation', - [author], 1) -] +man_pages = [(master_doc, "ipmininet", "IPMininet Documentation", [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -185,9 +185,15 @@ def close_on_start_cli(self): # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'IPMininet', u'IPMininet Documentation', - author, 'IPMininet', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "IPMininet", + "IPMininet Documentation", + author, + "IPMininet", + "One line description of project.", + "Miscellaneous", + ), ] @@ -206,4 +212,4 @@ def close_on_start_cli(self): # epub_uid = '' # A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] +epub_exclude_files = ["search.html"] diff --git a/ipmininet/__init__.py b/ipmininet/__init__.py index 1b9b0f52..a7e37695 100644 --- a/ipmininet/__init__.py +++ b/ipmininet/__init__.py @@ -8,15 +8,18 @@ # ImportError nested somewhere ... try: import mininet # noqa -except ImportError as e: +except ImportError: import sys - sys.stderr.write('Failed to import mininet!\n' - 'Using the mininet module requires mininet to be ' - 'installed.\n' - 'Visit www.mininet.org to learn how to do so.\n') + + sys.stderr.write( + "Failed to import mininet!\n" + "Using the mininet module requires mininet to be " + "installed.\n" + "Visit www.mininet.org to learn how to do so.\n" + ) sys.exit(1) # Define global constants MIN_IGP_METRIC = 1 -OSPF_DEFAULT_AREA = '0.0.0.0' +OSPF_DEFAULT_AREA = "0.0.0.0" DEBUG_FLAG = False diff --git a/ipmininet/clean.py b/ipmininet/clean.py index 957b9db2..0de1e157 100644 --- a/ipmininet/clean.py +++ b/ipmininet/clean.py @@ -1,16 +1,16 @@ import time -from subprocess import check_output, CalledProcessError -from typing import List +from subprocess import CalledProcessError, check_output import mininet.clean as mnclean from mininet.log import lg as log -import ipmininet.router.config as router_daemons import ipmininet.host.config as host_daemons +import ipmininet.router.config as router_daemons + from .utils import is_container -def cleanup(level: str = 'info'): +def cleanup(level: str = "info"): """Cleanup all possible junk that we may have started.""" log.setLogLevel(level) # Standard mininet cleanup @@ -20,15 +20,15 @@ def cleanup(level: str = 'info'): for package in [router_daemons, host_daemons]: for d in package.__all__: obj = getattr(package, d, None) - killp = getattr(obj, 'KILL_PATTERNS', None) + killp = getattr(obj, "KILL_PATTERNS", None) if not killp: continue if not is_container(killp): killp = [killp] patterns.extend(killp) - log.info('*** Cleaning up daemons:\n') + log.info("*** Cleaning up daemons:\n") killprocs(['"^%s"' % p for p in patterns]) - log.info('\n') + log.info("\n") def killprocs(patterns, timeout=10): @@ -36,23 +36,23 @@ def killprocs(patterns, timeout=10): # Try clean kill for p in patterns: - mnclean.sh('pkill -SIGINT -f %s' % p) + mnclean.sh("pkill -SIGINT -f %s" % p) # Make sure they are gone t = 0 - to_be_killed = {p: True for p in patterns} + to_be_killed = dict.fromkeys(patterns, True) while any(to_be_killed.values()) or t >= timeout: for p in patterns: try: - pids = check_output(['pgrep', '-f', p]) + pids = check_output(["pgrep", "-f", p]) except CalledProcessError: - pids = '' + pids = "" if not pids: log.info(p) to_be_killed[p] = False - time.sleep(.5) - t += .5 + time.sleep(0.5) + t += 0.5 # Last resort for p in patterns: @@ -61,5 +61,5 @@ def killprocs(patterns, timeout=10): mnclean.killprocs(p) -if __name__ == '__main__': +if __name__ == "__main__": cleanup() diff --git a/ipmininet/cli.py b/ipmininet/cli.py index 504c18b1..e90505d2 100644 --- a/ipmininet/cli.py +++ b/ipmininet/cli.py @@ -1,4 +1,5 @@ """An enhanced CLI providing IP-related commands""" + from mininet.cli import CLI from mininet.log import lg @@ -6,35 +7,36 @@ class IPCLI(CLI): - def do_route(self, line: str = ""): """route destination: Print all the routes towards that destination for every router in the network""" for r in self.mn.routers: lg.output("[%s] " % r.name) - self.default('%s ip route get %s' % (r.name, line)) + self.default("%s ip route get %s" % (r.name, line)) def do_ip(self, line: str): """ip IP1 IP2 ...: return the node associated to the given IP""" - for ip in line.split(' '): + for ip in line.split(" "): try: n = self.mn.node_for_ip(ip) except KeyError: - n = 'unknown IP' + n = "unknown IP" finally: - lg.output(ip, '|', n, "\n") + lg.output(ip, "|", n, "\n") def do_ips(self, line: str): """ips n1 n2 ...: return the ips associated to the given node name""" - for n in line.split(' '): + for n in line.split(" "): try: - ips = [ip.ip.compressed for itf in self.mn[n].intfList() - for ip in list(itf.ips()) - + list(itf.ip6s(exclude_lls=True))] + ips = [ + ip.ip.compressed + for itf in self.mn[n].intfList() + for ip in list(itf.ips()) + list(itf.ip6s(exclude_lls=True)) + ] except KeyError: - ips = 'unknown node' + ips = "unknown node" finally: - lg.output(n, '|', ips, "\n") + lg.output(n, "|", ips, "\n") def do_ping4all(self, line): """Ping (IPv4-only) between all hosts.""" @@ -60,7 +62,7 @@ def do_link(self, line: str): :param line: the routers between which the links are downed/up, given as: r1 r2, r3 r4 [down/up] """ - all_args = line.split(',') + all_args = line.split(",") last_args = all_args[-1].split() state = last_args[-1] for elem in all_args[:-1]: @@ -85,23 +87,23 @@ def default(self, line: str): lg.error("*** Enter a command for node: %s " % first) return node = self.mn[first] - rest = args.split(' ') + rest = args.split(" ") hops = [h for h in rest if h in self.mn] v4_support, v6_support = address_pair(self.mn[first]) v4_map = {} v6_map = {} for hop in hops: - ip, ip6 = address_pair(self.mn[hop], - v4_support is not None, - v6_support is not None) + ip, ip6 = address_pair( + self.mn[hop], v4_support is not None, v6_support is not None + ) if ip is not None: v4_map[hop] = ip if ip6 is not None: v6_map[hop] = ip6 ip_map = v4_map if len(v4_map) >= len(v6_map) else v6_map - node.sendCmd(' '.join([ip_map.get(r, r) for r in rest])) + node.sendCmd(" ".join([ip_map.get(r, r) for r in rest])) self.waitForNode(node) else: - lg.error('*** Unknown command: %s\n' % line) + lg.error("*** Unknown command: %s\n" % line) diff --git a/ipmininet/examples/README.md b/ipmininet/examples/README.md index 9391e525..31c95280 100644 --- a/ipmininet/examples/README.md +++ b/ipmininet/examples/README.md @@ -1,50 +1,51 @@ # Example topologies This directory contains example topologies, you can start them using + ```bash python -m ipmininet.examples --topo=[topo_name] [--args key=val,key=val] ``` + Where topo_name is the name of the topology, and args are optional arguments for it. The following sections will detail the topologies. - - [SimpleOSPFNetwork](#simpleospfnetwork) - - [SimpleBGPNetwork](#simplebgpnetwork) - - [BGPDecisionProcess](#bgpdecisionprocess) - - [BGPLocalPref](#bgplocalpref) - - [BGPMED](#bgpmed) - - [BGPRR](#bgprr) - - [BGPFullConfig](#bgpfullconfig) - - [BGPPolicies](#bgppolicies) - - [BGPPoliciesAdjust](#bgppoliciesadjust) - - [IPTables](#iptables) - - [LinkFailure](#linkfailure) - - [GRETopo](#gretopo) - - [SSHd](#sshd) - - [RouterAdvNetwork](#routeradvnetwork) - - [SimpleOpenRNetwork](#simpleopenrnetwork) - - [StaticAddressNetwork](#staticaddressnetwork) - - [PartialStaticAddressNet](#partialstaticaddressnetwork) - - [StaticRoutingNet](#staticroutingnet) - - [StaticRoutingNetBasic](#staticroutingnetbasic) - - [StaticRoutingNetComplex](#staticroutingnetcomplex) - - [StaticRoutingNetFailure](#staticroutingnetfailure) - - [SpanningTreeNet](#spanningtreenet) - - [SpanningTreeHub](#spanningtreehub) - - [SpanningTreeBus](#spanningtreebus) - - [SpanningTreeIntermediate](#spanningtreeintermediate) - - [SpanningTreeFullMesh](#spanningtreefullmesh) - - [SpanningTreeAdjust](#spanningtreeadjust) - - [SpanningTreeCost](#spanningtreecost) - - [DNSNetwork](#dnsnetwork) - - [DNSAdvancedNetwork](#dnsadvancednetwork) - - [IPv6SegmentRouting](#ipv6segmentrouting) - - [TCNetwork](#tcnetwork) - - [TCAdvancedNetwork](#tcadvancednetwork) - - [ExaBGPPrefixInjector](#exabgpprefixinjector) - - [NetworkCapture](#networkcapture) - +- [SimpleOSPFNetwork](#simpleospfnetwork) +- [SimpleBGPNetwork](#simplebgpnetwork) +- [BGPDecisionProcess](#bgpdecisionprocess) +- [BGPLocalPref](#bgplocalpref) +- [BGPMED](#bgpmed) +- [BGPRR](#bgprr) +- [BGPFullConfig](#bgpfullconfig) +- [BGPPolicies](#bgppolicies) +- [BGPPoliciesAdjust](#bgppoliciesadjust) +- [IPTables](#iptables) +- [LinkFailure](#linkfailure) +- [GRETopo](#gretopo) +- [SSHd](#sshd) +- [RouterAdvNetwork](#routeradvnetwork) +- [SimpleOpenRNetwork](#simpleopenrnetwork) +- [StaticAddressNetwork](#staticaddressnetwork) +- [PartialStaticAddressNet](#partialstaticaddressnetwork) +- [StaticRoutingNet](#staticroutingnet) +- [StaticRoutingNetBasic](#staticroutingnetbasic) +- [StaticRoutingNetComplex](#staticroutingnetcomplex) +- [StaticRoutingNetFailure](#staticroutingnetfailure) +- [SpanningTreeNet](#spanningtreenet) +- [SpanningTreeHub](#spanningtreehub) +- [SpanningTreeBus](#spanningtreebus) +- [SpanningTreeIntermediate](#spanningtreeintermediate) +- [SpanningTreeFullMesh](#spanningtreefullmesh) +- [SpanningTreeAdjust](#spanningtreeadjust) +- [SpanningTreeCost](#spanningtreecost) +- [DNSNetwork](#dnsnetwork) +- [DNSAdvancedNetwork](#dnsadvancednetwork) +- [IPv6SegmentRouting](#ipv6segmentrouting) +- [TCNetwork](#tcnetwork) +- [TCAdvancedNetwork](#tcadvancednetwork) +- [ExaBGPPrefixInjector](#exabgpprefixinjector) +- [NetworkCapture](#networkcapture) ## SimpleOSPFNetwork @@ -54,9 +55,11 @@ _args_ : n/a This network spawn a single AS topology, using OSPF, with multiple areas and variable link metrics. From the mininet CLI, access the routers vtysh using + ```bash [noecho rx] telnet localhost [ospfd/zebra] ``` + Where the noecho rx is required if you don't use a separate xterm window for the node (via `xterm rx`), and ospfd/zebra is the name of the daemon you wish to connect to. @@ -68,9 +71,11 @@ _args_ : n/a This network spawn a single AS topology, using OSPFv3, with variable link metrics. From the mininet CLI, access the routers vtysh using + ```bash [noecho rx] telnet localhost [ospf6d/zebra] ``` + Where the noecho rx is required if you don't use a separate xterm window for the node (via `xterm rx`), and ospf6d/zebra is the name of the daemon you wish to connect to. @@ -82,10 +87,9 @@ _args_ : n/a This networks spawn ASes, exchanging reachability information. - - AS1 has one eBGP peering with AS2 - - AS2 has 2 routers, using iBGP between them, and has two eBGP peering, one with AS1 and one with AS3 - - AS3 has one eBGP peering with AS2 - +- AS1 has one eBGP peering with AS2 +- AS2 has 2 routers, using iBGP between them, and has two eBGP peering, one with AS1 and one with AS3 +- AS3 has one eBGP peering with AS2 ## BGPDecisionProcess @@ -104,7 +108,7 @@ as2r1, as2r3 and as2r2 also participate in an iBGP fullmesh. Depending on the value of [other_cost] (if it is greater or lower than 10), as2r3 will either choose to use as2r1 or as2r2 as nexthop for 1.2.3.0/24, as -both routes are equal up to step #8 in the decision process, which is the IGP +both routes are equal up to step #8 in the decision process, which is the IGP cost (in a loosely defined way, as it includes any route towards the BGP nexthop). If other_cost is 10, we then arrive at step #10 to choose the best routes, and compare the router ids of as2r1 and as2r2 to select the path @@ -113,8 +117,8 @@ routes, and compare the router ids of as2r1 and as2r2 to select the path You can observe this selection by issuing one of the following command sequence once BGP has converged: - - net > as2r3 ip route show 1.2.3.0/24 - - [noecho as2r3] telnet localhost bgpd > password is zebra > enable > show ip bgp 1.2.3.0/24 +- net > as2r3 ip route show 1.2.3.0/24 +- [noecho as2r3] telnet localhost bgpd > password is zebra > enable > show ip bgp 1.2.3.0/24 ## BGPLocalPref @@ -176,8 +180,9 @@ They use two predefined BGP policies: shared-cost and client/provider peerings. ASes always favor routes received from clients, then routes from shared-cost peering, and finally, routes received from providers. Moreover, ASes filter out routes depending on the peering type: - - Routes learned from shared-cost are not forwarded to providers and other shared-cost peers. - - Routes learned from providers are not forwarded to shared-cost peers and other providers. + +- Routes learned from shared-cost are not forwarded to providers and other shared-cost peers. +- Routes learned from providers are not forwarded to shared-cost peers and other providers. ## BGPPoliciesAdjust @@ -206,8 +211,8 @@ _args_ : n/a This network spawns two routers, which have custom ACLs set such that their inbound traffic (the INPUT chains in ip(6)tables): - - Can only be ICMP traffic over IPv4 as well as non-privileged TCP ports - - Can only be (properly established) TCP over IPv6 +- Can only be ICMP traffic over IPv4 as well as non-privileged TCP ports +- Can only be (properly established) TCP over IPv6 You can test this by trying to ping(6) both routers, use nc to (try to) exchange data over TCP, or [tracebox](http://www.tracebox.org) to send a crafted TCP @@ -222,8 +227,8 @@ This network spawns 4 routers: r1, r2 and r3 are in a full mesh and r4 is connected to r3. Once the network is ready and launched, the script will: 1. Down links between routers given in the list of the failure plan. -2. Down two random links of the entire network -3. Randomly down one link of r1. Either the link r1 - r2 or r1 - r3 +1. Down two random links of the entire network +1. Randomly down one link of r1. Either the link r1 - r2 or r1 - r3 For each of these 3 scenario, the network will be rebuilt on its initial configuration. At the end of the failure simulation, the network should be @@ -239,8 +244,9 @@ A GRE Tunnel for prefix 10.0.1.0/24 is established with the two hosts (h1 having 10.0.1.1 assigned and h2 10.0.1.2). Example tests: -* Verify connectivity, normally: h1 ping h2, over the tunnel: h1 ping 10.0.1.2 -* h1 traceroute h2, h1 traceroute 10.0.1.2, should show two different routes, + +- Verify connectivity, normally: h1 ping h2, over the tunnel: h1 ping 10.0.1.2 +- h1 traceroute h2, h1 traceroute 10.0.1.2, should show two different routes, with the second one hiding the intermediate routers. ## SSHd @@ -264,6 +270,7 @@ _args_ : n/a This network uses the RIPng daemon to ensure connectivity between hosts. Like all FRRouting daemons, you can access the routers vtysh using, from the mininet CLI: + ```bash [noecho rx] telnet localhost 2603 ``` @@ -277,6 +284,7 @@ This network also uses the RIPng daemon to ensure connectivity between hosts. Moreover, the IGP metric on each link can be customized. For instance, the following command changes IGP cost of both the link between r1 and r2 and the link between r1 and r3 to 2: + ```bash python -m ipmininet.examples --topo=ripng_network_adjust --args lr1r2_cost=2,lr1r3_cost=2 ``` @@ -291,7 +299,6 @@ One of these hosts uses Router Advertisements to get its IPv6 addresses The other one's IP addresses are announced in the Router Advertisements as the DNS server's addresses. - ## SimpleOpenRNetwork _topo name_ : simple_openr_network @@ -405,12 +412,13 @@ It enables the spanning tree protocol to prevent packet looping in the LAN. _topo name_ : spannnig_tree_adjust _args_ : - - l1_start: Endpoint interface of the 1st link on which we want to change the cost - - l1_end: Endpoint interface of the 1st link on which we want to change the cost - - l1_cost: Cost to set on the first link - - l2_start: Endpoint interface of the 2nd link on which we want to change the cost - - l2_end: Endpoint interface of the 2nd link on which we want to change the cost - - l2_cost: Cost to set on the second link + +- l1_start: Endpoint interface of the 1st link on which we want to change the cost +- l1_end: Endpoint interface of the 1st link on which we want to change the cost +- l1_cost: Cost to set on the first link +- l2_start: Endpoint interface of the 2nd link on which we want to change the cost +- l2_end: Endpoint interface of the 2nd link on which we want to change the cost +- l2_cost: Cost to set on the second link This network contains a single LAN with many loops inside. It enables the spanning tree protocol to prevent packets diff --git a/ipmininet/examples/__main__.py b/ipmininet/examples/__main__.py index 019c996e..1326e24e 100644 --- a/ipmininet/examples/__main__.py +++ b/ipmininet/examples/__main__.py @@ -1,139 +1,148 @@ """This files lets you start all examples""" + import argparse +from mininet.log import LEVELS, lg + import ipmininet +from ipmininet.cli import IPCLI from ipmininet.examples.network_capture import NetworkCaptureTopo from ipmininet.ipnet import IPNet -from ipmininet.cli import IPCLI -from .simple_ospf_network import SimpleOSPFNet -from .simple_ospfv3_network import SimpleOSPFv3Net -from .simple_bgp_network import SimpleBGPTopo from .bgp_decision_process import BGPDecisionProcess -from .iptables import IPTablesTopo -from .gre import GRETopo -from .sshd import SSHTopo -from .router_adv_network import RouterAdvNet -from .ripng_network import RIPngNetwork -from .ripng_network_adjust import RIPngNetworkAdjust -from .simple_openr_network import SimpleOpenrNet -from .static_address_network import StaticAddressNet -from .static_routing_network_complex import StaticRoutingNetComplex -from .partial_static_address_network import PartialStaticAddressNet -from .static_routing import StaticRoutingNet -from .static_routing_network_basic import StaticRoutingNetBasic -from .static_routing_failure import StaticRoutingNetFailure -from .spanning_tree import SpanningTreeNet -from .spanning_tree_intermediate import SpanningTreeIntermediate -from .spanning_tree_full_mesh import SpanningTreeFullMesh -from .spanning_tree_bus import SpanningTreeBus -from .spanning_tree_hub import SpanningTreeHub -from .spanning_tree_cost import SpanningTreeCost -from .spanning_tree_adjust import SpanningTreeAdjust from .bgp_full_config import BGPTopoFull from .bgp_local_pref import BGPTopoLocalPref from .bgp_med import BGPTopoMed -from .bgp_rr import BGPTopoRR from .bgp_policies_1 import BGPPoliciesTopo1 from .bgp_policies_2 import BGPPoliciesTopo2 -from .bgp_policies_4 import BGPPoliciesTopo4 from .bgp_policies_3 import BGPPoliciesTopo3 -from .bgp_policies_adjust import BGPPoliciesAdjustTopo +from .bgp_policies_4 import BGPPoliciesTopo4 from .bgp_policies_5 import BGPPoliciesTopo5 -from .dns_network import DNSNetwork +from .bgp_policies_adjust import BGPPoliciesAdjustTopo +from .bgp_rr import BGPTopoRR from .dns_advanced_network import DNSAdvancedNetwork -from .srv6 import SRv6Topo -from .tc_network import TCNet -from .tc_advanced_network import TCAdvancedNet +from .dns_network import DNSNetwork from .exabgp_prefix_injector import ExaBGPTopoInjectPrefixes +from .gre import GRETopo +from .iptables import IPTablesTopo from .link_failure import FailureTopo +from .partial_static_address_network import PartialStaticAddressNet +from .ripng_network import RIPngNetwork +from .ripng_network_adjust import RIPngNetworkAdjust +from .router_adv_network import RouterAdvNet +from .simple_bgp_network import SimpleBGPTopo +from .simple_openr_network import SimpleOpenrNet +from .simple_ospf_network import SimpleOSPFNet +from .simple_ospfv3_network import SimpleOSPFv3Net +from .spanning_tree import SpanningTreeNet +from .spanning_tree_adjust import SpanningTreeAdjust +from .spanning_tree_bus import SpanningTreeBus +from .spanning_tree_cost import SpanningTreeCost +from .spanning_tree_full_mesh import SpanningTreeFullMesh +from .spanning_tree_hub import SpanningTreeHub +from .spanning_tree_intermediate import SpanningTreeIntermediate +from .srv6 import SRv6Topo +from .sshd import SSHTopo +from .static_address_network import StaticAddressNet +from .static_routing import StaticRoutingNet +from .static_routing_failure import StaticRoutingNetFailure +from .static_routing_network_basic import StaticRoutingNetBasic +from .static_routing_network_complex import StaticRoutingNetComplex +from .tc_advanced_network import TCAdvancedNet +from .tc_network import TCNet -from mininet.log import lg, LEVELS - -TOPOS = {'simple_ospf_network': SimpleOSPFNet, - 'simple_ospfv3_network': SimpleOSPFv3Net, - 'simple_bgp_network': SimpleBGPTopo, - 'bgp_decision_process': BGPDecisionProcess, - 'iptables': IPTablesTopo, - 'gre': GRETopo, - 'ssh': SSHTopo, - 'router_adv_network': RouterAdvNet, - 'ripng_network': RIPngNetwork, - 'ripng_network_adjust': RIPngNetworkAdjust, - 'simple_openr_network': SimpleOpenrNet, - 'static_address_network': StaticAddressNet, - 'static_routing_network_complex': StaticRoutingNetComplex, - 'partial_static_address_network': PartialStaticAddressNet, - 'static_routing_network': StaticRoutingNet, - 'static_routing_network_basic': StaticRoutingNetBasic, - 'static_routing_network_failure': StaticRoutingNetFailure, - 'spanning_tree_network': SpanningTreeNet, - 'spanning_tree_intermediate': SpanningTreeIntermediate, - 'spanning_tree_full_mesh': SpanningTreeFullMesh, - 'spanning_tree_bus': SpanningTreeBus, - 'spanning_tree_hub': SpanningTreeHub, - 'spanning_tree_cost': SpanningTreeCost, - 'spanning_tree_adjust': SpanningTreeAdjust, - 'bgp_full_config': BGPTopoFull, - 'bgp_local_pref': BGPTopoLocalPref, - 'bgp_med': BGPTopoMed, - 'bgp_rr': BGPTopoRR, - 'bgp_policies_1': BGPPoliciesTopo1, - 'bgp_policies_2': BGPPoliciesTopo2, - 'bgp_policies_3': BGPPoliciesTopo3, - 'bgp_policies_4': BGPPoliciesTopo4, - 'bgp_policies_adjust': BGPPoliciesAdjustTopo, - 'bgp_policies_5': BGPPoliciesTopo5, - 'dns_network': DNSNetwork, - 'dns_advanced_network': DNSAdvancedNetwork, - 'ipv6_segment_routing': SRv6Topo, - 'tc_network': TCNet, - 'tc_advanced_network': TCAdvancedNet, - 'exabgp_prefix_injector': ExaBGPTopoInjectPrefixes, - 'failure': FailureTopo, - 'network_capture': NetworkCaptureTopo} +TOPOS = { + "simple_ospf_network": SimpleOSPFNet, + "simple_ospfv3_network": SimpleOSPFv3Net, + "simple_bgp_network": SimpleBGPTopo, + "bgp_decision_process": BGPDecisionProcess, + "iptables": IPTablesTopo, + "gre": GRETopo, + "ssh": SSHTopo, + "router_adv_network": RouterAdvNet, + "ripng_network": RIPngNetwork, + "ripng_network_adjust": RIPngNetworkAdjust, + "simple_openr_network": SimpleOpenrNet, + "static_address_network": StaticAddressNet, + "static_routing_network_complex": StaticRoutingNetComplex, + "partial_static_address_network": PartialStaticAddressNet, + "static_routing_network": StaticRoutingNet, + "static_routing_network_basic": StaticRoutingNetBasic, + "static_routing_network_failure": StaticRoutingNetFailure, + "spanning_tree_network": SpanningTreeNet, + "spanning_tree_intermediate": SpanningTreeIntermediate, + "spanning_tree_full_mesh": SpanningTreeFullMesh, + "spanning_tree_bus": SpanningTreeBus, + "spanning_tree_hub": SpanningTreeHub, + "spanning_tree_cost": SpanningTreeCost, + "spanning_tree_adjust": SpanningTreeAdjust, + "bgp_full_config": BGPTopoFull, + "bgp_local_pref": BGPTopoLocalPref, + "bgp_med": BGPTopoMed, + "bgp_rr": BGPTopoRR, + "bgp_policies_1": BGPPoliciesTopo1, + "bgp_policies_2": BGPPoliciesTopo2, + "bgp_policies_3": BGPPoliciesTopo3, + "bgp_policies_4": BGPPoliciesTopo4, + "bgp_policies_adjust": BGPPoliciesAdjustTopo, + "bgp_policies_5": BGPPoliciesTopo5, + "dns_network": DNSNetwork, + "dns_advanced_network": DNSAdvancedNetwork, + "ipv6_segment_routing": SRv6Topo, + "tc_network": TCNet, + "tc_advanced_network": TCAdvancedNet, + "exabgp_prefix_injector": ExaBGPTopoInjectPrefixes, + "failure": FailureTopo, + "network_capture": NetworkCaptureTopo, +} -NET_ARGS = {'router_adv_network': {'use_v4': False, - 'use_v6': True, - 'allocate_IPs': False}, - 'bgp_full_config': {'use_v4': False, - 'use_v6': True}, - 'bgp_local_pref': {'use_v4': False, - 'use_v6': True}, - 'bgp_med': {'use_v4': False, - 'use_v6': True}, - 'bgp_rr': {'use_v4': False, - 'use_v6': True}} +NET_ARGS = { + "router_adv_network": {"use_v4": False, "use_v6": True, "allocate_IPs": False}, + "bgp_full_config": {"use_v4": False, "use_v6": True}, + "bgp_local_pref": {"use_v4": False, "use_v6": True}, + "bgp_med": {"use_v4": False, "use_v6": True}, + "bgp_rr": {"use_v4": False, "use_v6": True}, +} def parse_args(): parser = argparse.ArgumentParser() - parser.add_argument('--topo', choices=TOPOS.keys(), - default='simple_ospf_network', - help='The topology that you want to start.') - parser.add_argument('--log', choices=LEVELS.keys(), default='info', - help='The level of details in the logs.') - parser.add_argument('--args', help='Additional arguments to give' - 'to the topology constructor (key=val, key=val, ...)', - default='') + parser.add_argument( + "--topo", + choices=TOPOS.keys(), + default="simple_ospf_network", + help="The topology that you want to start.", + ) + parser.add_argument( + "--log", + choices=LEVELS.keys(), + default="info", + help="The level of details in the logs.", + ) + parser.add_argument( + "--args", + help="Additional arguments to give" + "to the topology constructor (key=val, key=val, ...)", + default="", + ) return parser.parse_args() -if __name__ == '__main__': +if __name__ == "__main__": args = parse_args() lg.setLogLevel(args.log) - if args.log == 'debug': + if args.log == "debug": ipmininet.DEBUG_FLAG = True kwargs = {} - for arg in args.args.strip(' \r\t\n').split(','): - arg = arg.strip(' \r\t\n') + for arg in args.args.strip(" \r\t\n").split(","): + arg = arg.strip(" \r\t\n") if not arg: continue try: - k, v = arg.split('=') + k, v = arg.split("=") kwargs[k] = v except ValueError: - lg.error('Ignoring args:', arg) + lg.error("Ignoring args:", arg) net = IPNet(topo=TOPOS[args.topo](**kwargs), **NET_ARGS.get(args.topo, {})) net.start() IPCLI(net) diff --git a/ipmininet/examples/bgp_decision_process.py b/ipmininet/examples/bgp_decision_process.py index 73e6f39c..1858549b 100644 --- a/ipmininet/examples/bgp_decision_process.py +++ b/ipmininet/examples/bgp_decision_process.py @@ -6,58 +6,58 @@ subnet directly connected to the router whose decision process we want to analyze, as this would result in a metric of 0 since the connected routes types as an administrative distance and a metric of 0.:""" -from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, ebgp_session, OSPF, RouterConfig + import ipmininet.router.config.bgp as _bgp +from ipmininet.iptopo import IPTopo +from ipmininet.router.config import BGP, OSPF, RouterConfig, ebgp_session class BGPDecisionProcess(IPTopo): """This topology builds a 3-AS network exchanging BGP reachability information towards the same route. The final decision is thus dependent on the IGP cost.""" + def __init__(self, other_cost=5, *args, **kwargs): """:param other_cost: The cost of the link as2r3--as2r2. - If it is lower than 10, it will cause as 2r3 - to use as2r2 as egress for 1.2.3.0/24 as it will - be cheaper than as2r1, although its router-id - is greater.""" + If it is lower than 10, it will cause as 2r3 + to use as2r2 as egress for 1.2.3.0/24 as it will + be cheaper than as2r1, although its router-id + is greater.""" self.other_cost = int(other_cost) super().__init__(*args, **kwargs) def build(self, *args, **kwargs): """ - +----------+ +--------+ - | | - AS1 | AS2 | AS3 - | | - | iBGP | - +-------+ eBGP | +-------+ +-------+ | eBGP +-------+ - | as1r1 +------------+ as2r1 +--X-as2r3-Y--+ as2r2 +------------+ as3r1 | - +-------+ | +-------+ OSPF +-------+ | +-------+ - 1.2.3.0/24 | | 1.2.3.0/24 - | | - | | - +------------+ +--------+ + +----------+ +--------+ + | | + AS1 | AS2 | AS3 + | | + | iBGP | + +-------+ eBGP | +-------+ +-------+ | eBGP +-------+ + | as1r1 +------------+ as2r1 +--X-as2r3-Y--+ as2r2 +------------+ as3r1 | + +-------+ | +-------+ OSPF +-------+ | +-------+ + 1.2.3.0/24 | | 1.2.3.0/24 + | | + | | + +------------+ +--------+ """ # Add all routers - as1r1, as2r1, as2r2, as2r3, x, y, as3r1 = \ - self.addRouters('as1r1', 'as2r1', 'as2r2', 'as2r3', 'x', 'y', - 'as3r1', config=RouterConfig) + as1r1, as2r1, as2r2, as2r3, x, y, as3r1 = self.addRouters( + "as1r1", "as2r1", "as2r2", "as2r3", "x", "y", "as3r1", config=RouterConfig + ) - as1r1.addDaemon(BGP, address_families=( - _bgp.AF_INET(networks=('1.2.3.0/24',)),)) + as1r1.addDaemon(BGP, address_families=(_bgp.AF_INET(networks=("1.2.3.0/24",)),)) - as2r1.addDaemon(BGP, routerid='1.1.1.1') + as2r1.addDaemon(BGP, routerid="1.1.1.1") as2r1.addDaemon(OSPF) - as2r2.addDaemon(BGP, routerid='1.1.1.2') + as2r2.addDaemon(BGP, routerid="1.1.1.2") as2r2.addDaemon(OSPF) as2r3.addDaemon(BGP) as2r3.addDaemon(OSPF) x.addDaemon(OSPF) y.addDaemon(OSPF) - as3r1.addDaemon(BGP, address_families=( - _bgp.AF_INET(networks=('1.2.3.0/24',)),)) + as3r1.addDaemon(BGP, address_families=(_bgp.AF_INET(networks=("1.2.3.0/24",)),)) self.addLink(as1r1, as2r1) self.addLink(as2r1, x, igp_metric=1) diff --git a/ipmininet/examples/bgp_full_config.py b/ipmininet/examples/bgp_full_config.py index dfd00302..a340c64b 100644 --- a/ipmininet/examples/bgp_full_config.py +++ b/ipmininet/examples/bgp_full_config.py @@ -1,11 +1,10 @@ from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, ebgp_session, set_rr, AccessList, \ - AF_INET6 +from ipmininet.router.config import AF_INET6, BGP, AccessList, ebgp_session, set_rr class BGPTopoFull(IPTopo): """This topology is composed of two AS connected in dual homing - with different local pref and MED. AS1 has one route reflector: as1r3.""" + with different local pref and MED. AS1 has one route reflector: as1r3.""" def build(self, *args, **kwargs): """ @@ -31,44 +30,47 @@ def build(self, *args, **kwargs): """ # Add all routers - as1r1 = self.bgp('as1r1') - as1r2 = self.bgp('as1r2') - as1r3 = self.bgp('as1r3') - as1r4 = self.bgp('as1r4') - as1r5 = self.bgp('as1r5', - family=AF_INET6(redistribute=('ospf6', 'connected'))) - as1r6 = self.bgp('as1r6', - family=AF_INET6(redistribute=('ospf6', 'connected'))) - as4r1 = self.bgp('as4r1', family=AF_INET6(networks=('dead:beef::/32',))) - as4r2 = self.bgp('as4r2', family=AF_INET6(networks=('dead:beef::/32',))) + as1r1 = self.bgp("as1r1") + as1r2 = self.bgp("as1r2") + as1r3 = self.bgp("as1r3") + as1r4 = self.bgp("as1r4") + as1r5 = self.bgp("as1r5", family=AF_INET6(redistribute=("ospf6", "connected"))) + as1r6 = self.bgp("as1r6", family=AF_INET6(redistribute=("ospf6", "connected"))) + as4r1 = self.bgp("as4r1", family=AF_INET6(networks=("dead:beef::/32",))) + as4r2 = self.bgp("as4r2", family=AF_INET6(networks=("dead:beef::/32",))) # Add the host and the switch - as4h1 = self.addHost('as4h1') - switch = self.addSwitch('s4') + as4h1 = self.addHost("as4h1") + switch = self.addSwitch("s4") # Add Links self.addLink(as1r1, as1r6) self.addLink(as1r1, as1r3, igp_metric=2) self.addLinks((as1r3, as1r2), (as1r3, as1r6)) self.addLink(as1r2, as1r4, igp_metric=4) - self.addLinks((as1r4, as1r5), (as1r5, as1r6), (as4r1, as1r6), - (as4r2, as1r5), (as4r1, switch), (as4r2, switch), - (switch, as4h1)) - self.addSubnet((as4r1, as4r2, as4h1), subnets=('dead:beef::/32',)) + self.addLinks( + (as1r4, as1r5), + (as1r5, as1r6), + (as4r1, as1r6), + (as4r2, as1r5), + (as4r1, switch), + (as4r2, switch), + (switch, as4h1), + ) + self.addSubnet((as4r1, as4r2, as4h1), subnets=("dead:beef::/32",)) - al4 = AccessList(name='all4', entries=('any',), family='ipv4') - al6 = AccessList(name='all6', entries=('any',), family='ipv6') + al4 = AccessList(name="all4", entries=("any",), family="ipv4") + al6 = AccessList(name="all6", entries=("any",), family="ipv6") - as1r6.get_config(BGP)\ - .set_local_pref(99, from_peer=as4r1, matching=(al4, al6))\ - .set_med(50, to_peer=as4r1, matching=(al4, al6)) + as1r6.get_config(BGP).set_local_pref( + 99, from_peer=as4r1, matching=(al4, al6) + ).set_med(50, to_peer=as4r1, matching=(al4, al6)) - as4r1.get_config(BGP)\ - .set_community('1:80', from_peer=as1r6, matching=(al4, al6))\ - .set_med(50, to_peer=as1r6, matching=(al4, al6)) + as4r1.get_config(BGP).set_community( + "1:80", from_peer=as1r6, matching=(al4, al6) + ).set_med(50, to_peer=as1r6, matching=(al4, al6)) - as1r5.get_config(BGP).set_local_pref(50, from_peer=as4r2, - matching=(al4, al6)) + as1r5.get_config(BGP).set_local_pref(50, from_peer=as4r2, matching=(al4, al6)) # Add full mesh self.addAS(4, (as4r1, as4r2)) diff --git a/ipmininet/examples/bgp_local_pref.py b/ipmininet/examples/bgp_local_pref.py index 4b0dfde8..4a25ac6e 100644 --- a/ipmininet/examples/bgp_local_pref.py +++ b/ipmininet/examples/bgp_local_pref.py @@ -1,12 +1,12 @@ from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, ebgp_session, AccessList, AF_INET6 +from ipmininet.router.config import AF_INET6, BGP, AccessList, ebgp_session class BGPTopoLocalPref(IPTopo): """This topology is composed of two AS connected in dual homing - with a higher local pref for routes from as4r1 than from as4r2. - Thus, all the traffic coming from AS1 will go through the link - between as1r6 and as4r1.""" + with a higher local pref for routes from as4r1 than from as4r2. + Thus, all the traffic coming from AS1 will go through the link + between as1r6 and as4r1.""" def build(self, *args, **kwargs): """ @@ -32,37 +32,39 @@ def build(self, *args, **kwargs): """ # Add all routers - as1r1 = self.bgp('as1r1') - as1r2 = self.bgp('as1r2') - as1r3 = self.bgp('as1r3') - as1r4 = self.bgp('as1r4') - as1r5 = self.bgp('as1r5', - family=AF_INET6(redistribute=('ospf6', 'connected'))) - as1r6 = self.bgp('as1r6', - family=AF_INET6(redistribute=('ospf6', 'connected'))) - as4r1 = self.bgp('as4r1', family=AF_INET6(networks=('dead:beef::/32',))) - as4r2 = self.bgp('as4r2', family=AF_INET6(networks=('dead:beef::/32',))) + as1r1 = self.bgp("as1r1") + as1r2 = self.bgp("as1r2") + as1r3 = self.bgp("as1r3") + as1r4 = self.bgp("as1r4") + as1r5 = self.bgp("as1r5", family=AF_INET6(redistribute=("ospf6", "connected"))) + as1r6 = self.bgp("as1r6", family=AF_INET6(redistribute=("ospf6", "connected"))) + as4r1 = self.bgp("as4r1", family=AF_INET6(networks=("dead:beef::/32",))) + as4r2 = self.bgp("as4r2", family=AF_INET6(networks=("dead:beef::/32",))) # Add the host and the switch - as4h1 = self.addHost('as4h1') - switch = self.addSwitch('s4') + as4h1 = self.addHost("as4h1") + switch = self.addSwitch("s4") # Add Links self.addLink(as1r1, as1r6) self.addLink(as1r1, as1r3, igp_metric=2) self.addLinks((as1r3, as1r2), (as1r3, as1r6)) self.addLink(as1r2, as1r4, igp_metric=4) - self.addLinks((as1r4, as1r5), (as1r5, as1r6), (as4r1, as1r6), - (as4r2, as1r5), (as4r1, switch), (as4r2, switch), - (switch, as4h1)) - self.addSubnet((as4r1, as4r2, as4h1), subnets=('dead:beef::/32',)) + self.addLinks( + (as1r4, as1r5), + (as1r5, as1r6), + (as4r1, as1r6), + (as4r2, as1r5), + (as4r1, switch), + (as4r2, switch), + (switch, as4h1), + ) + self.addSubnet((as4r1, as4r2, as4h1), subnets=("dead:beef::/32",)) - al4 = AccessList(name='all', entries=('any',), family='ipv4') - al6 = AccessList(name='all6', entries=('any',), family='ipv6') - as1r6.get_config(BGP).set_local_pref(99, from_peer=as4r1, - matching=(al4, al6)) - as1r5.get_config(BGP).set_local_pref(50, from_peer=as4r2, - matching=(al4, al6)) + al4 = AccessList(name="all", entries=("any",), family="ipv4") + al6 = AccessList(name="all6", entries=("any",), family="ipv6") + as1r6.get_config(BGP).set_local_pref(99, from_peer=as4r1, matching=(al4, al6)) + as1r5.get_config(BGP).set_local_pref(50, from_peer=as4r2, matching=(al4, al6)) # Add full mesh self.addAS(4, (as4r1, as4r2)) diff --git a/ipmininet/examples/bgp_med.py b/ipmininet/examples/bgp_med.py index 925a13f9..f91b6341 100644 --- a/ipmininet/examples/bgp_med.py +++ b/ipmininet/examples/bgp_med.py @@ -1,12 +1,12 @@ from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, ebgp_session, AccessList, AF_INET6 +from ipmininet.router.config import AF_INET6, BGP, AccessList, ebgp_session class BGPTopoMed(IPTopo): """This topology is composed of two AS connected in dual homing - with a higher MED for routes from as4r1 than from as4r2. - Thus, all the traffic coming from AS1 will go through the link - between as1r5 and as4r2.""" + with a higher MED for routes from as4r1 than from as4r2. + Thus, all the traffic coming from AS1 will go through the link + between as1r5 and as4r2.""" def build(self, *args, **kwargs): """ @@ -32,33 +32,37 @@ def build(self, *args, **kwargs): """ # Add all routers - as1r1 = self.bgp('as1r1') - as1r2 = self.bgp('as1r2') - as1r3 = self.bgp('as1r3') - as1r4 = self.bgp('as1r4') - as1r5 = self.bgp('as1r5', - family=AF_INET6(redistribute=('ospf6', 'connected'))) - as1r6 = self.bgp('as1r6', - family=AF_INET6(redistribute=('ospf6', 'connected'))) - as4r1 = self.bgp('as4r1', family=AF_INET6(networks=('dead:beef::/32',))) - as4r2 = self.bgp('as4r2', family=AF_INET6(networks=('dead:beef::/32',))) + as1r1 = self.bgp("as1r1") + as1r2 = self.bgp("as1r2") + as1r3 = self.bgp("as1r3") + as1r4 = self.bgp("as1r4") + as1r5 = self.bgp("as1r5", family=AF_INET6(redistribute=("ospf6", "connected"))) + as1r6 = self.bgp("as1r6", family=AF_INET6(redistribute=("ospf6", "connected"))) + as4r1 = self.bgp("as4r1", family=AF_INET6(networks=("dead:beef::/32",))) + as4r2 = self.bgp("as4r2", family=AF_INET6(networks=("dead:beef::/32",))) # Add the host and the switch - as4h1 = self.addHost('as4h1') - switch = self.addSwitch('s4') + as4h1 = self.addHost("as4h1") + switch = self.addSwitch("s4") # Add Links self.addLink(as1r1, as1r6) self.addLink(as1r1, as1r3, igp_metric=2) self.addLinks((as1r3, as1r2), (as1r3, as1r6)) self.addLink(as1r2, as1r4, igp_metric=4) - self.addLinks((as1r4, as1r5), (as1r5, as1r6), (as4r1, as1r6), - (as4r2, as1r5), (as4r1, switch), (as4r2, switch), - (switch, as4h1)) - self.addSubnet((as4r1, as4r2, as4h1), subnets=('dead:beef::/32',)) + self.addLinks( + (as1r4, as1r5), + (as1r5, as1r6), + (as4r1, as1r6), + (as4r2, as1r5), + (as4r1, switch), + (as4r2, switch), + (switch, as4h1), + ) + self.addSubnet((as4r1, as4r2, as4h1), subnets=("dead:beef::/32",)) - al4 = AccessList(name='all4', entries=('any',), family='ipv4') - al6 = AccessList(name='all6', entries=('any',), family='ipv6') + al4 = AccessList(name="all4", entries=("any",), family="ipv4") + al6 = AccessList(name="all6", entries=("any",), family="ipv6") as4r1.get_config(BGP).set_med(99, to_peer=as1r6, matching=(al4, al6)) as4r2.get_config(BGP).set_med(50, to_peer=as1r5, matching=(al4, al6)) diff --git a/ipmininet/examples/bgp_policies_1.py b/ipmininet/examples/bgp_policies_1.py index 030d3a27..d9aa15a5 100644 --- a/ipmininet/examples/bgp_policies_1.py +++ b/ipmininet/examples/bgp_policies_1.py @@ -1,6 +1,6 @@ import ipmininet.router.config.bgp as _bgp from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, ebgp_session, CLIENT_PROVIDER, SHARE +from ipmininet.router.config import BGP, CLIENT_PROVIDER, SHARE, ebgp_session class BGPPoliciesTopo1(IPTopo): @@ -10,42 +10,50 @@ class BGPPoliciesTopo1(IPTopo): routes received from clients, then routes from shared-cost peering, and finally, routes received from providers. This is not influenced by the AS path length.""" + def build(self, *args, **kwargs): """ - +-------+ $ - | as1r1 +-------------+ - +---+---+ | - | = | - +-------|--------+ | - | AS2 | v - +---+---+ | $ +---+---+ - | as2r1 +-------->+ as5r1 | - +---+---+ | +--++---+ - | | ^^ - +---+---+ | || - | as2r2 | | || - +---+---+ | || - | | $ || $ - +-------|--------+ || - | = || - +---+---+ || - | as3r1 +------------+| - +---+---+ | - | = | - +---+---+ | - | as4r1 +-------------+ - +-------+ + +-------+ $ + | as1r1 +-------------+ + +---+---+ | + | = | + +-------|--------+ | + | AS2 | v + +---+---+ | $ +---+---+ + | as2r1 +-------->+ as5r1 | + +---+---+ | +--++---+ + | | ^^ + +---+---+ | || + | as2r2 | | || + +---+---+ | || + | | $ || $ + +-------|--------+ || + | = || + +---+---+ || + | as3r1 +------------+| + +---+---+ | + | = | + +---+---+ | + | as4r1 +-------------+ + +-------+ """ # Add all routers - as1r1 = self.bgp('as1r1') - as2r1 = self.bgp('as2r1') - as2r2 = self.bgp('as2r2') - as3r1 = self.bgp('as3r1') - as4r1 = self.bgp('as4r1') - as5r1 = self.bgp('as5r1') - self.addLinks((as1r1, as2r1), (as2r1, as2r2), (as3r1, as2r2), - (as3r1, as4r1), (as5r1, as1r1), (as5r1, as2r1), - (as5r1, as3r1), (as5r1, as4r1)) + as1r1 = self.bgp("as1r1") + as2r1 = self.bgp("as2r1") + as2r2 = self.bgp("as2r2") + as3r1 = self.bgp("as3r1") + as4r1 = self.bgp("as4r1") + as5r1 = self.bgp("as5r1") + self.addLinks( + (as1r1, as2r1), + (as2r1, as2r2), + (as3r1, as2r2), + (as3r1, as4r1), + (as5r1, as1r1), + (as5r1, as2r1), + (as5r1, as3r1), + (as5r1, as4r1), + ) # Set AS-ownerships self.addAS(1, (as1r1,)) self.addiBGPFullMesh(2, (as2r1, as2r2)) @@ -62,12 +70,16 @@ def build(self, *args, **kwargs): ebgp_session(self, as4r1, as5r1, link_type=CLIENT_PROVIDER) # Add test hosts for r in self.routers(): - self.addLink(r, self.addHost('h%s' % r)) + self.addLink(r, self.addHost("h%s" % r)) super().build(*args, **kwargs) def bgp(self, name): r = self.addRouter(name) - r.addDaemon(BGP, address_families=( - _bgp.AF_INET(redistribute=('connected',)), - _bgp.AF_INET6(redistribute=('connected',)))) + r.addDaemon( + BGP, + address_families=( + _bgp.AF_INET(redistribute=("connected",)), + _bgp.AF_INET6(redistribute=("connected",)), + ), + ) return r diff --git a/ipmininet/examples/bgp_policies_2.py b/ipmininet/examples/bgp_policies_2.py index 18fd0667..eaa57856 100644 --- a/ipmininet/examples/bgp_policies_2.py +++ b/ipmininet/examples/bgp_policies_2.py @@ -1,6 +1,6 @@ import ipmininet.router.config.bgp as _bgp from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, ebgp_session, CLIENT_PROVIDER, SHARE +from ipmininet.router.config import BGP, CLIENT_PROVIDER, SHARE, ebgp_session class BGPPoliciesTopo2(IPTopo): @@ -15,6 +15,7 @@ class BGPPoliciesTopo2(IPTopo): This topology is taken from https://www.computer-networking.info/exercises/html/ex-routing-policies.html """ + def build(self, *args, **kwargs): """ = @@ -33,10 +34,10 @@ def build(self, *args, **kwargs): +----------------------+ """ # Add all routers - as1r1 = self.bgp('as1') - as2r1 = self.bgp('as2') - as3r1 = self.bgp('as3') - as4r1 = self.bgp('as4') + as1r1 = self.bgp("as1") + as2r1 = self.bgp("as2") + as3r1 = self.bgp("as3") + as4r1 = self.bgp("as4") # Add links las12 = self.addLink(as1r1, as2r1) @@ -72,12 +73,16 @@ def build(self, *args, **kwargs): ebgp_session(self, as4r1, as3r1, link_type=SHARE) # Add test hosts for r in self.routers(): - self.addLink(r, self.addHost('h%s' % r)) + self.addLink(r, self.addHost("h%s" % r)) super().build(*args, **kwargs) def bgp(self, name): r = self.addRouter(name, use_v4=False, use_v6=True) - r.addDaemon(BGP, address_families=( - _bgp.AF_INET(redistribute=('connected',)), - _bgp.AF_INET6(redistribute=('connected',)))) + r.addDaemon( + BGP, + address_families=( + _bgp.AF_INET(redistribute=("connected",)), + _bgp.AF_INET6(redistribute=("connected",)), + ), + ) return r diff --git a/ipmininet/examples/bgp_policies_3.py b/ipmininet/examples/bgp_policies_3.py index af95f331..04ebea6c 100644 --- a/ipmininet/examples/bgp_policies_3.py +++ b/ipmininet/examples/bgp_policies_3.py @@ -1,6 +1,5 @@ from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, ebgp_session, AF_INET6,\ - CLIENT_PROVIDER, SHARE +from ipmininet.router.config import AF_INET6, BGP, CLIENT_PROVIDER, SHARE, ebgp_session class BGPPoliciesTopo3(IPTopo): @@ -15,43 +14,40 @@ class BGPPoliciesTopo3(IPTopo): This topology is taken from https://www.computer-networking.info/exercises/html/ex-routing-policies.html """ + def build(self, *args, **kwargs): """ - +-------+ - +------+ as2r +-------+ - | +-------+ | - | = = | - | | - +---+---+ $ +---+---+ - | as1r +<-------------+ as3r | - +---+---+ +---+---+ - | | - | $ $ | - | +-------+ | - +----->+ as4r +<------+ - +-------+ + +-------+ + +------+ as2r +-------+ + | +-------+ | + | = = | + | | + +---+---+ $ +---+---+ + | as1r +<-------------+ as3r | + +---+---+ +---+---+ + | | + | $ $ | + | +-------+ | + +----->+ as4r +<------+ + +-------+ """ # Add all routers - as1r = self.addRouter('as1r') - as2r = self.addRouter('as2r') - as3r = self.addRouter('as3r') - as4r = self.addRouter('as4r') + as1r = self.addRouter("as1r") + as2r = self.addRouter("as2r") + as3r = self.addRouter("as3r") + as4r = self.addRouter("as4r") routers = self.routers() - prefix = {routers[i]: '2001:db:%04x::/48' % i - for i in range(len(routers))} - as1r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as1r],)),)) - as2r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as2r],)),)) - as3r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as3r],)),)) - as4r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as4r],)),)) + prefix = {routers[i]: "2001:db:%04x::/48" % i for i in range(len(routers))} + as1r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as1r],)),)) + as2r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as2r],)),)) + as3r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as3r],)),)) + as4r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as4r],)),)) # Add links - self.addLinks((as1r, as2r), (as1r, as3r), (as1r, as4r), (as2r, as3r), - (as3r, as4r)) + self.addLinks( + (as1r, as2r), (as1r, as3r), (as1r, as4r), (as2r, as3r), (as3r, as4r) + ) # Set AS-ownerships self.addAS(1, (as1r,)) @@ -68,6 +64,6 @@ def build(self, *args, **kwargs): # Add test hosts for r in self.routers(): - link = self.addLink(r, self.addHost('h%s' % r)) + link = self.addLink(r, self.addHost("h%s" % r)) self.addSubnet(links=[link], subnets=[prefix[r]]) super().build(*args, **kwargs) diff --git a/ipmininet/examples/bgp_policies_4.py b/ipmininet/examples/bgp_policies_4.py index 0845a7c9..5edb1fd6 100644 --- a/ipmininet/examples/bgp_policies_4.py +++ b/ipmininet/examples/bgp_policies_4.py @@ -1,6 +1,6 @@ import ipmininet.router.config.bgp as _bgp from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, ebgp_session, CLIENT_PROVIDER, SHARE +from ipmininet.router.config import BGP, CLIENT_PROVIDER, SHARE, ebgp_session class BGPPoliciesTopo4(IPTopo): @@ -15,6 +15,7 @@ class BGPPoliciesTopo4(IPTopo): This topology is taken from https://www.computer-networking.info/exercises/html/ex-bgp.html """ + def build(self, *args, **kwargs): r""" +-----+ = +-----+ @@ -38,11 +39,11 @@ def build(self, *args, **kwargs): +-----+ +-----+ """ # Add all routers - as1r = self.bgp('as1r') - as2r = self.bgp('as2r') - as3r = self.bgp('as3r') - as4r = self.bgp('as4r') - as5r = self.bgp('as5r') + as1r = self.bgp("as1r") + as2r = self.bgp("as2r") + as3r = self.bgp("as3r") + as4r = self.bgp("as4r") + as5r = self.bgp("as5r") # Add links las12 = self.addLink(as1r, as2r) @@ -101,12 +102,16 @@ def build(self, *args, **kwargs): # Add test hosts for r in self.routers(): - self.addLink(r, self.addHost('h%s' % r)) + self.addLink(r, self.addHost("h%s" % r)) super().build(*args, **kwargs) def bgp(self, name): r = self.addRouter(name, use_v4=False, use_v6=True) - r.addDaemon(BGP, address_families=( - _bgp.AF_INET(redistribute=('connected',)), - _bgp.AF_INET6(redistribute=('connected',)))) + r.addDaemon( + BGP, + address_families=( + _bgp.AF_INET(redistribute=("connected",)), + _bgp.AF_INET6(redistribute=("connected",)), + ), + ) return r diff --git a/ipmininet/examples/bgp_policies_5.py b/ipmininet/examples/bgp_policies_5.py index 820366b5..e169462d 100644 --- a/ipmininet/examples/bgp_policies_5.py +++ b/ipmininet/examples/bgp_policies_5.py @@ -1,6 +1,5 @@ from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, ebgp_session, AF_INET6,\ - CLIENT_PROVIDER, SHARE +from ipmininet.router.config import AF_INET6, BGP, CLIENT_PROVIDER, SHARE, ebgp_session class BGPPoliciesTopo5(IPTopo): @@ -15,63 +14,65 @@ class BGPPoliciesTopo5(IPTopo): This topology is taken from https://www.computer-networking.info/exercises/html/ex-bgp.html """ + def build(self, *args, **kwargs): """ - +-----+ - +----->+as2r +<----------------+ - | +-----+ | - | $ $ | - | | - +--+--+ = +--+--+ | - |as1r +-------------+ as3r| | - +--+--+ +--+--+ | - ^ ^ | - | $ $ | | - | | | - +--+--+ = +--+--+ | - |as4r +-------------+as8r | | - +--+--+ +--+--+ | - ^ ^ | - | $ $ | | - | | | - +--+--+ = +--+--+ | - |as6r +-------------+as5r +-------+ - +--+--+ +--+--+ - ^ | - | $ $ | - | +------+ | - +------+ as7r +<----+ - +------+ + +-----+ + +----->+as2r +<----------------+ + | +-----+ | + | $ $ | + | | + +--+--+ = +--+--+ | + |as1r +-------------+ as3r| | + +--+--+ +--+--+ | + ^ ^ | + | $ $ | | + | | | + +--+--+ = +--+--+ | + |as4r +-------------+as8r | | + +--+--+ +--+--+ | + ^ ^ | + | $ $ | | + | | | + +--+--+ = +--+--+ | + |as6r +-------------+as5r +-------+ + +--+--+ +--+--+ + ^ | + | $ $ | + | +------+ | + +------+ as7r +<----+ + +------+ """ # Add all routers - as1r, as2r, as3r, as4r, as5r, as6r, as7r, as8r = \ - self.addRouters('as1r', 'as2r', 'as3r', 'as4r', 'as5r', 'as6r', - 'as7r', 'as8r') + as1r, as2r, as3r, as4r, as5r, as6r, as7r, as8r = self.addRouters( + "as1r", "as2r", "as3r", "as4r", "as5r", "as6r", "as7r", "as8r" + ) routers = self.routers() - prefix = {routers[i]: '2001:db:%04x::/48' % i - for i in range(len(routers))} - as1r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as1r],)),)) - as2r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as2r],)),)) - as3r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as3r],)),)) - as4r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as4r],)),)) - as5r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as5r],)),)) - as6r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as6r],)),)) - as7r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as7r],)),)) - as8r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as8r],)),)) + prefix = {routers[i]: "2001:db:%04x::/48" % i for i in range(len(routers))} + as1r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as1r],)),)) + as2r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as2r],)),)) + as3r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as3r],)),)) + as4r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as4r],)),)) + as5r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as5r],)),)) + as6r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as6r],)),)) + as7r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as7r],)),)) + as8r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as8r],)),)) # Add links - self.addLinks((as1r, as2r), (as1r, as3r), (as1r, as4r), (as2r, as5r), - (as3r, as8r), (as4r, as6r), (as4r, as8r), (as5r, as6r), - (as5r, as7r), (as5r, as8r), (as6r, as7r)) + self.addLinks( + (as1r, as2r), + (as1r, as3r), + (as1r, as4r), + (as2r, as5r), + (as3r, as8r), + (as4r, as6r), + (as4r, as8r), + (as5r, as6r), + (as5r, as7r), + (as5r, as8r), + (as6r, as7r), + ) # Set AS-ownerships self.addAS(1, (as1r,)) @@ -98,6 +99,6 @@ def build(self, *args, **kwargs): # Add test hosts for r in self.routers(): - link = self.addLink(r, self.addHost('h%s' % r)) + link = self.addLink(r, self.addHost("h%s" % r)) self.addSubnet(links=[link], subnets=[prefix[r]]) super().build(*args, **kwargs) diff --git a/ipmininet/examples/bgp_policies_adjust.py b/ipmininet/examples/bgp_policies_adjust.py index 5288a32b..953f9145 100644 --- a/ipmininet/examples/bgp_policies_adjust.py +++ b/ipmininet/examples/bgp_policies_adjust.py @@ -1,6 +1,5 @@ from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, ebgp_session, AF_INET6,\ - CLIENT_PROVIDER, SHARE +from ipmininet.router.config import AF_INET6, BGP, CLIENT_PROVIDER, SHARE, ebgp_session class BGPPoliciesAdjustTopo(IPTopo): @@ -19,8 +18,7 @@ class BGPPoliciesAdjustTopo(IPTopo): https://www.computer-networking.info/exercises/html/ex-routing-policies.html """ - def __init__(self, as_start=None, as_end=None, bgp_policy=SHARE, - *args, **kwargs): + def __init__(self, as_start=None, as_end=None, bgp_policy=SHARE, *args, **kwargs): """:param as_start: The AS router at one end of the extra link. If the link type is 'Client-Provider', this AS will be the client. :param as_end: The AS router at other end of the extra link. @@ -58,26 +56,28 @@ def build(self, *args, **kwargs): """ # Add all routers - as1r, as2r, as3r, as4r, as5r = self.addRouters('as1r', 'as2r', 'as3r', - 'as4r', 'as5r') + as1r, as2r, as3r, as4r, as5r = self.addRouters( + "as1r", "as2r", "as3r", "as4r", "as5r" + ) routers = self.routers() - prefix = {routers[i]: '2001:db:%04x::/48' % i - for i in range(len(routers))} - as1r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as1r],)),)) - as2r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as2r],)),)) - as3r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as3r],)),)) - as4r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as4r],)),)) - as5r.addDaemon(BGP, - address_families=(AF_INET6(networks=(prefix[as5r],)),)) + prefix = {routers[i]: "2001:db:%04x::/48" % i for i in range(len(routers))} + as1r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as1r],)),)) + as2r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as2r],)),)) + as3r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as3r],)),)) + as4r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as4r],)),)) + as5r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as5r],)),)) # Add links - self.addLinks((as1r, as2r), (as1r, as3r), (as1r, as4r), (as2r, as3r), - (as2r, as4r), (as3r, as5r), (as4r, as5r)) + self.addLinks( + (as1r, as2r), + (as1r, as3r), + (as1r, as4r), + (as2r, as3r), + (as2r, as4r), + (as3r, as5r), + (as4r, as5r), + ) # Set AS-ownerships self.addAS(1, (as1r,)) @@ -101,12 +101,11 @@ def build(self, *args, **kwargs): # Add custom link if self.as_start is not None: self.addLink(self.as_start, self.as_end) - ebgp_session(self, self.as_start, self.as_end, - link_type=self.bgp_policy) + ebgp_session(self, self.as_start, self.as_end, link_type=self.bgp_policy) # Add test hosts for r in self.routers(): - link = self.addLink(r, self.addHost('h%s' % r)) + link = self.addLink(r, self.addHost("h%s" % r)) self.addSubnet(links=[link], subnets=[prefix[r]]) super().build(*args, **kwargs) @@ -118,15 +117,20 @@ def check_extra_link(self): return routers = self.routers() if self.as_start not in routers: - raise ValueError("as_start '%s' is not an AS router among %s" - % (self.as_start, ", ".join(routers))) + raise ValueError( + "as_start '%s' is not an AS router among %s" + % (self.as_start, ", ".join(routers)) + ) if self.as_end not in routers: - raise ValueError("as_end '%s' is not an AS router among %s" - % (self.as_end, ", ".join(routers))) + raise ValueError( + "as_end '%s' is not an AS router among %s" + % (self.as_end, ", ".join(routers)) + ) if self.bgp_policy not in [CLIENT_PROVIDER, SHARE]: - raise ValueError("bgp_policy '%s' is not a BGP policy among %s" - % (self.bgp_policy, ", ".join([CLIENT_PROVIDER, - SHARE]))) + raise ValueError( + "bgp_policy '%s' is not a BGP policy among %s" + % (self.bgp_policy, ", ".join([CLIENT_PROVIDER, SHARE])) + ) self.as_start = [r for r in routers if r == self.as_start][0] self.as_end = [r for r in routers if r == self.as_end][0] diff --git a/ipmininet/examples/bgp_rr.py b/ipmininet/examples/bgp_rr.py index ad14a21e..0d086c13 100644 --- a/ipmininet/examples/bgp_rr.py +++ b/ipmininet/examples/bgp_rr.py @@ -1,5 +1,5 @@ from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, ebgp_session, set_rr, AF_INET6 +from ipmininet.router.config import AF_INET6, BGP, ebgp_session, set_rr class BGPTopoRR(IPTopo): @@ -42,38 +42,40 @@ def build(self, *args, **kwargs): """ # Add all routers - as1r1 = self.bgp('as1r1') - as1r2 = self.bgp('as1r2') - as1r3 = self.bgp('as1r3') - as1r4 = self.bgp('as1r4') - as1r5 = self.bgp('as1r5', - family=AF_INET6(redistribute=('ospf6', 'connected'))) - as1r6 = self.bgp('as1r6', - family=AF_INET6(redistribute=('ospf6', 'connected'))) - as4r1 = self.bgp('as4r1', - family=AF_INET6(redistribute=('ospf6', 'connected'))) - as4r2 = self.bgp('as4r2', - family=AF_INET6(redistribute=('ospf6', 'connected'))) - as5r1 = self.bgp('as5r1', - family=AF_INET6(redistribute=('connected',))) - as3r1 = self.bgp('as3r1', - family=AF_INET6(redistribute=('connected',))) - as2r1 = self.bgp('as2r1', - family=AF_INET6(networks=('dead:beef::/32',))) + as1r1 = self.bgp("as1r1") + as1r2 = self.bgp("as1r2") + as1r3 = self.bgp("as1r3") + as1r4 = self.bgp("as1r4") + as1r5 = self.bgp("as1r5", family=AF_INET6(redistribute=("ospf6", "connected"))) + as1r6 = self.bgp("as1r6", family=AF_INET6(redistribute=("ospf6", "connected"))) + as4r1 = self.bgp("as4r1", family=AF_INET6(redistribute=("ospf6", "connected"))) + as4r2 = self.bgp("as4r2", family=AF_INET6(redistribute=("ospf6", "connected"))) + as5r1 = self.bgp("as5r1", family=AF_INET6(redistribute=("connected",))) + as3r1 = self.bgp("as3r1", family=AF_INET6(redistribute=("connected",))) + as2r1 = self.bgp("as2r1", family=AF_INET6(networks=("dead:beef::/32",))) # Add host - as2h1 = self.addHost('as2h1') + as2h1 = self.addHost("as2h1") # Add Links self.addLink(as1r1, as1r6) self.addLink(as1r1, as1r3, igp_metric=2) self.addLinks((as1r3, as1r2), (as1r3, as1r6)) self.addLink(as1r2, as1r4, igp_metric=4) - self.addLinks((as1r4, as1r5), (as1r5, as1r6), (as4r1, as1r5), - (as4r2, as1r4), (as3r1, as1r1), (as5r1, as1r6), - (as3r1, as5r1), (as5r1, as2r1), (as2r1, as4r1), - (as4r1, as4r2), (as2r1, as2h1)) - self.addSubnet((as2r1, as2h1), subnets=('dead:beef::/32',)) + self.addLinks( + (as1r4, as1r5), + (as1r5, as1r6), + (as4r1, as1r5), + (as4r2, as1r4), + (as3r1, as1r1), + (as5r1, as1r6), + (as3r1, as5r1), + (as5r1, as2r1), + (as2r1, as4r1), + (as4r1, as4r2), + (as2r1, as2h1), + ) + self.addSubnet((as2r1, as2h1), subnets=("dead:beef::/32",)) set_rr(self, rr=as1r1, peers=[as1r3, as1r2, as1r4, as1r5, as1r6]) set_rr(self, rr=as1r5, peers=[as1r1, as1r2, as1r4, as1r3, as1r6]) diff --git a/ipmininet/examples/dns_advanced_network.py b/ipmininet/examples/dns_advanced_network.py index 9e98d64d..3967a47d 100644 --- a/ipmininet/examples/dns_advanced_network.py +++ b/ipmininet/examples/dns_advanced_network.py @@ -6,9 +6,9 @@ class DNSAdvancedNetwork(IPTopo): """This network runs a named daemon on both master and slave hosts. - The zone 'mydomain.org' is configured on both named daemons. - org_dns is the name server of 'org' domain while root_dns is a root - name server.""" + The zone 'mydomain.org' is configured on both named daemons. + org_dns is the name server of 'org' domain while root_dns is a root + name server.""" def build(self, *args, **kwargs): """ @@ -29,35 +29,35 @@ def build(self, *args, **kwargs): """ # Add routers - r1, r2, r3, r4, r5 = self.addRouters('r1', 'r2', 'r3', 'r4', 'r5') - self.addLinks((r1, r2), (r1, r3), (r3, r2), (r2, r4), (r3, r5), - (r4, r5)) + r1, r2, r3, r4, r5 = self.addRouters("r1", "r2", "r3", "r4", "r5") + self.addLinks((r1, r2), (r1, r3), (r3, r2), (r2, r4), (r3, r5), (r4, r5)) # Add hosts - server = self.addHost('server') + server = self.addHost("server") self.addLink(r1, server) - master = self.addHost('master') + master = self.addHost("master") master.addDaemon(Named) self.addLink(r3, master) - slave = self.addHost('slave') + slave = self.addHost("slave") slave.addDaemon(Named) self.addLink(r2, slave) - orgdns = self.addHost('orgdns') + orgdns = self.addHost("orgdns") orgdns.addDaemon(Named) self.addLink(r4, orgdns) - rootdns = self.addHost('rootdns') + rootdns = self.addHost("rootdns") rootdns.addDaemon(Named) self.addLink(r5, rootdns) # Declare the mydomain.org, org and root DNS zones. # The orgdns server will have the NS records for 'mydomain.org' zone # and the rootdns server will have the NS records for the 'org' zone. - self.addDNSZone(name="mydomain.org", dns_master=master, - dns_slaves=[slave], nodes=[server]) + self.addDNSZone( + name="mydomain.org", dns_master=master, dns_slaves=[slave], nodes=[server] + ) self.addDNSZone(name="org", dns_master=orgdns) self.addDNSZone(name="", dns_master=rootdns) diff --git a/ipmininet/examples/dns_network.py b/ipmininet/examples/dns_network.py index 9dbe7a9c..d823b49b 100644 --- a/ipmininet/examples/dns_network.py +++ b/ipmininet/examples/dns_network.py @@ -1,13 +1,14 @@ """This file contains a simple switch topology using the Named daemon""" + from ipaddress import ip_address +from ipmininet.host.config import ARecord, Named, PTRRecord from ipmininet.iptopo import IPTopo -from ipmininet.host.config import Named, ARecord, PTRRecord class DNSNetwork(IPTopo): """This simple network runs a named daemon on both master and slave hosts. - The zone 'mydomain.org' is configured on both named daemons.""" + The zone 'mydomain.org' is configured on both named daemons.""" def build(self, *args, **kwargs): """ @@ -24,21 +25,20 @@ def build(self, *args, **kwargs): """ # Add routers - r1, r2, r3 = self.addRouters('r1', 'r2', 'r3') + r1, r2, r3 = self.addRouters("r1", "r2", "r3") self.addLinks((r1, r2), (r1, r3), (r3, r2)) # Add hosts - server = self.addHost('server') + server = self.addHost("server") lr1server = self.addLink(r1, server) - self.addSubnet(links=[lr1server], - subnets=["192.168.0.0/24", "fc00::/64"]) + self.addSubnet(links=[lr1server], subnets=["192.168.0.0/24", "fc00::/64"]) - master = self.addHost('master') + master = self.addHost("master") master.addDaemon(Named) self.addLink(r3, master) - slave = self.addHost('slave') + slave = self.addHost("slave") slave.addDaemon(Named) self.addLink(r2, slave) @@ -47,8 +47,13 @@ def build(self, *args, **kwargs): # By default all the NS, A and AAAA records are generated # but you can add them explicitly to change their TTL records = [ARecord(server, "fc00::2", ttl=120)] - self.addDNSZone(name="mydomain.org", dns_master=master, - dns_slaves=[slave], nodes=[server], records=records) + self.addDNSZone( + name="mydomain.org", + dns_master=master, + dns_slaves=[slave], + nodes=[server], + records=records, + ) # By default IPMininet creates the reverse DNS zones for the addresses # of the other zones but if you want to change the default values of @@ -58,8 +63,13 @@ def build(self, *args, **kwargs): ptr_record = PTRRecord("fc00::2", server + ".mydomain.org", ttl=120) # reverse_domain_name is "f.ip6.arpa" reverse_domain_name = ip_address("fc00::").reverse_pointer[-10:] - self.addDNSZone(name=reverse_domain_name, dns_master=master, - dns_slaves=[slave], records=[ptr_record], - ns_domain_name="mydomain.org", retry_time=8200) + self.addDNSZone( + name=reverse_domain_name, + dns_master=master, + dns_slaves=[slave], + records=[ptr_record], + ns_domain_name="mydomain.org", + retry_time=8200, + ) super().build(*args, **kwargs) diff --git a/ipmininet/examples/exabgp_prefix_injector.py b/ipmininet/examples/exabgp_prefix_injector.py index 61d20f9f..91473d9b 100644 --- a/ipmininet/examples/exabgp_prefix_injector.py +++ b/ipmininet/examples/exabgp_prefix_injector.py @@ -1,11 +1,27 @@ import itertools import random -from ipaddress import ip_network, ip_address, ip_interface, IPv4Interface, IPv6Interface, IPv4Network, IPv6Network -from typing import Sequence, List, Optional, Dict, Union, Set +from collections.abc import Sequence +from ipaddress import ( + IPv4Interface, + IPv4Network, + IPv6Interface, + IPv6Network, + ip_address, + ip_interface, + ip_network, +) +from typing import Union from ipmininet.iptopo import IPTopo -from ipmininet.router.config import ebgp_session, RouterConfig, BGP, ExaBGPDaemon, BGPRoute, BGPAttribute, \ - ExaList +from ipmininet.router.config import ( + BGP, + BGPAttribute, + BGPRoute, + ExaBGPDaemon, + ExaList, + RouterConfig, + ebgp_session, +) from ipmininet.router.config.bgp import AF_INET, AF_INET6 __MAX_UINT128_ = 340282366920938463463374607431768211455 @@ -18,8 +34,13 @@ _8000 = 170141183460469231731687303715884105728 -def rnd_list(max_len: int, strict=False, bound_lo: int = 1, - bound_hi: int = __MAX_UINT16_, rm: Set[int] = ()) -> List[int]: +def rnd_list( + max_len: int, + strict=False, + bound_lo: int = 1, + bound_hi: int = __MAX_UINT16_, + rm: set[int] = (), +) -> list[int]: """ Generates a list of random integers in the limits [bound_lo; bound_hi[ of at most max_len elements @@ -51,7 +72,11 @@ def random_gen(low: int, high: int): return list(rnd_set) -def build_bgp_route(ip_networks: Union[Sequence['IPv4Network'], Sequence['IPv6Network']], my_as: int, their_as: int): +def build_bgp_route( + ip_networks: Sequence["IPv4Network"] | Sequence["IPv6Network"], + my_as: int, + their_as: int, +): """ Generates BGP routes with custom attributes attached to IP prefixes given at argument. @@ -71,50 +96,57 @@ def build_bgp_route(ip_networks: Union[Sequence['IPv4Network'], Sequence['IPv6Ne for network in ip_networks: next_hop = BGPAttribute("next-hop", "self") - as_path = BGPAttribute("as-path", ExaList([my_as] + rnd_list(random.randint(1, 25), rm={their_as, my_as}))) - communities = BGPAttribute("community", - ExaList(["%d:%d" % (j, k) for j, k in zip(rnd_list(24, True), rnd_list(24, True))])) + as_path = BGPAttribute( + "as-path", + ExaList([my_as] + rnd_list(random.randint(1, 25), rm={their_as, my_as})), + ) + communities = BGPAttribute( + "community", + ExaList( + [ + "%d:%d" % (j, k) + for j, k in zip(rnd_list(24, True), rnd_list(24, True)) + ] + ), + ) med = BGPAttribute("med", random.randint(1, __MAX_UINT32_)) origin = BGPAttribute("origin", random.choice(["igp", "egp", "incomplete"])) - my_routes.append(BGPRoute(network, [next_hop, origin, med, as_path, communities])) + my_routes.append( + BGPRoute(network, [next_hop, origin, med, as_path, communities]) + ) return my_routes def gen_ip_prefix(family: str): - """Generates a random IP prefix according to the family given at argument - """ - assert family in {'ipv4', 'ipv6'}, 'Family "%s" is not a valid IP family' % family + """Generates a random IP prefix according to the family given at argument""" + assert family in {"ipv4", "ipv6"}, 'Family "%s" is not a valid IP family' % family _CONF_FAMILY = { - 'ipv4': { - 'pfx_lo': 8, - 'pfx_hi': 32, - 'start_ip': _1_0_0_0, # 1.0.0.0 - 'end_ip': _233_255_255_255 # 223.255.255.255 (avoid class D and class E addresses) + "ipv4": { + "pfx_lo": 8, + "pfx_hi": 32, + "start_ip": _1_0_0_0, # 1.0.0.0 + "end_ip": _233_255_255_255, # 223.255.255.255 (avoid class D and class E addresses) + }, + "ipv6": { + "pfx_lo": 16, + "pfx_hi": 128, + "start_ip": _2001, # 2001:: + "end_ip": _8000 - 1, # 7fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff }, - 'ipv6': { - 'pfx_lo': 16, - 'pfx_hi': 128, - 'start_ip': _2001, # 2001:: - 'end_ip': _8000 - 1 # 7fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff - } } def random_prefix(afi): ip_conf = _CONF_FAMILY[afi] - mask_len = random.randint(ip_conf['pfx_lo'], ip_conf['pfx_hi']) - int_addr = random.randint(ip_conf['start_ip'], ip_conf['end_ip']) + mask_len = random.randint(ip_conf["pfx_lo"], ip_conf["pfx_hi"]) + int_addr = random.randint(ip_conf["start_ip"], ip_conf["end_ip"]) - return ip_network("{addr}/{mask}" - .format(addr=ip_address(int_addr), mask=mask_len), - strict=False) + return ip_network(f"{ip_address(int_addr)}/{mask_len}", strict=False) while True: - if family == 'ipv4': - yield random_prefix(family) - elif family == 'ipv6': + if family == "ipv4" or family == "ipv6": yield random_prefix(family) @@ -125,9 +157,14 @@ class ExaBGPTopoInjectPrefixes(IPTopo): routes to its remote peer. as1 node runs ExaBGP and as2 runs FRRouting BGPD. """ - def __init__(self, routes: Optional[Dict[str, Sequence['BGPRoute']]] = None, - addr: Optional[Dict[str, Dict[str, Union[str, 'IPv4Interface', 'IPv6Interface']]]] = None, - *args, **kwargs): + def __init__( + self, + routes: dict[str, Sequence["BGPRoute"]] | None = None, + addr: dict[str, dict[str, Union[str, "IPv4Interface", "IPv6Interface"]]] + | None = None, + *args, + **kwargs, + ): """ Initialize the topology example. @@ -147,35 +184,53 @@ def __init__(self, routes: Optional[Dict[str, Sequence['BGPRoute']]] = None, node. In this case, the untouched IP address will be set to the default one. """ - self.routes = {'ipv4': list(), 'ipv6': list()} - self.addr = {'as1': {'ipv4': ip_interface("10.1.0.1/24"), 'ipv6': ip_interface("fd00:12::1/64")}, - 'as2': {'ipv4': ip_interface("10.1.0.2/24"), 'ipv6': ip_interface("fd00:12::2/64")}} + self.routes = {"ipv4": list(), "ipv6": list()} + self.addr = { + "as1": { + "ipv4": ip_interface("10.1.0.1/24"), + "ipv6": ip_interface("fd00:12::1/64"), + }, + "as2": { + "ipv4": ip_interface("10.1.0.2/24"), + "ipv6": ip_interface("fd00:12::2/64"), + }, + } if routes is None: self.routes = dict() - for afi in ('ipv4', 'ipv6'): + for afi in ("ipv4", "ipv6"): prefixes = set() - for x in itertools.takewhile(lambda _: len(prefixes) <= 5, gen_ip_prefix(afi)): + for x in itertools.takewhile( + lambda _: len(prefixes) <= 5, gen_ip_prefix(afi) + ): prefixes.add(x) - self.routes[afi] = build_bgp_route(list(prefixes), self.exabgp_asn, self.frr_asn) + self.routes[afi] = build_bgp_route( + list(prefixes), self.exabgp_asn, self.frr_asn + ) else: for key in self.routes.keys(): self.routes[key].extend(routes[key]) if addr is not None: for node in addr.keys(): - assert node in {'as1', 'as2'}, 'Unrecognized node: "%s". Expected: "as1" or "as2"' % node + assert node in {"as1", "as2"}, ( + 'Unrecognized node: "%s". Expected: "as1" or "as2"' % node + ) for afi in addr[node].keys(): - assert afi in {'ipv4', 'ipv6'}, 'Unrecognized AFI "%s" for "%s" node. ' \ - 'Expected: "ipv4" or "ipv6"' % (afi, node) + assert afi in {"ipv4", "ipv6"}, ( + 'Unrecognized AFI "%s" for "%s" node. ' + 'Expected: "ipv4" or "ipv6"' % (afi, node) + ) if isinstance(addr[node][afi], str): self.addr[node][afi] = ip_interface(addr[node][afi]) else: - assert isinstance(addr[node][afi], (IPv4Interface, IPv6Interface)), \ - "Bad type '{type}' for {afi} AFI. Expected: IPv4Interface or IPv6Interface or str." \ - .format(type=type(addr[node][afi]), afi=afi) + assert isinstance( + addr[node][afi], (IPv4Interface, IPv6Interface) + ), ( + f"Bad type '{type(addr[node][afi])}' for {afi} AFI. Expected: IPv4Interface or IPv6Interface or str." + ) self.addr[node][afi] = addr[node][afi] @@ -191,27 +246,31 @@ def frr_asn(self): def build(self, *args, **kwargs): """ - +---+---+---+ +---+---+---+ - | | | | - | as1 | | as2 | - | ExaBGP +-----+ FRR BGP | - | | | | - +---+---+---+ +---+---+---+ + +---+---+---+ +---+---+---+ + | | | | + | as1 | | as2 | + | ExaBGP +-----+ FRR BGP | + | | | | + +---+---+---+ +---+---+---+ """ - af4 = AF_INET(routes=self.routes['ipv4']) - af6 = AF_INET6(routes=self.routes['ipv6']) + af4 = AF_INET(routes=self.routes["ipv4"]) + af6 = AF_INET6(routes=self.routes["ipv6"]) # Add all routers - as1r1 = self.addRouter('as1', config=RouterConfig, use_v4=True, use_v6=True) + as1r1 = self.addRouter("as1", config=RouterConfig, use_v4=True, use_v6=True) as1r1.addDaemon(ExaBGPDaemon, address_families=(af4, af6)) - as2r1 = self.bgp('as2') + as2r1 = self.bgp("as2") # Add links las12 = self.addLink(as1r1, as2r1) - las12[as1r1].addParams(ip=(str(self.addr['as1']['ipv4']), str(self.addr['as1']['ipv6']))) - las12[as2r1].addParams(ip=(str(self.addr['as2']['ipv4']), str(self.addr['as2']['ipv6']))) + las12[as1r1].addParams( + ip=(str(self.addr["as1"]["ipv4"]), str(self.addr["as1"]["ipv6"])) + ) + las12[as2r1].addParams( + ip=(str(self.addr["as2"]["ipv4"]), str(self.addr["as2"]["ipv6"])) + ) # Set AS-ownerships self.addAS(self.exabgp_asn, (as1r1,)) @@ -221,12 +280,16 @@ def build(self, *args, **kwargs): # Add test hosts for r in self.routers(): - self.addLink(r, self.addHost('h%s' % r)) + self.addLink(r, self.addHost("h%s" % r)) super().build(*args, **kwargs) def bgp(self, name): r = self.addRouter(name, use_v4=True, use_v6=True) - r.addDaemon(BGP, address_families=( - AF_INET(redistribute=('connected',)), - AF_INET6(redistribute=('connected',)))) + r.addDaemon( + BGP, + address_families=( + AF_INET(redistribute=("connected",)), + AF_INET6(redistribute=("connected",)), + ), + ) return r diff --git a/ipmininet/examples/gre.py b/ipmininet/examples/gre.py index 302e0575..dbc8811a 100644 --- a/ipmininet/examples/gre.py +++ b/ipmininet/examples/gre.py @@ -1,8 +1,9 @@ """This modules show how to add GRE tunnels on top of an existing network. It introduces a stretched IP network that spans across the two hosts, with no cooperation from the routers.""" -from ipmininet.link import GRETunnel + from ipmininet.iptopo import IPTopo +from ipmininet.link import GRETunnel class GRETopo(IPTopo): @@ -10,15 +11,19 @@ class GRETopo(IPTopo): h1 -- r1 -- r2 -- r3 -- r4 -- h2 | | +---- GRE (10.0.1.0/24) -----+""" + def build(self, *args, **kw): - r1, r2, r3, r4 = map(self.addRouter, ('r1', 'r2', 'r3', 'r4')) - self.h1, self.h2 = map(self.addHost, ('h1', 'h2')) - for s, d in ((r1, r2), (r1, self.h1), (r2, r3), (r3, r4), - (r4, self.h2)): + r1, r2, r3, r4 = map(self.addRouter, ("r1", "r2", "r3", "r4")) + self.h1, self.h2 = map(self.addHost, ("h1", "h2")) + for s, d in ((r1, r2), (r1, self.h1), (r2, r3), (r3, r4), (r4, self.h2)): self.addLink(s, d) super().build(*args, **kw) def post_build(self, net): - GRETunnel(net[self.h1].defaultIntf(), net[self.h2].defaultIntf(), - '10.0.1.1/24', '10.0.1.2/24') + GRETunnel( + net[self.h1].defaultIntf(), + net[self.h2].defaultIntf(), + "10.0.1.1/24", + "10.0.1.2/24", + ) super().post_build(net) diff --git a/ipmininet/examples/iptables.py b/ipmininet/examples/iptables.py index 32453995..b9a0d9ea 100644 --- a/ipmininet/examples/iptables.py +++ b/ipmininet/examples/iptables.py @@ -6,31 +6,52 @@ IPv4 as well as non-privileged TCP ports, whereas IPv6 traffic can only be made of TCP traffic (regularly established, hence the use of CONNTRACK) or ICMP6 ND/NA.""" + from ipmininet.iptopo import IPTopo -from ipmininet.router.config import IPTables, IP6Tables, RouterConfig, Rule, \ - InputFilter, NOT, Deny, Allow +from ipmininet.router.config import ( + NOT, + Allow, + Deny, + InputFilter, + IP6Tables, + IPTables, + RouterConfig, + Rule, +) class IPTablesTopo(IPTopo): """This is a simple 2-hosts topology with custom IPTable rules.""" + def build(self, *args, **kw): - ip_rules = [InputFilter(default="DROP", rules=[ - Deny(proto='tcp', port='80:1024'), - Allow(proto='tcp', port=NOT(1480)), - Allow(proto='icmp'), - ])] + ip_rules = [ + InputFilter( + default="DROP", + rules=[ + Deny(proto="tcp", port="80:1024"), + Allow(proto="tcp", port=NOT(1480)), + Allow(proto="icmp"), + ], + ) + ] ip6_rules = [ - Rule('-A INPUT -p icmpv6 -m icmpv6', - '--icmpv6-type neighbour-solicitation -j ACCEPT'), - Rule('-A INPUT -p icmpv6 -m icmpv6 --icmpv6-type', - 'neighbour-advertisement -j ACCEPT'), - Rule('-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED', - '-j ACCEPT'), - Rule('-A INPUT -m conntrack --ctstate INVALID -j DROP'), - Rule('-A INPUT -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN', - '-m conntrack --ctstate NEW -j ACCEPT'), - Rule('-A INPUT -j DROP')] - r1, r2 = self.addRouters('r1', 'r2', config=RouterConfig) + Rule( + "-A INPUT -p icmpv6 -m icmpv6", + "--icmpv6-type neighbour-solicitation -j ACCEPT", + ), + Rule( + "-A INPUT -p icmpv6 -m icmpv6 --icmpv6-type", + "neighbour-advertisement -j ACCEPT", + ), + Rule("-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED", "-j ACCEPT"), + Rule("-A INPUT -m conntrack --ctstate INVALID -j DROP"), + Rule( + "-A INPUT -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN", + "-m conntrack --ctstate NEW -j ACCEPT", + ), + Rule("-A INPUT -j DROP"), + ] + r1, r2 = self.addRouters("r1", "r2", config=RouterConfig) self.addLink(r1, r2) r1.addDaemon(IPTables, rules=ip_rules) r1.addDaemon(IP6Tables, rules=ip6_rules) diff --git a/ipmininet/examples/link_failure.py b/ipmininet/examples/link_failure.py index ee981c85..a0fe4647 100644 --- a/ipmininet/examples/link_failure.py +++ b/ipmininet/examples/link_failure.py @@ -5,16 +5,16 @@ class FailureTopo(IPTopo): """ - +-----+ +------+ - | r1 +-----------------+ r2 | - +---+-+ +---+--+ - | +-----+ | - +--------| r3 |--------+ - +--+--+ - | - +--+--+ - | r4 | - +-----+ + +-----+ +------+ + | r1 +-----------------+ r2 | + +---+-+ +---+--+ + | +-----+ | + +--------| r3 |--------+ + +--+--+ + | + +--+--+ + | r4 | + +-----+ """ def build(self, *args, **kwargs): diff --git a/ipmininet/examples/network_capture.py b/ipmininet/examples/network_capture.py index 26b9ab73..cecf208f 100644 --- a/ipmininet/examples/network_capture.py +++ b/ipmininet/examples/network_capture.py @@ -1,4 +1,3 @@ - from ipmininet.iptopo import IPTopo @@ -11,20 +10,22 @@ class NetworkCaptureTopo(IPTopo): def build(self, *args, **kw): """ - +----+ +----+ - | r1 +-----------------+ r2 | - +--+-+ +-+--+ - | +----+ +----+ | - +---+ s1 +---+ s2 +---+ - +----+ +----+ + +----+ +----+ + | r1 +-----------------+ r2 | + +--+-+ +-+--+ + | +----+ +----+ | + +---+ s1 +---+ s2 +---+ + +----+ +----+ """ - r1, r2 = self.addRouters('r1', 'r2') - s1 = self.addSwitch('s1') - s2 = self.addSwitch('s2') + r1, r2 = self.addRouters("r1", "r2") + s1 = self.addSwitch("s1") + s2 = self.addSwitch("s2") lr1r2, _, ls1s2, _ = self.addLinks((r1, r2), (r1, s1), (s1, s2), (s2, r2)) # Capture on all the interfaces of r1 and s1 - self.addNetworkCapture(nodes=[r1, s1], - # Capture on two specific interfaces of r2 and s2 - interfaces=[lr1r2[r2], ls1s2[s2]]) + self.addNetworkCapture( + nodes=[r1, s1], + # Capture on two specific interfaces of r2 and s2 + interfaces=[lr1r2[r2], ls1s2[s2]], + ) super().build(*args, **kw) diff --git a/ipmininet/examples/partial_static_address_network.py b/ipmininet/examples/partial_static_address_network.py index 670a674c..a7171673 100644 --- a/ipmininet/examples/partial_static_address_network.py +++ b/ipmininet/examples/partial_static_address_network.py @@ -1,40 +1,38 @@ """This file contains a topology with statically assigned addresses on two of - the LANs. The remaining addresses will be dynamically allocated.""" +the LANs. The remaining addresses will be dynamically allocated.""" from ipmininet.iptopo import IPTopo class PartialStaticAddressNet(IPTopo): - def build(self, *args, **kwargs): """ - +----+ +----+ +----+ +----+ +----+ - | h1 +-------+ r1 +-------+ r2 +-------+ s2 +-------+ h3 | - +----+ +--+-+ +----+ +----+ +----+ - | - | +----+ +----+ - +---------+ s1 +-------+ h2 | - +--+-+ +----+ - | - | +----+ - +---------+ h4 | - +----+ + +----+ +----+ +----+ +----+ +----+ + | h1 +-------+ r1 +-------+ r2 +-------+ s2 +-------+ h3 | + +----+ +--+-+ +----+ +----+ +----+ + | + | +----+ +----+ + +---------+ s1 +-------+ h2 | + +--+-+ +----+ + | + | +----+ + +---------+ h4 | + +----+ """ - r1 = self.addRouter('r1', lo_addresses=["2042:1::1/64"]) - r2 = self.addRouter('r2') + r1 = self.addRouter("r1", lo_addresses=["2042:1::1/64"]) + r2 = self.addRouter("r2") - s1 = self.addSwitch('s1') - s2 = self.addSwitch('s2') + s1 = self.addSwitch("s1") + s2 = self.addSwitch("s2") - h1 = self.addHost('h1') - h2 = self.addHost('h2') - h3 = self.addHost('h3') - h4 = self.addHost('h4') + h1 = self.addHost("h1") + h2 = self.addHost("h2") + h3 = self.addHost("h3") + h4 = self.addHost("h4") - _, lr1r2, _, _, _, lr2s2, ls2h3 = self.addLinks((h1, r1), (r1, r2), - (r1, s1), (s1, h2), - (s1, h4), (r2, s2), - (s2, h3)) + _, lr1r2, _, _, _, lr2s2, ls2h3 = self.addLinks( + (h1, r1), (r1, r2), (r1, s1), (s1, h2), (s1, h4), (r2, s2), (s2, h3) + ) lr2s2[r2].addParams(ip=("192.168.1.1/24", "fc00:1::1/64")) ls2h3[h3].addParams(ip=("192.168.1.2/24", "fc00:1::2/64")) diff --git a/ipmininet/examples/ripng_network.py b/ipmininet/examples/ripng_network.py index 4bb3a46f..ba322519 100644 --- a/ipmininet/examples/ripng_network.py +++ b/ipmininet/examples/ripng_network.py @@ -6,7 +6,6 @@ class RIPngNetwork(IPTopo): - def __init__(self, ripng_timers=None, *args, **kwargs): """:param ripng_timers: optional dict of RIPng daemon options applied to every router (e.g. {'update_timer': 2} to speed up convergence)""" @@ -27,14 +26,14 @@ def build(self, *args, **kwargs): | h4 +-----+ r4 +-----+ r5 +-----+ h5 | +-----+ +-----+ +-----+ +-----+ """ - r1, r2, r3, r4, r5 = self.addRouters('r1', 'r2', 'r3', 'r4', 'r5', - use_v4=False, use_v6=True, - config=RouterConfig) + r1, r2, r3, r4, r5 = self.addRouters( + "r1", "r2", "r3", "r4", "r5", use_v4=False, use_v6=True, config=RouterConfig + ) - h1 = self.addHost('h1') - h3 = self.addHost('h3') - h4 = self.addHost('h4') - h5 = self.addHost('h5') + h1 = self.addHost("h1") + h3 = self.addHost("h3") + h4 = self.addHost("h4") + h5 = self.addHost("h5") self.addLinks((h1, r1), (h3, r3), (h4, r4), (h5, r5)) diff --git a/ipmininet/examples/ripng_network_adjust.py b/ipmininet/examples/ripng_network_adjust.py index 6bbbd9d1..72550306 100644 --- a/ipmininet/examples/ripng_network_adjust.py +++ b/ipmininet/examples/ripng_network_adjust.py @@ -1,16 +1,25 @@ """This file contains a topology controlled by RIPng daemons. - The weight can be customized.""" +The weight can be customized.""" from ipmininet.iptopo import IPTopo -from ipmininet.router.config.ripng import RIPng from ipmininet.router.config import RouterConfig +from ipmininet.router.config.ripng import RIPng class RIPngNetworkAdjust(IPTopo): - - def __init__(self, lr1r2_cost=1, lr1r3_cost=1, lr1r5_cost=1, lr2r3_cost=1, - lr2r4_cost=1, lr2r5_cost=1, lr4r5_cost=1, - ripng_timers=None, *args, **kwargs): + def __init__( + self, + lr1r2_cost=1, + lr1r3_cost=1, + lr1r5_cost=1, + lr2r3_cost=1, + lr2r4_cost=1, + lr2r5_cost=1, + lr4r5_cost=1, + ripng_timers=None, + *args, + **kwargs, + ): """ :param lr1r2_cost: Cost of link between R1 and R2 :param lr1r3_cost: Cost of link between R1 and R3 @@ -58,19 +67,19 @@ def build(self, *args, **kwargs): +-----+ +-----+ """ - r1, r2, r3, r4, r5 = self.addRouters('r1', 'r2', 'r3', 'r4', 'r5', - use_v4=False, use_v6=True, - config=RouterConfig) + r1, r2, r3, r4, r5 = self.addRouters( + "r1", "r2", "r3", "r4", "r5", use_v4=False, use_v6=True, config=RouterConfig + ) r1.addDaemon(RIPng, **self.ripng_timers) r2.addDaemon(RIPng, **self.ripng_timers) r3.addDaemon(RIPng, **self.ripng_timers) r4.addDaemon(RIPng, **self.ripng_timers) r5.addDaemon(RIPng, **self.ripng_timers) - h1 = self.addHost('h1') - h3 = self.addHost('h3') - h4 = self.addHost('h4') - h5 = self.addHost('h5') + h1 = self.addHost("h1") + h3 = self.addHost("h3") + h4 = self.addHost("h4") + h5 = self.addHost("h5") self.addLinks((h1, r1), (h3, r3), (h4, r4), (h5, r5)) diff --git a/ipmininet/examples/router_adv_network.py b/ipmininet/examples/router_adv_network.py index 610cf9a0..6c6675c4 100644 --- a/ipmininet/examples/router_adv_network.py +++ b/ipmininet/examples/router_adv_network.py @@ -2,12 +2,10 @@ addresses and to advertise DNS server's addresses""" from ipmininet.iptopo import IPTopo -from ipmininet.router.config import RouterConfig, RADVD, AdvConnectedPrefix,\ - AdvRDNSS +from ipmininet.router.config import RADVD, AdvConnectedPrefix, AdvRDNSS, RouterConfig class RouterAdvNet(IPTopo): - def build(self, *args, **kwargs): """ +---+ +---+ +------------+ @@ -20,15 +18,16 @@ def build(self, *args, **kwargs): server. Note that the DNS service is not actually started and thus the host won't get a DNS reply. """ - r = self.addRouter('r', config=RouterConfig, use_v4=False, - use_v6=True) + r = self.addRouter("r", config=RouterConfig, use_v4=False, use_v6=True) r.addDaemon(RADVD) - h = self.addHost('h') - dns = self.addHost('dns') + h = self.addHost("h") + dns = self.addHost("dns") lrh = self.addLink(r, h) - lrh[r].addParams(ip=("2001:1341::1/64", "2001:2141::1/64"), - ra=[AdvConnectedPrefix()], - rdnss=[AdvRDNSS(dns)]) + lrh[r].addParams( + ip=("2001:1341::1/64", "2001:2141::1/64"), + ra=[AdvConnectedPrefix()], + rdnss=[AdvRDNSS(dns)], + ) lrdns = self.addLink(r, dns) lrdns[r].addParams(ip=("2001:89ab::1/64", "2001:cdef::1/64")) lrdns[dns].addParams(ip=("2001:89ab::d/64", "2001:cdef::d/64")) diff --git a/ipmininet/examples/simple_bgp_network.py b/ipmininet/examples/simple_bgp_network.py index 71641d5c..61e7e712 100644 --- a/ipmininet/examples/simple_bgp_network.py +++ b/ipmininet/examples/simple_bgp_network.py @@ -1,30 +1,31 @@ from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BorderRouterConfig, BGP, ebgp_session +from ipmininet.router.config import BorderRouterConfig, ebgp_session class SimpleBGPTopo(IPTopo): """This topology builds a 3-AS network exchanging BGP reachability information""" + def build(self, *args, **kwargs): """ - +----------+ +--------+ - | | - AS1 | AS2 | AS3 - | | - | | - +-------+ eBGP | +-------+ iBGP +-------+ | eBGP +-------+ - | as1r1 +------------+ as2r1 +-------------+ as2r2 +------------+ as3r1 | - +-------+ | +-------+ +-------+ | +-------+ - | | - | | - | | - +------------+ +--------+ + +----------+ +--------+ + | | + AS1 | AS2 | AS3 + | | + | | + +-------+ eBGP | +-------+ iBGP +-------+ | eBGP +-------+ + | as1r1 +------------+ as2r1 +-------------+ as2r2 +------------+ as3r1 | + +-------+ | +-------+ +-------+ | +-------+ + | | + | | + | | + +------------+ +--------+ """ # Add all routers - as1r1 = self.bgp('as1r1') - as2r1 = self.bgp('as2r1') - as2r2 = self.bgp('as2r2') - as3r1 = self.bgp('as3r1') + as1r1 = self.bgp("as1r1") + as2r1 = self.bgp("as2r1") + as2r2 = self.bgp("as2r2") + as3r1 = self.bgp("as3r1") self.addLinks((as1r1, as2r1), (as2r1, as2r2), (as3r1, as2r2)) # Set AS-ownerships self.addAS(1, (as1r1,)) @@ -35,7 +36,7 @@ def build(self, *args, **kwargs): ebgp_session(self, as3r1, as2r2) # Add test hosts for r in self.routers(): - self.addLink(r, self.addHost('h%s' % r)) + self.addLink(r, self.addHost("h%s" % r)) super().build(*args, **kwargs) def bgp(self, name): diff --git a/ipmininet/examples/simple_openr_network.py b/ipmininet/examples/simple_openr_network.py index dfdff09f..616671df 100644 --- a/ipmininet/examples/simple_openr_network.py +++ b/ipmininet/examples/simple_openr_network.py @@ -1,8 +1,8 @@ """This file contains a simple OpenR topology""" from ipmininet.iptopo import IPTopo -from ipmininet.router import OpenrRouter from ipmininet.node_description import OpenrRouterDescription +from ipmininet.router import OpenrRouter from ipmininet.router.config import OpenrRouterConfig HOSTS_PER_ROUTER = 2 @@ -10,26 +10,30 @@ class SimpleOpenrNet(IPTopo): """ - +----+ +----+ - | r1 +-----+ r2 | - +--+-+ +----+ - | - | +----+ - +-------+ r3 | - +----+ + +----+ +----+ + | r1 +-----+ r2 | + +--+-+ +----+ + | + | +----+ + +-------+ r3 | + +----+ """ def build(self, *args, **kwargs): # pylint: disable=unbalanced-tuple-unpacking - r_1, r_2, r_3 = \ - self.addRouters('r_1', 'r_2', 'r_3', - cls=OpenrRouter, - routerDescription=OpenrRouterDescription, - config=OpenrRouterConfig) + r_1, r_2, r_3 = self.addRouters( + "r_1", + "r_2", + "r_3", + cls=OpenrRouter, + routerDescription=OpenrRouterDescription, + config=OpenrRouterConfig, + ) self.addLinks((r_1, r_2), (r_1, r_3)) for router in (r_1, r_2, r_3): for i in range(HOSTS_PER_ROUTER): - self.addLink(router, self.addHost('h%s%s' % (i, router)), - params2={'v4_width': 5}) + self.addLink( + router, self.addHost("h%s%s" % (i, router)), params2={"v4_width": 5} + ) super().build(*args, **kwargs) diff --git a/ipmininet/examples/simple_ospf_network.py b/ipmininet/examples/simple_ospf_network.py index a09a27d1..e81c331d 100644 --- a/ipmininet/examples/simple_ospf_network.py +++ b/ipmininet/examples/simple_ospf_network.py @@ -11,65 +11,62 @@ class SimpleOSPFNet(IPTopo): def build(self, *args, **kwargs): """ - +----+ - +-------+--------------+-----+ S1 +--------+-----------------+---------+ - | | | +--+-+ | | | - | | | | | | | -Management Network (OOB)| | | | | | | -+---------------------------------------------------------------------------------------------------+ - | | | | | | | - | | | +-+--+ 5 | | | - Area 0.0.0.0 | | | +----+ R1 +-----+ | | | - | | | | +----+ | | | | - +---------------------------------+ +------------------------------------------+ - | | +----+ | | +----+ | | - | +--+-+ | R2 +----------+ R3 | +-----+ | - | | R5 +-----------++---+ | | +-+--+-----------+ R6 ++ | - | +----+ | | | | +--+-+ | - | |10 | | | | | | - | | | | | | | | - | +--+-+ | | | | 5 +--+-+ | - +----+ R4 +------------+ | | +--------------+ R7 +----------+ - +----+ | | +----+ - | | - Area 1.1.1.1 | | Area 2.2.2.2 - + + - Two hosts are attached to each router, named as hXY where x is the - host number attached to that router, and Y the router name. + +----+ + +-------+--------------+-----+ S1 +--------+-----------------+---------+ + | | | +--+-+ | | | + | | | | | | | + Management Network (OOB)| | | | | | | + +---------------------------------------------------------------------------------------------------+ + | | | | | | | + | | | +-+--+ 5 | | | + Area 0.0.0.0 | | | +----+ R1 +-----+ | | | + | | | | +----+ | | | | + +---------------------------------+ +------------------------------------------+ + | | +----+ | | +----+ | | + | +--+-+ | R2 +----------+ R3 | +-----+ | + | | R5 +-----------++---+ | | +-+--+-----------+ R6 ++ | + | +----+ | | | | +--+-+ | + | |10 | | | | | | + | | | | | | | | + | +--+-+ | | | | 5 +--+-+ | + +----+ R4 +------------+ | | +--------------+ R7 +----------+ + +----+ | | +----+ + | | + Area 1.1.1.1 | | Area 2.2.2.2 + + + + Two hosts are attached to each router, named as hXY where x is the + host number attached to that router, and Y the router name. """ # Build backbone - r1, r2, r3 = self.addRouters('r1', 'r2', 'r3', use_v4=True, - use_v6=False) + r1, r2, r3 = self.addRouters("r1", "r2", "r3", use_v4=True, use_v6=False) self.addLink(r1, r2) self.addLink(r1, r3, igp_metric=5) self.addLink(r3, r2) for r in (r1, r2, r3): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost('h%s%s' % (i, r)), - params2={'v4_width': 5}) + self.addLink(r, self.addHost("h%s%s" % (i, r)), params2={"v4_width": 5}) # Area 1.1.1.1 is delimited by an OSPFArea overlay - r4, r5 = self.addRouters('r4', 'r5', use_v4=True, use_v6=False) + r4, r5 = self.addRouters("r4", "r5", use_v4=True, use_v6=False) self.addLink(r2, r5) self.addLink(r2, r4) self.addLink(r4, r5, igp_metric=10) for r in (r4, r5): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost('h%s%s' % (i, r))) - self.addOSPFArea(routers=(r4, r5), area='1.1.1.1') + self.addLink(r, self.addHost("h%s%s" % (i, r))) + self.addOSPFArea(routers=(r4, r5), area="1.1.1.1") # Area 2.2.2.2 is delimited by the igp_area parameter of addLink() - r6, r7 = self.addRouters('r6', 'r7', use_v4=True, use_v6=False) - self.addLink(r3, r6, igp_area='2.2.2.2') - self.addLink(r3, r7, igp_area='2.2.2.2', igp_metric=5) - self.addLink(r6, r7, igp_area='2.2.2.2') + r6, r7 = self.addRouters("r6", "r7", use_v4=True, use_v6=False) + self.addLink(r3, r6, igp_area="2.2.2.2") + self.addLink(r3, r7, igp_area="2.2.2.2", igp_metric=5) + self.addLink(r6, r7, igp_area="2.2.2.2") for r in (r6, r7): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost('h%s%s' % (i, r)), - igp_area='2.2.2.2') + self.addLink(r, self.addHost("h%s%s" % (i, r)), igp_area="2.2.2.2") # Management network - s1 = self.addSwitch('s1') + s1 = self.addSwitch("s1") for r in self.routers(): self.addLink(s1, r, igp_passive=True) diff --git a/ipmininet/examples/simple_ospfv3_network.py b/ipmininet/examples/simple_ospfv3_network.py index e1e87945..a42fc931 100644 --- a/ipmininet/examples/simple_ospfv3_network.py +++ b/ipmininet/examples/simple_ospfv3_network.py @@ -6,7 +6,6 @@ class SimpleOSPFv3Net(IPTopo): - def build(self, *args, **kwargs): """ +----+ 5 @@ -26,29 +25,28 @@ def build(self, *args, **kwargs): Two hosts are attached to each router, named as hXY where x is the host number attached to that router, and Y the router name. """ - r1, r2, r3 = self.addRouters('r1', 'r2', 'r3', use_v4=False, - use_v6=True) + r1, r2, r3 = self.addRouters("r1", "r2", "r3", use_v4=False, use_v6=True) self.addLink(r1, r2) self.addLink(r1, r3, igp_metric=5) self.addLink(r3, r2) for r in (r1, r2, r3): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost('h%s%s' % (i, r))) + self.addLink(r, self.addHost("h%s%s" % (i, r))) - r4, r5 = self.addRouters('r4', 'r5', use_v4=False, use_v6=True) + r4, r5 = self.addRouters("r4", "r5", use_v4=False, use_v6=True) self.addLink(r2, r5) self.addLink(r2, r4) self.addLink(r4, r5, igp_metric=10) for r in (r4, r5): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost('h%s%s' % (i, r))) + self.addLink(r, self.addHost("h%s%s" % (i, r))) - r6, r7 = self.addRouters('r6', 'r7', use_v4=False, use_v6=True) + r6, r7 = self.addRouters("r6", "r7", use_v4=False, use_v6=True) self.addLink(r3, r6) self.addLink(r3, r7, igp_metric=5) self.addLink(r6, r7) for r in (r6, r7): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost('h%s%s' % (i, r))) + self.addLink(r, self.addHost("h%s%s" % (i, r))) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree.py b/ipmininet/examples/spanning_tree.py index 22d48a7b..4a595c36 100644 --- a/ipmininet/examples/spanning_tree.py +++ b/ipmininet/examples/spanning_tree.py @@ -1,13 +1,12 @@ -"""This file contains a simple switch topology using the spanning tree protocol -""" +"""This file contains a simple switch topology using the spanning tree protocol""" from ipmininet.iptopo import IPTopo class SpanningTreeNet(IPTopo): """This simple network has a LAN with redundant links. - The IPSwitch class enables the spanning tree protocol by default - to prevent loops.""" + The IPSwitch class enables the spanning tree protocol by default + to prevent loops.""" def build(self, *args, **kwargs): """ @@ -22,11 +21,11 @@ def build(self, *args, **kwargs): | hs2 +-----+ s2 +----------+ s3 +-----+ hs3 | +-----+ +----+ +----+ +-----+ """ - s1 = self.addSwitch('s1', prio='3') - s2 = self.addSwitch('s2', prio='2') - s3 = self.addSwitch('s3', prio='1') + s1 = self.addSwitch("s1", prio="3") + s2 = self.addSwitch("s2", prio="2") + s3 = self.addSwitch("s3", prio="1") self.addLinks((s1, s2), (s1, s3), (s3, s2)) for s in (s1, s2, s3): - self.addLink(s, self.addHost('h%s' % s)) + self.addLink(s, self.addHost("h%s" % s)) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree_adjust.py b/ipmininet/examples/spanning_tree_adjust.py index 1d2b844e..c6535f75 100644 --- a/ipmininet/examples/spanning_tree_adjust.py +++ b/ipmininet/examples/spanning_tree_adjust.py @@ -2,9 +2,17 @@ class SpanningTreeAdjust(IPTopo): - - def __init__(self, l1_start=None, l1_end=None, l1_cost=1, - l2_start=None, l2_end=None, l2_cost=1, *args, **kwargs): + def __init__( + self, + l1_start=None, + l1_end=None, + l1_cost=1, + l2_start=None, + l2_end=None, + l2_cost=1, + *args, + **kwargs, + ): """ :param l1_start: Endpoint interface of the 1st link on which we want to change the cost @@ -30,84 +38,84 @@ def __init__(self, l1_start=None, l1_end=None, l1_cost=1, def build(self, *args, **kwargs): r""" - === Full topology === - - +------------------------+ - |s6.2 | - +--+--+ +-----+ | - | s6 + | s2 | | - +--+--+ ++---++ | - |s6.1 s2.1| |s2.2 | - | | | | - |s3.4 s1.1| |s4.2 | - +--+--+s3.1 s1.3++---++ | - | s3 +----------+ s1 | | - +--+--+ +--+--+ | - s3.2| \s3.3 |s1.4 | - | \ | | - | \ | | - | \ | | - | \ | | - | \ | | - | \ | | - | \ | | - | \ | | - |s4.1 s5.2\ |s5.1 | - +--+--+ +--+--+ | - | s4 +----------+ s5 +----+ - +-----+s4.2 s5.3+-----+ s5.4 - - === Spanning tree === - - +--+--+ +-----+ - | s6 + | s2 | - +--+--+ ++---++ - |s6.1 s2.1| - | | - |s3.4 s1.1| - +--+--+s3.1 s1.3++---++ - | s3 +----------+ s1 | - +--+--+ +--+--+ - s3.2| |s1.4 - | | - | | - | | - | | - | | - | | - | | - | | - |s4.1 |s5.1 - +--+--+ +--+--+ - | s4 + + s5 + - +-----+ +-----+ - - === Adjust 2 weights to get === - - +------------------------+ - |s6.2 | - +--+--+ +-----+ | - | s6 + | s2 | | - +--+--+ ++---++ | - |s2.2 | - | | - |s4.2 | - +--+--+ ++---++ | - | s3 + + s1 | | - +--+--+ +--+--+ | - \s3.3 |s1.4 | - \ | | - \ | | - \ | | - \ | | - \ | | - \ | | - \ | | - \ | | - s5.2\ |s5.1 | - +--+--+ +--+--+ | - | s4 +----------+ s5 +----+ - +-----+s4.2 s5.3+-----+ s5.4 + === Full topology === + + +------------------------+ + |s6.2 | + +--+--+ +-----+ | + | s6 + | s2 | | + +--+--+ ++---++ | + |s6.1 s2.1| |s2.2 | + | | | | + |s3.4 s1.1| |s4.2 | + +--+--+s3.1 s1.3++---++ | + | s3 +----------+ s1 | | + +--+--+ +--+--+ | + s3.2| \s3.3 |s1.4 | + | \ | | + | \ | | + | \ | | + | \ | | + | \ | | + | \ | | + | \ | | + | \ | | + |s4.1 s5.2\ |s5.1 | + +--+--+ +--+--+ | + | s4 +----------+ s5 +----+ + +-----+s4.2 s5.3+-----+ s5.4 + + === Spanning tree === + + +--+--+ +-----+ + | s6 + | s2 | + +--+--+ ++---++ + |s6.1 s2.1| + | | + |s3.4 s1.1| + +--+--+s3.1 s1.3++---++ + | s3 +----------+ s1 | + +--+--+ +--+--+ + s3.2| |s1.4 + | | + | | + | | + | | + | | + | | + | | + | | + |s4.1 |s5.1 + +--+--+ +--+--+ + | s4 + + s5 + + +-----+ +-----+ + + === Adjust 2 weights to get === + + +------------------------+ + |s6.2 | + +--+--+ +-----+ | + | s6 + | s2 | | + +--+--+ ++---++ | + |s2.2 | + | | + |s4.2 | + +--+--+ ++---++ | + | s3 + + s1 | | + +--+--+ +--+--+ | + \s3.3 |s1.4 | + \ | | + \ | | + \ | | + \ | | + \ | | + \ | | + \ | | + \ | | + s5.2\ |s5.1 | + +--+--+ +--+--+ | + | s4 +----------+ s5 +----+ + +-----+s4.2 s5.3+-----+ s5.4 """ # adding switches s1 = self.addSwitch("s1", prio=1) @@ -118,23 +126,32 @@ def build(self, *args, **kwargs): s6 = self.addSwitch("s6", prio=6) # adding links - self.addLinks((s1, s2), (s1, s2), (s1, s3), (s1, s5), (s3, s4), - (s3, s5), (s3, s6), (s4, s5), (s5, s6)) + self.addLinks( + (s1, s2), + (s1, s2), + (s1, s3), + (s1, s5), + (s3, s4), + (s3, s5), + (s3, s6), + (s4, s5), + (s5, s6), + ) for s in self.switches(): - self.addLink(s, self.addHost('h%s' % s)) + self.addLink(s, self.addHost("h%s" % s)) super().build(*args, **kwargs) - def addLink(self, node1, node2, port1=None, port2=None, - key=None, **opts): + def addLink(self, node1, node2, port1=None, port2=None, key=None, **opts): - link = super().addLink(node1, node2, port1=port1, port2=port2, - key=key, **opts) + link = super().addLink(node1, node2, port1=port1, port2=port2, key=key, **opts) # Adjust STP cost if the link was in the parameters - itfs = ["%s-eth%d" % (node1, link.link_attrs["port1"]), - "%s-eth%d" % (node2, link.link_attrs["port2"])] + itfs = [ + "%s-eth%d" % (node1, link.link_attrs["port1"]), + "%s-eth%d" % (node2, link.link_attrs["port2"]), + ] if self.l1_start in itfs and self.l1_end in itfs: link[0].addParams(stp_cost=self.l1_cost) link[1].addParams(stp_cost=self.l1_cost) diff --git a/ipmininet/examples/spanning_tree_bus.py b/ipmininet/examples/spanning_tree_bus.py index a54e2164..8ba60173 100644 --- a/ipmininet/examples/spanning_tree_bus.py +++ b/ipmininet/examples/spanning_tree_bus.py @@ -2,21 +2,20 @@ class SpanningTreeBus(IPTopo): - def build(self, *args, **kwargs): """ - +-----+ - | s1 | - +--+--+ - | - | - +--+--+ - +-----+ BUS +-----+ - | +-----+ | - | | - +--+--+ +--+--+ - | s2 | | s3 | - +-----+ +-----+ + +-----+ + | s1 | + +--+--+ + | + | + +--+--+ + +-----+ BUS +-----+ + | +-----+ | + | | + +--+--+ +--+--+ + | s2 | | s3 | + +-----+ +-----+ """ s1 = self.addSwitch("s1") s2 = self.addSwitch("s2") @@ -32,6 +31,6 @@ def build(self, *args, **kwargs): self.addLink(s1, s3) for s in (s1, s2, s3): - self.addLink(s, self.addHost('h%s' % s)) + self.addLink(s, self.addHost("h%s" % s)) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree_cost.py b/ipmininet/examples/spanning_tree_cost.py index 06e28d77..3af86d92 100644 --- a/ipmininet/examples/spanning_tree_cost.py +++ b/ipmininet/examples/spanning_tree_cost.py @@ -2,7 +2,6 @@ class SpanningTreeCost(IPTopo): - def build(self, *args, **kwargs): r""" +-----+ @@ -23,6 +22,6 @@ def build(self, *args, **kwargs): self.addLink(s3, s1, stp_cost=10) for s in self.switches(): - self.addLink(s, self.addHost('h%s' % s)) + self.addLink(s, self.addHost("h%s" % s)) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree_full_mesh.py b/ipmininet/examples/spanning_tree_full_mesh.py index 8871e758..9fa577a3 100644 --- a/ipmininet/examples/spanning_tree_full_mesh.py +++ b/ipmininet/examples/spanning_tree_full_mesh.py @@ -2,7 +2,6 @@ class SpanningTreeFullMesh(IPTopo): - def build(self, *args, **kwargs): r""" +-----+ +-----+ @@ -42,11 +41,24 @@ def build(self, *args, **kwargs): s12 = self.addSwitch("s12", prio=12) s17 = self.addSwitch("s17", prio=17) - self.addLinks((s10, s1), (s10, s2), (s11, s1), (s11, s2), (s1, s2), - (s1, s3), (s1, s4), (s2, s3), (s2, s4), (s3, s4), - (s3, s12), (s3, s17), (s4, s12), (s4, s17)) + self.addLinks( + (s10, s1), + (s10, s2), + (s11, s1), + (s11, s2), + (s1, s2), + (s1, s3), + (s1, s4), + (s2, s3), + (s2, s4), + (s3, s4), + (s3, s12), + (s3, s17), + (s4, s12), + (s4, s17), + ) for s in (s10, s11, s1, s2, s3, s4, s12, s17): - self.addLink(s, self.addHost('h%s' % s)) + self.addLink(s, self.addHost("h%s" % s)) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree_hub.py b/ipmininet/examples/spanning_tree_hub.py index 0263e1e4..ca2e13e9 100644 --- a/ipmininet/examples/spanning_tree_hub.py +++ b/ipmininet/examples/spanning_tree_hub.py @@ -2,27 +2,26 @@ class SpanningTreeHub(IPTopo): - def build(self, *args, **kwargs): """ - +-----+s3.2 || s17.1+-----+s17.2 || s10.1+-----+ - | s3 +-------||--------+ s17 +-------||-------+ s10 | - +-----+ || +-----+ || +--+--+ - |s3.1 || | s10.2 - | || | - | ||[hub s99] ======= - | || | - | || |s11.1 - | || s6.2+-----+ s6.3 || s11.3+--+--+ - ======= ||--------+ s6 +-------||-------+ s11 | - | || +--+--+ || +--+--+ - | |s6.1 |s11.2 - | | | - | ===================================[hub s100] - |s12.1 | - +--+--+ | - | s12 +---------------+ - +-----+s12.2 + +-----+s3.2 || s17.1+-----+s17.2 || s10.1+-----+ + | s3 +-------||--------+ s17 +-------||-------+ s10 | + +-----+ || +-----+ || +--+--+ + |s3.1 || | s10.2 + | || | + | ||[hub s99] ======= + | || | + | || |s11.1 + | || s6.2+-----+ s6.3 || s11.3+--+--+ + ======= ||--------+ s6 +-------||-------+ s11 | + | || +--+--+ || +--+--+ + | |s6.1 |s11.2 + | | | + | ===================================[hub s100] + |s12.1 | + +--+--+ | + | s12 +---------------+ + +-----+s12.2 """ # adding switches s3 = self.addSwitch("s3", prio=3) @@ -36,10 +35,20 @@ def build(self, *args, **kwargs): s100 = self.addHub("s100") # links - self.addLinks((s3, s12), (s3, s99), (s12, s100), (s6, s100), (s17, s99), - (s10, s17), (s11, s10), (s11, s100), (s6, s99), (s6, s11)) + self.addLinks( + (s3, s12), + (s3, s99), + (s12, s100), + (s6, s100), + (s17, s99), + (s10, s17), + (s11, s10), + (s11, s100), + (s6, s99), + (s6, s11), + ) for s in self.switches(): - self.addLink(s, self.addHost('h%s' % s)) + self.addLink(s, self.addHost("h%s" % s)) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree_intermediate.py b/ipmininet/examples/spanning_tree_intermediate.py index 3f76f78b..3b75dadd 100644 --- a/ipmininet/examples/spanning_tree_intermediate.py +++ b/ipmininet/examples/spanning_tree_intermediate.py @@ -2,7 +2,6 @@ class SpanningTreeIntermediate(IPTopo): - def build(self, *args, **kwargs): """ +-----+ @@ -25,10 +24,9 @@ def build(self, *args, **kwargs): s9 = self.addSwitch("s9", prio=9) s10 = self.addSwitch("s10", prio=10) - self.addLinks((s2, s9), (s9, s5), (s5, s4), (s2, s10), (s4, s10), - (s5, s10)) + self.addLinks((s2, s9), (s9, s5), (s5, s4), (s2, s10), (s4, s10), (s5, s10)) for s in (s2, s4, s5, s9, s10): - self.addLink(s, self.addHost('h%s' % s)) + self.addLink(s, self.addHost("h%s" % s)) super().build(*args, **kwargs) diff --git a/ipmininet/examples/srv6.py b/ipmininet/examples/srv6.py index 94fcfbf9..08473080 100644 --- a/ipmininet/examples/srv6.py +++ b/ipmininet/examples/srv6.py @@ -1,47 +1,41 @@ """This modules show how to add IPv6 Segment Routes on top of an existing - network. For instance, it introduces rerouting on h1 the routing of the - traffic to h4 through r1, r6, r5, r2, r3 and r4 instead of taking the - shortest IGP path.""" +network. For instance, it introduces rerouting on h1 the routing of the +traffic to h4 through r1, r6, r5, r2, r3 and r4 instead of taking the +shortest IGP path.""" + from ipmininet.iptopo import IPTopo -from ipmininet.srv6 import SRv6Encap, SRv6EndXFunction, LocalSIDTable +from ipmininet.srv6 import LocalSIDTable, SRv6Encap, SRv6EndXFunction class SRv6Topo(IPTopo): - def __init__(self, *args, **kwargs): self.tables = {} - super(SRv6Topo, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def build(self, *args, **kwargs): """ - +-----+ +-----+ +-----+ +-----+ +-----+ - | h1 +-----+ r1 +-----+ r2 +-----+ r3 +-----+ h3 | - +-----+ +--+--+ +--+--+ +--+--+ +-----+ - 4 | 3 | 9 | - +-----+ +--+--+ +--+--+ +--+--+ +-----+ - | h6 +-----+ r6 +-----+ r5 +-----+ r4 +-----+ h4 | - +-----+ +-----+ +-----+ +-----+ +-----+ + +-----+ +-----+ +-----+ +-----+ +-----+ + | h1 +-----+ r1 +-----+ r2 +-----+ r3 +-----+ h3 | + +-----+ +--+--+ +--+--+ +--+--+ +-----+ + 4 | 3 | 9 | + +-----+ +--+--+ +--+--+ +--+--+ +-----+ + | h6 +-----+ r6 +-----+ r5 +-----+ r4 +-----+ h4 | + +-----+ +-----+ +-----+ +-----+ +-----+ """ # Routers need to have a separate prefix for segments # and those representing interfaces to use SRv6 functions - r1 = self.addRouter('r1', - lo_addresses=["2042:1:1::1/64", "192.168.1.1/24"]) - r2 = self.addRouter('r2', - lo_addresses=["2042:2:2::1/64", "192.168.2.1/24"]) - r3 = self.addRouter('r3', - lo_addresses=["2042:3:3::1/64", "192.168.3.1/24"]) - r4 = self.addRouter('r4', - lo_addresses=["2042:4:4::1/64", "192.168.4.1/24"]) - r5 = self.addRouter('r5', - lo_addresses=["2042:5:5::1/64", "192.168.5.1/24"]) - r6 = self.addRouter('r6', - lo_addresses=["2042:6:6::1/64", "192.168.6.1/24"]) + r1 = self.addRouter("r1", lo_addresses=["2042:1:1::1/64", "192.168.1.1/24"]) + r2 = self.addRouter("r2", lo_addresses=["2042:2:2::1/64", "192.168.2.1/24"]) + r3 = self.addRouter("r3", lo_addresses=["2042:3:3::1/64", "192.168.3.1/24"]) + r4 = self.addRouter("r4", lo_addresses=["2042:4:4::1/64", "192.168.4.1/24"]) + r5 = self.addRouter("r5", lo_addresses=["2042:5:5::1/64", "192.168.5.1/24"]) + r6 = self.addRouter("r6", lo_addresses=["2042:6:6::1/64", "192.168.6.1/24"]) - h1 = self.addHost('h1') - h3 = self.addHost('h3') - h4 = self.addHost('h4') - h6 = self.addHost('h6') + h1 = self.addHost("h1") + h3 = self.addHost("h3") + h4 = self.addHost("h4") + h6 = self.addHost("h6") # Links to hosts self.addLinks((h1, r1), (h3, r3), (h4, r4), (h6, r6)) @@ -58,23 +52,30 @@ def build(self, *args, **kwargs): def post_build(self, net): # Adds an inline SRH on packets to h4 - SRv6Encap(net=net, node="h1", to="h4", - # You can specify the intermediate point with any of the host, - # interface or the address itself - through=["r6", net["r5"].intf("lo"), "2042:3:3::34", "r4"], - mode=SRv6Encap.INLINE) + SRv6Encap( + net=net, + node="h1", + to="h4", + # You can specify the intermediate point with any of the host, + # interface or the address itself + through=["r6", net["r5"].intf("lo"), "2042:3:3::34", "r4"], + mode=SRv6Encap.INLINE, + ) # Every packet on r3 destined to 2042:3:3::/64 except for 2042:3:3::1 # will trigger a lookup to the LocalSIDTable - self.tables["r3"] = LocalSIDTable(net["r3"], - matching=[net["r3"].intf("lo")]) + self.tables["r3"] = LocalSIDTable(net["r3"], matching=[net["r3"].intf("lo")]) # Packets with "2042:3:3::34" as active segment on r3 will be sent # to r3-r4 link # This rule is added to the LocalSIDTable created above - SRv6EndXFunction(net=net, node="r3", to="2042:3:3::34", - nexthop=net["r4"].intf("r4-eth1").ip6, - table=self.tables["r3"]) + SRv6EndXFunction( + net=net, + node="r3", + to="2042:3:3::34", + nexthop=net["r4"].intf("r4-eth1").ip6, + table=self.tables["r3"], + ) super().post_build(net) def clean(self): diff --git a/ipmininet/examples/sshd.py b/ipmininet/examples/sshd.py index 3f2c07bb..46e17247 100644 --- a/ipmininet/examples/sshd.py +++ b/ipmininet/examples/sshd.py @@ -1,11 +1,12 @@ """This modules shows a 2-routers topology which run the sshd daemon.""" + from ipmininet.iptopo import IPTopo -from ipmininet.router.config import SSHd, RouterConfig +from ipmininet.router.config import RouterConfig, SSHd class SSHTopo(IPTopo): def build(self, *args, **kw): - r1, r2 = self.addRouters('r1', 'r2', config=RouterConfig) + r1, r2 = self.addRouters("r1", "r2", config=RouterConfig) self.addLink(r1, r2) r1.addDaemon(SSHd) r2.addDaemon(SSHd) diff --git a/ipmininet/examples/static_address_network.py b/ipmininet/examples/static_address_network.py index e19b5852..b740adfc 100644 --- a/ipmininet/examples/static_address_network.py +++ b/ipmininet/examples/static_address_network.py @@ -4,35 +4,34 @@ class StaticAddressNet(IPTopo): - def build(self, *args, **kwargs): """ - +----+ +----+ +----+ +----+ +----+ - | h1 +-------+ r1 +-------+ r2 +-------+ s2 +-------+ h3 | - +----+ +--+-+ +----+ +----+ +----+ - | - | +----+ +----+ - +---------+ s1 +-------+ h2 | - +--+-+ +----+ - | - | +----+ - +---------+ h4 | - +----+ + +----+ +----+ +----+ +----+ +----+ + | h1 +-------+ r1 +-------+ r2 +-------+ s2 +-------+ h3 | + +----+ +--+-+ +----+ +----+ +----+ + | + | +----+ +----+ + +---------+ s1 +-------+ h2 | + +--+-+ +----+ + | + | +----+ + +---------+ h4 | + +----+ """ - r1 = self.addRouter('r1', lo_addresses=["2042:1::1/64", "10.1.1.1/24"]) - r2 = self.addRouter('r2', lo_addresses=["2042:2::1/64", "10.2.2.1/24"]) + r1 = self.addRouter("r1", lo_addresses=["2042:1::1/64", "10.1.1.1/24"]) + r2 = self.addRouter("r2", lo_addresses=["2042:2::1/64", "10.2.2.1/24"]) - s1 = self.addSwitch('s1') - s2 = self.addSwitch('s2') + s1 = self.addSwitch("s1") + s2 = self.addSwitch("s2") - h1 = self.addHost('h1') - h2 = self.addHost('h2') - h3 = self.addHost('h3') - h4 = self.addHost('h4') + h1 = self.addHost("h1") + h2 = self.addHost("h2") + h3 = self.addHost("h3") + h4 = self.addHost("h4") - _, lr1r2, _, _, _, lr2s2, ls2h3 = \ - self.addLinks((h1, r1), (r1, r2), (r1, s1), (s1, h2), (s1, h4), - (r2, s2), (s2, h3)) + _, lr1r2, _, _, _, lr2s2, ls2h3 = self.addLinks( + (h1, r1), (r1, r2), (r1, s1), (s1, h2), (s1, h4), (r2, s2), (s2, h3) + ) # IP addresses can be set with interface parameters lr2s2[r2].addParams(ip=("10.0.3.1/24", "2001:3c::1/64")) @@ -43,7 +42,6 @@ def build(self, *args, **kwargs): # interfaces requiring an address for each subnet self.addSubnet(nodes=[r1, h1], subnets=["10.0.0.0/24", "2001:1a::/64"]) self.addSubnet(links=[lr1r2], subnets=["10.1.0.0/24", "2001:12::/64"]) - self.addSubnet(nodes=[r1, h2, h4], - subnets=["10.2.0.0/24", "2001:12b::/64"]) + self.addSubnet(nodes=[r1, h2, h4], subnets=["10.2.0.0/24", "2001:12b::/64"]) super().build(*args, **kwargs) diff --git a/ipmininet/examples/static_routing.py b/ipmininet/examples/static_routing.py index cf54bad8..196b18c1 100644 --- a/ipmininet/examples/static_routing.py +++ b/ipmininet/examples/static_routing.py @@ -1,17 +1,18 @@ from ipmininet.iptopo import IPTopo -from ipmininet.router.config import RouterConfig, STATIC, StaticRoute +from ipmininet.router.config import STATIC, RouterConfig, StaticRoute class StaticRoutingNet(IPTopo): - def build(self, *args, **kwargs): # Change the config object for RouterConfig # because it does not add by default OSPF or OSPF6 - r1 = self.addRouter("r1", config=RouterConfig, - lo_addresses=["2042:1::1/64", "10.1.1.1/24"]) - r2 = self.addRouter("r2", config=RouterConfig, - lo_addresses=["2042:2::1/64", "10.2.2.1/24"]) + r1 = self.addRouter( + "r1", config=RouterConfig, lo_addresses=["2042:1::1/64", "10.1.1.1/24"] + ) + r2 = self.addRouter( + "r2", config=RouterConfig, lo_addresses=["2042:2::1/64", "10.2.2.1/24"] + ) h1 = self.addHost("h1") h2 = self.addHost("h2") @@ -27,11 +28,19 @@ def build(self, *args, **kwargs): lr2h2[h2].addParams(ip=("2042:2b::b/64", "10.62.0.6/24")) # Add static routes - r1.addDaemon(STATIC, - static_routes=[StaticRoute("2042:2b::/64", "2042:12::2"), - StaticRoute("10.62.0.0/24", "10.12.0.2")]) - r2.addDaemon(STATIC, - static_routes=[StaticRoute("2042:1a::/64", "2042:12::1"), - StaticRoute("10.51.0.0/24", "10.12.0.1")]) + r1.addDaemon( + STATIC, + static_routes=[ + StaticRoute("2042:2b::/64", "2042:12::2"), + StaticRoute("10.62.0.0/24", "10.12.0.2"), + ], + ) + r2.addDaemon( + STATIC, + static_routes=[ + StaticRoute("2042:1a::/64", "2042:12::1"), + StaticRoute("10.51.0.0/24", "10.12.0.1"), + ], + ) super().build(*args, **kwargs) diff --git a/ipmininet/examples/static_routing_failure.py b/ipmininet/examples/static_routing_failure.py index 6e0b3ebc..42df1678 100644 --- a/ipmininet/examples/static_routing_failure.py +++ b/ipmininet/examples/static_routing_failure.py @@ -1,37 +1,44 @@ """This file contains an example of topology using static routing. - There is no connectivity between all the routers. - Changing one routing table would ensure connectivity.""" +There is no connectivity between all the routers. +Changing one routing table would ensure connectivity.""" from ipmininet.iptopo import IPTopo -from ipmininet.router.config import RouterConfig, STATIC, StaticRoute +from ipmininet.router.config import STATIC, RouterConfig, StaticRoute class StaticRoutingNetFailure(IPTopo): - def build(self, *args, **kwargs): r""" - +-----+ +-----+ +-----+ +-----+ +-----+ - | h1 +-----+ r1 +-----+ r2 +-----+ r3 +-----+ h3 | - +-----+ +--+--+ +--+--+ +--+--+ +-----+ - | | \ / | - | | \ / | - | | \ | - | | / \ | - | | / \ | - +-----+ +--+--+ +--+--+ +--+--+ +-----+ - | h6 +-----+ r6 +-----+ r5 +-----+ r4 +-----+ h4 | - +-----+ +-----+ +-----+ +-----+ +-----+ + +-----+ +-----+ +-----+ +-----+ +-----+ + | h1 +-----+ r1 +-----+ r2 +-----+ r3 +-----+ h3 | + +-----+ +--+--+ +--+--+ +--+--+ +-----+ + | | \ / | + | | \ / | + | | \ | + | | / \ | + | | / \ | + +-----+ +--+--+ +--+--+ +--+--+ +-----+ + | h6 +-----+ r6 +-----+ r5 +-----+ r4 +-----+ h4 | + +-----+ +-----+ +-----+ +-----+ +-----+ """ - r1, r2, r3, r4, r5, r6 = \ - self.addRouters('r1', 'r2', 'r3', 'r4', 'r5', 'r6', - use_v4=False, use_v6=True, config=RouterConfig) + r1, r2, r3, r4, r5, r6 = self.addRouters( + "r1", + "r2", + "r3", + "r4", + "r5", + "r6", + use_v4=False, + use_v6=True, + config=RouterConfig, + ) - h1 = self.addHost('h1', use_v4=False, use_v6=True) - h3 = self.addHost('h3', use_v4=False, use_v6=True) - h4 = self.addHost('h4', use_v4=False, use_v6=True) - h6 = self.addHost('h6', use_v4=False, use_v6=True) + h1 = self.addHost("h1", use_v4=False, use_v6=True) + h3 = self.addHost("h3", use_v4=False, use_v6=True) + h4 = self.addHost("h4", use_v4=False, use_v6=True) + h6 = self.addHost("h6", use_v4=False, use_v6=True) self.addLinks((h1, r1), (h3, r3), (h4, r4), (h6, r6)) @@ -68,37 +75,55 @@ def build(self, *args, **kwargs): self.addSubnet(nodes=[r4, h4], subnets=["2042:44::/64"]) self.addSubnet(nodes=[r6, h6], subnets=["2042:66::/64"]) - r1.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:33::2", nexthop="2042:12::2"), - StaticRoute(prefix="2042:66::2", nexthop="2042:16::2"), - StaticRoute(prefix="2042:44::2", nexthop="2042:12::2"), - ]) - r2.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:11::2", nexthop="2042:25::2"), - StaticRoute(prefix="2042:33::2", nexthop="2042:25::2"), - StaticRoute(prefix="2042:44::2", nexthop="2042:24::2"), - StaticRoute(prefix="2042:66::2", nexthop="2042:12::1") - ]) - r3.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:11::2", nexthop="2042:23::1"), - StaticRoute(prefix="2042:66::2", nexthop="2042:23::1"), - StaticRoute(prefix="2042:44::2", nexthop="2042:23::1"), - ]) - r4.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:11::2", nexthop="2042:34::1"), - StaticRoute(prefix="2042:66::2", nexthop="2042:45::2"), - StaticRoute(prefix="2042:33::2", nexthop="2042:34::1") - ]) - r5.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:11::2", nexthop="2042:23::2"), - StaticRoute(prefix="2042:33::2", nexthop="2042:56::1"), - StaticRoute(prefix="2042:44::2", nexthop="2042:56::1"), - StaticRoute(prefix="2042:66::2", nexthop="2042:45::1"), - ]) - r6.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:11::2", nexthop="2042:16::1"), - StaticRoute(prefix="2042:33::2", nexthop="2042:56::1"), - StaticRoute(prefix="2042:44::2", nexthop="2042:16::1"), - ]) + r1.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:33::2", nexthop="2042:12::2"), + StaticRoute(prefix="2042:66::2", nexthop="2042:16::2"), + StaticRoute(prefix="2042:44::2", nexthop="2042:12::2"), + ], + ) + r2.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:11::2", nexthop="2042:25::2"), + StaticRoute(prefix="2042:33::2", nexthop="2042:25::2"), + StaticRoute(prefix="2042:44::2", nexthop="2042:24::2"), + StaticRoute(prefix="2042:66::2", nexthop="2042:12::1"), + ], + ) + r3.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:11::2", nexthop="2042:23::1"), + StaticRoute(prefix="2042:66::2", nexthop="2042:23::1"), + StaticRoute(prefix="2042:44::2", nexthop="2042:23::1"), + ], + ) + r4.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:11::2", nexthop="2042:34::1"), + StaticRoute(prefix="2042:66::2", nexthop="2042:45::2"), + StaticRoute(prefix="2042:33::2", nexthop="2042:34::1"), + ], + ) + r5.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:11::2", nexthop="2042:23::2"), + StaticRoute(prefix="2042:33::2", nexthop="2042:56::1"), + StaticRoute(prefix="2042:44::2", nexthop="2042:56::1"), + StaticRoute(prefix="2042:66::2", nexthop="2042:45::1"), + ], + ) + r6.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:11::2", nexthop="2042:16::1"), + StaticRoute(prefix="2042:33::2", nexthop="2042:56::1"), + StaticRoute(prefix="2042:44::2", nexthop="2042:16::1"), + ], + ) super().build(*args, **kwargs) diff --git a/ipmininet/examples/static_routing_network_basic.py b/ipmininet/examples/static_routing_network_basic.py index e20281a9..2024f1ea 100644 --- a/ipmininet/examples/static_routing_network_basic.py +++ b/ipmininet/examples/static_routing_network_basic.py @@ -1,7 +1,7 @@ """This file contains another example of static routing.""" from ipmininet.iptopo import IPTopo -from ipmininet.router.config import RouterConfig, STATIC, StaticRoute +from ipmininet.router.config import STATIC, RouterConfig, StaticRoute class StaticRoutingNetBasic(IPTopo): @@ -21,9 +21,9 @@ class StaticRoutingNetBasic(IPTopo): def build(self, *args, **kwargs): # Change the config object for RouterConfig # because it does not add by default OSPF or OSPF6 - r1, r2, r3, r4 = \ - self.addRouters('r1', 'r2', 'r3', 'r4', - use_v4=False, use_v6=True, config=RouterConfig) + r1, r2, r3, r4 = self.addRouters( + "r1", "r2", "r3", "r4", use_v4=False, use_v6=True, config=RouterConfig + ) # Hosts h1 = self.addHost("h1") @@ -77,23 +77,35 @@ def build(self, *args, **kwargs): lr4h4[h4].addParams(ip="2042:4d::d/64") # Add static routes - r1.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:2b::/64", nexthop="2042:12::2"), # h2->r2 - StaticRoute(prefix="2042:3c::/64", nexthop="2042:13::3"), # h3->r3 - StaticRoute(prefix="2042:4d::/64", nexthop="2042:13::3"), # h4->r3 - ]) - r2.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:1a::/64", nexthop="2042:12::1"), # h1->r1 - StaticRoute(prefix="2042:3c::/64", nexthop="2042:24::4"), # h3->r4 - StaticRoute(prefix="2042:4d::/64", nexthop="2042:24::4"), # h4->r4 - ]) - r3.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042::/16", nexthop="2042:34::4"), # /->r4 - ]) - r4.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:1a::/64", nexthop="2042:14::1"), # h1->r1 - StaticRoute(prefix="2042:2b::/64", nexthop="2042:24::2"), # h2->r2 - StaticRoute(prefix="2042:3c::/64", nexthop="2042:14::1"), # h3->r1 - ]) + r1.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:2b::/64", nexthop="2042:12::2"), # h2->r2 + StaticRoute(prefix="2042:3c::/64", nexthop="2042:13::3"), # h3->r3 + StaticRoute(prefix="2042:4d::/64", nexthop="2042:13::3"), # h4->r3 + ], + ) + r2.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:1a::/64", nexthop="2042:12::1"), # h1->r1 + StaticRoute(prefix="2042:3c::/64", nexthop="2042:24::4"), # h3->r4 + StaticRoute(prefix="2042:4d::/64", nexthop="2042:24::4"), # h4->r4 + ], + ) + r3.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042::/16", nexthop="2042:34::4"), # /->r4 + ], + ) + r4.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:1a::/64", nexthop="2042:14::1"), # h1->r1 + StaticRoute(prefix="2042:2b::/64", nexthop="2042:24::2"), # h2->r2 + StaticRoute(prefix="2042:3c::/64", nexthop="2042:14::1"), # h3->r1 + ], + ) super().build(*args, **kwargs) diff --git a/ipmininet/examples/static_routing_network_complex.py b/ipmininet/examples/static_routing_network_complex.py index 4fe22a46..9defd387 100644 --- a/ipmininet/examples/static_routing_network_complex.py +++ b/ipmininet/examples/static_routing_network_complex.py @@ -1,32 +1,39 @@ """This file contains a more complex example of static routing. - The routes are more complex than for the previous example.""" +The routes are more complex than for the previous example.""" from ipmininet.iptopo import IPTopo -from ipmininet.router.config import RouterConfig, STATIC, StaticRoute +from ipmininet.router.config import STATIC, RouterConfig, StaticRoute class StaticRoutingNetComplex(IPTopo): - def build(self, *args, **kwargs): """ - +-----+ +-----+ +-----+ +-----+ +-----+ - | h1 +-----+ r1 +-----+ r2 +-----+ r3 +-----+ h3 | - +-----+ +--+--+ +--+--+ +--+--+ +-----+ - | | | - +-----+ +--+--+ +--+--+ +--+--+ +-----+ - | h6 +-----+ r6 +-----+ r5 +-----+ r4 +-----+ h4 | - +-----+ +-----+ +-----+ +-----+ +-----+ + +-----+ +-----+ +-----+ +-----+ +-----+ + | h1 +-----+ r1 +-----+ r2 +-----+ r3 +-----+ h3 | + +-----+ +--+--+ +--+--+ +--+--+ +-----+ + | | | + +-----+ +--+--+ +--+--+ +--+--+ +-----+ + | h6 +-----+ r6 +-----+ r5 +-----+ r4 +-----+ h4 | + +-----+ +-----+ +-----+ +-----+ +-----+ """ - r1, r2, r3, r4, r5, r6 = \ - self.addRouters('r1', 'r2', 'r3', 'r4', 'r5', 'r6', - use_v4=False, use_v6=True, config=RouterConfig) + r1, r2, r3, r4, r5, r6 = self.addRouters( + "r1", + "r2", + "r3", + "r4", + "r5", + "r6", + use_v4=False, + use_v6=True, + config=RouterConfig, + ) - h1 = self.addHost('h1', use_v4=False, use_v6=True) - h3 = self.addHost('h3', use_v4=False, use_v6=True) - h4 = self.addHost('h4', use_v4=False, use_v6=True) - h6 = self.addHost('h6', use_v4=False, use_v6=True) + h1 = self.addHost("h1", use_v4=False, use_v6=True) + h3 = self.addHost("h3", use_v4=False, use_v6=True) + h4 = self.addHost("h4", use_v4=False, use_v6=True) + h6 = self.addHost("h6", use_v4=False, use_v6=True) self.addLinks((h1, r1), (h3, r3), (h4, r4), (h6, r6)) @@ -57,37 +64,55 @@ def build(self, *args, **kwargs): self.addSubnet(nodes=[r4, h4], subnets=["2042:44::/64"]) self.addSubnet(nodes=[r6, h6], subnets=["2042:66::/64"]) - r1.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:33::2", nexthop="2042:12::2"), - StaticRoute(prefix="2042:66::2", nexthop="2042:12::2"), - StaticRoute(prefix="2042:44::2", nexthop="2042:12::2"), - ]) - r2.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:11::2", nexthop="2042:12::1"), - StaticRoute(prefix="2042:33::2", nexthop="2042:25::2"), - StaticRoute(prefix="2042:44::2", nexthop="2042:25::2"), - StaticRoute(prefix="2042:66::2", nexthop="2042:25::2") - ]) - r3.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:11::2", nexthop="2042:23::1"), - StaticRoute(prefix="2042:66::2", nexthop="2042:23::1"), - StaticRoute(prefix="2042:44::2", nexthop="2042:23::1"), - ]) - r4.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:11::2", nexthop="2042:34::1"), - StaticRoute(prefix="2042:66::2", nexthop="2042:34::1"), - StaticRoute(prefix="2042:33::2", nexthop="2042:34::1") - ]) - r5.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:11::2", nexthop="2042:45::1"), - StaticRoute(prefix="2042:33::2", nexthop="2042:45::1"), - StaticRoute(prefix="2042:44::2", nexthop="2042:45::1"), - StaticRoute(prefix="2042:66::2", nexthop="2042:56::2"), - ]) - r6.addDaemon(STATIC, static_routes=[ - StaticRoute(prefix="2042:11::2", nexthop="2042:16::1"), - StaticRoute(prefix="2042:33::2", nexthop="2042:56::1"), - StaticRoute(prefix="2042:44::2", nexthop="2042:56::1"), - ]) + r1.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:33::2", nexthop="2042:12::2"), + StaticRoute(prefix="2042:66::2", nexthop="2042:12::2"), + StaticRoute(prefix="2042:44::2", nexthop="2042:12::2"), + ], + ) + r2.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:11::2", nexthop="2042:12::1"), + StaticRoute(prefix="2042:33::2", nexthop="2042:25::2"), + StaticRoute(prefix="2042:44::2", nexthop="2042:25::2"), + StaticRoute(prefix="2042:66::2", nexthop="2042:25::2"), + ], + ) + r3.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:11::2", nexthop="2042:23::1"), + StaticRoute(prefix="2042:66::2", nexthop="2042:23::1"), + StaticRoute(prefix="2042:44::2", nexthop="2042:23::1"), + ], + ) + r4.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:11::2", nexthop="2042:34::1"), + StaticRoute(prefix="2042:66::2", nexthop="2042:34::1"), + StaticRoute(prefix="2042:33::2", nexthop="2042:34::1"), + ], + ) + r5.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:11::2", nexthop="2042:45::1"), + StaticRoute(prefix="2042:33::2", nexthop="2042:45::1"), + StaticRoute(prefix="2042:44::2", nexthop="2042:45::1"), + StaticRoute(prefix="2042:66::2", nexthop="2042:56::2"), + ], + ) + r6.addDaemon( + STATIC, + static_routes=[ + StaticRoute(prefix="2042:11::2", nexthop="2042:16::1"), + StaticRoute(prefix="2042:33::2", nexthop="2042:56::1"), + StaticRoute(prefix="2042:44::2", nexthop="2042:56::1"), + ], + ) super().build(*args, **kwargs) diff --git a/ipmininet/examples/tc_advanced_network.py b/ipmininet/examples/tc_advanced_network.py index e1bd094a..2ffced6e 100644 --- a/ipmininet/examples/tc_advanced_network.py +++ b/ipmininet/examples/tc_advanced_network.py @@ -9,7 +9,6 @@ class TCAdvancedNet(IPTopo): - def __init__(self, *args, **kwargs): self.switch_count = 0 super().__init__(*args, **kwargs) @@ -26,8 +25,7 @@ def build(self, *args, **kwargs): super().build(*args, **kwargs) # We need at least 2ms of delay for accurate emulation - def addLink(self, node1, node2, delay="2ms", bw=None, - max_queue_size=None, **opts): + def addLink(self, node1, node2, delay="2ms", bw=None, max_queue_size=None, **opts): src_delay = None dst_delay = None opts1 = dict(opts) @@ -45,8 +43,8 @@ def addLink(self, node1, node2, delay="2ms", bw=None, except KeyError: pass - src_delay = src_delay if src_delay else delay - dst_delay = dst_delay if dst_delay else delay + src_delay = src_delay or delay + dst_delay = dst_delay or delay # node1 -> switch default_params1 = {"bw": bw} @@ -59,16 +57,13 @@ def addLink(self, node1, node2, delay="2ms", bw=None, opts2["params2"] = default_params2 # switch -> node1 - opts1["params2"] = {"delay": dst_delay, - "max_queue_size": max_queue_size} + opts1["params2"] = {"delay": dst_delay, "max_queue_size": max_queue_size} # switch -> node2 - opts2["params1"] = {"delay": src_delay, - "max_queue_size": max_queue_size} + opts2["params1"] = {"delay": src_delay, "max_queue_size": max_queue_size} # Netem queues will mess with shaping # Therefore, we put them on an intermediary switch self.switch_count += 1 s = "s%d" % self.switch_count self.addSwitch(s) - return super().addLink(node1, s, **opts1), \ - super().addLink(s, node2, **opts2) + return super().addLink(node1, s, **opts1), super().addLink(s, node2, **opts2) diff --git a/ipmininet/examples/tc_network.py b/ipmininet/examples/tc_network.py index b025e7ce..0ac4cc40 100644 --- a/ipmininet/examples/tc_network.py +++ b/ipmininet/examples/tc_network.py @@ -4,7 +4,6 @@ class TCNet(IPTopo): - def build(self, *args, **kw): h1 = self.addHost("h1") r1, r2 = self.addRouters("r1", "r2") diff --git a/ipmininet/host/__host.py b/ipmininet/host/__host.py index ad4a957f..6d70d256 100644 --- a/ipmininet/host/__host.py +++ b/ipmininet/host/__host.py @@ -1,28 +1,29 @@ """This modules defines a host class, - with a modular config system.""" -from typing import Type, Union, Tuple, Dict, Optional +with a modular config system.""" import mininet.node as _m +from ipmininet.host.config.base import HostConfig from ipmininet.link import IPIntf from ipmininet.router import IPNode -from ipmininet.host.config.base import HostConfig from ipmininet.utils import realIntfList class IPHost(IPNode): """A Host which manages a set of daemons""" - def __init__(self, name, - config: Union[Type[HostConfig], - Tuple[Type[HostConfig], Dict]] = HostConfig, - *args, **kwargs): + def __init__( + self, + name, + config: type[HostConfig] | tuple[type[HostConfig], dict] = HostConfig, + *args, + **kwargs, + ): super().__init__(name, config=config, *args, **kwargs) - def setDefaultRoute(self, intf: Optional[Union[IPIntf, str]] = None, - v6=False): + def setDefaultRoute(self, intf: IPIntf | str | None = None, v6=False): """Set the default routes to go through the intfs. - intf: Intf or {dev via ...}""" + intf: Intf or {dev via ...}""" if intf is None: return @@ -33,22 +34,22 @@ def setDefaultRoute(self, intf: Optional[Union[IPIntf, str]] = None, option_list = params.split(" ") for i, opt in enumerate(option_list): if opt == "dev": - intf = self.intf(option_list[i+1]) + intf = self.intf(option_list[i + 1]) else: params = "dev %s" % intf # Set command to be re-executed if interface is downed # and then brought back up version = "6" if v6 else "4" - cmd = "ip -{ver} route del default;" \ - " ip -{ver} route add default {params}".format(ver=version, - params=params) + cmd = ( + f"ip -{version} route del default; ip -{version} route add default {params}" + ) if isinstance(intf, IPIntf): intf.restore_cmds.append(cmd) self.cmd(cmd) def createDefaultRoutes(self): - if 'defaultRoute' in self.params: + if "defaultRoute" in self.params: return True # The first router we find will become the default gateway @@ -56,18 +57,18 @@ def createDefaultRoutes(self): for itf in realIntfList(self): for r in itf.broadcast_domain.routers: if self.use_v4 and self.use_v4 and len(r.addresses[4]) > 0: - self.setDefaultRoute("dev {} via {}".format(itf.name, - r.ip), - v6=False) + self.setDefaultRoute(f"dev {itf.name} via {r.ip}", v6=False) found = True - if self.use_v6 and self.use_v6 and len(r.addresses[6]) > 0 \ - and len(r.ra_prefixes) == 0: + if ( + self.use_v6 + and self.use_v6 + and len(r.addresses[6]) > 0 + and len(r.ra_prefixes) == 0 + ): # We define a default route only if router # advertisements are not activated. If we call the same # function, the route created above might be deleted - self.setDefaultRoute("dev {} via {}".format(itf.name, - r.ip6), - v6=True) + self.setDefaultRoute(f"dev {itf.name} via {r.ip6}", v6=True) found = True break if found: diff --git a/ipmininet/host/__init__.py b/ipmininet/host/__init__.py index e81675af..069e94ee 100644 --- a/ipmininet/host/__init__.py +++ b/ipmininet/host/__init__.py @@ -1,7 +1,7 @@ """This module defines a modular host that is able to support - multiple daemons +multiple daemons """ -from .__host import IPHost, CPULimitedHost +from .__host import CPULimitedHost, IPHost -__all__ = ['IPHost', 'CPULimitedHost'] +__all__ = ["CPULimitedHost", "IPHost"] diff --git a/ipmininet/host/config/__init__.py b/ipmininet/host/config/__init__.py index ca3564b6..075c0ce5 100644 --- a/ipmininet/host/config/__init__.py +++ b/ipmininet/host/config/__init__.py @@ -1,8 +1,17 @@ """This module holds the configuration generators for daemons that can be used in a host.""" -from .base import HostDaemon, HostConfig -from .named import Named, DNSZone, ARecord, NSRecord, AAAARecord, SOARecord,\ - PTRRecord -__all__ = ['HostConfig', 'HostDaemon', 'Named', 'DNSZone', 'ARecord', - 'NSRecord', 'AAAARecord', 'SOARecord', 'PTRRecord'] +from .base import HostConfig, HostDaemon +from .named import AAAARecord, ARecord, DNSZone, Named, NSRecord, PTRRecord, SOARecord + +__all__ = [ + "AAAARecord", + "ARecord", + "DNSZone", + "HostConfig", + "HostDaemon", + "NSRecord", + "Named", + "PTRRecord", + "SOARecord", +] diff --git a/ipmininet/host/config/base.py b/ipmininet/host/config/base.py index 29388315..b0242f4e 100644 --- a/ipmininet/host/config/base.py +++ b/ipmininet/host/config/base.py @@ -2,15 +2,15 @@ that is able to provide configurations for a set of daemons. It also defines the base class for a host daemon, as well as a minimalistic configuration for a host.""" + import abc import os from mako.lookup import TemplateLookup -from ipmininet.router.config.base import NodeConfig, Daemon - +from ipmininet.router.config.base import Daemon, NodeConfig -__TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), 'templates') +__TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") host_template_lookup = TemplateLookup(directories=[__TEMPLATES_DIR]) diff --git a/ipmininet/host/config/dnsmasq.py b/ipmininet/host/config/dnsmasq.py index 2bde9c64..bf6fadf1 100644 --- a/ipmininet/host/config/dnsmasq.py +++ b/ipmininet/host/config/dnsmasq.py @@ -1,9 +1,10 @@ -from .base import HostDaemon -from ipmininet.router.config.utils import ConfigDict -from mininet.log import info import re import tempfile +from ipmininet.router.config.utils import ConfigDict + +from .base import HostDaemon + class Dnsmasq(HostDaemon): NAME = "dnsmasq" @@ -11,7 +12,7 @@ class Dnsmasq(HostDaemon): def __init__(self, node, ip_range, mask, gw, intfs, **kwargs): self.node = node - self.pid_file = tempfile.mktemp(dir='/tmp') + self.pid_file = tempfile.mktemp(dir="/tmp") self.popen = None self.ip_range = ip_range self.mask = mask @@ -49,7 +50,7 @@ def dry_run(self): @property def cfg_filenames(self): - return [self._file(suffix=f"%s.cfg" % '_'.join(self.intfs))] + return [self._file(suffix="%s.cfg" % "_".join(self.intfs))] @property def template_filenames(self): @@ -64,10 +65,10 @@ def pids(self): if not output: return None - match = re.findall(r'pid=(\d+)', output) + match = re.findall(r"pid=(\d+)", output) if match: return match - + return None def kill(self): diff --git a/ipmininet/host/config/named.py b/ipmininet/host/config/named.py index d0e6fd7d..66e76306 100644 --- a/ipmininet/host/config/named.py +++ b/ipmininet/host/config/named.py @@ -1,15 +1,18 @@ """Base classes to configure a Named daemon""" + import copy import os import re +from collections.abc import Sequence from ipaddress import IPv4Address, IPv6Address, ip_address -from typing import List, Union, Sequence, Optional +from typing import Union from mininet.log import lg from ipmininet.overlay import Overlay from ipmininet.router.config.utils import ConfigDict -from ipmininet.utils import realIntfList, find_node, has_cmd +from ipmininet.utils import find_node, has_cmd, realIntfList + from .base import HostDaemon DNS_REFRESH = 86400 @@ -24,8 +27,7 @@ def dns_base_name(full_name: str) -> str: def dns_join_name(base_name: str, dns_zone: str) -> str: - return base_name + ("." + dns_zone if dns_zone != DNS_ROOT - else DNS_ROOT) + return base_name + ("." + dns_zone if dns_zone != DNS_ROOT else DNS_ROOT) def is_reverse_zone(zone_name: str) -> bool: @@ -33,7 +35,7 @@ def is_reverse_zone(zone_name: str) -> bool: class Named(HostDaemon): - NAME = 'named' + NAME = "named" KILL_PATTERNS = (NAME,) def __init__(self, node, **kwargs): @@ -45,16 +47,16 @@ def __init__(self, node, **kwargs): @property def startup_line(self): # This runs the daemon outside of AppArmor's restrictions - return '{apparmor}{name} -c {cfg} -f -u root -p {port}' \ - .format(apparmor="aa-exec -p unconfined " if self.apparmor else "", - name=self.NAME, - cfg=self.cfg_filename, - port=self.options.dns_server_port) + return "{apparmor}{name} -c {cfg} -f -u root -p {port}".format( + apparmor="aa-exec -p unconfined " if self.apparmor else "", + name=self.NAME, + cfg=self.cfg_filename, + port=self.options.dns_server_port, + ) @property def dry_run(self): - return '{name} {cfg}' \ - .format(name='named-checkconf', cfg=self.cfg_filename) + return "{name} {cfg}".format(name="named-checkconf", cfg=self.cfg_filename) def build(self): cfg = super().build() @@ -63,32 +65,32 @@ def build(self): cfg.zones = ConfigDict() root_zone_found = False - for zone in self._node.get('dns_zones', []): + for zone in self._node.get("dns_zones", []): root_zone_found = root_zone_found or zone.name == DNS_ROOT cfg.zones[self.zone_filename(zone.name)] = self.build_zone(zone) self.build_reverse_zone(cfg.zones) - root = self._node.get('root_zone', None) - if self.options.hint_root_zone and not root_zone_found \ - and root is not None: + root = self._node.get("root_zone", None) + if self.options.hint_root_zone and not root_zone_found and root is not None: root_filename = self.zone_filename(root.name) cfg.zones[root_filename] = self.build_zone(root) self.additional_zone_filenames.append(root_filename) return cfg - def build_zone(self, zone: 'DNSZone') -> ConfigDict: + def build_zone(self, zone: "DNSZone") -> ConfigDict: master_ips = [] - for s_name in zone.servers + [zone.dns_master] + zone.dns_slaves + \ - zone.delegation_servers: + for s_name in ( + zone.servers + [zone.dns_master] + zone.dns_slaves + zone.delegation_servers + ): server_itf = find_node(self._node, dns_base_name(s_name)) if server_itf is None: - lg.error("Cannot find the server node {name} of DNS zone" - " {zone}. Are you sure that they are connected to " - "the current node {current}?" - .format(name=s_name, zone=zone.name, - current=self._node.name)) + lg.error( + f"Cannot find the server node {s_name} of DNS zone" + f" {zone.name}. Are you sure that they are connected to " + f"the current node {self._node.name}?" + ) continue server = server_itf.node for itf in realIntfList(server): @@ -104,12 +106,14 @@ def build_zone(self, zone: 'DNSZone') -> ConfigDict: if s_name == zone.dns_master: master_ips.append(ip6.ip.compressed) - return ConfigDict(name=zone.name, - soa_record=zone.soa_record, - records=zone.records, - master=self._node.name == zone.dns_master, - master_ips=master_ips, - delegation_servers=zone.delegation_servers) + return ConfigDict( + name=zone.name, + soa_record=zone.soa_record, + records=zone.records, + master=self._node.name == zone.dns_master, + master_ips=master_ips, + delegation_servers=zone.delegation_servers, + ) def build_reverse_zone(self, cfg_zones: ConfigDict): """ @@ -121,20 +125,23 @@ def build_reverse_zone(self, cfg_zones: ConfigDict): ptr_records = set() for zone in cfg_zones.values(): for record in zone.records: - if not isinstance(record, ARecord) \ - or (record.domain_name in zone.delegation_servers): + if not isinstance(record, ARecord) or ( + record.domain_name in zone.delegation_servers + ): continue if record.full_domain_name: domain_name = record.domain_name else: domain_name = dns_join_name(record.domain_name, zone.name) - ptr_records.add(PTRRecord(record.address, domain_name, - ttl=record.ttl)) + ptr_records.add(PTRRecord(record.address, domain_name, ttl=record.ttl)) - existing_records = [record for zone in cfg_zones.values() - for record in zone.records - if isinstance(record, PTRRecord)] + existing_records = [ + record + for zone in cfg_zones.values() + for record in zone.records + if isinstance(record, PTRRecord) + ] ptr_v6_records = [] ptr_v4_records = [] @@ -145,8 +152,7 @@ def build_reverse_zone(self, cfg_zones: ConfigDict): # Try to place the rest in existing reverse DNS zones found = False for zone in cfg_zones.values(): - if zone.name in record.domain_name \ - and is_reverse_zone(zone.name): + if zone.name in record.domain_name and is_reverse_zone(zone.name): zone.records.append(record) found = True break @@ -163,9 +169,9 @@ def build_reverse_zone(self, cfg_zones: ConfigDict): if len(ptr_v4_records) > 0: self.build_largest_reverse_zone(cfg_zones, ptr_v4_records) - def build_largest_reverse_zone(self, cfg_zones: ConfigDict, - records: List[Union['PTRRecord', - 'NSRecord']]): + def build_largest_reverse_zone( + self, cfg_zones: ConfigDict, records: list[Union["PTRRecord", "NSRecord"]] + ): """ Create the ConfigDict object representing a new reverse zone whose prefix is the largest one that includes all the PTR records. @@ -182,8 +188,8 @@ def build_largest_reverse_zone(self, cfg_zones: ConfigDict, for i in range(1, len(records)): prefix = records[i].domain_name.split(".") for j in range(1, len(common)): - if prefix[len(prefix)-j] != common[len(common)-j]: - common = common[len(prefix)+1-j:] + if prefix[len(prefix) - j] != common[len(common) - j]: + common = common[len(prefix) + 1 - j :] break domain_name = ".".join(common) @@ -193,24 +199,30 @@ def build_largest_reverse_zone(self, cfg_zones: ConfigDict, if is_reverse_zone(zone.name): continue for record in zone.records: - if isinstance(record, NSRecord) \ - and self._node.name in record.name_server: + if ( + isinstance(record, NSRecord) + and self._node.name in record.name_server + ): ns_record = NSRecord("@", record.name_server) if ns_record is None: - lg.warning("Cannot forge a DNS reverse zone because there is no" - " NS Record for this node in regular zones.\n") + lg.warning( + "Cannot forge a DNS reverse zone because there is no" + " NS Record for this node in regular zones.\n" + ) return records.append(ns_record) # Build the reverse zone soa_record = SOARecord(domain_name=domain_name) - reverse_zone = ConfigDict(name=domain_name, - soa_record=soa_record, - records=records, - master=True, - master_ips=[]) - self._node.params.setdefault('dns_zones', []).append(reverse_zone) + reverse_zone = ConfigDict( + name=domain_name, + soa_record=soa_record, + records=records, + master=True, + master_ips=[], + ) + self._node.params.setdefault("dns_zones", []).append(reverse_zone) cfg_zones[self.zone_filename(reverse_zone.name)] = reverse_zone def set_defaults(self, defaults): @@ -228,25 +240,25 @@ def set_defaults(self, defaults): super().set_defaults(defaults) def zone_filename(self, domain_name: str) -> str: - return self._file(suffix='%szone.cfg' % domain_name) + return self._file(suffix="%szone.cfg" % domain_name) @property def cfg_filenames(self): - return super().cfg_filenames + \ - [self.zone_filename(z.name) - for z in self._node.get('dns_zones', [])] + \ - self.additional_zone_filenames + return ( + super().cfg_filenames + + [self.zone_filename(z.name) for z in self._node.get("dns_zones", [])] + + self.additional_zone_filenames + ) @property def template_filenames(self): - return super().template_filenames + \ - ["%s-zone.mako" % self.NAME - for _ in self._node.get('dns_zones', []) + - self.additional_zone_filenames] + return super().template_filenames + [ + "%s-zone.mako" % self.NAME + for _ in self._node.get("dns_zones", []) + self.additional_zone_filenames + ] class DNSRecord: - def __init__(self, rtype: str, domain_name: str, ttl=60): self.rtype = rtype self.domain_name = domain_name @@ -265,18 +277,18 @@ def full_domain_name(self) -> bool: return self.domain_name[-1:] == "." def __eq__(self, other): - return self.rtype == other.rtype \ - and self.domain_name == other.domain_name \ - and self.rdata == other.rdata + return ( + self.rtype == other.rtype + and self.domain_name == other.domain_name + and self.rdata == other.rdata + ) def __hash__(self): return hash(self.rtype) + hash(self.domain_name) + hash(self.rdata) class ARecord(DNSRecord): - - def __init__(self, domain_name, - address: Union[str, IPv4Address, IPv6Address], ttl=60): + def __init__(self, domain_name, address: str | IPv4Address | IPv6Address, ttl=60): self.address = ip_address(str(address)) rtype = "A" if self.address.version == 4 else "AAAA" super().__init__(rtype=rtype, domain_name=domain_name, ttl=ttl) @@ -291,13 +303,12 @@ class AAAARecord(ARecord): class PTRRecord(DNSRecord): - - def __init__(self, address: Union[str, IPv4Address, IPv6Address], - domain_name: str, ttl=60): + def __init__( + self, address: str | IPv4Address | IPv6Address, domain_name: str, ttl=60 + ): self.address = ip_address(str(address)) self.mapped_domain_name = domain_name - if self.mapped_domain_name[-1] != "." \ - and "." in self.mapped_domain_name: + if self.mapped_domain_name[-1] != "." and "." in self.mapped_domain_name: # Full DNS names should be ended by a dot in the config self.mapped_domain_name = self.mapped_domain_name + "." super().__init__("PTR", self.address.reverse_pointer, ttl=ttl) @@ -312,7 +323,6 @@ def rdata(self): class NSRecord(DNSRecord): - def __init__(self, domain_name, name_server: str, ttl=60): super().__init__(rtype="NS", domain_name=domain_name, ttl=ttl) self.name_server = name_server @@ -327,10 +337,14 @@ def rdata(self): class SOARecord(DNSRecord): - - def __init__(self, domain_name, refresh_time=DNS_REFRESH, - retry_time=DNS_RETRY, expire_time=DNS_EXPIRE, - min_ttl=DNS_MIN_TTL): + def __init__( + self, + domain_name, + refresh_time=DNS_REFRESH, + retry_time=DNS_RETRY, + expire_time=DNS_EXPIRE, + min_ttl=DNS_MIN_TTL, + ): super().__init__(rtype="SOA", domain_name=domain_name, ttl=min_ttl) self.refresh_time = refresh_time self.retry_time = retry_time @@ -338,26 +352,36 @@ def __init__(self, domain_name, refresh_time=DNS_REFRESH, @property def rdata(self): - return "{domain_name} {admin} (\n1 ; serial\n{refresh}" \ - " ; refresh timer\n{retry} ; retry timer\n{expire}" \ - " ; retry timer\n{min_ttl} ; minimum ttl\n)"\ - .format(domain_name=self.domain_name, - admin=dns_join_name("sysadmin", self.domain_name), - refresh=self.refresh_time, - retry=self.retry_time, expire=self.expire_time, - min_ttl=self.ttl) + return ( + "{domain_name} {admin} (\n1 ; serial\n{refresh}" + " ; refresh timer\n{retry} ; retry timer\n{expire}" + " ; retry timer\n{min_ttl} ; minimum ttl\n)".format( + domain_name=self.domain_name, + admin=dns_join_name("sysadmin", self.domain_name), + refresh=self.refresh_time, + retry=self.retry_time, + expire=self.expire_time, + min_ttl=self.ttl, + ) + ) class DNSZone(Overlay): - - def __init__(self, name: str, dns_master: str, - dns_slaves: Sequence[str] = (), - records: Sequence[DNSRecord] = (), nodes: Sequence[str] = (), - refresh_time=DNS_REFRESH, retry_time=DNS_RETRY, - expire_time=DNS_EXPIRE, min_ttl=DNS_MIN_TTL, - ns_domain_name: Optional[str] = None, - subdomain_delegation=True, - delegated_zones: Sequence['DNSZone'] = ()): + def __init__( + self, + name: str, + dns_master: str, + dns_slaves: Sequence[str] = (), + records: Sequence[DNSRecord] = (), + nodes: Sequence[str] = (), + refresh_time=DNS_REFRESH, + retry_time=DNS_RETRY, + expire_time=DNS_EXPIRE, + min_ttl=DNS_MIN_TTL, + ns_domain_name: str | None = None, + subdomain_delegation=True, + delegated_zones: Sequence["DNSZone"] = (), + ): """ :param name: The domain name of the zone :param dns_master: The name of the master DNS server @@ -385,21 +409,27 @@ def __init__(self, name: str, dns_master: str, self.dns_slaves = list(dns_slaves) self._records = list(records) self.servers = list(nodes) - self.soa_record = SOARecord(self.name, refresh_time=refresh_time, - retry_time=retry_time, - expire_time=expire_time, min_ttl=min_ttl) + self.soa_record = SOARecord( + self.name, + refresh_time=refresh_time, + retry_time=retry_time, + expire_time=expire_time, + min_ttl=min_ttl, + ) super().__init__(nodes=[dns_master] + list(dns_slaves)) self.consistent = True for node_name in [dns_master] + self.dns_slaves + self.servers: if "." in node_name: - lg.error("Cannot create zone {name} because the node name" - " {node_name} contains a '.'" - .format(name=self.name, node_name=node_name)) + lg.error( + f"Cannot create zone {self.name} because the node name" + f" {node_name} contains a '.'" + ) self.consistent = False - self.ns_domain_name = ns_domain_name if ns_domain_name is not None \ - else self.name + self.ns_domain_name = ( + ns_domain_name if ns_domain_name is not None else self.name + ) if self.ns_domain_name[-1:] != ".": self.ns_domain_name = self.ns_domain_name + "." @@ -433,8 +463,11 @@ def apply(self, topo): zones = [] for overlay in topo.overlays: - if not isinstance(overlay, type(self)) \ - or not overlay.consistent or self == overlay: + if ( + not isinstance(overlay, type(self)) + or not overlay.consistent + or self == overlay + ): continue # Find zones that want to hint root dns name servers @@ -444,8 +477,7 @@ def apply(self, topo): # Add direct subdomains as delegated zones if self.subdomain_delegation: domain = "." + self.name if self.name != DNS_ROOT else DNS_ROOT - match = re.match(r"^\w+" + re.escape(domain) + r"$", - overlay.name) + match = re.match(r"^\w+" + re.escape(domain) + r"$", overlay.name) if match is None: continue @@ -464,8 +496,7 @@ def apply(self, topo): for zone in self.delegated_zones: # Create NSRecords for the delegated subdomains for ns in [zone.dns_master] + zone.dns_slaves: - record = NSRecord(zone.name, - dns_join_name(ns, zone.ns_domain_name)) + record = NSRecord(zone.name, dns_join_name(ns, zone.ns_domain_name)) self.add_record(record) self.delegation_servers.append(record.name_server) diff --git a/ipmininet/install/__main__.py b/ipmininet/install/__main__.py index 7ad35fa8..0fa4de96 100644 --- a/ipmininet/install/__main__.py +++ b/ipmininet/install/__main__.py @@ -1,10 +1,16 @@ import os -from .install import parse_args, dist, install_mininet, install_frrouting, \ - install_exabgp, enable_ipv6, install_openr +from .install import ( + dist, + enable_ipv6, + install_exabgp, + install_frrouting, + install_mininet, + install_openr, + parse_args, +) if __name__ == "__main__": - args = parse_args() args.output_dir = os.path.normpath(os.path.abspath(args.output_dir)) @@ -62,5 +68,7 @@ if dist.NAME == "Ubuntu": install_openr(args.output_dir) else: - print("OpenR build currently only available on Ubuntu." - " Skipping installing OpenR.") + print( + "OpenR build currently only available on Ubuntu." + " Skipping installing OpenR." + ) diff --git a/ipmininet/install/install.py b/ipmininet/install/install.py index 789b47f1..3f7f4c7d 100644 --- a/ipmininet/install/install.py +++ b/ipmininet/install/install.py @@ -5,7 +5,7 @@ # For imports to work during setup and afterwards sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from utils import supported_distributions, identify_distribution, sh +from utils import identify_distribution, sh, supported_distributions MininetVersion = "2.3.0" FRRoutingVersion = "7.5" @@ -23,45 +23,62 @@ def _needs_rebuild(*paths: str) -> bool: """Return True when a component must be (re)built: one of its artifacts is missing or the user requested a forced rebuild (IPMININET_FORCE_INSTALL=1). """ - return os.environ.get("IPMININET_FORCE_INSTALL") == "1" or \ - not all(os.path.exists(p) for p in paths) + return os.environ.get("IPMININET_FORCE_INSTALL") == "1" or not all( + os.path.exists(p) for p in paths + ) def parse_args(): - parser = argparse.ArgumentParser(description="Install IPMininet with" - " its dependencies") - parser.add_argument("-o", "--output-dir", - help="Path to the directory that will store the" - " dependencies", default=os.environ["HOME"]) - parser.add_argument("-i", "--install-ipmininet", help="Install IPMininet", - action="store_true") - parser.add_argument("-m", "--install-mininet", - help="Install the last version of mininet" - " and its dependencies", - action="store_true") - parser.add_argument("-a", "--all", help="Install all daemons", - action="store_true") - parser.add_argument("-q", "--install-frrouting", - help="Install FRRouting (version %s) daemons" - % FRRoutingVersion, - action="store_true") - parser.add_argument("-e", "--install-exabgp", - help="Install ExaBGP (version %s) daemon" % ExaBGPVersion, - action="store_true") - parser.add_argument("-r", "--install-radvd", - help="Install the RADVD daemon", action="store_true") - parser.add_argument("-s", "--install-sshd", - help="Install the OpenSSH server", action="store_true") - parser.add_argument("-n", "--install-named", - help="Install the Named daemon", action="store_true") - parser.add_argument("-6", "--enable-ipv6", help="Enable IPv6", - action="store_true") - parser.add_argument("-f", "--install-openr", - help="Install OpenR. OpenR is not installed with '-a'" - " option since the build takes quite long. We" - " also experienced that the build requires a" - " substantial amount of memory (~4GB).", - action="store_true") + parser = argparse.ArgumentParser( + description="Install IPMininet with its dependencies" + ) + parser.add_argument( + "-o", + "--output-dir", + help="Path to the directory that will store the dependencies", + default=os.environ["HOME"], + ) + parser.add_argument( + "-i", "--install-ipmininet", help="Install IPMininet", action="store_true" + ) + parser.add_argument( + "-m", + "--install-mininet", + help="Install the last version of mininet and its dependencies", + action="store_true", + ) + parser.add_argument("-a", "--all", help="Install all daemons", action="store_true") + parser.add_argument( + "-q", + "--install-frrouting", + help="Install FRRouting (version %s) daemons" % FRRoutingVersion, + action="store_true", + ) + parser.add_argument( + "-e", + "--install-exabgp", + help="Install ExaBGP (version %s) daemon" % ExaBGPVersion, + action="store_true", + ) + parser.add_argument( + "-r", "--install-radvd", help="Install the RADVD daemon", action="store_true" + ) + parser.add_argument( + "-s", "--install-sshd", help="Install the OpenSSH server", action="store_true" + ) + parser.add_argument( + "-n", "--install-named", help="Install the Named daemon", action="store_true" + ) + parser.add_argument("-6", "--enable-ipv6", help="Enable IPv6", action="store_true") + parser.add_argument( + "-f", + "--install-openr", + help="Install OpenR. OpenR is not installed with '-a'" + " option since the build takes quite long. We" + " also experienced that the build requires a" + " substantial amount of memory (~4GB).", + action="store_true", + ) return parser.parse_args() @@ -85,28 +102,33 @@ def install_mininet(output_dir: str, pip_install=True): # (see pyproject.toml) and OVS comes from the openvswitch-switch # apt package installed above. if _needs_rebuild("/usr/local/bin/mnexec"): - sh("rm -rf mininet", - "git clone https://github.com/mininet/mininet.git", cwd=output_dir) - sh("git checkout %s" % MininetVersion, - cwd=os.path.join(output_dir, "mininet")) + sh( + "rm -rf mininet", + "git clone https://github.com/mininet/mininet.git", + cwd=output_dir, + ) + sh( + "git checkout %s" % MininetVersion, + cwd=os.path.join(output_dir, "mininet"), + ) sh("make mnexec", cwd=os.path.join(output_dir, "mininet")) - sh("cp mnexec /usr/local/bin/", - cwd=os.path.join(output_dir, "mininet")) + sh("cp mnexec /usr/local/bin/", cwd=os.path.join(output_dir, "mininet")) return if _needs_rebuild(os.path.join(output_dir, "mininet")): sh("git clone https://github.com/mininet/mininet.git", cwd=output_dir) # Save valid version of mininet install script - sh("git checkout %s" % MininetInstallCommit, - cwd=os.path.join(output_dir, "mininet/util")) - sh("cp install.sh install.tmp.sh", - cwd=os.path.join(output_dir, "mininet/util")) + sh( + "git checkout %s" % MininetInstallCommit, + cwd=os.path.join(output_dir, "mininet/util"), + ) + sh("cp install.sh install.tmp.sh", cwd=os.path.join(output_dir, "mininet/util")) # Use it in the fixed version of Mininet - sh("git checkout %s" % MininetVersion, - cwd=os.path.join(output_dir, "mininet/util")) - sh("mv install.tmp.sh install.sh", - cwd=os.path.join(output_dir, "mininet/util")) - sh("./install.sh %s -s ." % mininet_opts, - cwd=os.path.join(output_dir, "mininet/util")) + sh("git checkout %s" % MininetVersion, cwd=os.path.join(output_dir, "mininet/util")) + sh("mv install.tmp.sh install.sh", cwd=os.path.join(output_dir, "mininet/util")) + sh( + "./install.sh %s -s ." % mininet_opts, + cwd=os.path.join(output_dir, "mininet/util"), + ) if pip_install: dist.pip_install("mininet/", cwd=output_dir) @@ -122,12 +144,19 @@ def install_libyang(output_dir: str): elif dist.NAME == "Fedora": dist.install("pcre-devel") - sh("rm -rf libyang", - "git clone https://github.com/CESNET/libyang.git", cwd=output_dir) + sh( + "rm -rf libyang", + "git clone https://github.com/CESNET/libyang.git", + cwd=output_dir, + ) cloned_repo = os.path.join(output_dir, "libyang") sh("git checkout %s" % LibyangVersion, "mkdir build", cwd=cloned_repo) - sh("cmake -DENABLE_LYD_PRIV=ON -DCMAKE_INSTALL_PREFIX:PATH=/usr -D CMAKE_BUILD_TYPE:String=\"Release\" ..", - "make", "make install", cwd=os.path.join(cloned_repo, "build")) + sh( + 'cmake -DENABLE_LYD_PRIV=ON -DCMAKE_INSTALL_PREFIX:PATH=/usr -D CMAKE_BUILD_TYPE:String="Release" ..', + "make", + "make install", + cwd=os.path.join(cloned_repo, "build"), + ) def link_to_standard_dir(base_dir: str, standard_dir: str): @@ -141,18 +170,48 @@ def link_to_standard_dir(base_dir: str, standard_dir: str): def install_frrouting(output_dir: str): - dist.install("autoconf", "automake", "libtool", "make", "gcc", "groff", - "patch", "make", "bison", "flex", "gawk", "texinfo", - "python3-pytest") + dist.install( + "autoconf", + "automake", + "libtool", + "make", + "gcc", + "groff", + "patch", + "make", + "bison", + "flex", + "gawk", + "texinfo", + "python3-pytest", + ) if dist.NAME == "Ubuntu" or dist.NAME == "Debian": - dist.install("libreadline-dev", "libc-ares-dev", "libjson-c-dev", - "perl", "python3-dev", "libpam0g-dev", "libsystemd-dev", - "libsnmp-dev", "pkg-config", "libcap-dev") + dist.install( + "libreadline-dev", + "libc-ares-dev", + "libjson-c-dev", + "perl", + "python3-dev", + "libpam0g-dev", + "libsystemd-dev", + "libsnmp-dev", + "pkg-config", + "libcap-dev", + ) elif dist.NAME == "Fedora": - dist.install("readline-devel", "c-ares-devel", "json-c-devel", - "perl-core", "python3-devel", "pam-devel", "systemd-devel", - "net-snmp-devel", "pkgconfig", "libcap-devel") + dist.install( + "readline-devel", + "c-ares-devel", + "json-c-devel", + "perl-core", + "python3-devel", + "pam-devel", + "systemd-devel", + "net-snmp-devel", + "pkgconfig", + "libcap-devel", + ) install_libyang(output_dir) @@ -160,15 +219,19 @@ def install_frrouting(output_dir: str): if _needs_rebuild(os.path.join(frrouting_install, "sbin", "zebra")): frrouting_src = os.path.join(output_dir, "frr-%s" % FRRoutingVersion) frrouting_tar = frrouting_src + ".tar.gz" - sh("wget https://github.com/FRRouting/frr/releases/download/frr-{v}/" - "frr-{v}.tar.gz".format(v=FRRoutingVersion), - "tar -zxvf '%s'" % frrouting_tar, - cwd=output_dir) - - sh("./configure '--prefix=%s'" % frrouting_install, - "make", - "make install", - cwd=frrouting_src) + sh( + f"wget https://github.com/FRRouting/frr/releases/download/frr-{FRRoutingVersion}/" + f"frr-{FRRoutingVersion}.tar.gz", + "tar -zxvf '%s'" % frrouting_tar, + cwd=output_dir, + ) + + sh( + "./configure '--prefix=%s'" % frrouting_install, + "make", + "make install", + cwd=frrouting_src, + ) sh("rm -r '%s' '%s'" % (frrouting_src, frrouting_tar)) @@ -177,8 +240,10 @@ def install_frrouting(output_dir: str): sh("usermod -a -G frr root", may_fail=True) sh("usermod -a -G frrvty root", may_fail=True) - for curr_dir in ('sbin', 'bin'): - link_to_standard_dir(os.path.join(frrouting_install, curr_dir), "/usr/%s" % curr_dir) + for curr_dir in ("sbin", "bin"): + link_to_standard_dir( + os.path.join(frrouting_install, curr_dir), "/usr/%s" % curr_dir + ) def install_openr(output_dir: str, may_fail=False): @@ -187,14 +252,17 @@ def install_openr(output_dir: str, may_fail=False): # pinned manually. Builds and installs OpenR release rc-20190419-11514. # https://github.com/facebook/openr/releases/tag/rc-20190419-11514 script_name = "build_openr-rc-20190419-11514.sh" - openr_buildscript = os.path.join(os.path.dirname(os.path.abspath(__file__)), - script_name) + openr_buildscript = os.path.join( + os.path.dirname(os.path.abspath(__file__)), script_name + ) # Execute build script - p = sh(openr_buildscript, - cwd=output_dir, - shell=True, - executable="/bin/bash", - may_fail=may_fail) + p = sh( + openr_buildscript, + cwd=output_dir, + shell=True, + executable="/bin/bash", + may_fail=may_fail, + ) # We should end here only if may_fail is True if p.returncode != 0: print("WARNING: Ignoring failed OpenR installation.", file=sys.stderr) @@ -211,15 +279,21 @@ def install_exabgp(output_dir: str, may_fail=False): print("IPMininet: ExaBGP already installed; skipping build") return - sh("rm -rf {src_dir}".format(src_dir=exabgp_src_folder), - "git clone {url} {src_dir}".format(url=git_url, src_dir=exabgp_src_folder), - cwd=output_dir, may_fail=may_fail) + sh( + f"rm -rf {exabgp_src_folder}", + f"git clone {git_url} {exabgp_src_folder}", + cwd=output_dir, + may_fail=may_fail, + ) sh("git checkout %s" % ExaBGPVersion, cwd=exabgp_path_src_dir, may_fail=may_fail) # create self-contained executable - sh('python3 -m zipapp -o {executable_path} -m exabgp.application:main -p "/usr/bin/env python3" lib' - .format(executable_path=exabgp_self_executable), cwd=exabgp_path_src_dir, may_fail=may_fail) + sh( + f'python3 -m zipapp -o {exabgp_self_executable} -m exabgp.application:main -p "/usr/bin/env python3" lib', + cwd=exabgp_path_src_dir, + may_fail=may_fail, + ) if os.path.lexists(final_link): os.remove(final_link) @@ -242,8 +316,11 @@ def enable_ipv6(): grub_cfg = "/etc/default/grub" if not os.path.exists(grub_cfg): - print("Skipping IPv6 grub configuration: %s not present" - " (e.g. inside a container)" % grub_cfg, file=sys.stderr) + print( + "Skipping IPv6 grub configuration: %s not present" + " (e.g. inside a container)" % grub_cfg, + file=sys.stderr, + ) return with open(grub_cfg, "r+") as f: data = f.read() @@ -257,7 +334,7 @@ def enable_ipv6(): data = f.read() f.seek(0) # Comment out lines - f.write(re.sub(r'\n(.*disable_ipv6.*)', r'\n#\g<1>', data)) + f.write(re.sub(r"\n(.*disable_ipv6.*)", r"\n#\g<1>", data)) f.truncate() sh("sysctl -p") diff --git a/ipmininet/install/utils.py b/ipmininet/install/utils.py index 003e8692..603dad86 100644 --- a/ipmininet/install/utils.py +++ b/ipmininet/install/utils.py @@ -2,16 +2,15 @@ import shlex import subprocess import sys -from typing import Optional, Type, List from distutils.spawn import find_executable -def sh(*cmds, **kwargs) -> Optional[subprocess.Popen]: - if 'stdout' not in kwargs: - kwargs['stdout'] = subprocess.PIPE - if 'stderr' not in kwargs: - kwargs['stderr'] = subprocess.STDOUT +def sh(*cmds, **kwargs) -> subprocess.Popen | None: + if "stdout" not in kwargs: + kwargs["stdout"] = subprocess.PIPE + if "stderr" not in kwargs: + kwargs["stderr"] = subprocess.STDOUT may_fail = kwargs.pop("may_fail", False) output_stdout = kwargs.pop("output_stdout", True) env = kwargs.pop("env", os.environ) @@ -21,9 +20,7 @@ def sh(*cmds, **kwargs) -> Optional[subprocess.Popen]: for cmd in cmds: print("\n*** " + cmd) try: - p = subprocess.Popen(shlex.split(cmd), - env=env, - **kwargs) + p = subprocess.Popen(shlex.split(cmd), env=env, **kwargs) except OSError as e: if not may_fail: raise @@ -33,11 +30,11 @@ def sh(*cmds, **kwargs) -> Optional[subprocess.Popen]: if output_stdout: while p.poll() is None: out = p.stdout.readline() - if out != '': + if out != "": sys.stdout.write(out.decode("utf-8")) out = p.stdout.read() - if out != '': + if out != "": sys.stdout.write(out.decode("utf-8")) if p.poll() != 0: @@ -86,8 +83,14 @@ def update(self): def pip_install(self, *packages: str, **kwargs): if find_executable(self.PIP_CMD) is not None: - sh(self.PIP_CMD + " -q install " + self.pip_args + " " - + " ".join(packages), **kwargs) + sh( + self.PIP_CMD + + " -q install " + + self.pip_args + + " " + + " ".join(packages), + **kwargs, + ) def require_pip(self): if find_executable(self.PIP_CMD) is None: @@ -115,11 +118,11 @@ class Fedora(Distribution): PIP_CMD = "pip" -def supported_distributions() -> List[Type]: +def supported_distributions() -> list[type]: return Distribution.__subclasses__() -def identify_distribution() -> Optional[Distribution]: +def identify_distribution() -> Distribution | None: try: subprocess.check_call(shlex.split("grep Ubuntu /etc/lsb-release")) return Ubuntu() diff --git a/ipmininet/ipnet.py b/ipmininet/ipnet.py index 5264453d..19c92596 100644 --- a/ipmininet/ipnet.py +++ b/ipmininet/ipnet.py @@ -1,53 +1,69 @@ """IPNet: The Mininet that plays nice with IP networks. This modules will auto-generate all needed configuration properties if unspecified by the user""" + import math import random +from collections.abc import Iterable, Iterator, Mapping +from ipaddress import ( + IPv4Address, + IPv4Interface, + IPv4Network, + IPv6Address, + IPv6Interface, + IPv6Network, + ip_interface, + ip_network, +) from operator import attrgetter, methodcaller -from typing import Union, List, Optional, Type, Iterable, Mapping, Tuple, \ - Iterator, Dict, Set -from ipaddress import ip_network, ip_interface, IPv4Address, IPv6Address, \ - IPv4Network, IPv6Network, IPv4Interface, IPv6Interface +from mininet.log import lg as log +from mininet.net import Mininet +from mininet.node import Controller, Host, Node from . import MIN_IGP_METRIC, OSPF_DEFAULT_AREA -from .utils import otherIntf, realIntfList, L3Router, address_pair, has_cmd, \ - is_subnet_of from .host import IPHost +from .ipswitch import IPSwitch +from .link import IPIntf, IPLink, PhysicalInterface from .router import Router from .router.config import BasicRouterConfig, RouterConfig -from .link import IPIntf, IPLink, PhysicalInterface -from .ipswitch import IPSwitch - -from mininet.net import Mininet -from mininet.node import Host, Controller, Node -from mininet.log import lg as log +from .utils import ( + L3Router, + address_pair, + has_cmd, + is_subnet_of, + otherIntf, + realIntfList, +) # ping6 is not provided by default on newer systems -PING6_CMD = 'ping6' if has_cmd('ping6') else 'ping -6' +PING6_CMD = "ping6" if has_cmd("ping6") else "ping -6" class IPNet(Mininet): """IPNet: An IP-aware Mininet""" - def __init__(self, - router: Type[Router] = Router, - config: Type[RouterConfig] = BasicRouterConfig, - use_v4=True, - ipBase='192.168.0.0/16', - max_v4_prefixlen=24, - use_v6=True, - ip6Base='fc00::/7', - allocate_IPs=True, - max_v6_prefixlen=48, - igp_metric=MIN_IGP_METRIC, - igp_area=OSPF_DEFAULT_AREA, - host: Type[IPHost] = IPHost, - link: Type[IPLink] = IPLink, - intf: Type[IPIntf] = IPIntf, - switch: Type[IPSwitch] = IPSwitch, - controller: Optional[Type[Controller]] = None, - *args, **kwargs): + def __init__( + self, + router: type[Router] = Router, + config: type[RouterConfig] = BasicRouterConfig, + use_v4=True, + ipBase="192.168.0.0/16", + max_v4_prefixlen=24, + use_v6=True, + ip6Base="fc00::/7", + allocate_IPs=True, + max_v6_prefixlen=48, + igp_metric=MIN_IGP_METRIC, + igp_area=OSPF_DEFAULT_AREA, + host: type[IPHost] = IPHost, + link: type[IPLink] = IPLink, + intf: type[IPIntf] = IPIntf, + switch: type[IPSwitch] = IPSwitch, + controller: type[Controller] | None = None, + *args, + **kwargs, + ): """Extends Mininet by adding IP-related ivars/functions and configuration knobs. @@ -80,16 +96,23 @@ def __init__(self, self.igp_area = igp_area self.allocate_IPs = allocate_IPs self.physical_interface = {} # type: Dict[IPIntf, Node] - super().__init__(ipBase=ipBase, host=host, switch=switch, link=link, - intf=intf, controller=controller, *args, **kwargs) + super().__init__( + ipBase=ipBase, + host=host, + switch=switch, + link=link, + intf=intf, + controller=controller, + *args, + **kwargs, + ) def addRouter(self, name: str, cls=None, **params) -> Router: """Add a router to the network :param name: the node name :param cls: the class to use to instantiate it""" - defaults = {'use_v4': self.use_v4, 'use_v6': self.use_v6, - 'config': self.config} + defaults = {"use_v4": self.use_v4, "use_v6": self.use_v6, "config": self.config} defaults.update(params) if not cls: cls = self.router @@ -108,21 +131,27 @@ def __len__(self): return len(self.routers) + super().__len__() def buildFromTopo(self, topo): - log.info('\n*** Adding Routers:\n') + log.info("\n*** Adding Routers:\n") for routerName in topo.routers(): self.addRouter(routerName, **topo.nodeInfo(routerName)) - log.info(routerName + ' ') - log.info('\n') + log.info(routerName + " ") + log.info("\n") self.physical_interface.update(topo.phys_interface_capture) super().buildFromTopo(topo) - def addLink(self, node1: Node, node2: Node, - igp_metric: Optional[int] = None, - igp_area: Optional[str] = None, - igp_passive=False, - v4_width=1, v6_width=1, - duplicate: Optional[Union[str, float]] = None, - *args, **params) -> IPLink: + def addLink( + self, + node1: Node, + node2: Node, + igp_metric: int | None = None, + igp_area: str | None = None, + igp_passive=False, + v4_width=1, + v6_width=1, + duplicate: str | float | None = None, + *args, + **params, + ) -> IPLink: """Register a link with additional properties :param igp_metric: the associated igp metric for this link @@ -147,7 +176,7 @@ def addLink(self, node1: Node, node2: Node, duplicate_val = None if isinstance(duplicate, str): duplicate_str = duplicate.strip() - if duplicate_str.endswith('%'): + if duplicate_str.endswith("%"): duplicate_val = duplicate_str else: try: @@ -157,14 +186,16 @@ def addLink(self, node1: Node, node2: Node, else: duplicate_val = duplicate # Register all link properties - props = {'igp_metric': igp_metric, - 'igp_area': igp_area, - 'igp_passive': igp_passive, - 'v4_width': v4_width, - 'v6_width': v6_width, - 'duplicate': duplicate_val} + props = { + "igp_metric": igp_metric, + "igp_area": igp_area, + "igp_passive": igp_passive, + "v4_width": v4_width, + "v6_width": v6_width, + "duplicate": duplicate_val, + } # Update interface properties with link properties - for pstr in ('params1', 'params2'): + for pstr in ("params1", "params2"): try: p = params[pstr] except KeyError: @@ -177,13 +208,13 @@ def addLink(self, node1: Node, node2: Node, def addHost(self, name: str, **params) -> IPHost: """Prevent Mininet from forcing the allocation of IPv4 addresses - on hosts. We delegate it to the address auto-allocation of - IPNet.""" - if 'ip' not in params: - params['ip'] = None + on hosts. We delegate it to the address auto-allocation of + IPNet.""" + if "ip" not in params: + params["ip"] = None return super().addHost(name, **params) - def node_for_ip(self, ip: Union[str, IPv4Address, IPv6Address]) -> Node: + def node_for_ip(self, ip: str | IPv4Address | IPv6Address) -> Node: """Return the node owning a given IP address :param ip: an IP address @@ -192,53 +223,51 @@ def node_for_ip(self, ip: Union[str, IPv4Address, IPv6Address]) -> Node: def start(self): super().start() - log.info('*** Starting, ', len(self.routers), 'routers\n') + log.info("*** Starting, ", len(self.routers), "routers\n") for router in self.routers: - log.info(router.name + ' ') + log.info(router.name + " ") router.start() - log.info('*** Starting, ', len(self.hosts), 'hosts\n') + log.info("*** Starting, ", len(self.hosts), "hosts\n") for host in self.hosts: - log.info(host.name + ' ') + log.info(host.name + " ") host.start() - log.info('\n') - log.info('*** Setting default host routes\n') + log.info("\n") + log.info("*** Setting default host routes\n") for h in self.hosts: - if 'defaultRoute' in h.params: + if "defaultRoute" in h.params: continue # Skipping hosts with explicit default route if not h.createDefaultRoutes(): - log.info('skipping %s , ' % h.name) - log.info('\n') + log.info("skipping %s , " % h.name) + log.info("\n") def stop(self): - log.info('*** Stopping', len(self.routers), 'routers\n') + log.info("*** Stopping", len(self.routers), "routers\n") for router in self.routers: - log.info(router.name + ' ') + log.info(router.name + " ") router.terminate() - log.info('\n') + log.info("\n") super().stop() def build(self): super().build() self.broadcast_domains = self._broadcast_domains() - log.info("*** Found", len(self.broadcast_domains), - "broadcast domains\n") + log.info("*** Found", len(self.broadcast_domains), "broadcast domains\n") if self.allocate_IPs: self._allocate_IPs() # Physical interfaces are their own broadcast domain for itf_name, n in self.physical_interface.items(): try: itf = PhysicalInterface(itf_name, node=self[n]) - log.info('\n*** Adding Physical interface', - itf_name, 'to', n, '\n') + log.info("\n*** Adding Physical interface", itf_name, "to", n, "\n") self.broadcast_domains.append(BroadcastDomain(itf)) except KeyError: - log.error('!!! Node', n, 'not found!\n') + log.error("!!! Node", n, "not found!\n") try: self.topo.post_build(self) except AttributeError as e: - log.error('*** Skipping post_build():', e, '\n') + log.error("*** Skipping post_build():", e, "\n") - def _allocated_ipv4_subnets(self) -> List[IPv4Network]: + def _allocated_ipv4_subnets(self) -> list[IPv4Network]: subnets = [] # type: List[IPv4Network] if self.broadcast_domains is None: return [] @@ -246,7 +275,7 @@ def _allocated_ipv4_subnets(self) -> List[IPv4Network]: subnets.extend(d.fixed_net4s) return subnets - def _allocated_ipv6_subnets(self) -> List[IPv6Network]: + def _allocated_ipv6_subnets(self) -> list[IPv6Network]: subnets = [] # type: List[IPv6Network] if self.broadcast_domains is None: return [] @@ -264,21 +293,23 @@ def _allocate_IPs(self): def _allocate_ipv4(self): log.info("*** Allocating IPv4 addresses\n") - self._allocate_subnets(self._unallocated_ipbase, - self.broadcast_domains, - domainlen='len_v4', - net_key='net', - size_key='max_v4prefixlen', - max_prefixlen=self.max_v4_prefixlen, - allocated_subnets=self._allocated_ipv4_subnets()) + self._allocate_subnets( + self._unallocated_ipbase, + self.broadcast_domains, + domainlen="len_v4", + net_key="net", + size_key="max_v4prefixlen", + max_prefixlen=self.max_v4_prefixlen, + allocated_subnets=self._allocated_ipv4_subnets(), + ) for domain in self.broadcast_domains: if not domain.use_ip_version(4): continue for intf in domain: - if len(list(intf.ips())) == 0 \ - and intf.node.use_v4: - ips = tuple(domain.next_ipv4() - for _ in range(intf.interface_width[0])) + if len(list(intf.ips())) == 0 and intf.node.use_v4: + ips = tuple( + domain.next_ipv4() for _ in range(intf.interface_width[0]) + ) intf.setIP(ips) for ip in intf.ips(): self._ip_allocs[ip.with_prefixlen] = intf.node @@ -286,33 +317,38 @@ def _allocate_ipv4(self): def _allocate_ipv6(self): log.info("*** Allocating IPv6 addresses\n") - self._allocate_subnets(self._unallocated_ip6base, - self.broadcast_domains, - domainlen='len_v6', - net_key='net6', - size_key='max_v6prefixlen', - max_prefixlen=self.max_v6_prefixlen, - allocated_subnets=self._allocated_ipv6_subnets()) + self._allocate_subnets( + self._unallocated_ip6base, + self.broadcast_domains, + domainlen="len_v6", + net_key="net6", + size_key="max_v6prefixlen", + max_prefixlen=self.max_v6_prefixlen, + allocated_subnets=self._allocated_ipv6_subnets(), + ) for domain in self.broadcast_domains: if not domain.use_ip_version(6): continue for intf in domain: - if len(list(intf.ip6s(exclude_lls=True))) == 0 \ - and intf.node.use_v6: - ips = tuple(domain.next_ipv6() - for _ in range(intf.interface_width[1])) + if len(list(intf.ip6s(exclude_lls=True))) == 0 and intf.node.use_v6: + ips = tuple( + domain.next_ipv6() for _ in range(intf.interface_width[1]) + ) intf.setIP6(ips) for ip in intf.ip6s(exclude_lls=True): self._ip_allocs[ip.with_prefixlen] = intf.node self._ip_allocs[ip.ip.compressed] = intf.node @staticmethod - def _allocate_subnets(subnets: List[Union[IPv4Network, IPv6Network]], - domains: List['BroadcastDomain'], - domainlen='len_v4', net_key='net', - size_key='max_v4prefixlen', max_prefixlen=24, - allocated_subnets: Iterable[Union[IPv4Network, - IPv6Network]] = ()): + def _allocate_subnets( + subnets: list[IPv4Network | IPv6Network], + domains: list["BroadcastDomain"], + domainlen="len_v4", + net_key="net", + size_key="max_v4prefixlen", + max_prefixlen=24, + allocated_subnets: Iterable[IPv4Network | IPv6Network] = (), + ): """Allocate subnets to broadcast domains. We keep the subnets sorted as x < y wrt the available number of @@ -343,21 +379,22 @@ def _allocate_subnets(subnets: List[Union[IPv4Network, IPv6Network]], :return: iterator of (domain, subnet)""" _domainlen = methodcaller(domainlen) domains.sort(key=_domainlen, reverse=True) - _prefixlen = attrgetter('prefixlen') + _prefixlen = attrgetter("prefixlen") subnets.sort(key=_prefixlen, reverse=True) - ip_version = 4 if net_key == 'net' else 6 + ip_version = 4 if net_key == "net" else 6 for d in domains: if not d.use_ip_version(ip_version): continue if not subnets: - raise ValueError('No subnet left in the prefix space for all' - 'broadcast domains.') + raise ValueError( + "No subnet left in the prefix space for allbroadcast domains." + ) plen = min(max_prefixlen, getattr(d, size_key)) if plen < subnets[-1].prefixlen: - raise ValueError('Could not find a subnet big enough for a ' - 'broadcast domain.') - log.debug('Allocating prefix', plen, 'for interfaces', - d.interfaces) + raise ValueError( + "Could not find a subnet big enough for a broadcast domain." + ) + log.debug("Allocating prefix", plen, "for interfaces", d.interfaces) # Try to find a suitable subnet in the list for i, net in enumerate(subnets): nets = [] @@ -369,16 +406,35 @@ def _allocate_subnets(subnets: List[Union[IPv4Network, IPv6Network]], # expanded subnets as it is bigger wrt. prefixlen net, next_net = tuple(net.subnets(prefixlen_diff=1)) # If not a subnet of an allocated subnet - if len(list(filter(lambda y: is_subnet_of(next_net, y), - allocated_subnets))) == 0: + if ( + len( + list( + filter( + lambda y: is_subnet_of(next_net, y), + allocated_subnets, + ) + ) + ) + == 0 + ): nets.append(next_net) # Check if we have an appropriately-sized subnet if plen == net.prefixlen: # If the network overlaps with an allocated subnet, # we pass it - if len(list(filter(lambda y: is_subnet_of(net, y) - or is_subnet_of(y, net), - allocated_subnets))) == 0: + if ( + len( + list( + filter( + lambda y: ( + is_subnet_of(net, y) or is_subnet_of(y, net) + ), + allocated_subnets, + ) + ) + ) + == 0 + ): # Register the allocation setattr(d, net_key, net) # Delete the expanded/used subnet @@ -391,15 +447,17 @@ def _allocate_subnets(subnets: List[Union[IPv4Network, IPv6Network]], break # Otherwise try the next network for the current domain - def _broadcast_domains(self) -> List['BroadcastDomain']: + def _broadcast_domains(self) -> list["BroadcastDomain"]: """Build the broadcast domains for this topology""" domains = [] - interfaces = {intf: False - for n in self.values() - if BroadcastDomain.is_domain_boundary(n) - for intf in realIntfList(n)} - interfaces.update({r.intf('lo'): False for r in self.routers}) - for intf in interfaces.keys(): + interfaces = { + intf: False + for n in self.values() + if BroadcastDomain.is_domain_boundary(n) + for intf in realIntfList(n) + } + interfaces.update({r.intf("lo"): False for r in self.routers}) + for intf in interfaces: # the interface already belongs to a broadcast domain if interfaces[intf]: continue @@ -412,85 +470,99 @@ def _broadcast_domains(self) -> List['BroadcastDomain']: domains.append(bd) return domains - def _ping_set(self, src: Node, - dst_dict: Mapping[Node, Union[IPv4Address, IPv6Address, str]], - timeout: Optional[str], v4=True) -> Tuple[int, int]: + def _ping_set( + self, + src: Node, + dst_dict: Mapping[Node, IPv4Address | IPv6Address | str], + timeout: str | None, + v4=True, + ) -> tuple[int, int]: """Do the actual ping to the dict of {dst: dst_ip} from src - :param src: origin of the ping - :param dst_dict: destinations {dst: dst_ip} of the ping - :param timeout: the time to wait for a response, as string - :param v4: whether IPv4 or IPv6 is used - :param return: a tuple (lost packets, sent packets)""" + :param src: origin of the ping + :param dst_dict: destinations {dst: dst_ip} of the ping + :param timeout: the time to wait for a response, as string + :param v4: whether IPv4 or IPv6 is used + :param return: a tuple (lost packets, sent packets)""" if len(dst_dict) == 0: return 0, 0 lost = 0 packets = 0 - opts = '' + opts = "" if timeout: - opts = '-W %s' % timeout + opts = "-W %s" % timeout log.output("%s --%s--> " % (src.name, "IPv4" if v4 else "IPv6")) for dst, dst_ip in dst_dict.items(): - result = src.cmd('%s -c1 %s %s' % ("ping" if v4 else PING6_CMD, - opts, dst_ip)) + result = src.cmd( + "%s -c1 %s %s" % ("ping" if v4 else PING6_CMD, opts, dst_ip) + ) sent, received = self._parsePing(result) lost += sent - received packets += sent log.output("%s " % dst.name if received else "X ") - log.output('\n') + log.output("\n") return lost, packets - def ping(self, hosts: Optional[List[Node]] = None, - timeout: Optional[str] = None, use_v4=True, use_v6=True) -> float: + def ping( + self, + hosts: list[Node] | None = None, + timeout: str | None = None, + use_v4=True, + use_v6=True, + ) -> float: """Ping between all specified hosts. - If use_v4 is true, pings over IPv4 are used between any pair of - hosts having at least one IPv4 address on one of their interfaces - (loopback excluded). - If use_v6 is true, pings over IPv6 are used between any pair of - hosts having at least one non-link-local IPv6 address on one of - their interfaces (loopback excluded). - - :param hosts: list of hosts or None if all must be pinged - :param timeout: time to wait for a response, as string - :param use_v4: whether IPv4 addresses can be used - :param use_v6: whether IPv6 addresses can be used - :return: the packet loss percentage of IPv4 connectivity if - self.use_v4 is set the loss percentage of IPv6 connectivity - otherwise""" + If use_v4 is true, pings over IPv4 are used between any pair of + hosts having at least one IPv4 address on one of their interfaces + (loopback excluded). + If use_v6 is true, pings over IPv6 are used between any pair of + hosts having at least one non-link-local IPv6 address on one of + their interfaces (loopback excluded). + + :param hosts: list of hosts or None if all must be pinged + :param timeout: time to wait for a response, as string + :param use_v4: whether IPv4 addresses can be used + :param use_v6: whether IPv6 addresses can be used + :return: the packet loss percentage of IPv4 connectivity if + self.use_v4 is set the loss percentage of IPv6 connectivity + otherwise""" packets = lost = 0 host_list = self.hosts if hosts is not None: host_list = hosts incompatible_hosts = {} # type: Dict[str, Set[str]] if not use_v4 and not use_v6: - log.output("*** Warning: Parameters forbid both IPv4 and IPv6 for " - "pings\n") + log.output("*** Warning: Parameters forbid both IPv4 and IPv6 for pings\n") return 0 - log.output("*** Ping: testing reachability over %s%s%s\n" - % ("IPv4" if use_v4 else "", - " and " if use_v4 and use_v6 else "", - "IPv6" if use_v6 else "")) + log.output( + "*** Ping: testing reachability over %s%s%s\n" + % ( + "IPv4" if use_v4 else "", + " and " if use_v4 and use_v6 else "", + "IPv6" if use_v6 else "", + ) + ) for src in host_list: src_ip, src_ip6 = address_pair(src, use_v4, use_v6) ping_dict = {} ping6_dict = {} for dst in host_list: if src != dst: - dst_ip, dst_ip6 = address_pair(dst, src_ip is not None, - src_ip6 is not None) + dst_ip, dst_ip6 = address_pair( + dst, src_ip is not None, src_ip6 is not None + ) if dst_ip is not None: ping_dict[dst] = dst_ip if dst_ip6 is not None: ping6_dict[dst] = dst_ip6 - if (use_v4 and dst_ip is None and - use_v6 and dst_ip6 is None): + if use_v4 and dst_ip is None and use_v6 and dst_ip6 is None: node1 = src if src.name <= dst.name else dst node2 = src if node1 != src else dst if node2.name not in incompatible_hosts.setdefault( - node1.name, set()): + node1.name, set() + ): incompatible_hosts[node1.name].add(node2.name) result = self._ping_set(src, ping_dict, timeout, True) @@ -502,58 +574,61 @@ def ping(self, hosts: Optional[List[Node]] = None, for node1, incompatibilities in incompatible_hosts.items(): for node2 in incompatibilities: - log.output("*** Warning: %s and %s have no global address " - "in the same IP version\n" % (node1, node2)) + log.output( + "*** Warning: %s and %s have no global address " + "in the same IP version\n" % (node1, node2) + ) if packets > 0: ploss = 100.0 * lost / packets received = packets - lost - log.output("*** Results: %i%% dropped (%d/%d received)\n" % - (ploss, received, packets)) + log.output( + "*** Results: %i%% dropped (%d/%d received)\n" + % (ploss, received, packets) + ) else: ploss = 0 log.output("*** Warning: No packets sent\n") return ploss - def pingAll(self, timeout: Optional[str] = None, use_v4=True, use_v6=True): + def pingAll(self, timeout: str | None = None, use_v4=True, use_v6=True): """Ping between all hosts. - return: ploss packet loss percentage""" + return: ploss packet loss percentage""" return self.ping(timeout=timeout, use_v4=use_v4, use_v6=use_v6) def pingPair(self, use_v4=True, use_v6=True): """Ping between first two hosts, useful for testing. - return: ploss packet loss percentage""" + return: ploss packet loss percentage""" hosts = [self.hosts[0], self.hosts[1]] return self.ping(hosts=hosts, use_v4=use_v4, use_v6=use_v6) - def ping4All(self, timeout: Optional[str] = None): + def ping4All(self, timeout: str | None = None): """Ping (IPv4-only) between all hosts. - return: ploss packet loss percentage""" + return: ploss packet loss percentage""" return self.pingAll(timeout=timeout, use_v6=False) def ping4Pair(self): """Ping (IPv4-only) between first two hosts, useful for testing. - return: ploss packet loss percentage""" + return: ploss packet loss percentage""" return self.pingPair(use_v6=False) - def ping6All(self, timeout: Optional[str] = None): + def ping6All(self, timeout: str | None = None): """Ping (IPv6-only) between all hosts. - return: ploss packet loss percentage""" + return: ploss packet loss percentage""" return self.pingAll(timeout=timeout, use_v4=False) def ping6Pair(self): """Ping (IPv6-only) between first two hosts, useful for testing. - return: ploss packet loss percentage""" + return: ploss packet loss percentage""" return self.pingPair(use_v4=False) - def runFailurePlan(self, failure_plan: List[Tuple[str, str]]) \ - -> List[IPIntf]: + def runFailurePlan(self, failure_plan: list[tuple[str, str]]) -> list[IPIntf]: """Run a failure plan - :param: A list of pairs of node names: links connecting these two - links will be downed - :return: A list of interfaces that were downed + :param: A list of pairs of node names: links connecting these two + links will be downed + :return: A list of interfaces that were downed """ log.output("** Starting failure plan\n") interfaces_down = [] @@ -571,30 +646,32 @@ def runFailurePlan(self, failure_plan: List[Tuple[str, str]]) \ return interfaces_down @staticmethod - def restoreIntfs(interfaces: List[IPIntf]): + def restoreIntfs(interfaces: list[IPIntf]): """Restore interfaces - :param interfaces: the list of interfaces to restore + :param interfaces: the list of interfaces to restore """ log.output("** starting restoring link\n") for interface in interfaces: interface.up(restore=True) log.output("** interfaces " + str(interface) + " up\n") - def randomFailure(self, n: int, weak_links: Optional[List[IPLink]] = None)\ - -> List[IPIntf]: + def randomFailure( + self, n: int, weak_links: list[IPLink] | None = None + ) -> list[IPIntf]: """Randomly down 'n' link - :param n: the number of link to be downed - :param weak_links: the list of links that can be downed; if set - to None, every network link can be downed - :return: the list of interfaces which were downed + :param n: the number of link to be downed + :param weak_links: the list of links that can be downed; if set + to None, every network link can be downed + :return: the list of interfaces which were downed """ all_links = weak_links if weak_links is not None else self.links number_of_links = len(all_links) if n > number_of_links: - log.error("More link down requested than number of link" - " that can be downed\n") + log.error( + "More link down requested than number of link that can be downed\n" + ) return [] downed_interfaces = [] @@ -616,7 +693,7 @@ class BroadcastDomain: # FIXME Where do we put middleboxes in this model ? BOUNDARIES = (Host, IPHost, Router) - def __init__(self, interfaces: Union[None, List[IPIntf], IPIntf] = None): + def __init__(self, interfaces: None | list[IPIntf] | IPIntf = None): """Initialize the broadcast domain and optionally explore a set of interfaces @@ -659,16 +736,27 @@ def __iter__(self) -> Iterator[IPIntf]: def len_v4(self) -> int: """The number of IPv4 addresses in this broadcast domain""" - return sum(map(lambda x: x.interface_width[0] - if len(list(x.ips())) > 0 else 0, self.interfaces)) + return sum( + map( + lambda x: x.interface_width[0] if len(list(x.ips())) > 0 else 0, + self.interfaces, + ) + ) def len_v6(self) -> int: """The number of IPv6 addresses in this broadcast domain""" - return sum(map(lambda x: x.interface_width[1] - if len(list(x.ip6s(exclude_lls=True))) > 0 else 0, - self.interfaces)) - - def explore(self, itfs: List[IPIntf]): + return sum( + map( + lambda x: ( + x.interface_width[1] + if len(list(x.ip6s(exclude_lls=True))) > 0 + else 0 + ), + self.interfaces, + ) + ) + + def explore(self, itfs: list[IPIntf]): """Explore a new list of interfaces and add them and their neighbors to this broadcast domain @@ -691,30 +779,45 @@ def explore(self, itfs: List[IPIntf]): self.interfaces.add(other) else: # explode the node's interface to explore them - itfs.extend([x for x in realIntfList(other.node) - if x is not other]) + itfs.extend([x for x in realIntfList(other.node) if x is not other]) @property def max_v4prefixlen(self) -> int: """Return the maximal IPv4 prefix suitable for this domain""" # IPv4 reserves 2 addresses for broadcast/subnet addresses - return 32 \ - - math.ceil(math.log(2 + sum(map(lambda x: x.interface_width[1] - if x.node.use_v4 else 0, - self.interfaces)), 2)) + return 32 - math.ceil( + math.log( + 2 + + sum( + map( + lambda x: x.interface_width[1] if x.node.use_v4 else 0, + self.interfaces, + ) + ), + 2, + ) + ) @property def max_v6prefixlen(self) -> int: """Return the maximal IPv6 prefix suitable for this domain""" # IPv6 should use whole subnet space for addressing # But see FIXME in constructor - return 128 \ - - math.ceil(math.log(1 + sum(map(lambda x: x.interface_width[1] - if x.node.use_v6 else 0, - self.interfaces)), 2)) + return 128 - math.ceil( + math.log( + 1 + + sum( + map( + lambda x: x.interface_width[1] if x.node.use_v6 else 0, + self.interfaces, + ) + ), + 2, + ) + ) @property - def routers(self) -> List[IPIntf]: + def routers(self) -> list[IPIntf]: """List all interfaces in this domain belonging to a L3 router""" return [i for i in self.interfaces if L3Router.is_l3router_intf(i)] @@ -724,13 +827,13 @@ def next_ipv4(self) -> IPv4Interface: :return ip_interface:""" if self.net is None: - raise ValueError('No associated IPv4 subnet') + raise ValueError("No associated IPv4 subnet") try: addr = self.net[self._allocated_v4] self._allocated_v4 += 1 - return ip_interface('%s/%d' % (addr, self.net.prefixlen)) + return ip_interface("%s/%d" % (addr, self.net.prefixlen)) except IndexError: - raise ValueError('No more available IPv4 address') + raise ValueError("No more available IPv4 address") def next_ipv6(self) -> IPv6Interface: """Allocate and return the next available IPv6 address in this @@ -738,23 +841,24 @@ def next_ipv6(self) -> IPv6Interface: :return ip_interface:""" if self.net6 is None: - raise ValueError('No associated IPv6 subnet') + raise ValueError("No associated IPv6 subnet") try: addr = self.net6[self._allocated_v6] self._allocated_v6 += 1 - return ip_interface('%s/%d' % (addr, self.net6.prefixlen)) + return ip_interface("%s/%d" % (addr, self.net6.prefixlen)) except IndexError: - raise ValueError('No more available IPv6 address') + raise ValueError("No more available IPv6 address") def use_ip_version(self, ip_version) -> bool: - """ Checks whether there are nodes using this IP version + """Checks whether there are nodes using this IP version :param ip_version: either 4 or 6 :return: True iif there is more than one interface on the domain enabling this IP version """ for i in self.interfaces: - if i.node.use_v4 and ip_version == 4 \ - or i.node.use_v6 and ip_version == 6: + if (i.node.use_v4 and ip_version == 4) or ( + i.node.use_v6 and ip_version == 6 + ): return True return False diff --git a/ipmininet/ipovs_switch.py b/ipmininet/ipovs_switch.py index a0de1236..a29500bd 100644 --- a/ipmininet/ipovs_switch.py +++ b/ipmininet/ipovs_switch.py @@ -1,5 +1,4 @@ from mininet.node import OVSSwitch -from mininet.log import info, error, warn, debug class IPOVSSwitch(OVSSwitch): @@ -10,8 +9,18 @@ class IPOVSSwitch(OVSSwitch): working directory and a configurable priority. """ - def __init__(self, name: str, stp=False, hub=False, cwd='/tmp', rstp=False, - failMode="standalone", batch=False, priority=None, **params): + def __init__( + self, + name: str, + stp=False, + hub=False, + cwd="/tmp", + rstp=False, + failMode="standalone", + batch=False, + priority=None, + **params, + ): """Create a new IPOVSSwitch. :param name: the name of the switch @@ -27,33 +36,41 @@ def __init__(self, name: str, stp=False, hub=False, cwd='/tmp', rstp=False, self.priority = priority self.rstp = rstp and not hub stp = stp and not hub - OVSSwitch.__init__(self, failMode=failMode, name=name, stp=stp, batch=False, **params) + OVSSwitch.__init__( + self, failMode=failMode, name=name, stp=stp, batch=False, **params + ) def start(self, controllers): """Start up a new OVS OpenFlow switch using ovs-vsctl""" if self.inNamespace: - raise Exception('OVS kernel switch does not work in a namespace') + raise Exception("OVS kernel switch does not work in a namespace") - self.cmd('ifconfig', self, 'down') + self.cmd("ifconfig", self, "down") int(self.dpid, 16) # DPID must be a hex string # Command to add interfaces - intfs = ''.join(' -- add-port %s %s' % (self, intf) + self.intfOpts(intf) - for intf in self.intfList() if self.ports[intf] and not intf.IP()) + intfs = "".join( + " -- add-port %s %s" % (self, intf) + self.intfOpts(intf) + for intf in self.intfList() + if self.ports[intf] and not intf.IP() + ) # Command to create controller entries - clist = [(self.name + c.name, '%s:%s:%d' % (c.protocol, c.IP(), c.port)) for c in controllers] + clist = [ + (self.name + c.name, "%s:%s:%d" % (c.protocol, c.IP(), c.port)) + for c in controllers + ] if self.listenPort: - clist.append((self.name + '-listen', 'ptcp:%s' % self.listenPort)) + clist.append((self.name + "-listen", "ptcp:%s" % self.listenPort)) ccmd = '-- --id=@%s create Controller target=\\"%s\\"' if self.reconnectms: - ccmd += ' max_backoff=%d' % self.reconnectms - cargs = ' '.join(ccmd % (name, target) for name, target in clist) + ccmd += " max_backoff=%d" % self.reconnectms + cargs = " ".join(ccmd % (name, target) for name, target in clist) # Try to delete any existing bridges with the same name if not self.isOldOVS(): - cargs += ' --if-exists del-br %s' % self + cargs += " --if-exists del-br %s" % self # One ovs-vsctl command to rule them all! self.vsctl(cargs) - self.vsctl(' add-br %s' % self) - self.vsctl(' set bridge %s' % self + self.bridgeOpts()) + self.vsctl(" add-br %s" % self) + self.vsctl(" set bridge %s" % self + self.bridgeOpts()) self.vsctl(intfs) # Start the captures on this switch for capture in self.params.get("captures", []): @@ -61,26 +78,29 @@ def start(self, controllers): for intf in self.intfList(): for capture in intf.params.get("captures", []): capture.start(intf=intf) - self.cmd('ifconfig', self, 'up') + self.cmd("ifconfig", self, "up") def bridgeOpts(self): """Return OVS bridge options""" - opts = (' other_config:datapath-id=%s' % self.dpid + - ' fail_mode=%s' % self.failMode) + opts = ( + " other_config:datapath-id=%s" % self.dpid + " fail_mode=%s" % self.failMode + ) if self.priority is not None: - opts += (' other_config:stp-priority=%s' % self.priority + - ' other_config:rstp-priority=%s' % self.priority) + opts += ( + " other_config:stp-priority=%s" % self.priority + + " other_config:rstp-priority=%s" % self.priority + ) if not self.inband: - opts += ' other_config:disable-in-band=true' - if self.datapath == 'user': - opts += ' datapath_type=netdev' + opts += " other_config:disable-in-band=true" + if self.datapath == "user": + opts += " datapath_type=netdev" if self.protocols and not self.isOldOVS(): - opts += ' protocols=%s' % self.protocols - if self.rstp and self.failMode == 'standalone': - opts += ' rstp_enable=true' - elif self.stp and self.failMode == 'standalone': - opts += ' stp_enable=true' - opts += ' other_config:dp-desc=%s' % self.name + opts += " protocols=%s" % self.protocols + if self.rstp and self.failMode == "standalone": + opts += " rstp_enable=true" + elif self.stp and self.failMode == "standalone": + opts += " stp_enable=true" + opts += " other_config:dp-desc=%s" % self.name return opts def stop(self, deleteIntfs=True): diff --git a/ipmininet/ipswitch.py b/ipmininet/ipswitch.py index 2b969744..4fdac8f9 100644 --- a/ipmininet/ipswitch.py +++ b/ipmininet/ipswitch.py @@ -1,28 +1,35 @@ """This modules defines the IPSwitch class allowing to better support STP - and to create hubs""" -from typing import Optional +and to create hubs""" from mininet.nodelib import LinuxBridge + from ipmininet.utils import require_cmd class IPSwitch(LinuxBridge): """Linux Bridge (with optional spanning tree) extended to include - the hubs""" + the hubs""" - def __init__(self, name: str, stp=True, hub=False, - prio: Optional[int] = None, cwd='/tmp', - stp_forward_delay: Optional[int] = None, - stp_hello_time: Optional[int] = None, **kwargs): + def __init__( + self, + name: str, + stp=True, + hub=False, + prio: int | None = None, + cwd="/tmp", + stp_forward_delay: int | None = None, + stp_hello_time: int | None = None, + **kwargs, + ): """:param name: the name of the node - :param stp: whether to use spanning tree protocol - :param hub: whether this switch behaves as a hub (this disable stp) - :param prio: optional explicit bridge priority for STP - :param cwd: The base directory for temporary files such as configs - :param stp_forward_delay: optional STP forward delay in seconds - (kernel default is 15) - :param stp_hello_time: optional STP hello time in seconds - (kernel default is 2)""" + :param stp: whether to use spanning tree protocol + :param hub: whether this switch behaves as a hub (this disable stp) + :param prio: optional explicit bridge priority for STP + :param cwd: The base directory for temporary files such as configs + :param stp_forward_delay: optional STP forward delay in seconds + (kernel default is 15) + :param stp_hello_time: optional STP hello time in seconds + (kernel default is 2)""" self.hub = hub self.cwd = cwd self.stp_forward_delay = stp_forward_delay @@ -32,36 +39,38 @@ def __init__(self, name: str, stp=True, hub=False, def start(self, _controllers): """Start Linux bridge""" - require_cmd("brctl", help_str="You need brctl to use %s objects" - % self.__class__) + require_cmd( + "brctl", help_str="You need brctl to use %s objects" % self.__class__ + ) - self.cmd('ifconfig', self, 'down') - self.cmd('brctl delbr', self) - self.cmd('brctl addbr', self) + self.cmd("ifconfig", self, "down") + self.cmd("brctl delbr", self) + self.cmd("brctl addbr", self) if self.hub: - self.cmd('brctl setageing ', self, ' 0') + self.cmd("brctl setageing ", self, " 0") if self.stp: - self.cmd('brctl setbridgeprio', self, self.prio) - self.cmd('brctl stp', self, 'on') + self.cmd("brctl setbridgeprio", self, self.prio) + self.cmd("brctl stp", self, "on") # Accelerate convergence when requested (kernel requires the # forward delay to be at least twice the hello time) if self.stp_forward_delay is not None: - self.cmd('brctl setfd', self, self.stp_forward_delay) + self.cmd("brctl setfd", self, self.stp_forward_delay) if self.stp_hello_time is not None: - self.cmd('brctl sethello', self, self.stp_hello_time) + self.cmd("brctl sethello", self, self.stp_hello_time) for i in self.intfList(): if self.name in i.name: - self.cmd('brctl addif', self, i) - self.cmd('brctl setpathcost' - ' %s %s %d' % (self.name, i.name, - i.params.get('stp_cost', 1))) + self.cmd("brctl addif", self, i) + self.cmd( + "brctl setpathcost" + " %s %s %d" % (self.name, i.name, i.params.get("stp_cost", 1)) + ) # Start the captures on this switch for capture in self.params.get("captures", []): capture.start(node=self) for intf in self.intfList(): for capture in intf.params.get("captures", []): capture.start(intf=intf) - self.cmd('ifconfig', self, 'up') + self.cmd("ifconfig", self, "up") def stop(self, deleteIntfs=True): # Stop the captures on this switch diff --git a/ipmininet/iptopo.py b/ipmininet/iptopo.py index d596bce9..6095ede5 100644 --- a/ipmininet/iptopo.py +++ b/ipmininet/iptopo.py @@ -1,27 +1,45 @@ """This module defines topology class that supports adding L3 routers""" + import itertools -from typing import Union, Type, Dict, List, Tuple, Any +from typing import Any -from mininet.topo import Topo from mininet.log import lg +from mininet.topo import Topo -from ipmininet.overlay import Overlay, Subnet, NetworkCapture -from ipmininet.utils import get_set, is_container -from ipmininet.node_description import RouterDescription, HostDescription,\ - LinkDescription -from ipmininet.router.config import BasicRouterConfig, OSPFArea, AS,\ - iBGPFullMesh, OpenrDomain -from ipmininet.router.config.base import Daemon, NodeConfig from ipmininet.host.config import DNSZone from ipmininet.ipnet import IPNet +from ipmininet.node_description import ( + HostDescription, + LinkDescription, + RouterDescription, +) +from ipmininet.overlay import NetworkCapture, Overlay, Subnet +from ipmininet.router.config import ( + AS, + BasicRouterConfig, + OpenrDomain, + OSPFArea, + iBGPFullMesh, +) +from ipmininet.router.config.base import Daemon, NodeConfig +from ipmininet.utils import get_set, is_container class IPTopo(Topo): """A topology that supports L3 routers""" - OVERLAYS = {cls.__name__: cls - for cls in (AS, iBGPFullMesh, OpenrDomain, OSPFArea, - Subnet, DNSZone, NetworkCapture)} + OVERLAYS = { + cls.__name__: cls + for cls in ( + AS, + iBGPFullMesh, + OpenrDomain, + OSPFArea, + Subnet, + DNSZone, + NetworkCapture, + ) + } def __init__(self, *args, **kwargs): self.overlays = [] @@ -33,8 +51,7 @@ def build(self, *args, **kwargs): o.apply(self) for o in self.overlays: if not o.check_consistency(self): - lg.error('Consistency checks for', str(o), - 'overlay have failed!\n') + lg.error("Consistency checks for", str(o), "overlay have failed!\n") super().build(*args, **kwargs) def post_build(self, net: IPNet): @@ -53,28 +70,29 @@ def isNodeType(self, n: str, x) -> bool: except KeyError: # node not found return False - def addHost(self, name: str, **kwargs) -> 'HostDescription': + def addHost(self, name: str, **kwargs) -> "HostDescription": """Add a host to the topology - :param name: the name of the node""" + :param name: the name of the node""" return HostDescription(super().addHost(str(name), **kwargs), self) - def addRouter(self, - name: str, - routerDescription: 'RouterDescription' = RouterDescription, - **kwargs) -> 'RouterDescription': + def addRouter( + self, + name: str, + routerDescription: "RouterDescription" = RouterDescription, + **kwargs, + ) -> "RouterDescription": """Add a router to the topology :param name: the name of the node :param routerDescription: the RouterDescription class to return (optional) """ - return routerDescription(self.addNode(str(name), - isRouter=True, - **kwargs), self) + return routerDescription(self.addNode(str(name), isRouter=True, **kwargs), self) - def addRouters(self, *routers: Union[str, Tuple[str, Dict[str, Any]]], - **common_opts) -> List['RouterDescription']: + def addRouters( + self, *routers: str | tuple[str, dict[str, Any]], **common_opts + ) -> list["RouterDescription"]: """Add several routers in one go. :param routers: router names or tuples (each containing the router name @@ -84,40 +102,42 @@ def addRouters(self, *routers: Union[str, Tuple[str, Dict[str, Any]]], for router_info in routers: # Accept either router names or tuple containing both a router name # and the specific options of the router - n, opt = router_info if is_container(router_info) \ - else (router_info, {}) + n, opt = router_info if is_container(router_info) else (router_info, {}) # Merge router options by giving precedence to specific ones - router_opts = {k: v - for k, v in itertools.chain(common_opts.items(), - opt.items())} + router_opts = { + k: v for k, v in itertools.chain(common_opts.items(), opt.items()) + } try: new_routers.append(self.addRouter(n, **router_opts)) except Exception as e: - lg.error("Cannot create router '{}' with options '{}'" - .format(n, router_opts)) + lg.error(f"Cannot create router '{n}' with options '{router_opts}'") raise e return new_routers - def addLink(self, node1: str, node2: str, port1=None, port2=None, - key=None, **opts) -> 'LinkDescription': + def addLink( + self, node1: str, node2: str, port1=None, port2=None, key=None, **opts + ) -> "LinkDescription": """:param node1: first node to link - :param node2: second node to link - :param port1: port of the first node (optional) - :param port2: port of the second node (optional) - :param key: a key to identify the link (optional) - :param opts: link options (optional) - :return: link info key""" - key = super().addLink(node1=node1, node2=node2, port1=port1, port2=port2, key=key, **opts) + :param node2: second node to link + :param port1: port of the first node (optional) + :param port2: port of the second node (optional) + :param key: a key to identify the link (optional) + :param opts: link options (optional) + :return: link info key""" + key = super().addLink( + node1=node1, node2=node2, port1=port1, port2=port2, key=key, **opts + ) # Create an abstraction to allow additional calls - link_description = LinkDescription(self, node1, node2, key, - self.linkInfo(node1, node2, key)) + link_description = LinkDescription( + self, node1, node2, key, self.linkInfo(node1, node2, key) + ) return link_description - def addLinks(self, *links: Union[Tuple[str, str], - Tuple[str, str, Dict[str, Any]]], - **common_opts) -> List['LinkDescription']: + def addLinks( + self, *links: tuple[str, str] | tuple[str, str, dict[str, Any]], **common_opts + ) -> list["LinkDescription"]: """Add several links in one go. :param links: link description tuples, either only both node names @@ -128,20 +148,28 @@ def addLinks(self, *links: Union[Tuple[str, str], for u, v, *opt in links: # Merge link options by giving precedence to specific ones opt = opt[0] if opt else {} - link_opts = {k: v for k, v in itertools.chain(common_opts.items(), - opt.items())} + link_opts = { + k: v for k, v in itertools.chain(common_opts.items(), opt.items()) + } try: new_links.append(self.addLink(u, v, **link_opts)) except Exception as e: - lg.error("Cannot create link between '{}' and '{}'" - " with options '{}'".format(u, v, link_opts)) + lg.error( + f"Cannot create link between '{u}' and '{v}'" + f" with options '{link_opts}'" + ) raise e return new_links - def addDaemon(self, node: str, daemon: Union[Daemon, Type[Daemon]], - default_cfg_class: Type[NodeConfig] = BasicRouterConfig, - cfg_daemon_list="daemons", **daemon_params): + def addDaemon( + self, + node: str, + daemon: Daemon | type[Daemon], + default_cfg_class: type[NodeConfig] = BasicRouterConfig, + cfg_daemon_list="daemons", + **daemon_params, + ): """Add the daemon to the list of daemons to start on the router. :param node: node name @@ -167,9 +195,9 @@ def addDaemon(self, node: str, daemon: Union[Daemon, Type[Daemon]], def addHub(self, name: str, **opts) -> str: """Convenience method: Add hub to graph. - name: hub name - opts: hub options - returns: hub name""" + name: hub name + opts: hub options + returns: hub name""" if not opts and self.sopts: opts = self.sopts result = self.addSwitch(name, stp=False, hub=True, **opts) @@ -179,50 +207,51 @@ def isRouter(self, n: str) -> bool: """Check whether the given node is a router :param n: node name""" - return self.isNodeType(n, 'isRouter') + return self.isNodeType(n, "isRouter") def isHub(self, n: str) -> bool: """Check whether the given node is a router :param n: node name""" - return self.isNodeType(n, 'hub') + return self.isNodeType(n, "hub") - def hosts(self, sort=True) -> List['HostDescription']: + def hosts(self, sort=True) -> list["HostDescription"]: # The list is already sorted, simply filter out the routers - return [h for h in super().hosts(sort) - if not self.isRouter(h)] + return [h for h in super().hosts(sort) if not self.isRouter(h)] - def routers(self, sort=True) -> List['RouterDescription']: + def routers(self, sort=True) -> list["RouterDescription"]: """Return a list of router node names""" - return [RouterDescription(n, self) - for n in self.nodes(sort) if self.isRouter(n)] + return [ + RouterDescription(n, self) for n in self.nodes(sort) if self.isRouter(n) + ] - def hubs(self, sort=True) -> List[str]: + def hubs(self, sort=True) -> list[str]: """Return a list of hub node names""" return [n for n in self.nodes(sort) if self.isHub(n)] - def addOverlay(self, overlay: Union[Overlay, Type[Overlay]]): + def addOverlay(self, overlay: Overlay | type[Overlay]): """Add a new overlay on this topology""" if not isinstance(overlay, Overlay) and issubclass(overlay, Overlay): overlay = overlay() self.overlays.append(overlay) def __getattr__(self, item): - if item.startswith('add'): + if item.startswith("add"): try: return OverlayWrapper(self, self.OVERLAYS[item[3:]]) except KeyError: pass - raise AttributeError('%s is neither a method of IPTopo' - ' nor refers to any known overlay' % item) + raise AttributeError( + "%s is neither a method of IPTopo nor refers to any known overlay" % item + ) - def getNodeInfo(self, n: str, key, default: Type): + def getNodeInfo(self, n: str, key, default: type): """Attempt to retrieve the information for the given node/key combination. If not found, set to an instance of default and return it""" return get_set(self.nodeInfo(n), key, default) - def getLinkInfo(self, link: 'LinkDescription', key, default: Type): + def getLinkInfo(self, link: "LinkDescription", key, default: type): """Attempt to retrieve the information for the given link/key combination. If not found, set to an instance of default and return it""" @@ -234,8 +263,7 @@ def capture_physical_interface(self, intfname: str, node: str): class OverlayWrapper: - - def __init__(self, topo: IPTopo, overlay: Type[Overlay]): + def __init__(self, topo: IPTopo, overlay: type[Overlay]): self.topo = topo self.overlay = overlay diff --git a/ipmininet/link.py b/ipmininet/link.py index d82f3bc1..89c3fd6d 100644 --- a/ipmininet/link.py +++ b/ipmininet/link.py @@ -1,33 +1,35 @@ """Classes for interfaces and links that are IP-agnostic. This basically enhance the TCIntf class from Mininet, and then define sane defaults for the link classes.""" -from copy import deepcopy -from itertools import chain + +import functools import subprocess import time -from ipaddress import ip_interface, IPv4Interface, IPv6Interface -import functools -from typing import Union, Tuple, Optional, Generator, Sequence, List, Type - -from . import OSPF_DEFAULT_AREA, MIN_IGP_METRIC -from .utils import otherIntf, is_container +from collections.abc import Generator, Sequence +from copy import deepcopy +from ipaddress import IPv4Interface, IPv6Interface, ip_interface +from itertools import chain import mininet.link as _m from mininet.log import lg as log from mininet.node import Node +from . import MIN_IGP_METRIC, OSPF_DEFAULT_AREA +from .utils import is_container, otherIntf + class IPIntf(_m.TCIntf): """This class represents a node interface. It is IP-agnostic, as in its `addresses` attribute is a dictionary keyed by IP version, containing the list of all addresses for a given version""" + def __init__(self, *args, **kwargs): # Only one IP broadcast domain per interface, VLANs are supported # by aliasing interfaces. self.broadcast_domain = None self.addresses = {4: [], 6: []} - self.ra_prefixes = kwargs.pop('ra', []) - self.rdnss_list = kwargs.pop('rdnss', []) + self.ra_prefixes = kwargs.pop("ra", []) + self.rdnss_list = kwargs.pop("rdnss", []) super().__init__(*args, **kwargs) self.isUp(setUp=True) self._refresh_addresses() @@ -56,42 +58,42 @@ def _wait_interface_up(self, timeout: float = 5.0, poll: float = 0.1) -> bool: @property def igp_area(self) -> str: """Return the igp area associated to this interface""" - return self.get('igp_area', OSPF_DEFAULT_AREA) + return self.get("igp_area", OSPF_DEFAULT_AREA) @property def igp_metric(self) -> int: """Return the igp metric associated to this interface""" - return self.get('igp_metric', MIN_IGP_METRIC) + return self.get("igp_metric", MIN_IGP_METRIC) @property def describe(self) -> str: """Return a string describing the interface facing this one""" other = otherIntf(self) - return '-> %s' % (other.name if other else 'n/a') + return "-> %s" % (other.name if other else "n/a") @property - def interface_width(self) -> Tuple[int, int]: + def interface_width(self) -> tuple[int, int]: """Return the number of addresses that should be allocated to this interface, per address family""" - return self.get('v4_width', 1), self.get('v6_width', 1) + return self.get("v4_width", 1), self.get("v6_width", 1) def get(self, key, val): """Check for a given key in the interface parameters""" return self.params.get(key, val) - def __default(self, version: int) -> Union[IPv4Interface, IPv6Interface]: + def __default(self, version: int) -> IPv4Interface | IPv6Interface: """Return the default addresses for a given IP version :raise IndexError:""" return self.addresses[version][0] - def _ip(self, version: int) -> Optional[str]: + def _ip(self, version: int) -> str | None: """Return the main IP of the given version for this interface""" try: return self.__default(version).ip.compressed except IndexError: return None - def _prefixLen(self, version: int) -> Optional[int]: + def _prefixLen(self, version: int) -> int | None: """Return the prefixLen of the main IP for the given version""" try: return self.__default(version).network.prefixlen @@ -100,12 +102,11 @@ def _prefixLen(self, version: int) -> Optional[int]: # We want to stay API-compatible with Intf, so we override ip/prefixLen @property - def ip(self) -> Optional[str]: + def ip(self) -> str | None: return self._ip(4) @ip.setter - def ip(self, - ip: Union[str, IPv4Interface, Sequence[Union[str, IPv4Interface]]]): + def ip(self, ip: str | IPv4Interface | Sequence[str | IPv4Interface]): self.setIP(ip, prefixLen=self.prefixLen) def ips(self, exclude_lbs=True) -> Generator[IPv4Interface, None, None]: @@ -118,7 +119,7 @@ def ips(self, exclude_lbs=True) -> Generator[IPv4Interface, None, None]: yield i @property - def prefixLen(self) -> Optional[int]: + def prefixLen(self) -> int | None: return self._prefixLen(4) @prefixLen.setter @@ -127,29 +128,30 @@ def prefixLen(self, prefixLen: int): self.setIP(self.ip, prefixLen=prefixLen) @property - def ip6(self) -> Optional[str]: + def ip6(self) -> str | None: """Return the default IPv6 for this interface""" return self._ip(6) @ip6.setter - def ip6(self, - ip: Union[str, IPv6Interface, Sequence[Union[str, IPv6Interface]]]): + def ip6(self, ip: str | IPv6Interface | Sequence[str | IPv6Interface]): self.setIP6(ip, prefixLen=self.prefixLen6) - def ip6s(self, exclude_lls=False, exclude_lbs=True) \ - -> Generator[IPv6Interface, None, None]: + def ip6s( + self, exclude_lls=False, exclude_lbs=True + ) -> Generator[IPv6Interface, None, None]: """Return a generator over all IPv6 assigned to this interface :param exclude_lls: Whether Link-locals should be included or not :param exclude_lbs: Whether Loopback addresses should be included or not """ for i in self.addresses[6]: - if (not exclude_lls or not i.is_link_local) \ - and (not exclude_lbs or not i.is_loopback): + if (not exclude_lls or not i.is_link_local) and ( + not exclude_lbs or not i.is_loopback + ): yield i @property - def prefixLen6(self) -> Optional[int]: + def prefixLen6(self) -> int | None: """Return the prefix length for the default IPv6 for this interface""" return self._prefixLen(6) @@ -158,10 +160,14 @@ def prefixLen6(self, prefixLen: int): if self.ip6 is not None: self.setIP6(self.ip6, prefixLen=prefixLen) - def _set_ip(self, ip: Union[str, IPv4Interface, IPv6Interface, - Sequence[Union[str, IPv4Interface, - IPv6Interface]]], - prefixLen: Optional[int] = None) -> Union[None, List[str], str]: + def _set_ip( + self, + ip: str + | IPv4Interface + | IPv6Interface + | Sequence[str | IPv4Interface | IPv6Interface], + prefixLen: int | None = None, + ) -> None | list[str] | str: """Set one or more IP addresses, possibly from different families. This will remove previously set addresses of the affected families. @@ -183,16 +189,15 @@ def _set_ip(self, ip: Union[str, IPv4Interface, IPv6Interface, for addr in ip: # Make sure we have ip_interface-like objects if isinstance(addr, str): - if '/' not in addr and prefixLen is not None: + if "/" not in addr and prefixLen is not None: # And use the default prefix if absent - addr = ip_interface('%s/%s' % (addr, prefixLen)) + addr = ip_interface("%s/%s" % (addr, prefixLen)) else: # no prefixLen defaults to full /128 or /32 addr = ip_interface(str(addr)) # Prepare assignment commands - cmds.append('ip address add dev %s %s' - % (self.name, addr.with_prefixlen)) + cmds.append("ip address add dev %s %s" % (self.name, addr.with_prefixlen)) # Record assignment family if addr.version == 4: setv4 = True @@ -201,12 +206,11 @@ def _set_ip(self, ip: Union[str, IPv4Interface, IPv6Interface, setv6 = True lb_v6_update = addr.is_loopback or lb_v6_update # Clean-up old addresses - cleanup = [] # type: List + cleanup = [] # type: List if setv4: cleanup.append(self.ips(exclude_lbs=not lb_v4_update)) if setv6: - cleanup.append(self.ip6s(exclude_lls=True, - exclude_lbs=not lb_v6_update)) + cleanup.append(self.ip6s(exclude_lls=True, exclude_lbs=not lb_v6_update)) for old_ip in chain.from_iterable(cleanup): self._del_ip(old_ip) # Assign IP @@ -214,39 +218,40 @@ def _set_ip(self, ip: Union[str, IPv4Interface, IPv6Interface, self._refresh_addresses() return rval.pop() if rval and len(rval) == 1 else rval - def _del_ip(self, ip: Union[IPv4Interface, IPv6Interface]): + def _del_ip(self, ip: IPv4Interface | IPv6Interface): """Remove an assigned IP fom this interface. Does not update self.addresses! :param ip: ip_interface-like""" - self.cmd('ip', 'address', 'del', 'dev', self.name, ip.with_prefixlen) + self.cmd("ip", "address", "del", "dev", self.name, ip.with_prefixlen) setIP = setIP6 = _set_ip def _refresh_addresses(self): """Request and parse the addresses of this interface""" - self.mac, self.addresses[4], self.addresses[6] = \ - _addresses_of(self.name, self.node) + self.mac, self.addresses[4], self.addresses[6] = _addresses_of( + self.name, self.node + ) - def updateIP(self) -> Optional[str]: + def updateIP(self) -> str | None: self._refresh_addresses() return self.ip - def updateIP6(self) -> Optional[str]: + def updateIP6(self) -> str | None: self._refresh_addresses() return self.ip6 - def updateMAC(self) -> Optional[str]: + def updateMAC(self) -> str | None: self._refresh_addresses() return self.mac - def updateAddr(self) -> Tuple[Optional[str], Optional[str]]: + def updateAddr(self) -> tuple[str | None, str | None]: self._refresh_addresses() return self.ip, self.mac def down(self, backup=True): """Down the interface and, if 'backup' is true, - save the current allocated IPs""" + save the current allocated IPs""" if backup: self.backup_addresses = deepcopy(self.addresses) @@ -254,23 +259,24 @@ def down(self, backup=True): def up(self, restore=True): """Up the interface and, if 'restore' is true, - restore the saved addresses""" + restore the saved addresses""" self.isUp(setUp=True) if restore: self._refresh_addresses() current = set(self.addresses[4] + self.addresses[6]) for ip in self.backup_addresses[4] + self.backup_addresses[6]: if ip not in current: - self.cmd('ip', 'address', 'add', 'dev', self.name, - ip.with_prefixlen) + self.cmd( + "ip", "address", "add", "dev", self.name, ip.with_prefixlen + ) self._refresh_addresses() for cmd in self.restore_cmds: self.node.cmd(cmd) -def _addresses_of(devname: str, node: Optional[Node] = None): +def _addresses_of(devname: str, node: Node | None = None): """Return the addresses of a named interface""" - cmdline = ['ip', 'address', 'show', 'dev', devname] + cmdline = ["ip", "address", "show", "dev", devname] try: if node is not None: addrstr = node.cmd(*cmdline) @@ -279,16 +285,19 @@ def _addresses_of(devname: str, node: Optional[Node] = None): except (OSError, subprocess.CalledProcessError): addrstr = None if not addrstr: - log.warning('Failed to run ip address!') + log.warning("Failed to run ip address!") return None, (), () mac, v4, v6 = _parse_addresses(addrstr) - return (mac, - sorted(v4, key=OrderedAddress, reverse=True), - sorted(v6, key=OrderedAddress, reverse=True)) + return ( + mac, + sorted(v4, key=OrderedAddress, reverse=True), + sorted(v6, key=OrderedAddress, reverse=True), + ) -def _parse_addresses(out: str) -> Tuple[Optional[str], List[IPv4Interface], - List[IPv6Interface]]: +def _parse_addresses( + out: str, +) -> tuple[str | None, list[IPv4Interface], list[IPv6Interface]]: """Parse the output of an ip address command :return: mac, [ipv4], [ipv6]""" # 1: lo: mtu 65536 qdisc noqueue state ... @@ -300,25 +309,27 @@ def _parse_addresses(out: str) -> Tuple[Optional[str], List[IPv4Interface], mac = None v4 = [] v6 = [] - for line in out.strip(' \n\t\r').split('\n'): - parts = line.strip(' \n\t\r').split(' ') + for line in out.strip(" \n\t\r").split("\n"): + parts = line.strip(" \n\t\r").split(" ") try: t = parts[0] - if t == 'inet': + if t == "inet": v4.append(IPv4Interface(parts[1])) - elif t == 'inet6': + elif t == "inet6": v6.append(IPv6Interface(parts[1])) - elif 'link/' in t: + elif "link/" in t: mac = parts[1] except IndexError: - log.error('Malformed ip-address line:', line) + log.error("Malformed ip-address line:", line) return mac, v4, v6 class IPLink(_m.Link): """A Link class that defaults to IPIntf""" - def __init__(self, node1: str, node2: str, intf: Type[IPIntf] = IPIntf, - *args, **kwargs): + + def __init__( + self, node1: str, node2: str, intf: type[IPIntf] = IPIntf, *args, **kwargs + ): """We override Link intf default to use IPIntf""" super().__init__(node1=node1, node2=node2, intf=intf, *args, **kwargs) @@ -385,21 +396,23 @@ class PhysicalInterface(IPIntf): def __init__(self, name: str, *args, **kw): try: - node = kw['node'] + node = kw["node"] except KeyError: - raise ValueError('PhysicalInterface() requires a node= argument') + raise ValueError("PhysicalInterface() requires a node= argument") # Save the addresses from the root namespace try: _, v4, v6 = _addresses_of(name, node=None) except (subprocess.CalledProcessError, OSError): - log.error('Cannot retrieve the addresses of interface', name, '!') - raise ValueError('Unknown physical interface name') + log.error("Cannot retrieve the addresses of interface", name, "!") + raise ValueError("Unknown physical interface name") if node.inNamespace: # cfr man ip-link; some devices cannot change of net ns - if 'netns-local: on' in subprocess.check_output( - ('ethtool', '-k', name)).decode("utf-8"): - log.error('Cannot move interface', name, 'into another network' - ' namespace!') + if "netns-local: on" in subprocess.check_output( + ("ethtool", "-k", name) + ).decode("utf-8"): + log.error( + "Cannot move interface", name, "into another network namespace!" + ) super().__init__(name, *args, **kw) # Exclude link locals ... v4.extend(ip for ip in v6 if not ip.is_link_local) @@ -417,14 +430,19 @@ class GRETunnel: *after* the network has been built and *before* the network has been started. You can leverage the IPTopo.post_build method to do it.""" + # TODO add the created tunnel interfaces to the list of interfaces # known by the nodes (e.g. so they could be auto-detected-advertized in # the routing protocols) - def __init__(self, if1: IPIntf, if2: IPIntf, - if1address: Union[str, IPv4Interface, IPv6Interface], - if2address: Union[str, IPv4Interface, IPv6Interface], - bidirectional=True): + def __init__( + self, + if1: IPIntf, + if2: IPIntf, + if1address: str | IPv4Interface | IPv6Interface, + if2address: str | IPv4Interface | IPv6Interface, + bidirectional=True, + ): """:param if1: The first interface of the tunnel :param if2: The second interface of the tunnel :param if1address: The ip_interface address for if1 @@ -435,35 +453,56 @@ def __init__(self, if1: IPIntf, if2: IPIntf, tunnel is not established, the kernel will drop by default the encapsulated packets.""" self.if1, self.if2 = if1, if2 - self.ip1, self.gre1 = (ip_interface(str(if1address)), - self._gre_name(if1)) - self.ip2, self.gre2 = (ip_interface(str(if2address)), - self._gre_name(if2)) + self.ip1, self.gre1 = (ip_interface(str(if1address)), self._gre_name(if1)) + self.ip2, self.gre2 = (ip_interface(str(if2address)), self._gre_name(if2)) self.bidirectional = bidirectional self.setup_tunnel() def setup_tunnel(self): - self._add_tunnel(self.if1, self.if2, self.gre1, - self.ip1.with_prefixlen) + self._add_tunnel(self.if1, self.if2, self.gre1, self.ip1.with_prefixlen) if self.bidirectional: - self._add_tunnel(self.if2, self.if1, self.gre2, - self.ip2.with_prefixlen) + self._add_tunnel(self.if2, self.if1, self.gre2, self.ip2.with_prefixlen) @staticmethod def _gre_name(x) -> str: - return 'gre-%s' % x + return "gre-%s" % x @staticmethod - def _add_tunnel(if_local: IPIntf, if_remote: IPIntf, name: str, - address: str, ttl=255): - log.debug('Creating GRE tunnel named', name, ', for subnet', - str(address), 'from', if_local, '[', if_local.ip, '] to', - if_remote, '[', if_remote.ip, ']') + def _add_tunnel( + if_local: IPIntf, if_remote: IPIntf, name: str, address: str, ttl=255 + ): + log.debug( + "Creating GRE tunnel named", + name, + ", for subnet", + str(address), + "from", + if_local, + "[", + if_local.ip, + "] to", + if_remote, + "[", + if_remote.ip, + "]", + ) cmd = if_local.node.cmd - cmd('ip', 'tunnel', 'add', name, 'mode', 'gre', 'remote', if_remote.ip, - 'local', if_local.ip, 'ttl', str(ttl)) - cmd('ip', 'link', 'set', name, 'up') - cmd('ip', 'address', 'add', 'dev', name, address) + cmd( + "ip", + "tunnel", + "add", + name, + "mode", + "gre", + "remote", + if_remote.ip, + "local", + if_local.ip, + "ttl", + str(ttl), + ) + cmd("ip", "link", "set", name, "up") + cmd("ip", "address", "add", "dev", name, address) def cleanup(self): self._del_tunnel(self.if1, self.gre1) @@ -472,4 +511,4 @@ def cleanup(self): @staticmethod def _del_tunnel(if_local: IPIntf, name: str): - if_local.node.cmd('ip', 'tunnel', 'delete', name) + if_local.node.cmd("ip", "tunnel", "delete", name) diff --git a/ipmininet/node_description.py b/ipmininet/node_description.py index 75a8ac76..cfacd945 100644 --- a/ipmininet/node_description.py +++ b/ipmininet/node_description.py @@ -1,24 +1,32 @@ import functools -from typing import Union, Type, Dict, Optional +from typing import Optional -from ipmininet.router.config.base import Daemon, NodeConfig, RouterConfig -from ipmininet.router.config import BasicRouterConfig, OpenrDaemon, Openr, \ - OpenrRouterConfig from ipmininet.host.config import HostConfig +from ipmininet.router.config import ( + BasicRouterConfig, + Openr, + OpenrDaemon, + OpenrRouterConfig, +) +from ipmininet.router.config.base import Daemon, NodeConfig, RouterConfig class NodeDescription(str): def __new__(cls, value, *args, **kwargs): return super().__new__(cls, value) - def __init__(self, o, topo: Optional['IPTopo'] = None): + def __init__(self, o, topo: Optional["IPTopo"] = None): self.topo = topo self.node = o super().__init__() - def addDaemon(self, daemon: Union[Daemon, Type[Daemon]], - default_cfg_class: Type[NodeConfig] = BasicRouterConfig, - cfg_daemon_list="daemons", **daemon_params): + def addDaemon( + self, + daemon: Daemon | type[Daemon], + default_cfg_class: type[NodeConfig] = BasicRouterConfig, + cfg_daemon_list="daemons", + **daemon_params, + ): """Add the daemon to the list of daemons to start on the node. :param daemon: daemon class @@ -32,62 +40,63 @@ def addDaemon(self, daemon: Union[Daemon, Type[Daemon]], when instantiating the daemon class.""" if self.topo is None: return - self.topo.addDaemon(self, daemon, default_cfg_class=default_cfg_class, - cfg_daemon_list=cfg_daemon_list, **daemon_params) + self.topo.addDaemon( + self, + daemon, + default_cfg_class=default_cfg_class, + cfg_daemon_list=cfg_daemon_list, + **daemon_params, + ) - def get_config(self, daemon: Union[Daemon, Type[Daemon]], **kwargs): + def get_config(self, daemon: Daemon | type[Daemon], **kwargs): if self.topo is None: - return + return None return daemon.get_config(topo=self.topo, node=self, **kwargs) class RouterDescription(NodeDescription): - def addDaemon(self, daemon: Union[Daemon, Type[Daemon]], - default_cfg_class: Type[RouterConfig] = BasicRouterConfig, - **kwargs): - super().addDaemon(daemon, - default_cfg_class=default_cfg_class, - **kwargs) + def addDaemon( + self, + daemon: Daemon | type[Daemon], + default_cfg_class: type[RouterConfig] = BasicRouterConfig, + **kwargs, + ): + super().addDaemon(daemon, default_cfg_class=default_cfg_class, **kwargs) class OpenrRouterDescription(RouterDescription): def addOpenrDaemon( - self, - daemon: Union[OpenrDaemon, Type[OpenrDaemon]] = Openr, - default_cfg_class: Type[OpenrRouterConfig] = OpenrRouterConfig, - **kwargs + self, + daemon: OpenrDaemon | type[OpenrDaemon] = Openr, + default_cfg_class: type[OpenrRouterConfig] = OpenrRouterConfig, + **kwargs, ): - self.addDaemon(daemon, - default_cfg_class=default_cfg_class, - **kwargs) + self.addDaemon(daemon, default_cfg_class=default_cfg_class, **kwargs) class HostDescription(NodeDescription): - def addDaemon(self, daemon: Union[Daemon, Type[Daemon]], - default_cfg_class: Type[HostConfig] = HostConfig, **kwargs): - super().addDaemon(daemon, - default_cfg_class=default_cfg_class, - **kwargs) + def addDaemon( + self, + daemon: Daemon | type[Daemon], + default_cfg_class: type[HostConfig] = HostConfig, + **kwargs, + ): + super().addDaemon(daemon, default_cfg_class=default_cfg_class, **kwargs) @functools.total_ordering class LinkDescription: - def __init__(self, - topo: 'IPTopo', - src: str, - dst: str, - key, - link_attrs: Dict): + def __init__(self, topo: "IPTopo", src: str, dst: str, key, link_attrs: dict): self.src = src self.dst = dst self.key = key self.link_attrs = link_attrs - self.src_intf = IntfDescription(self.src, topo, self, - self.link_attrs.setdefault("params1", - {})) - self.dst_intf = IntfDescription(self.dst, topo, self, - self.link_attrs.setdefault("params2", - {})) + self.src_intf = IntfDescription( + self.src, topo, self, self.link_attrs.setdefault("params1", {}) + ) + self.dst_intf = IntfDescription( + self.dst, topo, self, self.link_attrs.setdefault("params2", {}) + ) super().__init__() def __getitem__(self, item): @@ -120,8 +129,7 @@ def __lt__(self, other): class IntfDescription(NodeDescription): - def __init__(self, o: str, topo: 'IPTopo', link: LinkDescription, - intf_attrs: Dict): + def __init__(self, o: str, topo: "IPTopo", link: LinkDescription, intf_attrs: dict): self.link = link self.intf_attrs = intf_attrs super().__init__(o, topo) diff --git a/ipmininet/overlay.py b/ipmininet/overlay.py index dcad2b33..75fd7c87 100644 --- a/ipmininet/overlay.py +++ b/ipmininet/overlay.py @@ -4,13 +4,13 @@ import subprocess import threading import time -from collections import defaultdict from builtins import str +from collections import defaultdict +from collections.abc import Sequence +from ipaddress import ip_network from subprocess import Popen -from typing import List, Sequence, Tuple, Optional, Dict, TYPE_CHECKING, Any, \ - Mapping +from typing import TYPE_CHECKING, Any, Optional -from ipaddress import ip_network from mininet.log import lg if TYPE_CHECKING: @@ -29,9 +29,13 @@ class Overlay: node -> node name link -> (node1 name, node2 name).""" - def __init__(self, nodes: Sequence[str] = (), - links: Sequence[str] = (), nprops: Optional[Dict] = None, - lprops: Optional[Dict] = None): + def __init__( + self, + nodes: Sequence[str] = (), + links: Sequence[str] = (), + nprops: dict | None = None, + lprops: dict | None = None, + ): """:param nodes: The nodes in this overlay :param links: the links in this overlay :param nprops: the properties shared by all nodes in this overlay @@ -43,7 +47,7 @@ def __init__(self, nodes: Sequence[str] = (), self.per_link_properties = defaultdict(dict) # type: Mapping self.per_node_properties = defaultdict(dict) # type: Mapping - def apply(self, topo: 'IPTopo'): + def apply(self, topo: "IPTopo"): """Apply the Overlay properties to the given topology""" # First set the common properties, then the element-specific ones for n in self.nodes: @@ -51,7 +55,7 @@ def apply(self, topo: 'IPTopo'): for l in self.links: topo.linkInfo(l[0], l[1]).update(self.link_property(l)) - def check_consistency(self, topo: 'IPTopo') -> bool: + def check_consistency(self, topo: "IPTopo") -> bool: """Check that this overlay is consistent""" return True @@ -63,7 +67,7 @@ def add_link(self, *link: str): """Add one or more link to this overlay""" self.links.extend(link) - def node_property(self, n: str) -> Dict: + def node_property(self, n: str) -> dict: """Return the properties for the given node""" p = self.nodes_properties.copy() p.update(self.per_node_properties[n]) @@ -73,7 +77,7 @@ def set_node_property(self, n: str, key, val): """Set the property of a given node""" self.per_node_properties[n][key] = val - def link_property(self, link: str) -> Dict: + def link_property(self, link: str) -> dict: """Return the properties for the given link""" p = self.links_properties.copy() p.update(self.per_link_properties[link]) @@ -119,8 +123,7 @@ def apply(self, topo): if not topo.isSwitch(y): self.nodes.append(y) - if not self._check_subnets() \ - or not self._find_nodes_in_lan(topo, self.nodes): + if not self._check_subnets() or not self._find_nodes_in_lan(topo, self.nodes): self.consistent = False return @@ -130,7 +133,7 @@ def apply(self, topo): for value in self.node_links[node]: attrs = value - addr = '%s/%d' % (subnet[i+1], subnet.prefixlen) + addr = "%s/%d" % (subnet[i + 1], subnet.prefixlen) addrs = tuple(attrs.get("ip", tuple())) attrs["ip"] = addrs + (addr,) @@ -142,9 +145,10 @@ def _check_subnets(self) -> bool: try: for subnet in self.subnets: if ip_network(str(subnet)).num_addresses - 1 < len(self.nodes): - lg.error("The subnet %s does not contain enough addresses." - " We need %s addresses\n" - % (subnet, len(self.nodes))) + lg.error( + "The subnet %s does not contain enough addresses." + " We need %s addresses\n" % (subnet, len(self.nodes)) + ) return False except ValueError as e: lg.error("One of the subnet is invalid: %s\n" % e) @@ -152,17 +156,20 @@ def _check_subnets(self) -> bool: return True @staticmethod - def _build_adjacency_list(topo: 'IPTopo') \ - -> Dict[str, List[Tuple[str, str, Any, Dict]]]: + def _build_adjacency_list( + topo: "IPTopo", + ) -> dict[str, list[tuple[str, str, Any, dict]]]: adjacencies = {} # type: Dict[str, List[Tuple[str, str, Any, Dict]]] for src, dst, k, attrs in topo.iterLinks(withInfo=True, withKeys=True): - adjacencies.setdefault(src, [])\ - .append((src, dst, k, attrs.setdefault("params2", {}))) - adjacencies.setdefault(dst, [])\ - .append((dst, src, k, attrs.setdefault("params1", {}))) + adjacencies.setdefault(src, []).append( + (src, dst, k, attrs.setdefault("params2", {})) + ) + adjacencies.setdefault(dst, []).append( + (dst, src, k, attrs.setdefault("params1", {})) + ) return adjacencies - def _find_nodes_in_lan(self, topo: 'IPTopo', nodes: List[str]) -> bool: + def _find_nodes_in_lan(self, topo: "IPTopo", nodes: list[str]) -> bool: """Checks that all nodes are in one same LAN. It also fills a map for each node name, the link on which an address should be set @@ -182,8 +189,9 @@ def _find_nodes_in_lan(self, topo: 'IPTopo', nodes: List[str]) -> bool: count_nodes = 0 count_links = 0 for previous, n_start, k_start, n_start_value in adjacencies[nodes[0]]: - nodes_0_value = [x for x in adjacencies[n_start] - if x[1] == nodes[0] and x[2] == k_start][0][3] + nodes_0_value = [ + x for x in adjacencies[n_start] if x[1] == nodes[0] and x[2] == k_start + ][0][3] node_links = {nodes[0]: [nodes_0_value]} count_nodes = 1 @@ -217,7 +225,7 @@ def _find_nodes_in_lan(self, topo: 'IPTopo', nodes: List[str]) -> bool: to_visit.extend(adjacencies[curr]) elif curr in self.nodes: # Add to node_links - if node_links.get(curr, None) is None: + if node_links.get(curr) is None: count_nodes += 1 node_links.setdefault(curr, []).append(curr_value) @@ -232,15 +240,19 @@ def _find_nodes_in_lan(self, topo: 'IPTopo', nodes: List[str]) -> bool: return True def __str__(self): - return "" % (self.nodes, - self.subnets) + return "" % (self.nodes, self.subnets) class NetworkCapture(Overlay): """This overlays capture traffic on multiple interfaces before starting the daemons and stores the result""" - def __init__(self, nodes: List['NodeDescription'] = (), interfaces: List['IntfDescription'] = (), - base_filename: str = "capture", extra_arguments: str = ""): + def __init__( + self, + nodes: list["NodeDescription"] = (), + interfaces: list["IntfDescription"] = (), + base_filename: str = "capture", + extra_arguments: str = "", + ): """ :param nodes: The routers and hosts that needs to capture traffic on every of their interfaces :param interfaces: The interfaces on which traffic should be captured @@ -259,45 +271,51 @@ def __init__(self, nodes: List['NodeDescription'] = (), interfaces: List['IntfDe self._stderr_lock = threading.Lock() super().__init__(nodes=list(set(nodes))) - def apply(self, topo: 'IPTopo'): + def apply(self, topo: "IPTopo"): for n in self.nodes: topo.nodeInfo(n).setdefault("captures", []).append(self) for itf in self.interfaces: itf.intf_attrs.setdefault("captures", []).append(self) - def check_consistency(self, topo: 'IPTopo') -> bool: + def check_consistency(self, topo: "IPTopo") -> bool: return len(self.nodes) != 0 or len(self.interfaces) != 0 - def start(self, node: Optional['IPNode'] = None, intf: Optional['IPIntf'] = None) -> Popen: + def start( + self, node: Optional["IPNode"] = None, intf: Optional["IPIntf"] = None + ) -> Popen: if node is not None: # We need to specify the interfaces explicitly because switches that are loaded on the root namespace # and therefore using '-i any' would listen on all the switches interfaces = [itf.name for itf in node.intfList()] - file_path = os.path.join(node.cwd, self.base_filename + '_' + node.name + '.pcapng') + file_path = os.path.join( + node.cwd, self.base_filename + "_" + node.name + ".pcapng" + ) cmd = f"tcpdump -Z root -i {' -i '.join(interfaces)} -w {file_path} {self.extra_arguments}" process = node.popen(shlex.split(cmd)) self._output_files[node.name] = file_path self.ongoing_captures[node.name] = process return process - elif intf is not None: - + if intf is not None: if intf.name in self.ongoing_captures: - lg.info('We already run tcpdump on this interface') + lg.info("We already run tcpdump on this interface") return None - file_path = os.path.join(intf.node.cwd, self.base_filename + '_' + intf.name + '.pcapng') - file_path_out = os.path.join(intf.node.cwd, self.base_filename + '_' + intf.name + '_out.pcapng') + file_path = os.path.join( + intf.node.cwd, self.base_filename + "_" + intf.name + ".pcapng" + ) + file_path_out = os.path.join( + intf.node.cwd, self.base_filename + "_" + intf.name + "_out.pcapng" + ) cmd = f"mimidump {intf.name} {file_path} {file_path_out} {self.extra_arguments}" - + process = intf.node.popen(shlex.split(cmd)) self._output_files[intf.name] = file_path_out self._drain_stderr(intf.name, process) self.ongoing_captures[intf.name] = process return process - else: - raise ValueError("The Network capture need a router or an interface to run") + raise ValueError("The Network capture need a router or an interface to run") - def stop(self, node: Optional['IPNode'] = None, intf: Optional['IPIntf'] = None): + def stop(self, node: Optional["IPNode"] = None, intf: Optional["IPIntf"] = None): for anchor in [node, intf]: if anchor is not None: process: subprocess.Popen = self.ongoing_captures.get(anchor.name) @@ -324,8 +342,10 @@ def wait_until_capturing(self, intf_name: str, timeout: float = 10.0) -> bool: if process is None or process.poll() is not None: return False with self._stderr_lock: - if any(line.strip().startswith(b"READY") - for line in self._stderr_lines.get(intf_name, ())): + if any( + line.strip().startswith(b"READY") + for line in self._stderr_lines.get(intf_name, ()) + ): return True output_file = self._output_files.get(intf_name) if output_file is not None and os.path.exists(output_file): @@ -349,5 +369,6 @@ def drain(): with self._stderr_lock: self._stderr_lines[intf_name].append(line) - threading.Thread(target=drain, daemon=True, - name=f"mimidump-stderr-{intf_name}").start() + threading.Thread( + target=drain, daemon=True, name=f"mimidump-stderr-{intf_name}" + ).start() diff --git a/ipmininet/router/__init__.py b/ipmininet/router/__init__.py index 2b9057a8..38bd4134 100644 --- a/ipmininet/router/__init__.py +++ b/ipmininet/router/__init__.py @@ -1,6 +1,7 @@ """This module defines a modular router that is able to support - multiple daemons +multiple daemons """ -from .__router import Router, ProcessHelper, IPNode, OpenrRouter -__all__ = ['IPNode', 'Router', 'OpenrRouter', 'ProcessHelper'] +from .__router import IPNode, OpenrRouter, ProcessHelper, Router + +__all__ = ["IPNode", "OpenrRouter", "ProcessHelper", "Router"] diff --git a/ipmininet/router/__router.py b/ipmininet/router/__router.py index d6e96652..3cdc8434 100644 --- a/ipmininet/router/__router.py +++ b/ipmininet/router/__router.py @@ -1,23 +1,24 @@ """This modules defines a L3 router class, - with a modular config system.""" +with a modular config system.""" + import shlex import subprocess import sys import time +from collections.abc import Sequence from ipaddress import IPv4Interface, IPv6Interface -from typing import Type, Optional, Tuple, Union, Dict, List, Sequence, Set import mininet.clean from mininet.log import lg -from mininet.node import Node, Host +from mininet.node import Host, Node from ipmininet import DEBUG_FLAG from ipmininet.link import IPIntf -from ipmininet.utils import L3Router, realIntfList, otherIntf -from .config import BasicRouterConfig, NodeConfig, RouterConfig, \ - OpenrRouterConfig -from .config.utils import ConfigDict +from ipmininet.utils import L3Router, otherIntf, realIntfList + +from .config import BasicRouterConfig, NodeConfig, OpenrRouterConfig, RouterConfig from .config.base import Daemon +from .config.utils import ConfigDict class ProcessHelper: @@ -26,13 +27,13 @@ class ProcessHelper: currently in a mininet namespace, but could be extended to execute in a different environment.""" - def __init__(self, node: 'IPNode'): + def __init__(self, node: "IPNode"): """:param node: The object to use to create subprocesses.""" self.node = node self._pid_gen = 0 self._processes = {} # type: Dict[int, subprocess.Popen] - def call(self, *args, **kwargs) -> Optional[str]: + def call(self, *args, **kwargs) -> str | None: """Call a command, wait for it to end and return its output. :param args: the command + arguments @@ -49,7 +50,7 @@ def popen(self, *args, **kwargs) -> int: self._processes[self._pid_gen] = self.node.popen(*args, **kwargs) return self._pid_gen - def pexec(self, *args, **kw) -> Tuple[str, str, int]: + def pexec(self, *args, **kw) -> tuple[str, str, int]: """Call a command, wait for it to terminate and save stdout, stderr and its return code""" return self.node.pexec(*args, **kw) @@ -82,15 +83,18 @@ def terminate(self): class IPNode(Node): """A Node which manages a set of daemons""" - def __init__(self, name: str, - config: Union[Type[NodeConfig], - Tuple[Type[NodeConfig], Dict]] = NodeConfig, - cwd='/tmp', - process_manager: Type[ProcessHelper] = ProcessHelper, - use_v4=True, - use_v6=True, - create_logdirs=True, - *args, **kwargs): + def __init__( + self, + name: str, + config: type[NodeConfig] | tuple[type[NodeConfig], dict] = NodeConfig, + cwd="/tmp", + process_manager: type[ProcessHelper] = ProcessHelper, + use_v4=True, + use_v6=True, + create_logdirs=True, + *args, + **kwargs, + ): """Most of the heavy lifting for this node should happen in the associated config object. @@ -111,8 +115,10 @@ class or a tuple (class, kwargs) try: self.nconfig = config[0](self, **config[1]) except ValueError: - lg.error("Expected a tuple (class, kwargs) for the config " - "parameter but got instead %s" % str(config)) + lg.error( + "Expected a tuple (class, kwargs) for the config " + "parameter but got instead %s" % str(config) + ) else: self.nconfig = config(self) self._processes = process_manager(self) @@ -134,12 +140,17 @@ def start(self): out, err, code = self._processes.pexec(shlex.split(d.dry_run)) err_code = err_code or code if code: - lg.error(d.NAME, 'configuration check failed [' - 'rcode:', code, ']\n' - 'stdout:', out, '\n' - 'stderr:', err) + lg.error( + d.NAME, + "configuration check failed [rcode:", + code, + "]\nstdout:", + out, + "\nstderr:", + err, + ) if err_code: - lg.error('Config checks failed, aborting!') + lg.error("Config checks failed, aborting!") mininet.clean.cleanup() sys.exit(1) # Set relevant sysctls @@ -154,24 +165,29 @@ def start(self): # nodes (use_v6=False) this poll would wait ~0.5 s per iteration on # nothing, so skip it entirely. if self.use_v6: - lg.debug(self._processes.node.name, 'Checking for any "tentative" addresses') + lg.debug( + self._processes.node.name, 'Checking for any "tentative" addresses' + ) tentative_cmd = "ip -6 addr show tentative" tentative_chk = self._processes.call(tentative_cmd) - while tentative_chk is not None and tentative_chk != '': + while tentative_chk is not None and tentative_chk != "": if tentative_chk.find("dadfailed") != -1: - lg.error('At least two nodes have the same IPv6 address!\n') + lg.error("At least two nodes have the same IPv6 address!\n") mininet.clean.cleanup() sys.exit(1) - time.sleep(.5) + time.sleep(0.5) tentative_chk = self._processes.call(tentative_cmd) - lg.debug(self._processes.node.name, 'All IPv6 addresses has passed the Duplicate address detection mechanism') + lg.debug( + self._processes.node.name, + "All IPv6 addresses has passed the Duplicate address detection mechanism", + ) # Fire up all daemons for d in self.nconfig.daemons: self._processes.popen(shlex.split(d.startup_line), cwd=self.cwd) # Busy-wait if the daemon needs some time before being started while not d.has_started(self._processes): - time.sleep(.001) + time.sleep(0.001) def build_daemon(self, daemon: Daemon): _cfg = ConfigDict() @@ -201,42 +217,41 @@ def terminate(self): capture.stop(intf=intf) super().terminate() - def _set_sysctl(self, key: str, val: Union[str, int]): + def _set_sysctl(self, key: str, val: str | int): """Change a sysctl value, and return the previous set value""" try: v = None - out = self._processes.call('sysctl', key) + out = self._processes.call("sysctl", key) if out is not None: - v = out.split('=')[1]\ - .strip(' \n\t\r') + v = out.split("=")[1].strip(" \n\t\r") except IndexError: v = None if v != val: - self._processes.call('sysctl', '-w', '%s=%s' % (key, val)) + self._processes.call("sysctl", "-w", "%s=%s" % (key, val)) return v - def _mklogdirs(self, logdir) -> Tuple[str, str, int]: + def _mklogdirs(self, logdir) -> tuple[str, str, int]: """Creates directories for the given logdir. - :param logdir: The log directory path to create - :return: (stdout, stderr, return_code) + :param logdir: The log directory path to create + :return: (stdout, stderr, return_code) """ - lg.debug('{}: Creating logdir {}.\n'.format(self.name, logdir)) - cmd = 'mkdir -p {}'.format(logdir) + lg.debug(f"{self.name}: Creating logdir {logdir}.\n") + cmd = f"mkdir -p {logdir}" stdout, stderr, return_code = self._processes.pexec(shlex.split(cmd)) if not return_code: - lg.debug('{}: Logdir {} successfully created.\n'.format(self.name, - logdir)) + lg.debug(f"{self.name}: Logdir {logdir} successfully created.\n") else: - lg.error('{}: Could not create logdir {}. Stderr: \n' - '{}\n'.format(self.name, logdir, stderr)) + lg.error( + f"{self.name}: Could not create logdir {logdir}. Stderr: \n{stderr}\n" + ) return (stdout, stderr, return_code) def get(self, key, val=None): """Check for a given key in the node parameters""" return self.params.get(key, val) - def network_ips(self) -> Dict[str, List[str]]: + def network_ips(self) -> dict[str, list[str]]: """Return all the addresses of the nodes connected directly or not to this node""" ips = {} # type: Dict[str, List[str]] @@ -249,8 +264,7 @@ def network_ips(self) -> Dict[str, List[str]]: visited.add(node.name) if isinstance(node, (Host, IPNode)): for i in node.intfList(): - for ip in list(i.ips()) \ - + list(i.ip6s(exclude_lls=True)): + for ip in list(i.ips()) + list(i.ip6s(exclude_lls=True)): ips.setdefault(node.name, []).append(ip.ip.compressed) for i in realIntfList(node): @@ -263,17 +277,19 @@ def network_ips(self) -> Dict[str, List[str]]: class Router(IPNode, L3Router): """The actual router, which manages a set of daemons""" - def __init__(self, name, - config: Union[Type[RouterConfig], - Tuple[Type[RouterConfig], - Dict]] = BasicRouterConfig, - password='zebra', - lo_addresses: Sequence[Union[str, IPv4Interface, - IPv6Interface]] = (), - *args, **kwargs): + def __init__( + self, + name, + config: type[RouterConfig] + | tuple[type[RouterConfig], dict] = BasicRouterConfig, + password="zebra", + lo_addresses: Sequence[str | IPv4Interface | IPv6Interface] = (), + *args, + **kwargs, + ): """:param password: The password for the routing daemons vtysh access - :param lo_addresses: The list of addresses to set on the loopback - interface""" + :param lo_addresses: The list of addresses to set on the loopback + interface""" super().__init__(name, config=config, *args, **kwargs) self.password = password @@ -281,29 +297,35 @@ def __init__(self, name, # so no need to move it node_params_for_lo = ["igp_area"] params = {k: v for k, v in kwargs.items() if k in node_params_for_lo} - lo = IPIntf('lo', node=self, port=-1, moveIntfFn=lambda x, y: None, **params) + lo = IPIntf("lo", node=self, port=-1, moveIntfFn=lambda x, y: None, **params) lo.ip = lo_addresses @property def asn(self) -> int: - return self.get('asn') + return self.get("asn") class OpenrRouter(Router): """OpenR router with private '/tmp' dir to handle unix sockets created by - the OpenR daemon""" - - def __init__(self, name, *args, - config: Type[OpenrRouterConfig], - lo_addresses: Optional[Sequence[ - Union[str, IPv4Interface, IPv6Interface]]] = None, - privateDirs=['/tmp'], - **kwargs): + the OpenR daemon""" + + def __init__( + self, + name, + *args, + config: type[OpenrRouterConfig], + lo_addresses: Sequence[str | IPv4Interface | IPv6Interface] | None = None, + privateDirs=["/tmp"], + **kwargs, + ): if not lo_addresses: lo_addresses = () - super().__init__(name, - config=config, - lo_addresses=lo_addresses, - password=None, - privateDirs=privateDirs, - *args, **kwargs) + super().__init__( + name, + config=config, + lo_addresses=lo_addresses, + password=None, + privateDirs=privateDirs, + *args, + **kwargs, + ) diff --git a/ipmininet/router/config/__init__.py b/ipmininet/router/config/__init__.py index 5a4097a5..bc6e90ec 100644 --- a/ipmininet/router/config/__init__.py +++ b/ipmininet/router/config/__init__.py @@ -1,36 +1,120 @@ """This module holds the configuration generators for daemons that can be used in a router.""" -from .base import BorderRouterConfig, BasicRouterConfig, RouterConfig, \ - OpenrRouterConfig, NodeConfig -from .zebra import Zebra -from .staticd import STATIC, StaticRoute + +from .base import ( + BasicRouterConfig, + BorderRouterConfig, + NodeConfig, + OpenrRouterConfig, + RouterConfig, +) +from .bgp import ( + AF_INET, + AF_INET6, + AS, + BGP, + CLIENT_PROVIDER, + SHARE, + AccessList, + CommunityList, + bgp_fullmesh, + bgp_peering, + ebgp_session, + iBGPFullMesh, + set_rr, +) +from .exabgp import ( + BGPAttribute, + BGPAttributeFlags, + BGPRoute, + ExaBGPDaemon, + ExaList, + HexRepresentable, + Representable, +) +from .iptables import ( + NOT, + AddressClause, + Allow, + Chain, + ChainRule, + Deny, + Filter, + InputFilter, + InterfaceClause, + IP6Tables, + IPTables, + OutputFilter, + PortClause, + Rule, + TransitFilter, +) +from .openr import Openr, OpenrDomain +from .openrd import OpenrDaemon from .ospf import OSPF, OSPFArea from .ospf6 import OSPF6 -from .bgp import BGP, AS, iBGPFullMesh, bgp_peering, bgp_fullmesh, \ - ebgp_session, set_rr, AccessList, CommunityList, AF_INET, AF_INET6, \ - SHARE, CLIENT_PROVIDER -from .radvd import RADVD, AdvPrefix, AdvRDNSS, AdvConnectedPrefix -from .iptables import IPTables, IP6Tables, Rule, Chain, ChainRule, NOT, \ - PortClause, InterfaceClause, AddressClause, Filter, InputFilter, \ - OutputFilter, TransitFilter, Allow, Deny -from .sshd import SSHd from .pimd import PIMD +from .radvd import RADVD, AdvConnectedPrefix, AdvPrefix, AdvRDNSS from .ripng import RIPng -from .openrd import OpenrDaemon -from .openr import Openr, OpenrDomain -from .exabgp import ExaBGPDaemon, ExaList, BGPRoute, BGPAttribute, \ - BGPAttributeFlags, Representable, HexRepresentable, ExaList +from .sshd import SSHd +from .staticd import STATIC, StaticRoute +from .zebra import Zebra -__all__ = ['BasicRouterConfig', 'NodeConfig', 'Zebra', 'OSPF', 'OSPF6', - 'OSPFArea', 'BGP', 'AS', 'SHARE', 'CLIENT_PROVIDER', - 'iBGPFullMesh', 'bgp_peering', 'RouterConfig', 'bgp_fullmesh', - 'ebgp_session', 'CommunityList', 'set_rr', 'AccessList', 'IPTables', - 'IP6Tables', 'SSHd', 'RADVD', 'AdvPrefix', 'AdvConnectedPrefix', - 'AdvRDNSS', 'PIMD', 'RIPng', 'STATIC', 'StaticRoute', - 'ExaBGPDaemon', 'ExaList', 'BGPRoute', 'BGPAttributeFlags', - 'BGPAttribute', 'Representable', 'HexRepresentable', - 'OpenrDaemon', 'Openr', 'OpenrRouterConfig', 'OpenrDomain', - 'AF_INET', 'AF_INET6', 'BorderRouterConfig', 'Rule', 'Chain', - 'ChainRule', 'NOT', 'PortClause', 'InterfaceClause', - 'AddressClause', 'Filter', 'InputFilter', 'OutputFilter', - 'TransitFilter', 'Allow', 'Deny'] +__all__ = [ + "AF_INET", + "AF_INET6", + "AS", + "BGP", + "CLIENT_PROVIDER", + "NOT", + "OSPF", + "OSPF6", + "PIMD", + "RADVD", + "SHARE", + "STATIC", + "AccessList", + "AddressClause", + "AdvConnectedPrefix", + "AdvPrefix", + "AdvRDNSS", + "Allow", + "BGPAttribute", + "BGPAttributeFlags", + "BGPRoute", + "BasicRouterConfig", + "BorderRouterConfig", + "Chain", + "ChainRule", + "CommunityList", + "Deny", + "ExaBGPDaemon", + "ExaList", + "Filter", + "HexRepresentable", + "IP6Tables", + "IPTables", + "InputFilter", + "InterfaceClause", + "NodeConfig", + "OSPFArea", + "Openr", + "OpenrDaemon", + "OpenrDomain", + "OpenrRouterConfig", + "OutputFilter", + "PortClause", + "RIPng", + "Representable", + "RouterConfig", + "Rule", + "SSHd", + "StaticRoute", + "TransitFilter", + "Zebra", + "bgp_fullmesh", + "bgp_peering", + "ebgp_session", + "iBGPFullMesh", + "set_rr", +] diff --git a/ipmininet/router/config/base.py b/ipmininet/router/config/base.py index 503a0f37..45bfe9fb 100644 --- a/ipmininet/router/config/base.py +++ b/ipmininet/router/config/base.py @@ -2,33 +2,38 @@ that is able to provide configurations for a set of routing daemons. It also defines the base class for a daemon, as well as a minimalistic configuration for a router.""" -import os + import abc +import os +from collections.abc import Iterable, Sequence from contextlib import closing -from operator import attrgetter from ipaddress import ip_address -from mako.lookup import TemplateLookup -from typing import TYPE_CHECKING, Iterable, Optional, Dict, Union, Type, \ - Tuple, Sequence, List, Set - -from .utils import ConfigDict, ip_statement -from ipmininet.utils import require_cmd, realIntfList -from ipmininet.link import OrderedAddress, IPIntf +from operator import attrgetter +from typing import ( + TYPE_CHECKING, + Union, +) import mako.exceptions - +from mako.lookup import TemplateLookup from mininet.log import lg as log +from ipmininet.link import OrderedAddress +from ipmininet.utils import realIntfList, require_cmd + +from .utils import ConfigDict, ip_statement + if TYPE_CHECKING: - from ipmininet.router import IPNode, Router, OpenrRouter, ProcessHelper from ipmininet.iptopo import IPTopo from ipmininet.node_description import NodeDescription -DaemonOption = Union['Daemon', Type['Daemon'], - Tuple[Union['Daemon', Type['Daemon']], Dict]] + from ipmininet.router import IPNode, OpenrRouter, ProcessHelper, Router +DaemonOption = Union[ + "Daemon", type["Daemon"], tuple[Union["Daemon", type["Daemon"]], dict] +] -last_routerid = ip_address('0.0.0.1') +last_routerid = ip_address("0.0.0.1") -__TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), 'templates') +__TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") router_template_lookup = TemplateLookup(directories=[__TEMPLATES_DIR]) @@ -36,9 +41,14 @@ class NodeConfig: """This class manages a set of daemons, and generates the global configuration for a node""" - def __init__(self, node: 'IPNode', daemons: Iterable[DaemonOption] = (), - sysctl: Optional[Dict[str, Union[str, int]]] = None, *args, - **kwargs): + def __init__( + self, + node: "IPNode", + daemons: Iterable[DaemonOption] = (), + sysctl: dict[str, str | int] | None = None, + *args, + **kwargs, + ): """Initialize our config builder :param node: The node for which this object will build configurations @@ -59,13 +69,17 @@ def build(self): configuration files""" # Mount a separate /etc/resolv.conf and /etc/hosts for the node - resolv_file_mount = os.path.join(self._node.cwd, 'resolv_%(name)s.conf') + resolv_file_mount = os.path.join(self._node.cwd, "resolv_%(name)s.conf") open(resolv_file_mount % self._node.__dict__, "w").close() - host_file_mount = os.path.join(self._node.cwd, 'hosts_%(name)s') + host_file_mount = os.path.join(self._node.cwd, "hosts_%(name)s") self.build_host_file(host_file_mount % self._node.__dict__) - self.add_private_fs_path([('/etc/resolv.conf', resolv_file_mount), - ('/etc/hosts', host_file_mount), - '/var/run/frr']) + self.add_private_fs_path( + [ + ("/etc/resolv.conf", resolv_file_mount), + ("/etc/hosts", host_file_mount), + "/var/run/frr", + ] + ) self._cfg.clear() self._cfg.name = self._node.name @@ -102,8 +116,9 @@ def register_daemon(self, cls: DaemonOption, **daemon_opts): try: cls, kw = cls except ValueError: - raise TypeError('Expected a tuple (Daemon, dict) ' - ' but got %s' % str(cls)) + raise TypeError( + "Expected a tuple (Daemon, dict) but got %s" % str(cls) + ) daemon_opts.update(kw) if cls.NAME in self._daemons: return @@ -111,12 +126,13 @@ def register_daemon(self, cls: DaemonOption, **daemon_opts): if issubclass(cls, Daemon): cls = cls(self._node, **daemon_opts) else: - raise TypeError('Expected an object or a subclass of ' - 'Daemon, got %s instead' % cls) + raise TypeError( + "Expected an object or a subclass of Daemon, got %s instead" % cls + ) else: cls.options.update(daemon_opts) self._daemons[cls.NAME] = cls - require_cmd(cls.NAME, 'Could not find an executable for a daemon!') + require_cmd(cls.NAME, "Could not find an executable for a daemon!") @property def sysctl(self): @@ -132,17 +148,19 @@ def sysctl(self, *values: str): 'net.ipv6.conf.all.forwarding=1'""" for value in values: try: - key, val = value.split('=') + key, val = value.split("=") self._sysctl[key] = val except ValueError: - raise ValueError('sysctl must be specified using `key=val` ' - 'format. Ignoring %s' % value) + raise ValueError( + "sysctl must be specified using `key=val` " + "format. Ignoring %s" % value + ) @property def daemons(self): - return sorted(self._daemons.values(), key=attrgetter('PRIO')) + return sorted(self._daemons.values(), key=attrgetter("PRIO")) - def daemon(self, key: Union[str, 'Daemon', Type['Daemon']]) -> 'Daemon': + def daemon(self, key: Union[str, "Daemon", type["Daemon"]]) -> "Daemon": """Return the Daemon object in this config for the given key :param key: the daemon name or a daemon class or instance @@ -154,8 +172,7 @@ def daemon(self, key: Union[str, 'Daemon', Type['Daemon']]) -> 'Daemon': key_str = key return self._daemons[key_str] - def add_private_fs_path(self, - loc: Sequence[Union[str, Tuple[str, str]]] = ()): + def add_private_fs_path(self, loc: Sequence[str | tuple[str, str]] = ()): old_private_dirs = self._node.privateDirs try: self._node.privateDirs = loc @@ -172,18 +189,14 @@ def build_host_file(self, filename: str): with open(filename, "wb") as fileobj: for node_name, ips in self._node.network_ips().items(): - for ip in ips: - fileobj.write("{ip}\t{name}\n" - .format(ip=ip, name=node_name).encode()) + fileobj.writelines(f"{ip}\t{node_name}\n".encode() for ip in ips) fileobj.write(b"\n") fileobj.write(b"".join(lines)) class RouterConfig(NodeConfig): - - def __init__(self, node: 'Router', sysctl=None, *args, **kwargs): - self._sysctl = {'net.ipv4.ip_forward': 1, - 'net.ipv6.conf.all.forwarding': 1} + def __init__(self, node: "Router", sysctl=None, *args, **kwargs): + self._sysctl = {"net.ipv4.ip_forward": 1, "net.ipv6.conf.all.forwarding": 1} if sysctl: self._sysctl.update(sysctl) super().__init__(node, sysctl=self._sysctl, *args, **kwargs) @@ -199,7 +212,7 @@ def incr_last_routerid(): global last_routerid last_routerid += 1 - def _equal_routerid(self, n: 'Router') -> bool: + def _equal_routerid(self, n: "Router") -> bool: # Router id of 'n' already set if n.nconfig.routerid: @@ -209,18 +222,19 @@ def _equal_routerid(self, n: 'Router') -> bool: # in any other daemon is not in conflict # with the current router id for d in n.nconfig.daemons: - if d != self \ - and d.options.routerid \ - and str(d.options.routerid) == str(last_routerid): + if ( + d != self + and d.options.routerid + and str(d.options.routerid) == str(last_routerid) + ): return True # Check that the most-visible IPv4 address is not in conflict # with the current router id - ip_list = sorted((ip for itf in n.intfList() - for ip in itf.ips()), - key=OrderedAddress) - if len(ip_list) != 0 \ - and str(ip_list.pop().ip) == str(last_routerid): + ip_list = sorted( + (ip for itf in n.intfList() for ip in itf.ips()), key=OrderedAddress + ) + if len(ip_list) != 0 and str(ip_list.pop().ip) == str(last_routerid): return True return False @@ -238,11 +252,11 @@ def compute_routerid(self) -> str: if d.options.routerid: return d.options.routerid - ip_list = sorted((ip for itf in self._node.intfList() - for ip in itf.ips()), - key=OrderedAddress) + ip_list = sorted( + (ip for itf in self._node.intfList() for ip in itf.ips()), + key=OrderedAddress, + ) if len(ip_list) == 0: - to_visit = realIntfList(self._node) # Explore all routers to check that none has the same router id while to_visit: @@ -264,6 +278,7 @@ def compute_routerid(self) -> str: class Daemon(metaclass=abc.ABCMeta): """This class serves as base for routing daemons""" + # The name of this routing daemon NAME = None # type: str # The priority of this daemon, relative to others @@ -274,9 +289,12 @@ class Daemon(metaclass=abc.ABCMeta): # The kill patterns to cleanup any processes started by this daemon KILL_PATTERNS = () # type: Sequence[str] - def __init__(self, node: 'IPNode', - template_lookup: TemplateLookup = router_template_lookup, - **kwargs): + def __init__( + self, + node: "IPNode", + template_lookup: TemplateLookup = router_template_lookup, + **kwargs, + ): """:param node: The node for which we build the config :param template_lookup: The TemplateLookup object of the template directory @@ -294,8 +312,8 @@ def options(self) -> ConfigDict: @property def logdir(self) -> str: - if 'logfile' in self._options: - return os.path.dirname(self._options['logfile']) + if "logfile" in self._options: + return os.path.dirname(self._options["logfile"]) return None def build(self) -> ConfigDict: @@ -303,7 +321,7 @@ def build(self) -> ConfigDict: :return: ConfigDict-like object describing this configuration""" cfg = ConfigDict() - cfg.logfile = self._options['logfile'] + cfg.logfile = self._options["logfile"] return cfg def cleanup(self): @@ -311,11 +329,11 @@ def cleanup(self): for f in self.files: try: os.unlink(f) - except (IOError, OSError): + except OSError: pass self.files = [] - def render(self, cfg, **kwargs) -> Dict[str, str]: + def render(self, cfg, **kwargs) -> dict[str, str]: """Render the configuration content for each config file of this daemon :param cfg: The global config for the node @@ -324,29 +342,31 @@ def render(self, cfg, **kwargs) -> Dict[str, str]: self.files.extend(self.cfg_filenames) cfg_content = {} for i, filename in enumerate(self.cfg_filenames): - log.debug('Generating %s\n' % filename) + log.debug("Generating %s\n" % filename) try: cfg.current_filename = filename kwargs["node"] = cfg kwargs["ip_statement"] = ip_statement - template = self.template_lookup.get_template( - self.template_filenames[i]) + template = self.template_lookup.get_template(self.template_filenames[i]) cfg_content[filename] = template.render(**kwargs) except Exception: # Display template errors in a less cryptic way - log.error('Couldn''t render a config file(', - self.template_filenames[i], ')') + log.error( + "Couldnt render a config file(", self.template_filenames[i], ")" + ) log.error(mako.exceptions.text_error_template().render()) - raise ValueError('Cannot render a configuration [%s: %s]' % ( - self._node.name, self.NAME)) + raise ValueError( + "Cannot render a configuration [%s: %s]" + % (self._node.name, self.NAME) + ) return cfg_content - def write(self, cfg: Dict[str, str]): + def write(self, cfg: dict[str, str]): """Write down the configuration files for this daemon :param cfg: The configuration string for each filename""" for filename in self.cfg_filenames: - with closing(open(filename, 'w')) as f: + with closing(open(filename, "w")) as f: f.write(cfg[filename]) @property @@ -363,7 +383,7 @@ def dry_run(self) -> str: def _filename(self, suffix: str) -> str: """Return a filename for this daemon and node, with the specified suffix""" - return '%s_%s.%s' % (self.NAME, self._node.name, suffix) + return "%s_%s.%s" % (self.NAME, self._node.name, suffix) def _filepath(self, f: str) -> str: """Return a path towards a given file""" @@ -376,25 +396,25 @@ def _file(self, suffix: str) -> str: @property def cfg_filename(self) -> str: """Return the main filename in which this daemon config should be - stored""" + stored""" return self.cfg_filenames[0] @property - def cfg_filenames(self) -> List[str]: + def cfg_filenames(self) -> list[str]: """Return the list of filenames in which this daemon config should be - stored""" - return [self._file(suffix='cfg')] + stored""" + return [self._file(suffix="cfg")] @property - def template_filenames(self) -> List[str]: - return ['%s.mako' % self.NAME] + def template_filenames(self) -> list[str]: + return ["%s.mako" % self.NAME] def _defaults(self, **kwargs) -> ConfigDict: """Return the default options for this daemon :param logfile: the path to the logfile for the daemon""" defaults = ConfigDict() - defaults.logfile = self._file('log') + defaults.logfile = self._file("log") # Apply daemon-specific defaults self.set_defaults(defaults) # Use user-supplied defaults if present @@ -405,24 +425,22 @@ def _defaults(self, **kwargs) -> ConfigDict: def set_defaults(self, defaults): """Update defaults to contain the defaults specific to this daemon""" - def has_started(self, node_exec: 'ProcessHelper' = None) -> bool: + def has_started(self, node_exec: "ProcessHelper" = None) -> bool: """Return whether this daemon has started or not :param node_exec: """ return True @classmethod - def get_config(cls, topo: 'IPTopo', node: 'NodeDescription', **kwargs): + def get_config(cls, topo: "IPTopo", node: "NodeDescription", **kwargs): """Returns a config object for the daemon if any""" - return None + return class RouterDaemon(Daemon, metaclass=abc.ABCMeta): - def build(self): cfg = super().build() - cfg.routerid = self._options.routerid if self._options.routerid \ - else self._node.nconfig.routerid + cfg.routerid = self._options.routerid or self._node.nconfig.routerid return cfg @abc.abstractmethod @@ -434,16 +452,21 @@ def set_defaults(self, defaults): class BasicRouterConfig(RouterConfig): """A basic router that will run an OSPF daemon""" - def __init__(self, node: 'Router', - daemons: Iterable[DaemonOption] = (), - additional_daemons: Iterable[DaemonOption] = (), - *args, **kwargs): + def __init__( + self, + node: "Router", + daemons: Iterable[DaemonOption] = (), + additional_daemons: Iterable[DaemonOption] = (), + *args, + **kwargs, + ): """A simple router made of at least an OSPF daemon :param additional_daemons: Other daemons that should be used""" # Importing here to avoid circular import from .ospf import OSPF from .ospf6 import OSPF6 + # We don't want any zebra-specific settings, so we rely on the # OSPF/OSPF6 DEPENDS list for that daemon to run it with default # settings. We also don't want specific settings beside the defaults, @@ -461,37 +484,47 @@ class BorderRouterConfig(BasicRouterConfig): """A router config that will run both OSPF and BGP, and redistribute all connected router into BGP.""" - def __init__(self, node: 'Router', - daemons: Iterable[DaemonOption] = (), - additional_daemons: Iterable[DaemonOption] = (), - *args, **kwargs): + def __init__( + self, + node: "Router", + daemons: Iterable[DaemonOption] = (), + additional_daemons: Iterable[DaemonOption] = (), + *args, + **kwargs, + ): """A simple router made of at least an OSPF daemon and a BGP daemon :param additional_daemons: Other daemons that should be used""" - from .bgp import BGP, AF_INET, AF_INET6 + from .bgp import AF_INET, AF_INET6, BGP af = [] if node.use_v4: - af.append(AF_INET(redistribute=('connected', 'ospf'))) + af.append(AF_INET(redistribute=("connected", "ospf"))) if node.use_v6: - af.append(AF_INET6(redistribute=('connected', 'ospf6'))) + af.append(AF_INET6(redistribute=("connected", "ospf6"))) if af: d = list(daemons) - d.append((BGP, {'address_families': af})) + d.append((BGP, {"address_families": af})) super().__init__(node, daemons=d, *args, **kwargs) class OpenrRouterConfig(RouterConfig): """A basic router that will run an OpenR daemon""" - def __init__(self, node: 'OpenrRouter', - daemons: Iterable[DaemonOption] = (), - additional_daemons: Iterable[DaemonOption] = (), - *args, **kwargs): + + def __init__( + self, + node: "OpenrRouter", + daemons: Iterable[DaemonOption] = (), + additional_daemons: Iterable[DaemonOption] = (), + *args, + **kwargs, + ): """A simple router made of at least an OpenR daemon :param additional_daemons: Other daemons that should be used""" # Importing here to avoid circular import from .openr import Openr + daemon_list = list(daemons) daemon_list.append(Openr) daemon_list.extend(additional_daemons) diff --git a/ipmininet/router/config/bgp.py b/ipmininet/router/config/bgp.py index df347013..2e17275e 100644 --- a/ipmininet/router/config/bgp.py +++ b/ipmininet/router/config/bgp.py @@ -1,15 +1,30 @@ """Base classes to configure a BGP daemon""" + import heapq import itertools from abc import ABC -from ipaddress import ip_network, ip_address, IPv4Network, IPv6Network -from typing import Sequence, TYPE_CHECKING, Optional, Union, Tuple, List, Set +from collections.abc import Sequence +from ipaddress import IPv4Network, IPv6Network, ip_address, ip_network +from typing import TYPE_CHECKING, Optional from ipmininet.overlay import Overlay from ipmininet.utils import realIntfList + from .base import RouterDaemon -from .zebra import QuaggaDaemon, Zebra, RouteMap, AccessList, RouteMapEntry, \ - RouteMapMatchCond, CommunityList, RouteMapSetAction, PERMIT, DENY, PrefixList, PrefixListEntry +from .zebra import ( + DENY, + PERMIT, + AccessList, + CommunityList, + PrefixList, + PrefixListEntry, + QuaggaDaemon, + RouteMap, + RouteMapEntry, + RouteMapMatchCond, + RouteMapSetAction, + Zebra, +) if TYPE_CHECKING: from ipmininet.iptopo import IPTopo @@ -33,15 +48,15 @@ def __init__(self, asn: int, routers=(), **props): @property def asn(self) -> int: - return self.nodes_properties['asn'] + return self.nodes_properties["asn"] @asn.setter def asn(self, x: int): x = int(x) - self.nodes_properties['asn'] = x + self.nodes_properties["asn"] = x def __str__(self): - return '' % self.asn + return "" % self.asn class iBGPFullMesh(AS): @@ -55,7 +70,7 @@ def apply(self, topo): super().apply(topo) def __str__(self): - return '' % self.asn + return "" % self.asn def bgp_fullmesh(topo, routers: Sequence[str]): @@ -71,10 +86,10 @@ def _set_peering(x): _set_peering(peering) -def bgp_peering(topo: 'IPTopo', a: 'RouterDescription', b: 'RouterDescription'): +def bgp_peering(topo: "IPTopo", a: "RouterDescription", b: "RouterDescription"): """Register a BGP peering between two nodes""" - topo.getNodeInfo(a, 'bgp_peers', list).append(b) - topo.getNodeInfo(b, 'bgp_peers', list).append(a) + topo.getNodeInfo(a, "bgp_peers", list).append(b) + topo.getNodeInfo(b, "bgp_peers", list).append(a) # recent versions of BGP must define import/export filters for eBGP sessions (RFC 8212). # This function allows all the routes to be accepted. As depicted, the user can still @@ -83,23 +98,49 @@ def bgp_peering(topo: 'IPTopo', a: 'RouterDescription', b: 'RouterDescription'): filter_allows_all_routes(a, b) -def filter_allows_all_routes(a: 'RouterDescription', b: 'RouterDescription'): - ip4_pfxl = PrefixList(name="hello-world-v4", family='ipv4', entries=(PrefixListEntry('0.0.0.0/0', le=32),)) - ip6_pfxl = PrefixList(name="hello-world-v6", family='ipv6', entries=(PrefixListEntry('::/0', le=128),)) +def filter_allows_all_routes(a: "RouterDescription", b: "RouterDescription"): + ip4_pfxl = PrefixList( + name="hello-world-v4", + family="ipv4", + entries=(PrefixListEntry("0.0.0.0/0", le=32),), + ) + ip6_pfxl = PrefixList( + name="hello-world-v6", family="ipv6", entries=(PrefixListEntry("::/0", le=128),) + ) # since only one route-map can be used for importation/exportation filter, # this RM allows any routes as ultimate resort. The user needs to # explicitly deny in the smaller orders - a.get_config(BGP) \ - .filter(policy=PERMIT, from_peer=b, matching=(ip4_pfxl, ip6_pfxl), order=RouteMap.DEFAULT_POLICY) \ - .filter(policy=PERMIT, to_peer=b, matching=(ip4_pfxl, ip6_pfxl), order=RouteMap.DEFAULT_POLICY) - b.get_config(BGP) \ - .filter(policy=PERMIT, from_peer=a, matching=(ip4_pfxl, ip6_pfxl), order=RouteMap.DEFAULT_POLICY) \ - .filter(policy=PERMIT, to_peer=a, matching=(ip4_pfxl, ip6_pfxl), order=RouteMap.DEFAULT_POLICY) - - -def ebgp_session(topo: 'IPTopo', a: 'RouterDescription', b: 'RouterDescription', - link_type: Optional[str] = None): + a.get_config(BGP).filter( + policy=PERMIT, + from_peer=b, + matching=(ip4_pfxl, ip6_pfxl), + order=RouteMap.DEFAULT_POLICY, + ).filter( + policy=PERMIT, + to_peer=b, + matching=(ip4_pfxl, ip6_pfxl), + order=RouteMap.DEFAULT_POLICY, + ) + b.get_config(BGP).filter( + policy=PERMIT, + from_peer=a, + matching=(ip4_pfxl, ip6_pfxl), + order=RouteMap.DEFAULT_POLICY, + ).filter( + policy=PERMIT, + to_peer=a, + matching=(ip4_pfxl, ip6_pfxl), + order=RouteMap.DEFAULT_POLICY, + ) + + +def ebgp_session( + topo: "IPTopo", + a: "RouterDescription", + b: "RouterDescription", + link_type: str | None = None, +): """Register an eBGP peering between two nodes, and disable IGP adjacencies between them. @@ -111,71 +152,109 @@ def ebgp_session(topo: 'IPTopo', a: 'RouterDescription', b: 'RouterDescription', filter and set local pref based on the link type """ if link_type: - all_al_v4 = AccessList('ipv4', name='Allv4', entries=('any',)) - all_al_v6 = AccessList('ipv6', name='Allv6', entries=('any',)) + all_al_v4 = AccessList("ipv4", name="Allv4", entries=("any",)) + all_al_v6 = AccessList("ipv6", name="Allv6", entries=("any",)) # Create the community filter for the export policy - peers_link = CommunityList(name='from-peers', community=1, - action=PERMIT) - up_link = CommunityList(name='from-up', community=3, action=PERMIT) + peers_link = CommunityList(name="from-peers", community=1, action=PERMIT) + up_link = CommunityList(name="from-up", community=3, action=PERMIT) if link_type == SHARE: # Set the community and local pref for the import policy - a.get_config(BGP) \ - .set_community(1, from_peer=b, matching=(all_al_v4, all_al_v6)) \ - .set_local_pref(150, from_peer=b, matching=(all_al_v4, all_al_v6,)) - b.get_config(BGP) \ - .set_community(1, from_peer=a, matching=(all_al_v4, all_al_v6,)) \ - .set_local_pref(150, from_peer=a, matching=(all_al_v4, all_al_v6,)) + a.get_config(BGP).set_community( + 1, from_peer=b, matching=(all_al_v4, all_al_v6) + ).set_local_pref( + 150, + from_peer=b, + matching=( + all_al_v4, + all_al_v6, + ), + ) + b.get_config(BGP).set_community( + 1, + from_peer=a, + matching=( + all_al_v4, + all_al_v6, + ), + ).set_local_pref( + 150, + from_peer=a, + matching=( + all_al_v4, + all_al_v6, + ), + ) # Create route maps to filter exported route - a.get_config(BGP) \ - .deny(to_peer=b, matching=(up_link,), - order=10) \ - .deny(to_peer=b, matching=(peers_link,), - order=15) \ - .permit(to_peer=b, order=20) - - b.get_config(BGP) \ - .deny(to_peer=a, matching=(up_link,), - order=10) \ - .deny(to_peer=a, matching=(peers_link,), - order=15) \ - .permit(to_peer=a, order=20) + a.get_config(BGP).deny(to_peer=b, matching=(up_link,), order=10).deny( + to_peer=b, matching=(peers_link,), order=15 + ).permit(to_peer=b, order=20) + + b.get_config(BGP).deny(to_peer=a, matching=(up_link,), order=10).deny( + to_peer=a, matching=(peers_link,), order=15 + ).permit(to_peer=a, order=20) elif link_type == CLIENT_PROVIDER: # Set the community and local pref for the import policy - a.get_config(BGP) \ - .set_community(3, from_peer=b, matching=(all_al_v4, all_al_v6,)) \ - .set_local_pref(100, from_peer=b, matching=(all_al_v4, all_al_v6,)) - b.get_config(BGP) \ - .set_community(2, from_peer=a, matching=(all_al_v4, all_al_v6,)) \ - .set_local_pref(200, from_peer=a, matching=(all_al_v4, all_al_v6,)) + a.get_config(BGP).set_community( + 3, + from_peer=b, + matching=( + all_al_v4, + all_al_v6, + ), + ).set_local_pref( + 100, + from_peer=b, + matching=( + all_al_v4, + all_al_v6, + ), + ) + b.get_config(BGP).set_community( + 2, + from_peer=a, + matching=( + all_al_v4, + all_al_v6, + ), + ).set_local_pref( + 200, + from_peer=a, + matching=( + all_al_v4, + all_al_v6, + ), + ) # Create route maps to filter exported route - a.get_config(BGP) \ - .deny(to_peer=b, matching=(up_link,), - order=10) \ - .deny(to_peer=b, matching=(peers_link,), - order=15) \ - .permit(to_peer=b, order=20) + a.get_config(BGP).deny(to_peer=b, matching=(up_link,), order=10).deny( + to_peer=b, matching=(peers_link,), order=15 + ).permit(to_peer=b, order=20) bgp_peering(topo, a, b) - topo.linkInfo(a, b)['igp_passive'] = True + topo.linkInfo(a, b)["igp_passive"] = True class BGPConfig: - - def __init__(self, topo: 'IPTopo', router: 'RouterDescription'): + def __init__(self, topo: "IPTopo", router: "RouterDescription"): self.topo = topo self.router = router @staticmethod def rm_name(peer: str, family: str, direction: str): - return f"{peer}-{family}-{direction}".format(peer=peer, family=family, direction=direction) - - def set_local_pref(self, local_pref: int, from_peer: str, - matching: Sequence[Union[AccessList, CommunityList]] = - (), name: Optional[str] = None) -> 'BGPConfig': + return f"{peer}-{family}-{direction}".format( + peer=peer, family=family, direction=direction + ) + + def set_local_pref( + self, + local_pref: int, + from_peer: str, + matching: Sequence[AccessList | CommunityList] = (), + name: str | None = None, + ) -> "BGPConfig": """Set local pref on a peering with 'from_peer' on routes matching all of the access and community lists in 'matching' @@ -185,16 +264,22 @@ def set_local_pref(self, local_pref: int, from_peer: str, :param matching: A list of AccessList and/or CommunityList :return: self """ - self.add_set_action(peer=from_peer, - set_action=RouteMapSetAction('local-preference', - local_pref), - matching=matching, direction='in', name=name) + self.add_set_action( + peer=from_peer, + set_action=RouteMapSetAction("local-preference", local_pref), + matching=matching, + direction="in", + name=name, + ) return self - def set_med(self, med: int, to_peer: str, - matching: Sequence[Union[AccessList, CommunityList]] = (), - name: Optional[str] = None) \ - -> 'BGPConfig': + def set_med( + self, + med: int, + to_peer: str, + matching: Sequence[AccessList | CommunityList] = (), + name: str | None = None, + ) -> "BGPConfig": """Set MED on a peering with 'to_peer' on routes matching all of the access and community lists in 'matching' @@ -204,16 +289,23 @@ def set_med(self, med: int, to_peer: str, :param matching: A list of AccessList and/or CommunityList :return: self """ - self.add_set_action(peer=to_peer, - set_action=RouteMapSetAction('metric', med), - matching=matching, direction='out', name=name) + self.add_set_action( + peer=to_peer, + set_action=RouteMapSetAction("metric", med), + matching=matching, + direction="out", + name=name, + ) return self - def set_community(self, community: Union[str, int], - from_peer: Optional[str] = None, - to_peer: Optional[str] = None, - matching: Sequence[Union[AccessList, CommunityList]] = - (), name: Optional[str] = None) -> 'BGPConfig': + def set_community( + self, + community: str | int, + from_peer: str | None = None, + to_peer: str | None = None, + matching: Sequence[AccessList | CommunityList] = (), + name: str | None = None, + ) -> "BGPConfig": """Set community on a routes received from 'from_peer' and routes sent to 'to_peer' on routes matching all of the access and community lists in 'matching' @@ -227,21 +319,32 @@ def set_community(self, community: Union[str, int], :return: self """ if to_peer is not None: - self.add_set_action(peer=to_peer, - set_action=RouteMapSetAction('community', - community), - matching=matching, direction='out', name=name) + self.add_set_action( + peer=to_peer, + set_action=RouteMapSetAction("community", community), + matching=matching, + direction="out", + name=name, + ) if from_peer is not None: - self.add_set_action(peer=from_peer, - set_action=RouteMapSetAction('community', - community), - matching=matching, direction='in', name=name) + self.add_set_action( + peer=from_peer, + set_action=RouteMapSetAction("community", community), + matching=matching, + direction="in", + name=name, + ) return self - def filter(self, name: Optional[str] = None, policy=DENY, - from_peer: Optional[str] = None, to_peer: Optional[str] = None, - matching: Sequence[Union[AccessList, CommunityList, PrefixList]] = (), - order=10) -> 'BGPConfig': + def filter( + self, + name: str | None = None, + policy=DENY, + from_peer: str | None = None, + to_peer: str | None = None, + matching: Sequence[AccessList | CommunityList | PrefixList] = (), + order=10, + ) -> "BGPConfig": """Either accept or deny all routes received from 'from_peer' and routes sent to 'to_peer' matching any access/prefix lists or community lists in 'matching' @@ -263,24 +366,33 @@ def filter(self, name: Optional[str] = None, policy=DENY, i.e., lower order means applied before :return: self """ - route_maps = self.topo.getNodeInfo(self.router, 'bgp_route_maps', list) - for peer, direction in ((from_peer, 'in'), (to_peer, 'out')): + route_maps = self.topo.getNodeInfo(self.router, "bgp_route_maps", list) + for peer, direction in ((from_peer, "in"), (to_peer, "out")): if peer is None: continue - for family in ['ipv4', 'ipv6']: + for family in ["ipv4", "ipv6"]: match_cond = self.filters_to_match_cond(matching, family) - rm = RouteMap(family=family, - name=name if name is not None - else self.rm_name(peer, family, direction), - proto={'bgp'}, - neighbor=peer, - direction=direction) - - rm.entry(RouteMapEntry(family=family, - match_policy=policy, - match_cond=match_cond if match_cond and len(match_cond) > 0 else ()), - order) + rm = RouteMap( + family=family, + name=name + if name is not None + else self.rm_name(peer, family, direction), + proto={"bgp"}, + neighbor=peer, + direction=direction, + ) + + rm.entry( + RouteMapEntry( + family=family, + match_policy=policy, + match_cond=match_cond + if match_cond and len(match_cond) > 0 + else (), + ), + order, + ) try: idx = route_maps.index(rm) @@ -293,10 +405,14 @@ def filter(self, name: Optional[str] = None, policy=DENY, return self - def deny(self, name: Optional[str] = None, from_peer: Optional[str] = None, - to_peer: Optional[str] = None, - matching: Sequence[Union[AccessList, CommunityList]] = (), - order=10) -> 'BGPConfig': + def deny( + self, + name: str | None = None, + from_peer: str | None = None, + to_peer: str | None = None, + matching: Sequence[AccessList | CommunityList] = (), + order=10, + ) -> "BGPConfig": """Deny all routes received from 'from_peer' and routes sent to 'to_peer' matching all of the access and community lists in 'matching' @@ -310,13 +426,23 @@ def deny(self, name: Optional[str] = None, from_peer: Optional[str] = None, i.e., lower order means applied before :return: self """ - return self.filter(name, policy=DENY, from_peer=from_peer, - to_peer=to_peer, matching=matching, order=order) - - def permit(self, name: Optional[str] = None, - from_peer: Optional[str] = None, to_peer: Optional[str] = None, - matching: Sequence[Union[AccessList, CommunityList]] = (), - order=10) -> 'BGPConfig': + return self.filter( + name, + policy=DENY, + from_peer=from_peer, + to_peer=to_peer, + matching=matching, + order=order, + ) + + def permit( + self, + name: str | None = None, + from_peer: str | None = None, + to_peer: str | None = None, + matching: Sequence[AccessList | CommunityList] = (), + order=10, + ) -> "BGPConfig": """Accept all routes received from 'from_peer' and routes sent to 'to_peer' matching all of the access and community lists in 'matching' @@ -330,46 +456,59 @@ def permit(self, name: Optional[str] = None, i.e., lower order means applied before :return: self """ - return self.filter(name, policy=PERMIT, from_peer=from_peer, - to_peer=to_peer, matching=matching, order=order) - - def filters_to_match_cond(self, - filter_list: Sequence[Union[AccessList, - CommunityList, - PrefixList]], - family: str): + return self.filter( + name, + policy=PERMIT, + from_peer=from_peer, + to_peer=to_peer, + matching=matching, + order=order, + ) + + def filters_to_match_cond( + self, + filter_list: Sequence[AccessList | CommunityList | PrefixList], + family: str, + ): match_cond = [] - assert family in {'ipv4', 'ipv6'}, "Bad family %s" % family - access_lists = self.topo.getNodeInfo(self.router, 'bgp_access_lists', - list) - community_list = self.topo.getNodeInfo(self.router, - 'bgp_community_lists', list) + assert family in {"ipv4", "ipv6"}, "Bad family %s" % family + access_lists = self.topo.getNodeInfo(self.router, "bgp_access_lists", list) + community_list = self.topo.getNodeInfo(self.router, "bgp_community_lists", list) - prefix_list = self.topo.getNodeInfo(self.router, 'bgp_prefix_lists', list) + prefix_list = self.topo.getNodeInfo(self.router, "bgp_prefix_lists", list) # Create match_conditions based on the provided filters for f in filter_list: if isinstance(f, CommunityList): - match_cond.append(RouteMapMatchCond('community', f.name, f.family)) + match_cond.append(RouteMapMatchCond("community", f.name, f.family)) if f not in community_list: community_list.append(f) elif isinstance(f, AccessList): if f.family == family: - match_cond.append(RouteMapMatchCond('access-list', f.name, f.family)) + match_cond.append( + RouteMapMatchCond("access-list", f.name, f.family) + ) if f not in access_lists: access_lists.append(f) elif isinstance(f, PrefixList): if f.family == family: - match_cond.append(RouteMapMatchCond('prefix-list', f.name, f.family)) + match_cond.append( + RouteMapMatchCond("prefix-list", f.name, f.family) + ) if f not in prefix_list: prefix_list.append(f) else: raise Exception("Filter not yet implemented") return match_cond - def add_set_action(self, peer: str, set_action: RouteMapSetAction, name: Optional[str], - matching: Sequence[Union[AccessList, CommunityList]], - direction: str) -> 'BGPConfig': + def add_set_action( + self, + peer: str, + set_action: RouteMapSetAction, + name: str | None, + matching: Sequence[AccessList | CommunityList], + direction: str, + ) -> "BGPConfig": """Add a 'RouteMapSetAction' to a BGP peering between two nodes :param name: if set, define the name of the route map, the action @@ -379,16 +518,24 @@ def add_set_action(self, peer: str, set_action: RouteMapSetAction, name: Optiona :param direction: direction of the route map: 'in', 'out' or 'both' :return: self """ - route_maps = self.topo.getNodeInfo(self.router, 'bgp_route_maps', list) + route_maps = self.topo.getNodeInfo(self.router, "bgp_route_maps", list) - for family in ('ipv4', 'ipv6'): + for family in ("ipv4", "ipv6"): match_cond = self.filters_to_match_cond(matching, family) - rm = RouteMap(family=family, name=name if name is not None else self.rm_name(peer, family, direction), - proto={'bgp'}, neighbor=peer, direction=direction) + rm = RouteMap( + family=family, + name=name + if name is not None + else self.rm_name(peer, family, direction), + proto={"bgp"}, + neighbor=peer, + direction=direction, + ) - rm_entry = RouteMapEntry(family=family, match_cond=match_cond, - set_actions=[set_action]) + rm_entry = RouteMapEntry( + family=family, match_cond=match_cond, set_actions=[set_action] + ) try: idx = route_maps.index(rm) @@ -404,7 +551,7 @@ def add_set_action(self, peer: str, set_action: RouteMapSetAction, name: Optiona return self -def set_rr(topo: 'IPTopo', rr: str, peers: Sequence[str] = ()): +def set_rr(topo: "IPTopo", rr: str, peers: Sequence[str] = ()): """ Set rr as route reflector for all router r @@ -414,17 +561,16 @@ def set_rr(topo: 'IPTopo', rr: str, peers: Sequence[str] = ()): """ for r in peers: bgp_peering(topo, rr, r) - router_is_rr = topo.getNodeInfo(rr, 'bgp_rr_info', list) + router_is_rr = topo.getNodeInfo(rr, "bgp_rr_info", list) router_is_rr.append(True) class AbstractBGP(ABC, RouterDaemon): - @property def afi(self): return [AF_INET(), AF_INET6()] - def _build_afi(self, af: Optional[List['AddressFamily']]): + def _build_afi(self, af: list["AddressFamily"] | None): afis = self.afi if af: for address_family in af: @@ -435,19 +581,20 @@ def _build_afi(self, af: Optional[List['AddressFamily']]): return afis @staticmethod - def _address_families(af: List['AddressFamily'], nei: List['Peer']) \ - -> List['AddressFamily']: + def _address_families( + af: list["AddressFamily"], nei: list["Peer"] + ) -> list["AddressFamily"]: """Complete the address families: add extra networks, or activate neighbors. The default is to activate all given neighbors""" for a in af: a.neighbors.extend(nei) return af - def _build_neighbors(self) -> List['Peer']: + def _build_neighbors(self) -> list["Peer"]: """Compute the set of BGP peers for this BGP router :return: set of neighbors""" neighbors = [] - for x in self._node.get('bgp_peers', []): + for x in self._node.get("bgp_peers", []): for v6 in [True, False]: peer = Peer(self._node, x, v6=v6) if peer.peer: @@ -457,17 +604,17 @@ def _build_neighbors(self) -> List['Peer']: class BGP(QuaggaDaemon, AbstractBGP): """This class provides the configuration skeletons for BGP routers.""" - NAME = 'bgpd' + + NAME = "bgpd" DEPENDS = (Zebra,) KILL_PATTERNS = (NAME,) @property def STARTUP_LINE_EXTRA(self): """We add the port to the standard startup line""" - return '-p %s' % self.port + return "-p %s" % self.port - def __init__(self, node, port=BGP_DEFAULT_PORT, - *args, **kwargs): + def __init__(self, node, port=BGP_DEFAULT_PORT, *args, **kwargs): super().__init__(node=node, *args, **kwargs) self.port = port @@ -476,54 +623,60 @@ def build(self): cfg.asn = self._node.asn cfg.neighbors = self._build_neighbors() cfg.address_families = self._address_families( - self._build_afi(self.options.address_families), cfg.neighbors) + self._build_afi(self.options.address_families), cfg.neighbors + ) cfg.access_lists = self.build_access_list() cfg.community_lists = self.build_community_list() cfg.prefix_lists = self.build_prefix_list() cfg.route_maps = self.build_route_map(cfg.neighbors) - cfg.rr = self._node.get('bgp_rr_info') + cfg.rr = self._node.get("bgp_rr_info") return cfg - def build_community_list(self) -> List[CommunityList]: + def build_community_list(self) -> list[CommunityList]: """ Build and return a list of community_filter """ - node_community_lists = self._node.get('bgp_community_lists') + node_community_lists = self._node.get("bgp_community_lists") community_lists = [] if node_community_lists: for node_cl in node_community_lists: # If community is an int change it to the right format # asn:community by adding node asn - cl = CommunityList(name=node_cl.name, - community=node_cl.community, - action=node_cl.action) + cl = CommunityList( + name=node_cl.name, + community=node_cl.community, + action=node_cl.action, + ) community_lists.append(cl) if isinstance(node_cl.community, int): - cl.community = '%s:%d' % (self._node.asn, node_cl.community) + cl.community = "%s:%d" % (self._node.asn, node_cl.community) return community_lists - def build_access_list(self) -> List[AccessList]: + def build_access_list(self) -> list[AccessList]: """ Build and return a list of access_filter :return: """ - return self._node.get('bgp_access_lists', val=list()) + return self._node.get("bgp_access_lists", val=list()) def build_prefix_list(self): - return self._node.get('bgp_prefix_lists', val=list()) + return self._node.get("bgp_prefix_lists", val=list()) - def build_route_map(self, neighbors: Sequence['Peer']) -> List[RouteMap]: + def build_route_map(self, neighbors: Sequence["Peer"]) -> list[RouteMap]: """ Build and return a list of route map for the current node """ - def find_neighbor(route_map: 'RouteMap'): + + def find_neighbor(route_map: "RouteMap"): """ Find the Peer object associated to the neighbor on which the route map will be applied """ - assert route_map.neighbor is not None, "BGP Neighbor route maps must have one neighbor!" + assert route_map.neighbor is not None, ( + "BGP Neighbor route maps must have one neighbor!" + ) for n in neighbors: if n.node == route_map.neighbor and n.family == route_map.family: @@ -532,7 +685,7 @@ def find_neighbor(route_map: 'RouteMap'): final_rms = list() - rms = self._node.get('bgp_route_maps', val=list()) # type: List['RouteMap'] + rms = self._node.get("bgp_route_maps", val=list()) # type: List['RouteMap'] for rm in rms: neigh = find_neighbor(rm) @@ -552,16 +705,20 @@ def set_defaults(self, defaults): super().set_defaults(defaults) @classmethod - def get_config(cls, topo: 'IPTopo', node: 'RouterDescription', **kwargs): + def get_config(cls, topo: "IPTopo", node: "RouterDescription", **kwargs): return BGPConfig(topo=topo, router=node) class AddressFamily: """An address family that is exchanged through BGP""" - def __init__(self, af_name: str, redistribute: Sequence[str] = (), - networks: Sequence[Union[str, IPv4Network, IPv6Network]] = (), - routes=()): + def __init__( + self, + af_name: str, + redistribute: Sequence[str] = (), + networks: Sequence[str | IPv4Network | IPv6Network] = (), + routes=(), + ): self.name = af_name self.networks = [ip_network(str(n)) for n in networks] self.redistribute = set(redistribute) # type: Set[str] @@ -572,10 +729,15 @@ def __str__(self): return repr(self) def __repr__(self) -> str: - return "AddressFamily(%s, networks: %s, redistribute: %s, %s, routes: %s)" % \ - (self.name, self.networks, self.redistribute, self.neighbors, self.routes) - - def extend(self, af: 'AddressFamily'): + return "AddressFamily(%s, networks: %s, redistribute: %s, %s, routes: %s)" % ( + self.name, + self.networks, + self.redistribute, + self.neighbors, + self.routes, + ) + + def extend(self, af: "AddressFamily"): self.name = af.name self.redistribute.update(af.redistribute) self.neighbors.extend(af.neighbors) @@ -587,22 +749,21 @@ def family(self): """ :return: the AddressFamily to be used in FRRouting configuration """ - if self.name == 'ipv4': - return 'ip' - elif self.name == 'ipv6': - return 'ip6' - else: - raise ValueError("Unsupported AddressFamily %s" % self.name) + if self.name == "ipv4": + return "ip" + if self.name == "ipv6": + return "ip6" + raise ValueError("Unsupported AddressFamily %s" % self.name) def AF_INET(*args, **kwargs): """The ipv4 (unicast) address family""" - return AddressFamily('ipv4', *args, **kwargs) + return AddressFamily("ipv4", *args, **kwargs) def AF_INET6(*args, **kwargs): """The ipv6 (unicast) address family""" - return AddressFamily('ipv6', *args, **kwargs) + return AddressFamily("ipv6", *args, **kwargs) class Peer: @@ -619,12 +780,12 @@ def __lt__(self, other): return self.key < other.key def __str__(self): - return str("{} : {}".format(self.key, self.value)) + return str(f"{self.key} : {self.value}") def __repr__(self): return str(self) - def __init__(self, base: 'Router', node: str, v6=False): + def __init__(self, base: "Router", node: str, v6=False): """:param base: The base router that has this peer :param node: The actual peer""" _peer, other, _local_addr = self._find_peer_address(base, node, v6=v6) @@ -634,21 +795,22 @@ def __init__(self, base: 'Router', node: str, v6=False): return self.node = node self.asn = other.asn - self.family = 'ipv4' if not v6 else 'ipv6' + self.family = "ipv4" if not v6 else "ipv6" try: self.port = other.nconfig.daemon(BGP).port except KeyError: # No configured daemon - yet - use default self.port = BGP_DEFAULT_PORT # We default to nexthop self for eBGP routes only - self.nh_self = 'next-hop-self' + self.nh_self = "next-hop-self" # We enable eBGP multihop if eBGP is in use ebgp = self.asn != base.asn self.ebgp_multihop = ebgp - self.description = '%s (%sBGP)' % (node, 'e' if ebgp else 'i') + self.description = "%s (%sBGP)" % (node, "e" if ebgp else "i") @staticmethod - def _find_peer_address(base: 'Router', peer: str, v6=False) \ - -> Tuple[Optional[str], Optional['Router'], Optional[str]]: + def _find_peer_address( + base: "Router", peer: str, v6=False + ) -> tuple[str | None, Optional["Router"], str | None]: """ Finds the IP address of the peer from the base router to typically configure the BGP session between the two routers. @@ -665,7 +827,9 @@ def _find_peer_address(base: 'Router', peer: str, v6=False) \ """ visited = set() # type: Set[str] to_visit = {i.name: i for i in realIntfList(base)} - prio_queue: List['Peer.PQNode'] = [Peer.PQNode((0, i), to_visit[i]) for i in to_visit.keys()] + prio_queue: list[Peer.PQNode] = [ + Peer.PQNode((0, i), to_visit[i]) for i in to_visit + ] heapq.heapify(prio_queue) # Explore all interfaces in base ASN recursively, until we find one # connected to the peer @@ -688,5 +852,10 @@ def _find_peer_address(base: 'Router', peer: str, v6=False) \ if n.node.asn == base.asn or not n.node.asn: for i in realIntfList(n.node): to_visit[i.name] = i - heapq.heappush(prio_queue, Peer.PQNode((path_cost + i.igp_metric, i.name), my_interface)) + heapq.heappush( + prio_queue, + Peer.PQNode( + (path_cost + i.igp_metric, i.name), my_interface + ), + ) return None, None, None diff --git a/ipmininet/router/config/dhcprelay.py b/ipmininet/router/config/dhcprelay.py index 348024a8..5c423753 100644 --- a/ipmininet/router/config/dhcprelay.py +++ b/ipmininet/router/config/dhcprelay.py @@ -1,7 +1,10 @@ -from .base import RouterDaemon -from ipmininet.router.config.utils import ConfigDict import tempfile +from ipmininet.router.config.utils import ConfigDict + +from .base import RouterDaemon + + class DHCPRelay(RouterDaemon): NAME = "dhcprelay" KILL_PATTERNS = ("dnsmasq",) @@ -10,28 +13,28 @@ def __init__(self, node, dhcp_server_ip, listening_ip, **kwargs): self.node = node self.dhcp_server_ip = dhcp_server_ip self.listening_ip = listening_ip - self.pid_file = tempfile.mktemp(dir='/tmp') + self.pid_file = tempfile.mktemp(dir="/tmp") super().__init__(node, **kwargs) - + def build(self) -> ConfigDict: cfg = super().build() cfg.pid_file = self.pid_file cfg.dhcp_server_ip = self.dhcp_server_ip cfg.listening_ip = self.listening_ip return cfg - + @property def startup_line(self): return f"dnsmasq --conf-file={self.cfg_filenames[0]}" - + @property def dry_run(self): return "" - + @property def cfg_filenames(self): - return [self._file(suffix=f"%s.cfg" % self.listening_ip)] - + return [self._file(suffix="%s.cfg" % self.listening_ip)] + def set_defaults(self, defaults): pass diff --git a/ipmininet/router/config/exabgp.py b/ipmininet/router/config/exabgp.py index 8efc9f9e..cf981ebe 100644 --- a/ipmininet/router/config/exabgp.py +++ b/ipmininet/router/config/exabgp.py @@ -1,7 +1,8 @@ from abc import ABC, abstractmethod -from typing import Optional, Sequence, Union, List +from collections.abc import Sequence +from typing import Optional, Union -from .bgp import AbstractBGP, AF_INET, AF_INET6, BGP_DEFAULT_PORT +from .bgp import AF_INET, AF_INET6, BGP_DEFAULT_PORT, AbstractBGP from .utils import ConfigDict @@ -77,12 +78,12 @@ def hex_repr(self) -> str: def val(self): return self.lst - def __init__(self, lst: List[Union[int, str]]): + def __init__(self, lst: list[int | str]): assert isinstance(lst, list), "%s is not a list" % type(lst) self.lst = lst def __str__(self) -> str: - return "[ %s ]" % ' '.join([str(it) for it in self.lst]) + return "[ %s ]" % " ".join([str(it) for it in self.lst]) class BGPAttributeFlags(HexRepresentable): @@ -119,7 +120,9 @@ def __init__(self, optional, transitive, partial, extended): self.partial = partial self.extended = extended - self._hex = self.to_hex_flags(self.optional, self.transitive, self.partial, self.extended) + self._hex = self.to_hex_flags( + self.optional, self.transitive, self.partial, self.extended + ) def __str__(self): return self.hex_repr() @@ -128,8 +131,17 @@ def hex_repr(self): return f"0X{self._hex:X}" def __repr__(self): - return "BGPAttributeFlags(opt=%d, transitive=%d, partial=%d, ext=%d, _hex=%s (%s))" % ( - self.optional, self.transitive, self.partial, self.extended, hex(self._hex), bin(self._hex)) + return ( + "BGPAttributeFlags(opt=%d, transitive=%d, partial=%d, ext=%d, _hex=%s (%s))" + % ( + self.optional, + self.transitive, + self.partial, + self.extended, + hex(self._hex), + bin(self._hex), + ) + ) class BGPAttribute(Representable): @@ -145,23 +157,35 @@ class BGPAttribute(Representable): @property def _known_attr(self): - return {'next-hop', 'origin', 'med', - 'as-path', 'local-preference', 'atomic-aggregate', - 'aggregator', 'originator-id', 'cluster-list', - 'community', 'large-community', 'extended-community', - 'name', 'aigp'} + return { + "next-hop", + "origin", + "med", + "as-path", + "local-preference", + "atomic-aggregate", + "aggregator", + "originator-id", + "cluster-list", + "community", + "large-community", + "extended-community", + "name", + "aigp", + } def hex_repr(self) -> str: - return "attribute [ {type} {flags} {value} ]".format( - type=hex(self.type), - flags=self.flags.hex_repr(), - value=self.val.hex_repr()) + return f"attribute [ {hex(self.type)} {self.flags.hex_repr()} {self.val.hex_repr()} ]" def str_repr(self) -> str: - return "{type} {value}".format(type=str(self.type), value=str(self.val)) - - def __init__(self, attr_type: Union[str, int], val: Union['HexRepresentable', int, str], - flags: Optional['BGPAttributeFlags'] = None): + return f"{self.type!s} {self.val!s}" + + def __init__( + self, + attr_type: str | int, + val: Union["HexRepresentable", int, str], + flags: Optional["BGPAttributeFlags"] = None, + ): """ Constructs an Attribute known from ExaBGP or an unknown attribute if flags is not None. It raises a ValueError if the initialisation of BGPAttribute fails. Either because type_attr @@ -183,9 +207,11 @@ def __init__(self, attr_type: Union[str, int], val: Union['HexRepresentable', in if flags is None: if str(attr_type) not in self._known_attr: - raise ValueError("{unk_attr} is not a known attribute".format(unk_attr=str(attr_type))) + raise ValueError(f"{attr_type!s} is not a known attribute") else: - assert isinstance(val, HexRepresentable), "If flags are set, val must be of type 'HexRepresentable'" + assert isinstance(val, HexRepresentable), ( + "If flags are set, val must be of type 'HexRepresentable'" + ) self.flags = flags self.type = attr_type @@ -198,8 +224,12 @@ def __str__(self): def __repr__(self) -> str: return "BGPAttribute(attr_type={attr_type}, val={val}{flags})".format( - attr_type=self.type, val=self.val, - flags=" flags={val}".format(val=self.flags.hex_repr() if self.flags is not None else "")) + attr_type=self.type, + val=self.val, + flags=" flags={val}".format( + val=self.flags.hex_repr() if self.flags is not None else "" + ), + ) class BGPRoute(Representable): @@ -207,12 +237,12 @@ class BGPRoute(Representable): A BGP route as represented in ExaBGP """ - def __init__(self, network: 'Representable', attributes: Sequence['BGPAttribute']): + def __init__(self, network: "Representable", attributes: Sequence["BGPAttribute"]): self.IPNetwork = network self.attributes = attributes def __str__(self): - route = "unicast {prefix}".format(prefix=str(self.IPNetwork)) + route = f"unicast {self.IPNetwork!s}" for attr in self.attributes: route += " %s" % str(attr) @@ -222,7 +252,7 @@ def __repr__(self): return str(self) def __getitem__(self, item): - if item in ('network', 'IPnetwork'): + if item in ("network", "IPnetwork"): return self.IPNetwork for attr in self.attributes: @@ -246,7 +276,8 @@ def build(self): cfg.port = self.port cfg.neighbors = self._build_neighbors() cfg.address_families = self._address_families( - self.options.address_families, cfg.neighbors) + self.options.address_families, cfg.neighbors + ) self.options.base_env.update(self.options.env) cfg.env = self.options.base_env cfg.passive = self.options.passive @@ -255,11 +286,11 @@ def build(self): @property def STARTUP_LINE_EXTRA(self): - return '' + return "" @property def env_filename(self): - return self._file('env') + return self._file("env") @property def cfg_filenames(self): @@ -271,17 +302,11 @@ def template_filenames(self): @property def startup_line(self) -> str: - return '{name} --env {env} {conf}' \ - .format(name=self.NAME, - env=self.env_filename, - conf=self.cfg_filename) + return f"{self.NAME} --env {self.env_filename} {self.cfg_filename}" @property def dry_run(self) -> str: - return '{name} --validate --env {env} {conf}' \ - .format(name=self.NAME, - env=self.env_filename, - conf=self.cfg_filename) + return f"{self.NAME} --validate --env {self.env_filename} {self.cfg_filename}" def set_defaults(self, defaults): """ @@ -316,30 +341,27 @@ def set_defaults(self, defaults): """ defaults.base_env = ConfigDict( daemon=ConfigDict( - user='root', - drop='false', - daemonize='false', - pid=self._file('pid') + user="root", drop="false", daemonize="false", pid=self._file("pid") ), log=ConfigDict( - level='CRIT', - destination=self._file('log'), - reactor='false', - processes='false', - network='false', + level="CRIT", + destination=self._file("log"), + reactor="false", + processes="false", + network="false", ), api=ConfigDict( - cli='false', + cli="false", ), tcp=ConfigDict( delay=2 # wait at most 2 minutes before sending UPDATE, the other peer - # may not be ready yet. FRRouting do not accept incoming routes - # if ExaBGP sends directly its routes. Debugging information - # says that routes are denied due to a "deny" action from a route map - # (rcvd UPDATE about 8.8.8.0/24 IPv4 unicast -- DENIED due to: route-map;) - # However, if the daemon waits a little bit, routes are all - # accepted... - ) + # may not be ready yet. FRRouting do not accept incoming routes + # if ExaBGP sends directly its routes. Debugging information + # says that routes are denied due to a "deny" action from a route map + # (rcvd UPDATE about 8.8.8.0/24 IPv4 unicast -- DENIED due to: route-map;) + # However, if the daemon waits a little bit, routes are all + # accepted... + ), ) defaults.address_families = [AF_INET(), AF_INET6()] defaults.passive = True diff --git a/ipmininet/router/config/iptables.py b/ipmininet/router/config/iptables.py index 31c4c0ff..8297698d 100644 --- a/ipmininet/router/config/iptables.py +++ b/ipmininet/router/config/iptables.py @@ -1,11 +1,13 @@ """This module defines IP(6)Table configuration. Due to the current (sad) state of affairs of IPv6, one is required to explicitly make two different daemon instances, one to manage iptables, one to manage ip6tables ...""" + from itertools import groupby from operator import attrgetter from os import EX_OK from ipmininet.utils import is_container + from .base import Daemon @@ -17,17 +19,15 @@ class IPTables(Daemon): As such, see `man iptables` and `man iptables-extensions` to see the various table names, commands, pre-existing chains, ...""" - NAME = 'iptables' + NAME = "iptables" @property def startup_line(self): - return '{name}-restore {fname} -w'.format(name=self.NAME, - fname=self.cfg_filename) + return f"{self.NAME}-restore {self.cfg_filename} -w" @property def dry_run(self): - return '{name}-restore -vt {fname} -w'.format(name=self.NAME, - fname=self.cfg_filename) + return f"{self.NAME}-restore -vt {self.cfg_filename} -w" def set_defaults(self, defaults): """ @@ -40,16 +40,15 @@ def set_defaults(self, defaults): def build(self): cfg = super().build() - table_name = attrgetter('table') - cfg.rules = {k: [r for x in v - for r in self._compile_rule(x)] - for k, v in groupby(sorted(self.options.rules, - key=table_name), - table_name)} + table_name = attrgetter("table") + cfg.rules = { + k: [r for x in v for r in self._compile_rule(x)] + for k, v in groupby(sorted(self.options.rules, key=table_name), table_name) + } return cfg def has_started(self, node_exec=None) -> bool: - cmd = '{iptable} -w -L'.format(iptable=self.NAME) + cmd = f"{self.NAME} -w -L" if node_exec is not None: _, _, code = node_exec.pexec(cmd) return code == EX_OK @@ -65,22 +64,24 @@ def _compile_rule(self, rule): class IP6Tables(IPTables): """The IPv6 counterpart to iptables ...""" - NAME = 'ip6tables' + NAME = "ip6tables" # Everything else is already handled through iptables (and ip6tables.mako) class Rule: """A Simple wrapper to represent an IPTable rule""" + def __init__(self, *args, **kw): """:param args: the rule members, which will joined by a whitespace :param table: Specify the table in which the rule should be installed. Defaults to filter.""" self.args = list(args) - self.table = kw.get('table', 'filter') + self.table = kw.get("table", "filter") super().__init__() def __str__(self): - return ' '.join(self.args) + return " ".join(self.args) + __repr__ = __str__ @@ -91,14 +92,14 @@ class Chain: # See the iptables doc for the possible table->chains mappings TABLE_CHAINS = { - 'filter': {'INPUT', 'OUTPUT', 'FORWARD'}, - 'nat': {'PREROUTING', 'INTPUT', 'OUTPUT', 'POSTROUTING'}, - 'mangle': {'PREROUTING', 'INTPUT', 'OUTPUT', 'FORWARD', 'POSTROUTING'}, - 'raw': {'PREROUTING', 'OUTPUT'}, - 'security': {'SECMARK','CONNSECMARK', 'INPUT', 'OUTPUT', 'FORWARD'} + "filter": {"INPUT", "OUTPUT", "FORWARD"}, + "nat": {"PREROUTING", "INTPUT", "OUTPUT", "POSTROUTING"}, + "mangle": {"PREROUTING", "INTPUT", "OUTPUT", "FORWARD", "POSTROUTING"}, + "raw": {"PREROUTING", "OUTPUT"}, + "security": {"SECMARK", "CONNSECMARK", "INPUT", "OUTPUT", "FORWARD"}, } - def __init__(self, table='filter', name='INPUT', default='DROP', rules=()): + def __init__(self, table="filter", name="INPUT", default="DROP", rules=()): """Build a chain description. For convenience, most parameters have more intuitive aliases than their one-letter CLI params. @@ -111,27 +112,25 @@ def __init__(self, table='filter', name='INPUT', default='DROP', rules=()): _table = str(table).lower() allowed_chains = self.TABLE_CHAINS[_table] except KeyError: - raise ValueError('%s does not match to an IPTables table name' % table) + raise ValueError("%s does not match to an IPTables table name" % table) self.table = _table _name = str(name).upper() if _name not in allowed_chains: - raise ValueError('%s is not an allowed Chain for table %s' % (name, table)) + raise ValueError("%s is not an allowed Chain for table %s" % (name, table)) self.name = _name _default = str(default).upper() - if _default != 'DROP' and _default != 'ACCEPT': - raise ValueError('%s is an invalid default policy' % default) + if _default != "DROP" and _default != "ACCEPT": + raise ValueError("%s is an invalid default policy" % default) self.default = _default self.rules = rules def build(self): - yield "-P {chain} {default}".format(chain=self.name, default=self.default) + yield f"-P {self.name} {self.default}" for r in self.rules: - yield "-A {chain} {match_rule}".format(table=self.table, - chain=self.name, - match_rule=r.build()) + yield f"-A {self.name} {r.build()}" class ChainRule: @@ -139,20 +138,24 @@ class ChainRule: embedded in a chain.""" # For convenience, provide more readable aliases of common parameters - ALIASES = {alias: code for code, aliases in ( - ('o', ('oif', 'out_intf', 'out_interface')), - ('i', ('iif', 'in_intf', 'in_interface')), - ('s', ('src', 'source')), - ('d', ('dst', 'destination')), - ('p', ('proto', 'protocol')), - ('m', ('match', 'matching')), - ('port', ('ports')), - ('sport', ('source_port', 'sports', 'source_ports')), - ('dport', ('destination_port', 'dports', 'destination_ports')), - ) for alias in aliases} - ALIASES.update({k:k for k in ALIASES.values()}) - - def __init__(self, action='DROP', **kwargs): + ALIASES = { + alias: code + for code, aliases in ( + ("o", ("oif", "out_intf", "out_interface")), + ("i", ("iif", "in_intf", "in_interface")), + ("s", ("src", "source")), + ("d", ("dst", "destination")), + ("p", ("proto", "protocol")), + ("m", ("match", "matching")), + ("port", ("ports")), + ("sport", ("source_port", "sports", "source_ports")), + ("dport", ("destination_port", "dports", "destination_ports")), + ) + for alias in aliases + } + ALIASES.update({k: k for k in ALIASES.values()}) + + def __init__(self, action="DROP", **kwargs): """ :params action: The action to perform on matching packets. :params oif: match in the output interface (optional) @@ -172,28 +175,38 @@ def __init__(self, action='DROP', **kwargs): raise ValueError("Unknown parameters: %s" % unknown_args) args = {self.ALIASES[k]: v for k, v in kwargs.items()} - self.oif = InterfaceClause('o', args) - self.iif = InterfaceClause('i', args) - self.src = AddressClause('s', args) - self.dst = AddressClause('d', args) - self.proto = MatchClause('p', args) - self.match = MatchClause('m', args) - self.port = PortClause('port', args) - self.sport = PortClause('sport', args) - self.dport = PortClause('dport', args) + self.oif = InterfaceClause("o", args) + self.iif = InterfaceClause("i", args) + self.src = AddressClause("s", args) + self.dst = AddressClause("d", args) + self.proto = MatchClause("p", args) + self.match = MatchClause("m", args) + self.port = PortClause("port", args) + self.sport = PortClause("sport", args) + self.dport = PortClause("dport", args) def build(self): sub_rule = [] - for clause in (self.oif, self.iif, self.src, self.dst, self.proto, - self.match, self.port, self.sport, self.dport): - sub_rule.extend([sub_clause for sub_clause in clause.build() - if sub_clause is not None]) - return "%s -j %s" % (' '.join(sub_rule), self.action) + for clause in ( + self.oif, + self.iif, + self.src, + self.dst, + self.proto, + self.match, + self.port, + self.sport, + self.dport, + ): + sub_rule.extend( + [sub_clause for sub_clause in clause.build() if sub_clause is not None] + ) + return "%s -j %s" % (" ".join(sub_rule), self.action) class NOT: def __init__(self, clause): - """ Negates the match clause + """Negates the match clause :param clause: The value of the match clause to negate """ self.clause = clause @@ -220,9 +233,12 @@ def build(self): for val in self.render(v): yield "{prefix}{neg}-{code} {val}".format( prefix=self.prefix, - neg='' if not self.negate else '! ', - code=self.code if len(self.code.split()[0]) == 1 - else "-" + self.code, val=val) + neg="" if not self.negate else "! ", + code=self.code + if len(self.code.split()[0]) == 1 + else "-" + self.code, + val=val, + ) def render(self, v): # useful if we ever want to be able to expand "node objects" to their @@ -238,7 +254,7 @@ def __init__(self, code, val): elif not is_container(self.val): self.val = str(self.val) else: - self.val = ','.join(self.val) + self.val = ",".join(self.val) self.prefix = "-m multiport " self.code = "%ss" % self.code @@ -253,35 +269,41 @@ class AddressClause(MatchClause): class Filter(Chain): """The filter table acts as inbound, outbound, and forwarding firewall.""" + def __init__(self, **kwargs): - super().__init__(table='filter', **kwargs) + super().__init__(table="filter", **kwargs) class InputFilter(Filter): """The inbound firewall.""" + def __init__(self, **kwargs): - super().__init__(name='INPUT', **kwargs) + super().__init__(name="INPUT", **kwargs) class OutputFilter(Filter): """The outbound firewall.""" + def __init__(self, **kwargs): - super().__init__(name='OUTPUT', **kwargs) + super().__init__(name="OUTPUT", **kwargs) class TransitFilter(Filter): """The forward firewall.""" + def __init__(self, **kwargs): - super().__init__(name='FORWARD', **kwargs) + super().__init__(name="FORWARD", **kwargs) class Allow(ChainRule): """Shorthand for ChainRule(action='ACCEPT', ...). Expresses a whitelisting rule.""" + def __init__(self, **kwargs): - super().__init__(action='ACCEPT', **kwargs) + super().__init__(action="ACCEPT", **kwargs) class Deny(ChainRule): """Shorthand for ChainRule(action='DROP', ...). Expresses a blacklisting rule.""" + def __init__(self, **kwargs): - super().__init__(action='DROP', **kwargs) + super().__init__(action="DROP", **kwargs) diff --git a/ipmininet/router/config/openr.py b/ipmininet/router/config/openr.py index 456f3126..47877e54 100644 --- a/ipmininet/router/config/openr.py +++ b/ipmininet/router/config/openr.py @@ -1,10 +1,12 @@ """Base classes to configure an OpenR daemon""" + from ipaddress import ip_interface from ipmininet.overlay import Overlay from ipmininet.utils import L3Router, realIntfList -from .utils import ConfigDict + from .openrd import OpenrDaemon +from .utils import ConfigDict class OpenrDomain(Overlay): @@ -20,11 +22,11 @@ def __init__(self, domain, routers=(), links=(), **props): @property def domain(self): - return self.links_properties['openr_domain'] + return self.links_properties["openr_domain"] @domain.setter def domain(self, domain): - self.links_properties['openr_domain'] = domain + self.links_properties["openr_domain"] = domain def apply(self, topo): # Add all links for the routers @@ -33,12 +35,13 @@ def apply(self, topo): super().apply(topo) def __str__(self): - return '' % self.domain + return "" % self.domain class Openr(OpenrDaemon): """This class provides a simple configuration for an OpenR daemon.""" - NAME = 'openr' + + NAME = "openr" DEPENDS = (OpenrDaemon,) KILL_PATTERNS = (NAME,) @@ -47,14 +50,15 @@ def __init__(self, node, *args, **kwargs): @property def logdir(self) -> str: - if 'log_dir' in self._options: - return self._options['log_dir'] + if "log_dir" in self._options: + return self._options["log_dir"] return None def build(self): cfg = super().build() - self.options.redistribute_ifaces = \ - ','.join([intf.name for intf in self._node.intfList()]) + self.options.redistribute_ifaces = ",".join( + [intf.name for intf in self._node.intfList()] + ) cfg.update(self.options) interfaces = realIntfList(self._node) cfg.interfaces = self._build_interfaces(interfaces) @@ -66,9 +70,10 @@ def build(self): def _build_networks(interfaces): """Return the list of OpenR networks to advertize from the list of active OpenR interfaces""" + # Check that we have at least one IPv4 network on that interface ... def _openr_net(ip, prefixLen): - domain = ip_interface('%s/%s' % (ip, prefixLen)) + domain = ip_interface("%s/%s" % (ip, prefixLen)) return OpenrNetwork(domain=domain) return [_openr_net(i.ip, i.prefixLen) for i in interfaces if i.ip] @@ -76,10 +81,13 @@ def _openr_net(ip, prefixLen): def _build_interfaces(self, interfaces): """Return the list of OpenR interface properties from the list of active interfaces""" + def _iface_conf(iface): - return ConfigDict(description=iface.describe, - name=iface.name, - active=self.is_active_interface(iface)) + return ConfigDict( + description=iface.describe, + name=iface.name, + active=self.is_active_interface(iface), + ) return [_iface_conf(i) for i in interfaces] @@ -89,16 +97,16 @@ def _build_prefixes(interfaces): for itf in interfaces: ipv6_addresses += itf.addresses[6] ipv6_addresses = filter(lambda x: not x.is_link_local, ipv6_addresses) - return ','.join(map(lambda x: x.with_prefixlen, ipv6_addresses)) + return ",".join(map(lambda x: x.with_prefixlen, ipv6_addresses)) def set_defaults(self, defaults): """Updates some options of the OpenR daemon to run a network of routers in mininet. For a full list of parameters see OpenrDaemon:_defaults in openrd.py""" defaults.node_name = self._node.name - defaults.ifname_prefix = '{}-eth'.format(self._node.name) - defaults.iface_regex_include = '{}-eth.*'.format(self._node.name) - defaults.log_dir = '/var/tmp/log/{}'.format(self._node.name) + defaults.ifname_prefix = f"{self._node.name}-eth" + defaults.iface_regex_include = f"{self._node.name}-eth.*" + defaults.log_dir = f"/var/tmp/log/{self._node.name}" defaults.enable_v4 = True super().set_defaults(defaults) @@ -107,8 +115,9 @@ def is_active_interface(itf): """Return whether an interface is active or not for the OpenR daemon""" if itf.broadcast_domain is None: return False - return any(L3Router.is_l3router_intf(i) for i in itf.broadcast_domain - if i != itf) + return any( + L3Router.is_l3router_intf(i) for i in itf.broadcast_domain if i != itf + ) class OpenrNetwork: diff --git a/ipmininet/router/config/openrd.py b/ipmininet/router/config/openrd.py index 0f998e8d..7355fdf5 100644 --- a/ipmininet/router/config/openrd.py +++ b/ipmininet/router/config/openrd.py @@ -5,19 +5,16 @@ class OpenrDaemon(RouterDaemon): """The base class for the OpenR daemon""" - NAME = 'openr' + NAME = "openr" @property def STARTUP_LINE_EXTRA(self): # Add options to the standard startup line - return '' + return "" @property def startup_line(self): - return '{name} {cfg} {extra}'\ - .format(name=self.NAME, - cfg=self._cfg_options(), - extra=self.STARTUP_LINE_EXTRA) + return f"{self.NAME} {self._cfg_options()} {self.STARTUP_LINE_EXTRA}" def build(self): cfg = ConfigDict() @@ -236,8 +233,9 @@ def _cfg_options(self): the openr.mako template and passed to the daemon as argument.""" cfg = ConfigDict() cfg[self.NAME] = self.options - return self.template_lookup.get_template(self.template_filenames[0])\ - .render(node=cfg) + return self.template_lookup.get_template(self.template_filenames[0]).render( + node=cfg + ) @property def dry_run(self): @@ -245,5 +243,4 @@ def dry_run(self): As a workaround we only show the version of the openr daemon""" # TODO: Replace with a config parser or shutdown the daemon after few # seconds - return '{name} --version'\ - .format(name=self.NAME) + return f"{self.NAME} --version" diff --git a/ipmininet/router/config/ospf.py b/ipmininet/router/config/ospf.py index 4caeca0a..8bf0916c 100644 --- a/ipmininet/router/config/ospf.py +++ b/ipmininet/router/config/ospf.py @@ -1,10 +1,12 @@ """Base classes to configure an OSPF daemon""" -from ipaddress import ip_interface, IPv4Network -from typing import Sequence, List + +from collections.abc import Sequence +from ipaddress import IPv4Network, ip_interface from ipmininet.link import IPIntf from ipmininet.overlay import Overlay from ipmininet.utils import L3Router + from .utils import ConfigDict from .zebra import QuaggaDaemon, Zebra @@ -12,8 +14,9 @@ class OSPFArea(Overlay): """An overlay to group OSPF links and routers by area""" - def __init__(self, area: str, routers: Sequence[str] = (), - links: Sequence[str] = (), **props): + def __init__( + self, area: str, routers: Sequence[str] = (), links: Sequence[str] = (), **props + ): """:param area: the area for this overlay :param routers: the set of routers for which all their interfaces belong to that area @@ -23,13 +26,13 @@ def __init__(self, area: str, routers: Sequence[str] = (), @property def area(self) -> str: - return self.links_properties['igp_area'] + return self.links_properties["igp_area"] @area.setter def area(self, x: str): - self.links_properties['igp_area'] = x + self.links_properties["igp_area"] = x # Also set node property so we can use it for the loopback interface - self.nodes_properties['igp_area'] = x + self.nodes_properties["igp_area"] = x def apply(self, topo): # Add all links for the routers @@ -38,14 +41,15 @@ def apply(self, topo): super().apply(topo) def __str__(self): - return '' % self.area + return "" % self.area class OSPF(QuaggaDaemon): """This class provides a simple configuration for an OSPF daemon. It advertizes one network per interface (the primary one), and set interfaces not facing another L3Router to passive""" - NAME = 'ospfd' + + NAME = "ospfd" DEPENDS = (Zebra,) KILL_PATTERNS = (NAME,) @@ -61,30 +65,36 @@ def build(self): return cfg @staticmethod - def _build_networks(interfaces: List[IPIntf]) -> List['OSPFNetwork']: + def _build_networks(interfaces: list[IPIntf]) -> list["OSPFNetwork"]: """Return the list of OSPF networks to advertize from the list of active OSPF interfaces""" # Check that we have at least one IPv4 network on that interface ... - return [OSPFNetwork(domain=ip_interface('%s/%s' % (i.ip, i.prefixLen)), - area=i.igp_area) for i in interfaces if i.ip] - - def _build_interfaces(self, interfaces: List[IPIntf]) -> List[ConfigDict]: + return [ + OSPFNetwork( + domain=ip_interface("%s/%s" % (i.ip, i.prefixLen)), area=i.igp_area + ) + for i in interfaces + if i.ip + ] + + def _build_interfaces(self, interfaces: list[IPIntf]) -> list[ConfigDict]: """Return the list of OSPF interface properties from the list of active interfaces""" - return [ConfigDict(description=i.describe, - name=i.name, - # Is the interface between two routers? - active=self.is_active_interface(i), - priority=i.get('ospf_priority', - self.options.priority), - dead_int=i.get('ospf_dead_int', - self.options.dead_int), - hello_int=i.get('ospf_hello_int', - self.options.hello_int), - cost=i.igp_metric, - # Is the interface forcefully disabled? - passive=i.get('igp_passive', False)) - for i in interfaces] + return [ + ConfigDict( + description=i.describe, + name=i.name, + # Is the interface between two routers? + active=self.is_active_interface(i), + priority=i.get("ospf_priority", self.options.priority), + dead_int=i.get("ospf_dead_int", self.options.dead_int), + hello_int=i.get("ospf_hello_int", self.options.hello_int), + cost=i.igp_metric, + # Is the interface forcefully disabled? + passive=i.get("igp_passive", False), + ) + for i in interfaces + ] def set_defaults(self, defaults): """:param debug: the set of debug events that should be logged @@ -92,7 +102,7 @@ def set_defaults(self, defaults): :param hello_int: Hello interval timer :param priority: priority for the interface, used for DR election :param redistribute: set of OSPFRedistributedRoute sources""" - defaults.dead_int = 'minimal hello-multiplier 5' + defaults.dead_int = "minimal hello-multiplier 5" defaults.hello_int = 1 defaults.priority = 10 defaults.redistribute = [] @@ -103,8 +113,9 @@ def is_active_interface(itf) -> bool: """Return whether an interface is active or not for the OSPF daemon""" if itf.broadcast_domain is None: return False - return any(L3Router.is_l3router_intf(i) for i in itf.broadcast_domain - if i != itf) + return any( + L3Router.is_l3router_intf(i) for i in itf.broadcast_domain if i != itf + ) class OSPFNetwork: diff --git a/ipmininet/router/config/ospf6.py b/ipmininet/router/config/ospf6.py index 26d5d933..86c6801e 100644 --- a/ipmininet/router/config/ospf6.py +++ b/ipmininet/router/config/ospf6.py @@ -8,29 +8,37 @@ class OSPF6(OSPF): """This class provides a simple configuration for an OSPF6 daemon. It advertizes one network per interface (the primary one), and set interfaces not facing another L3Router to passive""" - NAME = 'ospf6d' + + NAME = "ospf6d" DEAD_INT = 3 KILL_PATTERNS = (NAME,) def _build_interfaces(self, interfaces): """Return the list of OSPF6 interface properties from the list of active interfaces""" - conf = [ConfigDict( - description=i.describe, - name=i.name, - # Is the interface between two routers? - active=self.is_active_interface(i), - priority=i.get('ospf6_priority', - i.get('ospf_priority', self.options.priority)), - dead_int=i.get('ospf6_dead_int', - i.get('ospf_dead_int', self.options.dead_int)), - hello_int=i.get('ospf6_hello_int', - i.get('ospf_hello_int', self.options.hello_int)), - cost=i.igp_metric, - # Is the interface forcefully disabled? - passive=i.get('igp_passive', False), - instance_id=i.get('instance_id', self.options.instance_id), - area=i.igp_area) for i in interfaces] + conf = [ + ConfigDict( + description=i.describe, + name=i.name, + # Is the interface between two routers? + active=self.is_active_interface(i), + priority=i.get( + "ospf6_priority", i.get("ospf_priority", self.options.priority) + ), + dead_int=i.get( + "ospf6_dead_int", i.get("ospf_dead_int", self.options.dead_int) + ), + hello_int=i.get( + "ospf6_hello_int", i.get("ospf_hello_int", self.options.hello_int) + ), + cost=i.igp_metric, + # Is the interface forcefully disabled? + passive=i.get("igp_passive", False), + instance_id=i.get("instance_id", self.options.instance_id), + area=i.igp_area, + ) + for i in interfaces + ] # 'minimal hello-multiplier x' is not implemented in ospf6d for dic in conf: try: diff --git a/ipmininet/router/config/pimd.py b/ipmininet/router/config/pimd.py index 771007de..9e849980 100644 --- a/ipmininet/router/config/pimd.py +++ b/ipmininet/router/config/pimd.py @@ -1,13 +1,14 @@ -from .zebra import QuaggaDaemon, Zebra -from .utils import ConfigDict from ipmininet.utils import realIntfList +from .utils import ConfigDict +from .zebra import QuaggaDaemon, Zebra + class PIMD(QuaggaDaemon): """This class configures a PIM daemon to responds to IGMP queries in order to setup multicast routing in the network.""" - NAME = 'pimd' + NAME = "pimd" DEPENDS = (Zebra,) KILL_PATTERNS = (NAME,) @@ -18,12 +19,14 @@ def build(self): cfg = super().build() cfg.update(self.options) cfg.interfaces = [ - ConfigDict(name=itf.name, - ssm=itf.get('multicast_ssm', self.options.multicast_ssm), - igmp=itf.get('multicast_igmp', - self.options.multicast_igmp)) - for itf in realIntfList(self._node) if itf.get("enable_multicast", - False)] + ConfigDict( + name=itf.name, + ssm=itf.get("multicast_ssm", self.options.multicast_ssm), + igmp=itf.get("multicast_igmp", self.options.multicast_igmp), + ) + for itf in realIntfList(self._node) + if itf.get("enable_multicast", False) + ] return cfg def set_defaults(self, defaults): diff --git a/ipmininet/router/config/radvd.py b/ipmininet/router/config/radvd.py index 5b0f6fbf..3ad853ea 100644 --- a/ipmininet/router/config/radvd.py +++ b/ipmininet/router/config/radvd.py @@ -1,11 +1,10 @@ -from ipaddress import ip_address, IPv6Network, IPv6Address -from typing import Sequence, Union +from collections.abc import Sequence +from ipaddress import IPv6Address, IPv6Network, ip_address + +from ipmininet.utils import find_node, is_container, realIntfList -from ipmininet.utils import find_node -from ipmininet.utils import realIntfList from .base import RouterDaemon from .utils import ConfigDict -from ipmininet.utils import is_container RA_DEFAULT_VALID = 86400 RA_DEFAULT_PREF = 14400 @@ -15,14 +14,17 @@ class AdvPrefix(ConfigDict): """The class representing advertised prefixes in a Router Advertisement""" - def __init__(self, prefix: Sequence[Union[str, IPv6Network]] = (), - valid_lifetime=RA_DEFAULT_VALID, - preferred_lifetime=RA_DEFAULT_PREF): + def __init__( + self, + prefix: Sequence[str | IPv6Network] = (), + valid_lifetime=RA_DEFAULT_VALID, + preferred_lifetime=RA_DEFAULT_PREF, + ): """:param prefix: the list of IPv6 prefixes to advertise - :param valid_lifetime: corresponds to the AdvValidLifetime - in radvd.conf(5) for this prefix - :param preferred_lifetime: corresponds to the AdvPreferredLifetime - in radvd.conf(5) for this prefix""" + :param valid_lifetime: corresponds to the AdvValidLifetime + in radvd.conf(5) for this prefix + :param preferred_lifetime: corresponds to the AdvPreferredLifetime + in radvd.conf(5) for this prefix""" super().__init__() self["prefixes"] = list(prefix) if is_container(prefix) else [prefix] self["valid_lifetime"] = valid_lifetime @@ -32,26 +34,29 @@ def __init__(self, prefix: Sequence[Union[str, IPv6Network]] = (), class AdvConnectedPrefix(AdvPrefix): """This class forces the advertisement of all prefixes on the interface""" - def __init__(self, valid_lifetime=RA_DEFAULT_VALID, - preferred_lifetime=RA_DEFAULT_PREF): + def __init__( + self, valid_lifetime=RA_DEFAULT_VALID, preferred_lifetime=RA_DEFAULT_PREF + ): """:param valid_lifetime: corresponds to the AdvValidLifetime - in radvd.conf(5) for this prefix - :param preferred_lifetime: corresponds to the AdvPreferredLifetime - in radvd.conf(5) for this prefix""" - super().__init__(valid_lifetime=valid_lifetime, - preferred_lifetime=preferred_lifetime) + in radvd.conf(5) for this prefix + :param preferred_lifetime: corresponds to the AdvPreferredLifetime + in radvd.conf(5) for this prefix""" + super().__init__( + valid_lifetime=valid_lifetime, preferred_lifetime=preferred_lifetime + ) class AdvRDNSS(ConfigDict): """The class representing an advertised DNS server in a Router Advertisement""" - def __init__(self, node: Union[str, IPv6Address], - max_lifetime=DEFAULT_ADV_RDNSS_LIFETIME): + def __init__( + self, node: str | IPv6Address, max_lifetime=DEFAULT_ADV_RDNSS_LIFETIME + ): """:param node: Either the IPv6 address of the DNS server - or the node name - :param max_lifetime: corresponds to the AdvValidLifetime - in radvd.conf(5) for this dns server address""" + or the node name + :param max_lifetime: corresponds to the AdvValidLifetime + in radvd.conf(5) for this dns server address""" super().__init__() self["node"] = node self["max_lifetime"] = max_lifetime @@ -66,7 +71,7 @@ class RADVD(RouterDaemon): """The class representing the radvd daemon, used for router advertisements""" - NAME = 'radvd' + NAME = "radvd" KILL_PATTERNS = (NAME,) def build(self): @@ -74,11 +79,16 @@ def build(self): # Update with preset defaults cfg.update(self.options) # Track interfaces - cfg.interfaces = (ConfigDict(name=itf.name, description=itf.describe, - ra_prefixes=itf.ra_prefixes, - rdnss_list=itf.rdnss_list) - for itf in realIntfList(self._node) - if itf.ra_prefixes) + cfg.interfaces = ( + ConfigDict( + name=itf.name, + description=itf.describe, + ra_prefixes=itf.ra_prefixes, + rdnss_list=itf.rdnss_list, + ) + for itf in realIntfList(self._node) + if itf.ra_prefixes + ) # Fill AdvConnectedPrefix prefixes self._fill_connected_prefixes() # Fill AdvRDNSS IP addresses @@ -113,22 +123,27 @@ def set_defaults(self, defaults): @property def startup_line(self): - return ('radvd -d {debuglevel} -C {cfg} -p {pid} -m logfile -l {log}' - ' -u root'.format(debuglevel=self.options.debuglevel, - cfg=self.cfg_filename, log=self._file('log'), - pid=self._file('pid'))) + return ( + "radvd -d {debuglevel} -C {cfg} -p {pid} -m logfile -l {log}" + " -u root".format( + debuglevel=self.options.debuglevel, + cfg=self.cfg_filename, + log=self._file("log"), + pid=self._file("pid"), + ) + ) @property def dry_run(self): - return 'radvd -c -C {cfg} -u root'.format(cfg=self.cfg_filename) + return f"radvd -c -C {self.cfg_filename} -u root" def cleanup(self): try: - with open(self._file('pid'), 'r') as f: + with open(self._file("pid")) as f: for line in f: if len(line) > 1: pid = int(line[:-1]) - self._node._processes.call('kill -9 %d ' % pid) - except (IOError, OSError): + self._node._processes.call("kill -9 %d " % pid) + except OSError: pass super().cleanup() diff --git a/ipmininet/router/config/ripng.py b/ipmininet/router/config/ripng.py index 6451ac8f..8ab3aa1e 100644 --- a/ipmininet/router/config/ripng.py +++ b/ipmininet/router/config/ripng.py @@ -1,9 +1,10 @@ """Base classes to configure a RIP daemon""" -from ipaddress import ip_interface, IPv6Interface -from typing import List + +from ipaddress import IPv6Interface, ip_interface from ipmininet.link import IPIntf from ipmininet.utils import L3Router + from .utils import ConfigDict from .zebra import QuaggaDaemon, Zebra @@ -16,7 +17,8 @@ class RIPng(QuaggaDaemon): """This class provides a simple configuration for an RIP daemon. It advertizes one network per interface (the primary one), and set interfaces not facing another L3Router to passive""" - NAME = 'ripngd' + + NAME = "ripngd" DEPENDS = (Zebra,) KILL_PATTERNS = (NAME,) @@ -34,23 +36,29 @@ def build(self): return cfg @staticmethod - def _build_networks(interfaces: List[IPIntf]) -> List['RIPNetwork']: + def _build_networks(interfaces: list[IPIntf]) -> list["RIPNetwork"]: """Return the list of RIP networks to advertize from the list of active RIP interfaces""" - return [RIPNetwork(domain=ip_interface('%s/%s' % (i.ip6, i.prefixLen6))) - for i in interfaces if i.ip6] + return [ + RIPNetwork(domain=ip_interface("%s/%s" % (i.ip6, i.prefixLen6))) + for i in interfaces + if i.ip6 + ] - def _build_interfaces(self, interfaces: List[IPIntf]) -> List[ConfigDict]: + def _build_interfaces(self, interfaces: list[IPIntf]) -> list[ConfigDict]: """Return the list of RIP interface properties from the list of active interfaces""" - return [ConfigDict(description=i.describe, - name=i.name, - # Is the interface between two routers? - active=self.is_active_interface(i), - cost=i.igp_metric - 1, - domain=ip_interface('%s/%s' - % (i.ip6, i.prefixLen6))) - for i in interfaces] + return [ + ConfigDict( + description=i.describe, + name=i.name, + # Is the interface between two routers? + active=self.is_active_interface(i), + cost=i.igp_metric - 1, + domain=ip_interface("%s/%s" % (i.ip6, i.prefixLen6)), + ) + for i in interfaces + ] def set_defaults(self, defaults): """:param debug: the set of debug events that should be logged @@ -82,8 +90,9 @@ def is_active_interface(itf) -> bool: """Return whether an interface is active or not for the RIPng daemon""" if itf.broadcast_domain is None: return False - return any(L3Router.is_l3router_intf(i) for i in itf.broadcast_domain - if i != itf) + return any( + L3Router.is_l3router_intf(i) for i in itf.broadcast_domain if i != itf + ) class RIPNetwork: diff --git a/ipmininet/router/config/sshd.py b/ipmininet/router/config/sshd.py index 816611a8..188481e9 100644 --- a/ipmininet/router/config/sshd.py +++ b/ipmininet/router/config/sshd.py @@ -1,38 +1,37 @@ """This module defines an sshd configuration.""" -from distutils.spawn import find_executable -import subprocess + import os +import subprocess import tempfile -from .base import Daemon +from distutils.spawn import find_executable +from .base import Daemon # Generate a new ssh keypair at each run -KEYFILE = tempfile.mktemp(dir='/tmp') -PUBKEY = '%s.pub' % KEYFILE +KEYFILE = tempfile.mktemp(dir="/tmp") +PUBKEY = "%s.pub" % KEYFILE if os.path.exists(KEYFILE): os.unlink(KEYFILE) if os.path.exists(PUBKEY): os.unlink(PUBKEY) -subprocess.call(['ssh-keygen', '-b', '2048', '-t', 'rsa', '-f', KEYFILE, '-q', - '-P', '']) +subprocess.call( + ["ssh-keygen", "-b", "2048", "-t", "rsa", "-f", KEYFILE, "-q", "-P", ""] +) class SSHd(Daemon): - - NAME = 'sshd' - STARTUP_LINE_BASE = '{name} -D -u0'.format(name=find_executable(NAME)) + NAME = "sshd" + STARTUP_LINE_BASE = f"{find_executable(NAME)} -D -u0" KILL_PATTERNS = (STARTUP_LINE_BASE,) @property def startup_line(self): - return ('{base} -f {cfg}' - .format(base=self.STARTUP_LINE_BASE, - cfg=os.path.abspath(self.cfg_filename))) + return f"{self.STARTUP_LINE_BASE} -f {os.path.abspath(self.cfg_filename)}" @property def dry_run(self): - return '%s -t' % self.startup_line + return "%s -t" % self.startup_line def set_defaults(self, defaults): super().set_defaults(defaults) diff --git a/ipmininet/router/config/staticd.py b/ipmininet/router/config/staticd.py index d727a72f..f883f36f 100644 --- a/ipmininet/router/config/staticd.py +++ b/ipmininet/router/config/staticd.py @@ -1,12 +1,10 @@ -from ipaddress import ip_network, IPv4Network, IPv6Network, IPv4Address, \ - IPv6Address -from typing import Union +from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network from ipmininet.router.config.zebra import QuaggaDaemon, Zebra class STATIC(QuaggaDaemon): - NAME = 'staticd' + NAME = "staticd" DEPENDS = (Zebra,) KILL_PATTERNS = (NAME,) @@ -26,8 +24,12 @@ def set_defaults(self, defaults): class StaticRoute: """A class representing a static route""" - def __init__(self, prefix: Union[str, IPv4Network, IPv6Network], - nexthop: Union[str, IPv4Address, IPv6Address], distance=1): + def __init__( + self, + prefix: str | IPv4Network | IPv6Network, + nexthop: str | IPv4Address | IPv6Address, + distance=1, + ): """:param prefix: The prefix for this static route :param nexthop: The nexthop for this prefix, one of: diff --git a/ipmininet/router/config/utils.py b/ipmininet/router/config/utils.py index e215dcfa..21b303da 100644 --- a/ipmininet/router/config/utils.py +++ b/ipmininet/router/config/utils.py @@ -1,6 +1,6 @@ """This modules contains various utilities to streamline config generation""" -from ipaddress import ip_interface, IPv6Address, IPv4Address -from typing import Union + +from ipaddress import IPv4Address, IPv6Address, ip_interface class ConfigDict(dict): @@ -29,10 +29,10 @@ def __setattr__(self, key, value): self[key] = value -def ip_statement(ip: Union[int, str, IPv6Address, IPv4Address]): +def ip_statement(ip: int | str | IPv6Address | IPv4Address): """Return the zebra ip statement for a given ip prefix :type ip: ip_interface, ip_network, ip_address, int, str""" if not isinstance(ip, int): ip = ip_interface(str(ip)).version - return 'ipv6' if ip == 6 else 'ip' + return "ipv6" if ip == 6 else "ip" diff --git a/ipmininet/router/config/zebra.py b/ipmininet/router/config/zebra.py index 0f1e05c4..15d67617 100644 --- a/ipmininet/router/config/zebra.py +++ b/ipmininet/router/config/zebra.py @@ -1,23 +1,24 @@ import os import socket -from abc import abstractmethod, ABC +from abc import ABC, abstractmethod +from collections.abc import Sequence from ipaddress import IPv4Network, IPv6Network, ip_network -from typing import Optional, Union, Sequence, Tuple, Set, Dict +from typing import Union from .base import RouterDaemon from .utils import ConfigDict # Route Map actions -DENY = 'deny' -PERMIT = 'permit' +DENY = "deny" +PERMIT = "permit" -def get_family(prefix: Union[str, IPv4Network, IPv6Network]) -> Optional[str]: +def get_family(prefix: str | IPv4Network | IPv6Network) -> str | None: pfx = ip_network(prefix) if isinstance(prefix, str) else prefix if pfx.version == 4: - return 'ipv4' - elif pfx.version == 6: - return 'ipv6' + return "ipv4" + if pfx.version == 6: + return "ipv6" return None @@ -26,22 +27,22 @@ class QuaggaDaemon(RouterDaemon): """The base class for all Quagga-derived daemons""" # Additional parameters to pass when starting the daemon - STARTUP_LINE_EXTRA = '' + STARTUP_LINE_EXTRA = "" @property def startup_line(self): - return '{name} -f {cfg} -i {pid} -z {api} -u root {extra}'\ - .format(name=self.NAME, - cfg=self.cfg_filename, - pid=self._file('pid'), - api=self.zebra_socket, - extra=self.STARTUP_LINE_EXTRA) + return "{name} -f {cfg} -i {pid} -z {api} -u root {extra}".format( + name=self.NAME, + cfg=self.cfg_filename, + pid=self._file("pid"), + api=self.zebra_socket, + extra=self.STARTUP_LINE_EXTRA, + ) @property def zebra_socket(self): """Return the path towards the zebra API socket for the given node""" - return os.path.join(self._node.cwd, - '%s_%s.api' % ('quagga', self._node.name)) + return os.path.join(self._node.cwd, "%s_%s.api" % ("quagga", self._node.name)) def build(self): cfg = super().build() @@ -55,15 +56,13 @@ def set_defaults(self, defaults): @property def dry_run(self): - return '{name} -Cf {cfg} -u root'\ - .format(name=self.NAME, - cfg=self.cfg_filename) + return f"{self.NAME} -Cf {self.cfg_filename} -u root" class Zebra(QuaggaDaemon): - NAME = 'zebra' + NAME = "zebra" PRIO = 0 - STARTUP_LINE_EXTRA = '' + STARTUP_LINE_EXTRA = "" KILL_PATTERNS = (NAME,) def __init__(self, *args, **kwargs): @@ -75,9 +74,10 @@ def build(self): # Update with preset defaults cfg.update(self.options) # Track interfaces - cfg.interfaces = (ConfigDict(name=itf.name, - description=itf.describe) - for itf in self._node.intfList()) + cfg.interfaces = ( + ConfigDict(name=itf.name, description=itf.describe) + for itf in self._node.intfList() + ) return cfg def set_defaults(self, defaults): @@ -100,17 +100,19 @@ def listening(self) -> bool: sock.connect(self.zebra_socket) sock.close() return True - except socket.error: + except OSError: return False class CommunityList: """A zebra community-list entry""" + # Number of CmL count = 0 - def __init__(self, name: Optional[str] = None, action=PERMIT, - community: Union[int, str] = 0): + def __init__( + self, name: str | None = None, action=PERMIT, community: int | str = 0 + ): """ :param name: @@ -118,18 +120,19 @@ def __init__(self, name: Optional[str] = None, action=PERMIT, :param community: """ CommunityList.count += 1 - self.name = name if name else 'cml%d' % CommunityList.count + self.name = name or "cml%d" % CommunityList.count self.action = action self.community = community - self.family = 'community' + self.family = "community" def __eq__(self, other): return self.name == other.name and self.action == other.action class Entry: - def __init__(self, prefix: Union[str, IPv4Network, IPv6Network], - action=PERMIT, family=None): + def __init__( + self, prefix: str | IPv4Network | IPv6Network, action=PERMIT, family=None + ): """ :param prefix: The ip_interface prefix for that ACL entry :param action: Whether that prefix belongs to the ACL (PERMIT) @@ -137,57 +140,73 @@ def __init__(self, prefix: Union[str, IPv4Network, IPv6Network], """ if isinstance(prefix, str): - if prefix == 'any': + if prefix == "any": assert family is not None _prefix = prefix else: _prefix = ip_network(prefix) if family is not None: - assert get_family(_prefix) == family, \ - "prefix family %s != family (%s)" % \ - (get_family(_prefix), family) + assert get_family(_prefix) == family, ( + "prefix family %s != family (%s)" + % (get_family(_prefix), family) + ) else: _prefix = prefix self.prefix = _prefix self.action = action - self.family = family if family else get_family(self.prefix) + self.family = family or get_family(self.prefix) @property def zebra_family(self): - if self.family == 'ipv4': - return 'ip' - return 'ipv6' + if self.family == "ipv4": + return "ip" + return "ipv6" class AccessListEntry(Entry): """A zebra access-list entry""" - def __init__(self, prefix: Union[str, IPv4Network, IPv6Network], action=PERMIT, family=None): + def __init__( + self, prefix: str | IPv4Network | IPv6Network, action=PERMIT, family=None + ): super().__init__(prefix, action, family) class PrefixListEntry(Entry): - def __init__(self, prefix: Union[str, IPv4Network, IPv6Network], action=PERMIT, family=None, le=None, ge=None): - type_mask = {'ipv4': 32, 'ipv6': 128} + def __init__( + self, + prefix: str | IPv4Network | IPv6Network, + action=PERMIT, + family=None, + le=None, + ge=None, + ): + type_mask = {"ipv4": 32, "ipv6": 128} super().__init__(prefix, action, family) # The 'any' prefix-list entry has a special action - if self.prefix == 'any': - self.prefix = ip_network("0.0.0.0/0") if self.family == 'ipv4' \ - else ip_network('::/0') + if self.prefix == "any": + self.prefix = ( + ip_network("0.0.0.0/0") if self.family == "ipv4" else ip_network("::/0") + ) self.le = type_mask[self.family] return if le is not None: - assert 0 <= le <= type_mask[self.family], "assertion %d <= le (%d) <= %d failed" % ( - 0, le, type_mask[self.family]) + assert 0 <= le <= type_mask[self.family], ( + "assertion %d <= le (%d) <= %d failed" % (0, le, type_mask[self.family]) + ) if ge is not None: - assert 0 <= ge <= type_mask[self.family], "assertion %d <= ge (%d) <= %d failed" % ( - 0, ge, type_mask[self.family]) + assert 0 <= ge <= type_mask[self.family], ( + "assertion %d <= ge (%d) <= %d failed" % (0, ge, type_mask[self.family]) + ) if le is not None and ge is not None: - assert le >= ge, "assertion le (%d) >= ge (%d) failed! le must be lower than ge" % (le, ge) + assert le >= ge, ( + "assertion le (%d) >= ge (%d) failed! le must be lower than ge" + % (le, ge) + ) self.le = le self.ge = ge @@ -211,9 +230,12 @@ def Entry(self): def zebra_family(self): raise NotImplementedError - def __init__(self, family, entries: Sequence[Union['ZebraList.Entry', - str, IPv4Network, - IPv6Network]] = (), name=None): + def __init__( + self, + family, + entries: Sequence[Union["ZebraList.Entry", str, IPv4Network, IPv6Network]] = (), + name=None, + ): """Setup a new zebra-list. :param name: The name of the acl, which will default to acl## where ## @@ -223,22 +245,30 @@ def __init__(self, family, entries: Sequence[Union['ZebraList.Entry', are composing the list """ - assert family in {'ipv4', 'ipv6'}, "PrefixList unknown %s type. type must be either ipv4 or ipv6" % family + assert family in {"ipv4", "ipv6"}, ( + "PrefixList unknown %s type. type must be either ipv4 or ipv6" % family + ) ZebraList.count += 1 - self.name = name if name else '%s%d' % (self.prefix_name, ZebraList.count) + self.name = name or "%s%d" % (self.prefix_name, ZebraList.count) self.entries = [] for e in entries: if isinstance(e, self.Entry): assert e.family == family, "The prefix entry must be of the same type" self.entries.append(e) - elif isinstance(e, str) and e == 'any': + elif isinstance(e, str) and e == "any": self.entries.append(self.Entry(prefix=e, family=family)) - elif isinstance(e, IPv4Network) or isinstance(e, IPv6Network) or isinstance(e, str): + elif ( + isinstance(e, IPv4Network) + or isinstance(e, IPv6Network) + or isinstance(e, str) + ): self.entries.append(self.Entry(prefix=e)) else: - raise ValueError('"%s" is not a valid prefix entry for the %s family' % (e, family)) + raise ValueError( + '"%s" is not a valid prefix entry for the %s family' % (e, family) + ) self.family = family @@ -249,13 +279,13 @@ def __eq__(self, other): class PrefixList(ZebraList): @property def zebra_family(self): - if self.family == 'ipv4': - return 'ip' - return 'ipv6' + if self.family == "ipv4": + return "ip" + return "ipv6" @property def prefix_name(self): - return 'pfxl' + return "pfxl" @property def Entry(self): @@ -268,13 +298,13 @@ class AccessList(ZebraList): @property def zebra_family(self): - if self.family == 'ipv4': - return '' - return 'ipv6 ' + if self.family == "ipv4": + return "" + return "ipv6 " @property def prefix_name(self): - return 'acl' + return "acl" @property def Entry(self): @@ -286,7 +316,7 @@ class RouteMapMatchCond: A class representing a RouteMap matching condition """ - def __init__(self, cond_type: str, condition, family: Optional[str] = None): + def __init__(self, cond_type: str, condition, family: str | None = None): """ :param condition: Can be an ip address, the id of an access or prefix list @@ -296,26 +326,30 @@ def __init__(self, cond_type: str, condition, family: Optional[str] = None): specify the family of the list (either ipv4 or ipv6) """ if family: - assert family in {'ipv4', 'ipv6', 'community'}, "Unrecognized family type (%s)" % family + assert family in {"ipv4", "ipv6", "community"}, ( + "Unrecognized family type (%s)" % family + ) self.condition = condition self.cond_type = cond_type self.family = family @property def zebra_family(self): - if self.family == 'ipv4': - return 'ip' - elif self.family == 'ipv6': - return 'ipv6' - elif self.family == 'community': - return 'community' + if self.family == "ipv4": + return "ip" + if self.family == "ipv6": + return "ipv6" + if self.family == "community": + return "community" - raise ValueError('Unsupported family; %s' % self.family) + raise ValueError("Unsupported family; %s" % self.family) def __eq__(self, other): - return self.condition == other.condition \ - and self.cond_type == other.cond_type \ - and self.family == other.family + return ( + self.condition == other.condition + and self.cond_type == other.cond_type + and self.family == other.family + ) class RouteMapSetAction: @@ -332,16 +366,19 @@ def __init__(self, action_type: str, value): self.value = value def __eq__(self, other): - return self.action_type == other.action_type \ - and self.value == other.value + return self.action_type == other.action_type and self.value == other.value class RouteMapEntry: - def __init__(self, family: str, match_policy=PERMIT, - match_cond: Sequence[Union[RouteMapMatchCond, Tuple]] = (), - set_actions: Sequence[Union[RouteMapSetAction, Tuple]] = (), - call_action: Optional[str] = None, - exit_policy: Optional[str] = None): + def __init__( + self, + family: str, + match_policy=PERMIT, + match_cond: Sequence[RouteMapMatchCond | tuple] = (), + set_actions: Sequence[RouteMapSetAction | tuple] = (), + call_action: str | None = None, + exit_policy: str | None = None, + ): """ :param match_policy: Deny or permit the actions if the route match the condition @@ -355,19 +392,23 @@ def __init__(self, family: str, match_policy=PERMIT, acl, ...]) tuples that will compose the route map """ - assert family in {'ipv4', 'ipv6', 'community'}, "Unrecognized family" + assert family in {"ipv4", "ipv6", "community"}, "Unrecognized family" self.family = family self.match_policy = match_policy - self.match_cond = [e if isinstance(e, RouteMapMatchCond) - else RouteMapMatchCond(cond_type=e[0], - condition=e[1], - family=family) - for e in match_cond] - self.set_actions = [e if isinstance(e, RouteMapSetAction) - else RouteMapSetAction(action_type=e[0], value=e[1]) - for e in set_actions] + self.match_cond = [ + e + if isinstance(e, RouteMapMatchCond) + else RouteMapMatchCond(cond_type=e[0], condition=e[1], family=family) + for e in match_cond + ] + self.set_actions = [ + e + if isinstance(e, RouteMapSetAction) + else RouteMapSetAction(action_type=e[0], value=e[1]) + for e in set_actions + ] self.call_action = call_action self.exit_policy = exit_policy @@ -390,7 +431,7 @@ def append_set_action(self, set_actions): if set_action not in self.set_actions: self.set_actions.append(set_action) - def update(self, rm_entry: 'RouteMapEntry'): + def update(self, rm_entry: "RouteMapEntry"): if not self.can_merge(rm_entry): raise ValueError("Attempting to merge incompatible RouteMap Entries") @@ -398,10 +439,12 @@ def update(self, rm_entry: 'RouteMapEntry'): self.append_match_cond(rm_entry.match_cond) def can_merge(self, rm_entry): - return self.family == rm_entry.family \ - and self.match_policy == rm_entry.match_policy \ - and self.call_action == rm_entry.call_action \ - and self.exit_policy == rm_entry.exit_policy + return ( + self.family == rm_entry.family + and self.match_policy == rm_entry.match_policy + and self.call_action == rm_entry.call_action + and self.exit_policy == rm_entry.exit_policy + ) class RouteMap: @@ -411,9 +454,14 @@ class RouteMap: count = 0 DEFAULT_POLICY = 65535 - def __init__(self, family: str, name: Optional[str] = None, - proto: Set[str] = (), neighbor: Optional[str] = None, - direction: str = 'in'): + def __init__( + self, + family: str, + name: str | None = None, + proto: set[str] = (), + neighbor: str | None = None, + direction: str = "in", + ): """ :param name: The name of the route-map, defaulting to rm## :param proto: The set of protocols to which this route-map applies @@ -422,9 +470,9 @@ def __init__(self, family: str, name: Optional[str] = None, """ RouteMap.count += 1 - assert family in {'ipv4', 'ipv6', 'community'}, "Unrecognized family" + assert family in {"ipv4", "ipv6", "community"}, "Unrecognized family" - self.name = name if name else 'rm%d' % RouteMap.count + self.name = name or "rm%d" % RouteMap.count self.entries = dict() # type: Dict[int, 'RouteMapEntry'] @@ -443,12 +491,14 @@ def __init__(self, family: str, name: Optional[str] = None, def _inc_order(self): self._hi_order += 10 - assert self._hi_order < self.DEFAULT_POLICY, "Maximum route-map order exceeded (> %d)" % self.DEFAULT_POLICY + assert self._hi_order < self.DEFAULT_POLICY, ( + "Maximum route-map order exceeded (> %d)" % self.DEFAULT_POLICY + ) def default_policy_set(self): return self._default_policy_set - def entry(self, rm_entry: 'RouteMapEntry', order: Optional[int] = None): + def entry(self, rm_entry: "RouteMapEntry", order: int | None = None): if order is None: self._inc_order() order = self._hi_order @@ -470,14 +520,14 @@ def remove_default_policy(self): if self.DEFAULT_POLICY in self.entries: self.entries.pop(self.DEFAULT_POLICY, None) - def update(self, rm: 'RouteMap'): + def update(self, rm: "RouteMap"): if self != rm: raise ValueError("Attempting to update incompatible RouteMaps") for order in rm.entries.keys(): self.entry(rm.entries[order], order) - def find_entry_by_match_condition(self, condition: Sequence['RouteMapMatchCond']): + def find_entry_by_match_condition(self, condition: Sequence["RouteMapMatchCond"]): for entry in self.entries: if self.entries[entry].match_cond == condition: return self.entries[entry] @@ -486,14 +536,16 @@ def __len__(self): return len(self.entries) def __eq__(self, other): - return self.name == other.name \ - and self.direction == other.direction \ - and self.family == other.family \ - and self.proto == other.proto \ - and self.neighbor == other.neighbor + return ( + self.name == other.name + and self.direction == other.direction + and self.family == other.family + and self.proto == other.proto + and self.neighbor == other.neighbor + ) @property def describe(self): """Return the zebra description of this route map and apply it to the relevant protocols""" - return 'route-map' + return "route-map" diff --git a/ipmininet/srv6.py b/ipmininet/srv6.py index e05834ff..173b9209 100644 --- a/ipmininet/srv6.py +++ b/ipmininet/srv6.py @@ -1,11 +1,17 @@ """This modules defines classes to create IPv6 Segment Routing (SRv6) Routes - For more information about SRv6, see https://segment-routing.org""" +For more information about SRv6, see https://segment-routing.org""" + import abc import shlex import subprocess -from ipaddress import IPv6Address, AddressValueError, NetmaskValueError, \ - IPv4Address, IPv6Network -from typing import List, Union, Iterable, Optional +from collections.abc import Iterable +from ipaddress import ( + AddressValueError, + IPv4Address, + IPv6Address, + IPv6Network, + NetmaskValueError, +) from mininet.log import lg as log @@ -30,15 +36,15 @@ def check_srv6_compatibility() -> bool: :return: True if the distribution supports SRv6 """ try: - subprocess.check_output( - shlex.split("sysctl net.ipv6.conf.all.seg6_enabled")) + subprocess.check_output(shlex.split("sysctl net.ipv6.conf.all.seg6_enabled")) return True except subprocess.CalledProcessError: return False -def srv6_segment_space(node: Optional[Union[str, IPNode]] = None, - intf: Union[str, IPIntf] = "lo") -> List[IPv6Network]: +def srv6_segment_space( + node: str | IPNode | None = None, intf: str | IPIntf = "lo" +) -> list[IPv6Network]: """ :param node: The IPNode object representing the node :param intf: Either the interface name (in which case the node parameter has @@ -47,8 +53,10 @@ def srv6_segment_space(node: Optional[Union[str, IPNode]] = None, """ if isinstance(intf, str): if not isinstance(node, IPNode): - raise ValueError("Cannot retrieve an IPIntf from name without " - "passing its node as parameter") + raise ValueError( + "Cannot retrieve an IPIntf from name without " + "passing its node as parameter" + ) intf = node.intf(intf) return [ip.network for ip in intf.ip6s(exclude_lls=True, exclude_lbs=True)] @@ -57,9 +65,11 @@ def srv6_segment_space(node: Optional[Union[str, IPNode]] = None, class LocalSIDTable: """A class representing a LocalSID routing table""" - def __init__(self, node: IPNode, - matching: Iterable[Union[str, IPv6Network, IPNode, IPIntf]] - = ("::/0",)): + def __init__( + self, + node: IPNode, + matching: Iterable[str | IPv6Network | IPNode | IPIntf] = ("::/0",), + ): """ :param node: The node on which the table is added :param matching: The list of destinations @@ -80,9 +90,10 @@ def __init__(self, node: IPNode, elif isinstance(destination, IPIntf): self.prefixes.extend(srv6_segment_space(intf=destination)) else: - raise ValueError("The LocalSIDTable cannot be created because " - "the destination {} cannot be matched" - .format(destination)) + raise ValueError( + "The LocalSIDTable cannot be created because " + f"the destination {destination} cannot be matched" + ) # Find Free table number tables = [] @@ -106,34 +117,38 @@ def __init__(self, node: IPNode, def create(self): self.clean() for prefix in self.prefixes: - cmd = "ip -6 rule add to {prefix} table {num}"\ - .format(prefix=prefix, num=self.num) + cmd = f"ip -6 rule add to {prefix} table {self.num}" out, err, exitcode = self.node.pexec(shlex.split(cmd)) if exitcode != 0: - log.error("Cannot install rule for new LocalSIDTable:\n" - "{cmd}\nstdout:{out}\nstderr:{err}\n" - .format(cmd=cmd, out=out, err=err)) - cmd = "ip -6 route add blackhole default table {num}"\ - .format(num=self.num) + log.error( + "Cannot install rule for new LocalSIDTable:\n" + f"{cmd}\nstdout:{out}\nstderr:{err}\n" + ) + cmd = f"ip -6 route add blackhole default table {self.num}" out, err, exitcode = self.node.pexec(cmd) if exitcode != 0: - log.error("Cannot install blackhole rule for new LocalSIDTable:\n" - "{cmd}\nstdout:{out}\nstderr:{err}\n" - .format(cmd=cmd, out=out, err=err)) + log.error( + "Cannot install blackhole rule for new LocalSIDTable:\n" + f"{cmd}\nstdout:{out}\nstderr:{err}\n" + ) def clean(self): - self.node.cmd("ip -6 route flush table {num}".format(num=self.num)) + self.node.cmd(f"ip -6 route flush table {self.num}") for prefix in self.prefixes: - self.node.cmd(shlex.split("ip rule del to {prefix} table {num}" - .format(prefix=prefix, num=self.num))) + self.node.cmd(shlex.split(f"ip rule del to {prefix} table {self.num}")) class SRv6Route(metaclass=abc.ABCMeta): """The SRv6Route abstract class, which enables to create an SRv6 route""" - def __init__(self, net: IPNet, node: Union[IPNode, str], - to: Union[str, IPv6Network, IPNode, IPIntf] = "::/0", cost=1, - table: Optional[LocalSIDTable] = None): + def __init__( + self, + net: IPNet, + node: IPNode | str, + to: str | IPv6Network | IPNode | IPIntf = "::/0", + cost=1, + table: LocalSIDTable | None = None, + ): """ :param net: The IPNet instance :param node: The IPNode object on which the route has to be installed @@ -152,15 +167,17 @@ def __init__(self, net: IPNet, node: Union[IPNode, str], self.source = node if not isinstance(node, str) else net[node] itfs = realIntfList(self.source) if len(itfs) == 0: - raise ValueError("Cannot install SRv6Route %s without" - " a real interface on node %s\n" - % (self, self.source.name)) + raise ValueError( + "Cannot install SRv6Route %s without" + " a real interface on node %s\n" % (self, self.source.name) + ) self.dev = itfs[0].name # Check SRv6 capability if not self.is_available(): - raise ValueError("Cannot create %s because" - " the distribution does not support it" % self) + raise ValueError( + "Cannot create %s because the distribution does not support it" % self + ) # Activate SRv6 on all routers and hosts for n in net.routers + net.hosts: @@ -174,7 +191,7 @@ def is_available(self) -> bool: """Check the compatibility with this encapsulation method""" return check_srv6_compatibility() - def dest_prefixes(self) -> List[str]: + def dest_prefixes(self) -> list[str]: prefixes = [] try: IPv6Network(str(self.destination)) @@ -192,20 +209,22 @@ def dest_prefixes(self) -> List[str]: for ip6 in itf.ip6s(exclude_lls=True, exclude_lbs=True): prefixes.append(ip6.network.with_prefixlen) elif isinstance(self.destination, IPIntf): - for ip6 in self.destination.ip6s(exclude_lls=True, - exclude_lbs=True): + for ip6 in self.destination.ip6s(exclude_lls=True, exclude_lbs=True): prefixes.append(ip6.network.with_prefixlen) if len(prefixes) == 0: - log.error("Cannot install SRv6Route", self, - "because the destination", self.destination, - "does not have a global IPv6 address\n") + log.error( + "Cannot install SRv6Route", + self, + "because the destination", + self.destination, + "does not have a global IPv6 address\n", + ) return prefixes - def nexthops_to_ips(self, - nexthops: List[Union[str, IPNode, IPIntf, IPv6Address, - IPv4Address]], - v6=True) -> List[str]: + def nexthops_to_ips( + self, nexthops: list[str | IPNode | IPIntf | IPv6Address | IPv4Address], v6=True + ) -> list[str]: """ :param nexthops: Each element of the list can either be an IP or IPv6 address, an IPIntf, an IPNode or the name of an IPNode. @@ -232,14 +251,15 @@ def nexthops_to_ips(self, elif isinstance(nh, IPIntf): ip = nh.ip6 if v6 else nh.ip if ip is None: - raise ValueError("Cannot find for the nexthop %s" - " a global IPv%d address\n" - % (nh, 6 if v6 else 4)) + raise ValueError( + "Cannot find for the nexthop %s" + " a global IPv%d address\n" % (nh, 6 if v6 else 4) + ) s.append(ip) return s @abc.abstractmethod - def build_commands(self) -> List[str]: + def build_commands(self) -> list[str]: return [] def install(self): @@ -252,20 +272,31 @@ def _run_cmds(self, prefix: str = "ip -6 route add ") -> int: for cmd in self.cmds: cmd = prefix + cmd if self.table is not None: - cmd = cmd + " table {num}".format(num=self.table.num) + cmd = cmd + f" table {self.table.num}" out, err, code = self.source.pexec(shlex.split(cmd)) - log.debug("Installing route on router %s: '%s'\n" - % (self.source.name, cmd)) + log.debug("Installing route on router %s: '%s'\n" % (self.source.name, cmd)) if code: - log.error('Cannot install SRv6Route', self, '[rcode:', - str(code), ']:\n', cmd, '\nstdout:', str(out), - '\nstderr:', str(err)) + log.error( + "Cannot install SRv6Route", + self, + "[rcode:", + str(code), + "]:\n", + cmd, + "\nstdout:", + str(out), + "\nstderr:", + str(err), + ) return code return -1 def __str__(self): - return "SRv6Route" \ - % (self.source.name, self.destination, self.cost) + return "SRv6Route" % ( + self.source.name, + self.destination, + self.cost, + ) class SRv6Encap(SRv6Route): @@ -275,13 +306,19 @@ class SRv6Encap(SRv6Route): The instantiation of these tunnels should happen *after* the network has been built and its addresses has been allocated. You can leverage the IPTopo.post_build method to do it.""" + ENCAP = "encap" INLINE = "inline" - def __init__(self, net: IPNet, node: Union[IPNode, str], - to: Union[str, IPv6Network, IPNode, IPIntf] = "::/0", - through: List[Union[str, IPv6Address, IPNode, IPIntf]] = (), - mode=ENCAP, cost=1): + def __init__( + self, + net: IPNet, + node: IPNode | str, + to: str | IPv6Network | IPNode | IPIntf = "::/0", + through: list[str | IPv6Address | IPNode | IPIntf] = (), + mode=ENCAP, + cost=1, + ): """ :param net: The IPNet instance :param node: The IPNode object on which the route has to be installed @@ -300,18 +337,21 @@ def __init__(self, net: IPNet, node: Union[IPNode, str], preferred if the destination prefix is the same. """ if len(through) == 0: - raise ValueError("It does not make sense to use Segment Routing" - " without any redirection.") + raise ValueError( + "It does not make sense to use Segment Routing without any redirection." + ) self.nexthops = list(through) self.mode = mode super().__init__(net, node, to=to, cost=cost) def is_available(self) -> bool: """Check the compatibility with this encapsulation method""" - return super().is_available() \ + return ( + super().is_available() and subprocess.check_call(shlex.split("ip sr tunsrc set ::")) == 0 + ) - def build_commands(self) -> List[str]: + def build_commands(self) -> list[str]: cmds = [] # type: List[str] # Get destination addresses @@ -326,19 +366,25 @@ def build_commands(self) -> List[str]: # Build iproute2 commands for prefix in prefixes: - cmds.append("%s encap seg6 mode %s segs %s metric %s dev %s" - % (prefix, self.mode, ",".join(nexthops), self.cost, - self.dev)) + cmds.append( + "%s encap seg6 mode %s segs %s metric %s dev %s" + % (prefix, self.mode, ",".join(nexthops), self.cost, self.dev) + ) return cmds def __str__(self): - return "SRv6Encap" \ - % (self.source.name, self.destination, self.nexthops, - self.mode, self.cost) + return "SRv6Encap" % ( + self.source.name, + self.destination, + self.nexthops, + self.mode, + self.cost, + ) class SRv6EndFunction(SRv6Route): """This class represents an SRv6 End function""" + ACTION = "End" @property @@ -347,14 +393,17 @@ def params(self) -> str: def is_available(self) -> bool: """Check the compatibility with this advanced SRv6 routes""" - cmd = "{seg} encap seg6local action End dev {dev}".format(seg="::2/128", - dev=self.dev) + cmd = "{seg} encap seg6local action End dev {dev}".format( + seg="::2/128", dev=self.dev + ) src = self.source - return super().is_available() \ - and src.pexec(shlex.split("ip -6 route add " + cmd))[2] == 0 \ + return ( + super().is_available() + and src.pexec(shlex.split("ip -6 route add " + cmd))[2] == 0 and src.pexec(shlex.split("ip -6 route del " + cmd))[2] == 0 + ) - def build_commands(self) -> List[str]: + def build_commands(self) -> list[str]: cmds = [] # type: List[str] # Get destination addresses @@ -364,25 +413,28 @@ def build_commands(self) -> List[str]: # Build iproute2 commands for prefix in prefixes: - cmds.append("{segment} encap seg6local action {action} {params}" - " metric {metric} dev {dev}" - .format(segment=prefix, action=self.ACTION, - params=self.params, metric=self.cost, - dev=self.dev)) + cmds.append( + f"{prefix} encap seg6local action {self.ACTION} {self.params}" + f" metric {self.cost} dev {self.dev}" + ) return cmds def __str__(self): - return "SRv6Function" \ - % (self.ACTION, self.source.name, self.destination, self.cost, - self.params) + return "SRv6Function" % ( + self.ACTION, + self.source.name, + self.destination, + self.cost, + self.params, + ) class SRv6EndXFunction(SRv6EndFunction): """This class represents an SRv6 End.X function""" + ACTION = "End.X" - def __init__(self, nexthop: Union[str, IPv6Address, IPIntf, IPNode], *args, - **kwargs): + def __init__(self, nexthop: str | IPv6Address | IPIntf | IPNode, *args, **kwargs): """ :param net: The IPNet instance :param node: The IPNode object on which the route has to be installed @@ -407,6 +459,7 @@ def params(self) -> str: class SRv6EndTFunction(SRv6EndFunction): """This class represents an SRv6 End.T function""" + ACTION = "End.T" def __init__(self, lookup_table: str, *args, **kwargs): @@ -427,14 +480,15 @@ def __init__(self, lookup_table: str, *args, **kwargs): @property def params(self) -> str: - return "table {}".format(self.lookup_table) + return f"table {self.lookup_table}" class SRv6EndDX2Function(SRv6EndFunction): """This class represents an SRv6 End.DX2 function""" + ACTION = "End.DX2" - def __init__(self, interface: Union[str, IPIntf], *args, **kwargs): + def __init__(self, interface: str | IPIntf, *args, **kwargs): """ :param net: The IPNet instance :param node: The IPNode object on which the route has to be installed @@ -446,8 +500,9 @@ def __init__(self, interface: Union[str, IPIntf], *args, **kwargs): the main one. :param interface: The packet is forwarded to this specific interface """ - self.interface = interface.name if isinstance(interface, IPIntf) \ - else str(interface) + self.interface = ( + interface.name if isinstance(interface, IPIntf) else str(interface) + ) super().__init__(*args, **kwargs) @property @@ -457,15 +512,16 @@ def params(self) -> str: class SRv6EndDX6Function(SRv6EndXFunction): """This class represents an SRv6 End.DX6 function""" + ACTION = "End.DX6" class SRv6EndDX4Function(SRv6EndFunction): """This class represents an SRv6 End.DX4 function""" + ACTION = "End.DX4" - def __init__(self, nexthop: Union[str, IPv4Address, IPIntf, IPNode], - *args, **kwargs): + def __init__(self, nexthop: str | IPv4Address | IPIntf | IPNode, *args, **kwargs): """ :param net: The IPNet instance :param node: The IPNode object on which the route has to be installed @@ -490,15 +546,18 @@ def params(self) -> str: class SRv6EndDT6Function(SRv6EndTFunction): """This class represents an SRv6 End.DT6 function""" + ACTION = "End.DT6" class SRv6EndB6Function(SRv6EndFunction): """This class represents an SRv6 End.B6 function""" + ACTION = "End.B6" - def __init__(self, segments: List[Union[str, IPv6Address, IPIntf, IPNode]], - *args, **kwargs): + def __init__( + self, segments: list[str | IPv6Address | IPIntf | IPNode], *args, **kwargs + ): """ :param net: The IPNet instance :param node: The IPNode object on which the route has to be installed @@ -514,8 +573,9 @@ def __init__(self, segments: List[Union[str, IPv6Address, IPIntf, IPNode]], cases, the default IPv6 address is selected. """ if len(segments) == 0: - raise ValueError("It does not make sense to use Segment Routing" - " without any segment.") + raise ValueError( + "It does not make sense to use Segment Routing without any segment." + ) self.segments = self.nexthops_to_ips(segments) super().__init__(*args, **kwargs) @@ -526,4 +586,5 @@ def params(self) -> str: class SRv6EndB6EncapsFunction(SRv6EndB6Function): """This class represents an SRv6 End.B6.Encaps function""" + ACTION = "End.B6.Encaps" diff --git a/ipmininet/tests/__init__.py b/ipmininet/tests/__init__.py index 552731c6..1acfd77a 100644 --- a/ipmininet/tests/__init__.py +++ b/ipmininet/tests/__init__.py @@ -11,6 +11,7 @@ reasons are surfaced in CI via `pytest -rs`; these files are intentionally not --ignore'd at the workflow level so the skip reason stays next to the test. """ + import os import subprocess @@ -19,7 +20,8 @@ from ipmininet.utils import has_cmd require_root = pytest.mark.skipif( - os.getuid() != 0, reason='Running this test requires to be root') + os.getuid() != 0, reason="Running this test requires to be root" +) def _exabgp_usable() -> bool: @@ -29,24 +31,33 @@ def _exabgp_usable() -> bool: ``six.moves``), so simply being on PATH is not enough: the daemon must also start (--version) cleanly. """ - if not has_cmd('exabgp'): + if not has_cmd("exabgp"): return False try: - return subprocess.run(['exabgp', '--version'], stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=10).returncode == 0 + return ( + subprocess.run( + ["exabgp", "--version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10, + ).returncode + == 0 + ) except (OSError, subprocess.SubprocessError): return False require_openr = pytest.mark.skipif( - not has_cmd('openr'), - reason='OpenR daemon not available (needs build, not run in CI)') + not has_cmd("openr"), + reason="OpenR daemon not available (needs build, not run in CI)", +) require_exabgp = pytest.mark.skipif( - not _exabgp_usable(), - reason='ExaBGP daemon not available (broken vendored six on Py3.12)') + not _exabgp_usable(), + reason="ExaBGP daemon not available (broken vendored six on Py3.12)", +) require_mimidump = pytest.mark.skipif( - not has_cmd('mimidump'), - reason='interface captures rely on mimidump which is not shipped') + not has_cmd("mimidump"), + reason="interface captures rely on mimidump which is not shipped", +) diff --git a/ipmininet/tests/test_address_alllocation.py b/ipmininet/tests/test_address_alllocation.py index c212d265..4d6592e3 100644 --- a/ipmininet/tests/test_address_alllocation.py +++ b/ipmininet/tests/test_address_alllocation.py @@ -9,13 +9,16 @@ @require_root -@pytest.mark.parametrize("topo,use_v4,use_v6", [ - (SimpleBGPTopo, True, True), - (SimpleBGPTopo, True, False), - (SimpleBGPTopo, False, True), - (SimpleOSPFNet, True, True), # Routers with use_v6=False - (SimpleOSPFv3Net, True, True), # Routers with use_v4=False -]) +@pytest.mark.parametrize( + "topo,use_v4,use_v6", + [ + (SimpleBGPTopo, True, True), + (SimpleBGPTopo, True, False), + (SimpleBGPTopo, False, True), + (SimpleOSPFNet, True, True), # Routers with use_v6=False + (SimpleOSPFv3Net, True, True), # Routers with use_v4=False + ], +) def test_v4_and_v6_only_network(topo, use_v4, use_v6): try: net = IPNet(topo=topo(), use_v4=use_v4, use_v6=use_v6) @@ -24,22 +27,21 @@ def test_v4_and_v6_only_network(topo, use_v4, use_v6): for n in net.routers + net.hosts: for itf in n.intfList(): if itf.node.use_v4 and net.use_v4: - assert len(list(itf.ips())) == itf.interface_width[0], \ - "Did not allocate enough IPv4 addresses on interface " \ - "{}".format(itf) + assert len(list(itf.ips())) == itf.interface_width[0], ( + f"Did not allocate enough IPv4 addresses on interface {itf}" + ) else: - assert len(list(itf.ips())) == 0,\ - "Should not allocate IPv4 addresses on interface " \ - "{}".format(itf) + assert len(list(itf.ips())) == 0, ( + f"Should not allocate IPv4 addresses on interface {itf}" + ) if itf.node.use_v6 and net.use_v6: - assert len(list(itf.ip6s(exclude_lls=True))) == \ - itf.interface_width[1], \ - "Did not allocate enough IPv6 addresses on interface " \ - "{}".format(itf) + assert ( + len(list(itf.ip6s(exclude_lls=True))) == itf.interface_width[1] + ), f"Did not allocate enough IPv6 addresses on interface {itf}" else: - assert len(list(itf.ip6s(exclude_lls=True))) == 0, \ - "Should not allocate IPv6 addresses on interface " \ - "{}".format(itf) + assert len(list(itf.ip6s(exclude_lls=True))) == 0, ( + f"Should not allocate IPv6 addresses on interface {itf}" + ) net.stop() finally: cleanup() diff --git a/ipmininet/tests/test_bgp.py b/ipmininet/tests/test_bgp.py index e4bd2f8a..48bda5b6 100644 --- a/ipmininet/tests/test_bgp.py +++ b/ipmininet/tests/test_bgp.py @@ -3,72 +3,93 @@ import pytest from ipmininet.clean import cleanup -from ipmininet.examples.simple_bgp_network import SimpleBGPTopo +from ipmininet.examples.bgp_full_config import BGPTopoFull from ipmininet.examples.bgp_local_pref import BGPTopoLocalPref from ipmininet.examples.bgp_med import BGPTopoMed -from ipmininet.examples.bgp_rr import BGPTopoRR -from ipmininet.examples.bgp_full_config import BGPTopoFull from ipmininet.examples.bgp_policies_1 import BGPPoliciesTopo1 from ipmininet.examples.bgp_policies_2 import BGPPoliciesTopo2 -from ipmininet.examples.bgp_policies_4 import BGPPoliciesTopo4 from ipmininet.examples.bgp_policies_3 import BGPPoliciesTopo3 +from ipmininet.examples.bgp_policies_4 import BGPPoliciesTopo4 from ipmininet.examples.bgp_policies_5 import BGPPoliciesTopo5 from ipmininet.examples.bgp_policies_adjust import BGPPoliciesAdjustTopo +from ipmininet.examples.bgp_rr import BGPTopoRR +from ipmininet.examples.simple_bgp_network import SimpleBGPTopo from ipmininet.ipnet import IPNet from ipmininet.iptopo import IPTopo -from ipmininet.router.config import BGP, bgp_peering, AS, iBGPFullMesh +from ipmininet.router.config import AS, BGP, bgp_peering, iBGPFullMesh from ipmininet.router.config.base import RouterConfig from ipmininet.router.config.bgp import AF_INET, AF_INET6, CLIENT_PROVIDER from ipmininet.tests.utils import assert_connectivity, assert_path + from . import require_root class BGPTopo(IPTopo): - def __init__(self, as2r1_params, *args, **kwargs): self.as2r1_params = as2r1_params super().__init__(*args, **kwargs) def build(self, *args, **kwargs): """ - +----------+ +--------+ - | | - AS1 | AS2 | AS3 - | | - | | - +-------+ eBGP | +-------+ iBGP +-------+ | eBGP +-------+ - | as1r1 +------------+ as2r1 +-------------+ as2r2 +------------+ as3r1 | - +-------+ | +-------+ +-------+ | +-------+ - | | - | | - | | - +------------+ +--------+ + +----------+ +--------+ + | | + AS1 | AS2 | AS3 + | | + | | + +-------+ eBGP | +-------+ iBGP +-------+ | eBGP +-------+ + | as1r1 +------------+ as2r1 +-------------+ as2r2 +------------+ as3r1 | + +-------+ | +-------+ +-------+ | +-------+ + | | + | | + | | + +------------+ +--------+ """ # Add all routers - as1r1 = self.addRouter('as1r1', config=RouterConfig) - as1r1.addDaemon(BGP, - address_families=[AF_INET(redistribute=["connected"]), - AF_INET6(redistribute=["connected"])]) - as2r1 = self.addRouter('as2r1', config=RouterConfig) + as1r1 = self.addRouter("as1r1", config=RouterConfig) + as1r1.addDaemon( + BGP, + address_families=[ + AF_INET(redistribute=["connected"]), + AF_INET6(redistribute=["connected"]), + ], + ) + as2r1 = self.addRouter("as2r1", config=RouterConfig) as2r1.addDaemon(BGP, **self.as2r1_params) - as2r2 = self.addRouter('as2r2', config=RouterConfig) - as2r2.addDaemon(BGP, - address_families=[AF_INET(redistribute=["connected"]), - AF_INET6(redistribute=["connected"])]) - as3r1 = self.addRouter('as3r1', config=RouterConfig) - as3r1.addDaemon(BGP, - address_families=[AF_INET(redistribute=["connected"]), - AF_INET6(redistribute=["connected"])]) - - self.addLink(as1r1, as2r1, - params1={"ip": ("10.1.1.1/24", "fd00:1:1::1/64")}, - params2={"ip": ("10.1.1.2/24", "fd00:1:1::2/64")}) - self.addLink(as2r1, as2r2, - params1={"ip": ("10.2.1.1/24", "fd00:2:1::1/64")}, - params2={"ip": ("10.2.1.2/24", "fd00:2:1::2/64")}) - self.addLink(as3r1, as2r2, - params1={"ip": ("10.3.1.1/24", "fd00:3:1::1/64")}, - params2={"ip": ("10.3.1.2/24", "fd00:3:1::2/64")}) + as2r2 = self.addRouter("as2r2", config=RouterConfig) + as2r2.addDaemon( + BGP, + address_families=[ + AF_INET(redistribute=["connected"]), + AF_INET6(redistribute=["connected"]), + ], + ) + as3r1 = self.addRouter("as3r1", config=RouterConfig) + as3r1.addDaemon( + BGP, + address_families=[ + AF_INET(redistribute=["connected"]), + AF_INET6(redistribute=["connected"]), + ], + ) + + self.addLink( + as1r1, + as2r1, + params1={"ip": ("10.1.1.1/24", "fd00:1:1::1/64")}, + params2={"ip": ("10.1.1.2/24", "fd00:1:1::2/64")}, + ) + self.addLink( + as2r1, + as2r2, + params1={"ip": ("10.2.1.1/24", "fd00:2:1::1/64")}, + params2={"ip": ("10.2.1.2/24", "fd00:2:1::2/64")}, + ) + self.addLink( + as3r1, + as2r2, + params1={"ip": ("10.3.1.1/24", "fd00:3:1::1/64")}, + params2={"ip": ("10.3.1.2/24", "fd00:3:1::2/64")}, + ) # Set AS-ownerships self.addOverlay(AS(1, (as1r1,))) @@ -79,12 +100,18 @@ def build(self, *args, **kwargs): bgp_peering(self, as3r1, as2r2) # Add test hosts - self.addLink(as1r1, self.addHost('h%s' % as1r1), - params1={"ip": ("10.1.0.1/24", "fd00:1::1/64")}, - params2={"ip": ("10.1.0.2/24", "fd00:1::2/64")}) - self.addLink(as3r1, self.addHost('h%s' % as3r1), - params1={"ip": ("10.3.0.1/24", "fd00:3::1/64")}, - params2={"ip": ("10.3.0.2/24", "fd00:3::2/64")}) + self.addLink( + as1r1, + self.addHost("h%s" % as1r1), + params1={"ip": ("10.1.0.1/24", "fd00:1::1/64")}, + params2={"ip": ("10.1.0.2/24", "fd00:1::2/64")}, + ) + self.addLink( + as3r1, + self.addHost("h%s" % as3r1), + params1={"ip": ("10.3.0.1/24", "fd00:3::1/64")}, + params2={"ip": ("10.3.0.2/24", "fd00:3::2/64")}, + ) super().build(*args, **kwargs) @@ -101,23 +128,39 @@ def test_bgp_example(): @require_root -@pytest.mark.parametrize("bgp_params,expected_cfg", [ - ({"address_families": [AF_INET(redistribute=["connected"]), - AF_INET6(redistribute=["connected"])]}, - ["router bgp 2", - "neighbor 10.1.1.1 remote-as 1", - "neighbor 10.2.1.2 remote-as 2", - "neighbor 10.1.1.1 ebgp-multihop", - "neighbor 10.1.1.1 activate", - "neighbor 10.2.1.2 activate", - "redistribute connected"]), - ({"address_families": [AF_INET(redistribute=["connected"], - networks=["10.0.0.0/24"]), - AF_INET6(redistribute=["connected"], - networks=["fd00:2001:180::/64"])]}, - ["network 10.0.0.0/24", - "network fd00:2001:180::/64"]), -]) +@pytest.mark.parametrize( + "bgp_params,expected_cfg", + [ + ( + { + "address_families": [ + AF_INET(redistribute=["connected"]), + AF_INET6(redistribute=["connected"]), + ] + }, + [ + "router bgp 2", + "neighbor 10.1.1.1 remote-as 1", + "neighbor 10.2.1.2 remote-as 2", + "neighbor 10.1.1.1 ebgp-multihop", + "neighbor 10.1.1.1 activate", + "neighbor 10.2.1.2 activate", + "redistribute connected", + ], + ), + ( + { + "address_families": [ + AF_INET(redistribute=["connected"], networks=["10.0.0.0/24"]), + AF_INET6( + redistribute=["connected"], networks=["fd00:2001:180::/64"] + ), + ] + }, + ["network 10.0.0.0/24", "network fd00:2001:180::/64"], + ), + ], +) def test_bgp_daemon_params(bgp_params, expected_cfg): try: net = IPNet(topo=BGPTopo(bgp_params), allocate_IPs=False) @@ -127,9 +170,10 @@ def test_bgp_daemon_params(bgp_params, expected_cfg): with open("/tmp/bgpd_as2r1.cfg") as fileobj: cfg = [line for line in (line.strip() for line in fileobj) if line] for line in expected_cfg: - assert line in cfg,\ - "Cannot find the line '%s' in the generated " \ + assert line in cfg, ( + "Cannot find the line '%s' in the generated " "configuration:\n%s" % (line, "".join(cfg)) + ) # Check reachability assert_connectivity(net, v6=False) @@ -140,12 +184,12 @@ def test_bgp_daemon_params(bgp_params, expected_cfg): local_pref_paths = [ - ['as1r1', 'as1r6', 'as4r1', 'as4h1'], - ['as1r2', 'as1r3', 'as1r6', 'as4r1', 'as4h1'], - ['as1r3', 'as1r6', 'as4r1', 'as4h1'], - ['as1r4', 'as1r5', 'as1r6', 'as4r1', 'as4h1'], - ['as1r5', 'as1r6', 'as4r1', 'as4h1'], - ['as1r6', 'as4r1', 'as4h1'] + ["as1r1", "as1r6", "as4r1", "as4h1"], + ["as1r2", "as1r3", "as1r6", "as4r1", "as4h1"], + ["as1r3", "as1r6", "as4r1", "as4h1"], + ["as1r4", "as1r5", "as1r6", "as4r1", "as4h1"], + ["as1r5", "as1r6", "as4r1", "as4h1"], + ["as1r6", "as4r1", "as4h1"], ] @@ -162,12 +206,12 @@ def test_bgp_local_pref(): med_paths = [ - ['as1r1', 'as1r6', 'as1r5', 'as4r2', 'as4h1'], - ['as1r2', 'as1r3', 'as1r6', 'as1r5', 'as4r2', 'as4h1'], - ['as1r3', 'as1r6', 'as1r5', 'as4r2', 'as4h1'], - ['as1r4', 'as1r5', 'as4r2', 'as4h1'], - ['as1r5', 'as4r2', 'as4h1'], - ['as1r6', 'as1r5', 'as4r2', 'as4h1'] + ["as1r1", "as1r6", "as1r5", "as4r2", "as4h1"], + ["as1r2", "as1r3", "as1r6", "as1r5", "as4r2", "as4h1"], + ["as1r3", "as1r6", "as1r5", "as4r2", "as4h1"], + ["as1r4", "as1r5", "as4r2", "as4h1"], + ["as1r5", "as4r2", "as4h1"], + ["as1r6", "as1r5", "as4r2", "as4h1"], ] @@ -184,12 +228,12 @@ def test_bgp_med(): rr_paths = [ - ['as1r1', 'as1r6', 'as5r1', 'as2r1', 'as2h1'], - ['as1r2', 'as1r3', 'as1r6', 'as5r1', 'as2r1', 'as2h1'], - ['as1r3', 'as1r6', 'as5r1', 'as2r1', 'as2h1'], - ['as1r4', 'as4r2', 'as4r1', 'as2r1', 'as2h1'], - ['as1r5', 'as4r1', 'as2r1', 'as2h1'], - ['as1r6', 'as5r1', 'as2r1', 'as2h1'] + ["as1r1", "as1r6", "as5r1", "as2r1", "as2h1"], + ["as1r2", "as1r3", "as1r6", "as5r1", "as2r1", "as2h1"], + ["as1r3", "as1r6", "as5r1", "as2r1", "as2h1"], + ["as1r4", "as4r2", "as4r1", "as2r1", "as2h1"], + ["as1r5", "as4r1", "as2r1", "as2h1"], + ["as1r6", "as5r1", "as2r1", "as2h1"], ] @@ -206,12 +250,12 @@ def test_bgp_rr(): full_paths = [ - ['as1r1', 'as1r6', 'as4r1', 'as4h1'], - ['as1r2', 'as1r3', 'as1r6', 'as4r1', 'as4h1'], - ['as1r3', 'as1r6', 'as4r1', 'as4h1'], - ['as1r4', 'as1r5', 'as1r6', 'as4r1', 'as4h1'], - ['as1r5', 'as1r6', 'as4r1', 'as4h1'], - ['as1r6', 'as4r1', 'as4h1'] + ["as1r1", "as1r6", "as4r1", "as4h1"], + ["as1r2", "as1r3", "as1r6", "as4r1", "as4h1"], + ["as1r3", "as1r6", "as4r1", "as4h1"], + ["as1r4", "as1r5", "as1r6", "as4r1", "as4h1"], + ["as1r5", "as1r6", "as4r1", "as4h1"], + ["as1r6", "as4r1", "as4h1"], ] @@ -229,47 +273,54 @@ def test_bgp_full_config(): policies_paths = { BGPPoliciesTopo1.__name__: [ - ['has1r1', 'as1r1', 'as5r1', 'has5r1'], - ['has1r1', 'as1r1', 'as2r1', 'has2r1'], - ['has1r1', 'as1r1', 'as2r1', 'as2r2', 'has2r2'], - ['has2r1', 'as2r1', 'as5r1', 'has5r1'], - ['has2r2', 'as2r2', 'as2r1', 'as5r1', 'has5r1'], - ['has2r2', 'as2r2', 'as2r1', 'as5r1', 'has5r1'], - ['has4r1', 'as4r1', 'as5r1', 'as1r1', 'has1r1'] + ["has1r1", "as1r1", "as5r1", "has5r1"], + ["has1r1", "as1r1", "as2r1", "has2r1"], + ["has1r1", "as1r1", "as2r1", "as2r2", "has2r2"], + ["has2r1", "as2r1", "as5r1", "has5r1"], + ["has2r2", "as2r2", "as2r1", "as5r1", "has5r1"], + ["has2r2", "as2r2", "as2r1", "as5r1", "has5r1"], + ["has4r1", "as4r1", "as5r1", "as1r1", "has1r1"], ], BGPPoliciesTopo2.__name__: [ - ['has1', 'as1', 'as2', 'has2'], - ['has4', 'as4', 'as3', 'has3'], - ['has2', 'as2', 'as3', 'has3'] + ["has1", "as1", "as2", "has2"], + ["has4", "as4", "as3", "has3"], + ["has2", "as2", "as3", "has3"], ], BGPPoliciesTopo3.__name__: [ - ['has2r', 'as2r', 'as1r', 'has1r'], - ['has2r', 'as2r', 'as3r', 'has3r'], - ['has1r', 'as1r', 'as4r', 'has4r'], - ['has3r', 'as3r', 'as4r', 'has4r'] + ["has2r", "as2r", "as1r", "has1r"], + ["has2r", "as2r", "as3r", "has3r"], + ["has1r", "as1r", "as4r", "has4r"], + ["has3r", "as3r", "as4r", "has4r"], ], BGPPoliciesTopo4.__name__: [ - ['has2r', 'as2r', 'as3r', 'has3r'], - ['has2r', 'as2r', 'as5r', 'has5r'], - ['has2r', 'as2r', 'as1r', 'has1r'], - ['has3r', 'as3r', 'as4r', 'has4r'] + ["has2r", "as2r", "as3r", "has3r"], + ["has2r", "as2r", "as5r", "has5r"], + ["has2r", "as2r", "as1r", "has1r"], + ["has3r", "as3r", "as4r", "has4r"], ], BGPPoliciesTopo5.__name__: [ - ['has1r', 'as1r', 'as4r', 'as6r', 'as7r', 'as5r', 'has5r'], - ['has5r', 'as5r', 'as2r', 'as1r', 'has1r'] + ["has1r", "as1r", "as4r", "as6r", "as7r", "as5r", "has5r"], + ["has5r", "as5r", "as2r", "as1r", "has1r"], ], BGPPoliciesAdjustTopo.__name__: [ - ['has4r', 'as4r', 'as1r', 'has1r'], - ['has5r', 'as5r', 'as3r', 'has3r'] + ["has4r", "as4r", "as1r", "has1r"], + ["has5r", "as5r", "as3r", "has3r"], ], } @require_root -@pytest.mark.parametrize("topology", [ - BGPPoliciesTopo1, BGPPoliciesTopo2, BGPPoliciesTopo3, - BGPPoliciesTopo4, BGPPoliciesTopo5, BGPPoliciesAdjustTopo -]) +@pytest.mark.parametrize( + "topology", + [ + BGPPoliciesTopo1, + BGPPoliciesTopo2, + BGPPoliciesTopo3, + BGPPoliciesTopo4, + BGPPoliciesTopo5, + BGPPoliciesAdjustTopo, + ], +) def test_bgp_policies(topology): try: net = IPNet(topo=topology()) @@ -286,8 +337,11 @@ def test_bgp_policies_adjust(): try: # Adding this new peering link should enable all hosts # to ping each others - net = IPNet(topo=BGPPoliciesAdjustTopo(as_start="as5r", as_end="as2r", - bgp_policy=CLIENT_PROVIDER)) + net = IPNet( + topo=BGPPoliciesAdjustTopo( + as_start="as5r", as_end="as2r", bgp_policy=CLIENT_PROVIDER + ) + ) net.start() assert_connectivity(net, v6=True) net.stop() diff --git a/ipmininet/tests/test_cli.py b/ipmininet/tests/test_cli.py index 897891b4..f66c9ff6 100644 --- a/ipmininet/tests/test_cli.py +++ b/ipmininet/tests/test_cli.py @@ -54,54 +54,90 @@ def perm(*args): @require_root -@pytest.mark.parametrize("input_line,expected_lines", [ - ("route 2001:1a::2", - [re.compile(r"\[r1\] 2001:1a::2.*dev +r1-eth0.*"), - re.compile(r"\[r2\] 2001:1a::2.*dev +r2-eth0.*")]), - ("ip 2001:1a::1/64 2001:1a::1 10.2.0.3 10.2.0.3/24 2001::3/64 invalid", - ["2001:1a::1/64 | r1 ", "2001:1a::1 | r1 ", - "10.2.0.3/24 | h4 ", "10.2.0.3 | h4 ", - "2001::3/64 | unknown IP ", "invalid | unknown IP "]), - ("ips h1 h4 invalid", - [re.compile(r"h1 \| \[u?'10\.0\.0\.2', u?'2001:1a::2'\] "), - re.compile(r"h4 \| \[u?'10\.2\.0\.3', u?'2001:12b::3'\] "), - "invalid | unknown node "]), - ("pingall", [re.compile(r"h1 --IPv4--> %s" % perm("h2 ", "h3 ", "h4 ")), - re.compile(r"h1 --IPv6--> %s" % perm("h2 ", "h3 ", "h4 ")), - re.compile(r"h2 --IPv4--> %s" % perm("h1 ", "h3 ", "h4 ")), - re.compile(r"h2 --IPv6--> %s" % perm("h1 ", "h3 ", "h4 ")), - re.compile(r"h3 --IPv4--> %s" % perm("h1 ", "h2 ", "h4 ")), - re.compile(r"h3 --IPv6--> %s" % perm("h1 ", "h2 ", "h4 ")), - re.compile(r"h4 --IPv4--> %s" % perm("h1 ", "h2 ", "h3 ")), - re.compile(r"h4 --IPv6--> %s" % perm("h1 ", "h2 ", "h3 "))]), - ("pingpair h1 h2", ["h1 --IPv4--> h2 ", - "h1 --IPv6--> h2 ", - "h2 --IPv4--> h1 ", - r"h2 --IPv6--> h1 "]), - ("ping4all", [re.compile(r"h1 --IPv4--> %s" % perm("h2 ", "h3 ", "h4 ")), - re.compile(r"h2 --IPv4--> %s" % perm("h1 ", "h3 ", "h4 ")), - re.compile(r"h3 --IPv4--> %s" % perm("h1 ", "h2 ", "h4 ")), - re.compile(r"h4 --IPv4--> %s" % perm("h1 ", "h2 ", "h3 "))]), - ("ping4pair h1 h2", ["h1 --IPv4--> h2 ", - "h2 --IPv4--> h1 "]), - ("ping6all", [re.compile(r"h1 --IPv6--> %s" % perm("h2 ", "h3 ", "h4 ")), - re.compile(r"h2 --IPv6--> %s" % perm("h1 ", "h3 ", "h4 ")), - re.compile(r"h3 --IPv6--> %s" % perm("h1 ", "h2 ", "h4 ")), - re.compile(r"h4 --IPv6--> %s" % perm("h1 ", "h2 ", "h3 "))]), - ("ping6pair", ["h1 --IPv6--> h2 ", - "h2 --IPv6--> h1 "]), - ("h1 echo h4", ["10.2.0.3"]), - ("s2 echo h4", ["h4"]), - ("h1", ["*** Enter a command for node: h1 "]), - ("invalid_command", ["*** Unknown command: invalid_command"]) -]) +@pytest.mark.parametrize( + "input_line,expected_lines", + [ + ( + "route 2001:1a::2", + [ + re.compile(r"\[r1\] 2001:1a::2.*dev +r1-eth0.*"), + re.compile(r"\[r2\] 2001:1a::2.*dev +r2-eth0.*"), + ], + ), + ( + "ip 2001:1a::1/64 2001:1a::1 10.2.0.3 10.2.0.3/24 2001::3/64 invalid", + [ + "2001:1a::1/64 | r1 ", + "2001:1a::1 | r1 ", + "10.2.0.3/24 | h4 ", + "10.2.0.3 | h4 ", + "2001::3/64 | unknown IP ", + "invalid | unknown IP ", + ], + ), + ( + "ips h1 h4 invalid", + [ + re.compile(r"h1 \| \[u?'10\.0\.0\.2', u?'2001:1a::2'\] "), + re.compile(r"h4 \| \[u?'10\.2\.0\.3', u?'2001:12b::3'\] "), + "invalid | unknown node ", + ], + ), + ( + "pingall", + [ + re.compile(r"h1 --IPv4--> %s" % perm("h2 ", "h3 ", "h4 ")), + re.compile(r"h1 --IPv6--> %s" % perm("h2 ", "h3 ", "h4 ")), + re.compile(r"h2 --IPv4--> %s" % perm("h1 ", "h3 ", "h4 ")), + re.compile(r"h2 --IPv6--> %s" % perm("h1 ", "h3 ", "h4 ")), + re.compile(r"h3 --IPv4--> %s" % perm("h1 ", "h2 ", "h4 ")), + re.compile(r"h3 --IPv6--> %s" % perm("h1 ", "h2 ", "h4 ")), + re.compile(r"h4 --IPv4--> %s" % perm("h1 ", "h2 ", "h3 ")), + re.compile(r"h4 --IPv6--> %s" % perm("h1 ", "h2 ", "h3 ")), + ], + ), + ( + "pingpair h1 h2", + [ + "h1 --IPv4--> h2 ", + "h1 --IPv6--> h2 ", + "h2 --IPv4--> h1 ", + r"h2 --IPv6--> h1 ", + ], + ), + ( + "ping4all", + [ + re.compile(r"h1 --IPv4--> %s" % perm("h2 ", "h3 ", "h4 ")), + re.compile(r"h2 --IPv4--> %s" % perm("h1 ", "h3 ", "h4 ")), + re.compile(r"h3 --IPv4--> %s" % perm("h1 ", "h2 ", "h4 ")), + re.compile(r"h4 --IPv4--> %s" % perm("h1 ", "h2 ", "h3 ")), + ], + ), + ("ping4pair h1 h2", ["h1 --IPv4--> h2 ", "h2 --IPv4--> h1 "]), + ( + "ping6all", + [ + re.compile(r"h1 --IPv6--> %s" % perm("h2 ", "h3 ", "h4 ")), + re.compile(r"h2 --IPv6--> %s" % perm("h1 ", "h3 ", "h4 ")), + re.compile(r"h3 --IPv6--> %s" % perm("h1 ", "h2 ", "h4 ")), + re.compile(r"h4 --IPv6--> %s" % perm("h1 ", "h2 ", "h3 ")), + ], + ), + ("ping6pair", ["h1 --IPv6--> h2 ", "h2 --IPv6--> h1 "]), + ("h1 echo h4", ["10.2.0.3"]), + ("s2 echo h4", ["h4"]), + ("h1", ["*** Enter a command for node: h1 "]), + ("invalid_command", ["*** Unknown command: invalid_command"]), + ], +) def test_cli(tmp, net, input_line, expected_lines): with open(tmp, "w") as fileobj: fileobj.write(input_line + "\n") with CLICapture("info") as capture: - f = open(tmp, "r") + f = open(tmp) try: IPCLI(net, stdin=f, script=tmp) finally: @@ -110,11 +146,12 @@ def test_cli(tmp, net, input_line, expected_lines): pattern = re.compile("") for line in expected_lines: if isinstance(line, type(pattern)): - assert len([x for x in capture.out - if line.match(x) is not None]) > 0, \ - "Regex '%s' does not match the output of '%s':\n%s" \ + assert len([x for x in capture.out if line.match(x) is not None]) > 0, ( + "Regex '%s' does not match the output of '%s':\n%s" % (line, input_line, "\n".join(capture.out)) + ) else: - assert line in capture.out, \ - "Line '%s' cannot be found in the output of '%s':\n%s" \ + assert line in capture.out, ( + "Line '%s' cannot be found in the output of '%s':\n%s" % (line, input_line, "\n".join(capture.out)) + ) diff --git a/ipmininet/tests/test_dns.py b/ipmininet/tests/test_dns.py index 964399e2..9484a05d 100644 --- a/ipmininet/tests/test_dns.py +++ b/ipmininet/tests/test_dns.py @@ -1,22 +1,23 @@ """This module tests the Named daemon""" + import pytest from ipmininet.clean import cleanup from ipmininet.examples.dns_network import DNSNetwork from ipmininet.examples.simple_bgp_network import SimpleBGPTopo from ipmininet.examples.static_routing import StaticRoutingNet -from ipmininet.host.config import Named, ARecord, AAAARecord, NSRecord, \ - PTRRecord +from ipmininet.host.config import AAAARecord, ARecord, Named, NSRecord, PTRRecord from ipmininet.ipnet import IPNet from ipmininet.tests.utils import assert_connectivity, assert_dns_record -from . import require_root + from ..examples.dns_advanced_network import DNSAdvancedNetwork +from . import require_root class CustomDNSNetwork(DNSNetwork): def __init__(self, named_cfg, zone_args, *args, **kwargs): """:param named_cfg: Parameters to set on the master dns daemon - :param zone_args: Parameters to set on the DNS Zone""" + :param zone_args: Parameters to set on the DNS Zone""" self.named_cfg = named_cfg self.zone_args = zone_args super().__init__(*args, **kwargs) @@ -24,7 +25,7 @@ def __init__(self, named_cfg, zone_args, *args, **kwargs): def build(self, *args, **kwargs): # Update master DNS parameters - master2 = self.addHost('master2') + master2 = self.addHost("master2") master2.addDaemon(Named, **self.named_cfg) # Add new parametrized DNS zone @@ -35,30 +36,41 @@ def build(self, *args, **kwargs): @require_root -@pytest.mark.parametrize("named_cfg,zone_args,exp_named_cfg,exp_zone_cfg", [ - ({}, {}, [], []), - ({"log_severity": "info"}, - {}, - [" severity info;"], - []), - ({"dns_server_port": 2000}, - {}, - [], - []), - ({}, - {"refresh_time": 20, "retry_time": 30, "expire_time": 40, "min_ttl": 50}, - [], - ["20 ; refresh timer", "30 ; retry timer", "40 ; retry timer", - "50 ; minimum ttl"]), - ({}, - {"records": [AAAARecord("new", "fc00::2", ttl=100), - ARecord("new", "192.0.0.1", ttl=300), - NSRecord("test.org", "new", ttl=10)]}, - [], - ["new 100\tIN\tAAAA\tfc00::2", - "new 300\tIN\tA\t192.0.0.1", - "test.org. 10\tIN\tNS\tnew.test.org."]), -]) +@pytest.mark.parametrize( + "named_cfg,zone_args,exp_named_cfg,exp_zone_cfg", + [ + ({}, {}, [], []), + ({"log_severity": "info"}, {}, [" severity info;"], []), + ({"dns_server_port": 2000}, {}, [], []), + ( + {}, + {"refresh_time": 20, "retry_time": 30, "expire_time": 40, "min_ttl": 50}, + [], + [ + "20 ; refresh timer", + "30 ; retry timer", + "40 ; retry timer", + "50 ; minimum ttl", + ], + ), + ( + {}, + { + "records": [ + AAAARecord("new", "fc00::2", ttl=100), + ARecord("new", "192.0.0.1", ttl=300), + NSRecord("test.org", "new", ttl=10), + ] + }, + [], + [ + "new 100\tIN\tAAAA\tfc00::2", + "new 300\tIN\tA\t192.0.0.1", + "test.org. 10\tIN\tNS\tnew.test.org.", + ], + ), + ], +) def test_dns_network(named_cfg, zone_args, exp_named_cfg, exp_zone_cfg): try: net = IPNet(topo=CustomDNSNetwork(named_cfg, zone_args)) @@ -68,22 +80,26 @@ def test_dns_network(named_cfg, zone_args, exp_named_cfg, exp_zone_cfg): with open("/tmp/named_master2.cfg") as fileobj: cfg = fileobj.readlines() for line in exp_named_cfg: - assert line + "\n" in cfg,\ - "Cannot find the line '%s' in the generated " \ + assert line + "\n" in cfg, ( + "Cannot find the line '%s' in the generated " "main configuration:\n%s" % (line, "".join(cfg)) + ) with open("/tmp/named_master2.test.org.zone.cfg") as fileobj: cfg = fileobj.readlines() for line in exp_zone_cfg: - assert line + "\n" in cfg,\ - "Cannot find the line '%s' in the generated zone " \ + assert line + "\n" in cfg, ( + "Cannot find the line '%s' in the generated zone " "configuration:\n%s" % (line, "".join(cfg)) + ) # Check port number configuration dns_server_port = named_cfg.get("dns_server_port", 53) - assert_dns_record(net["master2"], "localhost", - AAAARecord("master2.test.org", - net["master2"].defaultIntf().ip6), - port=dns_server_port) + assert_dns_record( + net["master2"], + "localhost", + AAAARecord("master2.test.org", net["master2"].defaultIntf().ip6), + port=dns_server_port, + ) # Check connectivity assert_connectivity(net, v6=False) @@ -98,15 +114,13 @@ def test_dns_network(named_cfg, zone_args, exp_named_cfg, exp_zone_cfg): ARecord("slave.mydomain.org", net["slave"].defaultIntf().ip), AAAARecord("slave.mydomain.org", net["slave"].defaultIntf().ip6), ARecord("server.mydomain.org", net["server"].defaultIntf().ip), - AAAARecord("server.mydomain.org", net["server"].defaultIntf().ip6, - ttl=120), + AAAARecord("server.mydomain.org", net["server"].defaultIntf().ip6, ttl=120), PTRRecord(net["master"].defaultIntf().ip, "master.mydomain.org"), PTRRecord(net["master"].defaultIntf().ip6, "master.mydomain.org"), PTRRecord(net["slave"].defaultIntf().ip, "slave.mydomain.org"), PTRRecord(net["slave"].defaultIntf().ip6, "slave.mydomain.org"), PTRRecord(net["server"].defaultIntf().ip, "server.mydomain.org"), - PTRRecord(net["server"].defaultIntf().ip6, "server.mydomain.org", - ttl=120) + PTRRecord(net["server"].defaultIntf().ip6, "server.mydomain.org", ttl=120), ] for node in [net["master"], net["slave"]]: for record in records: @@ -128,10 +142,11 @@ def test_zone_delegation(): assert_connectivity(net, v6=True) # Check zone delegation and root hinting - root_hints = [NSRecord("", "rootdns"), - ARecord("rootdns", net["rootdns"].defaultIntf().ip), - AAAARecord("rootdns", - net["rootdns"].defaultIntf().ip6)] + root_hints = [ + NSRecord("", "rootdns"), + ARecord("rootdns", net["rootdns"].defaultIntf().ip), + AAAARecord("rootdns", net["rootdns"].defaultIntf().ip6), + ] mydomain_delegation_records = [ NSRecord("mydomain.org", "master"), NSRecord("mydomain.org", "slave"), @@ -145,17 +160,22 @@ def test_zone_delegation(): ARecord("orgdns.org", net["orgdns"].defaultIntf().ip), AAAARecord("orgdns.org", net["orgdns"].defaultIntf().ip6), ] - records = [([net["master"], net["slave"]], - mydomain_delegation_records - + [ARecord("server.mydomain.org", - net["server"].defaultIntf().ip), - AAAARecord("server.mydomain.org", - net["server"].defaultIntf().ip6)] - + root_hints), - ([net["orgdns"]], - org_delegation_records + mydomain_delegation_records - + root_hints), - ([net['rootdns']], root_hints + org_delegation_records)] + records = [ + ( + [net["master"], net["slave"]], + mydomain_delegation_records + + [ + ARecord("server.mydomain.org", net["server"].defaultIntf().ip), + AAAARecord("server.mydomain.org", net["server"].defaultIntf().ip6), + ] + + root_hints, + ), + ( + [net["orgdns"]], + org_delegation_records + mydomain_delegation_records + root_hints, + ), + ([net["rootdns"]], root_hints + org_delegation_records), + ] for nodes, zone_records in records: for node in nodes: for record in zone_records: @@ -167,11 +187,7 @@ def test_zone_delegation(): @require_root -@pytest.mark.parametrize("topo", [ - StaticRoutingNet, - DNSNetwork, - SimpleBGPTopo -]) +@pytest.mark.parametrize("topo", [StaticRoutingNet, DNSNetwork, SimpleBGPTopo]) def test_etc_hosts(topo): try: net = IPNet(topo=topo()) diff --git a/ipmininet/tests/test_exabgp.py b/ipmininet/tests/test_exabgp.py index 0c9cab74..8b1acc7a 100644 --- a/ipmininet/tests/test_exabgp.py +++ b/ipmininet/tests/test_exabgp.py @@ -1,83 +1,174 @@ import json import os import re +from collections.abc import Sequence from contextlib import closing -from ipaddress import IPv4Interface, IPv6Interface -from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address -from typing import Tuple, Sequence, Dict, Union +from ipaddress import ( + IPv4Address, + IPv4Interface, + IPv6Address, + IPv6Interface, + ip_address, + ip_network, +) +from typing import Union import pytest from ipmininet.clean import cleanup from ipmininet.examples.exabgp_prefix_injector import ExaBGPTopoInjectPrefixes from ipmininet.ipnet import IPNet -from ipmininet.router.config import BGPRoute, BGPAttribute, ExaList -from ipmininet.tests import require_root, require_exabgp +from ipmininet.router.config import BGPAttribute, BGPRoute, ExaList +from ipmininet.tests import require_exabgp, require_root from ipmininet.tests.utils import wait_until exa_routes = { - 'ipv4': [ - BGPRoute(ip_network('8.8.8.0/24'), [BGPAttribute("next-hop", "self"), - BGPAttribute("as-path", ExaList([1, 56, 97])), - BGPAttribute("med", 42), - BGPAttribute("origin", "egp")]), - BGPRoute(ip_network('30.252.0.0/16'), [BGPAttribute("next-hop", "self"), - BGPAttribute("as-path", ExaList([1, 48964, 598])), - BGPAttribute("med", 100), - BGPAttribute("origin", "incomplete"), - BGPAttribute("community", ExaList(["1:666", "468:45687"]))]), - BGPRoute(ip_network('1.2.3.4/32'), [BGPAttribute("next-hop", "self"), - BGPAttribute("as-path", ExaList([1, 49887, 39875, 3, 4])), - BGPAttribute("origin", "igp"), - BGPAttribute("local-preference", 42)]), - BGPRoute(ip_network('79.232.8.234/31'), [BGPAttribute("next-hop", "self"), - BGPAttribute("as-path", ExaList( - [1, 5, 48643, 27269, 40070, 23066, 16156, 42942, 63941, 59598, - 13519, 34769, 58452, 30040, 10201, 20699, 47328, 60517, 10726, - 30566, 41722])), - BGPAttribute("med", 2983147431), - BGPAttribute("origin", "incomplete"), - BGPAttribute("community", ExaList( - ['27143:50178', '43275:3204', '8207:51989', '37776:50582', - '43665:5655', '27666:57245', '404:44723', '35094:21563', - '43160:60093', '52506:5571', '26526:20041', '64552:41036', - '42411:6349', '22060:7250', '5047:27611', '956:27358', - '41924:60774', '39756:8423', '55633:46188', '52836:8813', - '35178:22387', '37869:27641', '27376:27259', '8825:27516', - '37759:17407'] - ))]) + "ipv4": [ + BGPRoute( + ip_network("8.8.8.0/24"), + [ + BGPAttribute("next-hop", "self"), + BGPAttribute("as-path", ExaList([1, 56, 97])), + BGPAttribute("med", 42), + BGPAttribute("origin", "egp"), + ], + ), + BGPRoute( + ip_network("30.252.0.0/16"), + [ + BGPAttribute("next-hop", "self"), + BGPAttribute("as-path", ExaList([1, 48964, 598])), + BGPAttribute("med", 100), + BGPAttribute("origin", "incomplete"), + BGPAttribute("community", ExaList(["1:666", "468:45687"])), + ], + ), + BGPRoute( + ip_network("1.2.3.4/32"), + [ + BGPAttribute("next-hop", "self"), + BGPAttribute("as-path", ExaList([1, 49887, 39875, 3, 4])), + BGPAttribute("origin", "igp"), + BGPAttribute("local-preference", 42), + ], + ), + BGPRoute( + ip_network("79.232.8.234/31"), + [ + BGPAttribute("next-hop", "self"), + BGPAttribute( + "as-path", + ExaList( + [ + 1, + 5, + 48643, + 27269, + 40070, + 23066, + 16156, + 42942, + 63941, + 59598, + 13519, + 34769, + 58452, + 30040, + 10201, + 20699, + 47328, + 60517, + 10726, + 30566, + 41722, + ] + ), + ), + BGPAttribute("med", 2983147431), + BGPAttribute("origin", "incomplete"), + BGPAttribute( + "community", + ExaList( + [ + "27143:50178", + "43275:3204", + "8207:51989", + "37776:50582", + "43665:5655", + "27666:57245", + "404:44723", + "35094:21563", + "43160:60093", + "52506:5571", + "26526:20041", + "64552:41036", + "42411:6349", + "22060:7250", + "5047:27611", + "956:27358", + "41924:60774", + "39756:8423", + "55633:46188", + "52836:8813", + "35178:22387", + "37869:27641", + "27376:27259", + "8825:27516", + "37759:17407", + ] + ), + ), + ], + ), + ], + "ipv6": [ + BGPRoute( + ip_network("dead:beef:15:dead::/64"), + [ + BGPAttribute("next-hop", "self"), + BGPAttribute("as-path", ExaList([1, 4, 3, 5])), + BGPAttribute("origin", "egp"), + BGPAttribute("local-preference", 1000), + ], + ), + BGPRoute( + ip_network("bad:c0ff:ee:bad:c0de::/80"), + [ + BGPAttribute("next-hop", "self"), + BGPAttribute("as-path", ExaList([1, 3, 4])), + BGPAttribute("origin", "egp"), + BGPAttribute( + "community", ExaList(["2914:480", "2914:413", "2914:4621"]) + ), + ], + ), + BGPRoute( + ip_network("1:5ee:bad:c0de::/64"), + [ + BGPAttribute("next-hop", "self"), + BGPAttribute("as-path", ExaList([1, 89, 42, 5])), + BGPAttribute("origin", "igp"), + ], + ), ], - 'ipv6': [ - BGPRoute(ip_network("dead:beef:15:dead::/64"), [BGPAttribute("next-hop", "self"), - BGPAttribute("as-path", ExaList([1, 4, 3, 5])), - BGPAttribute("origin", "egp"), - BGPAttribute("local-preference", 1000)]), - BGPRoute(ip_network("bad:c0ff:ee:bad:c0de::/80"), [BGPAttribute("next-hop", "self"), - BGPAttribute("as-path", ExaList([1, 3, 4])), - BGPAttribute("origin", "egp"), - BGPAttribute("community", - ExaList( - ["2914:480", "2914:413", "2914:4621"]))]), - BGPRoute(ip_network("1:5ee:bad:c0de::/64"), [BGPAttribute("next-hop", "self"), - BGPAttribute("as-path", ExaList([1, 89, 42, 5])), - BGPAttribute("origin", "igp")]) - ] } -get_rib = "#!/usr/bin/env sh \n" \ - "nc {host} {port} < Sequence[Tuple[str, str]]: +def prepare_rib_lookup_script(rib_script) -> Sequence[tuple[str, str]]: scripts = list() - for family in ('ipv4', 'ipv6'): + for family in ("ipv4", "ipv6"): super_path = "/tmp/_get_%s_rib.sh" % family scripts.append((super_path, family)) - with closing(open(super_path, 'w')) as f: + with closing(open(super_path, "w")) as f: f.write(rib_script.format(host="localhost", port=2605, family=family)) return scripts @@ -92,9 +183,7 @@ def get_rib_routes(node, command, family): output = out.decode(errors="ignore") - p = re.compile( - r"(?s)as2> show bgp {family} json(?P.*)as2> exit".format(family=family) - ) + p = re.compile(rf"(?s)as2> show bgp {family} json(?P.*)as2> exit") m = p.search(output) if m is None: return None @@ -129,10 +218,14 @@ def _all_routes_present(): return not missing wait_until( - _all_routes_present, timeout=timeout, interval=poll, - description=lambda: "the routes injected by ExaBGP to appear in the " - "BGP RIB within %ds. Missing: %s" - % (timeout, missing)) + _all_routes_present, + timeout=timeout, + interval=poll, + description=lambda: ( + "the routes injected by ExaBGP to appear in the " + "BGP RIB within %ds. Missing: %s" % (timeout, missing) + ), + ) def check_correct_rib(node, rib_scripts, topo): @@ -146,40 +239,48 @@ def check_correct_rib(node, rib_scripts, topo): print(rib_routes) for our_route in expected_rib_routes[family]: - str_ipnet = str(our_route.IPNetwork) - assert str_ipnet in rib_routes, \ - "{route} not in FRRouting BGP RIB".format(route=our_route.IPNetwork) + assert str_ipnet in rib_routes, ( + f"{our_route.IPNetwork} not in FRRouting BGP RIB" + ) # take the first one as ExaBGP sends only one route per prefix rib_route = rib_routes[str_ipnet][0] - assert rib_route["origin"].lower() == our_route['origin'].val, \ - "Bad origin for route {route}. Expected {origin_expect}. Received {origin_real}" \ - .format(route=our_route.IPNetwork, origin_expect=our_route['origin'].val, - origin_real=rib_route["origin"]) + assert rib_route["origin"].lower() == our_route["origin"].val, ( + "Bad origin for route {route}. Expected {origin_expect}. Received {origin_real}".format( + route=our_route.IPNetwork, + origin_expect=our_route["origin"].val, + origin_real=rib_route["origin"], + ) + ) - check_as_path(rib_route["path"], our_route['as-path'].val) + check_as_path(rib_route["path"], our_route["as-path"].val) - assert check_next_hop(rib_route['nexthops'], topo.addr['as1']) is True, \ + assert check_next_hop(rib_route["nexthops"], topo.addr["as1"]) is True, ( "Bad next hop" + ) - if 'metric' in rib_route: - assert rib_route['metric'] == our_route['med'].val, \ - "Bad MED. Expected {expected}. Received {received}" \ - .format(expected=our_route['med'].val, received=rib_route['metric']) + if "metric" in rib_route: + assert rib_route["metric"] == our_route["med"].val, ( + "Bad MED. Expected {expected}. Received {received}".format( + expected=our_route["med"].val, received=rib_route["metric"] + ) + ) -def check_next_hop(next_hops: dict, expected_nh: Dict[str, Union['IPv4Interface', 'IPv6Interface']]): +def check_next_hop( + next_hops: dict, expected_nh: dict[str, Union["IPv4Interface", "IPv6Interface"]] +): for next_hop in next_hops: - rib_next_hop = ip_address(next_hop['ip']) + rib_next_hop = ip_address(next_hop["ip"]) if isinstance(rib_next_hop, IPv4Address): - if rib_next_hop == expected_nh['ipv4'].ip: + if rib_next_hop == expected_nh["ipv4"].ip: return True elif isinstance(rib_next_hop, IPv6Address): - if rib_next_hop == expected_nh['ipv6'].ip: + if rib_next_hop == expected_nh["ipv6"].ip: return True return False @@ -191,27 +292,48 @@ def check_as_path(as_path_rib: str, as_path_us: ExaList): error_msg = "Bad AS-PATH. Expected {expected}. Received {received}" - assert len(as_rib) == len(as_rib_us), error_msg. \ - format(expected=as_rib_us, received=as_path_rib) + assert len(as_rib) == len(as_rib_us), error_msg.format( + expected=as_rib_us, received=as_path_rib + ) for idx, asn_received, asn_expected in zip(range(len(as_rib)), as_rib, as_rib_us): - assert asn_received == asn_expected, "Bad ASN at index {index}. Expected AS{expected}. Received AS{received}". \ - format(index=idx, expected=asn_expected, received=asn_received) + assert asn_received == asn_expected, ( + f"Bad ASN at index {idx}. Expected AS{asn_expected}. Received AS{asn_received}" + ) @require_root @require_exabgp @pytest.mark.timeout(1200) -@pytest.mark.parametrize('topo_test,frr_bgp_node', [ - (ExaBGPTopoInjectPrefixes(routes=exa_routes), 'as2'), # default IPs, custom routes, - (ExaBGPTopoInjectPrefixes(), 'as2'), # default IPs, random routes - (ExaBGPTopoInjectPrefixes(addr={ - 'as1': {'ipv4': '8.8.8.1/24', 'ipv6': '2001:4860:4860::1/64'}, - 'as2': {'ipv4': '8.8.8.2/24', 'ipv6': '2001:4860:4860::2/64'}}), 'as2'), # custom IP addr, random routes - (ExaBGPTopoInjectPrefixes(routes=exa_routes, addr={ - 'as1': {'ipv4': '9.8.8.1/24', 'ipv6': '2001:4860:4860::1/64'}, - 'as2': {'ipv4': '9.8.8.2/24', 'ipv6': '2001:4860:4860::2/64'}}), 'as2'), # custom IP addr, custom routes -]) +@pytest.mark.parametrize( + "topo_test,frr_bgp_node", + [ + ( + ExaBGPTopoInjectPrefixes(routes=exa_routes), + "as2", + ), # default IPs, custom routes, + (ExaBGPTopoInjectPrefixes(), "as2"), # default IPs, random routes + ( + ExaBGPTopoInjectPrefixes( + addr={ + "as1": {"ipv4": "8.8.8.1/24", "ipv6": "2001:4860:4860::1/64"}, + "as2": {"ipv4": "8.8.8.2/24", "ipv6": "2001:4860:4860::2/64"}, + } + ), + "as2", + ), # custom IP addr, random routes + ( + ExaBGPTopoInjectPrefixes( + routes=exa_routes, + addr={ + "as1": {"ipv4": "9.8.8.1/24", "ipv6": "2001:4860:4860::1/64"}, + "as2": {"ipv4": "9.8.8.2/24", "ipv6": "2001:4860:4860::2/64"}, + }, + ), + "as2", + ), # custom IP addr, custom routes + ], +) def test_example_exabgp(topo_test, frr_bgp_node): rib_scripts = prepare_rib_lookup_script(get_rib) net = IPNet(topo=topo_test) diff --git a/ipmininet/tests/test_gre.py b/ipmininet/tests/test_gre.py index 6fdb74be..d4e18217 100644 --- a/ipmininet/tests/test_gre.py +++ b/ipmininet/tests/test_gre.py @@ -1,7 +1,9 @@ """This module tests GRE tunnels""" + from ipmininet.clean import cleanup from ipmininet.examples.gre import GRETopo from ipmininet.ipnet import IPNet + from . import require_root from .utils import wait_until @@ -12,7 +14,7 @@ def test_gre_example(): net = IPNet(topo=GRETopo(), use_v6=False) net.start() - cmd = "ping -W 1 -c 1 -I 10.0.1.1 10.0.1.2".split(" ") + cmd = ["ping", "-W", "1", "-c", "1", "-I", "10.0.1.1", "10.0.1.2"] last_out = "" last_err = "" @@ -24,10 +26,14 @@ def _gre_ping_ok(): return code == 0 wait_until( - _gre_ping_ok, timeout=60, interval=0.5, - description=lambda: "the GRE tunnel from %s to 10.0.1.2 to be " - "usable\n[stdout]\n%s\n[stderr]\n%s" - % (net["h1"], last_out, last_err)) + _gre_ping_ok, + timeout=60, + interval=0.5, + description=lambda: ( + "the GRE tunnel from %s to 10.0.1.2 to be " + "usable\n[stdout]\n%s\n[stderr]\n%s" % (net["h1"], last_out, last_err) + ), + ) net.stop() finally: diff --git a/ipmininet/tests/test_iptables.py b/ipmininet/tests/test_iptables.py index cc74a59b..5ab69e59 100644 --- a/ipmininet/tests/test_iptables.py +++ b/ipmininet/tests/test_iptables.py @@ -1,8 +1,10 @@ """This module tests iptables""" + from ipmininet.clean import cleanup from ipmininet.examples.iptables import IPTablesTopo from ipmininet.ipnet import IPNet from ipmininet.tests.utils import check_tcp_connectivity, wait_until + from . import require_root @@ -17,41 +19,58 @@ def test_iptables_example(): p = net["r1"].popen(cmd.split(" ")) out, err = p.communicate() ret = p.poll() - assert ret == 0, "Pings over IPv4 should not be blocked.\n" \ - "[stdout]\n%s\n[stderr]\n%s" % (out, err) + assert ret == 0, ( + "Pings over IPv4 should not be blocked.\n" + "[stdout]\n%s\n[stderr]\n%s" % (out, err) + ) ip6 = net["r2"].intf("r2-eth0").ip6 cmd = "ping6 -W 1 -c 1 %s" % ip6 wait_until( lambda: net["r1"].popen(cmd.split(" ")).wait() != 0, - timeout=50, interval=5, - description="IPv6 pings from %s to %s to be blocked" - % (net["r1"], ip6)) - - ret, _, _ = check_tcp_connectivity(net["r1"], net["r2"], - server_port=80, - server_itf=net["r2"].intf("r2-eth0"), - timeout=.5) + timeout=50, + interval=5, + description="IPv6 pings from %s to %s to be blocked" % (net["r1"], ip6), + ) + + ret, _, _ = check_tcp_connectivity( + net["r1"], + net["r2"], + server_port=80, + server_itf=net["r2"].intf("r2-eth0"), + timeout=0.5, + ) assert ret != 0, "TCP over port 80 should be blocked over IPv4" - ret, _, _ = check_tcp_connectivity(net["r1"], net["r2"], - server_port=1480, - server_itf=net["r2"].intf("r2-eth0"), - timeout=.5) + ret, _, _ = check_tcp_connectivity( + net["r1"], + net["r2"], + server_port=1480, + server_itf=net["r2"].intf("r2-eth0"), + timeout=0.5, + ) assert ret != 0, "TCP over port 1480 should be blocked over IPv4" - ret, _, _ = check_tcp_connectivity(net["r1"], net["r2"], - server_port=2000, - server_itf=net["r2"].intf("r2-eth0"), - timeout=.5) + ret, _, _ = check_tcp_connectivity( + net["r1"], + net["r2"], + server_port=2000, + server_itf=net["r2"].intf("r2-eth0"), + timeout=0.5, + ) assert ret == 0, "TCP over port 2000 should not be blocked over IPv4" - ret, out, err = \ - check_tcp_connectivity(net["r1"], net["r2"], v6=True, - server_port=80, - server_itf=net["r2"].intf("r2-eth0")) - assert ret == 0, "TCP over port 80 should not be blocked over IPv6.\n" \ - "[stdout]\n%s\n[stderr]\n%s" % (out, err) + ret, out, err = check_tcp_connectivity( + net["r1"], + net["r2"], + v6=True, + server_port=80, + server_itf=net["r2"].intf("r2-eth0"), + ) + assert ret == 0, ( + "TCP over port 80 should not be blocked over IPv6.\n" + "[stdout]\n%s\n[stderr]\n%s" % (out, err) + ) net.stop() finally: diff --git a/ipmininet/tests/test_link.py b/ipmininet/tests/test_link.py index 0a17798a..dcd6c3e3 100644 --- a/ipmininet/tests/test_link.py +++ b/ipmininet/tests/test_link.py @@ -1,6 +1,7 @@ -import pytest from ipaddress import ip_interface +import pytest + from ipmininet.clean import cleanup from ipmininet.examples.static_address_network import StaticAddressNet from ipmininet.ipnet import IPNet @@ -8,32 +9,53 @@ from ipmininet.tests import require_root -@pytest.mark.parametrize("unsorted_list,sorted_list", [ - (["10.0.0.1/8", "2001::1/16"], - ["2001::1/16", "10.0.0.1/8"]), # V4 before V6 - (["2001::1/16", "10.0.0.1/8"], - ["2001::1/16", "10.0.0.1/8"]), # V4 before V6 - (["fe80::/16", "::1/128"], - ["fe80::/16", "::1/128"]), # Link-local before loopback - (["::1/128", "fe80::/16"], - ["fe80::/16", "::1/128"]), # Link-local before loopback - (["fe80::/16", "2001::1/16"], - ["2001::1/16", "fe80::/16"]), # Global before link-local - (["2001::1/16", "fe80::/16"], - ["2001::1/16", "fe80::/16"]), # Global before link-local - (["10.0.0.1/8", "100.64.0.1/32"], - ["100.64.0.1/32", "10.0.0.1/8"]), # Public before private - (["100.64.0.1/32", "10.0.0.1/8"], - ["100.64.0.1/32", "10.0.0.1/8"]), # Public before private - (["2001::1/16", "2002::1/16"], - ["2001::1/16", "2002::1/16"]), # Global before private (2002::/16 is 6to4) - (["2002::1/16", "2001::1/16"], - ["2001::1/16", "2002::1/16"]), # Global before private (2002::/16 is 6to4) - (["2001::1/16", "2001::2/16"], - ["2001::2/16", "2001::1/16"]), # Bigger IP value first - (["2001::2/16", "2001::1/16"], - ["2001::2/16", "2001::1/16"]), # Bigger IP value first -]) +@pytest.mark.parametrize( + "unsorted_list,sorted_list", + [ + (["10.0.0.1/8", "2001::1/16"], ["2001::1/16", "10.0.0.1/8"]), # V4 before V6 + (["2001::1/16", "10.0.0.1/8"], ["2001::1/16", "10.0.0.1/8"]), # V4 before V6 + ( + ["fe80::/16", "::1/128"], + ["fe80::/16", "::1/128"], + ), # Link-local before loopback + ( + ["::1/128", "fe80::/16"], + ["fe80::/16", "::1/128"], + ), # Link-local before loopback + ( + ["fe80::/16", "2001::1/16"], + ["2001::1/16", "fe80::/16"], + ), # Global before link-local + ( + ["2001::1/16", "fe80::/16"], + ["2001::1/16", "fe80::/16"], + ), # Global before link-local + ( + ["10.0.0.1/8", "100.64.0.1/32"], + ["100.64.0.1/32", "10.0.0.1/8"], + ), # Public before private + ( + ["100.64.0.1/32", "10.0.0.1/8"], + ["100.64.0.1/32", "10.0.0.1/8"], + ), # Public before private + ( + ["2001::1/16", "2002::1/16"], + ["2001::1/16", "2002::1/16"], + ), # Global before private (2002::/16 is 6to4) + ( + ["2002::1/16", "2001::1/16"], + ["2001::1/16", "2002::1/16"], + ), # Global before private (2002::/16 is 6to4) + ( + ["2001::1/16", "2001::2/16"], + ["2001::2/16", "2001::1/16"], + ), # Bigger IP value first + ( + ["2001::2/16", "2001::1/16"], + ["2001::2/16", "2001::1/16"], + ), # Bigger IP value first + ], +) def test_ordered_address(unsorted_list, sorted_list): unsorted_list = [ip_interface(ip) for ip in unsorted_list] new_list = sorted(unsorted_list, key=OrderedAddress, reverse=True) @@ -51,26 +73,27 @@ def test_addr_intf(): # Check IP6 update itf.ip6 = "2001:21::1" ip6s = list(itf.ip6s(exclude_lls=True)) - assert len(ip6s) == 1 and ip6s[0].with_prefixlen == "2001:21::1/64",\ + assert len(ip6s) == 1 and ip6s[0].with_prefixlen == "2001:21::1/64", ( "Cannot update an IPv6 address" + ) assert itf.prefixLen6 == 64 itf.prefixLen6 = 48 - assert itf.prefixLen6 == 48,\ - "Cannot update prefix len of an IPv6 address" + assert itf.prefixLen6 == 48, "Cannot update prefix len of an IPv6 address" # Check IP update itf.ip = "10.1.2.1" ips = list(itf.ips()) - assert len(ips) == 1 and ips[0].with_prefixlen == "10.1.2.1/24",\ + assert len(ips) == 1 and ips[0].with_prefixlen == "10.1.2.1/24", ( "Cannot update an IPv4 address" + ) assert itf.prefixLen == 24 itf.prefixLen = 28 - assert itf.prefixLen == 28,\ - "Cannot update prefix len of an IPv4 address" + assert itf.prefixLen == 28, "Cannot update prefix len of an IPv4 address" # Check MAC getters - assert itf.updateMAC() == itf.updateAddr()[1],\ + assert itf.updateMAC() == itf.updateAddr()[1], ( "MAC address obtained through two methods is not identical" + ) net.stop() finally: diff --git a/ipmininet/tests/test_linkfailure.py b/ipmininet/tests/test_linkfailure.py index 8047095d..d0820c96 100644 --- a/ipmininet/tests/test_linkfailure.py +++ b/ipmininet/tests/test_linkfailure.py @@ -1,15 +1,16 @@ -""""This module test the Link Failure API""" +""" "This module test the Link Failure API""" + +from ipaddress import ip_network import pytest -from ipaddress import ip_network from ipmininet.clean import cleanup from ipmininet.ipnet import IPNet from ipmininet.iptopo import IPTopo -from . import require_root -from .utils import assert_connectivity, assert_node_not_connected, \ - assert_routing_table + from ..examples.link_failure import FailureTopo +from . import require_root +from .utils import assert_connectivity, assert_node_not_connected, assert_routing_table def _wait_reconvergence(net: IPNet, timeout=120): @@ -25,12 +26,10 @@ def _wait_reconvergence(net: IPNet, timeout=120): far_ip = itf.ip6 if v6 else itf.ip plen = itf.prefixLen6 if v6 else itf.prefixLen prefix = str(ip_network("%s/%s" % (far_ip, plen), strict=False)) - assert_routing_table(net[router], [prefix], present=True, - timeout=timeout) + assert_routing_table(net[router], [prefix], present=True, timeout=timeout) class Topo(IPTopo): - def build(self, *args, **kwargs): r1 = self.addRouter("r1") r2 = self.addRouter("r2") @@ -57,11 +56,14 @@ def test_failure_topo(): @require_root -@pytest.mark.parametrize("plan", [ - [("r1", "r2")], - [("h1", "r1")], - [("r1", "h1"), ("r2", "r1"), ("r2", "h2")], -]) +@pytest.mark.parametrize( + "plan", + [ + [("r1", "r2")], + [("h1", "r1")], + [("r1", "h1"), ("r2", "r1"), ("r2", "h2")], + ], +) def test_failurePlan(plan): try: net = IPNet(topo=Topo()) @@ -131,8 +133,7 @@ def test_randomFailureOnTargetedLink(): assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) - itfs = net.randomFailure(1, - weak_links=[net["r1"].intf("r1-eth0").link]) + itfs = net.randomFailure(1, weak_links=[net["r1"].intf("r1-eth0").link]) # Check a failure between both hosts assert_node_not_connected(src=net["h1"], dst=net["h2"], v6=False) diff --git a/ipmininet/tests/test_misc.py b/ipmininet/tests/test_misc.py index 3f6f2448..102e3b8d 100644 --- a/ipmininet/tests/test_misc.py +++ b/ipmininet/tests/test_misc.py @@ -1,7 +1,7 @@ +import ipaddress import os import subprocess -import ipaddress import pytest import ipmininet.utils as utils @@ -10,12 +10,13 @@ from ipmininet.ipnet import IPNet from ipmininet.link import _parse_addresses from ipmininet.router.config.utils import ip_statement + from . import require_root -@pytest.mark.parametrize('address', [ - '::/0', '0.0.0.0/0', '1.2.3.0/24', '2001:db8:1234::/48' -]) +@pytest.mark.parametrize( + "address", ["::/0", "0.0.0.0/0", "1.2.3.0/24", "2001:db8:1234::/48"] +) def test_nested_ip_networks(address): """This test ensures that we can build an IPvXNetwork from another one. If this breaks, need to grep through for ip_network calls as I removed the @@ -24,29 +25,37 @@ def test_nested_ip_networks(address): _N = ipaddress.ip_network n1 = _N(address) # Build an IPvXNetwork n2 = _N(n1) # Build a new one from the previous one - assert (n1 == n2 and - n1.with_prefixlen == address and - n2.with_prefixlen == address and - n1.max_prefixlen == n2.max_prefixlen) - - -@pytest.mark.parametrize("test_input,expected", [ - ("255.255.0.0", 16), - ("f000::", 4), - ("ffff::", 16), - ("128.0.0.0", 1), - ("fe00::", 7) -]) + assert ( + n1 == n2 + and n1.with_prefixlen == address + and n2.with_prefixlen == address + and n1.max_prefixlen == n2.max_prefixlen + ) + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ("255.255.0.0", 16), + ("f000::", 4), + ("ffff::", 16), + ("128.0.0.0", 1), + ("fe00::", 7), + ], +) def test_prefix_for_netmask(test_input, expected): assert utils.prefix_for_netmask(test_input) == expected -@pytest.mark.parametrize("test_input,expected", [ - ('0.0.0.1', 1), - ('0.0.128.0', 128 << 8), - ('0.0.123.3', (123 << 8) + 3), - ('::f:1', (0xf << 16) + 1) -]) +@pytest.mark.parametrize( + "test_input,expected", + [ + ("0.0.0.1", 1), + ("0.0.128.0", 128 << 8), + ("0.0.123.3", (123 << 8) + 3), + ("::f:1", (0xF << 16) + 1), + ], +) def test_ipaddress_endianness(test_input, expected): """Checks int(ipaddress) endianness""" assert int(ipaddress.ip_address(test_input)) == expected @@ -59,12 +68,13 @@ def test_ip_address_format(): to parse it properly. """ # We force up status so we parse at least one IP of each family - subprocess.call(['ip', 'link', 'set', 'dev', 'lo', 'up']) - out = subprocess.check_output(['ip', 'address', 'show', 'dev', 'lo'])\ - .decode("utf-8") + subprocess.call(["ip", "link", "set", "dev", "lo", "up"]) + out = subprocess.check_output(["ip", "address", "show", "dev", "lo"]).decode( + "utf-8" + ) mac, v4, v6 = _parse_addresses(out) assert mac is not None - assert len(mac.split(':')) == 6 + assert len(mac.split(":")) == 6 assert mac in out assert len(v4) > 0 for a in v4: @@ -75,31 +85,37 @@ def test_ip_address_format(): assert a.version == 6 assert a.with_prefixlen in out # IF status, MAC, inet, valid, inet, valid, ..., inet6, valid, ... - assert len(out.strip('\n').split('\n')) == (2 + 2 * len(v4) + 2 * len(v6)) - - -@pytest.mark.parametrize("cmd,present", [ - ("ls", True), - ("/bin/sh", True), - ("for", False), # Never a command because in bash syntax - (os.path.abspath(__file__), False), -]) + assert len(out.strip("\n").split("\n")) == (2 + 2 * len(v4) + 2 * len(v6)) + + +@pytest.mark.parametrize( + "cmd,present", + [ + ("ls", True), + ("/bin/sh", True), + ("for", False), # Never a command because in bash syntax + (os.path.abspath(__file__), False), + ], +) def test_require_cmd(cmd, present): try: utils.require_cmd(cmd) - assert present, "The command [%s] was found" \ - " while it is not present" % cmd + assert present, "The command [%s] was found while it is not present" % cmd except RuntimeError: - assert not present, "The command [%s] could not be found" \ - " while it is present" % cmd - - -@pytest.mark.parametrize("node,use_v4,use_v6,expected", [ - ("h1", True, True, ("10.0.0.2", "2001:1a::2")), - ("h1", False, True, (None, "2001:1a::2")), - ("h1", True, False, ("10.0.0.2", None)), - ("h1", False, False, (None, None)), -]) + assert not present, ( + "The command [%s] could not be found while it is present" % cmd + ) + + +@pytest.mark.parametrize( + "node,use_v4,use_v6,expected", + [ + ("h1", True, True, ("10.0.0.2", "2001:1a::2")), + ("h1", False, True, (None, "2001:1a::2")), + ("h1", True, False, ("10.0.0.2", None)), + ("h1", False, False, (None, None)), + ], +) @require_root def test_address_pair(node, use_v4, use_v6, expected): try: @@ -111,14 +127,17 @@ def test_address_pair(node, use_v4, use_v6, expected): cleanup() -@pytest.mark.parametrize("start,node,present", [ - ("h1", "h1", True), - ("h1", "r1", True), - ("h1", "h2", True), - ("h1", "h3", True), - ("h1", "s2", False), # Switches are not searched - ("h1", "None", False), -]) +@pytest.mark.parametrize( + "start,node,present", + [ + ("h1", "h1", True), + ("h1", "r1", True), + ("h1", "h2", True), + ("h1", "h3", True), + ("h1", "s2", False), # Switches are not searched + ("h1", "None", False), + ], +) @require_root def test_find_node(start, node, present): try: @@ -126,33 +145,36 @@ def test_find_node(start, node, present): net.start() i = utils.find_node(net[start], node) if present: - assert i is not None,\ - "Node %s not found from node %s" % (node, start) - assert i.node.name == node,\ - "Node %s was found while we expected %s" % (i.node.name, node) + assert i is not None, "Node %s not found from node %s" % (node, start) + assert i.node.name == node, "Node %s was found while we expected %s" % ( + i.node.name, + node, + ) else: - assert i is None,\ - "Node %s should not be found from node %s" % (node, start) + assert i is None, "Node %s should not be found from node %s" % (node, start) net.stop() finally: cleanup() -@pytest.mark.parametrize("test_input,expected", [ - (4, "ip"), - (6, "ipv6"), - ("10.0.0.1", "ip"), - ("10.0.0.0/8", "ip"), - ("10.0.0.1/8", "ip"), - ("2042::1", "ipv6"), - ("2042::/16", "ipv6"), - ("2042::1/16", "ipv6"), - (ipaddress.ip_address("10.0.0.1"), "ip"), - (ipaddress.ip_network("10.0.0.0/8"), "ip"), - (ipaddress.ip_interface("10.0.0.1/8"), "ip"), - (ipaddress.ip_address("2042::1"), "ipv6"), - (ipaddress.ip_network("2042::/16"), "ipv6"), - (ipaddress.ip_interface("2042::1/16"), "ipv6"), -]) +@pytest.mark.parametrize( + "test_input,expected", + [ + (4, "ip"), + (6, "ipv6"), + ("10.0.0.1", "ip"), + ("10.0.0.0/8", "ip"), + ("10.0.0.1/8", "ip"), + ("2042::1", "ipv6"), + ("2042::/16", "ipv6"), + ("2042::1/16", "ipv6"), + (ipaddress.ip_address("10.0.0.1"), "ip"), + (ipaddress.ip_network("10.0.0.0/8"), "ip"), + (ipaddress.ip_interface("10.0.0.1/8"), "ip"), + (ipaddress.ip_address("2042::1"), "ipv6"), + (ipaddress.ip_network("2042::/16"), "ipv6"), + (ipaddress.ip_interface("2042::1/16"), "ipv6"), + ], +) def test_ip_statement(test_input, expected): assert ip_statement(test_input) == expected diff --git a/ipmininet/tests/test_network_capture.py b/ipmininet/tests/test_network_capture.py index 8b767cfd..9d8452bf 100644 --- a/ipmininet/tests/test_network_capture.py +++ b/ipmininet/tests/test_network_capture.py @@ -1,10 +1,12 @@ -""""This module test the Link Failure API""" +""" "This module test the Link Failure API""" + from ipmininet.clean import cleanup from ipmininet.ipnet import IPNet from ipmininet.overlay import NetworkCapture -from . import require_root, require_mimidump -from .utils import assert_connectivity + from ..examples.network_capture import NetworkCaptureTopo +from . import require_mimidump, require_root +from .utils import assert_connectivity @require_mimidump @@ -13,8 +15,7 @@ def test_network_capture_example(): try: net = IPNet(topo=NetworkCaptureTopo()) net.start() - overlay = next(o for o in net.topo.overlays - if isinstance(o, NetworkCapture)) + overlay = next(o for o in net.topo.overlays if isinstance(o, NetworkCapture)) # Capture readiness is asynchronous; wait for it instead of asserting # file existence immediately after net.start(). @@ -38,8 +39,7 @@ def test_network_capture_wait_until_capturing(): try: net = IPNet(topo=NetworkCaptureTopo()) net.start() - overlay = next(o for o in net.topo.overlays - if isinstance(o, NetworkCapture)) + overlay = next(o for o in net.topo.overlays if isinstance(o, NetworkCapture)) # Captures on interfaces (mimidump) become live once the READY signal # is received diff --git a/ipmininet/tests/test_openr.py b/ipmininet/tests/test_openr.py index 27b6aecf..b7b2181a 100644 --- a/ipmininet/tests/test_openr.py +++ b/ipmininet/tests/test_openr.py @@ -10,24 +10,27 @@ from ipmininet.router.config import OpenrRouterConfig from ipmininet.tests.utils import assert_connectivity -from . import require_root, require_openr +from . import require_openr, require_root class SimpleOpenrTopo(IPTopo): def __init__(self, *args, **kwargs): - self.r4_log_dir = '/var/tmp/custom/log_dir/location' + self.r4_log_dir = "/var/tmp/custom/log_dir/location" super().__init__(*args, **kwargs) def build(self, *args, **kwargs): # pylint: disable=unbalanced-tuple-unpacking - r_1, r_2, r_3 = \ - self.addRouters('r_1', 'r_2', 'r_3', - cls=OpenrRouter, - routerDescription=OpenrRouterDescription, - config=OpenrRouterConfig) - r_4 = self.addRouter('r_4', - cls=OpenrRouter, - routerDescription=OpenrRouterDescription) + r_1, r_2, r_3 = self.addRouters( + "r_1", + "r_2", + "r_3", + cls=OpenrRouter, + routerDescription=OpenrRouterDescription, + config=OpenrRouterConfig, + ) + r_4 = self.addRouter( + "r_4", cls=OpenrRouter, routerDescription=OpenrRouterDescription + ) r_4.addOpenrDaemon(log_dir=self.r4_log_dir) self.addLinks((r_1, r_2), (r_1, r_3), (r_3, r_4)) super().build(*args, **kwargs) @@ -53,11 +56,11 @@ def test_logdir_creation(): net = IPNet(topo=topo) net.start() - default_log_dir = '/var/tmp/log' + default_log_dir = "/var/tmp/log" for i in range(1, 4): - assert os.path.isdir('{}/r_{}'.format(default_log_dir, i)) - assert not os.path.isdir('{}/r_4'.format(default_log_dir)) + assert os.path.isdir(f"{default_log_dir}/r_{i}") + assert not os.path.isdir(f"{default_log_dir}/r_4") assert os.path.isdir(topo.r4_log_dir) net.stop() @@ -72,7 +75,7 @@ def test_tmp_isolation(): net = IPNet(topo=SimpleOpenrTopo()) net.start() - tmp_dir = '/tmp' + tmp_dir = "/tmp" with tempfile.NamedTemporaryFile(dir=tmp_dir) as file: host_file_name = file.name host_file_base_name = os.path.basename(host_file_name) @@ -81,16 +84,13 @@ def test_tmp_isolation(): assert os.path.isfile(host_file_name) assert host_file_base_name in host_tmp_dir_content for i in range(1, 5): - node_tmp_dir_content = net['r_{}'.format(i)] \ - .cmd('ls {}'.format(tmp_dir)) \ - .split() + node_tmp_dir_content = net[f"r_{i}"].cmd(f"ls {tmp_dir}").split() assert host_file_base_name not in node_tmp_dir_content node_file_base_name = str(uuid.uuid1()) - node_file_name = '{}/{}'.format(tmp_dir, - node_file_base_name) - net['r_1'].cmd('touch {}'.format(node_file_name)) - node_tmp_dir_content = net['r_1'].cmd('ls {}'.format(tmp_dir)).split() + node_file_name = f"{tmp_dir}/{node_file_base_name}" + net["r_1"].cmd(f"touch {node_file_name}") + node_tmp_dir_content = net["r_1"].cmd(f"ls {tmp_dir}").split() host_tmp_dir_content = os.listdir(tmp_dir) assert node_file_base_name in node_tmp_dir_content diff --git a/ipmininet/tests/test_ospf.py b/ipmininet/tests/test_ospf.py index e632e4f5..5bcbd5d2 100644 --- a/ipmininet/tests/test_ospf.py +++ b/ipmininet/tests/test_ospf.py @@ -10,6 +10,7 @@ from ipmininet.router.config.base import RouterConfig from ipmininet.router.config.ospf import OSPFRedistributedRoute from ipmininet.tests.utils import assert_connectivity, assert_path + from . import require_root @@ -22,6 +23,7 @@ class MinimalOSPFNet(IPTopo): | h3 """ + def __init__(self, node_params_r1, ospf_params_r1, link_params, *args, **kwargs): """:param node_params_r1: Parameters to set on the r1 router :param ospf_params_r1: Parameters to set on the OSPF daemon of r1 @@ -29,10 +31,8 @@ def __init__(self, node_params_r1, ospf_params_r1, link_params, *args, **kwargs) self.node_params_r1 = node_params_r1 self.ospf_params_r1 = ospf_params_r1 self.link_params = link_params - self.link_params.setdefault("params1", {})\ - .setdefault("ip", "10.0.0.1/24") - self.link_params.setdefault("params2", {})\ - .setdefault("ip", "10.0.0.2/24") + self.link_params.setdefault("params1", {}).setdefault("ip", "10.0.0.1/24") + self.link_params.setdefault("params2", {}).setdefault("ip", "10.0.0.2/24") super().__init__(*args, **kwargs) def build(self, *args, **kwargs): @@ -46,17 +46,34 @@ def build(self, *args, **kwargs): h2 = self.addHost("h2") h3 = self.addHost("h3") - self.addLinks((r1, r2, self.link_params), - (r1, r3, {"params1": {"ip": "10.0.4.1/24"}, - "params2": {"ip": "10.0.4.2/24"}}), - (r2, r3, {"params1": {"ip": "10.0.5.1/24"}, - "params2": {"ip": "10.0.5.2/24"}}), - (r1, h1, {"params1": {"ip": "10.0.1.1/24"}, - "params2": {"ip": "10.0.1.2/24"}}), - (r2, h2, {"params1": {"ip": "10.0.2.1/24"}, - "params2": {"ip": "10.0.2.2/24"}}), - (r3, h3, {"params1": {"ip": "10.0.3.1/24"}, - "params2": {"ip": "10.0.3.2/24"}})) + self.addLinks( + (r1, r2, self.link_params), + ( + r1, + r3, + {"params1": {"ip": "10.0.4.1/24"}, "params2": {"ip": "10.0.4.2/24"}}, + ), + ( + r2, + r3, + {"params1": {"ip": "10.0.5.1/24"}, "params2": {"ip": "10.0.5.2/24"}}, + ), + ( + r1, + h1, + {"params1": {"ip": "10.0.1.1/24"}, "params2": {"ip": "10.0.1.2/24"}}, + ), + ( + r2, + h2, + {"params1": {"ip": "10.0.2.1/24"}, "params2": {"ip": "10.0.2.2/24"}}, + ), + ( + r3, + h3, + {"params1": {"ip": "10.0.3.1/24"}, "params2": {"ip": "10.0.3.2/24"}}, + ), + ) super().build(*args, **kwargs) @@ -91,64 +108,86 @@ def test_ospf_example(): @require_root -@pytest.mark.parametrize("node_params,ospf_params,link_params,exp_cfg,exp_paths", [ - ({}, - {}, - {}, - [" network 10.0.0.1/24 area 0.0.0.0", - "interface r1-eth0"], - unit_igp_cost_paths), - ({}, - {"debug": ["lsa"]}, - {}, - ["debug ospf lsa"], - unit_igp_cost_paths), - ({}, - {}, - {"igp_metric": 5}, - [" ip ospf cost 5"], - detour_paths), - ({}, - {}, - {"igp_area": "1.1.1.1"}, - [" network 10.0.0.1/24 area 1.1.1.1", " network 127.0.0.1/8 area 0.0.0.0"], - detour_paths), - ({"igp_area": "1.1.1.1"}, - {}, - {}, - [" network 127.0.0.1/8 area 1.1.1.1", " network 10.0.0.1/24 area 0.0.0.0"], - unit_igp_cost_paths), - ({}, - {}, - {"params1": {"ospf_priority": 1}}, - [" ip ospf priority 1"], - unit_igp_cost_paths), - ({}, - {}, - {"params1": {"ospf_dead_int": "minimal hello-multiplier 2"}}, - [" ip ospf dead-interval minimal hello-multiplier 2"], - unit_igp_cost_paths), - ({}, - {"redistribute": [OSPFRedistributedRoute("connected", 1, 15), - OSPFRedistributedRoute("static", 2, 50)]}, - {}, - [" redistribute connected metric-type 1 metric 15", - " redistribute static metric-type 2 metric 50"], - unit_igp_cost_paths), -]) +@pytest.mark.parametrize( + "node_params,ospf_params,link_params,exp_cfg,exp_paths", + [ + ( + {}, + {}, + {}, + [" network 10.0.0.1/24 area 0.0.0.0", "interface r1-eth0"], + unit_igp_cost_paths, + ), + ({}, {"debug": ["lsa"]}, {}, ["debug ospf lsa"], unit_igp_cost_paths), + ({}, {}, {"igp_metric": 5}, [" ip ospf cost 5"], detour_paths), + ( + {}, + {}, + {"igp_area": "1.1.1.1"}, + [ + " network 10.0.0.1/24 area 1.1.1.1", + " network 127.0.0.1/8 area 0.0.0.0", + ], + detour_paths, + ), + ( + {"igp_area": "1.1.1.1"}, + {}, + {}, + [ + " network 127.0.0.1/8 area 1.1.1.1", + " network 10.0.0.1/24 area 0.0.0.0", + ], + unit_igp_cost_paths, + ), + ( + {}, + {}, + {"params1": {"ospf_priority": 1}}, + [" ip ospf priority 1"], + unit_igp_cost_paths, + ), + ( + {}, + {}, + {"params1": {"ospf_dead_int": "minimal hello-multiplier 2"}}, + [" ip ospf dead-interval minimal hello-multiplier 2"], + unit_igp_cost_paths, + ), + ( + {}, + { + "redistribute": [ + OSPFRedistributedRoute("connected", 1, 15), + OSPFRedistributedRoute("static", 2, 50), + ] + }, + {}, + [ + " redistribute connected metric-type 1 metric 15", + " redistribute static metric-type 2 metric 50", + ], + unit_igp_cost_paths, + ), + ], +) def test_ospf_daemon_params(node_params, ospf_params, link_params, exp_cfg, exp_paths): try: - net = IPNet(topo=MinimalOSPFNet(node_params, ospf_params, link_params), - allocate_IPs=False, use_v6=False) + net = IPNet( + topo=MinimalOSPFNet(node_params, ospf_params, link_params), + allocate_IPs=False, + use_v6=False, + ) net.start() # Check generated configuration with open("/tmp/ospfd_r1.cfg") as fileobj: cfg = fileobj.readlines() for line in exp_cfg: - assert line + "\n" in cfg,\ - "Cannot find the line '%s' in the generated " \ + assert line + "\n" in cfg, ( + "Cannot find the line '%s' in the generated " "configuration:\n%s" % (line, "".join(cfg)) + ) # Check reachability and paths assert_connectivity(net) diff --git a/ipmininet/tests/test_ospf6.py b/ipmininet/tests/test_ospf6.py index e39c3928..79f6ba00 100644 --- a/ipmininet/tests/test_ospf6.py +++ b/ipmininet/tests/test_ospf6.py @@ -1,4 +1,5 @@ """This module tests the OSPF6 daemon""" + import pytest from ipmininet.clean import cleanup @@ -9,6 +10,7 @@ from ipmininet.router.config.base import RouterConfig from ipmininet.router.config.ospf6 import OSPF6RedistributedRoute from ipmininet.tests.utils import assert_connectivity, assert_path + from . import require_root @@ -21,6 +23,7 @@ class MinimalOSPFv3Net(IPTopo): | h3 """ + def __init__(self, node_params_r1, ospf6_params_r1, link_params, *args, **kwargs): """:param node_params_r1: Parameters to set on the r1 router :param ospf6_params_r1: Parameters to set on the OSPF6 daemon of r1 @@ -81,51 +84,63 @@ def test_ospf6_example(): @require_root -@pytest.mark.parametrize("node_params,ospf6_params,link_params,exp_cfg,exp_paths", [ - ({}, - {}, - {}, - [" interface r1-eth0 area 0.0.0.0"], - unit_igp_cost_paths), - ({}, - {"debug": ["flooding"]}, - {}, - ["debug ospf6 flooding"], - unit_igp_cost_paths), - ({}, - {}, - {"igp_metric": 5}, - [" ipv6 ospf6 cost 5"], - high_igp_cost_paths), - ({}, - {}, - {"igp_area": "1.1.1.1"}, - [" interface r1-eth0 area 1.1.1.1", " interface lo area 0.0.0.0"], - high_igp_cost_paths), - ({"igp_area": "1.1.1.1"}, - {}, - {}, - [" interface lo area 1.1.1.1", " interface r1-eth0 area 0.0.0.0"], - unit_igp_cost_paths), - ({}, - {}, - {"params1": {"ospf6_priority": 1}}, - [" ipv6 ospf6 priority 1"], - unit_igp_cost_paths), - ({}, - {}, - {"params1": {"ospf_dead_int": "minimal hello-multiplier 2"}}, - [" ipv6 ospf6 dead-interval %d" % OSPF6.DEAD_INT], - unit_igp_cost_paths), - ({}, - {"redistribute": [OSPF6RedistributedRoute("connected"), - OSPF6RedistributedRoute("static")]}, - {}, - [" redistribute connected", - " redistribute static"], - unit_igp_cost_paths), -]) -def test_ospf6_daemon_params(node_params, ospf6_params, link_params, exp_cfg, exp_paths): +@pytest.mark.parametrize( + "node_params,ospf6_params,link_params,exp_cfg,exp_paths", + [ + ({}, {}, {}, [" interface r1-eth0 area 0.0.0.0"], unit_igp_cost_paths), + ( + {}, + {"debug": ["flooding"]}, + {}, + ["debug ospf6 flooding"], + unit_igp_cost_paths, + ), + ({}, {}, {"igp_metric": 5}, [" ipv6 ospf6 cost 5"], high_igp_cost_paths), + ( + {}, + {}, + {"igp_area": "1.1.1.1"}, + [" interface r1-eth0 area 1.1.1.1", " interface lo area 0.0.0.0"], + high_igp_cost_paths, + ), + ( + {"igp_area": "1.1.1.1"}, + {}, + {}, + [" interface lo area 1.1.1.1", " interface r1-eth0 area 0.0.0.0"], + unit_igp_cost_paths, + ), + ( + {}, + {}, + {"params1": {"ospf6_priority": 1}}, + [" ipv6 ospf6 priority 1"], + unit_igp_cost_paths, + ), + ( + {}, + {}, + {"params1": {"ospf_dead_int": "minimal hello-multiplier 2"}}, + [" ipv6 ospf6 dead-interval %d" % OSPF6.DEAD_INT], + unit_igp_cost_paths, + ), + ( + {}, + { + "redistribute": [ + OSPF6RedistributedRoute("connected"), + OSPF6RedistributedRoute("static"), + ] + }, + {}, + [" redistribute connected", " redistribute static"], + unit_igp_cost_paths, + ), + ], +) +def test_ospf6_daemon_params( + node_params, ospf6_params, link_params, exp_cfg, exp_paths +): try: net = IPNet(topo=MinimalOSPFv3Net(node_params, ospf6_params, link_params)) net.start() @@ -134,9 +149,10 @@ def test_ospf6_daemon_params(node_params, ospf6_params, link_params, exp_cfg, ex with open("/tmp/ospf6d_r1.cfg") as fileobj: cfg = fileobj.readlines() for line in exp_cfg: - assert line + "\n" in cfg,\ - "Cannot find the line '%s' in the generated" \ + assert line + "\n" in cfg, ( + "Cannot find the line '%s' in the generated" " configuration:\n%s" % (line, "".join(cfg)) + ) # Check reachability assert_connectivity(net, v6=True) diff --git a/ipmininet/tests/test_physicalinterface.py b/ipmininet/tests/test_physicalinterface.py index dde155fc..a2264066 100644 --- a/ipmininet/tests/test_physicalinterface.py +++ b/ipmininet/tests/test_physicalinterface.py @@ -1,27 +1,29 @@ """This module tests that we can move physical interface correctly across network namespaces, and that their IP addresses are preserved.""" -from ipmininet.iptopo import IPTopo -from ipmininet.link import _addresses_of -from ipmininet.ipnet import IPNet -import pytest -from subprocess import check_output, CalledProcessError +import logging from ipaddress import ip_interface +from subprocess import CalledProcessError, check_output + +import pytest + +from ipmininet.ipnet import IPNet +from ipmininet.iptopo import IPTopo +from ipmininet.link import _addresses_of from . import require_root -import logging logging.basicConfig() log = logging.getLogger() log.setLevel(logging.DEBUG) -itf = 'dummy0' +itf = "dummy0" class LinkTopo(IPTopo): def build(self, *args, **kw): - r1 = self.addRouter('r1') - r2 = self.addRouter('r2') + r1 = self.addRouter("r1") + r2 = self.addRouter("r2") # Default config is OSPF, add another router so we have at least # one (auto-allocated) IPv4 address---don't break config generation self.addLink(r1, r2) @@ -29,19 +31,21 @@ def build(self, *args, **kw): super().build(*args, **kw) -@pytest.fixture(scope='module', - params=[[ip_interface('192.168.1.2/24')], - [ip_interface('2001:db8:ff::d/48')], - [ip_interface('192.168.1.2/24'), - ip_interface('2001:db8:ff::d/48')], - ]) +@pytest.fixture( + scope="module", + params=[ + [ip_interface("192.168.1.2/24")], + [ip_interface("2001:db8:ff::d/48")], + [ip_interface("192.168.1.2/24"), ip_interface("2001:db8:ff::d/48")], + ], +) def dummy_interface(request): - if itf in ip('link'): - ip('link', 'delete', 'dev', itf) - ip('link', 'add', 'dev', itf, 'type', 'dummy') - ip('link', 'set', 'dev', itf, 'up') + if itf in ip("link"): + ip("link", "delete", "dev", itf) + ip("link", "add", "dev", itf, "type", "dummy") + ip("link", "set", "dev", itf, "up") for addr in request.param: - ip('address', 'add', 'dev', itf, addr.compressed) + ip("address", "add", "dev", itf, addr.compressed) # Turns out deleting a net ns also deletes dummy interfaces so no need # for a finalizer/teardown @@ -57,7 +61,7 @@ def test_capture_dummy_interface(dummy_interface): check_addresses(dummy_interface, node=None) net = IPNet(topo=LinkTopo()) net.start() - check_addresses(dummy_interface, node=net['r1']) + check_addresses(dummy_interface, node=net["r1"]) net.stop() @@ -68,10 +72,10 @@ def check_addresses(addr_list, node): def ip(*args): - cmd = ['ip'] + cmd = ["ip"] cmd.extend(args) try: - log.info('Calling: %s', cmd) + log.info("Calling: %s", cmd) return check_output(cmd).decode("utf-8") except (OSError, CalledProcessError): - log.error('Command failed!') + log.error("Command failed!") diff --git a/ipmininet/tests/test_pure.py b/ipmininet/tests/test_pure.py index b0a47a0d..f67167b3 100644 --- a/ipmininet/tests/test_pure.py +++ b/ipmininet/tests/test_pure.py @@ -5,6 +5,7 @@ in a rootless CI job. Tests that require root are marked with `require_root` in the other test modules. """ + import time from ipaddress import IPv4Network, IPv6Network, ip_network @@ -37,16 +38,20 @@ def _eventually(): assert calls["n"] == _EXPECTED_CALLS def test_timeout_fails(self): - with pytest.raises(pytest.fail.Exception, - match="Timed out after 0s while waiting for the " - "condition"): + with pytest.raises( + pytest.fail.Exception, + match="Timed out after 0s while waiting for the condition", + ): wait_until(lambda: False, timeout=0.05, interval=0.01) def test_timeout_evaluates_callable_description(self): - with pytest.raises(pytest.fail.Exception, match="last observed state: " - "5"): - wait_until(lambda: False, timeout=0.05, interval=0.01, - description=lambda: "last observed state: 5") + with pytest.raises(pytest.fail.Exception, match="last observed state: 5"): + wait_until( + lambda: False, + timeout=0.05, + interval=0.01, + description=lambda: "last observed state: 5", + ) def test_success_does_not_wait(self): # Check-then-sleep: an immediate success must return without sleeping. @@ -74,7 +79,8 @@ def _burst_events(gaps, probes): for probe_time in probes: if probe_time >= gaps.get(node, 0.0): events[TestInferSubPaths._DEST].append( - (probe_time + 0.005 * pos, node)) + (probe_time + 0.005 * pos, node) + ) return events def test_single_probe(self): @@ -98,34 +104,49 @@ def test_burst_with_staggered_liveness(self): def test_last_probe_missed_on_every_node(self): packet_received = self._burst_events({}, self._BURST) packet_received[self._DEST] = [ - (t, n) for t, n in packet_received[self._DEST] - if t < self._BURST[-1] + (t, n) for t, n in packet_received[self._DEST] if t < self._BURST[-1] ] assert _infer_sub_paths(packet_received)[self._DEST] == self._NODES def test_double_capture_on_router(self): # A router captures a forwarded packet once per interface, so a probe # can appear twice on one node; the path must still be recovered. - packet_received = {self._DEST: [ - (0.300, "h6"), (0.3005, "r6"), (0.3010, "r6"), (0.302, "r5"), - (0.303, "r4"), (0.305, "h4"), - (0.800, "h6"), (0.8005, "r6"), (0.8010, "r6"), (0.802, "r5"), - (0.803, "r4"), (0.805, "h4"), - ]} + packet_received = { + self._DEST: [ + (0.300, "h6"), + (0.3005, "r6"), + (0.3010, "r6"), + (0.302, "r5"), + (0.303, "r4"), + (0.305, "h4"), + (0.800, "h6"), + (0.8005, "r6"), + (0.8010, "r6"), + (0.802, "r5"), + (0.803, "r4"), + (0.805, "h4"), + ] + } assert _infer_sub_paths(packet_received)[self._DEST] == self._NODES def test_probes_not_split_at_half_second_boundary(self): # Probes must be grouped by time gap, not by rounding: a probe at # .648 and the next one +0.5s later (.156) both round to the same # integer and must not be merged into one fake probe. - packet_received = {self._DEST: [ - (176.648, "h6"), (176.6485, "r6"), (176.649, "r5"), (176.650, - "r4"), - (176.651, "h4"), - (177.156, "h6"), (177.1565, "r6"), (177.157, "r5"), (177.158, - "r4"), - (177.159, "h4"), - ]} + packet_received = { + self._DEST: [ + (176.648, "h6"), + (176.6485, "r6"), + (176.649, "r5"), + (176.650, "r4"), + (176.651, "h4"), + (177.156, "h6"), + (177.1565, "r6"), + (177.157, "r5"), + (177.158, "r4"), + (177.159, "h4"), + ] + } assert _infer_sub_paths(packet_received)[self._DEST] == self._NODES @@ -222,11 +243,14 @@ def test_attribute_assignment(self): assert d["foo"] == "bar" -@pytest.mark.parametrize("ip,expected", [ - ("10.0.0.0/8", "ip"), - (4, "ip"), - ("2001:db8::/32", "ipv6"), - (6, "ipv6"), -]) +@pytest.mark.parametrize( + "ip,expected", + [ + ("10.0.0.0/8", "ip"), + (4, "ip"), + ("2001:db8::/32", "ipv6"), + (6, "ipv6"), + ], +) def test_ip_statement(ip, expected): assert ip_statement(ip) == expected diff --git a/ipmininet/tests/test_radv.py b/ipmininet/tests/test_radv.py index 47341819..73de13e0 100644 --- a/ipmininet/tests/test_radv.py +++ b/ipmininet/tests/test_radv.py @@ -1,4 +1,5 @@ """This module tests the RADVD daemon""" + import pytest from ipmininet.clean import cleanup @@ -7,6 +8,7 @@ from ipmininet.iptopo import IPTopo from ipmininet.router.config import RADVD, AdvPrefix, AdvRDNSS from ipmininet.tests.utils import assert_connectivity + from . import require_root @@ -14,31 +16,30 @@ class CustomRouterAdvNet(IPTopo): def __init__(self, link_params, *args, **kwargs): """:param link_params: Parameters to set on the link between h and r""" self.link_params = link_params - self.link_params.setdefault("params1", {})\ - .setdefault("ip", "2001:1341::1/64") + self.link_params.setdefault("params1", {}).setdefault("ip", "2001:1341::1/64") super().__init__(*args, **kwargs) def build(self, *args, **kwargs): """ - +---+ +---+ +------------+ - | H +-------+ R +-------+ DNS server | - +---+ +---+ +------------+ + +---+ +---+ +------------+ + | H +-------+ R +-------+ DNS server | + +---+ +---+ +------------+ """ - r = self.addRouter('r', use_v4=False, use_v6=True) + r = self.addRouter("r", use_v4=False, use_v6=True) r.addDaemon(RADVD) - h = self.addHost('h') - dns = self.addHost('dns') + h = self.addHost("h") + dns = self.addHost("dns") self.addLink(r, h, **self.link_params) - self.addLink(r, dns, params1={"ip": "2001:89ab::1/64"}, - params2={"ip": "2001:89ab::d/64"}) + self.addLink( + r, dns, params1={"ip": "2001:89ab::1/64"}, params2={"ip": "2001:89ab::d/64"} + ) super().build(*args, **kwargs) @require_root def test_radvd_example(): try: - net = IPNet(topo=RouterAdvNet(), use_v4=False, use_v6=True, - allocate_IPs=False) + net = IPNet(topo=RouterAdvNet(), use_v4=False, use_v6=True, allocate_IPs=False) net.start() assert_connectivity(net, v6=True) net.stop() @@ -47,30 +48,59 @@ def test_radvd_example(): @require_root -@pytest.mark.parametrize("link_params,expected_cfg", [ - ({"params1": {"ra": [AdvPrefix("2001:1341::/64", valid_lifetime=2000, - preferred_lifetime=1000)]}}, - [" prefix 2001:1341::/64", - " AdvValidLifetime 2000;", - " AdvPreferredLifetime 1000;"]), - ({"params1": {"ra": [AdvPrefix("2001:1341::/64")], - "rdnss": [AdvRDNSS("2001:89ab::d", max_lifetime=1000)]}}, - [" RDNSS 2001:89ab::d {", - " AdvRDNSSLifetime 1000; # in seconds (0 means invalid)"]) -]) +@pytest.mark.parametrize( + "link_params,expected_cfg", + [ + ( + { + "params1": { + "ra": [ + AdvPrefix( + "2001:1341::/64", + valid_lifetime=2000, + preferred_lifetime=1000, + ) + ] + } + }, + [ + " prefix 2001:1341::/64", + " AdvValidLifetime 2000;", + " AdvPreferredLifetime 1000;", + ], + ), + ( + { + "params1": { + "ra": [AdvPrefix("2001:1341::/64")], + "rdnss": [AdvRDNSS("2001:89ab::d", max_lifetime=1000)], + } + }, + [ + " RDNSS 2001:89ab::d {", + " AdvRDNSSLifetime 1000; # in seconds (0 means invalid)", + ], + ), + ], +) def test_radvd_daemon_params(link_params, expected_cfg): try: - net = IPNet(topo=CustomRouterAdvNet(link_params), use_v4=False, - use_v6=True, allocate_IPs=False) + net = IPNet( + topo=CustomRouterAdvNet(link_params), + use_v4=False, + use_v6=True, + allocate_IPs=False, + ) net.start() # Check generated configuration with open("/tmp/radvd_r.cfg") as fileobj: cfg = fileobj.readlines() for line in expected_cfg: - assert line + "\n" in cfg,\ - "Cannot find the line '%s' in the generated" \ + assert line + "\n" in cfg, ( + "Cannot find the line '%s' in the generated" " configuration:\n%s" % (line, "".join(cfg)) + ) # Check reachability assert_connectivity(net, v6=True) @@ -83,15 +113,15 @@ def test_radvd_daemon_params(link_params, expected_cfg): @require_root def test_radvd_cleanup(): try: - net = IPNet(topo=RouterAdvNet(), use_v4=False, use_v6=True, - allocate_IPs=False) + net = IPNet(topo=RouterAdvNet(), use_v4=False, use_v6=True, allocate_IPs=False) net.start() net["r"].nconfig.daemon(RADVD).cleanup() try: net["r"].nconfig.daemon(RADVD).cleanup() except Exception as e: - assert False, "An exception '%s' was raised" \ - " while cleaning twice RADVD daemon" % e + assert False, ( + "An exception '%s' was raised while cleaning twice RADVD daemon" % e + ) net.stop() finally: cleanup() diff --git a/ipmininet/tests/test_ripng.py b/ipmininet/tests/test_ripng.py index 05123152..6c079081 100644 --- a/ipmininet/tests/test_ripng.py +++ b/ipmininet/tests/test_ripng.py @@ -1,4 +1,5 @@ """This module tests the RIPng daemon""" + import pytest from ipmininet.clean import cleanup @@ -8,8 +9,8 @@ from ipmininet.iptopo import IPTopo from ipmininet.router.config import RIPng from ipmininet.router.config.base import RouterConfig -from ipmininet.tests.utils import assert_connectivity, assert_path,\ - assert_routing_table +from ipmininet.tests.utils import assert_connectivity, assert_path, assert_routing_table + from . import require_root @@ -56,9 +57,12 @@ def build(self, *args, **kwargs): self.addSubnet(nodes=[r3, h3], subnets=["2042:33::/64"]) if self.is_test_flush: for i in (r1, r2, r3): - i.addDaemon(RIPng, update_timer=self.args_test_2[0], - timeout_timer=self.args_test_2[1], - garbage_timer=self.args_test_2[2]) + i.addDaemon( + RIPng, + update_timer=self.args_test_2[0], + timeout_timer=self.args_test_2[1], + garbage_timer=self.args_test_2[2], + ) else: r1.addDaemon(RIPng, **self.ripng_timers) r2.addDaemon(RIPng, **self.ripng_timers) @@ -67,66 +71,56 @@ def build(self, *args, **kwargs): super().build(*args, **kwargs) def addRouter_v6(self, name): - return self.addRouter(name, use_v4=False, use_v6=True, - config=RouterConfig) + return self.addRouter(name, use_v4=False, use_v6=True, config=RouterConfig) expected_paths = { MinimalRIPngNet.__name__: [ - ['h1', 'r1', 'r3', 'r2', 'h2'], - ['h1', 'r1', 'r3', 'h3'], - ['h2', 'r2', 'r3', 'r1', 'h1'], - ['h2', 'r2', 'r3', 'h3'], - ['h3', 'r3', 'r1', 'h1'], - ['h3', 'r3', 'r2', 'h2'] + ["h1", "r1", "r3", "r2", "h2"], + ["h1", "r1", "r3", "h3"], + ["h2", "r2", "r3", "r1", "h1"], + ["h2", "r2", "r3", "h3"], + ["h3", "r3", "r1", "h1"], + ["h3", "r3", "r2", "h2"], ], RIPngNetwork.__name__: [ - ['h1', 'r1', 'r2', 'r3', 'h3'], - ['h1', 'r1', 'r4', 'h4'], - ['h1', 'r1', 'r4', 'r5', 'h5'], - - ['h3', 'r3', 'r2', 'r1', 'h1'], - ['h3', 'r3', 'r2', 'r5', 'r4', 'h4'], - ['h3', 'r3', 'r2', 'r5', 'h5'], - - ['h4', 'r4', 'r1', 'h1'], - ['h4', 'r4', 'r5', 'r2', 'r3', 'h3'], - ['h4', 'r4', 'r5', 'h5'], - - ['h5', 'r5', 'r4', 'r1', 'h1'], - ['h5', 'r5', 'r2', 'r3', 'h3'], - ['h5', 'r5', 'r4', 'h4'] + ["h1", "r1", "r2", "r3", "h3"], + ["h1", "r1", "r4", "h4"], + ["h1", "r1", "r4", "r5", "h5"], + ["h3", "r3", "r2", "r1", "h1"], + ["h3", "r3", "r2", "r5", "r4", "h4"], + ["h3", "r3", "r2", "r5", "h5"], + ["h4", "r4", "r1", "h1"], + ["h4", "r4", "r5", "r2", "r3", "h3"], + ["h4", "r4", "r5", "h5"], + ["h5", "r5", "r4", "r1", "h1"], + ["h5", "r5", "r2", "r3", "h3"], + ["h5", "r5", "r4", "h4"], ], RIPngNetworkAdjust.__name__: [ - ['h1', 'r1', 'r3', 'h3'], - ['h1', 'r1', 'r5', 'h5'], - - ['h3', 'r3', 'r1', 'h1'], - ['h3', 'r3', 'r2', 'r4', 'h4'], - - ['h4', 'r4', 'r2', 'r3', 'h3'], - ['h4', 'r4', 'r5', 'h5'], - - ['h5', 'r5', 'r1', 'h1'], - ['h5', 'r5', 'r4', 'h4'] + ["h1", "r1", "r3", "h3"], + ["h1", "r1", "r5", "h5"], + ["h3", "r3", "r1", "h1"], + ["h3", "r3", "r2", "r4", "h4"], + ["h4", "r4", "r2", "r3", "h3"], + ["h4", "r4", "r5", "h5"], + ["h5", "r5", "r1", "h1"], + ["h5", "r5", "r4", "h4"], ], "RIPngNetworkAdjust-mod": [ - ['h1', 'r1', 'r3', 'h3'], - ['h1', 'r1', 'r2', 'r4', 'h4'], - ['h1', 'r1', 'r2', 'r5', 'h5'], - - ['h3', 'r3', 'r1', 'h1'], - ['h3', 'r3', 'r2', 'r4', 'h4'], - ['h3', 'r3', 'r2', 'r5', 'h5'], - - ['h4', 'r4', 'r2', 'r3', 'h3'], - ['h4', 'r4', 'r2', 'r3', 'h3'], - ['h4', 'r4', 'r5', 'h5'], - - ['h5', 'r5', 'r2', 'r1', 'h1'], - ['h5', 'r5', 'r2', 'r3', 'h3'], - ['h5', 'r5', 'r4', 'h4'] - ] + ["h1", "r1", "r3", "h3"], + ["h1", "r1", "r2", "r4", "h4"], + ["h1", "r1", "r2", "r5", "h5"], + ["h3", "r3", "r1", "h1"], + ["h3", "r3", "r2", "r4", "h4"], + ["h3", "r3", "r2", "r5", "h5"], + ["h4", "r4", "r2", "r3", "h3"], + ["h4", "r4", "r2", "r3", "h3"], + ["h4", "r4", "r5", "h5"], + ["h5", "r5", "r2", "r1", "h1"], + ["h5", "r5", "r2", "r3", "h3"], + ["h5", "r5", "r4", "h4"], + ], } @@ -137,11 +131,11 @@ def addRouter_v6(self, name): @require_root -@pytest.mark.parametrize("topo", [ - MinimalRIPngNet, - RIPngNetwork, - RIPngNetworkAdjust -], ids=lambda v: v.__name__) +@pytest.mark.parametrize( + "topo", + [MinimalRIPngNet, RIPngNetwork, RIPngNetworkAdjust], + ids=lambda v: v.__name__, +) def test_ripng_examples(topo): try: net = IPNet(topo=topo(ripng_timers=RIPNG_FAST_TIMERS)) @@ -158,8 +152,9 @@ def test_ripng_examples(topo): @require_root def test_ripng_adjust(): try: - net = IPNet(topo=RIPngNetworkAdjust(lr1r5_cost=5, - ripng_timers=RIPNG_FAST_TIMERS)) + net = IPNet( + topo=RIPngNetworkAdjust(lr1r5_cost=5, ripng_timers=RIPNG_FAST_TIMERS) + ) net.start() assert_connectivity(net, v6=True) for path in expected_paths["RIPngNetworkAdjust-mod"]: @@ -179,7 +174,7 @@ def test_ripng_flush_routing_tables(): routing_tables = { "r1": ["2042:22::/64", "2042:33::/64", "2042:23::/64"], "r2": ["2042:11::/64", "2042:33::/64", "2042:13::/64"], - "r3": ["2042:11::/64", "2042:22::/64", "2042:12::/64"] + "r3": ["2042:11::/64", "2042:22::/64", "2042:12::/64"], } for router, expected_ipv6 in routing_tables.items(): assert_routing_table(net[router], expected_ipv6, present=False) diff --git a/ipmininet/tests/test_srv6.py b/ipmininet/tests/test_srv6.py index d223d75b..b80040da 100644 --- a/ipmininet/tests/test_srv6.py +++ b/ipmininet/tests/test_srv6.py @@ -4,16 +4,22 @@ import signal import time import traceback -from typing import Dict, List, Tuple import pytest from ipmininet.clean import cleanup from ipmininet.examples.srv6 import SRv6Topo from ipmininet.ipnet import IPNet -from ipmininet.srv6 import LocalSIDTable, SRv6EndFunction, SRv6EndXFunction, \ - SRv6EndTFunction, SRv6EndDX6Function, SRv6Encap, SRv6EndDT6Function, \ - SRv6EndB6EncapsFunction +from ipmininet.srv6 import ( + LocalSIDTable, + SRv6Encap, + SRv6EndB6EncapsFunction, + SRv6EndDT6Function, + SRv6EndDX6Function, + SRv6EndFunction, + SRv6EndTFunction, + SRv6EndXFunction, +) from ipmininet.tests import require_root from ipmininet.tests.utils import assert_connectivity, assert_path, wait_until from ipmininet.utils import require_cmd @@ -22,8 +28,7 @@ class SRv6TestTopo(SRv6Topo): - - def __init__(self, new_routes: Dict[str, Tuple], *args, **kwargs): + def __init__(self, new_routes: dict[str, tuple], *args, **kwargs): """ :param new_routes: A dictionary mapping the host name to the router name to a list of tuples (SRv6Route class, params). @@ -31,10 +36,10 @@ def __init__(self, new_routes: Dict[str, Tuple], *args, **kwargs): parameter except the node and the network. """ self.new_routes = new_routes - super(SRv6TestTopo, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def post_build(self, net): - super(SRv6TestTopo, self).post_build(net) + super().post_build(net) for r in self.new_routes.keys(): route_class, route_params = self.new_routes[r] @@ -42,8 +47,7 @@ def post_build(self, net): if issubclass(route_class, SRv6EndFunction): if r not in self.tables: r_segment_space = next(net[r].intf("lo").ip6s()).network - self.tables[r] = LocalSIDTable(net[r], - matching=[r_segment_space]) + self.tables[r] = LocalSIDTable(net[r], matching=[r_segment_space]) # We use the local SID table route_params["table"] = self.tables[r] try: @@ -68,7 +72,8 @@ def _tshark_capturing(tsharks: list, stderr_buf: dict) -> bool: ready, _, _ = select.select([p.stderr], [], [], 0) if ready: stderr_buf[p] = stderr_buf.get(p, "") + os.read( - p.stderr.fileno(), 4096).decode(errors="ignore") + p.stderr.fileno(), 4096 + ).decode(errors="ignore") except OSError: return False if "Capturing on" not in stderr_buf.get(p, ""): @@ -76,8 +81,9 @@ def _tshark_capturing(tsharks: list, stderr_buf: dict) -> bool: return True -def _infer_sub_paths(packet_received: dict[str, list[tuple[float, str]]]) \ - -> dict[str, list[str]]: +def _infer_sub_paths( + packet_received: dict[str, list[tuple[float, str]]], +) -> dict[str, list[str]]: """Return, for each observed destination, the ordered nodes on its path. The measurement ping is spread over a burst of probes because tshark only @@ -101,8 +107,9 @@ def _infer_sub_paths(packet_received: dict[str, list[tuple[float, str]]]) \ else: probes.append([(capture_time, node)]) node_count = len({node for _, node in ordered}) - complete = [group for group in probes - if len({node for _, node in group}) == node_count] + complete = [ + group for group in probes if len({node for _, node in group}) == node_count + ] if complete: chosen = max(complete, key=lambda group: group[-1][0]) nodes = [] # type: List[str] @@ -116,13 +123,13 @@ def _infer_sub_paths(packet_received: dict[str, list[tuple[float, str]]]) \ latest = {} # type: Dict[str, float] for capture_time, node in ordered: latest[node] = max(latest.get(node, 0.0), capture_time) - sub_paths[dest] = [node for node, _ in - sorted(latest.items(), key=lambda kv: kv[1])] + sub_paths[dest] = [ + node for node, _ in sorted(latest.items(), key=lambda kv: kv[1]) + ] return sub_paths -def sr_path(net: IPNet, src: str, dst_ip: str, timeout=1, through=()) \ - -> List[str]: +def sr_path(net: IPNet, src: str, dst_ip: str, timeout=1, through=()) -> list[str]: require_cmd("tshark", help_str="tshark is required to run tests") require_cmd("nmap", help_str="nmap is required to run tests") @@ -138,20 +145,22 @@ def sr_path(net: IPNet, src: str, dst_ip: str, timeout=1, through=()) \ nodes = net.routers + net.hosts try: for n in nodes: - p = n.popen(shlex.split("tshark -n -i any -f 'ip6'" - " -w /tmp/{}.pcap".format(n.name))) + p = n.popen(shlex.split(f"tshark -n -i any -f 'ip6' -w /tmp/{n.name}.pcap")) tsharks.append(p) # Wait for tshark to announce it started capturing. A freshly started # tshark only becomes functionally live a moment *after* printing this # message, so we then wait for each capture to record packets. - wait_until(lambda: _tshark_capturing(tsharks, stderr_buf), - timeout=30, interval=0.2, - description="tshark to start capturing on every node") + wait_until( + lambda: _tshark_capturing(tsharks, stderr_buf), + timeout=30, + interval=0.2, + description="tshark to start capturing on every node", + ) probe_cmd = f"ping -6 -c 1 -W {int(timeout)} {dst_ip}" def _pcap_size(node): - path = "/tmp/{}.pcap".format(node.name) + path = f"/tmp/{node.name}.pcap" return os.path.getsize(path) if os.path.isfile(path) else 0 # Phase 1: a capture is live once its file grew past the header @@ -159,12 +168,15 @@ def _pcap_size(node): # announcement alone is not a reliable signal under load. baseline = {n.name: _pcap_size(n) for n in nodes} deadline = time.monotonic() + 20 - while not all(_pcap_size(n) > baseline[n.name] for n in nodes) \ - and time.monotonic() < deadline: + while ( + not all(_pcap_size(n) > baseline[n.name] for n in nodes) + and time.monotonic() < deadline + ): out = net[src].cmd(shlex.split(probe_cmd)) - assert "100% packet loss" not in out, \ - f"Connectivity from {src} to {dst_ip} is not ensured, " \ + assert "100% packet loss" not in out, ( + f"Connectivity from {src} to {dst_ip} is not ensured, " "so we cannot infer the path." + ) time.sleep(0.5) # Phase 2: every capture is live; send the single measurement probe. @@ -173,13 +185,15 @@ def _pcap_size(node): # was captured, and the read-back assert below is the confirmation # that the measurement actually landed on the path. out = net[src].cmd(shlex.split(probe_cmd)) - assert "100% packet loss" not in out, \ - f"Connectivity from {src} to {dst_ip} is not ensured, " \ + assert "100% packet loss" not in out, ( + f"Connectivity from {src} to {dst_ip} is not ensured, " "so we cannot infer the path." + ) for p in tsharks: - assert p.poll() is None, "tshark stopped unexpectedly:" \ - "stderr '{}'".format(p.stderr.read()) + assert p.poll() is None, ( + f"tshark stopped unexpectedly:stderr '{p.stderr.read()}'" + ) finally: # Stop captures; SIGINT makes tshark flush the captured packets to disk for p in tsharks: @@ -189,10 +203,13 @@ def _pcap_size(node): # Retrieve packet captures captures = {} # type: Dict[str, List[Tuple[float, str]]] for n in net.routers + net.hosts: - out = n.cmd(shlex.split("tshark -n -r /tmp/{}.pcap -T fields " - "-E separator=, " - "-e icmpv6.type -e ipv6.dst -e frame.time_epoch" - .format(n.name))) + out = n.cmd( + shlex.split( + f"tshark -n -r /tmp/{n.name}.pcap -T fields " + "-E separator=, " + "-e icmpv6.type -e ipv6.dst -e frame.time_epoch" + ) + ) data = out.split("\n")[1:-1] for line in data: values = line.strip().split(",") @@ -202,16 +219,15 @@ def _pcap_size(node): data_dst = values[1] data_time = values[-1] if icmp_type == "128": - captures.setdefault(n.name, []) \ - .append((float(data_time), data_dst)) + captures.setdefault(n.name, []).append((float(data_time), data_dst)) # Verify the measurement actually landed in the captures: this is the # notification that tshark was capturing when the probes were sent. - captured_dests = {dest for _, packets in captures.items() - for _, dest in packets} - assert dst_ip in captured_dests, \ - f"No capture recorded the ping to {dst_ip} (captured destinations: " \ + captured_dests = {dest for _, packets in captures.items() for _, dest in packets} + assert dst_ip in captured_dests, ( + f"No capture recorded the ping to {dst_ip} (captured destinations: " f"{sorted(captured_dests)}). tshark was probably not capturing yet." + ) # Analyze results @@ -228,9 +244,11 @@ def _pcap_size(node): found = False try: - intermediate_ips = [ip.ip.compressed - for itf in net[intermediate].intfList() - for ip in itf.ip6s(exclude_lls=True)] + intermediate_ips = [ + ip.ip.compressed + for itf in net[intermediate].intfList() + for ip in itf.ip6s(exclude_lls=True) + ] except KeyError: intermediate_ips = [intermediate] @@ -255,7 +273,7 @@ def _pcap_size(node): # Clean up the per-node captures so no stale file lingers between the # parametrized cases of this module. for n in net.routers + net.hosts: - pcap_path = "/tmp/{}.pcap".format(n.name) + pcap_path = f"/tmp/{n.name}.pcap" if os.path.isfile(pcap_path): os.unlink(pcap_path) @@ -263,89 +281,148 @@ def _pcap_size(node): @require_root -@pytest.mark.parametrize("routes,paths,through", [ - ({}, - [["h6", "r6", "r5", "r4", "h4"], - ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"]], - [[], - ["r6", "r5", "2042:3:3::34", "r4"]]), # Intermediate destinations - ( - { - "h6": (SRv6Encap, {"to": "h4", "through": ["2042:6:6::600"], - "mode": SRv6Encap.INLINE}), - "r6": (SRv6EndFunction, {"to": "2042:6:6::600"}) - }, - [["h6", "r6", "r5", "r4", "h4"], - ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"]], - [["2042:6:6::600"], - ["r6", "r5", "2042:3:3::34", "r4"]] - ), - ( - { - "h6": (SRv6Encap, {"to": "h4", "through": ["2042:5:5::500"], - "mode": SRv6Encap.INLINE}), - "r5": (SRv6EndXFunction, {"to": "2042:5:5::500", - "nexthop": "2042:2:2::1"}) - }, - [["h6", "r6", "r5", "r2", "r5", "r4", "h4"], - ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"]], - [["2042:5:5::500"], - ["r6", "r5", "2042:3:3::34", "r4"]] - ), - ( - { - "h6": (SRv6Encap, {"to": "h4", - "through": ["2042:5:5::501", "2042:2:2::1"], - "mode": SRv6Encap.INLINE}), - "r5": (SRv6EndTFunction, {"to": "2042:5:5::501", - "lookup_table": MAIN_TABLE}) - }, - [["h6", "r6", "r5", "r2", "r5", "r4", "h4"], - ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"]], - [["2042:5:5::501", "2042:2:2::1"], - ["r6", "r5", "2042:3:3::34", "r4"]] - ), - ( - { - "h6": (SRv6Encap, {"to": "h4", "through": ["2042:5:5::500"], - "mode": SRv6Encap.ENCAP}), - "r5": (SRv6EndDX6Function, {"to": "2042:5:5::500", - "nexthop": "2042:2:2::1"}) - }, - [["h6", "r6", "r5", "r2", "r5", "r4", "h4"], - ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"]], - [["2042:5:5::500"], - ["r6", "r5", "2042:3:3::34", "r4"]] - ), - ( - { - "h6": (SRv6Encap, {"to": "h4", "through": ["2042:5:5::501", - "2042:4:4::1"], - "mode": SRv6Encap.INLINE}), - "r5": (SRv6EndB6EncapsFunction, {"to": "2042:5:5::501", - "segments": ["2042:2:2::1"]}) - }, - [["h6", "r6", "r5", "r2", "r5", "r4", "h4"], - ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"]], - [["2042:5:5::501", "2042:2:2::1", "2042:4:4::1"], - ["r6", "r5", "2042:3:3::34", "r4"]] - ), - ( - { - "h6": (SRv6Encap, {"to": "h4", - "through": ["2042:5:5::501", "2042:4:4::1"], - "mode": SRv6Encap.INLINE}), - "r5": (SRv6EndB6EncapsFunction, {"to": "2042:5:5::501", - "segments": ["2042:2:2::200"]}), - "r2": (SRv6EndDT6Function, {"to": "2042:2:2::200", - "lookup_table": MAIN_TABLE}) - }, - [["h6", "r6", "r5", "r2", "r5", "r4", "h4"], - ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"]], - [["2042:5:5::501", "2042:2:2::200", "2042:4:4::1"], - ["r6", "r5", "2042:3:3::34", "r4"]] - ) -]) +@pytest.mark.parametrize( + "routes,paths,through", + [ + ( + {}, + [ + ["h6", "r6", "r5", "r4", "h4"], + ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"], + ], + [[], ["r6", "r5", "2042:3:3::34", "r4"]], + ), # Intermediate destinations + ( + { + "h6": ( + SRv6Encap, + { + "to": "h4", + "through": ["2042:6:6::600"], + "mode": SRv6Encap.INLINE, + }, + ), + "r6": (SRv6EndFunction, {"to": "2042:6:6::600"}), + }, + [ + ["h6", "r6", "r5", "r4", "h4"], + ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"], + ], + [["2042:6:6::600"], ["r6", "r5", "2042:3:3::34", "r4"]], + ), + ( + { + "h6": ( + SRv6Encap, + { + "to": "h4", + "through": ["2042:5:5::500"], + "mode": SRv6Encap.INLINE, + }, + ), + "r5": ( + SRv6EndXFunction, + {"to": "2042:5:5::500", "nexthop": "2042:2:2::1"}, + ), + }, + [ + ["h6", "r6", "r5", "r2", "r5", "r4", "h4"], + ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"], + ], + [["2042:5:5::500"], ["r6", "r5", "2042:3:3::34", "r4"]], + ), + ( + { + "h6": ( + SRv6Encap, + { + "to": "h4", + "through": ["2042:5:5::501", "2042:2:2::1"], + "mode": SRv6Encap.INLINE, + }, + ), + "r5": ( + SRv6EndTFunction, + {"to": "2042:5:5::501", "lookup_table": MAIN_TABLE}, + ), + }, + [ + ["h6", "r6", "r5", "r2", "r5", "r4", "h4"], + ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"], + ], + [["2042:5:5::501", "2042:2:2::1"], ["r6", "r5", "2042:3:3::34", "r4"]], + ), + ( + { + "h6": ( + SRv6Encap, + {"to": "h4", "through": ["2042:5:5::500"], "mode": SRv6Encap.ENCAP}, + ), + "r5": ( + SRv6EndDX6Function, + {"to": "2042:5:5::500", "nexthop": "2042:2:2::1"}, + ), + }, + [ + ["h6", "r6", "r5", "r2", "r5", "r4", "h4"], + ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"], + ], + [["2042:5:5::500"], ["r6", "r5", "2042:3:3::34", "r4"]], + ), + ( + { + "h6": ( + SRv6Encap, + { + "to": "h4", + "through": ["2042:5:5::501", "2042:4:4::1"], + "mode": SRv6Encap.INLINE, + }, + ), + "r5": ( + SRv6EndB6EncapsFunction, + {"to": "2042:5:5::501", "segments": ["2042:2:2::1"]}, + ), + }, + [ + ["h6", "r6", "r5", "r2", "r5", "r4", "h4"], + ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"], + ], + [ + ["2042:5:5::501", "2042:2:2::1", "2042:4:4::1"], + ["r6", "r5", "2042:3:3::34", "r4"], + ], + ), + ( + { + "h6": ( + SRv6Encap, + { + "to": "h4", + "through": ["2042:5:5::501", "2042:4:4::1"], + "mode": SRv6Encap.INLINE, + }, + ), + "r5": ( + SRv6EndB6EncapsFunction, + {"to": "2042:5:5::501", "segments": ["2042:2:2::200"]}, + ), + "r2": ( + SRv6EndDT6Function, + {"to": "2042:2:2::200", "lookup_table": MAIN_TABLE}, + ), + }, + [ + ["h6", "r6", "r5", "r2", "r5", "r4", "h4"], + ["h1", "r1", "r6", "r5", "r2", "r3", "r4", "h4"], + ], + [ + ["2042:5:5::501", "2042:2:2::200", "2042:4:4::1"], + ["r6", "r5", "2042:3:3::34", "r4"], + ], + ), + ], +) def test_static_examples(routes, paths, through): try: topo = SRv6TestTopo(new_routes=routes) @@ -354,8 +431,15 @@ def test_static_examples(routes, paths, through): assert_connectivity(net, v6=False) for i, p in enumerate(paths): - assert_path(net, p, v6=True, traceroute_fun=sr_path, - through=through[i], timeout=1, retry=30) + assert_path( + net, + p, + v6=True, + traceroute_fun=sr_path, + through=through[i], + timeout=1, + retry=30, + ) topo.clean() net.stop() diff --git a/ipmininet/tests/test_sshd.py b/ipmininet/tests/test_sshd.py index 55858e14..b1839a96 100644 --- a/ipmininet/tests/test_sshd.py +++ b/ipmininet/tests/test_sshd.py @@ -1,9 +1,11 @@ """This module tests the SSH daemon""" + import os from ipmininet.clean import cleanup from ipmininet.examples.sshd import SSHTopo from ipmininet.ipnet import IPNet + from . import require_root from .utils import wait_until @@ -19,17 +21,20 @@ def test_sshd_example(): for line in fileobj: if "AuthorizedKeysFile" in line: ssh_key = line.split(" ")[1].split(".")[0] - assert ssh_key is not None,\ - "No authorized SSH key found in the configuration" + assert ssh_key is not None, "No authorized SSH key found in the configuration" assert os.path.isfile(ssh_key), "Cannot find key file at %s" % ssh_key ip = net["r2"].intf("r2-eth0").ip - cmd = "ssh -oStrictHostKeyChecking=no -oConnectTimeout=1" \ - " -oPasswordAuthentication=no -i %s %s ls" % (ssh_key, ip) + cmd = ( + "ssh -oStrictHostKeyChecking=no -oConnectTimeout=1" + " -oPasswordAuthentication=no -i %s %s ls" % (ssh_key, ip) + ) wait_until( lambda: net["r1"].popen(cmd.split(" ")).wait() == 0, - timeout=60, interval=0.5, - description="SSH from %s to %s to succeed" % (net["r1"], ip)) + timeout=60, + interval=0.5, + description="SSH from %s to %s to succeed" % (net["r1"], ip), + ) net.stop() finally: diff --git a/ipmininet/tests/test_static.py b/ipmininet/tests/test_static.py index 9aba5dd7..d60cd120 100644 --- a/ipmininet/tests/test_static.py +++ b/ipmininet/tests/test_static.py @@ -1,18 +1,17 @@ """This module tests the static address and route allocations""" + import pytest from ipmininet.clean import cleanup -from ipmininet.examples.partial_static_address_network import \ - PartialStaticAddressNet +from ipmininet.examples.partial_static_address_network import PartialStaticAddressNet from ipmininet.examples.static_address_network import StaticAddressNet from ipmininet.examples.static_routing import StaticRoutingNet from ipmininet.examples.static_routing_failure import StaticRoutingNetFailure -from ipmininet.examples.static_routing_network_basic import \ - StaticRoutingNetBasic -from ipmininet.examples.static_routing_network_complex import \ - StaticRoutingNetComplex +from ipmininet.examples.static_routing_network_basic import StaticRoutingNetBasic +from ipmininet.examples.static_routing_network_complex import StaticRoutingNetComplex from ipmininet.ipnet import IPNet from ipmininet.tests.utils import assert_connectivity, assert_path + from . import require_root @@ -89,58 +88,49 @@ def test_partial_static_example(): static_paths = { - - StaticRoutingNet.__name__: [ - ["h1", "r1", "r2", "h2"], - ["h2", "r2", "r1", "h1"] - ], - StaticRoutingNetFailure.__name__: [ - ["h4", "r4", "r3", "h3"] - ], + StaticRoutingNet.__name__: [["h1", "r1", "r2", "h2"], ["h2", "r2", "r1", "h1"]], + StaticRoutingNetFailure.__name__: [["h4", "r4", "r3", "h3"]], StaticRoutingNetBasic.__name__: [ ["h1", "r1", "r2", "h2"], ["h1", "r1", "r3", "h3"], ["h1", "r1", "r3", "r4", "h4"], - ["h2", "r2", "r1", "h1"], ["h2", "r2", "r4", "r1", "r3", "h3"], ["h2", "r2", "r4", "h4"], - ["h3", "r3", "r4", "r1", "h1"], ["h3", "r3", "r4", "r2", "h2"], ["h3", "r3", "r4", "h4"], - ["h4", "r4", "r1", "h1"], ["h4", "r4", "r2", "h2"], - ["h4", "r4", "r1", "r3", "h3"] + ["h4", "r4", "r1", "r3", "h3"], ], StaticRoutingNetComplex.__name__: [ ["h1", "r1", "r2", "r5", "r4", "r3", "h3"], ["h1", "r1", "r2", "r5", "r4", "h4"], ["h1", "r1", "r2", "r5", "r6", "h6"], - ["h3", "r3", "r2", "r1", "h1"], ["h3", "r3", "r2", "r5", "r4", "h4"], ["h3", "r3", "r2", "r5", "r6", "h6"], - ["h4", "r4", "r3", "r2", "r1", "h1"], ["h4", "r4", "r3", "h3"], ["h4", "r4", "r3", "r2", "r5", "r6", "h6"], - ["h6", "r6", "r1", "h1"], ["h6", "r6", "r5", "r4", "r3", "h3"], ["h6", "r6", "r5", "r4", "h4"], - ] + ], } @require_root -@pytest.mark.parametrize("topo,connected,v4,v6", [ - (StaticRoutingNet, True, True, True), - (StaticRoutingNetFailure, False, False, True), - (StaticRoutingNetBasic, True, False, True), - (StaticRoutingNetComplex, True, False, True), -]) +@pytest.mark.parametrize( + "topo,connected,v4,v6", + [ + (StaticRoutingNet, True, True, True), + (StaticRoutingNetFailure, False, False, True), + (StaticRoutingNetBasic, True, False, True), + (StaticRoutingNetComplex, True, False, True), + ], +) def test_static_examples(topo, connected, v4, v6): try: net = IPNet(topo=topo()) diff --git a/ipmininet/tests/test_switch.py b/ipmininet/tests/test_switch.py index 5bddb2f6..47cf3ca7 100644 --- a/ipmininet/tests/test_switch.py +++ b/ipmininet/tests/test_switch.py @@ -1,48 +1,47 @@ """This module tests the switches, hubs and STP""" import functools + import pytest from ipmininet.clean import cleanup from ipmininet.examples.spanning_tree import SpanningTreeNet from ipmininet.examples.spanning_tree_adjust import SpanningTreeAdjust from ipmininet.examples.spanning_tree_bus import SpanningTreeBus +from ipmininet.examples.spanning_tree_cost import SpanningTreeCost from ipmininet.examples.spanning_tree_full_mesh import SpanningTreeFullMesh from ipmininet.examples.spanning_tree_hub import SpanningTreeHub -from ipmininet.examples.spanning_tree_intermediate import \ - SpanningTreeIntermediate -from ipmininet.examples.spanning_tree_cost import SpanningTreeCost +from ipmininet.examples.spanning_tree_intermediate import SpanningTreeIntermediate from ipmininet.ipnet import IPNet -from ipmininet.iptopo import IPTopo from ipmininet.ipswitch import IPSwitch +from ipmininet.iptopo import IPTopo from ipmininet.tests import require_root -from ipmininet.tests.utils import assert_stp_state, assert_connectivity +from ipmininet.tests.utils import assert_connectivity, assert_stp_state # Accelerate STP convergence (kernel forward delay is 15s by default). FastSTPSwitch = functools.partial(IPSwitch, stp_forward_delay=4) class SimpleSpanningTree(IPTopo): - def build(self, *args, **kwargs): """ - +-----+s2.2 s3.2+-----+ - | s2 +-----1-----+ s3 | - +--+--+ +--+--+ - s2.1| s3.1/ |s3.3 - | / | - | / | - | / | - | / | - 4 1 1 - | / | - | / | - | / | - | / | - s1.1| /s1.3 |s4.2 - +--+--+ +--+--+ - | s1 +-----5-----+ s4 | - +-----+s1.2 s4.1+-----+ + +-----+s2.2 s3.2+-----+ + | s2 +-----1-----+ s3 | + +--+--+ +--+--+ + s2.1| s3.1/ |s3.3 + | / | + | / | + | / | + | / | + 4 1 1 + | / | + | / | + | / | + | / | + s1.1| /s1.3 |s4.2 + +--+--+ +--+--+ + | s1 +-----5-----+ s4 | + +-----+s1.2 s4.1+-----+ """ # adding switches s1 = self.addSwitch("s1", prio=1) @@ -58,108 +57,168 @@ def build(self, *args, **kwargs): self.addLink(s3, s4) for s in self.switches(): - self.addLink(s, self.addHost('h%s' % s)) + self.addLink(s, self.addHost("h%s" % s)) super().build(*args, **kwargs) expected_states = { SimpleSpanningTree.__name__: { - "s1": {"s1-eth1": "forwarding", "s1-eth2": "forwarding", - "s1-eth3": "forwarding"}, + "s1": { + "s1-eth1": "forwarding", + "s1-eth2": "forwarding", + "s1-eth3": "forwarding", + }, "s2": {"s2-eth1": "blocking", "s2-eth2": "forwarding"}, - "s3": {"s3-eth1": "forwarding", "s3-eth2": "forwarding", - "s3-eth3": "forwarding"}, - "s4": {"s4-eth1": "blocking", "s4-eth2": "forwarding"} + "s3": { + "s3-eth1": "forwarding", + "s3-eth2": "forwarding", + "s3-eth3": "forwarding", + }, + "s4": {"s4-eth1": "blocking", "s4-eth2": "forwarding"}, }, SpanningTreeNet.__name__: { "s1": {"s1-eth1": "blocking", "s1-eth2": "forwarding"}, "s2": {"s2-eth1": "forwarding", "s2-eth2": "forwarding"}, - "s3": {"s3-eth1": "forwarding", "s3-eth2": "forwarding"} + "s3": {"s3-eth1": "forwarding", "s3-eth2": "forwarding"}, }, SpanningTreeAdjust.__name__: { - "s1": {"s1-eth1": "forwarding", "s1-eth2": "forwarding", - "s1-eth3": "forwarding", "s1-eth4": "forwarding"}, + "s1": { + "s1-eth1": "forwarding", + "s1-eth2": "forwarding", + "s1-eth3": "forwarding", + "s1-eth4": "forwarding", + }, "s2": {"s2-eth1": "forwarding", "s2-eth2": "blocking"}, - "s3": {"s3-eth1": "forwarding", "s3-eth2": "forwarding", - "s3-eth3": "forwarding", "s3-eth4": "forwarding"}, + "s3": { + "s3-eth1": "forwarding", + "s3-eth2": "forwarding", + "s3-eth3": "forwarding", + "s3-eth4": "forwarding", + }, "s4": {"s4-eth1": "forwarding", "s4-eth2": "blocking"}, - "s5": {"s5-eth1": "forwarding", "s5-eth2": "blocking", - "s5-eth3": "forwarding", "s5-eth4": "forwarding"}, - "s6": {"s6-eth1": "forwarding", "s6-eth2": "blocking"} + "s5": { + "s5-eth1": "forwarding", + "s5-eth2": "blocking", + "s5-eth3": "forwarding", + "s5-eth4": "forwarding", + }, + "s6": {"s6-eth1": "forwarding", "s6-eth2": "blocking"}, }, "SpanningTreeAdjust-mod": { - "s1": {"s1-eth1": "forwarding", "s1-eth2": "forwarding", - "s1-eth3": "forwarding", "s1-eth4": "forwarding"}, + "s1": { + "s1-eth1": "forwarding", + "s1-eth2": "forwarding", + "s1-eth3": "forwarding", + "s1-eth4": "forwarding", + }, "s2": {"s2-eth1": "blocking", "s2-eth2": "forwarding"}, - "s3": {"s3-eth1": "blocking", "s3-eth2": "forwarding", - "s3-eth3": "forwarding", "s3-eth4": "forwarding"}, + "s3": { + "s3-eth1": "blocking", + "s3-eth2": "forwarding", + "s3-eth3": "forwarding", + "s3-eth4": "forwarding", + }, "s4": {"s4-eth1": "blocking", "s4-eth2": "forwarding"}, - "s5": {"s5-eth1": "forwarding", "s5-eth2": "forwarding", - "s5-eth3": "forwarding", "s5-eth4": "forwarding"}, - "s6": {"s6-eth1": "blocking", "s6-eth2": "forwarding"} + "s5": { + "s5-eth1": "forwarding", + "s5-eth2": "forwarding", + "s5-eth3": "forwarding", + "s5-eth4": "forwarding", + }, + "s6": {"s6-eth1": "blocking", "s6-eth2": "forwarding"}, }, SpanningTreeBus.__name__: { "s1": {"s1-eth1": "forwarding"}, "s2": {"s2-eth1": "forwarding"}, - "s3": {"s3-eth1": "forwarding"} + "s3": {"s3-eth1": "forwarding"}, }, SpanningTreeFullMesh.__name__: { - "s1": {"s1-eth1": "forwarding", "s1-eth2": "forwarding", - "s1-eth3": "forwarding", - "s1-eth4": "forwarding", "s1-eth5": "forwarding"}, - "s2": {"s2-eth1": "forwarding", "s2-eth2": "forwarding", - "s2-eth3": "forwarding", - "s2-eth4": "forwarding", "s2-eth5": "forwarding"}, - "s3": {"s3-eth1": "forwarding", "s3-eth2": "blocking", - "s3-eth3": "forwarding", - "s3-eth4": "forwarding", "s3-eth5": "forwarding"}, - "s4": {"s4-eth1": "forwarding", "s4-eth2": "blocking", - "s4-eth3": "blocking", - "s4-eth4": "forwarding", "s4-eth5": "forwarding"}, + "s1": { + "s1-eth1": "forwarding", + "s1-eth2": "forwarding", + "s1-eth3": "forwarding", + "s1-eth4": "forwarding", + "s1-eth5": "forwarding", + }, + "s2": { + "s2-eth1": "forwarding", + "s2-eth2": "forwarding", + "s2-eth3": "forwarding", + "s2-eth4": "forwarding", + "s2-eth5": "forwarding", + }, + "s3": { + "s3-eth1": "forwarding", + "s3-eth2": "blocking", + "s3-eth3": "forwarding", + "s3-eth4": "forwarding", + "s3-eth5": "forwarding", + }, + "s4": { + "s4-eth1": "forwarding", + "s4-eth2": "blocking", + "s4-eth3": "blocking", + "s4-eth4": "forwarding", + "s4-eth5": "forwarding", + }, "s10": {"s10-eth1": "forwarding", "s10-eth2": "blocking"}, "s11": {"s11-eth1": "forwarding", "s11-eth2": "blocking"}, "s12": {"s12-eth1": "forwarding", "s12-eth2": "blocking"}, - "s17": {"s17-eth1": "forwarding", "s17-eth2": "blocking"} + "s17": {"s17-eth1": "forwarding", "s17-eth2": "blocking"}, }, SpanningTreeHub.__name__: { - "s3": {"s3-eth1": "forwarding", "s3-eth2": "forwarding", - "s3-eth3": "forwarding"}, - "s6": {"s6-eth1": "forwarding", "s6-eth2": "forwarding", - "s6-eth3": "forwarding"}, + "s3": { + "s3-eth1": "forwarding", + "s3-eth2": "forwarding", + "s3-eth3": "forwarding", + }, + "s6": { + "s6-eth1": "forwarding", + "s6-eth2": "forwarding", + "s6-eth3": "forwarding", + }, "s10": {"s10-eth1": "forwarding", "s10-eth2": "forwarding"}, - "s11": {"s11-eth1": "blocking", "s11-eth2": "forwarding", - "s11-eth3": "blocking"}, + "s11": { + "s11-eth1": "blocking", + "s11-eth2": "forwarding", + "s11-eth3": "blocking", + }, "s12": {"s12-eth1": "forwarding", "s12-eth2": "blocking"}, - "s17": {"s17-eth1": "forwarding", "s17-eth2": "forwarding"} + "s17": {"s17-eth1": "forwarding", "s17-eth2": "forwarding"}, }, SpanningTreeIntermediate.__name__: { "s2": {"s2-eth1": "forwarding", "s2-eth2": "forwarding"}, "s4": {"s4-eth1": "forwarding", "s4-eth2": "forwarding"}, - "s5": {"s5-eth1": "forwarding", "s5-eth2": "blocking", - "s5-eth3": "blocking"}, - "s10": {"s10-eth1": "forwarding", "s10-eth2": "forwarding", - "s10-eth3": "forwarding"} + "s5": {"s5-eth1": "forwarding", "s5-eth2": "blocking", "s5-eth3": "blocking"}, + "s10": { + "s10-eth1": "forwarding", + "s10-eth2": "forwarding", + "s10-eth3": "forwarding", + }, }, SpanningTreeCost.__name__: { "s1": {"s1-eth1": "forwarding", "s1-eth2": "forwarding"}, "s2": {"s2-eth1": "forwarding", "s2-eth2": "forwarding"}, - "s3": {"s3-eth1": "forwarding", "s3-eth2": "blocking"} - } + "s3": {"s3-eth1": "forwarding", "s3-eth2": "blocking"}, + }, } @require_root -@pytest.mark.parametrize("topo", [ - SimpleSpanningTree, - SpanningTreeNet, - SpanningTreeAdjust, - SpanningTreeBus, - SpanningTreeFullMesh, - SpanningTreeHub, - SpanningTreeIntermediate, - SpanningTreeCost -]) +@pytest.mark.parametrize( + "topo", + [ + SimpleSpanningTree, + SpanningTreeNet, + SpanningTreeAdjust, + SpanningTreeBus, + SpanningTreeFullMesh, + SpanningTreeHub, + SpanningTreeIntermediate, + SpanningTreeCost, + ], +) def test_stp(topo): try: net = IPNet(topo=topo(), switch=FastSTPSwitch) @@ -177,11 +236,17 @@ def test_stp(topo): def test_stp_adjust(): try: - net = IPNet(topo=SpanningTreeAdjust(l1_start="s2-eth1", - l1_end="s1-eth1", l1_cost=2, - l2_start="s1-eth3", - l2_end="s3-eth1", l2_cost=3), - switch=FastSTPSwitch) + net = IPNet( + topo=SpanningTreeAdjust( + l1_start="s2-eth1", + l1_end="s1-eth1", + l1_cost=2, + l2_start="s1-eth3", + l2_end="s3-eth1", + l2_cost=3, + ), + switch=FastSTPSwitch, + ) net.start() assert_connectivity(net, v6=False) diff --git a/ipmininet/tests/test_tc.py b/ipmininet/tests/test_tc.py index 86cb9d3e..bc1f65e4 100644 --- a/ipmininet/tests/test_tc.py +++ b/ipmininet/tests/test_tc.py @@ -1,4 +1,5 @@ """This module tests the delay and bandwidth allocation""" + import json import re import subprocess @@ -11,25 +12,25 @@ from ipmininet.ipnet import IPNet from ipmininet.router import IPNode from ipmininet.tests.utils import assert_connectivity, wait_until + from . import require_root from .utils import require_cmd delay_regex = re.compile(r"time=(\d+\.?\d*) ms") -def assert_delay(src: IPNode, dst: IPNode, delay_target: float, tolerance=1.5, - v6=False): +def assert_delay( + src: IPNode, dst: IPNode, delay_target: float, tolerance=1.5, v6=False +): executable = "ping{v6}".format(v6="6" if v6 else "") - require_cmd(executable, help_str="{executable} is required to run " - "tests".format(executable=executable)) + require_cmd(executable, help_str=f"{executable} is required to run tests") - cmd = "{executable} -c 10 {dst}".format(executable=executable, - dst=dst.intf().ip6 if v6 - else dst.intf().ip) + cmd = "{executable} -c 10 {dst}".format( + executable=executable, dst=dst.intf().ip6 if v6 else dst.intf().ip + ) out, err, exitcode = src.pexec(cmd) - assert exitcode == 0, "Cannot ping between {src} and {dst}: " \ - "{err}".format(src=src, dst=dst, err=err) + assert exitcode == 0, f"Cannot ping between {src} and {dst}: {err}" delays = [] for line in out.split("\n"): @@ -38,13 +39,12 @@ def assert_delay(src: IPNode, dst: IPNode, delay_target: float, tolerance=1.5, delay = float(match.group(1)) if delay_target - tolerance <= delay <= delay_target + tolerance: delays.append(delay) - assert len(delays) >= 5, \ - "Less than half of the pings between {src} and {dst}" \ - " had the desired latency".format(src=src, dst=dst) + assert len(delays) >= 5, ( + f"Less than half of the pings between {src} and {dst} had the desired latency" + ) -def assert_bw(src: IPNode, dst: IPNode, bw_target: float, tolerance=1, - v6=False): +def assert_bw(src: IPNode, dst: IPNode, bw_target: float, tolerance=1, v6=False): require_cmd("iperf3", help_str="iperf3 is required to run tests") iperf = dst.popen("iperf3 -s -J --one-off", universal_newlines=True) @@ -54,34 +54,37 @@ def assert_bw(src: IPNode, dst: IPNode, bw_target: float, tolerance=1, # probe would consume it, leaving nothing for the client below. wait_until( lambda: ":5201" in dst.cmd("ss -ltn"), - timeout=30, interval=0.2, - description="iperf3 server on {ip} to start listening" - .format(ip=dst_ip)) - src.popen("iperf3 -c {}".format(dst_ip), stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL) + timeout=30, + interval=0.2, + description=f"iperf3 server on {dst_ip} to start listening", + ) + src.popen( + f"iperf3 -c {dst_ip}", stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) out, err = iperf.communicate() - assert iperf.poll() == 0, "Cannot use iperf3 between {src} and {dst}: " \ - "{err}".format(src=src, dst=dst, err=err) + assert iperf.poll() == 0, f"Cannot use iperf3 between {src} and {dst}: {err}" bws = [] data = json.loads(out) - assert "intervals" in data, "No intervals in the iperf3 output:\n{}" \ - .format(out) + assert "intervals" in data, f"No intervals in the iperf3 output:\n{out}" for sample in data["intervals"]: - bw = int(sample["sum"]["bits_per_second"]) / 10 ** 6 + bw = int(sample["sum"]["bits_per_second"]) / 10**6 if bw_target - tolerance <= bw <= bw_target + tolerance: bws.append(bw) - assert len(bws) >= 5, \ - "Less than half of the pings between {src} and {dst}" \ - " had the desired latency".format(src=src, dst=dst) + assert len(bws) >= 5, ( + f"Less than half of the pings between {src} and {dst} had the desired latency" + ) @require_root -@pytest.mark.parametrize("topo,delay,bw", [ - (TCNet, 32, 100), - (TCAdvancedNet, 49, 10), -]) +@pytest.mark.parametrize( + "topo,delay,bw", + [ + (TCNet, 32, 100), + (TCAdvancedNet, 49, 10), + ], +) def test_tc_example(topo, delay, bw): """ :param topo: The topology class diff --git a/ipmininet/tests/test_topologydb.py b/ipmininet/tests/test_topologydb.py index 17b1c839..d1948347 100644 --- a/ipmininet/tests/test_topologydb.py +++ b/ipmininet/tests/test_topologydb.py @@ -1,9 +1,9 @@ """This module tests the TopologyDB class""" + import itertools import os import shutil import tempfile -from typing import Type import pytest @@ -19,15 +19,21 @@ from ipmininet.router import Router from ipmininet.tests import require_root from ipmininet.topologydb import TopologyDB -from ipmininet.utils import realIntfList, otherIntf +from ipmininet.utils import otherIntf, realIntfList @require_root -@pytest.mark.parametrize("topology", [ - SimpleOSPFNet, SimpleOSPFv3Net, StaticAddressNet, - BGPDecisionProcess, SpanningTreeNet -]) -def test_topologydb(topology: Type[IPTopo]): +@pytest.mark.parametrize( + "topology", + [ + SimpleOSPFNet, + SimpleOSPFv3Net, + StaticAddressNet, + BGPDecisionProcess, + SpanningTreeNet, + ], +) +def test_topologydb(topology: type[IPTopo]): net = IPNet(topo=topology()) db_dir = tempfile.mkdtemp() try: @@ -36,44 +42,40 @@ def test_topologydb(topology: Type[IPTopo]): db_path = os.path.join(db_dir, "topology.json") db.save(db_path) - assert os.path.exists(db_path), \ - "TopologyDB did not write the JSON database" + assert os.path.exists(db_path), "TopologyDB did not write the JSON database" db = TopologyDB(db=db_path) for node in net.routers + net.hosts + net.switches: - assert node.name in db._network, \ - "The node {} in the network is not in the DB file".format(node) + assert node.name in db._network, ( + f"The node {node} in the network is not in the DB file" + ) for node, node_info in db._network.items(): - assert node in net, \ - "The node {} in the DB file is not in the network".format(node) + assert node in net, f"The node {node} in the DB file is not in the network" # Check type - assert "type" in node_info, \ - "No info on the type of node {}".format(node) + assert "type" in node_info, f"No info on the type of node {node}" node_type = node_info["type"] if node_type == "host": - assert type(net[node]) == IPHost, "The node {} is not an " \ - "host".format(node) + assert type(net[node]) == IPHost, f"The node {node} is not an host" elif node_type == "router": - assert type(net[node]) == Router, "The node {} is not a " \ - "router".format(node) + assert type(net[node]) == Router, f"The node {node} is not a router" elif node_type == "switch": - assert type(net[node]) == IPSwitch, "The node {} is not a " \ - "switch".format(node) + assert type(net[node]) == IPSwitch, f"The node {node} is not a switch" else: - pytest.fail("The node type {} of node {} is invalid" - .format(node_type, node)) + pytest.fail(f"The node type {node_type} of node {node} is invalid") # Check interfaces - assert "interfaces" in node_info, \ - "No information about interfaces of node {}".format(node) + assert "interfaces" in node_info, ( + f"No information about interfaces of node {node}" + ) real_intfs = {itf.name for itf in realIntfList(net[node])} - assert real_intfs == set(node_info["interfaces"]), \ - "The interface list is not the same on node {}".format(node) + assert real_intfs == set(node_info["interfaces"]), ( + f"The interface list is not the same on node {node}" + ) for info_key, info_value in node_info.items(): if info_key == "type" or info_key == "interfaces": @@ -84,27 +86,31 @@ def test_topologydb(topology: Type[IPTopo]): # info_key is a interface name except KeyError: # info_key is a node name - assert info_key in net, \ - "{} is neither a node nor an interface nor a special " \ - "key of node {}".format(info_key, node) + assert info_key in net, ( + f"{info_key} is neither a node nor an interface nor a special " + f"key of node {node}" + ) intf = net[node].intf(info_value["name"]) # Checks that the node is a neighbor - assert otherIntf(intf).node.name == info_key, \ - "The node {} has no neighbor node {}".format(node, - info_key) + assert otherIntf(intf).node.name == info_key, ( + f"The node {node} has no neighbor node {info_key}" + ) # Checks the IP address - assert info_value["ip"] == '%s/%s' % (intf.ip, intf.prefixLen), \ - "The IP address of the record {} of node {} does not " \ - "match".format(info_key, node) + assert info_value["ip"] == "%s/%s" % (intf.ip, intf.prefixLen), ( + f"The IP address of the record {info_key} of node {node} does not " + "match" + ) # Checks the IP prefixes - prefixes = {ip.with_prefixlen - for ip in itertools.chain(intf.ips(), intf.ip6s())} - assert set(info_value["ips"]) == prefixes, \ - "The IP prefixes of the record {} of node {} do not " \ - "match".format(info_key, node) + prefixes = { + ip.with_prefixlen for ip in itertools.chain(intf.ips(), intf.ip6s()) + } + assert set(info_value["ips"]) == prefixes, ( + f"The IP prefixes of the record {info_key} of node {node} do not " + "match" + ) finally: net.stop() diff --git a/ipmininet/tests/utils.py b/ipmininet/tests/utils.py index b4b9da53..45cdc729 100644 --- a/ipmininet/tests/utils.py +++ b/ipmininet/tests/utils.py @@ -1,23 +1,28 @@ -import pytest import re import signal import time from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor -from typing import List, Tuple, Dict, Pattern, Match, Optional - -import mininet.log from io import StringIO from ipaddress import ip_address, ip_network -from ipmininet.utils import require_cmd +from re import Match, Pattern + +import mininet.log +import pytest + +from ipmininet.host.config.named import DNSRecord from ipmininet.ipnet import IPNet -from ipmininet.router import IPNode from ipmininet.ipswitch import IPSwitch -from ipmininet.host.config.named import DNSRecord +from ipmininet.router import IPNode +from ipmininet.utils import require_cmd -def wait_until(predicate: Callable[[], bool], timeout: float = 60, - interval: float = 0.5, description=None): +def wait_until( + predicate: Callable[[], bool], + timeout: float = 60, + interval: float = 0.5, + description=None, +): """Wait until ``predicate()`` returns a truthy value. Polls every ``interval`` seconds up to ``timeout`` seconds and returns @@ -47,12 +52,12 @@ def wait_until(predicate: Callable[[], bool], timeout: float = 60, elapsed += interval if callable(description): description = description() - pytest.fail("Timed out after %ds while waiting for %s" - % (int(elapsed), description)) + pytest.fail( + "Timed out after %ds while waiting for %s" % (int(elapsed), description) + ) -def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300, - poll=1.0) -> List[str]: +def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300, poll=1.0) -> list[str]: require_cmd("traceroute", help_str="traceroute is required to run tests") t = 0 @@ -60,13 +65,27 @@ def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300, same_path_count = 0 white_space = re.compile(r" +") while t < timeout / poll: - out = net[src].cmd(["traceroute", "-w", "0.05", "-q", "1", "-n", - "-m", len(net.routers) + len(net.hosts), dst_ip]) + out = net[src].cmd( + [ + "traceroute", + "-w", + "0.05", + "-q", + "1", + "-n", + "-m", + len(net.routers) + len(net.hosts), + dst_ip, + ] + ) lines = out.split("\n")[1:-1] if "*" not in out and "!" not in out and "unreachable" not in out: path_ips = [str(white_space.split(line)[2]) for line in lines] - if len(path_ips) > 0 and path_ips[-1] == str(dst_ip) \ - and old_path_ips == path_ips: + if ( + len(path_ips) > 0 + and path_ips[-1] == str(dst_ip) + and old_path_ips == path_ips + ): same_path_count += 1 if same_path_count > 2: # Network has converged @@ -85,8 +104,15 @@ def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300, return [] -def assert_path(net: IPNet, expected_path: List[str], v6=False, retry=5, - timeout=300, traceroute_fun=traceroute, **kwargs): +def assert_path( + net: IPNet, + expected_path: list[str], + v6=False, + retry=5, + timeout=300, + traceroute_fun=traceroute, + **kwargs, +): src = expected_path[0] dst = expected_path[-1] dst_ip = net[dst].defaultIntf().ip6 if v6 else net[dst].defaultIntf().ip @@ -111,17 +137,20 @@ def assert_path(net: IPNet, expected_path: List[str], v6=False, retry=5, if found: path.append(n.name) break - assert found, "Traceroute returned the address '%s' " \ - "that cannot be linked to a node" % path_ip + assert found, ( + "Traceroute returned the address '%s' " + "that cannot be linked to a node" % path_ip + ) i += 1 - assert path == expected_path, "We expected the path from %s to %s to go " \ - "through %s but it went through %s" \ - % (src, dst, expected_path[1:-1], path[1:-1]) + assert path == expected_path, ( + "We expected the path from %s to %s to go " + "through %s but it went through %s" + % (src, dst, expected_path[1:-1], path[1:-1]) + ) -def host_connected(net: IPNet, v6=False, timeout=0.2, translate_address=True) \ - -> bool: +def host_connected(net: IPNet, v6=False, timeout=0.2, translate_address=True) -> bool: require_cmd("nmap", help_str="nmap is required to run tests") hosts = list(net.hosts) @@ -138,12 +167,14 @@ def _check_src(src) -> bool: for dst in hosts: if src != dst: if translate_address: - dst_ip = dst.defaultIntf().ip6 if v6 \ - else dst.defaultIntf().ip + dst_ip = dst.defaultIntf().ip6 if v6 else dst.defaultIntf().ip else: dst_ip = dst - cmd = "nmap%s -sn -n --max-retries 0 --max-rtt-timeout %dms %s"\ - % (" -6" if v6 else "", int(timeout * 1000), dst_ip) + cmd = "nmap%s -sn -n --max-retries 0 --max-rtt-timeout %dms %s" % ( + " -6" if v6 else "", + int(timeout * 1000), + dst_ip, + ) out = src.cmd(cmd.split(" ")) if "0 hosts up" in out: return False @@ -164,31 +195,37 @@ def assert_node_not_connected(src: IPNode, dst: IPNode, v6=False, timeout=0.2): dst.defaultIntf().updateIP() dst.defaultIntf().updateIP6() dst_ip = dst.defaultIntf().ip6 if v6 else dst.defaultIntf().ip - cmd = "nmap%s -sn -n --max-retries 0 --max-rtt-timeout %dms %s" \ - % (" -6" if v6 else "", int(timeout * 1000), dst_ip) + cmd = "nmap%s -sn -n --max-retries 0 --max-rtt-timeout %dms %s" % ( + " -6" if v6 else "", + int(timeout * 1000), + dst_ip, + ) out = src.cmd(cmd.split(" ")) - assert "0 hosts up" in out, "Node {} is connected to node {} over {}" \ - .format(src.name, dst.name, "IPv4" if not v6 else "IPv6") + assert "0 hosts up" in out, "Node {} is connected to node {} over {}".format( + src.name, dst.name, "IPv4" if not v6 else "IPv6" + ) -def assert_connectivity(net: IPNet, v6=False, attempts=1500, - translate_address=True): +def assert_connectivity(net: IPNet, v6=False, attempts=1500, translate_address=True): t = 0 connected = False while t < attempts and not connected: - connected = host_connected(net, v6=v6, - translate_address=translate_address) + connected = host_connected(net, v6=v6, translate_address=translate_address) if not connected: t += 1 time.sleep(1) - assert connected, \ - "Cannot ping all hosts over %s" % ("IPv4" if not v6 else "IPv6") + assert connected, "Cannot ping all hosts over %s" % ("IPv4" if not v6 else "IPv6") -def check_tcp_connectivity(client: IPNode, server: IPNode, v6=False, - server_port=80, server_itf=None, timeout=300) \ - -> Tuple[int, bytes, bytes]: +def check_tcp_connectivity( + client: IPNode, + server: IPNode, + v6=False, + server_port=80, + server_itf=None, + timeout=300, +) -> tuple[int, bytes, bytes]: require_cmd("nc", help_str="nc is required to run tests") if server_itf is None: @@ -205,10 +242,11 @@ def check_tcp_connectivity(client: IPNode, server: IPNode, v6=False, t += 1 if server_p.poll() is not None: out, err = server_p.communicate() - assert False, \ - "The netcat server used to check TCP connectivity failed" \ + assert False, ( + "The netcat server used to check TCP connectivity failed" " with the output:\n[stdout]\n%s\n[stderr]\n%s" % (out, err) - time.sleep(.5) + ) + time.sleep(0.5) client_p = client.popen(client_cmd.split(" ")) out, err = client_p.communicate() code = client_p.poll() @@ -217,8 +255,7 @@ def check_tcp_connectivity(client: IPNode, server: IPNode, v6=False, return code, out, err -def assert_stp_state(switch: IPSwitch, expected_states: Dict[str, str], - timeout=60): +def assert_stp_state(switch: IPSwitch, expected_states: dict[str, str], timeout=60): """ :param switch: The switch to test :param expected_states: Dictionary mapping an interface name to @@ -232,7 +269,7 @@ def assert_stp_state(switch: IPSwitch, expected_states: Dict[str, str], possible_states = "listening|learning|forwarding|blocking" # In these states the STP has not converged ignore_state = "listening", "learning" - cmd = ("%s %s" % (partial_cmd, switch.name)) + cmd = "%s %s" % (partial_cmd, switch.name) out = None states = None @@ -242,23 +279,32 @@ def _converged(): states = re.findall(possible_states, out) return not any(item in states for item in ignore_state) - wait_until(_converged, timeout=timeout, interval=1, - description="the spanning tree to be computed") + wait_until( + _converged, + timeout=timeout, + interval=1, + description="the spanning tree to be computed", + ) interfaces = re.findall(switch.name + r"-eth[0-9]+", out) state_map = {interfaces[i]: states[i] for i in range(len(states))} for itf, _ in expected_states.items(): - assert itf in state_map,\ - "The port %s of switch %s was not mentioned in the output of " \ + assert itf in state_map, ( + "The port %s of switch %s was not mentioned in the output of " "'brctl showstp':\n%s" % (itf, switch.name, out) - assert state_map[itf] == expected_states[itf],\ - "The state of port %s of switch %s wasn't correct: excepted '%s' " \ - "got '%s'"\ - % (itf, switch.name, expected_states[itf], state_map[itf]) - - -def assert_routing_table(router: IPNode, expected_prefixes: List[str], - present: bool = True, timeout: int = 120): + ) + assert state_map[itf] == expected_states[itf], ( + "The state of port %s of switch %s wasn't correct: excepted '%s' " + "got '%s'" % (itf, switch.name, expected_states[itf], state_map[itf]) + ) + + +def assert_routing_table( + router: IPNode, + expected_prefixes: list[str], + present: bool = True, + timeout: int = 120, +): """ Wait until all expected prefixes are (present=True) or none are (present=False) in the router's IPv4/IPv6 routing table. @@ -275,21 +321,30 @@ def assert_routing_table(router: IPNode, expected_prefixes: List[str], def _satisfied(): nonlocal found - found = set(re.findall(r"|".join(re.escape(p) for p in expected), - router.cmd(cmd))) + found = set( + re.findall(r"|".join(re.escape(p) for p in expected), router.cmd(cmd)) + ) return (expected <= found) if present else not (expected & found) wait_until( - _satisfied, timeout=timeout, interval=1, - description=lambda: "prefixes %s to be %s in the routing table " - "of %s within %ds (found: %s)" - % (expected_prefixes, - "present" if present else "absent", - router.name, timeout, sorted(found))) - - -def search_dns_reply(reply: str, regex: Pattern) \ - -> Tuple[bool, Optional[Match]]: + _satisfied, + timeout=timeout, + interval=1, + description=lambda: ( + "prefixes %s to be %s in the routing table " + "of %s within %ds (found: %s)" + % ( + expected_prefixes, + "present" if present else "absent", + router.name, + timeout, + sorted(found), + ) + ), + ) + + +def search_dns_reply(reply: str, regex: Pattern) -> tuple[bool, Match | None]: got_answer = False for line in reply.split("\n"): @@ -304,18 +359,18 @@ def search_dns_reply(reply: str, regex: Pattern) \ return got_answer, None -def assert_dns_record(node: IPNode, dns_server_address: str, record: DNSRecord, - port=53, timeout=60): +def assert_dns_record( + node: IPNode, dns_server_address: str, record: DNSRecord, port=53, timeout=60 +): require_cmd("dig", help_str="dig is required to run tests") - server_cmd = "dig @{address} -p {port} -t {rtype} {domain_name}"\ - .format(address=dns_server_address, rtype=record.rtype, - domain_name=record.domain_name, port=port) - out_regex = re.compile(r" *{name}.?[ \t]+{ttl}[ \t]+IN[ \t]+{rtype}[ \t]+" - r"{rdata}" - .format(rtype=record.rtype, ttl=record.ttl, - name=record.domain_name, - rdata=record.rdata)) + server_cmd = ( + f"dig @{dns_server_address} -p {port} -t {record.rtype} {record.domain_name}" + ) + out_regex = re.compile( + rf" *{record.domain_name}.?[ \t]+{record.ttl}[ \t]+IN[ \t]+{record.rtype}[ \t]+" + rf"{record.rdata}" + ) out = None got_answer = False @@ -328,24 +383,30 @@ def _answered(): return match is not None wait_until( - _answered, timeout=timeout, interval=0.5, - description=lambda: "the expected data '%s' to be found in the DNS " - "reply of '%s' received by %s from %s:\n%s" - % (out_regex.pattern, server_cmd, node.name, - dns_server_address, out)) - - assert got_answer, "No answer was received in %s" \ - " from server %s in the reply of '%s':\n%s" \ - % (node.name, dns_server_address, server_cmd, out) - - assert match is not None, "The expected data '%s' cannot be found " \ - "in the DNS reply of '%s' received by %s from " \ - "%s:\n%s" % (out_regex.pattern, server_cmd, - node.name, dns_server_address, out) + _answered, + timeout=timeout, + interval=0.5, + description=lambda: ( + "the expected data '%s' to be found in the DNS " + "reply of '%s' received by %s from %s:\n%s" + % (out_regex.pattern, server_cmd, node.name, dns_server_address, out) + ), + ) + + assert got_answer, ( + "No answer was received in %s" + " from server %s in the reply of '%s':\n%s" + % (node.name, dns_server_address, server_cmd, out) + ) + + assert match is not None, ( + "The expected data '%s' cannot be found " + "in the DNS reply of '%s' received by %s from " + "%s:\n%s" % (out_regex.pattern, server_cmd, node.name, dns_server_address, out) + ) class CLICapture: - def __init__(self, loglevel: str): self.loglevel = loglevel self.stream = None diff --git a/ipmininet/topologydb.py b/ipmininet/topologydb.py index 198bb025..a7fc50eb 100644 --- a/ipmininet/topologydb.py +++ b/ipmininet/topologydb.py @@ -1,21 +1,22 @@ """This module defines a data-store to help dealing with all (possibly) auto-allocated properties of a topology: ip addresses, router ids, ...""" -from builtins import str -import json import itertools +import json +from builtins import str from ipaddress import ip_interface -from .utils import otherIntf, realIntfList - from mininet.log import lg +from .utils import otherIntf, realIntfList + class TopologyDB: """A convenience store for auto-allocated mininet properties. This is *NOT* to be used as IGP graph as it does not reflect the actual availability of a node in the network (as-in it is a static view of the network).""" + def __init__(self, db=None, net=None, *args, **kwargs): """Either extract properties from a network or load a save file @@ -34,34 +35,35 @@ def __init__(self, db=None, net=None, *args, **kwargs): if net: self.parse_net(net) if not db and not net: - lg.warning('TopologyDB instantiated without any data') + lg.warning("TopologyDB instantiated without any data") def load(self, fpath): """Load a topology database :param fpath: path towards the file to load""" - with open(fpath, 'r') as f: + with open(fpath) as f: self._network = json.load(f) def save(self, fpath): """Save the topology database :param fpath: the save file name""" - with open(fpath, 'w') as f: + with open(fpath, "w") as f: json.dump(self._network, f) def _node(self, x): try: return self._network[x] except KeyError: - raise ValueError('No node named %s in the network' % x) + raise ValueError("No node named %s in the network" % x) + __getitem__ = node = _node def _interface(self, x, y): try: return self._node(x)[y] except KeyError: - raise ValueError('The link %s-%s does not exist' % (x, y)) + raise ValueError("The link %s-%s does not exist" % (x, y)) def interface(self, x, y): """Return the ip address of the interface of x facing y @@ -69,11 +71,11 @@ def interface(self, x, y): :param x: the node from which we want an IP address :param y: the node on the other side of the link :return: ip_interface-like object""" - return ip_interface(str(self._interface(x, y)['ip'])) + return ip_interface(str(self._interface(x, y)["ip"])) def interfaces(self, x): """Return the list of interface names of node x""" - return self._node(x)['interfaces'] + return self._node(x)["interfaces"] def interface_bandwidth(self, x, y): """Return the bandwidth capacity of the interface on node x @@ -83,7 +85,7 @@ def interface_bandwidth(self, x, y): :param y: node name :return: The bandwidth of link x-y, -1 if unlimited""" try: - return self._interface(x, y)['bw'] + return self._interface(x, y)["bw"] except KeyError: return -1 @@ -101,9 +103,9 @@ def routerid(self, x): :param x: router name :return: the routerid""" n = self._node(x) - if n['type'] != 'router': - raise TypeError('%s is not a router' % x) - return n['routerid'] + if n["type"] != "router": + raise TypeError("%s is not a router" % x) + return n["routerid"] def parse_net(self, net): """Stores the content of the given network @@ -118,15 +120,16 @@ def parse_net(self, net): def _add_node(self, n, props): itfs = realIntfList(n) - props['interfaces'] = [itf.name for itf in itfs] + props["interfaces"] = [itf.name for itf in itfs] for itf in itfs: nh = otherIntf(itf) itf_props = { - 'ip': '%s/%s' % (itf.ip, itf.prefixLen), - 'ips': [ip.with_prefixlen - for ip in itertools.chain(itf.ips(), itf.ip6s())], - 'name': itf.name, - 'bw': itf.params.get('bw', -1) + "ip": "%s/%s" % (itf.ip, itf.prefixLen), + "ips": [ + ip.with_prefixlen for ip in itertools.chain(itf.ips(), itf.ip6s()) + ], + "name": itf.name, + "bw": itf.params.get("bw", -1), } if nh: props[nh.node.name] = itf_props @@ -137,18 +140,23 @@ def add_host(self, n): """Register an host :param n: Host instance""" - self._add_node(n, {'type': 'host'}) + self._add_node(n, {"type": "host"}) def add_switch(self, n): """Register an switch :param n: Switch instance""" - self._add_node(n, {'type': 'switch'}) + self._add_node(n, {"type": "switch"}) def add_router(self, n): """Register an router :param n: Router instance""" - self._add_node(n, {'type': 'router', }) + self._add_node( + n, + { + "type": "router", + }, + ) # FIXME make routerid global across all daemons # 'routerid': n.id}) diff --git a/ipmininet/utils.py b/ipmininet/utils.py index 5c80d7c7..385a30a0 100644 --- a/ipmininet/utils.py +++ b/ipmininet/utils.py @@ -1,15 +1,14 @@ """utils: utility functions to manipulate host, interfaces, ...""" + import collections import os +from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_address +from typing import TYPE_CHECKING, Optional from mininet.link import Intf from mininet.log import lg as log from mininet.node import Node -from ipaddress import ip_address, IPv4Address, IPv6Address, IPv4Network,\ - IPv6Network - -from typing import Type, Dict, Optional, Union, Tuple, List, TYPE_CHECKING, Set if TYPE_CHECKING: from ipmininet.link import IPIntf @@ -28,7 +27,7 @@ def has_cmd(cmd: str) -> bool: return False -def require_cmd(cmd: str, help_str: Optional[str] = None): +def require_cmd(cmd: str, help_str: str | None = None): """ Ensures that a command is available in $PATH @@ -40,25 +39,25 @@ def require_cmd(cmd: str, help_str: Optional[str] = None): if help_str: log.error(help_str) - raise RuntimeError('[%s] is not available in $PATH' % cmd) + raise RuntimeError("[%s] is not available in $PATH" % cmd) -def otherIntf(intf: Intf) -> Optional['IPIntf']: - """"Get the interface on the other side of a link""" +def otherIntf(intf: Intf) -> Optional["IPIntf"]: + """ "Get the interface on the other side of a link""" link = intf.link return (link.intf1 if link.intf2 == intf else link.intf2) if link else None -def realIntfList(n: Node) -> List['IPIntf']: +def realIntfList(n: Node) -> list["IPIntf"]: """Return the list of interfaces of node n excluding loopback""" - return [i for i in n.intfList() if i.name != 'lo'] + return [i for i in n.intfList() if i.name != "lo"] -def address_pair(n: Node, use_v4=True, use_v6=True) \ - -> Tuple[Optional[str], Optional[str]]: +def address_pair(n: Node, use_v4=True, use_v6=True) -> tuple[str | None, str | None]: """Returns a tuple (ip, ip6) with ip/ip6 being one of the IPv4/IPv6 - addresses of the node n""" + addresses of the node n""" from .link import IPIntf # Prevent circular imports + v4_str = v6_str = None for itf in n.intfList(): # Mininet switches have a loopback interface @@ -75,14 +74,12 @@ def address_pair(n: Node, use_v4=True, use_v6=True) \ itf.updateIP6() v6 = next(itf.ip6s(exclude_lls=True), None) v6_str = v6.ip.compressed if v6 is not None else v6 - if (not use_v4 or v4_str is not None) \ - and (not use_v6 or v6_str is not None): + if (not use_v4 or v4_str is not None) and (not use_v6 or v6_str is not None): break return v4_str, v6_str -def is_subnet_of(a: Union[IPv4Network, IPv6Network], - b: Union[IPv4Network, IPv6Network]) -> bool: +def is_subnet_of(a: IPv4Network | IPv6Network, b: IPv4Network | IPv6Network) -> bool: """Return True if network a is a subnet of network b.""" # This code is copied from ipaddress module in Python 3.7 for @@ -90,22 +87,21 @@ def is_subnet_of(a: Union[IPv4Network, IPv6Network], try: # Always false if one is v4 and the other is v6. if a.version != b.version: - raise TypeError("{} and {} are not of the same version" - .format(a, b)) - return (b.network_address <= a.network_address and - b.broadcast_address >= a.broadcast_address) + raise TypeError(f"{a} and {b} are not of the same version") + return ( + b.network_address <= a.network_address + and b.broadcast_address >= a.broadcast_address + ) except AttributeError: - raise TypeError("Unable to test subnet containment " - "between {} and {}".format(a, b)) + raise TypeError(f"Unable to test subnet containment between {a} and {b}") def is_container(x) -> bool: """Return whether x is a container (=iterable but not a string)""" - return (isinstance(x, collections.abc.Sequence) and - not isinstance(x, str)) + return isinstance(x, collections.abc.Sequence) and not isinstance(x, str) -def prefix_for_netmask(mask: Union[IPv4Address, IPv6Address, str]) -> int: +def prefix_for_netmask(mask: IPv4Address | IPv6Address | str) -> int: """Return the prefix length associated to a given netmask. Will return garbage if the netmask is unproperly formatted!""" ip = ip_address(str(mask)) @@ -121,6 +117,7 @@ class L3Router: """Placeholder class to identify L3 routing devices (primarily routers, but this could also be used for a device needing to participate to some routing protocol e.g. for TE purposes)""" + @staticmethod def is_l3router_intf(itf: Intf) -> bool: """Returns whether an interface belongs to an L3Router @@ -131,7 +128,7 @@ def is_l3router_intf(itf: Intf) -> bool: return False -def get_set(d: Dict, key, default: Type): +def get_set(d: dict, key, default: type): """Attempt to return the value for the given key, otherwise initialize it @@ -145,7 +142,7 @@ def get_set(d: Dict, key, default: Type): return x -def find_node(start: Node, node_name: str) -> Optional[Intf]: +def find_node(start: Node, node_name: str) -> Intf | None: """ :param start: The starting node of the search :param node_name: The name of the node to find From 8f01a8841e69330cd15ed392bf717d238fb36cb0 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 00:34:36 +0300 Subject: [PATCH 35/69] lint: enable phase-2 rules, convert to py3.12 idioms, defer legacy rules per-file --- docs/conf.py | 4 +- ipmininet/clean.py | 4 +- ipmininet/cli.py | 8 +- ipmininet/examples/__main__.py | 4 +- ipmininet/examples/bgp_full_config.py | 4 +- ipmininet/examples/bgp_local_pref.py | 4 +- ipmininet/examples/bgp_med.py | 4 +- ipmininet/examples/bgp_policies_1.py | 2 +- ipmininet/examples/bgp_policies_2.py | 2 +- ipmininet/examples/bgp_policies_3.py | 4 +- ipmininet/examples/bgp_policies_4.py | 2 +- ipmininet/examples/bgp_policies_5.py | 4 +- ipmininet/examples/bgp_policies_adjust.py | 23 +-- ipmininet/examples/bgp_rr.py | 4 +- ipmininet/examples/exabgp_prefix_injector.py | 47 +++--- ipmininet/examples/link_failure.py | 2 +- ipmininet/examples/simple_bgp_network.py | 5 +- ipmininet/examples/simple_openr_network.py | 2 +- ipmininet/examples/simple_ospf_network.py | 6 +- ipmininet/examples/simple_ospfv3_network.py | 6 +- ipmininet/examples/spanning_tree.py | 2 +- ipmininet/examples/spanning_tree_adjust.py | 6 +- ipmininet/examples/spanning_tree_bus.py | 2 +- ipmininet/examples/spanning_tree_cost.py | 2 +- ipmininet/examples/spanning_tree_full_mesh.py | 2 +- ipmininet/examples/spanning_tree_hub.py | 2 +- .../examples/spanning_tree_intermediate.py | 2 +- ipmininet/examples/tc_advanced_network.py | 12 +- ipmininet/host/__host.py | 4 +- ipmininet/host/config/dnsmasq.py | 4 +- ipmininet/host/config/named.py | 19 +-- ipmininet/install/install.py | 36 ++--- ipmininet/install/utils.py | 16 +-- ipmininet/ipnet.py | 92 +++++------- ipmininet/ipovs_switch.py | 29 ++-- ipmininet/ipswitch.py | 8 +- ipmininet/iptopo.py | 32 ++--- ipmininet/link.py | 32 ++--- ipmininet/node_description.py | 7 +- ipmininet/overlay.py | 61 ++++---- ipmininet/router/__router.py | 18 ++- ipmininet/router/config/base.py | 66 ++++----- ipmininet/router/config/bgp.py | 43 +++--- ipmininet/router/config/dhcprelay.py | 2 +- ipmininet/router/config/exabgp.py | 135 ++++++++++-------- ipmininet/router/config/iptables.py | 19 +-- ipmininet/router/config/openr.py | 8 +- ipmininet/router/config/openrd.py | 3 +- ipmininet/router/config/ospf.py | 10 +- ipmininet/router/config/pimd.py | 2 +- ipmininet/router/config/radvd.py | 2 +- ipmininet/router/config/ripng.py | 4 +- ipmininet/router/config/sshd.py | 4 +- ipmininet/router/config/utils.py | 2 +- ipmininet/router/config/zebra.py | 59 ++++---- ipmininet/srv6.py | 112 +++++++-------- ipmininet/tests/__init__.py | 1 + ipmininet/tests/test_bgp.py | 8 +- ipmininet/tests/test_cli.py | 52 ++++--- ipmininet/tests/test_dns.py | 16 +-- ipmininet/tests/test_exabgp.py | 42 +++--- ipmininet/tests/test_gre.py | 6 +- ipmininet/tests/test_iptables.py | 11 +- ipmininet/tests/test_linkfailure.py | 2 +- ipmininet/tests/test_misc.py | 15 +- ipmininet/tests/test_ospf.py | 4 +- ipmininet/tests/test_ospf6.py | 6 +- ipmininet/tests/test_physicalinterface.py | 2 +- ipmininet/tests/test_pure.py | 7 +- ipmininet/tests/test_radv.py | 10 +- ipmininet/tests/test_srv6.py | 8 +- ipmininet/tests/test_sshd.py | 6 +- ipmininet/tests/test_switch.py | 2 +- ipmininet/tests/test_tc.py | 13 +- ipmininet/tests/test_topologydb.py | 18 +-- ipmininet/tests/utils.py | 85 +++++------ ipmininet/topologydb.py | 9 +- ipmininet/utils.py | 15 +- pyproject.toml | 53 ++++++- 79 files changed, 724 insertions(+), 667 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 8eff41ba..8d179ae8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -62,12 +62,12 @@ def __init__(self): self.old_stdin = sys.stdin sys.stdin = self.new_stdin self.write_pipe_fd = w_mod__ - + def clean(self): self.new_stdin.close os.close(self.write_pipe_fd) sys.stdin = self.old_stdin - + def close_on_start_cli(self): os.write(self.write_pipe_fd, "exit\\r\\n".encode()) """ diff --git a/ipmininet/clean.py b/ipmininet/clean.py index 0de1e157..98444d72 100644 --- a/ipmininet/clean.py +++ b/ipmininet/clean.py @@ -27,7 +27,7 @@ def cleanup(level: str = "info"): killp = [killp] patterns.extend(killp) log.info("*** Cleaning up daemons:\n") - killprocs(['"^%s"' % p for p in patterns]) + killprocs([f'"^{p}"' for p in patterns]) log.info("\n") @@ -36,7 +36,7 @@ def killprocs(patterns, timeout=10): # Try clean kill for p in patterns: - mnclean.sh("pkill -SIGINT -f %s" % p) + mnclean.sh(f"pkill -SIGINT -f {p}") # Make sure they are gone t = 0 diff --git a/ipmininet/cli.py b/ipmininet/cli.py index e90505d2..96c77629 100644 --- a/ipmininet/cli.py +++ b/ipmininet/cli.py @@ -11,8 +11,8 @@ def do_route(self, line: str = ""): """route destination: Print all the routes towards that destination for every router in the network""" for r in self.mn.routers: - lg.output("[%s] " % r.name) - self.default("%s ip route get %s" % (r.name, line)) + lg.output(f"[{r.name}] ") + self.default(f"{r.name} ip route get {line}") def do_ip(self, line: str): """ip IP1 IP2 ...: return the node associated to the given IP""" @@ -84,7 +84,7 @@ def default(self, line: str): if first in self.mn: if not args: - lg.error("*** Enter a command for node: %s " % first) + lg.error(f"*** Enter a command for node: {first} ") return node = self.mn[first] rest = args.split(" ") @@ -106,4 +106,4 @@ def default(self, line: str): node.sendCmd(" ".join([ip_map.get(r, r) for r in rest])) self.waitForNode(node) else: - lg.error("*** Unknown command: %s\n" % line) + lg.error(f"*** Unknown command: {line}\n") diff --git a/ipmininet/examples/__main__.py b/ipmininet/examples/__main__.py index 1326e24e..881c6f62 100644 --- a/ipmininet/examples/__main__.py +++ b/ipmininet/examples/__main__.py @@ -134,8 +134,8 @@ def parse_args(): if args.log == "debug": ipmininet.DEBUG_FLAG = True kwargs = {} - for arg in args.args.strip(" \r\t\n").split(","): - arg = arg.strip(" \r\t\n") + for raw in args.args.strip(" \r\t\n").split(","): + arg = raw.strip(" \r\t\n") if not arg: continue try: diff --git a/ipmininet/examples/bgp_full_config.py b/ipmininet/examples/bgp_full_config.py index a340c64b..d86bc9ee 100644 --- a/ipmininet/examples/bgp_full_config.py +++ b/ipmininet/examples/bgp_full_config.py @@ -83,7 +83,7 @@ def build(self, *args, **kwargs): super().build(*args, **kwargs) - def bgp(self, name, family=AF_INET6()): + def bgp(self, name, family=None): r = self.addRouter(name) - r.addDaemon(BGP, address_families=(family,)) + r.addDaemon(BGP, address_families=(family or AF_INET6(),)) return r diff --git a/ipmininet/examples/bgp_local_pref.py b/ipmininet/examples/bgp_local_pref.py index 4a25ac6e..82e83b6e 100644 --- a/ipmininet/examples/bgp_local_pref.py +++ b/ipmininet/examples/bgp_local_pref.py @@ -76,7 +76,7 @@ def build(self, *args, **kwargs): super().build(*args, **kwargs) - def bgp(self, name, family=AF_INET6()): + def bgp(self, name, family=None): r = self.addRouter(name) - r.addDaemon(BGP, address_families=(family,)) + r.addDaemon(BGP, address_families=(family or AF_INET6(),)) return r diff --git a/ipmininet/examples/bgp_med.py b/ipmininet/examples/bgp_med.py index f91b6341..151c2206 100644 --- a/ipmininet/examples/bgp_med.py +++ b/ipmininet/examples/bgp_med.py @@ -76,7 +76,7 @@ def build(self, *args, **kwargs): super().build(*args, **kwargs) - def bgp(self, name, family=AF_INET6()): + def bgp(self, name, family=None): r = self.addRouter(name) - r.addDaemon(BGP, address_families=(family,)) + r.addDaemon(BGP, address_families=(family or AF_INET6(),)) return r diff --git a/ipmininet/examples/bgp_policies_1.py b/ipmininet/examples/bgp_policies_1.py index d9aa15a5..89fdc0fd 100644 --- a/ipmininet/examples/bgp_policies_1.py +++ b/ipmininet/examples/bgp_policies_1.py @@ -70,7 +70,7 @@ def build(self, *args, **kwargs): ebgp_session(self, as4r1, as5r1, link_type=CLIENT_PROVIDER) # Add test hosts for r in self.routers(): - self.addLink(r, self.addHost("h%s" % r)) + self.addLink(r, self.addHost(f"h{r}")) super().build(*args, **kwargs) def bgp(self, name): diff --git a/ipmininet/examples/bgp_policies_2.py b/ipmininet/examples/bgp_policies_2.py index eaa57856..a7d695b5 100644 --- a/ipmininet/examples/bgp_policies_2.py +++ b/ipmininet/examples/bgp_policies_2.py @@ -73,7 +73,7 @@ def build(self, *args, **kwargs): ebgp_session(self, as4r1, as3r1, link_type=SHARE) # Add test hosts for r in self.routers(): - self.addLink(r, self.addHost("h%s" % r)) + self.addLink(r, self.addHost(f"h{r}")) super().build(*args, **kwargs) def bgp(self, name): diff --git a/ipmininet/examples/bgp_policies_3.py b/ipmininet/examples/bgp_policies_3.py index 04ebea6c..f335573d 100644 --- a/ipmininet/examples/bgp_policies_3.py +++ b/ipmininet/examples/bgp_policies_3.py @@ -38,7 +38,7 @@ def build(self, *args, **kwargs): as4r = self.addRouter("as4r") routers = self.routers() - prefix = {routers[i]: "2001:db:%04x::/48" % i for i in range(len(routers))} + prefix = {routers[i]: f"2001:db:{i:04x}::/48" for i in range(len(routers))} as1r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as1r],)),)) as2r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as2r],)),)) as3r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as3r],)),)) @@ -64,6 +64,6 @@ def build(self, *args, **kwargs): # Add test hosts for r in self.routers(): - link = self.addLink(r, self.addHost("h%s" % r)) + link = self.addLink(r, self.addHost(f"h{r}")) self.addSubnet(links=[link], subnets=[prefix[r]]) super().build(*args, **kwargs) diff --git a/ipmininet/examples/bgp_policies_4.py b/ipmininet/examples/bgp_policies_4.py index 5edb1fd6..a436d5ae 100644 --- a/ipmininet/examples/bgp_policies_4.py +++ b/ipmininet/examples/bgp_policies_4.py @@ -102,7 +102,7 @@ def build(self, *args, **kwargs): # Add test hosts for r in self.routers(): - self.addLink(r, self.addHost("h%s" % r)) + self.addLink(r, self.addHost(f"h{r}")) super().build(*args, **kwargs) def bgp(self, name): diff --git a/ipmininet/examples/bgp_policies_5.py b/ipmininet/examples/bgp_policies_5.py index e169462d..b3faaecd 100644 --- a/ipmininet/examples/bgp_policies_5.py +++ b/ipmininet/examples/bgp_policies_5.py @@ -49,7 +49,7 @@ def build(self, *args, **kwargs): ) routers = self.routers() - prefix = {routers[i]: "2001:db:%04x::/48" % i for i in range(len(routers))} + prefix = {routers[i]: f"2001:db:{i:04x}::/48" for i in range(len(routers))} as1r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as1r],)),)) as2r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as2r],)),)) as3r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as3r],)),)) @@ -99,6 +99,6 @@ def build(self, *args, **kwargs): # Add test hosts for r in self.routers(): - link = self.addLink(r, self.addHost("h%s" % r)) + link = self.addLink(r, self.addHost(f"h{r}")) self.addSubnet(links=[link], subnets=[prefix[r]]) super().build(*args, **kwargs) diff --git a/ipmininet/examples/bgp_policies_adjust.py b/ipmininet/examples/bgp_policies_adjust.py index 953f9145..0e1776a6 100644 --- a/ipmininet/examples/bgp_policies_adjust.py +++ b/ipmininet/examples/bgp_policies_adjust.py @@ -61,7 +61,7 @@ def build(self, *args, **kwargs): ) routers = self.routers() - prefix = {routers[i]: "2001:db:%04x::/48" % i for i in range(len(routers))} + prefix = {routers[i]: f"2001:db:{i:04x}::/48" for i in range(len(routers))} as1r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as1r],)),)) as2r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as2r],)),)) as3r.addDaemon(BGP, address_families=(AF_INET6(networks=(prefix[as3r],)),)) @@ -105,7 +105,7 @@ def build(self, *args, **kwargs): # Add test hosts for r in self.routers(): - link = self.addLink(r, self.addHost("h%s" % r)) + link = self.addLink(r, self.addHost(f"h{r}")) self.addSubnet(links=[link], subnets=[prefix[r]]) super().build(*args, **kwargs) @@ -118,19 +118,22 @@ def check_extra_link(self): routers = self.routers() if self.as_start not in routers: raise ValueError( - "as_start '%s' is not an AS router among %s" - % (self.as_start, ", ".join(routers)) + "as_start '{}' is not an AS router among {}".format( + self.as_start, ", ".join(routers) + ) ) if self.as_end not in routers: raise ValueError( - "as_end '%s' is not an AS router among %s" - % (self.as_end, ", ".join(routers)) + "as_end '{}' is not an AS router among {}".format( + self.as_end, ", ".join(routers) + ) ) if self.bgp_policy not in [CLIENT_PROVIDER, SHARE]: raise ValueError( - "bgp_policy '%s' is not a BGP policy among %s" - % (self.bgp_policy, ", ".join([CLIENT_PROVIDER, SHARE])) + "bgp_policy '{}' is not a BGP policy among {}".format( + self.bgp_policy, f"{CLIENT_PROVIDER}, {SHARE}" + ) ) - self.as_start = [r for r in routers if r == self.as_start][0] - self.as_end = [r for r in routers if r == self.as_end][0] + self.as_start = next(r for r in routers if r == self.as_start) + self.as_end = next(r for r in routers if r == self.as_end) diff --git a/ipmininet/examples/bgp_rr.py b/ipmininet/examples/bgp_rr.py index 0d086c13..891c927b 100644 --- a/ipmininet/examples/bgp_rr.py +++ b/ipmininet/examples/bgp_rr.py @@ -98,7 +98,7 @@ def build(self, *args, **kwargs): super().build(*args, **kwargs) - def bgp(self, name, family=AF_INET6()): + def bgp(self, name, family=None): r = self.addRouter(name) - r.addDaemon(BGP, address_families=(family,)) + r.addDaemon(BGP, address_families=(family or AF_INET6(),)) return r diff --git a/ipmininet/examples/exabgp_prefix_injector.py b/ipmininet/examples/exabgp_prefix_injector.py index 91473d9b..af4f618f 100644 --- a/ipmininet/examples/exabgp_prefix_injector.py +++ b/ipmininet/examples/exabgp_prefix_injector.py @@ -84,28 +84,30 @@ def build_bgp_route( :param ip_networks: sequence of valid IP prefixes that will be used to generate BGP routes. :param my_as: the ASN that will be prepended to the randomly generated AS-PATH - :param their_as: the route generated by this function will be injected to the AS - having the ASN referenced by this parameter. Notably, this parameter - (their_as) is used to not generate an AS-Path containing "their_as" - in the AS list + :param their_as: the route generated by this function will be injected to the + AS having the ASN referenced by this parameter. Notably, this + parameter (their_as) is used to not generate an AS-Path + containing "their_as" in the AS list :return: a list of BGP route, actually one per prefixes of ip_networks, with random BGP attributes """ - my_routes = list() + my_routes = [] for network in ip_networks: next_hop = BGPAttribute("next-hop", "self") as_path = BGPAttribute( "as-path", - ExaList([my_as] + rnd_list(random.randint(1, 25), rm={their_as, my_as})), + ExaList([my_as, *rnd_list(random.randint(1, 25), rm={their_as, my_as})]), ) communities = BGPAttribute( "community", ExaList( [ - "%d:%d" % (j, k) - for j, k in zip(rnd_list(24, True), rnd_list(24, True)) + f"{j}:{k}" + for j, k in zip( + rnd_list(24, True), rnd_list(24, True), strict=False + ) ] ), ) @@ -121,14 +123,14 @@ def build_bgp_route( def gen_ip_prefix(family: str): """Generates a random IP prefix according to the family given at argument""" - assert family in {"ipv4", "ipv6"}, 'Family "%s" is not a valid IP family' % family + assert family in {"ipv4", "ipv6"}, f'Family "{family}" is not a valid IP family' _CONF_FAMILY = { "ipv4": { "pfx_lo": 8, "pfx_hi": 32, "start_ip": _1_0_0_0, # 1.0.0.0 - "end_ip": _233_255_255_255, # 223.255.255.255 (avoid class D and class E addresses) + "end_ip": _233_255_255_255, # 223.255.255.255 (avoid class D/E addresses) }, "ipv6": { "pfx_lo": 16, @@ -184,7 +186,7 @@ def __init__( node. In this case, the untouched IP address will be set to the default one. """ - self.routes = {"ipv4": list(), "ipv6": list()} + self.routes = {"ipv4": [], "ipv6": []} self.addr = { "as1": { "ipv4": ip_interface("10.1.0.1/24"), @@ -197,11 +199,12 @@ def __init__( } if routes is None: - self.routes = dict() + self.routes = {} for afi in ("ipv4", "ipv6"): prefixes = set() for x in itertools.takewhile( - lambda _: len(prefixes) <= 5, gen_ip_prefix(afi) + lambda _, prefixes=prefixes: len(prefixes) <= 5, + gen_ip_prefix(afi), ): prefixes.add(x) @@ -209,18 +212,18 @@ def __init__( list(prefixes), self.exabgp_asn, self.frr_asn ) else: - for key in self.routes.keys(): + for key in self.routes: self.routes[key].extend(routes[key]) if addr is not None: - for node in addr.keys(): + for node in addr: assert node in {"as1", "as2"}, ( - 'Unrecognized node: "%s". Expected: "as1" or "as2"' % node + f'Unrecognized node: "{node}". Expected: "as1" or "as2"' ) - for afi in addr[node].keys(): + for afi in addr[node]: assert afi in {"ipv4", "ipv6"}, ( - 'Unrecognized AFI "%s" for "%s" node. ' - 'Expected: "ipv4" or "ipv6"' % (afi, node) + f'Unrecognized AFI "{afi}" for "{node}" node. ' + 'Expected: "ipv4" or "ipv6"' ) if isinstance(addr[node][afi], str): @@ -229,7 +232,9 @@ def __init__( assert isinstance( addr[node][afi], (IPv4Interface, IPv6Interface) ), ( - f"Bad type '{type(addr[node][afi])}' for {afi} AFI. Expected: IPv4Interface or IPv6Interface or str." + "Bad type " + f"'{type(addr[node][afi])}' for {afi} AFI. " + "Expected: IPv4Interface or IPv6Interface or str." ) self.addr[node][afi] = addr[node][afi] @@ -280,7 +285,7 @@ def build(self, *args, **kwargs): # Add test hosts for r in self.routers(): - self.addLink(r, self.addHost("h%s" % r)) + self.addLink(r, self.addHost(f"h{r}")) super().build(*args, **kwargs) def bgp(self, name): diff --git a/ipmininet/examples/link_failure.py b/ipmininet/examples/link_failure.py index a0fe4647..41d68712 100644 --- a/ipmininet/examples/link_failure.py +++ b/ipmininet/examples/link_failure.py @@ -37,7 +37,7 @@ def post_build(self, net): # Run a 1 link Failure Random based on a given list of link # and then, restore the link - links = list(map(lambda x: x.link, realIntfList(net["r1"]))) + links = [x.link for x in realIntfList(net["r1"])] interfaces_down = net.randomFailure(1, weak_links=links) net.restoreIntfs(interfaces_down) super().post_build(net) diff --git a/ipmininet/examples/simple_bgp_network.py b/ipmininet/examples/simple_bgp_network.py index 61e7e712..18caa152 100644 --- a/ipmininet/examples/simple_bgp_network.py +++ b/ipmininet/examples/simple_bgp_network.py @@ -36,9 +36,8 @@ def build(self, *args, **kwargs): ebgp_session(self, as3r1, as2r2) # Add test hosts for r in self.routers(): - self.addLink(r, self.addHost("h%s" % r)) + self.addLink(r, self.addHost(f"h{r}")) super().build(*args, **kwargs) def bgp(self, name): - r = self.addRouter(name, config=BorderRouterConfig) - return r + return self.addRouter(name, config=BorderRouterConfig) diff --git a/ipmininet/examples/simple_openr_network.py b/ipmininet/examples/simple_openr_network.py index 616671df..3f9bb400 100644 --- a/ipmininet/examples/simple_openr_network.py +++ b/ipmininet/examples/simple_openr_network.py @@ -33,7 +33,7 @@ def build(self, *args, **kwargs): for router in (r_1, r_2, r_3): for i in range(HOSTS_PER_ROUTER): self.addLink( - router, self.addHost("h%s%s" % (i, router)), params2={"v4_width": 5} + router, self.addHost(f"h{i}{router}"), params2={"v4_width": 5} ) super().build(*args, **kwargs) diff --git a/ipmininet/examples/simple_ospf_network.py b/ipmininet/examples/simple_ospf_network.py index e81c331d..e0a74987 100644 --- a/ipmininet/examples/simple_ospf_network.py +++ b/ipmininet/examples/simple_ospf_network.py @@ -44,7 +44,7 @@ def build(self, *args, **kwargs): self.addLink(r3, r2) for r in (r1, r2, r3): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost("h%s%s" % (i, r)), params2={"v4_width": 5}) + self.addLink(r, self.addHost(f"h{i}{r}"), params2={"v4_width": 5}) # Area 1.1.1.1 is delimited by an OSPFArea overlay r4, r5 = self.addRouters("r4", "r5", use_v4=True, use_v6=False) @@ -53,7 +53,7 @@ def build(self, *args, **kwargs): self.addLink(r4, r5, igp_metric=10) for r in (r4, r5): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost("h%s%s" % (i, r))) + self.addLink(r, self.addHost(f"h{i}{r}")) self.addOSPFArea(routers=(r4, r5), area="1.1.1.1") # Area 2.2.2.2 is delimited by the igp_area parameter of addLink() @@ -63,7 +63,7 @@ def build(self, *args, **kwargs): self.addLink(r6, r7, igp_area="2.2.2.2") for r in (r6, r7): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost("h%s%s" % (i, r)), igp_area="2.2.2.2") + self.addLink(r, self.addHost(f"h{i}{r}"), igp_area="2.2.2.2") # Management network s1 = self.addSwitch("s1") diff --git a/ipmininet/examples/simple_ospfv3_network.py b/ipmininet/examples/simple_ospfv3_network.py index a42fc931..9944c4d4 100644 --- a/ipmininet/examples/simple_ospfv3_network.py +++ b/ipmininet/examples/simple_ospfv3_network.py @@ -31,7 +31,7 @@ def build(self, *args, **kwargs): self.addLink(r3, r2) for r in (r1, r2, r3): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost("h%s%s" % (i, r))) + self.addLink(r, self.addHost(f"h{i}{r}")) r4, r5 = self.addRouters("r4", "r5", use_v4=False, use_v6=True) self.addLink(r2, r5) @@ -39,7 +39,7 @@ def build(self, *args, **kwargs): self.addLink(r4, r5, igp_metric=10) for r in (r4, r5): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost("h%s%s" % (i, r))) + self.addLink(r, self.addHost(f"h{i}{r}")) r6, r7 = self.addRouters("r6", "r7", use_v4=False, use_v6=True) self.addLink(r3, r6) @@ -47,6 +47,6 @@ def build(self, *args, **kwargs): self.addLink(r6, r7) for r in (r6, r7): for i in range(HOSTS_PER_ROUTER): - self.addLink(r, self.addHost("h%s%s" % (i, r))) + self.addLink(r, self.addHost(f"h{i}{r}")) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree.py b/ipmininet/examples/spanning_tree.py index 4a595c36..613a1258 100644 --- a/ipmininet/examples/spanning_tree.py +++ b/ipmininet/examples/spanning_tree.py @@ -26,6 +26,6 @@ def build(self, *args, **kwargs): s3 = self.addSwitch("s3", prio="1") self.addLinks((s1, s2), (s1, s3), (s3, s2)) for s in (s1, s2, s3): - self.addLink(s, self.addHost("h%s" % s)) + self.addLink(s, self.addHost(f"h{s}")) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree_adjust.py b/ipmininet/examples/spanning_tree_adjust.py index c6535f75..c85bb84b 100644 --- a/ipmininet/examples/spanning_tree_adjust.py +++ b/ipmininet/examples/spanning_tree_adjust.py @@ -139,7 +139,7 @@ def build(self, *args, **kwargs): ) for s in self.switches(): - self.addLink(s, self.addHost("h%s" % s)) + self.addLink(s, self.addHost(f"h{s}")) super().build(*args, **kwargs) @@ -149,8 +149,8 @@ def addLink(self, node1, node2, port1=None, port2=None, key=None, **opts): # Adjust STP cost if the link was in the parameters itfs = [ - "%s-eth%d" % (node1, link.link_attrs["port1"]), - "%s-eth%d" % (node2, link.link_attrs["port2"]), + f"{node1}-eth{link.link_attrs['port1']}", + f"{node2}-eth{link.link_attrs['port2']}", ] if self.l1_start in itfs and self.l1_end in itfs: link[0].addParams(stp_cost=self.l1_cost) diff --git a/ipmininet/examples/spanning_tree_bus.py b/ipmininet/examples/spanning_tree_bus.py index 8ba60173..baa80aec 100644 --- a/ipmininet/examples/spanning_tree_bus.py +++ b/ipmininet/examples/spanning_tree_bus.py @@ -31,6 +31,6 @@ def build(self, *args, **kwargs): self.addLink(s1, s3) for s in (s1, s2, s3): - self.addLink(s, self.addHost("h%s" % s)) + self.addLink(s, self.addHost(f"h{s}")) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree_cost.py b/ipmininet/examples/spanning_tree_cost.py index 3af86d92..a9410cfe 100644 --- a/ipmininet/examples/spanning_tree_cost.py +++ b/ipmininet/examples/spanning_tree_cost.py @@ -22,6 +22,6 @@ def build(self, *args, **kwargs): self.addLink(s3, s1, stp_cost=10) for s in self.switches(): - self.addLink(s, self.addHost("h%s" % s)) + self.addLink(s, self.addHost(f"h{s}")) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree_full_mesh.py b/ipmininet/examples/spanning_tree_full_mesh.py index 9fa577a3..d30d3de7 100644 --- a/ipmininet/examples/spanning_tree_full_mesh.py +++ b/ipmininet/examples/spanning_tree_full_mesh.py @@ -59,6 +59,6 @@ def build(self, *args, **kwargs): ) for s in (s10, s11, s1, s2, s3, s4, s12, s17): - self.addLink(s, self.addHost("h%s" % s)) + self.addLink(s, self.addHost(f"h{s}")) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree_hub.py b/ipmininet/examples/spanning_tree_hub.py index ca2e13e9..0eb9f078 100644 --- a/ipmininet/examples/spanning_tree_hub.py +++ b/ipmininet/examples/spanning_tree_hub.py @@ -49,6 +49,6 @@ def build(self, *args, **kwargs): ) for s in self.switches(): - self.addLink(s, self.addHost("h%s" % s)) + self.addLink(s, self.addHost(f"h{s}")) super().build(*args, **kwargs) diff --git a/ipmininet/examples/spanning_tree_intermediate.py b/ipmininet/examples/spanning_tree_intermediate.py index 3b75dadd..6982be8c 100644 --- a/ipmininet/examples/spanning_tree_intermediate.py +++ b/ipmininet/examples/spanning_tree_intermediate.py @@ -27,6 +27,6 @@ def build(self, *args, **kwargs): self.addLinks((s2, s9), (s9, s5), (s5, s4), (s2, s10), (s4, s10), (s5, s10)) for s in (s2, s4, s5, s9, s10): - self.addLink(s, self.addHost("h%s" % s)) + self.addLink(s, self.addHost(f"h{s}")) super().build(*args, **kwargs) diff --git a/ipmininet/examples/tc_advanced_network.py b/ipmininet/examples/tc_advanced_network.py index 2ffced6e..9bbe3e3f 100644 --- a/ipmininet/examples/tc_advanced_network.py +++ b/ipmininet/examples/tc_advanced_network.py @@ -5,6 +5,8 @@ details, you can refer to the IPMininet documentation. """ +import contextlib + from ipmininet.iptopo import IPTopo @@ -31,17 +33,13 @@ def addLink(self, node1, node2, delay="2ms", bw=None, max_queue_size=None, **opt opts1 = dict(opts) if "params2" in opts1: opts1.pop("params2") - try: + with contextlib.suppress(KeyError): src_delay = opts.get("params1", {}).pop("delay") - except KeyError: - pass opts2 = dict(opts) if "params1" in opts2: opts2.pop("params1") - try: + with contextlib.suppress(KeyError): dst_delay = opts.get("params2", {}).pop("delay") - except KeyError: - pass src_delay = src_delay or delay dst_delay = dst_delay or delay @@ -64,6 +62,6 @@ def addLink(self, node1, node2, delay="2ms", bw=None, max_queue_size=None, **opt # Netem queues will mess with shaping # Therefore, we put them on an intermediary switch self.switch_count += 1 - s = "s%d" % self.switch_count + s = f"s{self.switch_count}" self.addSwitch(s) return super().addLink(node1, s, **opts1), super().addLink(s, node2, **opts2) diff --git a/ipmininet/host/__host.py b/ipmininet/host/__host.py index 6d70d256..996455b8 100644 --- a/ipmininet/host/__host.py +++ b/ipmininet/host/__host.py @@ -19,7 +19,7 @@ def __init__( *args, **kwargs, ): - super().__init__(name, config=config, *args, **kwargs) + super().__init__(name, *args, config=config, **kwargs) def setDefaultRoute(self, intf: IPIntf | str | None = None, v6=False): """Set the default routes to go through the intfs. @@ -36,7 +36,7 @@ def setDefaultRoute(self, intf: IPIntf | str | None = None, v6=False): if opt == "dev": intf = self.intf(option_list[i + 1]) else: - params = "dev %s" % intf + params = f"dev {intf}" # Set command to be re-executed if interface is downed # and then brought back up diff --git a/ipmininet/host/config/dnsmasq.py b/ipmininet/host/config/dnsmasq.py index bf6fadf1..c9d7490c 100644 --- a/ipmininet/host/config/dnsmasq.py +++ b/ipmininet/host/config/dnsmasq.py @@ -50,11 +50,11 @@ def dry_run(self): @property def cfg_filenames(self): - return [self._file(suffix="%s.cfg" % "_".join(self.intfs))] + return [self._file(suffix="{}.cfg".format("_".join(self.intfs)))] @property def template_filenames(self): - return super().template_filenames + ["%s.mako" % self.NAME] + return [*super().template_filenames, f"{self.NAME}.mako"] def set_defaults(self, defaults): pass diff --git a/ipmininet/host/config/named.py b/ipmininet/host/config/named.py index 66e76306..84e6ae7a 100644 --- a/ipmininet/host/config/named.py +++ b/ipmininet/host/config/named.py @@ -81,9 +81,12 @@ def build(self): def build_zone(self, zone: "DNSZone") -> ConfigDict: master_ips = [] - for s_name in ( - zone.servers + [zone.dns_master] + zone.dns_slaves + zone.delegation_servers - ): + for s_name in [ + *zone.servers, + zone.dns_master, + *zone.dns_slaves, + *zone.delegation_servers, + ]: server_itf = find_node(self._node, dns_base_name(s_name)) if server_itf is None: lg.error( @@ -240,7 +243,7 @@ def set_defaults(self, defaults): super().set_defaults(defaults) def zone_filename(self, domain_name: str) -> str: - return self._file(suffix="%szone.cfg" % domain_name) + return self._file(suffix=f"{domain_name}zone.cfg") @property def cfg_filenames(self): @@ -253,7 +256,7 @@ def cfg_filenames(self): @property def template_filenames(self): return super().template_filenames + [ - "%s-zone.mako" % self.NAME + f"{self.NAME}-zone.mako" for _ in self._node.get("dns_zones", []) + self.additional_zone_filenames ] @@ -416,10 +419,10 @@ def __init__( expire_time=expire_time, min_ttl=min_ttl, ) - super().__init__(nodes=[dns_master] + list(dns_slaves)) + super().__init__(nodes=[dns_master, *list(dns_slaves)]) self.consistent = True - for node_name in [dns_master] + self.dns_slaves + self.servers: + for node_name in [dns_master, *self.dns_slaves, *self.servers]: if "." in node_name: lg.error( f"Cannot create zone {self.name} because the node name" @@ -495,7 +498,7 @@ def apply(self, topo): for zone in self.delegated_zones: # Create NSRecords for the delegated subdomains - for ns in [zone.dns_master] + zone.dns_slaves: + for ns in [zone.dns_master, *zone.dns_slaves]: record = NSRecord(zone.name, dns_join_name(ns, zone.ns_domain_name)) self.add_record(record) self.delegation_servers.append(record.name_server) diff --git a/ipmininet/install/install.py b/ipmininet/install/install.py index 3f7f4c7d..12b625c1 100644 --- a/ipmininet/install/install.py +++ b/ipmininet/install/install.py @@ -16,7 +16,7 @@ # https://github.com/mininet/mininet/issues/1120 MininetInstallCommit = "c3ba039a9781c6c5f475b7c88ff577185747a1da" -os.environ["PATH"] = "%s:/sbin:/usr/sbin/:/usr/local/sbin" % os.environ["PATH"] +os.environ["PATH"] = "{}:/sbin:/usr/sbin/:/usr/local/sbin".format(os.environ["PATH"]) def _needs_rebuild(*paths: str) -> bool: @@ -51,13 +51,13 @@ def parse_args(): parser.add_argument( "-q", "--install-frrouting", - help="Install FRRouting (version %s) daemons" % FRRoutingVersion, + help=f"Install FRRouting (version {FRRoutingVersion}) daemons", action="store_true", ) parser.add_argument( "-e", "--install-exabgp", - help="Install ExaBGP (version %s) daemon" % ExaBGPVersion, + help=f"Install ExaBGP (version {ExaBGPVersion}) daemon", action="store_true", ) parser.add_argument( @@ -108,7 +108,7 @@ def install_mininet(output_dir: str, pip_install=True): cwd=output_dir, ) sh( - "git checkout %s" % MininetVersion, + f"git checkout {MininetVersion}", cwd=os.path.join(output_dir, "mininet"), ) sh("make mnexec", cwd=os.path.join(output_dir, "mininet")) @@ -118,15 +118,15 @@ def install_mininet(output_dir: str, pip_install=True): sh("git clone https://github.com/mininet/mininet.git", cwd=output_dir) # Save valid version of mininet install script sh( - "git checkout %s" % MininetInstallCommit, + f"git checkout {MininetInstallCommit}", cwd=os.path.join(output_dir, "mininet/util"), ) sh("cp install.sh install.tmp.sh", cwd=os.path.join(output_dir, "mininet/util")) # Use it in the fixed version of Mininet - sh("git checkout %s" % MininetVersion, cwd=os.path.join(output_dir, "mininet/util")) + sh(f"git checkout {MininetVersion}", cwd=os.path.join(output_dir, "mininet/util")) sh("mv install.tmp.sh install.sh", cwd=os.path.join(output_dir, "mininet/util")) sh( - "./install.sh %s -s ." % mininet_opts, + f"./install.sh {mininet_opts} -s .", cwd=os.path.join(output_dir, "mininet/util"), ) @@ -150,7 +150,7 @@ def install_libyang(output_dir: str): cwd=output_dir, ) cloned_repo = os.path.join(output_dir, "libyang") - sh("git checkout %s" % LibyangVersion, "mkdir build", cwd=cloned_repo) + sh(f"git checkout {LibyangVersion}", "mkdir build", cwd=cloned_repo) sh( 'cmake -DENABLE_LYD_PRIV=ON -DCMAKE_INSTALL_PREFIX:PATH=/usr -D CMAKE_BUILD_TYPE:String="Release" ..', "make", @@ -217,23 +217,23 @@ def install_frrouting(output_dir: str): frrouting_install = os.path.join(output_dir, "frr") if _needs_rebuild(os.path.join(frrouting_install, "sbin", "zebra")): - frrouting_src = os.path.join(output_dir, "frr-%s" % FRRoutingVersion) + frrouting_src = os.path.join(output_dir, f"frr-{FRRoutingVersion}") frrouting_tar = frrouting_src + ".tar.gz" sh( f"wget https://github.com/FRRouting/frr/releases/download/frr-{FRRoutingVersion}/" f"frr-{FRRoutingVersion}.tar.gz", - "tar -zxvf '%s'" % frrouting_tar, + f"tar -zxvf '{frrouting_tar}'", cwd=output_dir, ) sh( - "./configure '--prefix=%s'" % frrouting_install, + f"./configure '--prefix={frrouting_install}'", "make", "make install", cwd=frrouting_src, ) - sh("rm -r '%s' '%s'" % (frrouting_src, frrouting_tar)) + sh(f"rm -r '{frrouting_src}' '{frrouting_tar}'") sh("groupadd frr", may_fail=True) sh("groupadd frrvty", may_fail=True) @@ -242,7 +242,7 @@ def install_frrouting(output_dir: str): for curr_dir in ("sbin", "bin"): link_to_standard_dir( - os.path.join(frrouting_install, curr_dir), "/usr/%s" % curr_dir + os.path.join(frrouting_install, curr_dir), f"/usr/{curr_dir}" ) @@ -270,7 +270,7 @@ def install_openr(output_dir: str, may_fail=False): def install_exabgp(output_dir: str, may_fail=False): git_url = "https://github.com/Exa-Networks/exabgp.git" - exabgp_src_folder = "exabgp-%s-src" % ExaBGPVersion + exabgp_src_folder = f"exabgp-{ExaBGPVersion}-src" exabgp_path_src_dir = os.path.join(output_dir, exabgp_src_folder) exabgp_self_executable = os.path.join(output_dir, "exabgp") final_link = "/usr/sbin/exabgp" @@ -286,7 +286,7 @@ def install_exabgp(output_dir: str, may_fail=False): may_fail=may_fail, ) - sh("git checkout %s" % ExaBGPVersion, cwd=exabgp_path_src_dir, may_fail=may_fail) + sh(f"git checkout {ExaBGPVersion}", cwd=exabgp_path_src_dir, may_fail=may_fail) # create self-contained executable sh( @@ -317,8 +317,8 @@ def enable_ipv6(): grub_cfg = "/etc/default/grub" if not os.path.exists(grub_cfg): print( - "Skipping IPv6 grub configuration: %s not present" - " (e.g. inside a container)" % grub_cfg, + f"Skipping IPv6 grub configuration: {grub_cfg} not present" + " (e.g. inside a container)", file=sys.stderr, ) return @@ -350,6 +350,6 @@ def enable_ipv6(): dist = identify_distribution() if dist is None: supported = ", ".join([d.NAME for d in supported_distributions()]) - print("The installation script only supports %s" % supported) + print(f"The installation script only supports {supported}") sys.exit(1) dist.update() diff --git a/ipmininet/install/utils.py b/ipmininet/install/utils.py index 603dad86..43f6a455 100644 --- a/ipmininet/install/utils.py +++ b/ipmininet/install/utils.py @@ -24,7 +24,7 @@ def sh(*cmds, **kwargs) -> subprocess.Popen | None: except OSError as e: if not may_fail: raise - sys.stderr.write("*** failed to run %s: %s\n" % (cmd, e)) + sys.stderr.write(f"*** failed to run {cmd}: {e}\n") continue if output_stdout: @@ -59,20 +59,20 @@ def check_pip_version(self, pip: str) -> str: if find_executable(pip) is None: return "" - p = sh("%s -V" % pip, output_stdout=False) + p = sh(f"{pip} -V", output_stdout=False) if p is None or p.wait() != 0: - print("Print cannot get the version of %s" % pip) + print(f"Print cannot get the version of {pip}") return "" content, _ = p.communicate() try: v = content.decode("utf-8").split(" ")[1] - + except (ValueError, IndexError): + print(f"Cannot retrieve version number of {pip}") + sys.exit(1) + else: if parse_version(v) >= parse_version(self.SpinPipVersion): return "" return "--process-dependency-links" - except (ValueError, IndexError): - print("Cannot retrieve version number of %s" % pip) - sys.exit(1) def install(self, *packages: str): if self.INSTALL_CMD: @@ -94,7 +94,7 @@ def pip_install(self, *packages: str, **kwargs): def require_pip(self): if find_executable(self.PIP_CMD) is None: - raise RuntimeError("Cannot find %s" % self.PIP_CMD) + raise RuntimeError(f"Cannot find {self.PIP_CMD}") class Ubuntu(Distribution): diff --git a/ipmininet/ipnet.py b/ipmininet/ipnet.py index 19c92596..05abbce2 100644 --- a/ipmininet/ipnet.py +++ b/ipmininet/ipnet.py @@ -97,13 +97,13 @@ def __init__( self.allocate_IPs = allocate_IPs self.physical_interface = {} # type: Dict[IPIntf, Node] super().__init__( + *args, ipBase=ipBase, host=host, switch=switch, link=link, intf=intf, controller=controller, - *args, **kwargs, ) @@ -124,8 +124,7 @@ def addRouter(self, name: str, cls=None, **params) -> Router: def __iter__(self): for r in self.routers: yield r.name - for n in super().__iter__(): - yield n + yield from super().__iter__() def __len__(self): return len(self.routers) + super().__len__() @@ -204,7 +203,7 @@ def addLink( # Only iff not already specified if k not in p: p[k] = v - return super().addLink(node1=node1, node2=node2, *args, **params) + return super().addLink(*args, node1=node1, node2=node2, **params) def addHost(self, name: str, **params) -> IPHost: """Prevent Mininet from forcing the allocation of IPv4 addresses @@ -237,7 +236,7 @@ def start(self): if "defaultRoute" in h.params: continue # Skipping hosts with explicit default route if not h.createDefaultRoutes(): - log.info("skipping %s , " % h.name) + log.info(f"skipping {h.name} , ") log.info("\n") def stop(self): @@ -396,15 +395,16 @@ def _allocate_subnets( ) log.debug("Allocating prefix", plen, "for interfaces", d.interfaces) # Try to find a suitable subnet in the list - for i, net in enumerate(subnets): + for i, subnet in enumerate(subnets): nets = [] + cur = subnet # if the subnet is too big for the prefix, perform a left # expansion (only expand one at a time to keep subnets as # aggregated as possible). - while plen > net.prefixlen: + while plen > cur.prefixlen: # Get list of subnets and append to list of previous # expanded subnets as it is bigger wrt. prefixlen - net, next_net = tuple(net.subnets(prefixlen_diff=1)) + cur, next_net = tuple(cur.subnets(prefixlen_diff=1)) # If not a subnet of an allocated subnet if ( len( @@ -419,7 +419,7 @@ def _allocate_subnets( ): nets.append(next_net) # Check if we have an appropriately-sized subnet - if plen == net.prefixlen: + if plen == cur.prefixlen: # If the network overlaps with an allocated subnet, # we pass it if ( @@ -427,7 +427,7 @@ def _allocate_subnets( list( filter( lambda y: ( - is_subnet_of(net, y) or is_subnet_of(y, net) + is_subnet_of(cur, y) or is_subnet_of(y, cur) ), allocated_subnets, ) @@ -436,7 +436,7 @@ def _allocate_subnets( == 0 ): # Register the allocation - setattr(d, net_key, net) + setattr(d, net_key, cur) # Delete the expanded/used subnet del subnets[i] # Insert the created subnets if any @@ -457,9 +457,9 @@ def _broadcast_domains(self) -> list["BroadcastDomain"]: for intf in realIntfList(n) } interfaces.update({r.intf("lo"): False for r in self.routers}) - for intf in interfaces: + for intf, visited in interfaces.items(): # the interface already belongs to a broadcast domain - if interfaces[intf]: + if visited: continue # create a new domain and explore the interface bd = BroadcastDomain(intf) @@ -490,17 +490,17 @@ def _ping_set( packets = 0 opts = "" if timeout: - opts = "-W %s" % timeout + opts = f"-W {timeout}" - log.output("%s --%s--> " % (src.name, "IPv4" if v4 else "IPv6")) + log.output("{} --{}--> ".format(src.name, "IPv4" if v4 else "IPv6")) for dst, dst_ip in dst_dict.items(): result = src.cmd( - "%s -c1 %s %s" % ("ping" if v4 else PING6_CMD, opts, dst_ip) + "{} -c1 {} {}".format("ping" if v4 else PING6_CMD, opts, dst_ip) ) sent, received = self._parsePing(result) lost += sent - received packets += sent - log.output("%s " % dst.name if received else "X ") + log.output(f"{dst.name} " if received else "X ") log.output("\n") return lost, packets @@ -537,8 +537,7 @@ def ping( return 0 log.output( - "*** Ping: testing reachability over %s%s%s\n" - % ( + "*** Ping: testing reachability over {}{}{}\n".format( "IPv4" if use_v4 else "", " and " if use_v4 and use_v6 else "", "IPv6" if use_v6 else "", @@ -575,16 +574,15 @@ def ping( for node1, incompatibilities in incompatible_hosts.items(): for node2 in incompatibilities: log.output( - "*** Warning: %s and %s have no global address " - "in the same IP version\n" % (node1, node2) + f"*** Warning: {node1} and {node2} have no global address " + "in the same IP version\n" ) if packets > 0: ploss = 100.0 * lost / packets received = packets - lost log.output( - "*** Results: %i%% dropped (%d/%d received)\n" - % (ploss, received, packets) + f"*** Results: {ploss}% dropped ({received}/{packets} received)\n" ) else: ploss = 0 @@ -693,7 +691,7 @@ class BroadcastDomain: # FIXME Where do we put middleboxes in this model ? BOUNDARIES = (Host, IPHost, Router) - def __init__(self, interfaces: None | list[IPIntf] | IPIntf = None): + def __init__(self, interfaces: list[IPIntf] | IPIntf | None = None): """Initialize the broadcast domain and optionally explore a set of interfaces @@ -737,23 +735,15 @@ def __iter__(self) -> Iterator[IPIntf]: def len_v4(self) -> int: """The number of IPv4 addresses in this broadcast domain""" return sum( - map( - lambda x: x.interface_width[0] if len(list(x.ips())) > 0 else 0, - self.interfaces, - ) + x.interface_width[0] if len(list(x.ips())) > 0 else 0 + for x in self.interfaces ) def len_v6(self) -> int: """The number of IPv6 addresses in this broadcast domain""" return sum( - map( - lambda x: ( - x.interface_width[1] - if len(list(x.ip6s(exclude_lls=True))) > 0 - else 0 - ), - self.interfaces, - ) + x.interface_width[1] if len(list(x.ip6s(exclude_lls=True))) > 0 else 0 + for x in self.interfaces ) def explore(self, itfs: list[IPIntf]): @@ -786,15 +776,12 @@ def max_v4prefixlen(self) -> int: """Return the maximal IPv4 prefix suitable for this domain""" # IPv4 reserves 2 addresses for broadcast/subnet addresses return 32 - math.ceil( - math.log( + math.log2( 2 + sum( - map( - lambda x: x.interface_width[1] if x.node.use_v4 else 0, - self.interfaces, - ) - ), - 2, + x.interface_width[1] if x.node.use_v4 else 0 + for x in self.interfaces + ) ) ) @@ -804,15 +791,12 @@ def max_v6prefixlen(self) -> int: # IPv6 should use whole subnet space for addressing # But see FIXME in constructor return 128 - math.ceil( - math.log( + math.log2( 1 + sum( - map( - lambda x: x.interface_width[1] if x.node.use_v6 else 0, - self.interfaces, - ) - ), - 2, + x.interface_width[1] if x.node.use_v6 else 0 + for x in self.interfaces + ) ) ) @@ -831,9 +815,9 @@ def next_ipv4(self) -> IPv4Interface: try: addr = self.net[self._allocated_v4] self._allocated_v4 += 1 - return ip_interface("%s/%d" % (addr, self.net.prefixlen)) + return ip_interface(f"{addr}/{self.net.prefixlen}") except IndexError: - raise ValueError("No more available IPv4 address") + raise ValueError("No more available IPv4 address") from None def next_ipv6(self) -> IPv6Interface: """Allocate and return the next available IPv6 address in this @@ -845,9 +829,9 @@ def next_ipv6(self) -> IPv6Interface: try: addr = self.net6[self._allocated_v6] self._allocated_v6 += 1 - return ip_interface("%s/%d" % (addr, self.net6.prefixlen)) + return ip_interface(f"{addr}/{self.net6.prefixlen}") except IndexError: - raise ValueError("No more available IPv6 address") + raise ValueError("No more available IPv6 address") from None def use_ip_version(self, ip_version) -> bool: """Checks whether there are nodes using this IP version diff --git a/ipmininet/ipovs_switch.py b/ipmininet/ipovs_switch.py index a29500bd..9114bdb0 100644 --- a/ipmininet/ipovs_switch.py +++ b/ipmininet/ipovs_switch.py @@ -43,34 +43,33 @@ def __init__( def start(self, controllers): """Start up a new OVS OpenFlow switch using ovs-vsctl""" if self.inNamespace: - raise Exception("OVS kernel switch does not work in a namespace") + raise RuntimeError("OVS kernel switch does not work in a namespace") self.cmd("ifconfig", self, "down") int(self.dpid, 16) # DPID must be a hex string # Command to add interfaces intfs = "".join( - " -- add-port %s %s" % (self, intf) + self.intfOpts(intf) + f" -- add-port {self} {intf}" + self.intfOpts(intf) for intf in self.intfList() if self.ports[intf] and not intf.IP() ) # Command to create controller entries clist = [ - (self.name + c.name, "%s:%s:%d" % (c.protocol, c.IP(), c.port)) - for c in controllers + (self.name + c.name, f"{c.protocol}:{c.IP()}:{c.port}") for c in controllers ] if self.listenPort: - clist.append((self.name + "-listen", "ptcp:%s" % self.listenPort)) + clist.append((self.name + "-listen", f"ptcp:{self.listenPort}")) ccmd = '-- --id=@%s create Controller target=\\"%s\\"' if self.reconnectms: - ccmd += " max_backoff=%d" % self.reconnectms + ccmd += f" max_backoff={self.reconnectms}" cargs = " ".join(ccmd % (name, target) for name, target in clist) # Try to delete any existing bridges with the same name if not self.isOldOVS(): - cargs += " --if-exists del-br %s" % self + cargs += f" --if-exists del-br {self}" # One ovs-vsctl command to rule them all! self.vsctl(cargs) - self.vsctl(" add-br %s" % self) - self.vsctl(" set bridge %s" % self + self.bridgeOpts()) + self.vsctl(f" add-br {self}") + self.vsctl(f" set bridge {self}" + self.bridgeOpts()) self.vsctl(intfs) # Start the captures on this switch for capture in self.params.get("captures", []): @@ -82,25 +81,23 @@ def start(self, controllers): def bridgeOpts(self): """Return OVS bridge options""" - opts = ( - " other_config:datapath-id=%s" % self.dpid + " fail_mode=%s" % self.failMode - ) + opts = f" other_config:datapath-id={self.dpid}" + f" fail_mode={self.failMode}" if self.priority is not None: opts += ( - " other_config:stp-priority=%s" % self.priority - + " other_config:rstp-priority=%s" % self.priority + f" other_config:stp-priority={self.priority}" + + f" other_config:rstp-priority={self.priority}" ) if not self.inband: opts += " other_config:disable-in-band=true" if self.datapath == "user": opts += " datapath_type=netdev" if self.protocols and not self.isOldOVS(): - opts += " protocols=%s" % self.protocols + opts += f" protocols={self.protocols}" if self.rstp and self.failMode == "standalone": opts += " rstp_enable=true" elif self.stp and self.failMode == "standalone": opts += " stp_enable=true" - opts += " other_config:dp-desc=%s" % self.name + opts += f" other_config:dp-desc={self.name}" return opts def stop(self, deleteIntfs=True): diff --git a/ipmininet/ipswitch.py b/ipmininet/ipswitch.py index 4fdac8f9..94ccfba0 100644 --- a/ipmininet/ipswitch.py +++ b/ipmininet/ipswitch.py @@ -39,9 +39,7 @@ def __init__( def start(self, _controllers): """Start Linux bridge""" - require_cmd( - "brctl", help_str="You need brctl to use %s objects" % self.__class__ - ) + require_cmd("brctl", help_str=f"You need brctl to use {self.__class__} objects") self.cmd("ifconfig", self, "down") self.cmd("brctl delbr", self) @@ -61,8 +59,8 @@ def start(self, _controllers): if self.name in i.name: self.cmd("brctl addif", self, i) self.cmd( - "brctl setpathcost" - " %s %s %d" % (self.name, i.name, i.params.get("stp_cost", 1)) + f"brctl setpathcost {self.name} {i.name} " + f"{i.params.get('stp_cost', 1)}" ) # Start the captures on this switch for capture in self.params.get("captures", []): diff --git a/ipmininet/iptopo.py b/ipmininet/iptopo.py index 6095ede5..6417a0cd 100644 --- a/ipmininet/iptopo.py +++ b/ipmininet/iptopo.py @@ -1,7 +1,7 @@ """This module defines topology class that supports adding L3 routers""" import itertools -from typing import Any +from typing import Any, ClassVar from mininet.log import lg from mininet.topo import Topo @@ -28,7 +28,7 @@ class IPTopo(Topo): """A topology that supports L3 routers""" - OVERLAYS = { + OVERLAYS: ClassVar[dict[str, type["Overlay"]]] = { cls.__name__: cls for cls in ( AS, @@ -104,14 +104,12 @@ def addRouters( # and the specific options of the router n, opt = router_info if is_container(router_info) else (router_info, {}) # Merge router options by giving precedence to specific ones - router_opts = { - k: v for k, v in itertools.chain(common_opts.items(), opt.items()) - } + router_opts = dict(itertools.chain(common_opts.items(), opt.items())) try: new_routers.append(self.addRouter(n, **router_opts)) - except Exception as e: + except Exception: lg.error(f"Cannot create router '{n}' with options '{router_opts}'") - raise e + raise return new_routers @@ -130,10 +128,9 @@ def addLink( ) # Create an abstraction to allow additional calls - link_description = LinkDescription( + return LinkDescription( self, node1, node2, key, self.linkInfo(node1, node2, key) ) - return link_description def addLinks( self, *links: tuple[str, str] | tuple[str, str, dict[str, Any]], **common_opts @@ -145,20 +142,18 @@ def addLinks( :param common_opts: common link options (optional)""" new_links = [] - for u, v, *opt in links: + for u, v, *opts in links: # Merge link options by giving precedence to specific ones - opt = opt[0] if opt else {} - link_opts = { - k: v for k, v in itertools.chain(common_opts.items(), opt.items()) - } + opt = opts[0] if opts else {} + link_opts = dict(itertools.chain(common_opts.items(), opt.items())) try: new_links.append(self.addLink(u, v, **link_opts)) - except Exception as e: + except Exception: lg.error( f"Cannot create link between '{u}' and '{v}'" f" with options '{link_opts}'" ) - raise e + raise return new_links @@ -200,8 +195,7 @@ def addHub(self, name: str, **opts) -> str: returns: hub name""" if not opts and self.sopts: opts = self.sopts - result = self.addSwitch(name, stp=False, hub=True, **opts) - return result + return self.addSwitch(name, stp=False, hub=True, **opts) def isRouter(self, n: str) -> bool: """Check whether the given node is a router @@ -242,7 +236,7 @@ def __getattr__(self, item): except KeyError: pass raise AttributeError( - "%s is neither a method of IPTopo nor refers to any known overlay" % item + f"{item} is neither a method of IPTopo nor refers to any known overlay" ) def getNodeInfo(self, n: str, key, default: type): diff --git a/ipmininet/link.py b/ipmininet/link.py index 89c3fd6d..ae0f1978 100644 --- a/ipmininet/link.py +++ b/ipmininet/link.py @@ -167,7 +167,7 @@ def _set_ip( | IPv6Interface | Sequence[str | IPv4Interface | IPv6Interface], prefixLen: int | None = None, - ) -> None | list[str] | str: + ) -> list[str] | str | None: """Set one or more IP addresses, possibly from different families. This will remove previously set addresses of the affected families. @@ -186,18 +186,19 @@ def _set_ip( # We want to iterate over the new ip sets if not is_container(ip): ip = (ip,) - for addr in ip: + for a in ip: # Make sure we have ip_interface-like objects + addr = a if isinstance(addr, str): if "/" not in addr and prefixLen is not None: # And use the default prefix if absent - addr = ip_interface("%s/%s" % (addr, prefixLen)) + addr = ip_interface(f"{addr}/{prefixLen}") else: # no prefixLen defaults to full /128 or /32 addr = ip_interface(str(addr)) # Prepare assignment commands - cmds.append("ip address add dev %s %s" % (self.name, addr.with_prefixlen)) + cmds.append(f"ip address add dev {self.name} {addr.with_prefixlen}") # Record assignment family if addr.version == 4: setv4 = True @@ -331,7 +332,7 @@ def __init__( self, node1: str, node2: str, intf: type[IPIntf] = IPIntf, *args, **kwargs ): """We override Link intf default to use IPIntf""" - super().__init__(node1=node1, node2=node2, intf=intf, *args, **kwargs) + super().__init__(*args, node1=node1, node2=node2, intf=intf, **kwargs) # This aliases is there for a historical reason: IPIntf used to extend @@ -347,6 +348,8 @@ def __init__(self, addr): def __eq__(self, other): return address_comparator(self.addr, other.addr) == 0 + __hash__ = None + def __lt__(self, other): return address_comparator(self.addr, other.addr) < 0 @@ -398,21 +401,18 @@ def __init__(self, name: str, *args, **kw): try: node = kw["node"] except KeyError: - raise ValueError("PhysicalInterface() requires a node= argument") + raise ValueError("PhysicalInterface() requires a node= argument") from None # Save the addresses from the root namespace try: _, v4, v6 = _addresses_of(name, node=None) except (subprocess.CalledProcessError, OSError): log.error("Cannot retrieve the addresses of interface", name, "!") - raise ValueError("Unknown physical interface name") - if node.inNamespace: - # cfr man ip-link; some devices cannot change of net ns - if "netns-local: on" in subprocess.check_output( - ("ethtool", "-k", name) - ).decode("utf-8"): - log.error( - "Cannot move interface", name, "into another network namespace!" - ) + raise ValueError("Unknown physical interface name") from None + # cfr man ip-link; some devices cannot change of net ns + if node.inNamespace and "netns-local: on" in subprocess.check_output( + ("ethtool", "-k", name) + ).decode("utf-8"): + log.error("Cannot move interface", name, "into another network namespace!") super().__init__(name, *args, **kw) # Exclude link locals ... v4.extend(ip for ip in v6 if not ip.is_link_local) @@ -465,7 +465,7 @@ def setup_tunnel(self): @staticmethod def _gre_name(x) -> str: - return "gre-%s" % x + return f"gre-{x}" @staticmethod def _add_tunnel( diff --git a/ipmininet/node_description.py b/ipmininet/node_description.py index cfacd945..4582772c 100644 --- a/ipmininet/node_description.py +++ b/ipmininet/node_description.py @@ -1,5 +1,5 @@ import functools -from typing import Optional +from typing import TYPE_CHECKING, Optional from ipmininet.host.config import HostConfig from ipmininet.router.config import ( @@ -10,6 +10,9 @@ ) from ipmininet.router.config.base import Daemon, NodeConfig, RouterConfig +if TYPE_CHECKING: + from ipmininet.iptopo import IPTopo + class NodeDescription(str): def __new__(cls, value, *args, **kwargs): @@ -113,7 +116,7 @@ def __getitem__(self, item): return self.src_intf if item == self.dst: return self.dst_intf - raise KeyError("Node '%s' is not on this link" % item) + raise KeyError(f"Node '{item}' is not on this link") # The following methods allow this object to behave like an edge key # for mininet.topo.MultiGraph diff --git a/ipmininet/overlay.py b/ipmininet/overlay.py index 75fd7c87..10bcfd9b 100644 --- a/ipmininet/overlay.py +++ b/ipmininet/overlay.py @@ -4,7 +4,6 @@ import subprocess import threading import time -from builtins import str from collections import defaultdict from collections.abc import Sequence from ipaddress import ip_network @@ -42,8 +41,8 @@ def __init__( :param lprops: the properties shared by all links in this overlay""" self.nodes = list(nodes) self.links = list(links) - self.nodes_properties = {} if not nprops else nprops - self.links_properties = {} if not lprops else lprops + self.nodes_properties = nprops or {} + self.links_properties = lprops or {} self.per_link_properties = defaultdict(dict) # type: Mapping self.per_node_properties = defaultdict(dict) # type: Mapping @@ -52,8 +51,8 @@ def apply(self, topo: "IPTopo"): # First set the common properties, then the element-specific ones for n in self.nodes: topo.nodeInfo(n).update(self.node_property(n)) - for l in self.links: - topo.linkInfo(l[0], l[1]).update(self.link_property(l)) + for link in self.links: + topo.linkInfo(link[0], link[1]).update(self.link_property(link)) def check_consistency(self, topo: "IPTopo") -> bool: """Check that this overlay is consistent""" @@ -133,9 +132,9 @@ def apply(self, topo): for value in self.node_links[node]: attrs = value - addr = "%s/%d" % (subnet[i + 1], subnet.prefixlen) - addrs = tuple(attrs.get("ip", tuple())) - attrs["ip"] = addrs + (addr,) + addr = f"{subnet[i + 1]}/{subnet.prefixlen}" + addrs = tuple(attrs.get("ip", ())) + attrs["ip"] = (*addrs, addr) def _check_subnets(self) -> bool: """ @@ -146,12 +145,12 @@ def _check_subnets(self) -> bool: for subnet in self.subnets: if ip_network(str(subnet)).num_addresses - 1 < len(self.nodes): lg.error( - "The subnet %s does not contain enough addresses." - " We need %s addresses\n" % (subnet, len(self.nodes)) + f"The subnet {subnet} does not contain enough addresses." + f" We need {len(self.nodes)} addresses\n" ) return False except ValueError as e: - lg.error("One of the subnet is invalid: %s\n" % e) + lg.error(f"One of the subnet is invalid: {e}\n") return False return True @@ -189,9 +188,9 @@ def _find_nodes_in_lan(self, topo: "IPTopo", nodes: list[str]) -> bool: count_nodes = 0 count_links = 0 for previous, n_start, k_start, n_start_value in adjacencies[nodes[0]]: - nodes_0_value = [ + nodes_0_value = next( x for x in adjacencies[n_start] if x[1] == nodes[0] and x[2] == k_start - ][0][3] + )[3] node_links = {nodes[0]: [nodes_0_value]} count_nodes = 1 @@ -233,18 +232,19 @@ def _find_nodes_in_lan(self, topo: "IPTopo", nodes: list[str]) -> bool: break # Found the LAN that includes all the nodes if count_nodes != len(nodes): - lg.error("The nodes of %s are not in the same LAN\n" % self) + lg.error(f"The nodes of {self} are not in the same LAN\n") return False self.node_links = node_links return True def __str__(self): - return "" % (self.nodes, self.subnets) + return f"" class NetworkCapture(Overlay): - """This overlays capture traffic on multiple interfaces before starting the daemons and stores the result""" + """Overlay capturing traffic on multiple interfaces before starting the + daemons, and stores the result""" def __init__( self, @@ -254,12 +254,16 @@ def __init__( extra_arguments: str = "", ): """ - :param nodes: The routers and hosts that needs to capture traffic on every of their interfaces + :param nodes: The routers and hosts that need to capture traffic on + every of their interfaces :param interfaces: The interfaces on which traffic should be captured - :param base_filename: The base name of the network capture. One file by router or interface will be created - of the form "{base_filename}_{router/interface}.pcapng" in the working directory of the node on which - each capture is made. - :param extra_arguments: The string encoding any additional argument for the tcpdump call + :param base_filename: The base name of the network capture. One file + by router or interface will be created + of the form "{base_filename}_{router/interface} + .pcapng" in the working directory of the node + on which each capture is made. + :param extra_arguments: The string encoding any additional argument + for the tcpdump call """ self.base_filename = base_filename @@ -284,13 +288,17 @@ def start( self, node: Optional["IPNode"] = None, intf: Optional["IPIntf"] = None ) -> Popen: if node is not None: - # We need to specify the interfaces explicitly because switches that are loaded on the root namespace - # and therefore using '-i any' would listen on all the switches + # Specify the interfaces explicitly because switches that are + # loaded on the root namespace would otherwise use '-i any' and + # listen on all the switches interfaces = [itf.name for itf in node.intfList()] file_path = os.path.join( node.cwd, self.base_filename + "_" + node.name + ".pcapng" ) - cmd = f"tcpdump -Z root -i {' -i '.join(interfaces)} -w {file_path} {self.extra_arguments}" + cmd = ( + f"tcpdump -Z root -i {' -i '.join(interfaces)} " + f"-w {file_path} {self.extra_arguments}" + ) process = node.popen(shlex.split(cmd)) self._output_files[node.name] = file_path self.ongoing_captures[node.name] = process @@ -306,7 +314,10 @@ def start( file_path_out = os.path.join( intf.node.cwd, self.base_filename + "_" + intf.name + "_out.pcapng" ) - cmd = f"mimidump {intf.name} {file_path} {file_path_out} {self.extra_arguments}" + cmd = ( + f"mimidump {intf.name} {file_path} {file_path_out} " + f"{self.extra_arguments}" + ) process = intf.node.popen(shlex.split(cmd)) self._output_files[intf.name] = file_path_out diff --git a/ipmininet/router/__router.py b/ipmininet/router/__router.py index 3cdc8434..362a73d6 100644 --- a/ipmininet/router/__router.py +++ b/ipmininet/router/__router.py @@ -74,7 +74,8 @@ def terminate(self): # if the process has not terminated yet, # we send a SIGKILL as ultimate resort. p.kill() - p.wait() # use p.wait to free kernel resources and clean the process table + # p.wait frees kernel resources and cleans the process table + p.wait() except OSError: pass # Process is already dead @@ -117,7 +118,7 @@ class or a tuple (class, kwargs) except ValueError: lg.error( "Expected a tuple (class, kwargs) for the config " - "parameter but got instead %s" % str(config) + f"parameter but got instead {config!s}" ) else: self.nconfig = config(self) @@ -179,7 +180,8 @@ def start(self): tentative_chk = self._processes.call(tentative_cmd) lg.debug( self._processes.node.name, - "All IPv6 addresses has passed the Duplicate address detection mechanism", + "All IPv6 addresses have passed the Duplicate address " + "detection mechanism", ) # Fire up all daemons @@ -227,7 +229,7 @@ def _set_sysctl(self, key: str, val: str | int): except IndexError: v = None if v != val: - self._processes.call("sysctl", "-w", "%s=%s" % (key, val)) + self._processes.call("sysctl", "-w", f"{key}={val}") return v def _mklogdirs(self, logdir) -> tuple[str, str, int]: @@ -290,7 +292,7 @@ def __init__( """:param password: The password for the routing daemons vtysh access :param lo_addresses: The list of addresses to set on the loopback interface""" - super().__init__(name, config=config, *args, **kwargs) + super().__init__(name, *args, config=config, **kwargs) self.password = password # This interface already exists in the node, @@ -315,17 +317,19 @@ def __init__( *args, config: type[OpenrRouterConfig], lo_addresses: Sequence[str | IPv4Interface | IPv6Interface] | None = None, - privateDirs=["/tmp"], + privateDirs=None, **kwargs, ): + if privateDirs is None: + privateDirs = ["/tmp"] if not lo_addresses: lo_addresses = () super().__init__( name, + *args, config=config, lo_addresses=lo_addresses, password=None, privateDirs=privateDirs, - *args, **kwargs, ) diff --git a/ipmininet/router/config/base.py b/ipmininet/router/config/base.py index 45bfe9fb..1b44243f 100644 --- a/ipmininet/router/config/base.py +++ b/ipmininet/router/config/base.py @@ -6,7 +6,7 @@ import abc import os from collections.abc import Iterable, Sequence -from contextlib import closing +from contextlib import closing, suppress from ipaddress import ip_address from operator import attrgetter from typing import ( @@ -31,8 +31,6 @@ "Daemon", type["Daemon"], tuple[Union["Daemon", type["Daemon"]], dict] ] -last_routerid = ip_address("0.0.0.1") - __TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") router_template_lookup = TemplateLookup(directories=[__TEMPLATES_DIR]) @@ -117,8 +115,8 @@ def register_daemon(self, cls: DaemonOption, **daemon_opts): cls, kw = cls except ValueError: raise TypeError( - "Expected a tuple (Daemon, dict) but got %s" % str(cls) - ) + f"Expected a tuple (Daemon, dict) but got {cls!s}" + ) from None daemon_opts.update(kw) if cls.NAME in self._daemons: return @@ -127,7 +125,7 @@ def register_daemon(self, cls: DaemonOption, **daemon_opts): cls = cls(self._node, **daemon_opts) else: raise TypeError( - "Expected an object or a subclass of Daemon, got %s instead" % cls + f"Expected an object or a subclass of Daemon, got {cls} instead" ) else: cls.options.update(daemon_opts) @@ -152,9 +150,8 @@ def sysctl(self, *values: str): self._sysctl[key] = val except ValueError: raise ValueError( - "sysctl must be specified using `key=val` " - "format. Ignoring %s" % value - ) + f"sysctl must be specified using `key=val` format. Ignoring {value}" + ) from None @property def daemons(self): @@ -166,10 +163,7 @@ def daemon(self, key: Union[str, "Daemon", type["Daemon"]]) -> "Daemon": :param key: the daemon name or a daemon class or instance :return: the Daemon object :raise KeyError: if not found""" - if not isinstance(key, str): - key_str = key.NAME - else: - key_str = key + key_str = key.NAME if not isinstance(key, str) else key return self._daemons[key_str] def add_private_fs_path(self, loc: Sequence[str | tuple[str, str]] = ()): @@ -195,11 +189,14 @@ def build_host_file(self, filename: str): class RouterConfig(NodeConfig): + # Last generated router id + _last_routerid = ip_address("0.0.0.1") + def __init__(self, node: "Router", sysctl=None, *args, **kwargs): self._sysctl = {"net.ipv4.ip_forward": 1, "net.ipv6.conf.all.forwarding": 1} if sysctl: self._sysctl.update(sysctl) - super().__init__(node, sysctl=self._sysctl, *args, **kwargs) + super().__init__(node, *args, sysctl=self._sysctl, **kwargs) self.routerid = None def post_register_daemons(self): @@ -207,16 +204,15 @@ def post_register_daemons(self): # Set the router id self.routerid = self.compute_routerid() - @staticmethod - def incr_last_routerid(): - global last_routerid - last_routerid += 1 + @classmethod + def incr_last_routerid(cls): + cls._last_routerid += 1 def _equal_routerid(self, n: "Router") -> bool: # Router id of 'n' already set if n.nconfig.routerid: - return str(n.nconfig.routerid) != str(last_routerid) + return str(n.nconfig.routerid) != str(self._last_routerid) # Check that a router id explicitly set # in any other daemon is not in conflict @@ -225,7 +221,7 @@ def _equal_routerid(self, n: "Router") -> bool: if ( d != self and d.options.routerid - and str(d.options.routerid) == str(last_routerid) + and str(d.options.routerid) == str(self._last_routerid) ): return True @@ -234,10 +230,9 @@ def _equal_routerid(self, n: "Router") -> bool: ip_list = sorted( (ip for itf in n.intfList() for ip in itf.ips()), key=OrderedAddress ) - if len(ip_list) != 0 and str(ip_list.pop().ip) == str(last_routerid): - return True - - return False + return bool( + len(ip_list) != 0 and str(ip_list.pop().ip) == str(self._last_routerid) + ) def compute_routerid(self) -> str: """Computes the default router id for all daemons. @@ -272,7 +267,7 @@ def compute_routerid(self) -> str: break # We need to change the router id to_visit.extend(realIntfList(n.node)) to_visit = realIntfList(self._node) if to_visit else [] - return last_routerid.compressed + return self._last_routerid.compressed return ip_list.pop().ip.compressed @@ -327,10 +322,8 @@ def build(self) -> ConfigDict: def cleanup(self): """Cleanup the files belonging to this daemon""" for f in self.files: - try: + with suppress(OSError): os.unlink(f) - except OSError: - pass self.files = [] def render(self, cfg, **kwargs) -> dict[str, str]: @@ -342,7 +335,7 @@ def render(self, cfg, **kwargs) -> dict[str, str]: self.files.extend(self.cfg_filenames) cfg_content = {} for i, filename in enumerate(self.cfg_filenames): - log.debug("Generating %s\n" % filename) + log.debug(f"Generating {filename}\n") try: cfg.current_filename = filename kwargs["node"] = cfg @@ -356,9 +349,8 @@ def render(self, cfg, **kwargs) -> dict[str, str]: ) log.error(mako.exceptions.text_error_template().render()) raise ValueError( - "Cannot render a configuration [%s: %s]" - % (self._node.name, self.NAME) - ) + f"Cannot render a configuration [{self._node.name}: {self.NAME}]" + ) from None return cfg_content def write(self, cfg: dict[str, str]): @@ -383,7 +375,7 @@ def dry_run(self) -> str: def _filename(self, suffix: str) -> str: """Return a filename for this daemon and node, with the specified suffix""" - return "%s_%s.%s" % (self.NAME, self._node.name, suffix) + return f"{self.NAME}_{self._node.name}.{suffix}" def _filepath(self, f: str) -> str: """Return a path towards a given file""" @@ -407,7 +399,7 @@ def cfg_filenames(self) -> list[str]: @property def template_filenames(self) -> list[str]: - return ["%s.mako" % self.NAME] + return [f"{self.NAME}.mako"] def _defaults(self, **kwargs) -> ConfigDict: """Return the default options for this daemon @@ -477,7 +469,7 @@ def __init__( if node.use_v6: d.append(OSPF6) d.extend(additional_daemons) - super().__init__(node, daemons=d, *args, **kwargs) + super().__init__(node, *args, daemons=d, **kwargs) class BorderRouterConfig(BasicRouterConfig): @@ -505,7 +497,7 @@ def __init__( if af: d = list(daemons) d.append((BGP, {"address_families": af})) - super().__init__(node, daemons=d, *args, **kwargs) + super().__init__(node, *args, daemons=d, **kwargs) class OpenrRouterConfig(RouterConfig): @@ -528,4 +520,4 @@ def __init__( daemon_list = list(daemons) daemon_list.append(Openr) daemon_list.extend(additional_daemons) - super().__init__(node, daemons=daemon_list, *args, **kwargs) + super().__init__(node, *args, daemons=daemon_list, **kwargs) diff --git a/ipmininet/router/config/bgp.py b/ipmininet/router/config/bgp.py index 2e17275e..97e2d0b2 100644 --- a/ipmininet/router/config/bgp.py +++ b/ipmininet/router/config/bgp.py @@ -56,7 +56,7 @@ def asn(self, x: int): self.nodes_properties["asn"] = x def __str__(self): - return "" % self.asn + return f"" class iBGPFullMesh(AS): @@ -70,7 +70,7 @@ def apply(self, topo): super().apply(topo) def __str__(self): - return "" % self.asn + return f"" def bgp_fullmesh(topo, routers: Sequence[str]): @@ -91,8 +91,9 @@ def bgp_peering(topo: "IPTopo", a: "RouterDescription", b: "RouterDescription"): topo.getNodeInfo(a, "bgp_peers", list).append(b) topo.getNodeInfo(b, "bgp_peers", list).append(a) - # recent versions of BGP must define import/export filters for eBGP sessions (RFC 8212). - # This function allows all the routes to be accepted. As depicted, the user can still + # recent versions of BGP must define import/export filters for eBGP + # sessions (RFC 8212). This function allows all the routes to be accepted. + # As depicted, the user can still # change the behavior of importation and exportation filters by matching prefixes # in the smaller orders of the RM filter_allows_all_routes(a, b) @@ -471,7 +472,7 @@ def filters_to_match_cond( family: str, ): match_cond = [] - assert family in {"ipv4", "ipv6"}, "Bad family %s" % family + assert family in {"ipv4", "ipv6"}, f"Bad family {family}" access_lists = self.topo.getNodeInfo(self.router, "bgp_access_lists", list) community_list = self.topo.getNodeInfo(self.router, "bgp_community_lists", list) @@ -498,7 +499,7 @@ def filters_to_match_cond( if f not in prefix_list: prefix_list.append(f) else: - raise Exception("Filter not yet implemented") + raise TypeError(f"Filter not yet implemented for {type(f).__name__}") return match_cond def add_set_action( @@ -612,10 +613,10 @@ class BGP(QuaggaDaemon, AbstractBGP): @property def STARTUP_LINE_EXTRA(self): """We add the port to the standard startup line""" - return "-p %s" % self.port + return f"-p {self.port}" def __init__(self, node, port=BGP_DEFAULT_PORT, *args, **kwargs): - super().__init__(node=node, *args, **kwargs) + super().__init__(*args, node=node, **kwargs) self.port = port def build(self): @@ -651,7 +652,7 @@ def build_community_list(self) -> list[CommunityList]: ) community_lists.append(cl) if isinstance(node_cl.community, int): - cl.community = "%s:%d" % (self._node.asn, node_cl.community) + cl.community = f"{self._node.asn}:{node_cl.community}" return community_lists def build_access_list(self) -> list[AccessList]: @@ -659,10 +660,10 @@ def build_access_list(self) -> list[AccessList]: Build and return a list of access_filter :return: """ - return self._node.get("bgp_access_lists", val=list()) + return self._node.get("bgp_access_lists", val=[]) def build_prefix_list(self): - return self._node.get("bgp_prefix_lists", val=list()) + return self._node.get("bgp_prefix_lists", val=[]) def build_route_map(self, neighbors: Sequence["Peer"]) -> list[RouteMap]: """ @@ -683,9 +684,9 @@ def find_neighbor(route_map: "RouteMap"): return n return None - final_rms = list() + final_rms = [] - rms = self._node.get("bgp_route_maps", val=list()) # type: List['RouteMap'] + rms = self._node.get("bgp_route_maps", val=[]) # type: List['RouteMap'] for rm in rms: neigh = find_neighbor(rm) @@ -729,12 +730,10 @@ def __str__(self): return repr(self) def __repr__(self) -> str: - return "AddressFamily(%s, networks: %s, redistribute: %s, %s, routes: %s)" % ( - self.name, - self.networks, - self.redistribute, - self.neighbors, - self.routes, + return ( + f"AddressFamily({self.name}, networks: {self.networks}, " + f"redistribute: {self.redistribute}, {self.neighbors}, " + f"routes: {self.routes})" ) def extend(self, af: "AddressFamily"): @@ -753,7 +752,7 @@ def family(self): return "ip" if self.name == "ipv6": return "ip6" - raise ValueError("Unsupported AddressFamily %s" % self.name) + raise ValueError(f"Unsupported AddressFamily {self.name}") def AF_INET(*args, **kwargs): @@ -770,7 +769,7 @@ class Peer: """A BGP peer""" class PQNode: - """Class representing an element of the priority queue used in _find_peer_address""" + """Element of the priority queue used in _find_peer_address""" def __init__(self, key, extra_val): self.key = key @@ -805,7 +804,7 @@ def __init__(self, base: "Router", node: str, v6=False): # We enable eBGP multihop if eBGP is in use ebgp = self.asn != base.asn self.ebgp_multihop = ebgp - self.description = "%s (%sBGP)" % (node, "e" if ebgp else "i") + self.description = "{} ({}BGP)".format(node, "e" if ebgp else "i") @staticmethod def _find_peer_address( diff --git a/ipmininet/router/config/dhcprelay.py b/ipmininet/router/config/dhcprelay.py index 5c423753..9aba30c3 100644 --- a/ipmininet/router/config/dhcprelay.py +++ b/ipmininet/router/config/dhcprelay.py @@ -33,7 +33,7 @@ def dry_run(self): @property def cfg_filenames(self): - return [self._file(suffix="%s.cfg" % self.listening_ip)] + return [self._file(suffix=f"{self.listening_ip}.cfg")] def set_defaults(self, defaults): pass diff --git a/ipmininet/router/config/exabgp.py b/ipmininet/router/config/exabgp.py index cf981ebe..818fdf06 100644 --- a/ipmininet/router/config/exabgp.py +++ b/ipmininet/router/config/exabgp.py @@ -9,7 +9,8 @@ class Representable(ABC): """ String representation for ExaBGP configuration - Each sub-part of any ExaBGP route must be representable and must override the default __str__ function + Each sub-part of any ExaBGP route must be representable and must override + the default __str__ function """ @abstractmethod @@ -79,11 +80,11 @@ def val(self): return self.lst def __init__(self, lst: list[int | str]): - assert isinstance(lst, list), "%s is not a list" % type(lst) + assert isinstance(lst, list), f"{type(lst)} is not a list" self.lst = lst def __str__(self) -> str: - return "[ %s ]" % " ".join([str(it) for it in self.lst]) + return "[ {} ]".format(" ".join([str(it) for it in self.lst])) class BGPAttributeFlags(HexRepresentable): @@ -93,15 +94,18 @@ class BGPAttributeFlags(HexRepresentable): When : * bit `O` is set to 0: the attribute is Well-Known. If 1, it is optional - * bit `T` is set to 0: the attribute is not Transitive. If 1, it is transitive + * bit `T` is set to 0: the attribute is not Transitive. If 1, it is + transitive * bit `P` is set to 0: the attribute is complete; If 1, partial - * bit `E` is set to 0: the attribute is of length < 256 bits. If set to 1: 256 <= length < 2^{16} + * bit `E` is set to 0: the attribute is of length < 256 bits. If set to + 1: 256 <= length < 2^{16} The last 4 bits are unused - This class is notably used to define new attributes unknown from ExaBGP or change - the flags of a already known attribute. For example, the MED value is not transitive. - To make it transitive, put the transitive bit to 1. + This class is notably used to define new attributes unknown from ExaBGP + or change the flags of an already known attribute. For example, the MED + value is not transitive. To make it transitive, put the transitive bit + to 1. """ @staticmethod @@ -132,27 +136,25 @@ def hex_repr(self): def __repr__(self): return ( - "BGPAttributeFlags(opt=%d, transitive=%d, partial=%d, ext=%d, _hex=%s (%s))" - % ( - self.optional, - self.transitive, - self.partial, - self.extended, - hex(self._hex), - bin(self._hex), - ) + "BGPAttributeFlags(" + f"opt={self.optional}, transitive={self.transitive}, " + f"partial={self.partial}, ext={self.extended}, " + f"_hex={hex(self._hex)} ({bin(self._hex)})" + ")" ) class BGPAttribute(Representable): """ - A BGP attribute as represented in ExaBGP. Either the Attribute is known from ExaBGP - and so the class uses its string representation. Or the attribute is not known, then - the class uses its hexadecimal representation. The latter representation is also useful - to modify flags of already known attributes. For example the MED value is a known attribute - which is not transitive. By passing a BGPAttributeFlags object to the constructor, it is - now possible to make is transitive with BGPAttributeFlags(1, 1, 0, 0) (both optional and - transitive bits are set to 1) + A BGP attribute as represented in ExaBGP. Either the Attribute is known + from ExaBGP and so the class uses its string representation. Or the + attribute is not known, then the class uses its hexadecimal + representation. The latter representation is also useful to modify flags + of already known attributes. For example the MED value is a known + attribute which is not transitive. By passing a BGPAttributeFlags object + to the constructor, it is now possible to make it transitive with + BGPAttributeFlags(1, 1, 0, 0) (both optional and transitive bits are set + to 1) """ @property @@ -175,7 +177,10 @@ def _known_attr(self): } def hex_repr(self) -> str: - return f"attribute [ {hex(self.type)} {self.flags.hex_repr()} {self.val.hex_repr()} ]" + return ( + f"attribute [ {hex(self.type)} {self.flags.hex_repr()} " + f"{self.val.hex_repr()} ]" + ) def str_repr(self) -> str: return f"{self.type!s} {self.val!s}" @@ -187,22 +192,27 @@ def __init__( flags: Optional["BGPAttributeFlags"] = None, ): """ - Constructs an Attribute known from ExaBGP or an unknown attribute if flags is - not None. It raises a ValueError if the initialisation of BGPAttribute fails. Either because type_attr - is not an int (for an unknown attribute), or the string of type_attr is not recognised - by ExaBGP (for a known attribute) - - :param attr_type: In the case of a Known attribute, attr_type is a valid string - recognised by ExaBGP. In the case of an unknown attribute, attr_type is the integer - ID of the attribute. If attr_type is a string it must be a valid string recognized - by ExaBGP. Valid strings are: - 'next-hop', 'origin', 'med', 'as-path', 'local-preference', 'atomic-aggregate', - 'aggregator', 'originator-id', 'cluster-list','community', 'large-community', - 'extended-community', 'name', 'aigp' + Constructs an Attribute known from ExaBGP or an unknown attribute if + flags is not None. It raises a ValueError if the initialisation of + BGPAttribute fails. Either because type_attr is not an int (for an + unknown attribute), or the string of type_attr is not recognised by + ExaBGP (for a known attribute) + + :param attr_type: In the case of a Known attribute, attr_type is a + valid string recognised by ExaBGP. In the case of + an unknown attribute, attr_type is the integer ID + of the attribute. Valid strings are: + 'next-hop', 'origin', 'med', 'as-path', + 'local-preference', 'atomic-aggregate', + 'aggregator', 'originator-id', 'cluster-list', + 'community', 'large-community', 'extended-community', + 'name', 'aigp' :param val: The actual value of the attribute - :param flags: If None, the BGPAttribute object contains a known attribute from ExaBGP. - In this case, the representation of this attribute will be a string. - If flags is an instance of BGPAttribute, the hexadecimal representation will be used + :param flags: If None, the BGPAttribute object contains a known + attribute from ExaBGP. In this case, the representation + of this attribute will be a string. If flags is an + instance of BGPAttribute, the hexadecimal representation + will be used """ if flags is None: @@ -244,7 +254,7 @@ def __init__(self, network: "Representable", attributes: Sequence["BGPAttribute" def __str__(self): route = f"unicast {self.IPNetwork!s}" for attr in self.attributes: - route += " %s" % str(attr) + route += f" {attr!s}" return route @@ -256,9 +266,8 @@ def __getitem__(self, item): return self.IPNetwork for attr in self.attributes: - if isinstance(attr.type, str): - if attr.type == item: - return attr + if isinstance(attr.type, str) and attr.type == item: + return attr return None @@ -267,7 +276,7 @@ class ExaBGPDaemon(AbstractBGP): KILL_PATTERNS = (NAME,) def __init__(self, node, port=BGP_DEFAULT_PORT, *args, **kwargs): - super().__init__(node=node, *args, **kwargs) + super().__init__(*args, node=node, **kwargs) self.port = port def build(self): @@ -294,11 +303,11 @@ def env_filename(self): @property def cfg_filenames(self): - return super().cfg_filenames + [self.env_filename] + return [*super().cfg_filenames, self.env_filename] @property def template_filenames(self): - return super().template_filenames + ["%s_env.mako" % self.NAME] + return [*super().template_filenames, f"{self.NAME}_env.mako"] @property def startup_line(self) -> str: @@ -312,21 +321,24 @@ def set_defaults(self, defaults): """ Modifies the default configuration of this ExaBGP daemon - :param env: a dictionary of all the environment variables that configure ExaBGP. - Type "exabgp --help" to take a look on every environment variable. - env.tcp.delay is set by default to 2 min as FRRouting BGPD daemon - seems to reject routes if ExaBGP injects routes to early. - Each environment variable is either a string or an int. + :param env: a dictionary of all the environment variables that + configure ExaBGP. Type "exabgp --help" to take a look on + every environment variable. env.tcp.delay is set by + default to 2 min as FRRouting BGPD daemon seems to reject + routes if ExaBGP injects routes to early. Each environment + variable is either a string or an int. The following environment variable are set : * daemon.user = 'root' * daemon.drop = 'false' * daemon.daemonize = 'false' - * daemon.pid = .pid> + * daemon.pid = .pid> * log.level = 'CRIT' - * log.destination = .log> + * log.destination = .log> * log.reactor = 'false' * log.processes = false' * log.network = 'false' @@ -334,10 +346,12 @@ def set_defaults(self, defaults): * api.cli = 'false' * tcp.delay = 2 # waits at most 2 minutes - :param address_families: the routes to inject for both IPv4 and IPv6 unicast AFI. - :param passive: Tells to ExaBGP to not send active BGP Open messages. The daemon - waits until the remote peer sends first the Open message to start - the BGP session. Its default value is set to True. + :param address_families: the routes to inject for both IPv4 and IPv6 + unicast AFI. + :param passive: Tells to ExaBGP to not send active BGP Open messages. + The daemon waits until the remote peer sends first + the Open message to start the BGP session. Its default + value is set to True. """ defaults.base_env = ConfigDict( daemon=ConfigDict( @@ -357,8 +371,9 @@ def set_defaults(self, defaults): delay=2 # wait at most 2 minutes before sending UPDATE, the other peer # may not be ready yet. FRRouting do not accept incoming routes # if ExaBGP sends directly its routes. Debugging information - # says that routes are denied due to a "deny" action from a route map - # (rcvd UPDATE about 8.8.8.0/24 IPv4 unicast -- DENIED due to: route-map;) + # says that routes are denied due to a "deny" action from a + # route map + # (rcvd UPDATE about 8.8.8.0/24 -- DENIED due to route-map;) # However, if the daemon waits a little bit, routes are all # accepted... ), diff --git a/ipmininet/router/config/iptables.py b/ipmininet/router/config/iptables.py index 8297698d..1b0f6b24 100644 --- a/ipmininet/router/config/iptables.py +++ b/ipmininet/router/config/iptables.py @@ -5,6 +5,7 @@ from itertools import groupby from operator import attrgetter from os import EX_OK +from typing import ClassVar from ipmininet.utils import is_container @@ -91,7 +92,7 @@ class Chain: """ # See the iptables doc for the possible table->chains mappings - TABLE_CHAINS = { + TABLE_CHAINS: ClassVar[dict[str, set[str]]] = { "filter": {"INPUT", "OUTPUT", "FORWARD"}, "nat": {"PREROUTING", "INTPUT", "OUTPUT", "POSTROUTING"}, "mangle": {"PREROUTING", "INTPUT", "OUTPUT", "FORWARD", "POSTROUTING"}, @@ -112,17 +113,19 @@ def __init__(self, table="filter", name="INPUT", default="DROP", rules=()): _table = str(table).lower() allowed_chains = self.TABLE_CHAINS[_table] except KeyError: - raise ValueError("%s does not match to an IPTables table name" % table) + raise ValueError( + f"{table} does not match to an IPTables table name" + ) from None self.table = _table _name = str(name).upper() if _name not in allowed_chains: - raise ValueError("%s is not an allowed Chain for table %s" % (name, table)) + raise ValueError(f"{name} is not an allowed Chain for table {table}") self.name = _name _default = str(default).upper() if _default != "DROP" and _default != "ACCEPT": - raise ValueError("%s is an invalid default policy" % default) + raise ValueError(f"{default} is an invalid default policy") self.default = _default self.rules = rules @@ -138,7 +141,7 @@ class ChainRule: embedded in a chain.""" # For convenience, provide more readable aliases of common parameters - ALIASES = { + ALIASES: ClassVar[dict[str, str]] = { alias: code for code, aliases in ( ("o", ("oif", "out_intf", "out_interface")), @@ -172,7 +175,7 @@ def __init__(self, action="DROP", **kwargs): unknown_args = set(kwargs.keys()).difference(self.ALIASES.keys()) if unknown_args: - raise ValueError("Unknown parameters: %s" % unknown_args) + raise ValueError(f"Unknown parameters: {unknown_args}") args = {self.ALIASES[k]: v for k, v in kwargs.items()} self.oif = InterfaceClause("o", args) @@ -201,7 +204,7 @@ def build(self): sub_rule.extend( [sub_clause for sub_clause in clause.build() if sub_clause is not None] ) - return "%s -j %s" % (" ".join(sub_rule), self.action) + return "{} -j {}".format(" ".join(sub_rule), self.action) class NOT: @@ -256,7 +259,7 @@ def __init__(self, code, val): else: self.val = ",".join(self.val) self.prefix = "-m multiport " - self.code = "%ss" % self.code + self.code = f"{self.code}s" class InterfaceClause(MatchClause): diff --git a/ipmininet/router/config/openr.py b/ipmininet/router/config/openr.py index 47877e54..07313add 100644 --- a/ipmininet/router/config/openr.py +++ b/ipmininet/router/config/openr.py @@ -35,7 +35,7 @@ def apply(self, topo): super().apply(topo) def __str__(self): - return "" % self.domain + return f"" class Openr(OpenrDaemon): @@ -46,7 +46,7 @@ class Openr(OpenrDaemon): KILL_PATTERNS = (NAME,) def __init__(self, node, *args, **kwargs): - super().__init__(node=node, *args, **kwargs) + super().__init__(*args, node=node, **kwargs) @property def logdir(self) -> str: @@ -73,7 +73,7 @@ def _build_networks(interfaces): # Check that we have at least one IPv4 network on that interface ... def _openr_net(ip, prefixLen): - domain = ip_interface("%s/%s" % (ip, prefixLen)) + domain = ip_interface(f"{ip}/{prefixLen}") return OpenrNetwork(domain=domain) return [_openr_net(i.ip, i.prefixLen) for i in interfaces if i.ip] @@ -97,7 +97,7 @@ def _build_prefixes(interfaces): for itf in interfaces: ipv6_addresses += itf.addresses[6] ipv6_addresses = filter(lambda x: not x.is_link_local, ipv6_addresses) - return ",".join(map(lambda x: x.with_prefixlen, ipv6_addresses)) + return ",".join(x.with_prefixlen for x in ipv6_addresses) def set_defaults(self, defaults): """Updates some options of the OpenR daemon to run a network of diff --git a/ipmininet/router/config/openrd.py b/ipmininet/router/config/openrd.py index 7355fdf5..c045ef25 100644 --- a/ipmininet/router/config/openrd.py +++ b/ipmininet/router/config/openrd.py @@ -17,8 +17,7 @@ def startup_line(self): return f"{self.NAME} {self._cfg_options()} {self.STARTUP_LINE_EXTRA}" def build(self): - cfg = ConfigDict() - return cfg + return ConfigDict() def _defaults(self, **kwargs): """ diff --git a/ipmininet/router/config/ospf.py b/ipmininet/router/config/ospf.py index 8bf0916c..d1c2e468 100644 --- a/ipmininet/router/config/ospf.py +++ b/ipmininet/router/config/ospf.py @@ -37,11 +37,11 @@ def area(self, x: str): def apply(self, topo): # Add all links for the routers for r in self.nodes: - self.add_link(*[(r, x) for x in topo.g[r].keys()]) + self.add_link(*[(r, x) for x in topo.g[r]]) super().apply(topo) def __str__(self): - return "" % self.area + return f"" class OSPF(QuaggaDaemon): @@ -54,7 +54,7 @@ class OSPF(QuaggaDaemon): KILL_PATTERNS = (NAME,) def __init__(self, node, *args, **kwargs): - super().__init__(node=node, *args, **kwargs) + super().__init__(*args, node=node, **kwargs) def build(self): cfg = super().build() @@ -70,9 +70,7 @@ def _build_networks(interfaces: list[IPIntf]) -> list["OSPFNetwork"]: active OSPF interfaces""" # Check that we have at least one IPv4 network on that interface ... return [ - OSPFNetwork( - domain=ip_interface("%s/%s" % (i.ip, i.prefixLen)), area=i.igp_area - ) + OSPFNetwork(domain=ip_interface(f"{i.ip}/{i.prefixLen}"), area=i.igp_area) for i in interfaces if i.ip ] diff --git a/ipmininet/router/config/pimd.py b/ipmininet/router/config/pimd.py index 9e849980..635fe05e 100644 --- a/ipmininet/router/config/pimd.py +++ b/ipmininet/router/config/pimd.py @@ -13,7 +13,7 @@ class PIMD(QuaggaDaemon): KILL_PATTERNS = (NAME,) def __init__(self, node, *args, **kwargs): - super().__init__(node=node, *args, **kwargs) + super().__init__(*args, node=node, **kwargs) def build(self): cfg = super().build() diff --git a/ipmininet/router/config/radvd.py b/ipmininet/router/config/radvd.py index 3ad853ea..7829f9c1 100644 --- a/ipmininet/router/config/radvd.py +++ b/ipmininet/router/config/radvd.py @@ -143,7 +143,7 @@ def cleanup(self): for line in f: if len(line) > 1: pid = int(line[:-1]) - self._node._processes.call("kill -9 %d " % pid) + self._node._processes.call(f"kill -9 {pid} ") except OSError: pass super().cleanup() diff --git a/ipmininet/router/config/ripng.py b/ipmininet/router/config/ripng.py index 8ab3aa1e..af4c762d 100644 --- a/ipmininet/router/config/ripng.py +++ b/ipmininet/router/config/ripng.py @@ -40,7 +40,7 @@ def _build_networks(interfaces: list[IPIntf]) -> list["RIPNetwork"]: """Return the list of RIP networks to advertize from the list of active RIP interfaces""" return [ - RIPNetwork(domain=ip_interface("%s/%s" % (i.ip6, i.prefixLen6))) + RIPNetwork(domain=ip_interface(f"{i.ip6}/{i.prefixLen6}")) for i in interfaces if i.ip6 ] @@ -55,7 +55,7 @@ def _build_interfaces(self, interfaces: list[IPIntf]) -> list[ConfigDict]: # Is the interface between two routers? active=self.is_active_interface(i), cost=i.igp_metric - 1, - domain=ip_interface("%s/%s" % (i.ip6, i.prefixLen6)), + domain=ip_interface(f"{i.ip6}/{i.prefixLen6}"), ) for i in interfaces ] diff --git a/ipmininet/router/config/sshd.py b/ipmininet/router/config/sshd.py index 188481e9..1a9e7394 100644 --- a/ipmininet/router/config/sshd.py +++ b/ipmininet/router/config/sshd.py @@ -10,7 +10,7 @@ # Generate a new ssh keypair at each run KEYFILE = tempfile.mktemp(dir="/tmp") -PUBKEY = "%s.pub" % KEYFILE +PUBKEY = f"{KEYFILE}.pub" if os.path.exists(KEYFILE): os.unlink(KEYFILE) if os.path.exists(PUBKEY): @@ -31,7 +31,7 @@ def startup_line(self): @property def dry_run(self): - return "%s -t" % self.startup_line + return f"{self.startup_line} -t" def set_defaults(self, defaults): super().set_defaults(defaults) diff --git a/ipmininet/router/config/utils.py b/ipmininet/router/config/utils.py index 21b303da..81d44426 100644 --- a/ipmininet/router/config/utils.py +++ b/ipmininet/router/config/utils.py @@ -18,7 +18,7 @@ def __getattr__(self, item): try: # But preserve i.e. methods return super().__getattr__(item) - except Exception: + except AttributeError: try: return self[item] except KeyError: diff --git a/ipmininet/router/config/zebra.py b/ipmininet/router/config/zebra.py index 15d67617..d8816716 100644 --- a/ipmininet/router/config/zebra.py +++ b/ipmininet/router/config/zebra.py @@ -42,7 +42,9 @@ def startup_line(self): @property def zebra_socket(self): """Return the path towards the zebra API socket for the given node""" - return os.path.join(self._node.cwd, "%s_%s.api" % ("quagga", self._node.name)) + return os.path.join( + self._node.cwd, "{}_{}.api".format("quagga", self._node.name) + ) def build(self): cfg = super().build() @@ -98,10 +100,11 @@ def listening(self) -> bool: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: sock.connect(self.zebra_socket) - sock.close() - return True except OSError: return False + else: + sock.close() + return True class CommunityList: @@ -120,7 +123,7 @@ def __init__( :param community: """ CommunityList.count += 1 - self.name = name or "cml%d" % CommunityList.count + self.name = name or f"cml{CommunityList.count}" self.action = action self.community = community self.family = "community" @@ -128,6 +131,8 @@ def __init__( def __eq__(self, other): return self.name == other.name and self.action == other.action + __hash__ = None + class Entry: def __init__( @@ -147,8 +152,7 @@ def __init__( _prefix = ip_network(prefix) if family is not None: assert get_family(_prefix) == family, ( - "prefix family %s != family (%s)" - % (get_family(_prefix), family) + f"prefix family {get_family(_prefix)} != family ({family})" ) else: _prefix = prefix @@ -196,16 +200,15 @@ def __init__( if le is not None: assert 0 <= le <= type_mask[self.family], ( - "assertion %d <= le (%d) <= %d failed" % (0, le, type_mask[self.family]) + f"assertion 0 <= le ({le}) <= {type_mask[self.family]} failed" ) if ge is not None: assert 0 <= ge <= type_mask[self.family], ( - "assertion %d <= ge (%d) <= %d failed" % (0, ge, type_mask[self.family]) + f"assertion 0 <= ge ({ge}) <= {type_mask[self.family]} failed" ) if le is not None and ge is not None: assert le >= ge, ( - "assertion le (%d) >= ge (%d) failed! le must be lower than ge" - % (le, ge) + f"assertion le ({le}) >= ge ({ge}) failed! le must be lower than ge" ) self.le = le @@ -246,12 +249,12 @@ def __init__( """ assert family in {"ipv4", "ipv6"}, ( - "PrefixList unknown %s type. type must be either ipv4 or ipv6" % family + f"PrefixList unknown {family} type. type must be either ipv4 or ipv6" ) ZebraList.count += 1 - self.name = name or "%s%d" % (self.prefix_name, ZebraList.count) + self.name = name or f"{self.prefix_name}{ZebraList.count}" self.entries = [] for e in entries: if isinstance(e, self.Entry): @@ -259,15 +262,11 @@ def __init__( self.entries.append(e) elif isinstance(e, str) and e == "any": self.entries.append(self.Entry(prefix=e, family=family)) - elif ( - isinstance(e, IPv4Network) - or isinstance(e, IPv6Network) - or isinstance(e, str) - ): + elif isinstance(e, (IPv4Network, IPv6Network, str)): self.entries.append(self.Entry(prefix=e)) else: raise ValueError( - '"%s" is not a valid prefix entry for the %s family' % (e, family) + f'"{e}" is not a valid prefix entry for the {family} family' ) self.family = family @@ -275,6 +274,8 @@ def __init__( def __eq__(self, other): return self.name == other.name + __hash__ = None + class PrefixList(ZebraList): @property @@ -327,7 +328,7 @@ def __init__(self, cond_type: str, condition, family: str | None = None): """ if family: assert family in {"ipv4", "ipv6", "community"}, ( - "Unrecognized family type (%s)" % family + f"Unrecognized family type ({family})" ) self.condition = condition self.cond_type = cond_type @@ -342,7 +343,7 @@ def zebra_family(self): if self.family == "community": return "community" - raise ValueError("Unsupported family; %s" % self.family) + raise ValueError(f"Unsupported family; {self.family}") def __eq__(self, other): return ( @@ -351,6 +352,8 @@ def __eq__(self, other): and self.family == other.family ) + __hash__ = None + class RouteMapSetAction: """ @@ -368,6 +371,8 @@ def __init__(self, action_type: str, value): def __eq__(self, other): return self.action_type == other.action_type and self.value == other.value + __hash__ = None + class RouteMapEntry: def __init__( @@ -472,15 +477,16 @@ def __init__( assert family in {"ipv4", "ipv6", "community"}, "Unrecognized family" - self.name = name or "rm%d" % RouteMap.count + self.name = name or f"rm{RouteMap.count}" - self.entries = dict() # type: Dict[int, 'RouteMapEntry'] + self.entries = {} # type: Dict[int, 'RouteMapEntry'] self.neighbor = neighbor self.direction = direction self.proto = proto self.family = family - self._hi_order = 0 # used when adding a route map entry. Represents the highest route map entry added + # Highest route-map entry order added so far + self._hi_order = 0 # used to indicate that the current route map has a default # entry that accepts all routes as default policy. @@ -492,7 +498,7 @@ def __init__( def _inc_order(self): self._hi_order += 10 assert self._hi_order < self.DEFAULT_POLICY, ( - "Maximum route-map order exceeded (> %d)" % self.DEFAULT_POLICY + f"Maximum route-map order exceeded (> {self.DEFAULT_POLICY})" ) def default_policy_set(self): @@ -524,13 +530,14 @@ def update(self, rm: "RouteMap"): if self != rm: raise ValueError("Attempting to update incompatible RouteMaps") - for order in rm.entries.keys(): + for order in rm.entries: self.entry(rm.entries[order], order) def find_entry_by_match_condition(self, condition: Sequence["RouteMapMatchCond"]): for entry in self.entries: if self.entries[entry].match_cond == condition: return self.entries[entry] + return None def __len__(self): return len(self.entries) @@ -544,6 +551,8 @@ def __eq__(self, other): and self.neighbor == other.neighbor ) + __hash__ = None + @property def describe(self): """Return the zebra description of this route map and apply it to the diff --git a/ipmininet/srv6.py b/ipmininet/srv6.py index 173b9209..0952ddaa 100644 --- a/ipmininet/srv6.py +++ b/ipmininet/srv6.py @@ -2,6 +2,7 @@ For more information about SRv6, see https://segment-routing.org""" import abc +import contextlib import shlex import subprocess from collections.abc import Iterable @@ -28,7 +29,7 @@ def enable_srv6(node: IPNode): node.nconfig.sysctl = "net.ipv6.conf.all.seg6_enabled=1" node.nconfig.sysctl = "net.ipv6.conf.default.seg6_enabled=1" for intf in realIntfList(node): - node.nconfig.sysctl = "net.ipv6.conf.%s.seg6_enabled=1" % intf.name + node.nconfig.sysctl = f"net.ipv6.conf.{intf.name}.seg6_enabled=1" def check_srv6_compatibility() -> bool: @@ -37,9 +38,10 @@ def check_srv6_compatibility() -> bool: """ try: subprocess.check_output(shlex.split("sysctl net.ipv6.conf.all.seg6_enabled")) - return True except subprocess.CalledProcessError: return False + else: + return True def srv6_segment_space( @@ -53,7 +55,7 @@ def srv6_segment_space( """ if isinstance(intf, str): if not isinstance(node, IPNode): - raise ValueError( + raise TypeError( "Cannot retrieve an IPIntf from name without " "passing its node as parameter" ) @@ -90,7 +92,7 @@ def __init__( elif isinstance(destination, IPIntf): self.prefixes.extend(srv6_segment_space(intf=destination)) else: - raise ValueError( + raise TypeError( "The LocalSIDTable cannot be created because " f"the destination {destination} cannot be matched" ) @@ -101,15 +103,13 @@ def __init__( lines = out.split("\n") for line in lines: if "lookup " in line: - try: + with contextlib.suppress(ValueError): tables.append(int(line.split("lookup ")[-1])) - except ValueError: - pass self.num = 1 while self.num in tables: self.num += 1 if self.num >= 253: - raise Exception("Cannot find a free table number on the host") + raise RuntimeError("Cannot find a free table number on the host") # Create the table self.create() @@ -168,15 +168,15 @@ def __init__( itfs = realIntfList(self.source) if len(itfs) == 0: raise ValueError( - "Cannot install SRv6Route %s without" - " a real interface on node %s\n" % (self, self.source.name) + f"Cannot install SRv6Route {self} without" + f" a real interface on node {self.source.name}\n" ) self.dev = itfs[0].name # Check SRv6 capability if not self.is_available(): raise ValueError( - "Cannot create %s because the distribution does not support it" % self + f"Cannot create {self} because the distribution does not support it" ) # Activate SRv6 on all routers and hosts @@ -199,18 +199,20 @@ def dest_prefixes(self) -> list[str]: prefixes.append(str(self.destination)) except (AddressValueError, NetmaskValueError): if isinstance(self.destination, str): - try: + with contextlib.suppress(KeyError): self.destination = self.net[self.destination] - except KeyError: - pass if isinstance(self.destination, IPNode): - for itf in self.destination.intfList(): - for ip6 in itf.ip6s(exclude_lls=True, exclude_lbs=True): - prefixes.append(ip6.network.with_prefixlen) + prefixes.extend( + ip6.network.with_prefixlen + for itf in self.destination.intfList() + for ip6 in itf.ip6s(exclude_lls=True, exclude_lbs=True) + ) elif isinstance(self.destination, IPIntf): - for ip6 in self.destination.ip6s(exclude_lls=True, exclude_lbs=True): - prefixes.append(ip6.network.with_prefixlen) + prefixes.extend( + ip6.network.with_prefixlen + for ip6 in self.destination.ip6s(exclude_lls=True, exclude_lbs=True) + ) if len(prefixes) == 0: log.error( @@ -240,21 +242,20 @@ def nexthops_to_ips( # This is an IPv6 or IPv4 address s.append(str(nh)) except (AddressValueError, NetmaskValueError): - if isinstance(nh, str): - try: - nh = self.net[nh] - except KeyError: - pass + h = nh + if isinstance(h, str): + with contextlib.suppress(KeyError): + h = self.net[h] ip = None - if isinstance(nh, IPNode): - ip = address_pair(nh, use_v4=not v6)[1 if v6 else 0] - elif isinstance(nh, IPIntf): - ip = nh.ip6 if v6 else nh.ip + if isinstance(h, IPNode): + ip = address_pair(h, use_v4=not v6)[1 if v6 else 0] + elif isinstance(h, IPIntf): + ip = h.ip6 if v6 else h.ip if ip is None: raise ValueError( - "Cannot find for the nexthop %s" - " a global IPv%d address\n" % (nh, 6 if v6 else 4) - ) + f"Cannot find for the nexthop {nh}" + f" a global IPv{6 if v6 else 4} address\n" + ) from None s.append(ip) return s @@ -269,12 +270,12 @@ def cleanup(self): self._run_cmds(prefix="ip -6 route add ") def _run_cmds(self, prefix: str = "ip -6 route add ") -> int: - for cmd in self.cmds: - cmd = prefix + cmd + for c in self.cmds: + cmd = prefix + c if self.table is not None: cmd = cmd + f" table {self.table.num}" out, err, code = self.source.pexec(shlex.split(cmd)) - log.debug("Installing route on router %s: '%s'\n" % (self.source.name, cmd)) + log.debug(f"Installing route on router {self.source.name}: '{cmd}'\n") if code: log.error( "Cannot install SRv6Route", @@ -292,10 +293,8 @@ def _run_cmds(self, prefix: str = "ip -6 route add ") -> int: return -1 def __str__(self): - return "SRv6Route" % ( - self.source.name, - self.destination, - self.cost, + return ( + f"SRv6Route" ) @@ -365,20 +364,18 @@ def build_commands(self) -> list[str]: return cmds # Build iproute2 commands - for prefix in prefixes: - cmds.append( - "%s encap seg6 mode %s segs %s metric %s dev %s" - % (prefix, self.mode, ",".join(nexthops), self.cost, self.dev) + cmds.extend( + "{} encap seg6 mode {} segs {} metric {} dev {}".format( + prefix, self.mode, ",".join(nexthops), self.cost, self.dev ) + for prefix in prefixes + ) return cmds def __str__(self): - return "SRv6Encap" % ( - self.source.name, - self.destination, - self.nexthops, - self.mode, - self.cost, + return ( + f"SRv6Encap" ) @@ -412,20 +409,17 @@ def build_commands(self) -> list[str]: return cmds # Build iproute2 commands - for prefix in prefixes: - cmds.append( - f"{prefix} encap seg6local action {self.ACTION} {self.params}" - f" metric {self.cost} dev {self.dev}" - ) + cmds.extend( + f"{prefix} encap seg6local action {self.ACTION} {self.params}" + f" metric {self.cost} dev {self.dev}" + for prefix in prefixes + ) return cmds def __str__(self): - return "SRv6Function" % ( - self.ACTION, - self.source.name, - self.destination, - self.cost, - self.params, + return ( + f"SRv6Function" ) diff --git a/ipmininet/tests/__init__.py b/ipmininet/tests/__init__.py index 1acfd77a..7528c861 100644 --- a/ipmininet/tests/__init__.py +++ b/ipmininet/tests/__init__.py @@ -40,6 +40,7 @@ def _exabgp_usable() -> bool: stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, + check=False, ).returncode == 0 ) diff --git a/ipmininet/tests/test_bgp.py b/ipmininet/tests/test_bgp.py index 48bda5b6..610fcfd2 100644 --- a/ipmininet/tests/test_bgp.py +++ b/ipmininet/tests/test_bgp.py @@ -102,13 +102,13 @@ def build(self, *args, **kwargs): # Add test hosts self.addLink( as1r1, - self.addHost("h%s" % as1r1), + self.addHost(f"h{as1r1}"), params1={"ip": ("10.1.0.1/24", "fd00:1::1/64")}, params2={"ip": ("10.1.0.2/24", "fd00:1::2/64")}, ) self.addLink( as3r1, - self.addHost("h%s" % as3r1), + self.addHost(f"h{as3r1}"), params1={"ip": ("10.3.0.1/24", "fd00:3::1/64")}, params2={"ip": ("10.3.0.2/24", "fd00:3::2/64")}, ) @@ -171,8 +171,8 @@ def test_bgp_daemon_params(bgp_params, expected_cfg): cfg = [line for line in (line.strip() for line in fileobj) if line] for line in expected_cfg: assert line in cfg, ( - "Cannot find the line '%s' in the generated " - "configuration:\n%s" % (line, "".join(cfg)) + "Cannot find the line '{}' in the generated " + "configuration:\n{}".format(line, "".join(cfg)) ) # Check reachability diff --git a/ipmininet/tests/test_cli.py b/ipmininet/tests/test_cli.py index f66c9ff6..5cef0006 100644 --- a/ipmininet/tests/test_cli.py +++ b/ipmininet/tests/test_cli.py @@ -48,7 +48,7 @@ def perm(*args): s = s + "(" + arg + ")" if i != len(args) - 1: s += "|" - s = s + r")(?!.*\1)){%d}" % len(args) + s = s + rf")(?!.*\1)){{{len(args)}}}" print(s) return s @@ -86,14 +86,14 @@ def perm(*args): ( "pingall", [ - re.compile(r"h1 --IPv4--> %s" % perm("h2 ", "h3 ", "h4 ")), - re.compile(r"h1 --IPv6--> %s" % perm("h2 ", "h3 ", "h4 ")), - re.compile(r"h2 --IPv4--> %s" % perm("h1 ", "h3 ", "h4 ")), - re.compile(r"h2 --IPv6--> %s" % perm("h1 ", "h3 ", "h4 ")), - re.compile(r"h3 --IPv4--> %s" % perm("h1 ", "h2 ", "h4 ")), - re.compile(r"h3 --IPv6--> %s" % perm("h1 ", "h2 ", "h4 ")), - re.compile(r"h4 --IPv4--> %s" % perm("h1 ", "h2 ", "h3 ")), - re.compile(r"h4 --IPv6--> %s" % perm("h1 ", "h2 ", "h3 ")), + re.compile(r"h1 --IPv4--> {}".format(perm("h2 ", "h3 ", "h4 "))), + re.compile(r"h1 --IPv6--> {}".format(perm("h2 ", "h3 ", "h4 "))), + re.compile(r"h2 --IPv4--> {}".format(perm("h1 ", "h3 ", "h4 "))), + re.compile(r"h2 --IPv6--> {}".format(perm("h1 ", "h3 ", "h4 "))), + re.compile(r"h3 --IPv4--> {}".format(perm("h1 ", "h2 ", "h4 "))), + re.compile(r"h3 --IPv6--> {}".format(perm("h1 ", "h2 ", "h4 "))), + re.compile(r"h4 --IPv4--> {}".format(perm("h1 ", "h2 ", "h3 "))), + re.compile(r"h4 --IPv6--> {}".format(perm("h1 ", "h2 ", "h3 "))), ], ), ( @@ -108,20 +108,20 @@ def perm(*args): ( "ping4all", [ - re.compile(r"h1 --IPv4--> %s" % perm("h2 ", "h3 ", "h4 ")), - re.compile(r"h2 --IPv4--> %s" % perm("h1 ", "h3 ", "h4 ")), - re.compile(r"h3 --IPv4--> %s" % perm("h1 ", "h2 ", "h4 ")), - re.compile(r"h4 --IPv4--> %s" % perm("h1 ", "h2 ", "h3 ")), + re.compile(r"h1 --IPv4--> {}".format(perm("h2 ", "h3 ", "h4 "))), + re.compile(r"h2 --IPv4--> {}".format(perm("h1 ", "h3 ", "h4 "))), + re.compile(r"h3 --IPv4--> {}".format(perm("h1 ", "h2 ", "h4 "))), + re.compile(r"h4 --IPv4--> {}".format(perm("h1 ", "h2 ", "h3 "))), ], ), ("ping4pair h1 h2", ["h1 --IPv4--> h2 ", "h2 --IPv4--> h1 "]), ( "ping6all", [ - re.compile(r"h1 --IPv6--> %s" % perm("h2 ", "h3 ", "h4 ")), - re.compile(r"h2 --IPv6--> %s" % perm("h1 ", "h3 ", "h4 ")), - re.compile(r"h3 --IPv6--> %s" % perm("h1 ", "h2 ", "h4 ")), - re.compile(r"h4 --IPv6--> %s" % perm("h1 ", "h2 ", "h3 ")), + re.compile(r"h1 --IPv6--> {}".format(perm("h2 ", "h3 ", "h4 "))), + re.compile(r"h2 --IPv6--> {}".format(perm("h1 ", "h3 ", "h4 "))), + re.compile(r"h3 --IPv6--> {}".format(perm("h1 ", "h2 ", "h4 "))), + re.compile(r"h4 --IPv6--> {}".format(perm("h1 ", "h2 ", "h3 "))), ], ), ("ping6pair", ["h1 --IPv6--> h2 ", "h2 --IPv6--> h1 "]), @@ -136,22 +136,20 @@ def test_cli(tmp, net, input_line, expected_lines): with open(tmp, "w") as fileobj: fileobj.write(input_line + "\n") - with CLICapture("info") as capture: - f = open(tmp) - try: - IPCLI(net, stdin=f, script=tmp) - finally: - f.close() + with CLICapture("info") as capture, open(tmp) as f: + IPCLI(net, stdin=f, script=tmp) pattern = re.compile("") for line in expected_lines: if isinstance(line, type(pattern)): assert len([x for x in capture.out if line.match(x) is not None]) > 0, ( - "Regex '%s' does not match the output of '%s':\n%s" - % (line, input_line, "\n".join(capture.out)) + "Regex '{}' does not match the output of '{}':\n{}".format( + line, input_line, "\n".join(capture.out) + ) ) else: assert line in capture.out, ( - "Line '%s' cannot be found in the output of '%s':\n%s" - % (line, input_line, "\n".join(capture.out)) + "Line '{}' cannot be found in the output of '{}':\n{}".format( + line, input_line, "\n".join(capture.out) + ) ) diff --git a/ipmininet/tests/test_dns.py b/ipmininet/tests/test_dns.py index 9484a05d..80f1bd7e 100644 --- a/ipmininet/tests/test_dns.py +++ b/ipmininet/tests/test_dns.py @@ -81,15 +81,15 @@ def test_dns_network(named_cfg, zone_args, exp_named_cfg, exp_zone_cfg): cfg = fileobj.readlines() for line in exp_named_cfg: assert line + "\n" in cfg, ( - "Cannot find the line '%s' in the generated " - "main configuration:\n%s" % (line, "".join(cfg)) + "Cannot find the line '{}' in the generated " + "main configuration:\n{}".format(line, "".join(cfg)) ) with open("/tmp/named_master2.test.org.zone.cfg") as fileobj: cfg = fileobj.readlines() for line in exp_zone_cfg: assert line + "\n" in cfg, ( - "Cannot find the line '%s' in the generated zone " - "configuration:\n%s" % (line, "".join(cfg)) + "Cannot find the line '{}' in the generated zone " + "configuration:\n{}".format(line, "".join(cfg)) ) # Check port number configuration @@ -163,12 +163,12 @@ def test_zone_delegation(): records = [ ( [net["master"], net["slave"]], - mydomain_delegation_records - + [ + [ + *mydomain_delegation_records, ARecord("server.mydomain.org", net["server"].defaultIntf().ip), AAAARecord("server.mydomain.org", net["server"].defaultIntf().ip6), - ] - + root_hints, + *root_hints, + ], ), ( [net["orgdns"]], diff --git a/ipmininet/tests/test_exabgp.py b/ipmininet/tests/test_exabgp.py index 8b1acc7a..5ee2a963 100644 --- a/ipmininet/tests/test_exabgp.py +++ b/ipmininet/tests/test_exabgp.py @@ -164,9 +164,9 @@ def prepare_rib_lookup_script(rib_script) -> Sequence[tuple[str, str]]: - scripts = list() + scripts = [] for family in ("ipv4", "ipv6"): - super_path = "/tmp/_get_%s_rib.sh" % family + super_path = f"/tmp/_get_{family}_rib.sh" scripts.append((super_path, family)) with closing(open(super_path, "w")) as f: f.write(rib_script.format(host="localhost", port=2605, family=family)) @@ -177,9 +177,9 @@ def prepare_rib_lookup_script(rib_script) -> Sequence[tuple[str, str]]: def get_rib_routes(node, command, family): """Return the parsed BGP RIB routes for the given family, or None if the RIB cannot be read yet (e.g. the router has not fully started).""" - my_output = node.popen("sh %s" % command) + my_output = node.popen(f"sh {command}") my_output.wait() - out, err = my_output.communicate() + out, _err = my_output.communicate() output = out.decode(errors="ignore") @@ -222,8 +222,8 @@ def _all_routes_present(): timeout=timeout, interval=poll, description=lambda: ( - "the routes injected by ExaBGP to appear in the " - "BGP RIB within %ds. Missing: %s" % (timeout, missing) + f"the routes injected by ExaBGP to appear in the " + f"BGP RIB within {timeout}s. Missing: {missing}" ), ) @@ -249,11 +249,8 @@ def check_correct_rib(node, rib_scripts, topo): rib_route = rib_routes[str_ipnet][0] assert rib_route["origin"].lower() == our_route["origin"].val, ( - "Bad origin for route {route}. Expected {origin_expect}. Received {origin_real}".format( - route=our_route.IPNetwork, - origin_expect=our_route["origin"].val, - origin_real=rib_route["origin"], - ) + f"Bad origin for route {our_route.IPNetwork}. Expected " + f"{our_route['origin'].val}. Received {rib_route['origin']}" ) check_as_path(rib_route["path"], our_route["as-path"].val) @@ -276,12 +273,16 @@ def check_next_hop( for next_hop in next_hops: rib_next_hop = ip_address(next_hop["ip"]) - if isinstance(rib_next_hop, IPv4Address): - if rib_next_hop == expected_nh["ipv4"].ip: - return True - elif isinstance(rib_next_hop, IPv6Address): - if rib_next_hop == expected_nh["ipv6"].ip: - return True + if ( + isinstance(rib_next_hop, IPv4Address) + and rib_next_hop == expected_nh["ipv4"].ip + ): + return True + if ( + isinstance(rib_next_hop, IPv6Address) + and rib_next_hop == expected_nh["ipv6"].ip + ): + return True return False @@ -296,9 +297,12 @@ def check_as_path(as_path_rib: str, as_path_us: ExaList): expected=as_rib_us, received=as_path_rib ) - for idx, asn_received, asn_expected in zip(range(len(as_rib)), as_rib, as_rib_us): + for idx, asn_received, asn_expected in zip( + range(len(as_rib)), as_rib, as_rib_us, strict=False + ): assert asn_received == asn_expected, ( - f"Bad ASN at index {idx}. Expected AS{asn_expected}. Received AS{asn_received}" + f"Bad ASN at index {idx}. Expected AS{asn_expected}. " + f"Received AS{asn_received}" ) diff --git a/ipmininet/tests/test_gre.py b/ipmininet/tests/test_gre.py index d4e18217..1f898197 100644 --- a/ipmininet/tests/test_gre.py +++ b/ipmininet/tests/test_gre.py @@ -30,8 +30,10 @@ def _gre_ping_ok(): timeout=60, interval=0.5, description=lambda: ( - "the GRE tunnel from %s to 10.0.1.2 to be " - "usable\n[stdout]\n%s\n[stderr]\n%s" % (net["h1"], last_out, last_err) + "the GRE tunnel from {} to 10.0.1.2 to be " + "usable\n[stdout]\n{}\n[stderr]\n{}".format( + net["h1"], last_out, last_err + ) ), ) diff --git a/ipmininet/tests/test_iptables.py b/ipmininet/tests/test_iptables.py index 5ab69e59..b193ae97 100644 --- a/ipmininet/tests/test_iptables.py +++ b/ipmininet/tests/test_iptables.py @@ -15,22 +15,21 @@ def test_iptables_example(): net.start() ip = net["r2"].intf("r2-eth0").ip - cmd = "ping -W 1 -c 1 %s" % ip + cmd = f"ping -W 1 -c 1 {ip}" p = net["r1"].popen(cmd.split(" ")) out, err = p.communicate() ret = p.poll() assert ret == 0, ( - "Pings over IPv4 should not be blocked.\n" - "[stdout]\n%s\n[stderr]\n%s" % (out, err) + f"Pings over IPv4 should not be blocked.\n[stdout]\n{out}\n[stderr]\n{err}" ) ip6 = net["r2"].intf("r2-eth0").ip6 - cmd = "ping6 -W 1 -c 1 %s" % ip6 + cmd = f"ping6 -W 1 -c 1 {ip6}" wait_until( lambda: net["r1"].popen(cmd.split(" ")).wait() != 0, timeout=50, interval=5, - description="IPv6 pings from %s to %s to be blocked" % (net["r1"], ip6), + description="IPv6 pings from {} to {} to be blocked".format(net["r1"], ip6), ) ret, _, _ = check_tcp_connectivity( @@ -69,7 +68,7 @@ def test_iptables_example(): ) assert ret == 0, ( "TCP over port 80 should not be blocked over IPv6.\n" - "[stdout]\n%s\n[stderr]\n%s" % (out, err) + f"[stdout]\n{out}\n[stderr]\n{err}" ) net.stop() diff --git a/ipmininet/tests/test_linkfailure.py b/ipmininet/tests/test_linkfailure.py index d0820c96..2f7fb295 100644 --- a/ipmininet/tests/test_linkfailure.py +++ b/ipmininet/tests/test_linkfailure.py @@ -25,7 +25,7 @@ def _wait_reconvergence(net: IPNet, timeout=120): itf = far.defaultIntf() far_ip = itf.ip6 if v6 else itf.ip plen = itf.prefixLen6 if v6 else itf.prefixLen - prefix = str(ip_network("%s/%s" % (far_ip, plen), strict=False)) + prefix = str(ip_network(f"{far_ip}/{plen}", strict=False)) assert_routing_table(net[router], [prefix], present=True, timeout=timeout) diff --git a/ipmininet/tests/test_misc.py b/ipmininet/tests/test_misc.py index 102e3b8d..b17a0aef 100644 --- a/ipmininet/tests/test_misc.py +++ b/ipmininet/tests/test_misc.py @@ -4,7 +4,7 @@ import pytest -import ipmininet.utils as utils +from ipmininet import utils from ipmininet.clean import cleanup from ipmininet.examples.static_address_network import StaticAddressNet from ipmininet.ipnet import IPNet @@ -100,10 +100,10 @@ def test_ip_address_format(): def test_require_cmd(cmd, present): try: utils.require_cmd(cmd) - assert present, "The command [%s] was found while it is not present" % cmd + assert present, f"The command [{cmd}] was found while it is not present" except RuntimeError: assert not present, ( - "The command [%s] could not be found while it is present" % cmd + f"The command [{cmd}] could not be found while it is present" ) @@ -145,13 +145,12 @@ def test_find_node(start, node, present): net.start() i = utils.find_node(net[start], node) if present: - assert i is not None, "Node %s not found from node %s" % (node, start) - assert i.node.name == node, "Node %s was found while we expected %s" % ( - i.node.name, - node, + assert i is not None, f"Node {node} not found from node {start}" + assert i.node.name == node, ( + f"Node {i.node.name} was found while we expected {node}" ) else: - assert i is None, "Node %s should not be found from node %s" % (node, start) + assert i is None, f"Node {node} should not be found from node {start}" net.stop() finally: cleanup() diff --git a/ipmininet/tests/test_ospf.py b/ipmininet/tests/test_ospf.py index 5bcbd5d2..acf5f059 100644 --- a/ipmininet/tests/test_ospf.py +++ b/ipmininet/tests/test_ospf.py @@ -185,8 +185,8 @@ def test_ospf_daemon_params(node_params, ospf_params, link_params, exp_cfg, exp_ cfg = fileobj.readlines() for line in exp_cfg: assert line + "\n" in cfg, ( - "Cannot find the line '%s' in the generated " - "configuration:\n%s" % (line, "".join(cfg)) + "Cannot find the line '{}' in the generated " + "configuration:\n{}".format(line, "".join(cfg)) ) # Check reachability and paths diff --git a/ipmininet/tests/test_ospf6.py b/ipmininet/tests/test_ospf6.py index 79f6ba00..12d9a219 100644 --- a/ipmininet/tests/test_ospf6.py +++ b/ipmininet/tests/test_ospf6.py @@ -121,7 +121,7 @@ def test_ospf6_example(): {}, {}, {"params1": {"ospf_dead_int": "minimal hello-multiplier 2"}}, - [" ipv6 ospf6 dead-interval %d" % OSPF6.DEAD_INT], + [f" ipv6 ospf6 dead-interval {OSPF6.DEAD_INT}"], unit_igp_cost_paths, ), ( @@ -150,8 +150,8 @@ def test_ospf6_daemon_params( cfg = fileobj.readlines() for line in exp_cfg: assert line + "\n" in cfg, ( - "Cannot find the line '%s' in the generated" - " configuration:\n%s" % (line, "".join(cfg)) + "Cannot find the line '{}' in the generated" + " configuration:\n{}".format(line, "".join(cfg)) ) # Check reachability diff --git a/ipmininet/tests/test_physicalinterface.py b/ipmininet/tests/test_physicalinterface.py index a2264066..b3a866a2 100644 --- a/ipmininet/tests/test_physicalinterface.py +++ b/ipmininet/tests/test_physicalinterface.py @@ -78,4 +78,4 @@ def ip(*args): log.info("Calling: %s", cmd) return check_output(cmd).decode("utf-8") except (OSError, CalledProcessError): - log.error("Command failed!") + log.exception("Command failed!") diff --git a/ipmininet/tests/test_pure.py b/ipmininet/tests/test_pure.py index f67167b3..1bff7a00 100644 --- a/ipmininet/tests/test_pure.py +++ b/ipmininet/tests/test_pure.py @@ -8,6 +8,7 @@ import time from ipaddress import IPv4Network, IPv6Network, ip_network +from typing import ClassVar import pytest @@ -66,9 +67,9 @@ def test_callable_predicate_receives_no_args(self): class TestInferSubPaths: """Tests for the SRv6 capture-path inference (burst-of-probes aware).""" - _NODES = ["h6", "r6", "r5", "r4", "h4"] - _DEST = "fc00:0:d::1" - _BURST = [0.3 + k for k in range(20)] + _NODES: ClassVar[list[str]] = ["h6", "r6", "r5", "r4", "h4"] + _DEST: ClassVar[str] = "fc00:0:d::1" + _BURST: ClassVar[list[float]] = [0.3 + k for k in range(20)] @staticmethod def _burst_events(gaps, probes): diff --git a/ipmininet/tests/test_radv.py b/ipmininet/tests/test_radv.py index 73de13e0..c2092c06 100644 --- a/ipmininet/tests/test_radv.py +++ b/ipmininet/tests/test_radv.py @@ -98,8 +98,8 @@ def test_radvd_daemon_params(link_params, expected_cfg): cfg = fileobj.readlines() for line in expected_cfg: assert line + "\n" in cfg, ( - "Cannot find the line '%s' in the generated" - " configuration:\n%s" % (line, "".join(cfg)) + "Cannot find the line '{}' in the generated" + " configuration:\n{}".format(line, "".join(cfg)) ) # Check reachability @@ -119,9 +119,9 @@ def test_radvd_cleanup(): try: net["r"].nconfig.daemon(RADVD).cleanup() except Exception as e: - assert False, ( - "An exception '%s' was raised while cleaning twice RADVD daemon" % e - ) + raise AssertionError( + f"An exception '{e}' was raised while cleaning twice RADVD daemon" + ) from e net.stop() finally: cleanup() diff --git a/ipmininet/tests/test_srv6.py b/ipmininet/tests/test_srv6.py index b80040da..181638e2 100644 --- a/ipmininet/tests/test_srv6.py +++ b/ipmininet/tests/test_srv6.py @@ -41,7 +41,7 @@ def __init__(self, new_routes: dict[str, tuple], *args, **kwargs): def post_build(self, net): super().post_build(net) - for r in self.new_routes.keys(): + for r in self.new_routes: route_class, route_params = self.new_routes[r] if issubclass(route_class, SRv6EndFunction): @@ -52,9 +52,9 @@ def post_build(self, net): route_params["table"] = self.tables[r] try: route_class(net=net, node=net[r], **route_params) - except Exception as e: + except Exception: traceback.print_exc() - raise e + raise def _tshark_capturing(tsharks: list, stderr_buf: dict) -> bool: @@ -134,7 +134,7 @@ def sr_path(net: IPNet, src: str, dst_ip: str, timeout=1, through=()) -> list[st require_cmd("nmap", help_str="nmap is required to run tests") # Check connectivity - ping_cmd = "ping -6 -c 1 -W %d %s" % (int(timeout), dst_ip) + ping_cmd = f"ping -6 -c 1 -W {int(timeout)} {dst_ip}" out = net[src].cmd(shlex.split(ping_cmd)) if ", 0% packet loss" not in out: return [] diff --git a/ipmininet/tests/test_sshd.py b/ipmininet/tests/test_sshd.py index b1839a96..a29be675 100644 --- a/ipmininet/tests/test_sshd.py +++ b/ipmininet/tests/test_sshd.py @@ -22,18 +22,18 @@ def test_sshd_example(): if "AuthorizedKeysFile" in line: ssh_key = line.split(" ")[1].split(".")[0] assert ssh_key is not None, "No authorized SSH key found in the configuration" - assert os.path.isfile(ssh_key), "Cannot find key file at %s" % ssh_key + assert os.path.isfile(ssh_key), f"Cannot find key file at {ssh_key}" ip = net["r2"].intf("r2-eth0").ip cmd = ( "ssh -oStrictHostKeyChecking=no -oConnectTimeout=1" - " -oPasswordAuthentication=no -i %s %s ls" % (ssh_key, ip) + f" -oPasswordAuthentication=no -i {ssh_key} {ip} ls" ) wait_until( lambda: net["r1"].popen(cmd.split(" ")).wait() == 0, timeout=60, interval=0.5, - description="SSH from %s to %s to succeed" % (net["r1"], ip), + description="SSH from {} to {} to succeed".format(net["r1"], ip), ) net.stop() diff --git a/ipmininet/tests/test_switch.py b/ipmininet/tests/test_switch.py index 47cf3ca7..56167ad7 100644 --- a/ipmininet/tests/test_switch.py +++ b/ipmininet/tests/test_switch.py @@ -57,7 +57,7 @@ def build(self, *args, **kwargs): self.addLink(s3, s4) for s in self.switches(): - self.addLink(s, self.addHost("h%s" % s)) + self.addLink(s, self.addHost(f"h{s}")) super().build(*args, **kwargs) diff --git a/ipmininet/tests/test_tc.py b/ipmininet/tests/test_tc.py index bc1f65e4..83ceaad8 100644 --- a/ipmininet/tests/test_tc.py +++ b/ipmininet/tests/test_tc.py @@ -23,11 +23,12 @@ def assert_delay( src: IPNode, dst: IPNode, delay_target: float, tolerance=1.5, v6=False ): executable = "ping{v6}".format(v6="6" if v6 else "") - require_cmd(executable, help_str=f"{executable} is required to run tests") - - cmd = "{executable} -c 10 {dst}".format( - executable=executable, dst=dst.intf().ip6 if v6 else dst.intf().ip + require_cmd( + executable, + help_str=f"{executable} is required to run tests", ) + + cmd = f"{executable} -c 10 {dst.intf().ip6 if v6 else dst.intf().ip}" out, err, exitcode = src.pexec(cmd) assert exitcode == 0, f"Cannot ping between {src} and {dst}: {err}" @@ -59,7 +60,9 @@ def assert_bw(src: IPNode, dst: IPNode, bw_target: float, tolerance=1, v6=False) description=f"iperf3 server on {dst_ip} to start listening", ) src.popen( - f"iperf3 -c {dst_ip}", stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + f"iperf3 -c {dst_ip}", + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) out, err = iperf.communicate() diff --git a/ipmininet/tests/test_topologydb.py b/ipmininet/tests/test_topologydb.py index d1948347..9e42a257 100644 --- a/ipmininet/tests/test_topologydb.py +++ b/ipmininet/tests/test_topologydb.py @@ -59,11 +59,13 @@ def test_topologydb(topology: type[IPTopo]): assert "type" in node_info, f"No info on the type of node {node}" node_type = node_info["type"] if node_type == "host": - assert type(net[node]) == IPHost, f"The node {node} is not an host" + assert isinstance(net[node], IPHost), f"The node {node} is not an host" elif node_type == "router": - assert type(net[node]) == Router, f"The node {node} is not a router" + assert isinstance(net[node], Router), f"The node {node} is not a router" elif node_type == "switch": - assert type(net[node]) == IPSwitch, f"The node {node} is not a switch" + assert isinstance(net[node], IPSwitch), ( + f"The node {node} is not a switch" + ) else: pytest.fail(f"The node type {node_type} of node {node} is invalid") @@ -98,9 +100,9 @@ def test_topologydb(topology: type[IPTopo]): ) # Checks the IP address - assert info_value["ip"] == "%s/%s" % (intf.ip, intf.prefixLen), ( - f"The IP address of the record {info_key} of node {node} does not " - "match" + assert info_value["ip"] == f"{intf.ip}/{intf.prefixLen}", ( + f"The IP address of the record {info_key} of node {node} " + "does not match" ) # Checks the IP prefixes @@ -108,8 +110,8 @@ def test_topologydb(topology: type[IPTopo]): ip.with_prefixlen for ip in itertools.chain(intf.ips(), intf.ip6s()) } assert set(info_value["ips"]) == prefixes, ( - f"The IP prefixes of the record {info_key} of node {node} do not " - "match" + f"The IP prefixes of the record {info_key} of node {node} " + "do not match" ) finally: diff --git a/ipmininet/tests/utils.py b/ipmininet/tests/utils.py index 45cdc729..603d200c 100644 --- a/ipmininet/tests/utils.py +++ b/ipmininet/tests/utils.py @@ -52,9 +52,7 @@ def wait_until( elapsed += interval if callable(description): description = description() - pytest.fail( - "Timed out after %ds while waiting for %s" % (int(elapsed), description) - ) + pytest.fail(f"Timed out after {int(elapsed)}s while waiting for {description}") def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300, poll=1.0) -> list[str]: @@ -138,15 +136,14 @@ def assert_path( path.append(n.name) break assert found, ( - "Traceroute returned the address '%s' " - "that cannot be linked to a node" % path_ip + f"Traceroute returned the address '{path_ip}' " + "that cannot be linked to a node" ) i += 1 assert path == expected_path, ( - "We expected the path from %s to %s to go " - "through %s but it went through %s" - % (src, dst, expected_path[1:-1], path[1:-1]) + f"We expected the path from {src} to {dst} to go " + f"through {expected_path[1:-1]} but it went through {path[1:-1]}" ) @@ -170,10 +167,9 @@ def _check_src(src) -> bool: dst_ip = dst.defaultIntf().ip6 if v6 else dst.defaultIntf().ip else: dst_ip = dst - cmd = "nmap%s -sn -n --max-retries 0 --max-rtt-timeout %dms %s" % ( - " -6" if v6 else "", - int(timeout * 1000), - dst_ip, + cmd = ( + f"nmap{' -6' if v6 else ''} -sn -n --max-retries 0 " + f"--max-rtt-timeout {int(timeout * 1000)}ms {dst_ip}" ) out = src.cmd(cmd.split(" ")) if "0 hosts up" in out: @@ -195,15 +191,15 @@ def assert_node_not_connected(src: IPNode, dst: IPNode, v6=False, timeout=0.2): dst.defaultIntf().updateIP() dst.defaultIntf().updateIP6() dst_ip = dst.defaultIntf().ip6 if v6 else dst.defaultIntf().ip - cmd = "nmap%s -sn -n --max-retries 0 --max-rtt-timeout %dms %s" % ( - " -6" if v6 else "", - int(timeout * 1000), - dst_ip, + cmd = ( + f"nmap{' -6' if v6 else ''} -sn -n --max-retries 0 " + f"--max-rtt-timeout {int(timeout * 1000)}ms {dst_ip}" ) out = src.cmd(cmd.split(" ")) - assert "0 hosts up" in out, "Node {} is connected to node {} over {}".format( - src.name, dst.name, "IPv4" if not v6 else "IPv6" + assert "0 hosts up" in out, ( + f"Node {src.name} is connected to node {dst.name} " + f"over {'IPv4' if not v6 else 'IPv6'}" ) @@ -231,20 +227,20 @@ def check_tcp_connectivity( if server_itf is None: server_itf = server.defaultIntf() server_ip = server_itf.ip6 if v6 else server_itf.ip - server_cmd = "nc %s -l %d" % ("-6" if v6 else "-4", server_port) + server_cmd = f"nc {'-6' if v6 else '-4'} -l {server_port}" server_p = server.popen(server_cmd.split(" ")) t = 0 - client_cmd = "nc -z -w 1 -v %s %d" % (server_ip, server_port) + client_cmd = f"nc -z -w 1 -v {server_ip} {server_port}" client_p = client.popen(client_cmd.split(" ")) while t < timeout * 2 and client_p.wait() != 0: t += 1 if server_p.poll() is not None: out, err = server_p.communicate() - assert False, ( + raise AssertionError( "The netcat server used to check TCP connectivity failed" - " with the output:\n[stdout]\n%s\n[stderr]\n%s" % (out, err) + f" with the output:\n[stdout]\n{out}\n[stderr]\n{err}" ) time.sleep(0.5) client_p = client.popen(client_cmd.split(" ")) @@ -269,7 +265,7 @@ def assert_stp_state(switch: IPSwitch, expected_states: dict[str, str], timeout= possible_states = "listening|learning|forwarding|blocking" # In these states the STP has not converged ignore_state = "listening", "learning" - cmd = "%s %s" % (partial_cmd, switch.name) + cmd = f"{partial_cmd} {switch.name}" out = None states = None @@ -288,14 +284,14 @@ def _converged(): interfaces = re.findall(switch.name + r"-eth[0-9]+", out) state_map = {interfaces[i]: states[i] for i in range(len(states))} - for itf, _ in expected_states.items(): + for itf, expected in expected_states.items(): assert itf in state_map, ( - "The port %s of switch %s was not mentioned in the output of " - "'brctl showstp':\n%s" % (itf, switch.name, out) + f"The port {itf} of switch {switch.name} was not mentioned " + f"in the output of 'brctl showstp':\n{out}" ) - assert state_map[itf] == expected_states[itf], ( - "The state of port %s of switch %s wasn't correct: excepted '%s' " - "got '%s'" % (itf, switch.name, expected_states[itf], state_map[itf]) + assert state_map[itf] == expected, ( + f"The state of port {itf} of switch {switch.name} wasn't correct: " + f"expected '{expected}' got '{state_map[itf]}'" ) @@ -316,7 +312,7 @@ def assert_routing_table( :param timeout: Time to wait for the routing convergence """ expected = set(expected_prefixes) - cmd = "ip -%d route" % ip_network(str(next(iter(expected)))).version + cmd = f"ip -{ip_network(str(next(iter(expected)))).version} route" found = set() def _satisfied(): @@ -331,15 +327,9 @@ def _satisfied(): timeout=timeout, interval=1, description=lambda: ( - "prefixes %s to be %s in the routing table " - "of %s within %ds (found: %s)" - % ( - expected_prefixes, - "present" if present else "absent", - router.name, - timeout, - sorted(found), - ) + f"prefixes {expected_prefixes} to be " + f"{'present' if present else 'absent'} in the routing table " + f"of {router.name} within {timeout}s (found: {sorted(found)})" ), ) @@ -387,22 +377,21 @@ def _answered(): timeout=timeout, interval=0.5, description=lambda: ( - "the expected data '%s' to be found in the DNS " - "reply of '%s' received by %s from %s:\n%s" - % (out_regex.pattern, server_cmd, node.name, dns_server_address, out) + f"the expected data '{out_regex.pattern}' to be found in the DNS " + f"reply of '{server_cmd}' received by {node.name} from " + f"{dns_server_address}:\n{out}" ), ) assert got_answer, ( - "No answer was received in %s" - " from server %s in the reply of '%s':\n%s" - % (node.name, dns_server_address, server_cmd, out) + f"No answer was received in {node.name}" + f" from server {dns_server_address} in the reply of '{server_cmd}':\n{out}" ) assert match is not None, ( - "The expected data '%s' cannot be found " - "in the DNS reply of '%s' received by %s from " - "%s:\n%s" % (out_regex.pattern, server_cmd, node.name, dns_server_address, out) + f"The expected data '{out_regex.pattern}' cannot be found " + f"in the DNS reply of '{server_cmd}' received by {node.name} from " + f"{dns_server_address}:\n{out}" ) diff --git a/ipmininet/topologydb.py b/ipmininet/topologydb.py index a7fc50eb..90549323 100644 --- a/ipmininet/topologydb.py +++ b/ipmininet/topologydb.py @@ -3,7 +3,6 @@ import itertools import json -from builtins import str from ipaddress import ip_interface from mininet.log import lg @@ -55,7 +54,7 @@ def _node(self, x): try: return self._network[x] except KeyError: - raise ValueError("No node named %s in the network" % x) + raise ValueError(f"No node named {x} in the network") from None __getitem__ = node = _node @@ -63,7 +62,7 @@ def _interface(self, x, y): try: return self._node(x)[y] except KeyError: - raise ValueError("The link %s-%s does not exist" % (x, y)) + raise ValueError(f"The link {x}-{y} does not exist") from None def interface(self, x, y): """Return the ip address of the interface of x facing y @@ -104,7 +103,7 @@ def routerid(self, x): :return: the routerid""" n = self._node(x) if n["type"] != "router": - raise TypeError("%s is not a router" % x) + raise TypeError(f"{x} is not a router") return n["routerid"] def parse_net(self, net): @@ -124,7 +123,7 @@ def _add_node(self, n, props): for itf in itfs: nh = otherIntf(itf) itf_props = { - "ip": "%s/%s" % (itf.ip, itf.prefixLen), + "ip": f"{itf.ip}/{itf.prefixLen}", "ips": [ ip.with_prefixlen for ip in itertools.chain(itf.ips(), itf.ip6s()) ], diff --git a/ipmininet/utils.py b/ipmininet/utils.py index 385a30a0..8cba0ea3 100644 --- a/ipmininet/utils.py +++ b/ipmininet/utils.py @@ -19,9 +19,9 @@ def has_cmd(cmd: str) -> bool: if os.path.isfile(cmd) and os.access(cmd, os.X_OK): return True # Try to find the cmd in each directory in $PATH - for path in os.environ["PATH"].split(os.path.pathsep): - path = path.strip('"') - exe = os.path.join(path, cmd) + for raw in os.environ["PATH"].split(os.path.pathsep): + p = raw.strip('"') + exe = os.path.join(p, cmd) if os.path.isfile(exe) and os.access(exe, os.X_OK): return True return False @@ -39,7 +39,7 @@ def require_cmd(cmd: str, help_str: str | None = None): if help_str: log.error(help_str) - raise RuntimeError("[%s] is not available in $PATH" % cmd) + raise RuntimeError(f"[{cmd}] is not available in $PATH") def otherIntf(intf: Intf) -> Optional["IPIntf"]: @@ -88,12 +88,15 @@ def is_subnet_of(a: IPv4Network | IPv6Network, b: IPv4Network | IPv6Network) -> # Always false if one is v4 and the other is v6. if a.version != b.version: raise TypeError(f"{a} and {b} are not of the same version") + except AttributeError: + raise TypeError( + f"Unable to test subnet containment between {a} and {b}" + ) from None + else: return ( b.network_address <= a.network_address and b.broadcast_address >= a.broadcast_address ) - except AttributeError: - raise TypeError(f"Unable to test subnet containment between {a} and {b}") def is_container(x) -> bool: diff --git a/pyproject.toml b/pyproject.toml index 9afd6b8d..9c478d23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,14 +65,63 @@ select = [ "FURB", "FLY", "TRY", - "BLE", + "E501", + "TRY003", + "PLR2004", + "PLR0913", + "PLR0917", + "PLC0415", + "PLR0912", + "PLR0911", + "PLR0915", "PLC0206", "PLW0603", "PLW1510", "PLW1641", "PLW2901", ] -ignore = [ "E501", "TRY003", "PLC0415",] + +[tool.ruff.lint.per-file-ignores] +# E501: lines that cannot be reflowed (ASCII-art diagram, shell command +# strings, a literal `ip addr` output fixture). +"ipmininet/examples/simple_ospf_network.py" = ["E501"] +"ipmininet/install/install.py" = ["E501"] +"ipmininet/tests/test_pure.py" = ["E501"] +# Architectural rules deferred for existing files (see NOTES.md): +# - PLC0415: intentional circular-import avoidance +# - TRY003: requires per-message exception classes +# - PLR2004: requires named constants for IP/protocol magic values +# - PLR0913/PLR0917/PLR0912/PLR0911/PLR0915: require function refactors +"ipmininet/examples/exabgp_prefix_injector.py" = ["PLR2004"] +"ipmininet/examples/ripng_network_adjust.py" = ["PLR0913", "PLR0917"] +"ipmininet/examples/spanning_tree_adjust.py" = ["PLR0913", "PLR0917"] +"ipmininet/host/config/named.py" = ["PLR0912", "PLR0913", "PLR0917", "PLR2004"] +"ipmininet/install/utils.py" = ["PLC0415", "TRY003"] +"ipmininet/ipnet.py" = ["PLR0912", "PLR0913", "PLR0917", "PLR2004", "TRY003"] +"ipmininet/ipovs_switch.py" = ["PLR0913", "PLR0917", "TRY003"] +"ipmininet/ipswitch.py" = ["PLR0913", "PLR0917"] +"ipmininet/iptopo.py" = ["TRY003"] +"ipmininet/link.py" = ["PLR0911", "PLR2004", "TRY003"] +"ipmininet/node_description.py" = ["PLR2004", "TRY003"] +"ipmininet/overlay.py" = ["TRY003"] +"ipmininet/router/__router.py" = ["PLR0913", "PLR0917"] +"ipmininet/router/config/base.py" = ["PLC0415", "TRY003"] +"ipmininet/router/config/bgp.py" = ["PLR0913", "PLR0917", "TRY003"] +"ipmininet/router/config/exabgp.py" = ["TRY003"] +"ipmininet/router/config/iptables.py" = ["TRY003"] +"ipmininet/router/config/utils.py" = ["PLR2004"] +"ipmininet/router/config/zebra.py" = ["PLR0913", "PLR0917", "PLR2004", "TRY003"] +"ipmininet/srv6.py" = ["PLR0913", "PLR0917", "PLR2004", "TRY003"] +"ipmininet/tests/test_gre.py" = ["PLR2004"] +"ipmininet/tests/test_link.py" = ["PLR2004"] +"ipmininet/tests/test_misc.py" = ["PLR2004"] +"ipmininet/tests/test_radv.py" = ["TRY003"] +"ipmininet/tests/test_srv6.py" = ["PLR0912", "PLR0915", "PLR2004"] +"ipmininet/tests/test_sshd.py" = ["PLR2004"] +"ipmininet/tests/test_tc.py" = ["PLR2004"] +"ipmininet/tests/utils.py" = ["PLR0913", "PLR0917", "PLR2004", "TRY003"] +"ipmininet/topologydb.py" = ["TRY003"] +"ipmininet/utils.py" = ["PLC0415", "TRY003"] [tool.mdformat] wrap = "keep" From c7fc0459e79eb325a735919f226df59143c636f9 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 00:34:55 +0300 Subject: [PATCH 36/69] chore: add .git-blame-ignore-revs for the auto-fix reformatting --- .git-blame-ignore-revs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..75f18444 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,6 @@ +# Revisions to skip in `git blame` — purely mechanical reformatting with no +# semantic change, so blame should attribute the code to its real author. +# +# style: apply ruff and mdformat auto-fixes (ruff lint autofix + ruff +# formatter + mdformat on the markdown files). +72264c21416e294f9f131ad3589f03205184f42b From 0ee53cb655751352d7652bceb844e6003263f6b0 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 01:04:27 +0300 Subject: [PATCH 37/69] fix: point .git-blame-ignore-revs at the merged auto-fix hash and harden the ExaBGP delivery timeout --- .git-blame-ignore-revs | 2 +- ipmininet/tests/test_exabgp.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 75f18444..00cad832 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -3,4 +3,4 @@ # # style: apply ruff and mdformat auto-fixes (ruff lint autofix + ruff # formatter + mdformat on the markdown files). -72264c21416e294f9f131ad3589f03205184f42b +46083b313a0aca6d9f1f8ad9e4805044d52c1fef diff --git a/ipmininet/tests/test_exabgp.py b/ipmininet/tests/test_exabgp.py index 5ee2a963..3ca9c63c 100644 --- a/ipmininet/tests/test_exabgp.py +++ b/ipmininet/tests/test_exabgp.py @@ -191,7 +191,7 @@ def get_rib_routes(node, command, family): return json.loads(m.group("rib")).get("routes") -def wait_for_expected_routes(node, rib_scripts, topo, timeout=540, poll=5): +def wait_for_expected_routes(node, rib_scripts, topo, timeout=900, poll=5): """Wait until all the routes ExaBGP is expected to inject appear in the FRRouting BGP RIB. @@ -199,7 +199,8 @@ def wait_for_expected_routes(node, rib_scripts, topo, timeout=540, poll=5): default), and the passive BGP session it uses may take a while to establish. A fixed sleep is therefore unreliable under load; poll the RIB instead. The default timeout accounts for that delay plus the slow - delivery of the routes under a loaded CI runner. + delivery of the routes under a loaded CI runner (observed up to ~9 minutes + on the container job). """ expected_routes = topo.routes missing = [] From 28fe6569c3dab38c9fe1fbe02611872189bad449 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 01:45:49 +0300 Subject: [PATCH 38/69] build: bump project version to 1.2.6 Signed-off-by: Iakov Kirilenko --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9c478d23..c5a88f69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ipmininet" -version = "1.1" +version = "1.2.6" requires-python = ">=3.12" description = "A mininet extension providing components to emulate IPnetworks running multiple protocols." keywords = [ "networking", "OSPF", "IP", "BGP", "quagga", "mininet",] From 51d300c5219071e922820317f2eb15a680261b35 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 01:47:39 +0300 Subject: [PATCH 39/69] docs: fix stale claims and document project conventions Signed-off-by: Iakov Kirilenko --- docs/conf.py | 10 +++++++++- docs/contribute.rst | 44 +++++++++++++++++++++++--------------------- docs/install.rst | 13 +++---------- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 8d179ae8..8682b7d5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,15 @@ # The short X.Y version version = "" # The full version, including alpha/beta/rc tags -release = "v0.6" +release = "" + +try: + from importlib.metadata import version as _pkg_version + + release = _pkg_version("ipmininet") + version = ".".join(release.split(".")[:2]) +except Exception: # package not installed; docs still build + pass # -- General configuration --------------------------------------------------- diff --git a/docs/contribute.rst b/docs/contribute.rst index 27c45673..735c146f 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -6,6 +6,21 @@ Don't hesitate to ask for advice on the `mailing list `_, to report bugs as Github issues and to contribute through Github pull requests. +Project conventions +------------------- + +- **Workflow:** GitHub Flow. One short-lived branch per change, opened as a + pull request, merged to ``master`` with a rebase merge. ``master`` is always + releasable. +- **Commits:** Conventional Commits (``type(scope): subject``), imperative + mood, signed. +- **Versioning:** bump ``pyproject.toml`` only when cutting a release. + ``fix`` → PATCH, ``feat`` → MINOR, breaking changes → MAJOR. Additive and + backwards-compatible changes without significant new functionality are + PATCH. +- **Changelog:** GitHub release notes, assembled from merged pull-request + titles. + Setting up the development environment -------------------------------------- @@ -19,18 +34,11 @@ First get the source code of your fork: $ git clone $ cd ipmininet -Then, install your version of IPMininet in development mode. -If you have pip above **18.1**, execute: +Then, install your version of IPMininet in development mode: .. code-block:: bash - $ sudo pip install -e . - -If you have an older version of pip, use: - -.. code-block:: bash - - $ sudo pip -e install --process-dependency-links . + $ uv sync --group dev Finally, you can install all the daemons: @@ -106,7 +114,7 @@ When instantiating each router and each host, their daemons are also instantiated and options parsed. However the daemon configurations are not built yet. -3. Addressing +4. Addressing ^^^^^^^^^^^^^ After creating all the devices and their interfaces, @@ -171,24 +179,18 @@ Running the tests ----------------- The `pytest `_ framework is used -for the test suite and are `integrated within setuptools -`_. -Currently the suite has end-to-end tests that check if the daemons work as -expected. Therefore, the tests require an operating environment, i.e. daemons -have to be installed and must be in PATH. - -To run the whole test suite go the top level directory and run: +for the test suite. The suite is end-to-end and requires root and the daemons +to be installed and in PATH. Run it in parallel as CI does: .. code-block:: bash - sudo pytest + sudo scripts/run-tests-parallel.sh -You can also run a single test by passing options to pytest: +Or run a single test by passing options to pytest: .. code-block:: bash - sudo pytest ipmininet/tests/test_sshd.py --fulltrace + sudo uv run pytest ipmininet/tests/test_sshd.py --fulltrace Some tests are skipped conditionally based on the environment (e.g. running as a non-root user, or a missing routing daemon). See the "Test-skip policy" diff --git a/docs/install.rst b/docs/install.rst index d9411b3b..22ddfe24 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -4,7 +4,7 @@ Installation IPMininet needs at minimum: -- Python_ (with pip) **3.6+** +- Python_ (with pip) **3.12+** - Mininet_ IPMininet needs some daemon executables to be installed @@ -50,18 +50,11 @@ Manual installation ------------------- You can download and install IPMininet. -You can change the installed version by replacing "v1.1" in the following commands. -If you have pip above **18.1**, execute: +You can change the installed version by replacing the version tag in the following command: .. code-block:: bash - $ sudo pip install --upgrade git+https://github.com/cnp3/ipmininet.git@v1.1 - -If you have an older version of pip, use: - -.. code-block:: bash - - $ sudo pip install --process-dependency-links --upgrade git+https://github.com/cnp3/ipmininet.git@v1.1 + $ sudo pip install --upgrade git+https://github.com/mimi-net/ipmininet.git@v1.2.6 Then, you can install all the daemons: From ad1fb95a90a6e41141048ffa04ac211ebca25f81 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 13:54:23 +0300 Subject: [PATCH 40/69] fix(overlay): add strict mode to wait_until_capturing mimidump creates its capture file before its captors enter the capture loop, so a bare file is a proxy, not liveness; the READY signal is the authoritative all-captors-live notification. wait_until_capturing() now takes strict=True which rejects bare file existence and instead accepts either the READY signal or file growth past the pcap header between two polls. Signed-off-by: Iakov Kirilenko --- ipmininet/overlay.py | 35 ++++++++-- ipmininet/tests/test_network_capture.py | 86 ++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/ipmininet/overlay.py b/ipmininet/overlay.py index 10bcfd9b..21c5988e 100644 --- a/ipmininet/overlay.py +++ b/ipmininet/overlay.py @@ -242,6 +242,11 @@ def __str__(self): return f"" +_CAPTURE_HEADER_SIZE = 24 +"""Size of a classic pcap global header, the format mimidump writes via +``pcap_dump_open`` before its captors enter the capture loop.""" + + class NetworkCapture(Overlay): """Overlay capturing traffic on multiple interfaces before starting the daemons, and stores the result""" @@ -333,21 +338,30 @@ def stop(self, node: Optional["IPNode"] = None, intf: Optional["IPIntf"] = None) process.send_signal(signal.SIGINT) process.wait() - def wait_until_capturing(self, intf_name: str, timeout: float = 10.0) -> bool: + def wait_until_capturing( + self, intf_name: str, timeout: float = 10.0, strict: bool = False + ) -> bool: """Wait until the capture on the given node/interface is actually running. A capture is considered live once the corresponding process is still alive AND either the mimidump ``READY`` signal has been seen on stderr (that signal is emitted once, when all captors enter the capture loop) OR the - capture output file already exists (the fallback used by captures that do - not signal readiness, e.g. tcpdump or older mimidump binaries). + capture output file is live. Without ``strict``, a file is live as soon + as it exists (the fallback used by captures that do not signal + readiness, e.g. tcpdump or older mimidump binaries). In ``strict`` mode + a file is only live once it has grown past the capture header between + two polls; mimidump creates its file before the captors enter the loop, + so bare existence is a proxy, not liveness. :param intf_name: name of the node or of the interface the capture is attached to (the key used in ``ongoing_captures``) :param timeout: maximum time to wait, in seconds + :param strict: if True, never accept a bare capture file as live; + require the ``READY`` signal or file growth instead :return: True as soon as the capture is live, False if the capture was not started, died or did not become live in time""" deadline = time.monotonic() + timeout + last_size = None while time.monotonic() < deadline: process = self.ongoing_captures.get(intf_name) if process is None or process.poll() is not None: @@ -360,7 +374,20 @@ def wait_until_capturing(self, intf_name: str, timeout: float = 10.0) -> bool: return True output_file = self._output_files.get(intf_name) if output_file is not None and os.path.exists(output_file): - return True + if not strict: + return True + try: + size = os.path.getsize(output_file) + except OSError: + size = None + if ( + size is not None + and last_size is not None + and size > _CAPTURE_HEADER_SIZE + and size > last_size + ): + return True + last_size = size time.sleep(0.05) return False diff --git a/ipmininet/tests/test_network_capture.py b/ipmininet/tests/test_network_capture.py index 9d8452bf..73ac19f6 100644 --- a/ipmininet/tests/test_network_capture.py +++ b/ipmininet/tests/test_network_capture.py @@ -1,8 +1,16 @@ """ "This module test the Link Failure API""" +import os +import tempfile +import threading +import time +from contextlib import contextmanager + +import pytest + from ipmininet.clean import cleanup from ipmininet.ipnet import IPNet -from ipmininet.overlay import NetworkCapture +from ipmininet.overlay import _CAPTURE_HEADER_SIZE, NetworkCapture from ..examples.network_capture import NetworkCaptureTopo from . import require_mimidump, require_root @@ -56,3 +64,79 @@ def test_network_capture_wait_until_capturing(): net.stop() finally: cleanup() + + +class _FakeProc: + """Popen stand-in that stays alive until poll() is called.""" + + def __init__(self, exit_code=None): + self._exit_code = exit_code + + def poll(self): + return self._exit_code + + +_HEADER = _CAPTURE_HEADER_SIZE +_GROWTH = 64 +_TIMEOUT = 0.5 + + +class TestWaitUntilCapturingStrict: + """Rootless unit tests for wait_until_capturing(..., strict=True). + + The matrix locks the strict contract: READY on stderr is live; a bare + header-only file is not; growth past the header between two polls is live; + a stable file that never grows again is not; and the default (non-strict) + mode still accepts bare file existence for backward compatibility. + + A ``tempfile.TemporaryDirectory`` is used instead of the ``tmp_path`` + fixture: the xdist workers run in a private mount namespace with a fresh + ``/tmp`` (scripts/py-unshare.sh), so pytest's basetemp directory does not + survive into the worker. + """ + + @staticmethod + @contextmanager + def _overlay(ready=False, size=0, exit_code=None): + overlay = NetworkCapture() + with tempfile.TemporaryDirectory() as tmp: + overlay._stderr_lines["eth0"] = [b"READY"] if ready else [] + overlay._output_files["eth0"] = os.path.join(tmp, "cap.pcap") + if size: + with open(overlay._output_files["eth0"], "wb") as f: + f.write(b"\0" * size) + overlay.ongoing_captures["eth0"] = _FakeProc(exit_code) + yield overlay + + @pytest.mark.parametrize( + "strict,ready,size,expected", + [ + (False, False, _HEADER, True), + (True, True, 0, True), + (True, False, _HEADER, False), + (True, False, _HEADER + _GROWTH, False), + ], + ) + def test_wait_until_capturing(self, strict, ready, size, expected): + with self._overlay(ready=ready, size=size) as overlay: + assert ( + overlay.wait_until_capturing("eth0", timeout=_TIMEOUT, strict=strict) + is expected + ) + + def test_strict_accepts_growth_between_polls(self): + with self._overlay(size=_HEADER) as overlay: + + def _grow(): + time.sleep(0.15) + with open(overlay._output_files["eth0"], "ab") as f: + f.write(b"\0" * _GROWTH) + + threading.Thread(target=_grow, daemon=True).start() + assert overlay.wait_until_capturing("eth0", timeout=_TIMEOUT, strict=True) + + def test_dead_process_is_not_live(self): + with self._overlay(size=_HEADER, exit_code=1) as overlay: + assert not overlay.wait_until_capturing( + "eth0", timeout=_TIMEOUT, strict=True + ) From 6bc82b7621f02679c6dd0d11c63ecf255c214c5d Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 14:48:26 +0300 Subject: [PATCH 41/69] fix(overlay): derive capture header size from magic bytes; accept stable past-header files Address review feedback: - The strict check no longer assumes a fixed 24-byte classic pcap header. _capture_header_size() reads the file's magic bytes and returns the correct header length for classic pcap (LE/BE) and pcapng, defaulting to classic pcap for unknown or unreadable files. - Strict mode now accepts a file that is already stable past its header (a bursty writer whose whole exchange landed before the first poll), instead of only growing files, so the file-growth fallback is reliable for captures that cannot signal readiness. READY remains the primary signal. Tests expanded to cover both magic byte formats, the bursty stable case and an undersized/torn header. Signed-off-by: Iakov Kirilenko --- ipmininet/overlay.py | 69 ++++++++++++++++++------- ipmininet/tests/test_network_capture.py | 57 +++++++++++++------- 2 files changed, 86 insertions(+), 40 deletions(-) diff --git a/ipmininet/overlay.py b/ipmininet/overlay.py index 21c5988e..5837fcaf 100644 --- a/ipmininet/overlay.py +++ b/ipmininet/overlay.py @@ -242,9 +242,30 @@ def __str__(self): return f"" -_CAPTURE_HEADER_SIZE = 24 -"""Size of a classic pcap global header, the format mimidump writes via -``pcap_dump_open`` before its captors enter the capture loop.""" +_PCAP_GLOBAL_HEADER_SIZE = 24 +_PCAPNG_SECTION_HEADER_SIZE = 28 +_MIN_PACKET_RECORD = 16 +_PCAP_MAGIC_LE = b"\xd4\xc3\xb2\xa1" +_PCAP_MAGIC_BE = b"\xa1\xb2\xc3\xd4" +_PCAPNG_MAGIC = b"\x0a\x0d\x0d\x0a" + + +def _capture_header_size(output_file: str) -> int: + """Return the capture format's header size from the file's magic bytes. + + Classic pcap has a 24-byte global header, pcapng a 28-byte section header + block; unknown or unreadable files default to classic pcap. + """ + try: + with open(output_file, "rb") as f: + magic = f.read(4) + except OSError: + return _PCAP_GLOBAL_HEADER_SIZE + if magic in (_PCAP_MAGIC_LE, _PCAP_MAGIC_BE): + return _PCAP_GLOBAL_HEADER_SIZE + if magic == _PCAPNG_MAGIC: + return _PCAPNG_SECTION_HEADER_SIZE + return _PCAP_GLOBAL_HEADER_SIZE class NetworkCapture(Overlay): @@ -344,20 +365,23 @@ def wait_until_capturing( """Wait until the capture on the given node/interface is actually running. A capture is considered live once the corresponding process is still alive - AND either the mimidump ``READY`` signal has been seen on stderr (that - signal is emitted once, when all captors enter the capture loop) OR the - capture output file is live. Without ``strict``, a file is live as soon - as it exists (the fallback used by captures that do not signal - readiness, e.g. tcpdump or older mimidump binaries). In ``strict`` mode - a file is only live once it has grown past the capture header between - two polls; mimidump creates its file before the captors enter the loop, - so bare existence is a proxy, not liveness. + AND either the mimidump ``READY`` signal has been seen on stderr (the + primary, authoritative notification: it is emitted once, when all + captors enter the capture loop) OR the capture output file is live. + Without ``strict``, a file is live as soon as it exists (the fallback + used by captures that do not signal readiness, e.g. tcpdump or older + mimidump binaries). In ``strict`` mode a file is only live once it holds + data past its capture header (the header size is derived from the file's + magic bytes, see :func:`_capture_header_size`): the file is either + growing between two polls or already stable past the header. mimidump + creates its file before the captors enter the loop, so a bare + header-only file is a proxy, not liveness. :param intf_name: name of the node or of the interface the capture is attached to (the key used in ``ongoing_captures``) :param timeout: maximum time to wait, in seconds :param strict: if True, never accept a bare capture file as live; - require the ``READY`` signal or file growth instead + require the ``READY`` signal or data past the header :return: True as soon as the capture is live, False if the capture was not started, died or did not become live in time""" deadline = time.monotonic() + timeout @@ -380,14 +404,19 @@ def wait_until_capturing( size = os.path.getsize(output_file) except OSError: size = None - if ( - size is not None - and last_size is not None - and size > _CAPTURE_HEADER_SIZE - and size > last_size - ): - return True - last_size = size + if size is None: + last_size = None + else: + header_size = _capture_header_size(output_file) + if ( + last_size is not None + and size > header_size + and ( + size > last_size or size - header_size >= _MIN_PACKET_RECORD + ) + ): + return True + last_size = size time.sleep(0.05) return False diff --git a/ipmininet/tests/test_network_capture.py b/ipmininet/tests/test_network_capture.py index 73ac19f6..2b0fbad5 100644 --- a/ipmininet/tests/test_network_capture.py +++ b/ipmininet/tests/test_network_capture.py @@ -10,7 +10,14 @@ from ipmininet.clean import cleanup from ipmininet.ipnet import IPNet -from ipmininet.overlay import _CAPTURE_HEADER_SIZE, NetworkCapture +from ipmininet.overlay import ( + _PCAP_GLOBAL_HEADER_SIZE, + _PCAP_MAGIC_BE, + _PCAP_MAGIC_LE, + _PCAPNG_MAGIC, + _PCAPNG_SECTION_HEADER_SIZE, + NetworkCapture, +) from ..examples.network_capture import NetworkCaptureTopo from . import require_mimidump, require_root @@ -76,7 +83,7 @@ def poll(self): return self._exit_code -_HEADER = _CAPTURE_HEADER_SIZE +_HEADER = _PCAP_GLOBAL_HEADER_SIZE _GROWTH = 64 _TIMEOUT = 0.5 @@ -85,9 +92,11 @@ class TestWaitUntilCapturingStrict: """Rootless unit tests for wait_until_capturing(..., strict=True). The matrix locks the strict contract: READY on stderr is live; a bare - header-only file is not; growth past the header between two polls is live; - a stable file that never grows again is not; and the default (non-strict) - mode still accepts bare file existence for backward compatibility. + pcap/pcapng header-only file is not; data past the header is live, whether + the file grows between two polls or is already stable past the header (the + bursty-writer case where the whole exchange landed before the first poll); + and the default (non-strict) mode still accepts bare file existence for + backward compatibility. A ``tempfile.TemporaryDirectory`` is used instead of the ``tmp_path`` fixture: the xdist workers run in a private mount namespace with a fresh @@ -97,35 +106,43 @@ class TestWaitUntilCapturingStrict: @staticmethod @contextmanager - def _overlay(ready=False, size=0, exit_code=None): + def _overlay(ready=False, total=0, magic=_PCAP_MAGIC_LE, exit_code=None): overlay = NetworkCapture() with tempfile.TemporaryDirectory() as tmp: overlay._stderr_lines["eth0"] = [b"READY"] if ready else [] - overlay._output_files["eth0"] = os.path.join(tmp, "cap.pcap") - if size: - with open(overlay._output_files["eth0"], "wb") as f: - f.write(b"\0" * size) + output_file = os.path.join(tmp, "cap.pcap") + overlay._output_files["eth0"] = output_file + if total: + with open(output_file, "wb") as f: + f.write(magic[:total]) + f.write(b"\0" * max(0, total - len(magic))) overlay.ongoing_captures["eth0"] = _FakeProc(exit_code) yield overlay @pytest.mark.parametrize( - "strict,ready,size,expected", + "strict,ready,total,magic,expected", [ - (False, False, _HEADER, True), - (True, True, 0, True), - (True, False, _HEADER, False), - (True, False, _HEADER + _GROWTH, False), + (False, False, _HEADER, _PCAP_MAGIC_LE, True), + (True, True, 0, _PCAP_MAGIC_LE, True), + (True, False, _HEADER, _PCAP_MAGIC_LE, False), + (True, False, _PCAPNG_SECTION_HEADER_SIZE, _PCAPNG_MAGIC, False), + (True, False, 20, _PCAP_MAGIC_LE, False), + (True, False, _HEADER + _GROWTH, _PCAP_MAGIC_BE, True), ], ) - def test_wait_until_capturing(self, strict, ready, size, expected): - with self._overlay(ready=ready, size=size) as overlay: + def test_wait_until_capturing(self, strict, ready, total, magic, expected): + with self._overlay(ready=ready, total=total, magic=magic) as overlay: assert ( overlay.wait_until_capturing("eth0", timeout=_TIMEOUT, strict=strict) is expected ) - def test_strict_accepts_growth_between_polls(self): - with self._overlay(size=_HEADER) as overlay: + @pytest.mark.parametrize( + "magic,header", + [(_PCAP_MAGIC_LE, _HEADER), (_PCAPNG_MAGIC, _PCAPNG_SECTION_HEADER_SIZE)], + ) + def test_strict_accepts_growth_between_polls(self, magic, header): + with self._overlay(total=header, magic=magic) as overlay: def _grow(): time.sleep(0.15) @@ -136,7 +153,7 @@ def _grow(): assert overlay.wait_until_capturing("eth0", timeout=_TIMEOUT, strict=True) def test_dead_process_is_not_live(self): - with self._overlay(size=_HEADER, exit_code=1) as overlay: + with self._overlay(total=_HEADER, exit_code=1) as overlay: assert not overlay.wait_until_capturing( "eth0", timeout=_TIMEOUT, strict=True ) From e53dfd85d9ea3c77ed90927601ed24576b6a0a6c Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 13:47:14 +0300 Subject: [PATCH 42/69] test(ci): add branch-coverage reporting and a duplicated-code gate Signed-off-by: Iakov Kirilenko --- .github/workflows/heavy-test.yaml | 25 +++- .github/workflows/test.yaml | 25 +++- .gitignore | 3 + README.md | 2 +- docs/contribute.rst | 15 ++ pyproject.toml | 31 ++++- scripts/check-duplicates.sh | 19 +++ scripts/ci-test.sh | 2 - scripts/run-tests-parallel.sh | 10 ++ uv.lock | 223 +++++++++++++++++++++++++++++- 10 files changed, 345 insertions(+), 10 deletions(-) create mode 100755 scripts/check-duplicates.sh diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index 46552c6a..f4d1e018 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -49,7 +49,28 @@ jobs: run: scripts/ci-save-deps.sh - name: Install mimidump run: scripts/ci-install-mimidump.sh - - name: Run full test suite + - name: Run full test suite (coverage) timeout-minutes: 170 run: | - sudo env "PATH=$PATH" scripts/run-tests-parallel.sh + sudo env "PATH=$PATH" COVERAGE=1 scripts/run-tests-parallel.sh + - name: Combine and enforce the coverage gate + run: | + uv run coverage combine + uv run coverage report -m + uv run coverage xml -o coverage.xml + uv run coverage html -d htmlcov + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-full + path: | + coverage.xml + htmlcov/ + - name: Upload coverage to Codecov + if: env.CODECOV_TOKEN != '' + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + flags: full diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c475a9c9..a589d1f0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -34,13 +34,34 @@ jobs: uv run ruff check . uv run ruff format --check . uv run mdformat --check README.md LICENSE.md ipmininet/examples/README.md - - name: Run rootless tests + - name: Check for duplicated code + run: scripts/check-duplicates.sh + - name: Run rootless tests (coverage) run: | uv run python -m pytest \ -v \ + --cov=ipmininet \ + --cov-report=html:htmlcov \ + --cov-report=xml:coverage.xml \ ipmininet/tests/test_pure.py \ ipmininet/tests/test_misc.py \ - ipmininet/tests/test_link.py + ipmininet/tests/test_link.py \ + ipmininet/tests/test_network_capture.py + - name: Upload coverage report (rootless) + uses: actions/upload-artifact@v4 + with: + name: coverage-rootless + path: | + coverage.xml + htmlcov/ + - name: Upload coverage to Codecov + if: env.CODECOV_TOKEN != '' + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + flags: rootless test: runs-on: ubuntu-latest timeout-minutes: 200 diff --git a/.gitignore b/.gitignore index 3118673e..e51651e8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ docs/api/*.rst .venv/ /.tmp/ .coverage +.coverage.* +coverage.xml +htmlcov/ diff --git a/README.md b/README.md index 9cc11089..e6fb7162 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Build Status](https://jenkins-mininet.info.ucl.ac.be/buildStatus/icon?job=ipmininet%2Fpythonversion%3Dipmininet-py37%2Ctestname%3Dunittest&subject=Python3.7)](https://jenkins-mininet.info.ucl.ac.be/job/ipmininet/pythonversion=ipmininet-py37,testname=unittest) [![Documentation Status](https://readthedocs.org/projects/ipmininet/badge/?version=latest)](http://ipmininet.readthedocs.io/?badge=latest) [![Build Status](https://jenkins-mininet.info.ucl.ac.be/buildStatus/icon?job=ipmininet%2Fpythonversion%3Dipmininet-py37%2Ctestname%3Ddoctest&subject=doc%20examples)](https://jenkins-mininet.info.ucl.ac.be/job/ipmininet/pythonversion=ipmininet-py37,testname=doctest) -[![Code Coverage](https://img.shields.io/jenkins/coverage/cobertura/https/jenkins-mininet.info.ucl.ac.be/job/ipmininet/pythonversion=ipmininet-py37,testname=unittest.svg)](https://jenkins-mininet.info.ucl.ac.be/job/ipmininet/pythonversion=ipmininet-py37,testname=unittest/cobertura) +[![Code Coverage](https://codecov.io/gh/mimi-net/ipmininet/branch/master/graph/badge.svg)](https://codecov.io/gh/mimi-net/ipmininet) This is a python library, extending [Mininet](http://mininet.org), in order to support emulation of (complex) IP networks. As such it provides new classes, diff --git a/docs/contribute.rst b/docs/contribute.rst index 735c146f..58baad6d 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -197,6 +197,21 @@ as a non-root user, or a missing routing daemon). See the "Test-skip policy" comment at the top of ``ipmininet/tests/__init__.py`` for the full matrix and reasons; skipped tests and their reasons are shown with ``pytest -rs``. +Coverage and code-quality gates +------------------------------- + +- CI measures **branch coverage** (``[tool.coverage]`` in ``pyproject.toml`` + is the single source of truth). PRs run a cheap rootless coverage pass; + ``master`` enforces the full-suite gate with + ``coverage report --fail-under``. Reports (``coverage.xml`` + ``htmlcov/``) + are uploaded as workflow artifacts and to Codecov. +- Run coverage locally with ``COVERAGE=1 scripts/run-tests-parallel.sh`` (as + root), or on the rootless tests directly with + ``uv run pytest --cov=ipmininet ipmininet/tests/test_pure.py``. +- Code duplication is capped by ``scripts/check-duplicates.sh`` (pylint + ``duplicate-code``) against the ``duplication_max`` baseline in + ``pyproject.toml``; CI runs it in the ``rootless`` job. + Writing reliable tests ---------------------- diff --git a/pyproject.toml b/pyproject.toml index c5a88f69..fc082c2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,15 +26,42 @@ text = "GPLv2" Homepage = "https://github.com/cnp3/ipmininet" [project.optional-dependencies] -testing = [ "pytest", "pytest-timeout", "pytest-xdist",] +testing = [ "coverage", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist",] [dependency-groups] docs = [ "sphinx", "sphinx_rtd_theme", "sphinxcontrib-apidoc", "sphinx-mdinclude",] -dev = [ "mdformat", "pre-commit", "ruff",] +dev = [ "mdformat", "pylint", "pre-commit", "ruff",] [tool.aliases] test = "pytest" +[tool.coverage.run] +branch = true +parallel = true +data_file = ".coverage" +omit = [ + "ipmininet/examples/*", + "ipmininet/install/*", + "ipmininet/tests/*", +] + +[tool.coverage.report] +show_missing = true +skip_covered = true +fail_under = 0 + +[tool.ipmininet] +duplication_max = 51 + +[tool.pylint] + +[tool.pylint.messages_control] +disable = "all" +enable = "duplicate-code" + +[tool.pylint.similarities] +min-similarity-lines = 4 + [tool.pytest.ini_options] timeout = 600 timeout_method = "signal" diff --git a/scripts/check-duplicates.sh b/scripts/check-duplicates.sh new file mode 100755 index 00000000..0fef89b1 --- /dev/null +++ b/scripts/check-duplicates.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Enforce a ceiling on duplicated source/test code blocks (pylint duplicate-code). +# The allowed count is read from pyproject.toml [tool.ipmininet] duplication_max, +# so the project config file stays the single source of truth. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +BASELINE="$(uv run python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["tool"]["ipmininet"].get("duplication_max", 0))')" + +OUT="$(uv run pylint ipmininet/ 2>&1 || true)" +COUNT="$(printf '%s\n' "$OUT" | grep -c "R0801" || true)" + +if [ "$COUNT" -gt "$BASELINE" ]; then + printf 'FAIL: %s duplicated code block(s) exceed the baseline of %s\n' "$COUNT" "$BASELINE" >&2 + printf '%s\n' "$OUT" | grep -B1 "duplicate-code" || true + exit 1 +fi +printf 'OK: %s duplicated code block(s) <= baseline %s\n' "$COUNT" "$BASELINE" diff --git a/scripts/ci-test.sh b/scripts/ci-test.sh index 4d3a9ba1..4321767d 100755 --- a/scripts/ci-test.sh +++ b/scripts/ci-test.sh @@ -4,8 +4,6 @@ mkdir -p /workspace/.tmp LOGFILE="${LOGFILE:-/workspace/.tmp/pytest.log}" exec > >(tee "$LOGFILE") 2>&1 -mkdir -p /workspace/htmlcov - # sshd -t requires the privilege separation directory; it is created at # install time but lost between the image build and the container start. mkdir -p /run/sshd diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index 43ec6345..7436cddd 100755 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -59,9 +59,19 @@ if [ ! -x "$WRAPPER" ]; then fi echo "==> pytest-xdist workers: $N (isolated namespaces)" +COVERAGE_FLAGS=() +if [ "${COVERAGE:-0}" = "1" ]; then + echo "==> coverage enabled (branch mode)" + COVERAGE_FLAGS=( + --cov=ipmininet + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + ) +fi exec uv run python -m pytest \ -p no:cacheprovider \ -v \ --dist=loadscope \ --tx "${N}*popen//python=${WRAPPER}" \ + "${COVERAGE_FLAGS[@]}" \ "${ARGS[@]}" diff --git a/uv.lock b/uv.lock index c81ddf47..40d69335 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "astroid" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -178,6 +187,114 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/9c/8d2688694f53dc0b0f0e4783c7eb3c4bb1e79beaf1411879f6dabedf4607/coverage-7.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1c77c3579ac42798f8b7eed6d3dd258debacca32c8753fc8a1f6eaf1db644f5", size = 223194, upload-time = "2026-08-28T21:51:27.767Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/f002163dd688aa3fa49ac6a424b7c2705c7fcf80fba18ec9f586d77827ca/coverage-7.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f81cb1554c3712e41649ed5dc98656b50b958e4da12f0f5adb681ce3db92831", size = 223553, upload-time = "2026-08-28T21:51:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/f9d469e97c4554372a710650a109004a2434dfc56f577142e5d6057fa0cc/coverage-7.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e701938ec9081d3e400a0c9a9a8ae0f7ca44214741daeac4454b1c6ef6dbd19", size = 255054, upload-time = "2026-08-28T21:51:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/95/29/dd89fd39af1a3b6e9a9c3eddeaf03f6376ba517d43d6cbf8b519177e2a10/coverage-7.16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:719a3feb6220dd32ed932d4c3676d17fb8739e2643b29c0e7c3af400ff80ac44", size = 257790, upload-time = "2026-08-28T21:51:33.374Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/208d26cedc525d6b5db9c492cf9130784c42d9eb08d22badaa7b806005ad/coverage-7.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87771ecf986cff55e87413238cd5e4f54d949c2074bd6fc1657d26a56314ee24", size = 258904, upload-time = "2026-08-28T21:51:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/98/28e2752aa9a8baee5798edade9c95602ca200f4e7eeb503eb64df42e5921/coverage-7.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47d5e1fc0b321c8308a2aacee0497c435b08acaa629b7059798fdf6fc3006352", size = 261165, upload-time = "2026-08-28T21:51:36.744Z" }, + { url = "https://files.pythonhosted.org/packages/eb/77/fa6ae699a0ea2bc12acb38a85d96b786fea0f833c12b5756056350e0e547/coverage-7.16.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06", size = 255416, upload-time = "2026-08-28T21:51:38.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/c8/5ee46d1de7d34cb00ba08b5c50da1971114dbc09ca9898ccc32975ec74dd/coverage-7.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:32c56b5b47c50635081445ac404dd08c2d591b9c837c22570aa9e182c3b42cd4", size = 256825, upload-time = "2026-08-28T21:51:40.27Z" }, + { url = "https://files.pythonhosted.org/packages/15/f6/d59e1c0693ad48855fe20169fbf6ee5befefe5887a7fabf5f0bcb464a2dc/coverage-7.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ad3bbad240ab937512156bc944fdee63ac4dd34a7558a3094548fd4c1150c02", size = 254970, upload-time = "2026-08-28T21:51:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/df/7b/b51bbe05b3a7565927fccfb1be42b8b3c1f4ab15e53d91b303e9923969aa/coverage-7.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c1f16d5555a195295d0dc9c902612270e3dfed6a11f3bf7bc470b7b6a79ed3c", size = 259039, upload-time = "2026-08-28T21:51:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/d513f816456a8a43c1859abe88a37d01d7d2515b6c3e24ebb3c9b1dd44ec/coverage-7.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f6c9c21a8bf0d19788f3c5f3e020c90317a0a63ef60521b376003801e21250fb", size = 254539, upload-time = "2026-08-28T21:51:46.733Z" }, + { url = "https://files.pythonhosted.org/packages/dc/54/5542190ceb97e0d1333a4ce0c8f95b2ef2efe790f1ad018a4b61766f849e/coverage-7.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f20145a9eb5bf1fd1dde3c0bc2af2e7c22135ab07ca6284d6ada7cc3904c4e", size = 256410, upload-time = "2026-08-28T21:51:48.363Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/78643f361ff6bb5b2ade90f8bfc8395fe9ca367a18c101f8991215b4c65b/coverage-7.16.0-cp312-cp312-win32.whl", hash = "sha256:916cf8d25c1ce148f7eceb1d45afc9724841200110adc4e53250391852debd91", size = 225239, upload-time = "2026-08-28T21:51:50.22Z" }, + { url = "https://files.pythonhosted.org/packages/67/61/8e76b36c36b1a033dc933dd2480db96b04ce3be975793ce3fad122e7174d/coverage-7.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:78f8b56261d608be102c62edd3a60b66bcd0b581f3f86fdcabaf8b8d95adc950", size = 225775, upload-time = "2026-08-28T21:51:51.912Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f3/bb4787a4b81c1792ca69b502f5f730dbbb609f73fed552ab074c6b92cb8b/coverage-7.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:577c2ac8c0036f6f8edd3a7783a9e67302b17771d1abf0fd2ed246e3158be51b", size = 225159, upload-time = "2026-08-28T21:51:53.667Z" }, + { url = "https://files.pythonhosted.org/packages/54/c5/e62c87f4799d1e3647d5b2ae16ea1d12205d72fde1ea8529e13fe050f678/coverage-7.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1545c52ce756b8a97007f439a220297f1cd72a2cbbcdffccdf1c1f70e74f9a42", size = 223215, upload-time = "2026-08-28T21:51:55.628Z" }, + { url = "https://files.pythonhosted.org/packages/89/e9/5e62fda9397175fb206f75368b6e85da06d831c181b6d0f67ca073cd2f89/coverage-7.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0598aadae641f30a0796b75b45c0b9c5de8619bd5cfb251bb0cc254e86e6dd13", size = 223585, upload-time = "2026-08-28T21:51:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/b9/40/bede08621b1ba67e88c4d3336c22b52cb7911ff1fa4ef055344b6670e58a/coverage-7.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4080ad6bad9f14690e6b2104f5e8d137ccc65a4b5427a36662090637d4bd16d5", size = 254575, upload-time = "2026-08-28T21:51:59.233Z" }, + { url = "https://files.pythonhosted.org/packages/12/d8/ab0bdaa45dfd6b8cbf1a3ec548fdf827684b1997f9724375c5b3e89144fb/coverage-7.16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9883a2f8206ce3af59117dc278e5d043fea06912bca3f199816129e5e2de354", size = 257172, upload-time = "2026-08-28T21:52:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bb/135de81784bbd7dfedcab2b92b03d71d75b09b0815b42d6dabb052def5a6/coverage-7.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984e5430fc6f858385009e92549955157d79335b1f3e13e1031e0f89d1284261", size = 258410, upload-time = "2026-08-28T21:52:02.76Z" }, + { url = "https://files.pythonhosted.org/packages/ad/72/ce44ecc062fb2e43d9447bb76154d091c2139232f20c125297c4b58f4c6a/coverage-7.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b1374099dd1ad0d31fbb6c95d00a56a3c5e85fb3343dca14fc12f78323a2b42a", size = 260539, upload-time = "2026-08-28T21:52:04.821Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/9389c36a41e59406ca2bba493807c2294d2e5186a7e9ebcc2e63a0f2a711/coverage-7.16.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d8686bce035c8465b318a8c2890e69ba14a00801a27f4eb6bdc97c23944d87", size = 254756, upload-time = "2026-08-28T21:52:06.68Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0f/7762447b15e01fb84263608540123c4d9941f06303265ee74d801ccbec0e/coverage-7.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:857fceba6ff4b507ee0ad98798a33d544a8473df0c542bf04251ee4ed5ee6292", size = 256540, upload-time = "2026-08-28T21:52:08.529Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fa/c60dc75a8346c1dbebebc7279b19971c88f70dd575f0bc10bc0cb16f92d5/coverage-7.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bbf08d951abaa1ce89e28c998361d56b952413846b459cd017f116ad4c9adbfa", size = 254508, upload-time = "2026-08-28T21:52:10.323Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/4e0834f3a1fccaa8bf625a2a1d73bde0fa32577dc3249853c0dd0e7f2b20/coverage-7.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1a03e78f53e4d2ab13adac19958a89322d1829913e5623d642627bf60b35da21", size = 258659, upload-time = "2026-08-28T21:52:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ec/fe712d3a11fd6e874565a5fa5497c48b8ece561d9611da040b44cdcf8386/coverage-7.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dcd3dafcdd78305d27c59a1006b53a4990acb89e68d8fbe0992f4f83503c827f", size = 254326, upload-time = "2026-08-28T21:52:14.181Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/093e12072e01034c65ff380f76c74b79dd83e44fa92b689a2154389be734/coverage-7.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c1bcfe470a796fbea6234accd81d258a31574dc0b7bf569e16be757572c4de17", size = 256102, upload-time = "2026-08-28T21:52:16.003Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c0/265176117ca5d06e3f65575842884cdda96cf213350a31e9d41c80d65854/coverage-7.16.0-cp313-cp313-win32.whl", hash = "sha256:1420370276f1694b663207b8245c3628aafb9624fe3cebf313a13d860e55ee67", size = 225250, upload-time = "2026-08-28T21:52:17.82Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/8a87f2c04fde322430b45d16d8f543693e9894c5b2d2ca238a287c00beca/coverage-7.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:496277c8d7beed695e02c7be53516a0152e4caef8738a0feab6a638546cce449", size = 225790, upload-time = "2026-08-28T21:52:19.641Z" }, + { url = "https://files.pythonhosted.org/packages/23/40/c21feacd9edfe7063195bf9cc84d650e9938fc6a23063e4f027199b160e1/coverage-7.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:181c2906b9b3759955c1c33c51fbb91c754fbd0b82ea49e2c81061f5a052082c", size = 225180, upload-time = "2026-08-28T21:52:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/850675f262391b322c4c988b6cdc32cdc6629288f0fb158687b587a393a8/coverage-7.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:54b7fba6a74d010de34319a0419d5b65af8c00f539ad0b6f39fc6f342ab99697", size = 223258, upload-time = "2026-08-28T21:52:23.558Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/4f54c6d47c80d1cc58ef8fe6b74e6eb50f9e2c0f6e2de6cf38dbca2937b8/coverage-7.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fa4ff0b3dd52208d2b30903022d5087f82000507b504753dfeee83e4f32d6883", size = 223587, upload-time = "2026-08-28T21:52:25.627Z" }, + { url = "https://files.pythonhosted.org/packages/3c/be/298f2456230fb44e272a4e53a41b3f3c39f0821c242d7b7daa9787b4d6f7/coverage-7.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:35a9676bf86097f790113ebd9fb67681804ef54d40941d2f10ba68c02239e575", size = 254632, upload-time = "2026-08-28T21:52:27.689Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9c/a1bda6439c19c4783d50df896142b67b9e7d432db36675d339a32778669d/coverage-7.16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f98d438add63546745e5e847192e3e9ab897ed6f2ca96f8281e2f5a15958ae62", size = 257139, upload-time = "2026-08-28T21:52:29.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cd/cd735c9be757f97237c305f36897a5e5b348bdbc12ebed3b2b80060dd8a9/coverage-7.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:151855767480be14db595cbc2040f6a4db965cdfeebd354d79b0256742b029e0", size = 258484, upload-time = "2026-08-28T21:52:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/84b2e1e8aae9db3f549782f28ce25bba5fd6a9c7bfba3782ffe8b4cd2559/coverage-7.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:183613f664718b340589d7f005c7e92b4b601cffd20a8a4117cfda3e983b080f", size = 260798, upload-time = "2026-08-28T21:52:33.642Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/e04cf52483619a4dc5dd6367b30c9a8ac52243567fdfacec9b11a441565c/coverage-7.16.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:785b114356c99c0dd5b3f57b9696cfd57b7704f4c53847df8dc88c6cc0d9bcb6", size = 254612, upload-time = "2026-08-28T21:52:35.543Z" }, + { url = "https://files.pythonhosted.org/packages/da/33/627c4113f66bfffd43807f54dbf080c4632ecf12e4ef7a3bdd4ec38e46a2/coverage-7.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:30f5aee6d1d517abcdfd4f9cad027969ff79a1440a22da263f9514e31b5b66e9", size = 256495, upload-time = "2026-08-28T21:52:37.485Z" }, + { url = "https://files.pythonhosted.org/packages/3c/38/aaca432f4e008a88f2bc4d1459aa7016d8d1bbbe801f7e4fa3cf2746557b/coverage-7.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:190ffa0f5af966254c249fb3aeaca2cef389785e3e287fd577d39e134d20f8a3", size = 254454, upload-time = "2026-08-28T21:52:39.425Z" }, + { url = "https://files.pythonhosted.org/packages/cc/db/8430aa87ef0a508f4c17c1b8fa7e0cf80231988d9081aa36c194036592d6/coverage-7.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ccc37c00e1a5d30840902c54557e104d04aead872cedf6d2281c8725a467e06", size = 258728, upload-time = "2026-08-28T21:52:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/76/88/cd8aa8c82493ffbd291d3ef5554452fffc634c6c6098a04ac848c79c98f3/coverage-7.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6c60cde430c0e7e3be612973af39b4cff90ec2e2defe7b2b701daea3a0ffff04", size = 254271, upload-time = "2026-08-28T21:52:43.278Z" }, + { url = "https://files.pythonhosted.org/packages/a8/49/fe16c811ea9314a84b48f34e4bf5a3d9013091093b285a74b2272fc863d7/coverage-7.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c5297028c8df849a61b29129cadfe682f90b5b396f528eb319a57d7678eefdad", size = 255927, upload-time = "2026-08-28T21:52:45.461Z" }, + { url = "https://files.pythonhosted.org/packages/d1/45/d0bd410e78cfbf768acc8099b335e1d5c0d5c26103c796d2bebdee001715/coverage-7.16.0-cp314-cp314-win32.whl", hash = "sha256:136988df5bc5a48795d9c42c75c4bbda5d9a78e750a080c1233010edff93a1af", size = 225424, upload-time = "2026-08-28T21:52:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/17/78/1ce6ce4646822e9308dcdb1942eaf31bfd7da43247b8886338b0d6fe3767/coverage-7.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:ce2ba5e9f1842fe09165825abfb3bc6b527c71a27bc2eb3a10f2284ced64506d", size = 225918, upload-time = "2026-08-28T21:52:49.692Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cd/e1323fe3a7dfcdd709451a43fe708ca1dfd36a7fc07b34eb7bd1dfdfb52d/coverage-7.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a89d07e48d9baead9a15599923a02f62c6df6c3d85aa84ef34be3c9fd6aeb91f", size = 225344, upload-time = "2026-08-28T21:52:51.665Z" }, + { url = "https://files.pythonhosted.org/packages/39/fb/1c15460d4cf915f09ae3ad3862fef4f901838991c5641b0cec545050d810/coverage-7.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6e2854b62601c89a63814ad5def3b90d99c6724cc4cb977f75b725e5fca4b1e3", size = 223986, upload-time = "2026-08-28T21:52:53.572Z" }, + { url = "https://files.pythonhosted.org/packages/9f/73/347d2d0009ac211f79ee2a2364fd2aa19d6b9628dc22ed13a9b9386097ab/coverage-7.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f093faf23df888518d273be6da65f0ec5a25b5d8b670231e4d87de07361042e7", size = 224254, upload-time = "2026-08-28T21:52:55.59Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2f/51442e6ad9d705369596f08496021647e276d5b57311818fd4312d93509b/coverage-7.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7dbbbf6551eb94618e7bc76ab61cc2740a5b3d13294171bd6adb36e12346c3c", size = 265619, upload-time = "2026-08-28T21:52:57.645Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8e/0f752276f6d13efbd019ab6d90792e20d6272c44cda039dc5c6d27b91e7f/coverage-7.16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51e7d0e311d2fba3915f971236cbdd4ad821fc7a23988221c0b33c964b0eba22", size = 267734, upload-time = "2026-08-28T21:52:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/02/4df3baef8029881c9d1a380859f2be73f90080d430def567d182e8566a35/coverage-7.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bb04ee77e557d7476471969d35fbbfb5fc8a4152e9409aa5811780c36d9b23e", size = 270156, upload-time = "2026-08-28T21:53:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/9f/30/ce10fdb74055ebbfb5c8a025d8845dc19c76e4b2c42bb5c755b56678990c/coverage-7.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c72c9b201dc0e8c2c8821d49858fd865010d08181bf877d2320971b6464ebfd5", size = 271279, upload-time = "2026-08-28T21:53:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/71/19/c7e1fc9504d90da848493bad4018dd235c713a80633e48c5f0a41b63d45e/coverage-7.16.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0fca700cae4635656668ba6e2b66a85aac9f2622d7b2bcf82e844c409eaa1313", size = 264677, upload-time = "2026-08-28T21:53:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f3/4021519dd41583ab396c81955387f927779641f6bac26818b6918a45aafc/coverage-7.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:584896fb8b650e999e24ef57e9513e482c12f8e15a73ee9d4584e23c99465867", size = 267610, upload-time = "2026-08-28T21:53:07.763Z" }, + { url = "https://files.pythonhosted.org/packages/55/fc/df65aac93938d8f506434c8e96440c1d696f6be0a6a01d3c6bfe5d49403e/coverage-7.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:949eae7e0f562b1518355aaef4b03523e49a6d3fea12aa3542d9e36c863f8267", size = 265217, upload-time = "2026-08-28T21:53:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/32/2d/dc9a5e62715165fcb4c715f965f411e324917c9daeddde16536e9d36ce3f/coverage-7.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:64f0611ee05364fc85cc3e5bc371804117a76fd337720e6017332fc7c534257a", size = 268948, upload-time = "2026-08-28T21:53:11.866Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/fe73a5560f25fca52acda76fc1554f30de081793ae4de97e920f8ab161d7/coverage-7.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:050a291b3cfe5e0df5999ef2fa5a7aff6e2db329f069d47eb63f02bde2e7e96b", size = 264061, upload-time = "2026-08-28T21:53:13.996Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f7/bb78cc4b97085ebbd77fa18cbc25abfab462814efa3e2363b4e50885c775/coverage-7.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a336b1e2990a64f5c356a9b8380fb9c029d56c832b801255250c44d603271bfd", size = 266371, upload-time = "2026-08-28T21:53:16.233Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ec/84b4af5cd4ad498477b3bfb2217e47b048da919451053790efda66f7383c/coverage-7.16.0-cp314-cp314t-win32.whl", hash = "sha256:058631257350b31784ed43ceb808298b6f074edf4ebca4c7ce5082e6bf873a61", size = 225736, upload-time = "2026-08-28T21:53:18.632Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/50fc0e6c675c3ef14895a74bab2d6120cb5d6f4b562a3d3f5046797758dc/coverage-7.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ed35097438dfa980c1ec75bc83edf8acbe7a374d7007e571957a257fbd0e2fb3", size = 226570, upload-time = "2026-08-28T21:53:20.754Z" }, + { url = "https://files.pythonhosted.org/packages/fc/24/9effce7bcd3c6eeb4da3561905837509e582dcdde7a7f07d6ef2c8512f76/coverage-7.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0466f4a5c0370461b7d8c7eb259d7d1db0b5756f13d66230b04d22a1d380ee11", size = 225879, upload-time = "2026-08-28T21:53:22.747Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/318e4379106bc8047ba235e3732ddc87d1b393ac3db9776f5405ff14f322/coverage-7.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:80d7d5d744a041f08637df743ac086204ec5acbcd8432a42b00b49e607358024", size = 223257, upload-time = "2026-08-28T21:53:25.376Z" }, + { url = "https://files.pythonhosted.org/packages/81/4d/a5c54d9144e9db6505749758ba50a28be624148873751728a59cbb72d27a/coverage-7.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c5feffce90c3d602e149de1c477578efc34dee5f069f9764cc15808ce01ee15c", size = 223596, upload-time = "2026-08-28T21:53:27.461Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/38e93a10899c9315964c0a4e729b3e5867f8f46e977808f9c6fbda52525a/coverage-7.16.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:acadbf2f2a18d7f9c7f119ac798c00c540d7c79c93abd71ed648c87891303633", size = 254699, upload-time = "2026-08-28T21:53:29.715Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/acddda030b4630f68167f3daa94b41d22071847822a70d8178d43dcf678e/coverage-7.16.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4212cec9b42fd9929e70b462732fefd8b13406371871c82f3c14397499d6550b", size = 257614, upload-time = "2026-08-28T21:53:31.948Z" }, + { url = "https://files.pythonhosted.org/packages/15/7e/225b182497c1ce6d3f0d76a3074a4dbc9f272300e92bb100df53b03de0aa/coverage-7.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c5a43cc0ef101637ae920a9eed24cf0549ef815621eae68b3ad577ec5a7ad2f", size = 259236, upload-time = "2026-08-28T21:53:34.291Z" }, + { url = "https://files.pythonhosted.org/packages/2e/19/76641ddc50cb2410ebbd0ed7fe1052614d0e5612e802a2817521adb9febb/coverage-7.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c76a9b50a344261fe4a9bd20c322b48d3913cc48e8c37f78c21a596008296e68", size = 261433, upload-time = "2026-08-28T21:53:36.401Z" }, + { url = "https://files.pythonhosted.org/packages/12/9e/5f89de8b7c2017f36b68b4e4a25940723a748b21474820bf61e8bce0891c/coverage-7.16.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80cf547379ad6b1878fd03b033b51188beab4b41824c96e7839e014a4cb947be", size = 255182, upload-time = "2026-08-28T21:53:38.496Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c1/ce94b2ec502e79775efb5efa22c741ebb0bd2be10bdd29650825ff57bdcb/coverage-7.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4b1d09cb5d8dc2c7164450f5217e6f0717497de9c588806a0780d352abef904a", size = 257329, upload-time = "2026-08-28T21:53:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/86/8d/3f5374df3a6ca19ee5f98a6bd21dbb05f1e9d399bd9978e9821d260eab5e/coverage-7.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:cd1e85abed2d2499c16664137ac802356316f92b4e2bf3c150bdf0c45f5dd9ae", size = 255210, upload-time = "2026-08-28T21:53:43.393Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b8/1bc5751496d0be6fd9dde8ca547d9a8a9f07847856aba3f3ae5ac594cd81/coverage-7.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:360967a6fd77794c167529eec2d16ff8e38216110619d23acc3fd466a1648bee", size = 259442, upload-time = "2026-08-28T21:53:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/dc/8aca78e47e1e6fcc761cd28a20daf4a84bd847a7369e2701a93ccfc3d1fd/coverage-7.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:92cbc2bf4f7f67c79f1d3ca4fe8c50faddf48e852a3d07eaaf02dc014889832f", size = 254618, upload-time = "2026-08-28T21:53:48.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/fd/787842cdf6ce16ac5c1bd8a26549bab3b3f27b02500075bc540dc7853bca/coverage-7.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cce4dc8528453128c6fae523b15f3887fbea1d4d7c9eb9639d3d4fdcbe570c73", size = 256541, upload-time = "2026-08-28T21:53:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/8df302cbef373dd1f3401044cdb94dfc74517e5af2af27b4d0e721557e0e/coverage-7.16.0-cp315-cp315-win32.whl", hash = "sha256:5205baea687133613dced668a3d0168ea1479349615bfc255849a7944988c889", size = 225429, upload-time = "2026-08-28T21:53:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/85/87/5bad7ac45f76b3728ca211028ee561c2ede3ba44da401129e28bb8737291/coverage-7.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:4fcb5f07a9b7083bfb715115d27ce263ba2b5b89dddeee536b295ba0e3c2c627", size = 225903, upload-time = "2026-08-28T21:53:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ea/67d84b11caf240f059ec313f616d82212df5004e8bc85802c1edfc50bb3d/coverage-7.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:d568a8adcec0eda42ec23e5e65dfb8c184fc255120f9e99b484f7c869d923fb9", size = 225334, upload-time = "2026-08-28T21:53:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/65/21/a88349cce3ff720729b754916ac47e2e3646a8137552e4fa7cdd5967cc7f/coverage-7.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3e8037e8213adf882e9d7eedd2c5c557933ab0b9632c42d98fe98ec9bcdb4025", size = 223980, upload-time = "2026-08-28T21:54:00.082Z" }, + { url = "https://files.pythonhosted.org/packages/fd/02/4d54abf3e6a4d8b7675921b20e91163b1064a5a9dbefebb71c05065dd136/coverage-7.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:289f2ed4d56eebf029b649e7dfc3c1153b111962a75e294cdd8e4a1598a04cc3", size = 224276, upload-time = "2026-08-28T21:54:02.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/10dbc96d95d20b9b041045d293480bd49e536180e93af62dd7662376284d/coverage-7.16.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b83f6ac575530783771c8dcf05284f7c8b5b12f1e7cb226d63445aac4497a3a", size = 265135, upload-time = "2026-08-28T21:54:04.558Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/6b326544afd1a8aef3a495bbae109a7ab5baf23e04a2741d8d64e2df2ba2/coverage-7.16.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c3ff6580f2dfc5bec34717b85b2e6cf5ec993b721e7bb58a794babd525a8178", size = 268216, upload-time = "2026-08-28T21:54:06.97Z" }, + { url = "https://files.pythonhosted.org/packages/54/34/1dc8265f3ed990690e24d5f31ff79bc9fb9b25d54f9f89bebad5a6a8b7a1/coverage-7.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507596cee23e9968b1934fe86d799b76166541af0a293930918b1b48a5c84bd2", size = 270772, upload-time = "2026-08-28T21:54:09.234Z" }, + { url = "https://files.pythonhosted.org/packages/66/a7/3a8463713a402b44044ec832f4a76e442ce4b3a207804303f4d1dc1a9bb4/coverage-7.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edc2be98e6c55ccc5ff7832bb64f023a4b03dba39dfa84b850046cf08a8249b0", size = 271752, upload-time = "2026-08-28T21:54:11.701Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/dd5e795cfbe1842f69899189089ae289a96d6a68de312960ea668542e33c/coverage-7.16.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c0690994b84a15a53bdd39e0b2fdb539b22533820623eb86ba75b93760c645b", size = 265589, upload-time = "2026-08-28T21:54:14.12Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b6/fd90636cbd95cb018312f6ca1ca2bbd70fbe8e4ee6f3992fc36a4230364e/coverage-7.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:de24c62bf798940a14674a47489a81b79915ec4134f556d5199830e065225dd0", size = 268596, upload-time = "2026-08-28T21:54:16.303Z" }, + { url = "https://files.pythonhosted.org/packages/91/10/ef2d59264f3b3b358cc5885ca375e6cdbda7c195e78304d5aae800a72d9d/coverage-7.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:69474d81f198774c9d2937599ca5da04c9e1c5de5032da23c607ce4960ce360e", size = 265072, upload-time = "2026-08-28T21:54:18.597Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5b/400891c364c0170408d172501b340b18611800f4c42d8fbb16f9f5497c24/coverage-7.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:72a0795cc6d34acc2b03dfeabdc82b61b72087f2737018b56ac92c1cf5446c54", size = 269768, upload-time = "2026-08-28T21:54:20.985Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/9792c80271df04d287d21ed5d662fd8fa58b1737888d817679b1ce5d2fab/coverage-7.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d9a218d3f9c7d6916684ed5ba94f620661117a730e733cd6ef5e87accc5872eb", size = 265211, upload-time = "2026-08-28T21:54:23.344Z" }, + { url = "https://files.pythonhosted.org/packages/81/67/5b8f827cfa6616e6bd7ba9397acfe7e3c4fd5b9fca4125511d5089f55d5a/coverage-7.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:49fa72ead28c8216f8916398a4f3c4669acb30a061822810ee20a727a1be2897", size = 267170, upload-time = "2026-08-28T21:54:25.85Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ee/c135d2d2cb617d744bc3e13c922f2fae66964494176ddef225dc4656bd2c/coverage-7.16.0-cp315-cp315t-win32.whl", hash = "sha256:27461af9f3ed7d2cf2411eb083784f87055ebf42211789ae3a216c48609bc743", size = 225731, upload-time = "2026-08-28T21:54:28.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4d/dc3d53eadf155916e183bf5dfacbfc4aa5bfb7f13b7da11c01caa7a05cbc/coverage-7.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c5612cc20ca76abc883e50269af47c1494b42958bb63dbb9aa79729a1ab5f7d3", size = 226562, upload-time = "2026-08-28T21:54:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/2f/00/ac9da1a60a4e84c3ad0f7db4723fd327154a8f9add210c0dcd2db3ec5156/coverage-7.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2ddaa9e2af4760a329d80008b7a3b4762fbb0dbcb169199360f9a5179c32f2dc", size = 225872, upload-time = "2026-08-28T21:54:32.806Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + [[package]] name = "distlib" version = "0.4.3" @@ -252,7 +369,7 @@ wheels = [ [[package]] name = "ipmininet" -version = "1.1" +version = "1.2.6" source = { editable = "." } dependencies = [ { name = "mako" }, @@ -262,7 +379,9 @@ dependencies = [ [package.optional-dependencies] testing = [ + { name = "coverage" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, ] @@ -271,6 +390,7 @@ testing = [ dev = [ { name = "mdformat" }, { name = "pre-commit" }, + { name = "pylint" }, { name = "ruff" }, ] docs = [ @@ -282,9 +402,11 @@ docs = [ [package.metadata] requires-dist = [ + { name = "coverage", marker = "extra == 'testing'" }, { name = "mako", specifier = ">=1.1,<1.5" }, { name = "mininet", git = "https://github.com/mimi-net/mininet.git" }, { name = "pytest", marker = "extra == 'testing'" }, + { name = "pytest-cov", marker = "extra == 'testing'" }, { name = "pytest-timeout", marker = "extra == 'testing'" }, { name = "pytest-xdist", marker = "extra == 'testing'" }, { name = "setuptools" }, @@ -295,6 +417,7 @@ provides-extras = ["testing"] dev = [ { name = "mdformat" }, { name = "pre-commit" }, + { name = "pylint" }, { name = "ruff" }, ] docs = [ @@ -304,6 +427,45 @@ docs = [ { name = "sphinxcontrib-apidoc" }, ] +[[package]] +name = "isort" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/43/067e17bfa10b6486b408d5294105ac894149a9abb94b338568b1f53a73c9/isort-9.0.1.tar.gz", hash = "sha256:ba23db109e3e93ef1999f7209a651214994cd807801addd16ac485982eb4edd7", size = 667724, upload-time = "2026-08-27T20:54:26.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/bd/7221ab6fa6912487953334a9953dcc11816f8568e8d1fec07cda0bc01d87/isort-9.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c3ce022ccedf63aa5fc77bd0e926b8561a1476c9709d7cedf63abd7967772aac", size = 1045272, upload-time = "2026-08-27T20:53:42.378Z" }, + { url = "https://files.pythonhosted.org/packages/65/3e/ef6f9247dd0b7faad0450883808c94b94f22fc254524b5b05b5c6c4f94b2/isort-9.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a75d4c21d8b93345a2743b96cc75c6f085aa89ddbaadd6edd5e9765be12ab77", size = 1659645, upload-time = "2026-08-27T20:53:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/49/16/4f791073e6f91f8e9442ed7eb370886a0f0a52c9dadba540fcd24b4c2074/isort-9.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f6877ed17054eae153d686270678b11c1f6cb79433a1c07453140cccbaf7cc1d", size = 1649411, upload-time = "2026-08-27T20:53:45.597Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/28a06514565129fbb225138045323fb2876aaf871cf77f5976e62e5624bc/isort-9.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:182918b730772292d33564a6ac5b201ca2bb79a8ad2ac77e7681ecc0f19a8f84", size = 871297, upload-time = "2026-08-27T20:53:47.24Z" }, + { url = "https://files.pythonhosted.org/packages/e1/65/aba682b6d0853de85f5bb5c18f0269d2980760783f5144dfcded6b8a1f14/isort-9.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:67680927f739d4b48d67d8b7430faa92c95b02fb6075ca0351c6446214f6c7bb", size = 1021693, upload-time = "2026-08-27T20:53:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bb/7025f3606b42f7db084fa234d61462a73443af166ecf7c1a0e8e3b448433/isort-9.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3727eb33a9759649346481cf2a9287d656a170c31ed7c105856f9c6f5b539756", size = 1648575, upload-time = "2026-08-27T20:53:50.701Z" }, + { url = "https://files.pythonhosted.org/packages/47/78/a92b09507565f8bd27eb72a7df3ba59a1ea0a5baacbf155c587d0be51a13/isort-9.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:466b0c3f156a21c10edefba697e641666bc26ffb0122bf08b42caa3d464c20aa", size = 1639624, upload-time = "2026-08-27T20:53:52.22Z" }, + { url = "https://files.pythonhosted.org/packages/52/ba/f5efd28563bc751792a7a1f64ae73db57dcbfa0563dc3e0cbbd825a48252/isort-9.0.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:5832683294dd61c59d00cd043a68d42f6ecd7dc7d04b73ac777f7f90a534d6ae", size = 457708, upload-time = "2026-08-27T20:53:53.837Z" }, + { url = "https://files.pythonhosted.org/packages/53/d6/a776ea214de5403f038b182e1d5fd2211d0cee41f2c62ac9f2f9a0ecc463/isort-9.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:7281cdf538f682b8d75fa44bcdad1b299036bbc440855f7d61412b3b85d5727d", size = 874790, upload-time = "2026-08-27T20:53:56.154Z" }, + { url = "https://files.pythonhosted.org/packages/db/42/d75a674a7fcbbf4374cd358b8529535d3c59e0ab6acc164b6862acb95058/isort-9.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7ea5f505b152fedd2b990b39d8b76108a48b355da874025aad4982e8ceeb0f3d", size = 1020950, upload-time = "2026-08-27T20:53:57.744Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b6/04d74c7c42b9af1c0d47f34a79722dbbcff71bae3765c9250376ebd44ec5/isort-9.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:873cf1b6371d41e2a74d57d7c0176d311822f0415441abf8251ad074c9fe4a66", size = 1653678, upload-time = "2026-08-27T20:53:59.292Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/621969c0cde272b7a183484589cc9f28132775d78fd708c231fd292c7abd/isort-9.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99b7bc28b1f05f7e3267629043a99c6c479a750df3689327a10324e396827f94", size = 1645589, upload-time = "2026-08-27T20:54:00.911Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/e3db1ae5d5fbe9e2dff0c6a0b2b9dda07a742ede20f8fbc7d78d5d73f472/isort-9.0.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:89ebbcdbdd9d66cc14909bbac36acb9db29f37325606113c9f270242f8a1f896", size = 457029, upload-time = "2026-08-27T20:54:02.551Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f2/b9cf816c8dfd5d9066b65a2f738f8d03fb0f6001bcffa3d436cfdbe1b421/isort-9.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:2057236a764f31c78dac78f7343057621fcc2fd40461ce61061f34fd09066f46", size = 894580, upload-time = "2026-08-27T20:54:03.985Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d7/e5e9e477ff7ec9f75b4c07b25aef10814775200c1e90bc9acbb788def54a/isort-9.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5e72a7063570f1d740f0284c7ae5739dc34c6a2d9f1049b13027a5bdadb56682", size = 1088685, upload-time = "2026-08-27T20:54:05.376Z" }, + { url = "https://files.pythonhosted.org/packages/96/6f/915effa62cdcdc757bd0c567ee4d611c7a73ce7da097c820b3b2f340c2f7/isort-9.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2525606f62742fc4ed9f8ca89043b9522ac3e6f9c9892e6cb16f4870d937f38", size = 1863445, upload-time = "2026-08-27T20:54:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d4/af84d711cda084ddcb7a01a3d6a2608542c16ae7d418d99169b7deb775a4/isort-9.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e3a2697ebcb54b51af4833de44447dbf31ddf081c5f163772092d21c0267483b", size = 1878639, upload-time = "2026-08-27T20:54:08.618Z" }, + { url = "https://files.pythonhosted.org/packages/58/d0/d77ec7d1c648a0b92ea6a8da3591a342550404af98a338106278a872ea1a/isort-9.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd326823ddbe338357ba1823b7f96481d4421d54c83ebd43c92f1b51314a24ae", size = 919565, upload-time = "2026-08-27T20:54:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/9963610d22fa55cbbd3c141a350f7011dd1023be9d3ba06212053c3639c6/isort-9.0.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:5022b332ac91ccb39dc28bb206d5ae96ae7f8d45e710b072cb039b2fcda6602a", size = 1020254, upload-time = "2026-08-27T20:54:11.732Z" }, + { url = "https://files.pythonhosted.org/packages/11/20/9f22f2574d94cc2b86f7c1764a186843ed19e7215666b57b10935335614f/isort-9.0.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:930879e4cfab3264f1d7346abeec10726b5382dc4be9f4251c25ec7fa057926b", size = 1667500, upload-time = "2026-08-27T20:54:13.193Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/5a46b814d7fa49fc778e65a6703e4bb4d60149e01574afd7366b5ac7a1dc/isort-9.0.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:825c05d2d63a1b9c608c352503c10b6411a3c6e12bcacc97b306774ee379786f", size = 1655914, upload-time = "2026-08-27T20:54:14.827Z" }, + { url = "https://files.pythonhosted.org/packages/a7/09/b575e1837f3b182c97f937cf39cb55b3942f44dedfec4303403e4aa9bbd0/isort-9.0.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:cc9814ce2ee42c17007d822455e4db55e32e589808ecfc2665d51c848d0bb30a", size = 457120, upload-time = "2026-08-27T20:54:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/3c73139b71b7031caf54aa098bc589b18374533b5f96f31c541b36112896/isort-9.0.1-cp315-cp315-win_amd64.whl", hash = "sha256:1b8d6c836fb83232f5f4c1c037d332caf743bb24dca63167bad9174ae13e150e", size = 894525, upload-time = "2026-08-27T20:54:17.895Z" }, + { url = "https://files.pythonhosted.org/packages/e5/27/5c17d4c8239a33a876f91e52426cc0d3fc75eb8438f34c40f403998057ac/isort-9.0.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2fb33e0c0f9f87821acf6d82c83f0a0c7e54680fdf3fe4131409d2b95901f00a", size = 1088576, upload-time = "2026-08-27T20:54:19.599Z" }, + { url = "https://files.pythonhosted.org/packages/32/9b/9e80dee125b3d1208fb89afa380cae6fdfac660ad682eb0139a8fd8d790a/isort-9.0.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cdf765657edb2bcccbb1b20d26e710acbcb27379c0a407c6cb376e5619059a7b", size = 1853379, upload-time = "2026-08-27T20:54:21.119Z" }, + { url = "https://files.pythonhosted.org/packages/34/43/f9f456c223ba34293ab28f736f00e26875b26913f24451e5a2938a8c4e02/isort-9.0.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23d3b6657763f9be1b15bb9664b016abfce34849d6215a46a42af7945d4acd68", size = 1871162, upload-time = "2026-08-27T20:54:22.593Z" }, + { url = "https://files.pythonhosted.org/packages/38/5c/17fedc2bee564bb13b50ed3cb2ce7d66dd8e09184fb45d7a263ebaaaa97e/isort-9.0.1-cp315-cp315t-win_amd64.whl", hash = "sha256:8f490acc182253d07071cc8255b57a281855e2e027b929a89eaa7c797f7b213e", size = 918269, upload-time = "2026-08-27T20:54:23.996Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/4ec84f19b008864656b9f158bad006aa6346e754694aff47a7fa9ee65a19/isort-9.0.1-py3-none-any.whl", hash = "sha256:5aac7263b7a7f9f647f94fb6df2761ff5b60a7168eb492ff39dd30443207fa19", size = 103487, upload-time = "2026-08-27T20:54:25.395Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -403,6 +565,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + [[package]] name = "mdformat" version = "1.0.0" @@ -441,6 +612,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -514,6 +694,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pylint" +version = "4.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2e/424cbfcb3af792a6b7da6e5c640de463b98c8d3a49fbd8882f38e83d4232/pylint-4.0.8.tar.gz", hash = "sha256:1c1b2128bde5ff5e966801413080b6384d42a5782718d528c906dbb6beab94ed", size = 1599177, upload-time = "2026-08-29T12:21:59.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/ea/9b601859afa055afe659ffe55abdfed6e5f17e17a840d9cd61c54ac89c02/pylint-4.0.8-py3-none-any.whl", hash = "sha256:3341c08c0aabaa4adc71516de0969f3ba5c692b56c75af4dcb4d242823fbe363", size = 540709, upload-time = "2026-08-29T12:21:57.392Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -530,6 +728,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "pytest-timeout" version = "2.4.0" @@ -816,6 +1028,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From 3c0d746e7587dfa46b7891f29a5ce2b234bebadf Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 14:16:48 +0300 Subject: [PATCH 43/69] test: widen the OSPF reconvergence wait for the link-failure suite test_randomFailure[3] restores all three links at once; under load (and with the coverage-instrumented full suite) OSPF can take longer than the 120s cap to repopulate r1/r2. The poll exits as soon as routes appear, so the larger cap only costs time when convergence is genuinely slow. Signed-off-by: Iakov Kirilenko --- ipmininet/tests/test_linkfailure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ipmininet/tests/test_linkfailure.py b/ipmininet/tests/test_linkfailure.py index 2f7fb295..b1764a4c 100644 --- a/ipmininet/tests/test_linkfailure.py +++ b/ipmininet/tests/test_linkfailure.py @@ -13,7 +13,7 @@ from .utils import assert_connectivity, assert_node_not_connected, assert_routing_table -def _wait_reconvergence(net: IPNet, timeout=120): +def _wait_reconvergence(net: IPNet, timeout=180): """Wait for OSPF to recompute routes after link restoration. The connectivity probe can false-negative while the routing tables are From 94fbc4e1329d0f6df1615c887ecc47242b30d0fa Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 14:16:52 +0300 Subject: [PATCH 44/69] build: bump project version to 1.2.7 Signed-off-by: Iakov Kirilenko --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fc082c2a..657ccce5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ipmininet" -version = "1.2.6" +version = "1.2.7" requires-python = ">=3.12" description = "A mininet extension providing components to emulate IPnetworks running multiple protocols." keywords = [ "networking", "OSPF", "IP", "BGP", "quagga", "mininet",] From de0a5ff9f148b1a56aadfe31b7c291e8d41d15cb Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 14:45:25 +0300 Subject: [PATCH 45/69] test(ci): make the coverage gate tolerant of pre-combined data pytest-cov already merges the per-worker data files into .coverage at the end of the suite, so 'coverage combine' finds nothing to merge and exits non-zero. The gate step now tolerates that and runs under sudo because the suite writes the report files as root. Signed-off-by: Iakov Kirilenko --- .github/workflows/heavy-test.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index f4d1e018..95bbf13e 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -55,10 +55,12 @@ jobs: sudo env "PATH=$PATH" COVERAGE=1 scripts/run-tests-parallel.sh - name: Combine and enforce the coverage gate run: | - uv run coverage combine - uv run coverage report -m - uv run coverage xml -o coverage.xml - uv run coverage html -d htmlcov + sudo env "PATH=$PATH" bash -c ' + uv run coverage combine || true + uv run coverage report -m + uv run coverage xml -o coverage.xml + uv run coverage html -d htmlcov + ' - name: Upload coverage report uses: actions/upload-artifact@v4 with: From 4ee5ef174afba739468e22758024a6d90b48a965 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 15:11:17 +0300 Subject: [PATCH 46/69] test(ci): gate the full-suite branch coverage at the measured baseline The fork CI heavy-test run measured 84% branch coverage of the core library (3746 statements, omitting tests/examples/install); the gate now fails below that level. Signed-off-by: Iakov Kirilenko --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 657ccce5..6c72e4e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ omit = [ [tool.coverage.report] show_missing = true skip_covered = true -fail_under = 0 +fail_under = 84 [tool.ipmininet] duplication_max = 51 From 058d4ea7c31d4959156bc1993cda535c394a3b70 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Mon, 31 Aug 2026 15:18:42 +0300 Subject: [PATCH 47/69] test(ci): exempt the rootless coverage signal from the full-suite gate pytest-cov enforces the [tool.coverage.report] fail_under on every --cov run, so the rootless job (26% on the small rootless subset) failed the gate meant for the full-suite master run. Disable it there with --cov-fail-under=0; the strict gate stays enforced by heavy-test on master. Signed-off-by: Iakov Kirilenko --- .github/workflows/test.yaml | 1 + docs/contribute.rst | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a589d1f0..ae1f4e51 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -41,6 +41,7 @@ jobs: uv run python -m pytest \ -v \ --cov=ipmininet \ + --cov-fail-under=0 \ --cov-report=html:htmlcov \ --cov-report=xml:coverage.xml \ ipmininet/tests/test_pure.py \ diff --git a/docs/contribute.rst b/docs/contribute.rst index 58baad6d..3f9608db 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -207,7 +207,9 @@ Coverage and code-quality gates are uploaded as workflow artifacts and to Codecov. - Run coverage locally with ``COVERAGE=1 scripts/run-tests-parallel.sh`` (as root), or on the rootless tests directly with - ``uv run pytest --cov=ipmininet ipmininet/tests/test_pure.py``. + ``uv run pytest --cov=ipmininet --cov-fail-under=0 ipmininet/tests/test_pure.py`` + (the ``fail_under`` gate applies to the full-suite master run, not to the + rootless signal). - Code duplication is capped by ``scripts/check-duplicates.sh`` (pylint ``duplicate-code``) against the ``duplication_max`` baseline in ``pyproject.toml``; CI runs it in the ``rootless`` job. From 8421fed40309d99eac22b88aad601a24e3d52174 Mon Sep 17 00:00:00 2001 From: iakov Date: Tue, 1 Sep 2026 23:29:10 +0300 Subject: [PATCH 48/69] build(container): support Ubuntu 26.04; build PCRE1 from source (#32) * fix(install): build PCRE1 from source on Ubuntu 26.04 and newer Ubuntu 26.04 dropped the obsolete PCRE1 packages (libpcre3-dev). libyang v1, which FRRouting 7.5 requires, is still built against PCRE1, so fall back to building PCRE 8.45 from a SHA-256-pinned tarball when apt no longer ships it. Defer TRY003 in install.py, matching the rest of the codebase. * build(container): base the test container on ubuntu:26.04 Drop libpcre3-dev from the apt install list (gone in 26.04; install.py now provisions PCRE1 itself) and refresh the PEP 668 comments. * fix(install): configure libyang on CMake 4 via policy-version minimum Ubuntu 26.04 ships CMake 4.x, which dropped compatibility with projects declaring cmake_minimum_required < 3.5 (libyang v1.0.215 still declares 2.8.12). Pass -DCMAKE_POLICY_VERSION_MINIMUM=3.5, the documented escape hatch; older CMake ignores the variable. Validated locally on ubuntu:26.04: PCRE 8.45 source build + libyang v1.0.215 configure and full build. * fix(install): make FRR clippy find the uv-managed libpython FRR's build-time clippy tool embeds the Python that configured it. In the container that is a uv-managed CPython whose libpython3.12.so.1.0 sits outside the loader's default search path (on 24.04 the system Python 3.12 masked this; on 26.04 the system Python is 3.13). Expose the running interpreter's LIBDIR via LD_LIBRARY_PATH for the FRR configure/make/install. Validated on ubuntu:26.04: a Python-embedding program reproduces the clippy 'cannot open shared object file' error without the path and runs with it. * fix(link): disable iproute2 color so parsers see plain output mininet runs commands on a pty, and iproute2 >= 6.19 (Ubuntu 26.04) colorizes `ip address show` and `ip rule list` there, corrupting the parsed addresses (ipaddress.AddressValueError on the ANSI escapes). Pass ip -color=never in the two places whose output is parsed; a no-op on older iproute2. * fix(install): build PCRE1 with Unicode property support FRR 7.5's bundled YANG (ietf-inet-types) uses \p{...} regexes, which PCRE only compiles in with --enable-unicode-properties (Debian's libpcre3-dev enables it by default, so the system package worked on 24.04). Without it, libyang fails to parse the module and every FRR daemon aborts its config check. Validated on ubuntu:26.04: \p{L} compiles and matches. * fix(host,cli): named zone transfers on BIND 9.20 and iproute2 colors BIND >= 9.20 denies zone transfers unless allow-transfer is explicit, so slaves SERVFAIL their zones (24.04's 9.18 allowed transfers by default). Add allow-transfer to master zones and a directory clause (9.20 requires a writable working directory to load zones). Also pass ip -color=never for the CLI's `ip route get` output, which iproute2 >= 6.19 colorizes on a pty and would otherwise corrupt the asserted output. * fix(tests): parse multi-address ipv6.dst and resolve hostnames via system DNS - sr_path: tshark 4.x reports every IPv6 header address for SRv6 encapsulated packets (SID plus inner destination) as a single comma separated field; switching to a tab separator and keeping the outermost address restores the through-segment matching that routes 4-6 rely on. - host_connected: nmap 7.98's internal resolver ignores /etc/hosts; --system-dns routes hostname targets through the system resolver so the per-node hosts files are honoured again. --- .github/workflows/heavy-test.yaml | 2 +- .github/workflows/test.yaml | 2 +- Containerfile | 8 +- ipmininet/cli.py | 4 +- ipmininet/host/config/templates/named.mako | 5 ++ ipmininet/install/install.py | 86 ++++++++++++++++++++-- ipmininet/link.py | 4 +- ipmininet/srv6.py | 4 +- ipmininet/tests/test_srv6.py | 11 ++- ipmininet/tests/utils.py | 3 +- pyproject.toml | 2 +- scripts/ci-install.sh | 2 +- 12 files changed, 112 insertions(+), 21 deletions(-) diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index 95bbf13e..dc856406 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -41,7 +41,7 @@ jobs: - name: Install dependencies (FRRouting, libyang, mininet) timeout-minutes: 10 run: | - # PEP 668 on Ubuntu 24.04 blocks pip3 system installs; relax it. + # PEP 668 on Ubuntu 24.04 and newer blocks pip3 system installs; relax it. export PIP_BREAK_SYSTEM_PACKAGES=1 sudo env "PATH=$PATH" PIP_BREAK_SYSTEM_PACKAGES=1 uv run python -m ipmininet.install -a - name: Save compiled deps diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ae1f4e51..3e9e0067 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -94,7 +94,7 @@ jobs: - name: Install dependencies (FRRouting, libyang, mininet) timeout-minutes: 10 run: | - # PEP 668 on Ubuntu 24.04 blocks pip3 system installs; relax it. + # PEP 668 on Ubuntu 24.04 and newer blocks pip3 system installs; relax it. export PIP_BREAK_SYSTEM_PACKAGES=1 sudo env "PATH=$PATH" PIP_BREAK_SYSTEM_PACKAGES=1 uv run python -m ipmininet.install -a - name: Save compiled deps diff --git a/Containerfile b/Containerfile index 7c4e9f15..8b65b62e 100644 --- a/Containerfile +++ b/Containerfile @@ -5,9 +5,9 @@ # container-test.yaml) and then runs the full `ipmininet.install -a` so that # FRRouting, libyang, mininet, radvd, sshd and named are available. # -# PEP 668 blocks pip3 system-wide installs on Ubuntu 24.04; the deferred -# install.py refactor will make this explicit in-code, for now we relax it. -FROM ubuntu:24.04 AS final +# PEP 668 blocks pip3 system-wide installs on Ubuntu; the deferred install.py +# refactor will make this explicit in-code, for now we relax it. +FROM ubuntu:26.04 AS final ENV DEBIAN_FRONTEND=noninteractive ENV PIP_BREAK_SYSTEM_PACKAGES=1 @@ -18,7 +18,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ autoconf automake libtool make gcc groff patch bison flex gawk \ texinfo libreadline-dev libc-ares-dev libjson-c-dev \ perl python3-dev libpam0g-dev libsystemd-dev libsnmp-dev pkg-config \ - libcap-dev cmake libpcre3-dev socat psmisc xterm openssh-client iperf3 \ + libcap-dev cmake socat psmisc xterm openssh-client iperf3 \ ethtool help2man net-tools python3-pexpect python3-tk iproute2 \ cgroup-tools autotools-dev libc6-dev tcpdump python3-scapy \ libpcap-dev libbsd-dev libconfig-dev openvswitch-switch radvd bind9 dnsutils \ diff --git a/ipmininet/cli.py b/ipmininet/cli.py index 96c77629..f7a3f7c2 100644 --- a/ipmininet/cli.py +++ b/ipmininet/cli.py @@ -12,7 +12,9 @@ def do_route(self, line: str = ""): for every router in the network""" for r in self.mn.routers: lg.output(f"[{r.name}] ") - self.default(f"{r.name} ip route get {line}") + # -color=never: commands run on a pty and iproute2 >= 6.19 + # colorizes `ip route get` there, corrupting the output. + self.default(f"{r.name} ip -color=never route get {line}") def do_ip(self, line: str): """ip IP1 IP2 ...: return the node associated to the given IP""" diff --git a/ipmininet/host/config/templates/named.mako b/ipmininet/host/config/templates/named.mako index 31e06086..58bc0ae5 100644 --- a/ipmininet/host/config/templates/named.mako +++ b/ipmininet/host/config/templates/named.mako @@ -14,6 +14,8 @@ zone "${zone.name}" { type hint; % elif zone.master: type master; + # BIND >= 9.20 denies zone transfers unless allow-transfer is explicit. + allow-transfer { any; }; % else: type slave; masters { @@ -27,5 +29,8 @@ zone "${zone.name}" { % endfor options { + # BIND >= 9.20 requires its working directory to be writable (it writes + # runtime files there); the daemon otherwise fails to load the zones. + directory "/tmp"; dnssec-validation no; }; diff --git a/ipmininet/install/install.py b/ipmininet/install/install.py index 12b625c1..8f59b50e 100644 --- a/ipmininet/install/install.py +++ b/ipmininet/install/install.py @@ -1,17 +1,27 @@ import argparse +import hashlib import os import re import sys +import sysconfig +import urllib.request # For imports to work during setup and afterwards sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from utils import identify_distribution, sh, supported_distributions +from utils import find_executable, identify_distribution, sh, supported_distributions MininetVersion = "2.3.0" FRRoutingVersion = "7.5" LibyangVersion = "v1.0.215" ExaBGPVersion = "4.2.25" +# PCRE1 (libpcre3) is required to build libyang v1, which FRRouting pins to. +# Ubuntu 26.04 and newer dropped the obsolete PCRE1 packages, so the installer +# builds this release from source there. +PcreVersion = "8.45" +PcreSha256 = "4e6ce03e0336e8b4a3d6c2b70b1c5e18590a5673a98186da90d4f33c23defc09" +PcreUrl = "https://downloads.sourceforge.net/project/pcre/pcre/8.45/pcre-8.45.tar.gz" + # XXX: We need the explicit script until the following issue is fixed: # https://github.com/mininet/mininet/issues/1120 MininetInstallCommit = "c3ba039a9781c6c5f475b7c88ff577185747a1da" @@ -134,15 +144,52 @@ def install_mininet(output_dir: str, pip_install=True): dist.pip_install("mininet/", cwd=output_dir) +def ensure_pcre1(dist, output_dir: str) -> None: + """Provide the PCRE1 library needed to build libyang. + + libyang v1, which FRRouting requires, is built against PCRE1 (libpcre3 on + Debian/Ubuntu). Ubuntu 26.04 and newer dropped the obsolete PCRE1 packages, + so fall back to building PCRE from source when the distro does not ship it. + """ + if dist.NAME == "Fedora": + dist.install("pcre-devel") + return + if dist.NAME not in ("Ubuntu", "Debian") or find_executable("pcre-config"): + return + p = sh("apt-get -y -q install libpcre3-dev", may_fail=True) + if p is not None and p.wait() == 0: + return + if find_executable("pcre-config"): + return + print("IPMininet: libpcre3-dev is unavailable, building PCRE from source") + pcre_archive = os.path.join(output_dir, f"pcre-{PcreVersion}.tar.gz") + pcre_src = os.path.join(output_dir, f"pcre-{PcreVersion}") + if not os.path.exists(pcre_archive): + urllib.request.urlretrieve(PcreUrl, pcre_archive) + with open(pcre_archive, "rb") as f: + digest = hashlib.sha256(f.read()).hexdigest() + if digest != PcreSha256: + raise RuntimeError( + f"PCRE {PcreVersion} download has an unexpected SHA-256 digest: {digest}" + ) + sh(f"rm -rf {pcre_src}", f"tar -xzf {pcre_archive}", cwd=output_dir) + # --enable-unicode-properties: FRR 7.5's bundled YANG (ietf-inet-types) + # regexes use \\p{...}, which is only compiled in with UCP support. + sh( + "./configure --prefix=/usr --enable-utf8 --enable-unicode-properties", + f"make -j{os.cpu_count() or 1}", + "make install", + cwd=pcre_src, + ) + sh("ldconfig") + + def install_libyang(output_dir: str): if not _needs_rebuild("/usr/bin/yanglint"): print("IPMininet: libyang already installed; skipping build") return dist.install("git", "cmake") - if dist.NAME == "Ubuntu" or dist.NAME == "Debian": - dist.install("libpcre3-dev") - elif dist.NAME == "Fedora": - dist.install("pcre-devel") + ensure_pcre1(dist, output_dir) sh( "rm -rf libyang", @@ -151,8 +198,13 @@ def install_libyang(output_dir: str): ) cloned_repo = os.path.join(output_dir, "libyang") sh(f"git checkout {LibyangVersion}", "mkdir build", cwd=cloned_repo) + # CMake 4.x removed compatibility with versions < 3.5, but libyang v1.0.x + # still declares cmake_minimum_required(2.8.12); the policy-version escape + # hatch lets it configure (and is ignored by older CMake). sh( - 'cmake -DENABLE_LYD_PRIV=ON -DCMAKE_INSTALL_PREFIX:PATH=/usr -D CMAKE_BUILD_TYPE:String="Release" ..', + "cmake -DENABLE_LYD_PRIV=ON -DCMAKE_INSTALL_PREFIX:PATH=/usr" + " -DCMAKE_POLICY_VERSION_MINIMUM=3.5" + ' -D CMAKE_BUILD_TYPE:String="Release" ..', "make", "make install", cwd=os.path.join(cloned_repo, "build"), @@ -169,6 +221,21 @@ def link_to_standard_dir(base_dir: str, standard_dir: str): break +def _python_lib_dir() -> str | None: + """Directory holding the shared libpython of the running interpreter. + + FRR's build-time `clippy` tool embeds the Python that configured it. When + that interpreter is a uv-managed CPython (as in the test container), its + libpython lives outside the loader's default search path, so expose it via + LD_LIBRARY_PATH during the build. + """ + libdir = sysconfig.get_config_var("LIBDIR") + if libdir and os.path.isdir(libdir): + return libdir + libdir = os.path.join(sys.base_prefix, "lib") + return libdir if os.path.isdir(libdir) else None + + def install_frrouting(output_dir: str): dist.install( "autoconf", @@ -226,11 +293,18 @@ def install_frrouting(output_dir: str): cwd=output_dir, ) + env = dict(os.environ) + python_libdir = _python_lib_dir() + if python_libdir: + env["LD_LIBRARY_PATH"] = os.pathsep.join( + filter(None, [python_libdir, env.get("LD_LIBRARY_PATH")]) + ) sh( f"./configure '--prefix={frrouting_install}'", "make", "make install", cwd=frrouting_src, + env=env, ) sh(f"rm -r '{frrouting_src}' '{frrouting_tar}'") diff --git a/ipmininet/link.py b/ipmininet/link.py index ae0f1978..75eb4f17 100644 --- a/ipmininet/link.py +++ b/ipmininet/link.py @@ -277,7 +277,9 @@ def up(self, restore=True): def _addresses_of(devname: str, node: Node | None = None): """Return the addresses of a named interface""" - cmdline = ["ip", "address", "show", "dev", devname] + # -color=never: mininet runs commands on a pty, and iproute2 (>= 6.19) + # colorizes `ip address show` there, which would corrupt the parsed output. + cmdline = ["ip", "-color=never", "address", "show", "dev", devname] try: if node is not None: addrstr = node.cmd(*cmdline) diff --git a/ipmininet/srv6.py b/ipmininet/srv6.py index 0952ddaa..e4872db0 100644 --- a/ipmininet/srv6.py +++ b/ipmininet/srv6.py @@ -99,7 +99,9 @@ def __init__( # Find Free table number tables = [] - out = self.node.cmd("ip rule list") + # -color=never: mininet runs commands on a pty, and iproute2 (>= 6.19) + # colorizes `ip rule list` there, which would corrupt the lookup parse. + out = self.node.cmd("ip -color=never rule list") lines = out.split("\n") for line in lines: if "lookup " in line: diff --git a/ipmininet/tests/test_srv6.py b/ipmininet/tests/test_srv6.py index 181638e2..6a60f462 100644 --- a/ipmininet/tests/test_srv6.py +++ b/ipmininet/tests/test_srv6.py @@ -206,17 +206,22 @@ def _pcap_size(node): out = n.cmd( shlex.split( f"tshark -n -r /tmp/{n.name}.pcap -T fields " - "-E separator=, " + "-E separator=/t " "-e icmpv6.type -e ipv6.dst -e frame.time_epoch" ) ) data = out.split("\n")[1:-1] for line in data: - values = line.strip().split(",") + values = line.strip().split("\t") if len(values) < 3: continue icmp_type = values[0] - data_dst = values[1] + # On newer tshark, ipv6.dst lists every IPv6 header of an + # encapsulated packet (e.g. the SRv6 SID and the inner + # destination). Keep the outermost one, which is the + # destination each router actually forwards on, and matches + # the addresses used by the `through` segments of the test. + data_dst = values[1].split(",")[0] data_time = values[-1] if icmp_type == "128": captures.setdefault(n.name, []).append((float(data_time), data_dst)) diff --git a/ipmininet/tests/utils.py b/ipmininet/tests/utils.py index 603d200c..c80ed731 100644 --- a/ipmininet/tests/utils.py +++ b/ipmininet/tests/utils.py @@ -168,7 +168,8 @@ def _check_src(src) -> bool: else: dst_ip = dst cmd = ( - f"nmap{' -6' if v6 else ''} -sn -n --max-retries 0 " + f"nmap{' -6' if v6 else ''} -sn -n --system-dns " + f"--max-retries 0 " f"--max-rtt-timeout {int(timeout * 1000)}ms {dst_ip}" ) out = src.cmd(cmd.split(" ")) diff --git a/pyproject.toml b/pyproject.toml index 6c72e4e5..c6579bdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,7 +112,7 @@ select = [ # E501: lines that cannot be reflowed (ASCII-art diagram, shell command # strings, a literal `ip addr` output fixture). "ipmininet/examples/simple_ospf_network.py" = ["E501"] -"ipmininet/install/install.py" = ["E501"] +"ipmininet/install/install.py" = ["E501", "TRY003"] "ipmininet/tests/test_pure.py" = ["E501"] # Architectural rules deferred for existing files (see NOTES.md): # - PLC0415: intentional circular-import avoidance diff --git a/scripts/ci-install.sh b/scripts/ci-install.sh index 0a5e215d..bed53d0b 100755 --- a/scripts/ci-install.sh +++ b/scripts/ci-install.sh @@ -2,7 +2,7 @@ set -euo pipefail LOGFILE="${LOGFILE:-/workspace/.tmp/install.log}" exec > >(tee "$LOGFILE") 2>&1 -# PEP 668 on Ubuntu 24.04 blocks pip3 system installs; relax it. +# PEP 668 on Ubuntu 24.04 and newer blocks pip3 system installs; relax it. export PIP_BREAK_SYSTEM_PACKAGES="${PIP_BREAK_SYSTEM_PACKAGES:-1}" exec sudo env "PATH=$PATH" PIP_BREAK_SYSTEM_PACKAGES="$PIP_BREAK_SYSTEM_PACKAGES" \ ${UV_PROJECT_ENVIRONMENT:+"UV_PROJECT_ENVIRONMENT=$UV_PROJECT_ENVIRONMENT"} \ From 997618b5b1043eb6d34614631c31e22ab2ba96cb Mon Sep 17 00:00:00 2001 From: iakov Date: Tue, 1 Sep 2026 23:29:48 +0300 Subject: [PATCH 49/69] ci: pin the Codecov upload slug to the current repository (#33) The uploader resolved the repository to the fork (iakov/ipmininet) even on runs against mimi-net, so the README badge (mimi-net/ipmininet) stayed empty. Force the slug to github.repository so the report lands on the right repo. --- .github/workflows/heavy-test.yaml | 1 + .github/workflows/test.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index dc856406..33bb0227 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -76,3 +76,4 @@ jobs: with: files: coverage.xml flags: full + slug: ${{ github.repository }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3e9e0067..e643f2a6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -63,6 +63,7 @@ jobs: with: files: coverage.xml flags: rootless + slug: ${{ github.repository }} test: runs-on: ubuntu-latest timeout-minutes: 200 From 7a04eeb81b53c3a6011ce65af241c05a81c720b9 Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Wed, 2 Sep 2026 00:32:19 +0300 Subject: [PATCH 50/69] refactor(install): handle PEP 668 in-code and dedupe distro deps Set PIP_BREAK_SYSTEM_PACKAGES explicitly in pip_install instead of relying on the environment in CI scripts and the container, and key the distro package selections off a package_family helper (apt/rpm) to drop the repeated NAME comparisons. Also sync uv.lock with the 1.2.7 version bump. --- .github/workflows/heavy-test.yaml | 8 +- .github/workflows/test.yaml | 4 +- Containerfile | 4 - ipmininet/examples/simple_openr_network.py | 39 --- ipmininet/install/__main__.py | 20 +- .../install/build_openr-rc-20190419-11514.sh | 312 ------------------ ipmininet/install/install.py | 48 +-- ipmininet/install/utils.py | 10 + ipmininet/router/config/openr.py | 134 -------- ipmininet/router/config/openrd.py | 245 -------------- ipmininet/router/config/templates/openr.mako | 74 ----- ipmininet/tests/test_openr.py | 102 ------ scripts/ci-install.sh | 4 +- uv.lock | 2 +- 14 files changed, 28 insertions(+), 978 deletions(-) delete mode 100644 ipmininet/examples/simple_openr_network.py delete mode 100755 ipmininet/install/build_openr-rc-20190419-11514.sh delete mode 100644 ipmininet/router/config/openr.py delete mode 100644 ipmininet/router/config/openrd.py delete mode 100644 ipmininet/router/config/templates/openr.mako delete mode 100644 ipmininet/tests/test_openr.py diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index 33bb0227..7a818241 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -1,8 +1,8 @@ # Full test suite, run on master and on demand. # # Performs the full `ipmininet.install -a` and runs every test except the -# known-broken daemons (exabgp, openr), which are skipped in their own test -# modules. Lint/format checks are run in the `rootless` job of test.yaml. +# known-broken exabgp daemon, which is skipped in its own test module. +# Lint/format checks are run in the `rootless` job of test.yaml. name: Heavy test on: workflow_dispatch: @@ -41,9 +41,7 @@ jobs: - name: Install dependencies (FRRouting, libyang, mininet) timeout-minutes: 10 run: | - # PEP 668 on Ubuntu 24.04 and newer blocks pip3 system installs; relax it. - export PIP_BREAK_SYSTEM_PACKAGES=1 - sudo env "PATH=$PATH" PIP_BREAK_SYSTEM_PACKAGES=1 uv run python -m ipmininet.install -a + sudo env "PATH=$PATH" uv run python -m ipmininet.install -a - name: Save compiled deps if: steps.cache-deps.outputs.cache-hit != 'true' run: scripts/ci-save-deps.sh diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e643f2a6..ff38c740 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -95,9 +95,7 @@ jobs: - name: Install dependencies (FRRouting, libyang, mininet) timeout-minutes: 10 run: | - # PEP 668 on Ubuntu 24.04 and newer blocks pip3 system installs; relax it. - export PIP_BREAK_SYSTEM_PACKAGES=1 - sudo env "PATH=$PATH" PIP_BREAK_SYSTEM_PACKAGES=1 uv run python -m ipmininet.install -a + sudo env "PATH=$PATH" uv run python -m ipmininet.install -a - name: Save compiled deps if: steps.cache-deps.outputs.cache-hit != 'true' run: scripts/ci-save-deps.sh diff --git a/Containerfile b/Containerfile index 8b65b62e..e7c41dad 100644 --- a/Containerfile +++ b/Containerfile @@ -4,13 +4,9 @@ # (UV_PROJECT_ENVIRONMENT=/opt/venv, which survives the bind-mount in # container-test.yaml) and then runs the full `ipmininet.install -a` so that # FRRouting, libyang, mininet, radvd, sshd and named are available. -# -# PEP 668 blocks pip3 system-wide installs on Ubuntu; the deferred install.py -# refactor will make this explicit in-code, for now we relax it. FROM ubuntu:26.04 AS final ENV DEBIAN_FRONTEND=noninteractive -ENV PIP_BREAK_SYSTEM_PACKAGES=1 ENV UV_PROJECT_ENVIRONMENT=/opt/venv RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/ipmininet/examples/simple_openr_network.py b/ipmininet/examples/simple_openr_network.py deleted file mode 100644 index 3f9bb400..00000000 --- a/ipmininet/examples/simple_openr_network.py +++ /dev/null @@ -1,39 +0,0 @@ -"""This file contains a simple OpenR topology""" - -from ipmininet.iptopo import IPTopo -from ipmininet.node_description import OpenrRouterDescription -from ipmininet.router import OpenrRouter -from ipmininet.router.config import OpenrRouterConfig - -HOSTS_PER_ROUTER = 2 - - -class SimpleOpenrNet(IPTopo): - """ - +----+ +----+ - | r1 +-----+ r2 | - +--+-+ +----+ - | - | +----+ - +-------+ r3 | - +----+ - """ - - def build(self, *args, **kwargs): - # pylint: disable=unbalanced-tuple-unpacking - r_1, r_2, r_3 = self.addRouters( - "r_1", - "r_2", - "r_3", - cls=OpenrRouter, - routerDescription=OpenrRouterDescription, - config=OpenrRouterConfig, - ) - self.addLinks((r_1, r_2), (r_1, r_3)) - for router in (r_1, r_2, r_3): - for i in range(HOSTS_PER_ROUTER): - self.addLink( - router, self.addHost(f"h{i}{router}"), params2={"v4_width": 5} - ) - - super().build(*args, **kwargs) diff --git a/ipmininet/install/__main__.py b/ipmininet/install/__main__.py index 0fa4de96..71fcf648 100644 --- a/ipmininet/install/__main__.py +++ b/ipmininet/install/__main__.py @@ -6,7 +6,6 @@ install_exabgp, install_frrouting, install_mininet, - install_openr, parse_args, ) @@ -26,7 +25,7 @@ install_exabgp(args.output_dir) if args.all or args.install_radvd: - if dist.NAME == "Ubuntu" or dist.NAME == "Debian": + if dist.package_family == "apt": dist.install("resolvconf") dist.install("radvd") @@ -35,9 +34,9 @@ os.makedirs("/run/sshd", exist_ok=True) if args.all or args.install_named: - if dist.NAME == "Ubuntu" or dist.NAME == "Debian": + if dist.package_family == "apt": dist.install("bind9") - elif dist.NAME == "Fedora": + elif dist.package_family == "rpm": dist.install("bind") # Install IPMininet @@ -55,20 +54,9 @@ # Install test dependencies dist.install("bridge-utils", "traceroute", "nmap", "iperf3") - if dist.NAME == "Fedora": + if dist.package_family == "rpm": dist.install("nc", "bind-utils", "wireshark", "tc", "kernel-modules-extra") else: dist.install("netcat-openbsd", "dnsutils", "tshark") dist.pip_install("pytest") - - # Install OpenR - - if args.install_openr: - if dist.NAME == "Ubuntu": - install_openr(args.output_dir) - else: - print( - "OpenR build currently only available on Ubuntu." - " Skipping installing OpenR." - ) diff --git a/ipmininet/install/build_openr-rc-20190419-11514.sh b/ipmininet/install/build_openr-rc-20190419-11514.sh deleted file mode 100755 index 3823a673..00000000 --- a/ipmininet/install/build_openr-rc-20190419-11514.sh +++ /dev/null @@ -1,312 +0,0 @@ -#!/bin/bash - -set -exo pipefail -### Install packages for Debian-based OS ### - -apt-get update && apt-get install -yq autoconf-archive bison build-essential cmake curl flex git gperf joe libboost-all-dev libcap-dev libdouble-conversion-dev libevent-dev libgflags-dev libgoogle-glog-dev libkrb5-dev libpcre3-dev libpthread-stubs0-dev libnuma-dev libsasl2-dev libsnappy-dev libsqlite3-dev libssl-dev libtool netcat-openbsd pkg-config sudo unzip wget python3-venv python-setuptools python3-setuptools python-pip python3-pip ccache -apt-get install -yq gcc-'5' g++-'5' -update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-'5' 40 --slave /usr/bin/g++ g++ /usr/bin/g++-'5' -update-alternatives --config gcc - -FOLLY_REV="3ceffd7d145be3c85a2aae39f99eb86ea730bdcc" -SODIUM_REV="6bece9c8c45259998f83ce243b1933e76c03f545" -FIZZ_REV="53e9df90e7876ba207deacbf60703bfbee31c442" -GTEST_REV="release-1.8.1" -RSOCKET_REV="8584e390e26c1eccae8da4283b42e93f7d4926f0" -WANGLE_REV="f2f8e1996739df1fc8ab1e003a1bc6472bd5b9bd" -ZSTD_REV="83b51e9f886be7c2a4d477b6e7bc6db831791d8d" -MSTCH_REV="ff459067bd02e80dc399006bb610238223d41c50" -FBTHRIFT_REV="2e3a85eb29e0cc3bad910093656bc2196f9e96ae" -SIGAR_REV="ad47dc3b494e9293d1f087aebb099bdba832de5e" -ZMQ_REV="v4.2.5" -FBZMQ_REV="8fba3b727c8194031351cefee71bd36ba3486645" -RE2_REV="653f9e2a6a17bcdf8dba2b3f8671aa8880efca29" -LIBNL_REV="libnl3_2_25" -OPENR_REV="rc-20190419-11514" - -PY_VERSION="$(python3 --version 2>&1)" -PY_MINOR=$(sed -e 's/\(Python \)\([[:digit:]]\)\.\([[:digit:]]\)\(.*\)/\3/g' <<< "$PY_VERSION") -PY_LIB_PATH="/usr/local/lib/python3.$PY_MINOR/site-packages" -echo "PY_LIB_PATH: $PY_LIB_PATH" - -NPROC=$(nproc 2> /dev/null || echo 1) -export MAKEFLAGS="$MAKEFLAGS -j $NPROC" - -export CCACHE_DIR='/ccache' CC="ccache ${CC:-gcc}" CXX="ccache ${CXX:-g++}" -### Diagnostics ### - -# Builder DebianSystemFBCodeBuilder(google/googletest:cmake_defines={u'BUILD_GTEST': u'ON', u'BUILD_SHARED_LIBS': u'OFF'}, gcc_version=u'5', facebook/zstd:git_hash=ShellQuoted(u'$(git describe --abbrev=0 --tags origin/master)'), hyperic/sigar:autoconf_options={u'CFLAGS': u'-fgnu89-inline'}, openr/build:cmake_defines={u'ADD_ROOT_TESTS': u'OFF'}, prefix=u'/usr/local', projects_dir=u'/usr/local/src', google/googletest:git_hash=u'release-1.8.1', wangle/wangle/build:cmake_defines={u'BUILD_TESTS': u'OFF'}, thom311/libnl:git_hash=u'libnl3_2_25', jedisct1/libsodium:git_hash=u'stable', ccache_dir=u'/ccache', make_parallelism=4, no1msd/mstch:git_hash=ShellQuoted(u'$(git describe --abbrev=0 --tags)'), zeromq/libzmq:git_hash=u'v4.2.5') -hostname -cat /etc/issue || echo no /etc/issue -g++ --version || echo g++ not installed -cmake --version || echo cmake not installed - -### Check out facebook/folly, workdir _build ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'folly' ]]; then - git clone https://github.com/'facebook/folly' -fi -mkdir -p '/usr/local/src'/'folly'/'_build' && cd '/usr/local/src'/'folly'/'_build' -git checkout $FOLLY_REV - -### Build and install facebook/folly ### - -CXXFLAGS="$CXXFLAGS -fPIC" CFLAGS="$CFLAGS -fPIC" cmake -D'BUILD_SHARED_LIBS'='ON' '..' -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Check out jedisct1/libsodium, workdir . ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'libsodium' ]]; then - git clone https://github.com/'jedisct1/libsodium' -fi -mkdir -p '/usr/local/src'/'libsodium'/'.' && cd '/usr/local/src'/'libsodium'/'.' -git checkout $SODIUM_REV - -### Build and install jedisct1/libsodium ### - -./autogen.sh -LDFLAGS="$LDFLAGS" CFLAGS="$CFLAGS" CPPFLAGS="$CPPFLAGS" ./configure -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Check out facebookincubator/fizz, workdir fizz/build ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'fizz' ]]; then - git clone https://github.com/'facebookincubator/fizz' -fi -mkdir -p '/usr/local/src'/'fizz'/'fizz/build' && cd '/usr/local/src'/'fizz'/'fizz/build' -git checkout $FIZZ_REV - -### Build and install fizz/fizz/build ### - -CXXFLAGS="$CXXFLAGS -fPIC" CFLAGS="$CFLAGS -fPIC" cmake -D'BUILD_SHARED_LIBS'='ON' '..' -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Check out google/googletest, workdir build ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'googletest' ]]; then - git clone https://github.com/'google/googletest' -fi -mkdir -p '/usr/local/src'/'googletest'/'build' && cd '/usr/local/src'/'googletest'/'build' -git checkout $GTEST_REV - -### Build and install google/googletest ### - -CXXFLAGS="$CXXFLAGS -fPIC" CFLAGS="$CFLAGS -fPIC" cmake -D'BUILD_GTEST'='ON' -D'BUILD_SHARED_LIBS'='OFF' '..' -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Check out rsocket/rsocket-cpp, workdir rsocket ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'rsocket-cpp' ]]; then - git clone https://github.com/'rsocket/rsocket-cpp' -fi -mkdir -p '/usr/local/src'/'rsocket-cpp'/'rsocket' && cd '/usr/local/src'/'rsocket-cpp'/'rsocket' -git checkout $RSOCKET_REV - -### Build and install rsocket-cpp/rsocket ### - -CXXFLAGS="$CXXFLAGS -fPIC" CFLAGS="$CFLAGS -fPIC" cmake -D'BUILD_SHARED_LIBS'='ON' '..' -make gmock -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Check out facebook/wangle, workdir wangle/build ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'wangle' ]]; then - git clone https://github.com/'facebook/wangle' -fi -mkdir -p '/usr/local/src'/'wangle'/'wangle/build' && cd '/usr/local/src'/'wangle'/'wangle/build' -git checkout $WANGLE_REV - -### Build and install wangle/wangle/build ### - -CXXFLAGS="$CXXFLAGS -fPIC" CFLAGS="$CFLAGS -fPIC" cmake -D'BUILD_SHARED_LIBS'='ON' -D'BUILD_TESTS'='OFF' '..' -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Check out facebook/zstd, workdir . ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'zstd' ]]; then - git clone https://github.com/'facebook/zstd' -fi -mkdir -p '/usr/local/src'/'zstd'/'.' && cd '/usr/local/src'/'zstd'/'.' -git checkout $ZSTD_REV - -### Build and install zstd ### - -make VERBOSE=1 'PREFIX'='/usr/local' -sudo make install VERBOSE=1 'PREFIX'='/usr/local' -sudo ldconfig - -### Check out no1msd/mstch, workdir build ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'mstch' ]]; then - git clone https://github.com/'no1msd/mstch' -fi -mkdir -p '/usr/local/src'/'mstch'/'build' && cd '/usr/local/src'/'mstch'/'build' -git checkout $MSTCH_REV - -### Build and install no1msd/mstch ### - -CXXFLAGS="$CXXFLAGS -fPIC" CFLAGS="$CFLAGS -fPIC" cmake -D'BUILD_SHARED_LIBS'='ON' '..' -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Check out facebook/fbthrift, workdir thrift ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'fbthrift' ]]; then - git clone https://github.com/'facebook/fbthrift' -fi -mkdir -p '/usr/local/src'/'fbthrift'/'thrift' && cd '/usr/local/src'/'fbthrift'/'thrift' -git checkout $FBTHRIFT_REV - -### Build and install fbthrift/thrift ### - -CXXFLAGS="$CXXFLAGS -fPIC" CFLAGS="$CFLAGS -fPIC" cmake -D'BUILD_SHARED_LIBS'='ON' '..' -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Install thrift python modules ### - -mkdir -p '/usr/local/src'/'fbthrift/thrift/lib/py' && cd '/usr/local/src'/'fbthrift/thrift/lib/py' -PYTHONPATH="$PYTHONPATH:$PY_LIB_PATH" sudo python3 setup.py install - -### Check out hyperic/sigar, workdir . ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'sigar' ]]; then - git clone https://github.com/'hyperic/sigar' -fi -mkdir -p '/usr/local/src'/'sigar'/'.' && cd '/usr/local/src'/'sigar'/'.' -git checkout $SIGAR_REV - -### Build and install sigar ### - -./autogen.sh -LDFLAGS="$LDFLAGS" CFLAGS="$CFLAGS" CPPFLAGS="$CPPFLAGS" ./configure 'CFLAGS'='-fgnu89-inline' -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Check out zeromq/libzmq, workdir . ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'libzmq' ]]; then - git clone https://github.com/'zeromq/libzmq' -fi -mkdir -p '/usr/local/src'/'libzmq'/'.' && cd '/usr/local/src'/'libzmq'/'.' -git checkout $ZMQ_REV - -### Build and install zeromq/libzmq ### - -./autogen.sh -LDFLAGS="$LDFLAGS" CFLAGS="$CFLAGS" CPPFLAGS="$CPPFLAGS" ./configure -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Check out facebook/fbzmq, workdir fbzmq/build ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'fbzmq' ]]; then - git clone https://github.com/'facebook/fbzmq' -fi -mkdir -p '/usr/local/src'/'fbzmq'/'fbzmq/build' && cd '/usr/local/src'/'fbzmq'/'fbzmq/build' -git checkout $FBZMQ_REV - -### Build and install fbzmq/fbzmq/build ### - -CXXFLAGS="$CXXFLAGS -fPIC" CFLAGS="$CFLAGS -fPIC" cmake -D'BUILD_SHARED_LIBS'='ON' '..' -PYTHONPATH="$PYTHONPATH:$PY_LIB_PATH" make -sudo make install -sudo ldconfig - -### Install fbzmq python modules ### - -mkdir -p '/usr/local/src'/'fbzmq/fbzmq/py' && cd '/usr/local/src'/'fbzmq/fbzmq/py' -PYTHONPATH="$PYTHONPATH:$PY_LIB_PATH" sudo python3 setup.py install - -### Check out google/re2, workdir build ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'re2' ]]; then - git clone https://github.com/'google/re2' -fi -mkdir -p '/usr/local/src'/'re2'/'build' && cd '/usr/local/src'/'re2'/'build' -git checkout $RE2_REV - -### Build and install google/re2 ### - -CXXFLAGS="$CXXFLAGS -fPIC" CFLAGS="$CFLAGS -fPIC" cmake -D'BUILD_SHARED_LIBS'='ON' '..' -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Check out thom311/libnl, workdir . ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'libnl' ]]; then - git clone https://github.com/'thom311/libnl' -fi -mkdir -p '/usr/local/src'/'libnl'/'.' && cd '/usr/local/src'/'libnl'/'.' -git checkout $LIBNL_REV - -### Build and install thom311/libnl ### - -curl -O https://raw.githubusercontent.com/facebook/openr/$OPENR_REV/build/fix-route-obj-attr-list.patch -git apply 'fix-route-obj-attr-list.patch' -./autogen.sh -LDFLAGS="$LDFLAGS" CFLAGS="$CFLAGS" CPPFLAGS="$CPPFLAGS" ./configure -make VERBOSE=1 -sudo make install VERBOSE=1 -sudo ldconfig - -### Check out facebook/openr, workdir build ### - -mkdir -p '/usr/local/src' && cd '/usr/local/src' -if [[ ! -d '/usr/local/src'/'openr' ]]; then - git clone https://github.com/'facebook/openr' -fi -mkdir -p '/usr/local/src'/'openr'/'build' && cd '/usr/local/src'/'openr'/'build' -git checkout $OPENR_REV - -### Build and install openr/build ### - -CXXFLAGS="$CXXFLAGS -fPIC" CFLAGS="$CFLAGS -fPIC" cmake -D'BUILD_SHARED_LIBS'='ON' -D'ADD_ROOT_TESTS'='OFF' '..' -make Decision-cpp2-obj Dual-cpp2-obj HealthChecker-cpp2-obj KvStore-cpp2-obj LinkMonitor-cpp2-obj -PYTHONPATH="$PYTHONPATH:$PY_LIB_PATH" make -sudo make install -sudo ldconfig - -### Install OpenR python modules ### - -mkdir -p '/usr/local/src'/'openr/openr/py' && cd '/usr/local/src'/'openr/openr/py' -PYTHONPATH="$PYTHONPATH:$PY_LIB_PATH" sudo pip3 install cffi future pathlib 'networkx==2.2' -PYTHONPATH="$PYTHONPATH:$PY_LIB_PATH" sudo python3 setup.py build -PYTHONPATH="$PYTHONPATH:$PY_LIB_PATH" sudo python3 setup.py install - -### Run openr tests ### - -mkdir -p '/usr/local/src'/'openr/build' && cd '/usr/local/src'/'openr/build' -CTEST_OUTPUT_ON_FAILURE=TRUE make test - diff --git a/ipmininet/install/install.py b/ipmininet/install/install.py index 8f59b50e..a8e9486f 100644 --- a/ipmininet/install/install.py +++ b/ipmininet/install/install.py @@ -80,24 +80,15 @@ def parse_args(): "-n", "--install-named", help="Install the Named daemon", action="store_true" ) parser.add_argument("-6", "--enable-ipv6", help="Enable IPv6", action="store_true") - parser.add_argument( - "-f", - "--install-openr", - help="Install OpenR. OpenR is not installed with '-a'" - " option since the build takes quite long. We" - " also experienced that the build requires a" - " substantial amount of memory (~4GB).", - action="store_true", - ) return parser.parse_args() def install_mininet(output_dir: str, pip_install=True): dist.install("git") - if dist.NAME in {"Ubuntu", "Debian"}: + if dist.package_family == "apt": dist.install("openvswitch-switch") - if dist.NAME == "Fedora": + if dist.package_family == "rpm": mininet_opts = "-fnp" dist.install("openvswitch", "openvswitch-devel", "openvswitch-test") sh("systemctl enable openvswitch") @@ -151,10 +142,10 @@ def ensure_pcre1(dist, output_dir: str) -> None: Debian/Ubuntu). Ubuntu 26.04 and newer dropped the obsolete PCRE1 packages, so fall back to building PCRE from source when the distro does not ship it. """ - if dist.NAME == "Fedora": + if dist.package_family == "rpm": dist.install("pcre-devel") return - if dist.NAME not in ("Ubuntu", "Debian") or find_executable("pcre-config"): + if dist.package_family != "apt" or find_executable("pcre-config"): return p = sh("apt-get -y -q install libpcre3-dev", may_fail=True) if p is not None and p.wait() == 0: @@ -245,7 +236,6 @@ def install_frrouting(output_dir: str): "gcc", "groff", "patch", - "make", "bison", "flex", "gawk", @@ -253,7 +243,7 @@ def install_frrouting(output_dir: str): "python3-pytest", ) - if dist.NAME == "Ubuntu" or dist.NAME == "Debian": + if dist.package_family == "apt": dist.install( "libreadline-dev", "libc-ares-dev", @@ -266,7 +256,7 @@ def install_frrouting(output_dir: str): "pkg-config", "libcap-dev", ) - elif dist.NAME == "Fedora": + elif dist.package_family == "rpm": dist.install( "readline-devel", "c-ares-devel", @@ -320,28 +310,6 @@ def install_frrouting(output_dir: str): ) -def install_openr(output_dir: str, may_fail=False): - # It's not possible to get a build script with pinned dependencies from the - # OpenR github repository. The checked-in build script has the dependencies - # pinned manually. Builds and installs OpenR release rc-20190419-11514. - # https://github.com/facebook/openr/releases/tag/rc-20190419-11514 - script_name = "build_openr-rc-20190419-11514.sh" - openr_buildscript = os.path.join( - os.path.dirname(os.path.abspath(__file__)), script_name - ) - # Execute build script - p = sh( - openr_buildscript, - cwd=output_dir, - shell=True, - executable="/bin/bash", - may_fail=may_fail, - ) - # We should end here only if may_fail is True - if p.returncode != 0: - print("WARNING: Ignoring failed OpenR installation.", file=sys.stderr) - - def install_exabgp(output_dir: str, may_fail=False): git_url = "https://github.com/Exa-Networks/exabgp.git" exabgp_src_folder = f"exabgp-{ExaBGPVersion}-src" @@ -375,9 +343,9 @@ def install_exabgp(output_dir: str, may_fail=False): def update_grub(): - if dist.NAME == "Fedora": + if dist.package_family == "rpm": cmd = "grub2-mkconfig --output=/boot/grub2/grub.cfg" - elif dist.NAME == "Ubuntu" or dist.NAME == "Debian": + elif dist.package_family == "apt": cmd = "update-grub" else: return diff --git a/ipmininet/install/utils.py b/ipmininet/install/utils.py index 43f6a455..70b9721e 100644 --- a/ipmininet/install/utils.py +++ b/ipmininet/install/utils.py @@ -51,6 +51,11 @@ class Distribution: PIP_CMD = "pip" SpinPipVersion = "18.1" + @property + def package_family(self) -> str: + """Package-manager family: 'apt' (Debian/Ubuntu) or 'rpm' (Fedora).""" + return "apt" if self.NAME in ("Ubuntu", "Debian") else "rpm" + def __init__(self): self.pip_args = self.check_pip_version(self.PIP_CMD) @@ -83,12 +88,17 @@ def update(self): def pip_install(self, *packages: str, **kwargs): if find_executable(self.PIP_CMD) is not None: + # PEP 668 (externally-managed-environment) blocks system-wide pip + # installs on Debian/Ubuntu 23.04+; the installer targets system + # locations, so relax it explicitly. + env = dict(os.environ, PIP_BREAK_SYSTEM_PACKAGES="1") sh( self.PIP_CMD + " -q install " + self.pip_args + " " + " ".join(packages), + env=env, **kwargs, ) diff --git a/ipmininet/router/config/openr.py b/ipmininet/router/config/openr.py deleted file mode 100644 index 07313add..00000000 --- a/ipmininet/router/config/openr.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Base classes to configure an OpenR daemon""" - -from ipaddress import ip_interface - -from ipmininet.overlay import Overlay -from ipmininet.utils import L3Router, realIntfList - -from .openrd import OpenrDaemon -from .utils import ConfigDict - - -class OpenrDomain(Overlay): - """An overlay to group OpenR links and routers by domain""" - - def __init__(self, domain, routers=(), links=(), **props): - """:param domain: the domain for this overlay - :param routers: the set of routers for which all their interfaces - belong to that area - :param links: individual links belonging to this area""" - super().__init__(nodes=routers, links=links, nprops=props) - self.domain = domain - - @property - def domain(self): - return self.links_properties["openr_domain"] - - @domain.setter - def domain(self, domain): - self.links_properties["openr_domain"] = domain - - def apply(self, topo): - # Add all links for the routers - for router in self.nodes: - self.add_link(*topo.g[router]) - super().apply(topo) - - def __str__(self): - return f"" - - -class Openr(OpenrDaemon): - """This class provides a simple configuration for an OpenR daemon.""" - - NAME = "openr" - DEPENDS = (OpenrDaemon,) - KILL_PATTERNS = (NAME,) - - def __init__(self, node, *args, **kwargs): - super().__init__(*args, node=node, **kwargs) - - @property - def logdir(self) -> str: - if "log_dir" in self._options: - return self._options["log_dir"] - return None - - def build(self): - cfg = super().build() - self.options.redistribute_ifaces = ",".join( - [intf.name for intf in self._node.intfList()] - ) - cfg.update(self.options) - interfaces = realIntfList(self._node) - cfg.interfaces = self._build_interfaces(interfaces) - cfg.networks = self._build_networks(interfaces) - cfg.prefixes = self._build_prefixes(interfaces) - return cfg - - @staticmethod - def _build_networks(interfaces): - """Return the list of OpenR networks to advertize from the list of - active OpenR interfaces""" - - # Check that we have at least one IPv4 network on that interface ... - def _openr_net(ip, prefixLen): - domain = ip_interface(f"{ip}/{prefixLen}") - return OpenrNetwork(domain=domain) - - return [_openr_net(i.ip, i.prefixLen) for i in interfaces if i.ip] - - def _build_interfaces(self, interfaces): - """Return the list of OpenR interface properties from the list of - active interfaces""" - - def _iface_conf(iface): - return ConfigDict( - description=iface.describe, - name=iface.name, - active=self.is_active_interface(iface), - ) - - return [_iface_conf(i) for i in interfaces] - - @staticmethod - def _build_prefixes(interfaces): - ipv6_addresses = [] - for itf in interfaces: - ipv6_addresses += itf.addresses[6] - ipv6_addresses = filter(lambda x: not x.is_link_local, ipv6_addresses) - return ",".join(x.with_prefixlen for x in ipv6_addresses) - - def set_defaults(self, defaults): - """Updates some options of the OpenR daemon to run a network of - routers in mininet. For a full list of parameters see - OpenrDaemon:_defaults in openrd.py""" - defaults.node_name = self._node.name - defaults.ifname_prefix = f"{self._node.name}-eth" - defaults.iface_regex_include = f"{self._node.name}-eth.*" - defaults.log_dir = f"/var/tmp/log/{self._node.name}" - defaults.enable_v4 = True - super().set_defaults(defaults) - - @staticmethod - def is_active_interface(itf): - """Return whether an interface is active or not for the OpenR daemon""" - if itf.broadcast_domain is None: - return False - return any( - L3Router.is_l3router_intf(i) for i in itf.broadcast_domain if i != itf - ) - - -class OpenrNetwork: - """A class holding an OpenR network properties""" - - def __init__(self, domain): - self.domain = domain - - -class OpenrPrefixes: - """A class representing a prefix type in OpenR""" - - def __init__(self, prefixes): - self.prefixes = prefixes diff --git a/ipmininet/router/config/openrd.py b/ipmininet/router/config/openrd.py deleted file mode 100644 index c045ef25..00000000 --- a/ipmininet/router/config/openrd.py +++ /dev/null @@ -1,245 +0,0 @@ -from .base import RouterDaemon -from .utils import ConfigDict - - -class OpenrDaemon(RouterDaemon): - """The base class for the OpenR daemon""" - - NAME = "openr" - - @property - def STARTUP_LINE_EXTRA(self): - # Add options to the standard startup line - return "" - - @property - def startup_line(self): - return f"{self.NAME} {self._cfg_options()} {self.STARTUP_LINE_EXTRA}" - - def build(self): - return ConfigDict() - - def _defaults(self, **kwargs): - """ - Default parameters of the OpenR daemon. The template file openr.mako - sets the default parameters listed here. See: - https://github.com/facebook/openr/blob/master/openr/docs/Runbook.md. - - :param alloc_prefix_len: Block size of allocated prefix in terms of - it's prefix length. In this case '/80' prefix will be elected for a - node. e.g. 'face:b00c:0:0:1234::/80'. Default: 128. - :param assume_drained: Default: False. - :param config_store_filepath: Default: - /tmp/aq_persistent_config_store.bin - :param decision_debounce_max_ms: Knobs to control how often to run - Decision. On receipt of first even debounce is created with MIN - time which grows exponentially up to max if there are more events - before debounce is executed. This helps us to react to single - network failures quickly enough (with min duration) while avoid - high CPU utilization under heavy network churn. Default: 250. - :param decision_debounce_min_ms: Knobs to control how often to run - Decision. On receipt of first even debounce is created with MIN time - which grows exponentially up to max if there are more events before - debounce is executed. This helps us to react to single network - failures quickly enough (with min duration) while avoid high CPU - utilization under heavy network churn. Default: 10. - :param decision_rep_port: Default: 60004. - :param domain: Name of domain this node is part of. OpenR will 'only' - form adjacencies to OpenR instances within it's own domain. This - option becomes very useful if you want to run OpenR on two nodes - adjacent to each other but belonging to different domains, e.g. - Data Center and Wide Area Network. Usually it should depict the - Network. Default: openr. - :param dryrun: OpenR will not try to program routes in it's default - configuration. You should explicitly set this option to false to - proceed with route programming. Default: False. - :param enable_subnet_validation: OpenR supports subnet validation to - avoid mis-cabling of v4 addresses on different subnets on each end - of the link. Need to enable v4 and this flag at the same time to - turn on validation. Default: True. - :param enable_fib_sync: Default: False. - :param enable_health_checker: OpenR can measure network health - internally by pinging other nodes in the network and exports this - information as counters or via breeze APIs. By default health - checker is disabled. The expectation is that each node must have at - least one v6 loopback addressed announced into the network for the - reachability check. Default: False. - :param enable_legacy_flooding: Default: True. - :param enable_lfa: With this option, additional Loop-Free Alternate - (LFA) routes can be computed, per RFC 5286, for fast failure - recovery. Under the failure of all primary nexthops for a prefix, - because of link failure, next best precomputed LFA will be used - without need of an SPF run. Default: False. - :param enable_netlink_fib_handler: Knob to enable/disable default - implementation of 'FibService' that comes along with OpenR for - Linux platform. If you want to run your own FIB service then - disable this option. Default: True. - :param enable_netlink_system_handler: Knob to enable/disable default - implementation of 'SystemService' and 'PlatformPublisher' that - comes along with OpenR for Linux platform. If you want to run your - own SystemService then disable this option. Default: True. - :param enable_perf_measurement: Experimental feature to measure - convergence performance. Performance information can be viewed via - breeze API 'breeze perf fib'. Default: True. - :param enable_prefix_alloc: Enable prefix allocator to elect and assign - a unique prefix for the node. You will need to specify other - configuration parameters below. Default: False. - :param enable_rtt_metric: Default mechanism for cost of a link is '1' - and hence cost of path is hop count. With this option you can ask - OpenR to compute and use RTT of a link as a metric value. You - should only use this for networks where links have significant - delay, on the order of a couple of milliseconds. Using this for - point-to-point links will cause lot of churn in metric updates as - measured RTT will fluctuate a lot because of packet processing - overhead. RTT is measured at application level and hence the - fluctuation for point-to-point links. Default: True. - :param enable_secure_thrift_server: Flag to enable TLS for our thrift - server. Disable this for plaintext thrift. Default: False. - :param enable_segment_routing: Experimental and partially implemented - segment routing feature. As of now it only elects node/adjacency - labels. In future we will extend it to compute and program FIB - routes. Default: False. - :param enable_spark: Default: True. - :param enable_v4: OpenR supports v4 as well but it needs to be turned - on explicitly. It is expected that each interface will have v4 - address configured for link local transport and v4/v6 topologies - are congruent. Default: False. - :param enable_watchdog: Default: True. - :param fib_handler_port: TCP port on which 'FibService' will be - listening. Default: 60100. - :param fib_rep_port: Default: 60009. - :param health_checker_ping_interval_s: Configure ping interval of the - health checker. The below option configures it to ping all other - nodes every 3 seconds. Default: 3. - :param health_checker_rep_port: Default: 60012. - :param ifname_prefix: Interface prefixes to perform neighbor discovery - on. All interfaces whose names start with these are used for - neighbor discovery. Default: "" - :param iface_regex_exclude: Default:"". - :param iface_regex_include: Default: "". - :param ip_tos: Set type of service (TOS) value with which every control - plane packet from Open/R will be marked with. This marking can be - used to prioritize control plane traffic (as compared to data - plane) so that congestion in network doesn't affect operations of - Open/R. Default: 192 - :param key_prefix_filters: This comma separated string is used to set - the key prefixes when key prefix filter is enabled (See - SET_LEAF_NODE). It is also set when requesting KEY_DUMP from peer - to request keys that match one of these prefixes. Default: "". - :param kvstore_flood_msg_per_sec: Default: 0. - :param kvstore_flood_msg_burst_size: Default: 0. - :param kvstore_flood_msg_per_sec: Default: 0. - :param kvstore_ttl_decrement_ms: Default: 1. - :param kvstore_zmq_hwm: Set buffering size for KvStore socket - communication. Updates to neighbor node during flooding can be - buffered upto this number. For larger networks where burst of - updates can be high having high value makes sense. For smaller - networks where burst of updates are low, having low value makes - more sense. Default: 65536. - :param link_flap_initial_backoff_ms: Default: 1000. - :param link_flap_max_backoff_ms: Default: 60000. - :param link_monitor_cmd_port: Default: 60006. - :param loopback_iface: Indicates loopback address to which auto elected - prefix will be assigned if enabled. Default: "lo". - :param memory_limit_mb: Enforce upper limit on amount of memory in - mega-bytes that open/r process can use. Above this limit watchdog - thread will trigger crash. Service can be auto-restarted via system - or some kind of service manager. This is very useful to guarantee - protocol doesn't cause trouble to other services on device where it - runs and takes care of slow memory leak kind of issues. Default: - 300. - :param minloglevel: Log messages at or above this level. Again, the - numbers of severity levels INFO, WARNING, ERROR, and FATAL are 0, - 1, 2, and 3, respectively. Default: 0. - :param node_name: Name of the OpenR node. Crucial setting if you run - multiple nodes. Default: "". - :param override_loopback_addr: Whenever new address is elected for a - node, before assigning it to interface all previously allocated - prefixes or other global prefixes will be overridden with the new - one. Use it with care! Default: False. - :param prefix_manager_cmd_port: Default: 60011. - :param prefixes: Static list of comma separate prefixes to announce - from the current node. Can't be changed while running. Default: "". - :param redistribute_ifaces: Comma separated list of interface names - whose '/32' (for v4) and '/128' (for v6) should be announced. OpenR - will monitor address add/remove activity on this interface and - announce it to rest of the network. Default: "lo". - :param seed_prefix: In order to elect a prefix for the node a super - prefix to elect from is required. This is only applicable when - 'ENABLE_PREFIX_ALLOC' is set to true. Default: "". - :param set_leaf_node: Sometimes a node maybe a leaf node and have only - one path in to network. This node does not require to keep track of - the entire topology. In this case, it may be useful to optimize - memory by reducing the amount of key/vals tracked by the node. - Setting this flag enables key prefix filters defined by - KEY_PREFIX_FILTERS. A node only tracks keys in kvstore that matches - one of the prefixes in KEY_PREFIX_FILTERS. Default: False. - :param set_loopback_address: If set to true along with - 'ENABLE_PREFIX_ALLOC' then second valid IP address of the block - will be assigned onto 'LOOPBACK_IFACE' interface. e.g. in this case - 'face:b00c:0:0:1234::1/80' will be assigned on 'lo' interface. - Default: False. - :param spark_fastinit_keepalive_time_ms: When interface is detected UP, - OpenR can perform fast initial neighbor discovery as opposed to - slower keep alive packets. Default value is 100 which means - neighbor will be discovered within 200ms on a link. Default: 100. - :param spark_hold_time_s: Hold time indicating time in seconds from - it's last hello after which neighbor will be declared as down. - Default: 30. - :param spark_keepalive_time_s: How often to send spark hello messages - to neighbors. Default: 3. - :param static_prefix_alloc: Default: False. - :param tls_acceptable_peers: A comma separated list of strings. Strings - are x509 common names to accept SSL connections from. Default: "" - :param tls_ecc_curve_name: If we are running an SSL thrift server, this - option specifies the eccCurveName for the associated - wangle::SSLContextConfig. Default: "prime256v1". - :param tls_ticket_seed_path: If we are running an SSL thrift server, - this option specifies the TLS ticket seed file path to use for - client session resumption. Default: "". - :param x509_ca_path: If we are running an SSL thrift server, this - option specifies the certificate authority path for verifying - peers. Default: "". - :param x509_cert_path: If we are running an SSL thrift server, this - option specifies the certificate path for the associated - wangle::SSLContextConfig. Default: "". - :param x509_key_path: If we are running an SSL thrift server, this - option specifies the key path for the associated - wangle::SSLContextConfig. Default: "". - :param logbufsecs: Default: 0 - :param log_dir: Directory to store log files at. The folder must exist. - Default: /var/log. - :param max_log_size: Default: 1. - :param v: Show all verbose 'VLOG(m)' messages for m less or equal the - value of this flag. Use higher value for more verbose logging. - Default: 1. - """ - defaults = ConfigDict() - # Apply daemon-specific defaults - self.set_defaults(defaults) - # Use user-supplied defaults if present - defaults.update(**kwargs) - return defaults - - def set_defaults(self, defaults): - super().set_defaults(defaults) - - def _cfg_options(self): - """The OpenR daemon has currently no option to read config from - configuration file itself. The run_openr.sh script can be used to read - options from environment files. However, we want to run the daemon - directly. The default options from the shell script are implemented in - the openr.mako template and passed to the daemon as argument.""" - cfg = ConfigDict() - cfg[self.NAME] = self.options - return self.template_lookup.get_template(self.template_filenames[0]).render( - node=cfg - ) - - @property - def dry_run(self): - """The OpenR dryrun runs the daemon and does not shutdown the daemon. - As a workaround we only show the version of the openr daemon""" - # TODO: Replace with a config parser or shutdown the daemon after few - # seconds - return f"{self.NAME} --version" diff --git a/ipmininet/router/config/templates/openr.mako b/ipmininet/router/config/templates/openr.mako deleted file mode 100644 index 9a7fe150..00000000 --- a/ipmininet/router/config/templates/openr.mako +++ /dev/null @@ -1,74 +0,0 @@ -<% - # Read value from node or use default value - def getConfig(key, default): - if key in node['openr']: - val = node['openr'][key] - else: - val = default - return "--{k}={v}".format(k=key, v=val) -%>\ -${getConfig("alloc_prefix_len", 128)} \ -${getConfig("assume_drained", False)} \ -${getConfig("config_store_filepath", "/tmp/aq_persistent_config_store.bin")} \ -${getConfig("decision_debounce_max_ms", 250)} \ -${getConfig("decision_debounce_min_ms", 10)} \ -${getConfig("decision_rep_port", 60004)} \ -${getConfig("domain","openr")} \ -${getConfig("dryrun", False)} \ -${getConfig("enable_subnet_validation", True)} \ -${getConfig("enable_fib_sync", False)} \ -${getConfig("enable_health_checker", False)} \ -${getConfig("enable_legacy_flooding", True)} \ -${getConfig("enable_lfa", False)} \ -${getConfig("enable_netlink_fib_handler", True)} \ -${getConfig("enable_netlink_system_handler", True)} \ -${getConfig("enable_perf_measurement", True)} \ -${getConfig("enable_prefix_alloc", False)} \ -${getConfig("enable_rtt_metric", True)} \ -${getConfig("enable_secure_thrift_server", False)} \ -${getConfig("enable_segment_routing", False)} \ -${getConfig("enable_spark", True)} \ -${getConfig("enable_v4", False)} \ -${getConfig("enable_watchdog", True)} \ -${getConfig("fib_handler_port", 60100)} \ -${getConfig("fib_rep_port", 60009)} \ -${getConfig("health_checker_ping_interval_s", 3)} \ -${getConfig("health_checker_rep_port", 60012)} \ -${getConfig("ifname_prefix", "")} \ -${getConfig("iface_regex_exclude", "")} \ -${getConfig("iface_regex_include", "")} \ -${getConfig("ip_tos", 192)} \ -${getConfig("key_prefix_filters", "")} \ -${getConfig("kvstore_flood_msg_per_sec", 0)} \ -${getConfig("kvstore_flood_msg_burst_size", 0)} \ -${getConfig("kvstore_flood_msg_per_sec", 0)} \ -${getConfig("kvstore_ttl_decrement_ms", 1)} \ -${getConfig("kvstore_zmq_hwm", 65536)} \ -${getConfig("link_flap_initial_backoff_ms", 1000)} \ -${getConfig("link_flap_max_backoff_ms", 60000)} \ -${getConfig("link_monitor_cmd_port", 60006)} \ -${getConfig("loopback_iface", "lo")} \ -${getConfig("memory_limit_mb", 300)} \ -${getConfig("minloglevel", 0)} \ -${getConfig("node_name", "")} \ -${getConfig("override_loopback_addr", False)} \ -${getConfig("prefix_manager_cmd_port", 60011)} \ -${getConfig("prefixes", "")} \ -${getConfig("redistribute_ifaces", "lo")} \ -${getConfig("seed_prefix", "")} \ -${getConfig("set_leaf_node", False)} \ -${getConfig("set_loopback_address", False)} \ -${getConfig("spark_fastinit_keepalive_time_ms", 100)} \ -${getConfig("spark_hold_time_s", 30)} \ -${getConfig("spark_keepalive_time_s", 3)} \ -${getConfig("static_prefix_alloc", False)} \ -${getConfig("tls_acceptable_peers", "")} \ -${getConfig("tls_ecc_curve_name", "prime256v1")} \ -${getConfig("tls_ticket_seed_path", "")} \ -${getConfig("x509_ca_path", "")} \ -${getConfig("x509_cert_path", "")} \ -${getConfig("x509_key_path", "")} \ -${getConfig("logbufsecs", 0)} \ -${getConfig("log_dir", "/var/log")} \ -${getConfig("max_log_size", 1)} \ -${getConfig("v", 1)} \ diff --git a/ipmininet/tests/test_openr.py b/ipmininet/tests/test_openr.py deleted file mode 100644 index b7b2181a..00000000 --- a/ipmininet/tests/test_openr.py +++ /dev/null @@ -1,102 +0,0 @@ -import os -import tempfile -import uuid - -from ipmininet.clean import cleanup -from ipmininet.ipnet import IPNet -from ipmininet.iptopo import IPTopo -from ipmininet.node_description import OpenrRouterDescription -from ipmininet.router import OpenrRouter -from ipmininet.router.config import OpenrRouterConfig -from ipmininet.tests.utils import assert_connectivity - -from . import require_openr, require_root - - -class SimpleOpenrTopo(IPTopo): - def __init__(self, *args, **kwargs): - self.r4_log_dir = "/var/tmp/custom/log_dir/location" - super().__init__(*args, **kwargs) - - def build(self, *args, **kwargs): - # pylint: disable=unbalanced-tuple-unpacking - r_1, r_2, r_3 = self.addRouters( - "r_1", - "r_2", - "r_3", - cls=OpenrRouter, - routerDescription=OpenrRouterDescription, - config=OpenrRouterConfig, - ) - r_4 = self.addRouter( - "r_4", cls=OpenrRouter, routerDescription=OpenrRouterDescription - ) - r_4.addOpenrDaemon(log_dir=self.r4_log_dir) - self.addLinks((r_1, r_2), (r_1, r_3), (r_3, r_4)) - super().build(*args, **kwargs) - - -@require_root -@require_openr -def test_openr_connectivity(): - try: - net = IPNet(topo=SimpleOpenrTopo()) - net.start() - assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() - - -@require_root -@require_openr -def test_logdir_creation(): - try: - topo = SimpleOpenrTopo() - net = IPNet(topo=topo) - net.start() - - default_log_dir = "/var/tmp/log" - - for i in range(1, 4): - assert os.path.isdir(f"{default_log_dir}/r_{i}") - assert not os.path.isdir(f"{default_log_dir}/r_4") - assert os.path.isdir(topo.r4_log_dir) - - net.stop() - finally: - cleanup() - - -@require_root -@require_openr -def test_tmp_isolation(): - try: - net = IPNet(topo=SimpleOpenrTopo()) - net.start() - - tmp_dir = "/tmp" - with tempfile.NamedTemporaryFile(dir=tmp_dir) as file: - host_file_name = file.name - host_file_base_name = os.path.basename(host_file_name) - host_tmp_dir_content = os.listdir(tmp_dir) - - assert os.path.isfile(host_file_name) - assert host_file_base_name in host_tmp_dir_content - for i in range(1, 5): - node_tmp_dir_content = net[f"r_{i}"].cmd(f"ls {tmp_dir}").split() - assert host_file_base_name not in node_tmp_dir_content - - node_file_base_name = str(uuid.uuid1()) - node_file_name = f"{tmp_dir}/{node_file_base_name}" - net["r_1"].cmd(f"touch {node_file_name}") - node_tmp_dir_content = net["r_1"].cmd(f"ls {tmp_dir}").split() - host_tmp_dir_content = os.listdir(tmp_dir) - - assert node_file_base_name in node_tmp_dir_content - assert not os.path.isfile(node_file_name) - assert node_file_base_name not in host_tmp_dir_content - - net.stop() - finally: - cleanup() diff --git a/scripts/ci-install.sh b/scripts/ci-install.sh index bed53d0b..502f1567 100755 --- a/scripts/ci-install.sh +++ b/scripts/ci-install.sh @@ -2,8 +2,6 @@ set -euo pipefail LOGFILE="${LOGFILE:-/workspace/.tmp/install.log}" exec > >(tee "$LOGFILE") 2>&1 -# PEP 668 on Ubuntu 24.04 and newer blocks pip3 system installs; relax it. -export PIP_BREAK_SYSTEM_PACKAGES="${PIP_BREAK_SYSTEM_PACKAGES:-1}" -exec sudo env "PATH=$PATH" PIP_BREAK_SYSTEM_PACKAGES="$PIP_BREAK_SYSTEM_PACKAGES" \ +exec sudo env "PATH=$PATH" \ ${UV_PROJECT_ENVIRONMENT:+"UV_PROJECT_ENVIRONMENT=$UV_PROJECT_ENVIRONMENT"} \ uv run python -m ipmininet.install -a diff --git a/uv.lock b/uv.lock index 40d69335..be27d433 100644 --- a/uv.lock +++ b/uv.lock @@ -369,7 +369,7 @@ wheels = [ [[package]] name = "ipmininet" -version = "1.2.6" +version = "1.2.7" source = { editable = "." } dependencies = [ { name = "mako" }, From ac0627b93e055d343443f1e9a9b2bb531256fb2d Mon Sep 17 00:00:00 2001 From: Iakov Kirilenko Date: Wed, 2 Sep 2026 00:32:32 +0300 Subject: [PATCH 51/69] chore: remove EOL OpenR daemon support OpenR has been unmaintained upstream since 2019 and its test was always skipped in CI (require_openr). Drop the installer, config generators, example, test, and docs. --- docs/daemons.rst | 45 ----------------------------- ipmininet/examples/README.md | 15 ---------- ipmininet/examples/__main__.py | 2 -- ipmininet/iptopo.py | 2 -- ipmininet/node_description.py | 17 +---------- ipmininet/router/__init__.py | 4 +-- ipmininet/router/__router.py | 30 +------------------ ipmininet/router/config/__init__.py | 7 ----- ipmininet/router/config/base.py | 25 +--------------- ipmininet/tests/__init__.py | 13 +++------ pyproject.toml | 1 - scripts/run-tests-parallel.sh | 1 - 12 files changed, 9 insertions(+), 153 deletions(-) diff --git a/docs/daemons.rst b/docs/daemons.rst index 17a8ae6d..b9597f82 100644 --- a/docs/daemons.rst +++ b/docs/daemons.rst @@ -364,51 +364,6 @@ It also takes the same parameter (see previous section for details): :noindex: -OpenR ------ - -The OpenR daemon can be tuned by adding keyword arguments to -``router.addDaemon(Openr, **kargs)``. Here is a list of the parameters: - -.. automethod:: ipmininet.router.config.openrd.OpenrDaemon._defaults - :noindex: - -At the moment IPMininet supports OpenR release `rc-20190419-11514 -`_. This -release can be build from the script -``install/build_openr-rc-20190419-11514.sh``. - -As of ``rc-20190419-11514`` the OpenR daemon creates `ZeroMQ -`_ sockets in ``/tmp``. Therefore, it is advisable for -networks with several OpenR daemons to isolate the ``/tmp`` folder within Linux -namespaces. ``OpenrRouter`` utilizes Mininet's ``privateDirs`` option to -provide this isolation. We can pass the ``cls`` option to ``addRouter`` to -select custom router classes: - -.. testcode:: OpenR - - from ipmininet.iptopo import IPTopo - from ipmininet.router import OpenrRouter - from ipmininet.node_description import OpenrRouterDescription - - - class MyTopology(IPTopo): - - def build(self, *args, **kwargs): - r1 = self.addRouter('r1', - cls=OpenrRouter, - routerDescription=OpenrRouterDescription) - r1.addOpenrDaemon() - - r2 = self.addRouter('r2', - cls=OpenrRouter, - routerDescription=OpenrRouterDescription) - r2.addOpenrDaemon() - self.addLink(r1, r2) - - super().build(*args, **kwargs) - - OSPF ---- diff --git a/ipmininet/examples/README.md b/ipmininet/examples/README.md index 31c95280..bfdba3b8 100644 --- a/ipmininet/examples/README.md +++ b/ipmininet/examples/README.md @@ -25,7 +25,6 @@ The following sections will detail the topologies. - [GRETopo](#gretopo) - [SSHd](#sshd) - [RouterAdvNetwork](#routeradvnetwork) -- [SimpleOpenRNetwork](#simpleopenrnetwork) - [StaticAddressNetwork](#staticaddressnetwork) - [PartialStaticAddressNet](#partialstaticaddressnetwork) - [StaticRoutingNet](#staticroutingnet) @@ -299,20 +298,6 @@ One of these hosts uses Router Advertisements to get its IPv6 addresses The other one's IP addresses are announced in the Router Advertisements as the DNS server's addresses. -## SimpleOpenRNetwork - -_topo name_ : simple_openr_network -_args_ : n/a - -This network represents a small OpenR network connecting three routers in a Bus -topology. Each router has hosts attached. OpenR routers use private `/tmp` -folders to isolate the ZMQ sockets used by the daemon. The OpenR logs are by -default available in the host machine at `/var/tmp/log/`. - -Use -[breeze](https://github.com/facebook/openr/blob/master/openr/docs/Breeze.md) to -investigate the routing state of OpenR. - ## StaticAddressNetwork _topo name_ : static_address_network diff --git a/ipmininet/examples/__main__.py b/ipmininet/examples/__main__.py index 881c6f62..9aad469c 100644 --- a/ipmininet/examples/__main__.py +++ b/ipmininet/examples/__main__.py @@ -31,7 +31,6 @@ from .ripng_network_adjust import RIPngNetworkAdjust from .router_adv_network import RouterAdvNet from .simple_bgp_network import SimpleBGPTopo -from .simple_openr_network import SimpleOpenrNet from .simple_ospf_network import SimpleOSPFNet from .simple_ospfv3_network import SimpleOSPFv3Net from .spanning_tree import SpanningTreeNet @@ -62,7 +61,6 @@ "router_adv_network": RouterAdvNet, "ripng_network": RIPngNetwork, "ripng_network_adjust": RIPngNetworkAdjust, - "simple_openr_network": SimpleOpenrNet, "static_address_network": StaticAddressNet, "static_routing_network_complex": StaticRoutingNetComplex, "partial_static_address_network": PartialStaticAddressNet, diff --git a/ipmininet/iptopo.py b/ipmininet/iptopo.py index 6417a0cd..a83bec1f 100644 --- a/ipmininet/iptopo.py +++ b/ipmininet/iptopo.py @@ -17,7 +17,6 @@ from ipmininet.router.config import ( AS, BasicRouterConfig, - OpenrDomain, OSPFArea, iBGPFullMesh, ) @@ -33,7 +32,6 @@ class IPTopo(Topo): for cls in ( AS, iBGPFullMesh, - OpenrDomain, OSPFArea, Subnet, DNSZone, diff --git a/ipmininet/node_description.py b/ipmininet/node_description.py index 4582772c..393ee35b 100644 --- a/ipmininet/node_description.py +++ b/ipmininet/node_description.py @@ -2,12 +2,7 @@ from typing import TYPE_CHECKING, Optional from ipmininet.host.config import HostConfig -from ipmininet.router.config import ( - BasicRouterConfig, - Openr, - OpenrDaemon, - OpenrRouterConfig, -) +from ipmininet.router.config import BasicRouterConfig from ipmininet.router.config.base import Daemon, NodeConfig, RouterConfig if TYPE_CHECKING: @@ -67,16 +62,6 @@ def addDaemon( super().addDaemon(daemon, default_cfg_class=default_cfg_class, **kwargs) -class OpenrRouterDescription(RouterDescription): - def addOpenrDaemon( - self, - daemon: OpenrDaemon | type[OpenrDaemon] = Openr, - default_cfg_class: type[OpenrRouterConfig] = OpenrRouterConfig, - **kwargs, - ): - self.addDaemon(daemon, default_cfg_class=default_cfg_class, **kwargs) - - class HostDescription(NodeDescription): def addDaemon( self, diff --git a/ipmininet/router/__init__.py b/ipmininet/router/__init__.py index 38bd4134..e5bf3c64 100644 --- a/ipmininet/router/__init__.py +++ b/ipmininet/router/__init__.py @@ -2,6 +2,6 @@ multiple daemons """ -from .__router import IPNode, OpenrRouter, ProcessHelper, Router +from .__router import IPNode, ProcessHelper, Router -__all__ = ["IPNode", "OpenrRouter", "ProcessHelper", "Router"] +__all__ = ["IPNode", "ProcessHelper", "Router"] diff --git a/ipmininet/router/__router.py b/ipmininet/router/__router.py index 362a73d6..3b593397 100644 --- a/ipmininet/router/__router.py +++ b/ipmininet/router/__router.py @@ -16,7 +16,7 @@ from ipmininet.link import IPIntf from ipmininet.utils import L3Router, otherIntf, realIntfList -from .config import BasicRouterConfig, NodeConfig, OpenrRouterConfig, RouterConfig +from .config import BasicRouterConfig, NodeConfig, RouterConfig from .config.base import Daemon from .config.utils import ConfigDict @@ -305,31 +305,3 @@ def __init__( @property def asn(self) -> int: return self.get("asn") - - -class OpenrRouter(Router): - """OpenR router with private '/tmp' dir to handle unix sockets created by - the OpenR daemon""" - - def __init__( - self, - name, - *args, - config: type[OpenrRouterConfig], - lo_addresses: Sequence[str | IPv4Interface | IPv6Interface] | None = None, - privateDirs=None, - **kwargs, - ): - if privateDirs is None: - privateDirs = ["/tmp"] - if not lo_addresses: - lo_addresses = () - super().__init__( - name, - *args, - config=config, - lo_addresses=lo_addresses, - password=None, - privateDirs=privateDirs, - **kwargs, - ) diff --git a/ipmininet/router/config/__init__.py b/ipmininet/router/config/__init__.py index bc6e90ec..29ddf500 100644 --- a/ipmininet/router/config/__init__.py +++ b/ipmininet/router/config/__init__.py @@ -5,7 +5,6 @@ BasicRouterConfig, BorderRouterConfig, NodeConfig, - OpenrRouterConfig, RouterConfig, ) from .bgp import ( @@ -49,8 +48,6 @@ Rule, TransitFilter, ) -from .openr import Openr, OpenrDomain -from .openrd import OpenrDaemon from .ospf import OSPF, OSPFArea from .ospf6 import OSPF6 from .pimd import PIMD @@ -98,10 +95,6 @@ "InterfaceClause", "NodeConfig", "OSPFArea", - "Openr", - "OpenrDaemon", - "OpenrDomain", - "OpenrRouterConfig", "OutputFilter", "PortClause", "RIPng", diff --git a/ipmininet/router/config/base.py b/ipmininet/router/config/base.py index 1b44243f..02d4e194 100644 --- a/ipmininet/router/config/base.py +++ b/ipmininet/router/config/base.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: from ipmininet.iptopo import IPTopo from ipmininet.node_description import NodeDescription - from ipmininet.router import IPNode, OpenrRouter, ProcessHelper, Router + from ipmininet.router import IPNode, ProcessHelper, Router DaemonOption = Union[ "Daemon", type["Daemon"], tuple[Union["Daemon", type["Daemon"]], dict] ] @@ -498,26 +498,3 @@ def __init__( d = list(daemons) d.append((BGP, {"address_families": af})) super().__init__(node, *args, daemons=d, **kwargs) - - -class OpenrRouterConfig(RouterConfig): - """A basic router that will run an OpenR daemon""" - - def __init__( - self, - node: "OpenrRouter", - daemons: Iterable[DaemonOption] = (), - additional_daemons: Iterable[DaemonOption] = (), - *args, - **kwargs, - ): - """A simple router made of at least an OpenR daemon - - :param additional_daemons: Other daemons that should be used""" - # Importing here to avoid circular import - from .openr import Openr - - daemon_list = list(daemons) - daemon_list.append(Openr) - daemon_list.extend(additional_daemons) - super().__init__(node, *args, daemons=daemon_list, **kwargs) diff --git a/ipmininet/tests/__init__.py b/ipmininet/tests/__init__.py index 7528c861..82d40128 100644 --- a/ipmininet/tests/__init__.py +++ b/ipmininet/tests/__init__.py @@ -6,10 +6,10 @@ job runs as root). Tests that depend on a daemon binary that may be absent use the conditional -markers below (require_openr, require_exabgp, require_mimidump). They run -wherever the daemon is available and are skipped with a reason otherwise. The -reasons are surfaced in CI via `pytest -rs`; these files are intentionally not ---ignore'd at the workflow level so the skip reason stays next to the test. +markers below (require_exabgp, require_mimidump). They run wherever the daemon +is available and are skipped with a reason otherwise. The reasons are surfaced +in CI via `pytest -rs`; these files are intentionally not --ignore'd at the +workflow level so the skip reason stays next to the test. """ import os @@ -48,11 +48,6 @@ def _exabgp_usable() -> bool: return False -require_openr = pytest.mark.skipif( - not has_cmd("openr"), - reason="OpenR daemon not available (needs build, not run in CI)", -) - require_exabgp = pytest.mark.skipif( not _exabgp_usable(), reason="ExaBGP daemon not available (broken vendored six on Py3.12)", diff --git a/pyproject.toml b/pyproject.toml index c6579bdb..87f656cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,6 @@ timeout_method = "signal" markers = [ "require_root: test requires root privileges", "require_exabgp: test requires a working ExaBGP daemon", - "require_openr: test requires the OpenR daemon", "require_mimidump: test requires the mimidump capture tool", ] diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index 7436cddd..15cd732e 100755 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -48,7 +48,6 @@ if [ ${#ARGS[@]} -eq 0 ]; then ipmininet/tests/test_pure.py ipmininet/tests/test_physicalinterface.py ipmininet/tests/test_address_alllocation.py - ipmininet/tests/test_openr.py ) fi From 6497f556baf9c8ba355c8520e55cc2e151a13c4a Mon Sep 17 00:00:00 2001 From: iakov Date: Wed, 2 Sep 2026 08:30:38 +0300 Subject: [PATCH 52/69] feat: migrate FRRouting to 10.7.1 with mgmtd-based config (#36) Migrates the routing stack to FRRouting 10.7.1 and libyang v3, adopting the FRR 9+ mgmtd configuration architecture and slimming the test container via a two-stage build. - install.py: FRR 10.7.1 (tag archive + bootstrap.sh), libyang v3.13.6 (drops the PCRE1 source build and CMake policy hack), new build deps, standard system paths (--sysconfdir=/etc --localstatedir=/var --runstatedir=/var/run), --enable-protobuf=no. - New Mgmtd daemon and MgmtdBackendDaemon base: staticd, ripd and ripngd are mgmtd backends in FRR 9+ and reject -f/-C; their rendered config is pushed via vtysh once each daemon is up. - bgpd: skip the -C dry-run (FRR 10.7.1 crashes on 'neighbor activate' in the config-check path; validated at real startup instead). - ospf6d: attach interfaces to their area with 'ipv6 ospf6 area' inside the interface block ('interface X area Y' was removed in FRR 10.x). - Containerfile: two-stage builder/runtime build. The builder runs install -a and builds the venv + mimidump; the runtime stage ships only test-facing apt packages plus the compiled artifacts (FRR, libyang, mnexec, exabgp, mimidump, venv + uv CPython toolchain). Final image drops from 1.74 GB to 0.98 GB (613 MB to 277 MB compressed download, -55%). Full container test suite green (container-test, test, rootless, docs). --- .github/workflows/container-test.yaml | 11 ++- Containerfile | 71 ++++++++++++-- ipmininet/install/install.py | 86 +++++------------ ipmininet/router/config/__init__.py | 3 +- ipmininet/router/config/bgp.py | 9 ++ ipmininet/router/config/ripng.py | 5 +- ipmininet/router/config/staticd.py | 5 +- ipmininet/router/config/templates/ospf6d.mako | 4 +- ipmininet/router/config/zebra.py | 95 +++++++++++++++++++ ipmininet/tests/test_ospf6.py | 12 ++- 10 files changed, 216 insertions(+), 85 deletions(-) diff --git a/.github/workflows/container-test.yaml b/.github/workflows/container-test.yaml index bd4fb440..2ffa8f40 100644 --- a/.github/workflows/container-test.yaml +++ b/.github/workflows/container-test.yaml @@ -1,10 +1,11 @@ # Containerized CI. # -# Builds a self-contained image with the full install (-a: FRRouting, libyang, -# mininet, radvd, sshd, named) plus the uv venv and mimidump, then runs the -# full test suite inside it with root privileges. Every worker runs in its own -# isolated namespace (scripts/run-tests-parallel.sh), mirroring the bare-metal -# job but in a reproducible environment. +# Builds a two-stage image: the builder runs the full install (-a: FRRouting, +# libyang, mininet, radvd, sshd, named) plus the uv venv and mimidump, and the +# slim runtime stage keeps only the test-facing packages and the compiled +# artifacts. Runs the full test suite inside it with root privileges. Every +# worker runs in its own isolated namespace (scripts/run-tests-parallel.sh), +# mirroring the bare-metal job but in a reproducible environment. name: Container test on: pull_request: diff --git a/Containerfile b/Containerfile index e7c41dad..f2ce1088 100644 --- a/Containerfile +++ b/Containerfile @@ -1,14 +1,21 @@ -# Self-contained test container. +# Self-contained test container, built in two stages to keep the runtime +# image lean. # -# Installs the runtime/build dependencies, the uv-managed virtualenv -# (UV_PROJECT_ENVIRONMENT=/opt/venv, which survives the bind-mount in -# container-test.yaml) and then runs the full `ipmininet.install -a` so that -# FRRouting, libyang, mininet, radvd, sshd and named are available. -FROM ubuntu:26.04 AS final +# builder: installs every build dependency and runs the full +# `ipmininet.install -a` (FRRouting 10.x, libyang v3, mininet, radvd, sshd, +# named, exabgp) plus the uv-managed virtualenv and mimidump. +# +# runtime: a plain ubuntu:26.04 with only the packages the test suite needs, +# plus the compiled artifacts copied over from the builder. The build +# toolchain (compilers, autotools, *-dev headers) never reaches this stage. + +FROM ubuntu:26.04 AS builder ENV DEBIAN_FRONTEND=noninteractive ENV UV_PROJECT_ENVIRONMENT=/opt/venv +# Build-time and run-time packages in one shot for the builder; the runtime +# stage installs only its own subset below. RUN apt-get update && apt-get install -y --no-install-recommends \ git sudo ca-certificates wget build-essential \ autoconf automake libtool make gcc groff patch bison flex gawk \ @@ -19,7 +26,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ cgroup-tools autotools-dev libc6-dev tcpdump python3-scapy \ libpcap-dev libbsd-dev libconfig-dev openvswitch-switch radvd bind9 dnsutils \ bridge-utils traceroute nmap netcat-openbsd tshark iptables iputils-ping \ - python3-pip grub-common \ + python3-pip grub-common libpcre2-dev libelf-dev libunwind-dev python3-sphinx \ && rm -rf /var/lib/apt/lists/* # Build mimidump (mimi-net/mimidump) so the interface-capture tests can run in @@ -39,4 +46,54 @@ RUN uv sync --all-extras COPY . /workspace RUN mkdir -p /workspace/.tmp && scripts/ci-install.sh +# ---------------------------------------------------------------- runtime --- + +FROM ubuntu:26.04 AS final + +ENV DEBIAN_FRONTEND=noninteractive +ENV UV_PROJECT_ENVIRONMENT=/opt/venv + +# Run-time packages only: the tools the test suite actually invokes. The FRR +# daemons themselves and libyang come over from the builder. +RUN apt-get update && apt-get install -y --no-install-recommends \ + git sudo ca-certificates wget \ + socat psmisc xterm openssh-client openssh-server iperf3 ethtool net-tools \ + python3-pexpect python3-tk iproute2 tcpdump python3-scapy \ + openvswitch-switch radvd bind9 dnsutils bridge-utils traceroute \ + nmap netcat-openbsd tshark iptables iputils-ping python3-pip grub-common \ + libjson-c5 libcap2 libpcre2-8-0 libunwind8 \ + && rm -rf /var/lib/apt/lists/* + +# FRR daemons and helpers from the builder install (prefix /root/frr). +COPY --from=builder /root/frr /root/frr +# libyang v3 runtime library and headers. +COPY --from=builder /usr/lib/x86_64-linux-gnu/libyang* /usr/lib/x86_64-linux-gnu/ +COPY --from=builder /usr/include/libyang /usr/include/ +COPY --from=builder /usr/share/yang/modules/libyang /usr/share/yang/modules/ +COPY --from=builder /usr/bin/yanglint /usr/bin/yanglint +# mininet mnexec and exabgp. +COPY --from=builder /usr/local/bin/mnexec /usr/local/bin/mnexec +COPY --from=builder /root/exabgp /root/exabgp +COPY --from=builder /usr/sbin/exabgp /usr/sbin/exabgp +# mimidump. +COPY --from=builder /usr/local/bin/mimidump /usr/local/bin/mimidump +# The uv-managed virtualenv and its CPython toolchain (the venv's +# bin/python* are symlinks into it). +COPY --from=builder /opt/venv /opt/venv +COPY --from=builder /root/.local/share/uv/python /root/.local/share/uv/python +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# FRR runtime identity and the /usr/{sbin,bin} links (matching what the +# installer's link_to_standard_dir() would have created). +RUN groupadd frr && groupadd frrvty \ + && usermod -a -G frr root \ + && usermod -a -G frrvty root \ + && for f in /root/frr/sbin/*; do ln -sf "$f" /usr/sbin/; done \ + && for f in /root/frr/bin/*; do ln -sf "$f" /usr/bin/; done \ + && mkdir -p /var/run/frr /var/lib/frr /etc/frr + +WORKDIR /workspace +COPY . /workspace +RUN mkdir -p /workspace/.tmp + CMD ["bash"] diff --git a/ipmininet/install/install.py b/ipmininet/install/install.py index a8e9486f..a22ffca7 100644 --- a/ipmininet/install/install.py +++ b/ipmininet/install/install.py @@ -1,27 +1,18 @@ import argparse -import hashlib import os import re import sys import sysconfig -import urllib.request # For imports to work during setup and afterwards sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from utils import find_executable, identify_distribution, sh, supported_distributions +from utils import identify_distribution, sh, supported_distributions MininetVersion = "2.3.0" -FRRoutingVersion = "7.5" -LibyangVersion = "v1.0.215" +FRRoutingVersion = "10.7.1" +LibyangVersion = "v3.13.6" ExaBGPVersion = "4.2.25" -# PCRE1 (libpcre3) is required to build libyang v1, which FRRouting pins to. -# Ubuntu 26.04 and newer dropped the obsolete PCRE1 packages, so the installer -# builds this release from source there. -PcreVersion = "8.45" -PcreSha256 = "4e6ce03e0336e8b4a3d6c2b70b1c5e18590a5673a98186da90d4f33c23defc09" -PcreUrl = "https://downloads.sourceforge.net/project/pcre/pcre/8.45/pcre-8.45.tar.gz" - # XXX: We need the explicit script until the following issue is fixed: # https://github.com/mininet/mininet/issues/1120 MininetInstallCommit = "c3ba039a9781c6c5f475b7c88ff577185747a1da" @@ -135,52 +126,16 @@ def install_mininet(output_dir: str, pip_install=True): dist.pip_install("mininet/", cwd=output_dir) -def ensure_pcre1(dist, output_dir: str) -> None: - """Provide the PCRE1 library needed to build libyang. - - libyang v1, which FRRouting requires, is built against PCRE1 (libpcre3 on - Debian/Ubuntu). Ubuntu 26.04 and newer dropped the obsolete PCRE1 packages, - so fall back to building PCRE from source when the distro does not ship it. - """ - if dist.package_family == "rpm": - dist.install("pcre-devel") - return - if dist.package_family != "apt" or find_executable("pcre-config"): - return - p = sh("apt-get -y -q install libpcre3-dev", may_fail=True) - if p is not None and p.wait() == 0: - return - if find_executable("pcre-config"): - return - print("IPMininet: libpcre3-dev is unavailable, building PCRE from source") - pcre_archive = os.path.join(output_dir, f"pcre-{PcreVersion}.tar.gz") - pcre_src = os.path.join(output_dir, f"pcre-{PcreVersion}") - if not os.path.exists(pcre_archive): - urllib.request.urlretrieve(PcreUrl, pcre_archive) - with open(pcre_archive, "rb") as f: - digest = hashlib.sha256(f.read()).hexdigest() - if digest != PcreSha256: - raise RuntimeError( - f"PCRE {PcreVersion} download has an unexpected SHA-256 digest: {digest}" - ) - sh(f"rm -rf {pcre_src}", f"tar -xzf {pcre_archive}", cwd=output_dir) - # --enable-unicode-properties: FRR 7.5's bundled YANG (ietf-inet-types) - # regexes use \\p{...}, which is only compiled in with UCP support. - sh( - "./configure --prefix=/usr --enable-utf8 --enable-unicode-properties", - f"make -j{os.cpu_count() or 1}", - "make install", - cwd=pcre_src, - ) - sh("ldconfig") - - def install_libyang(output_dir: str): if not _needs_rebuild("/usr/bin/yanglint"): print("IPMininet: libyang already installed; skipping build") return dist.install("git", "cmake") - ensure_pcre1(dist, output_dir) + # libyang v3, which FRRouting 8+ builds against, needs PCRE2. + if dist.package_family == "rpm": + dist.install("pcre2-devel") + else: + dist.install("libpcre2-dev") sh( "rm -rf libyang", @@ -189,12 +144,8 @@ def install_libyang(output_dir: str): ) cloned_repo = os.path.join(output_dir, "libyang") sh(f"git checkout {LibyangVersion}", "mkdir build", cwd=cloned_repo) - # CMake 4.x removed compatibility with versions < 3.5, but libyang v1.0.x - # still declares cmake_minimum_required(2.8.12); the policy-version escape - # hatch lets it configure (and is ignored by older CMake). sh( "cmake -DENABLE_LYD_PRIV=ON -DCMAKE_INSTALL_PREFIX:PATH=/usr" - " -DCMAKE_POLICY_VERSION_MINIMUM=3.5" ' -D CMAKE_BUILD_TYPE:String="Release" ..', "make", "make install", @@ -255,6 +206,9 @@ def install_frrouting(output_dir: str): "libsnmp-dev", "pkg-config", "libcap-dev", + "libelf-dev", + "libunwind-dev", + "python3-sphinx", ) elif dist.package_family == "rpm": dist.install( @@ -268,6 +222,8 @@ def install_frrouting(output_dir: str): "net-snmp-devel", "pkgconfig", "libcap-devel", + "elfutils-libelf-devel", + "libunwind-devel", ) install_libyang(output_dir) @@ -276,10 +232,14 @@ def install_frrouting(output_dir: str): if _needs_rebuild(os.path.join(frrouting_install, "sbin", "zebra")): frrouting_src = os.path.join(output_dir, f"frr-{FRRoutingVersion}") frrouting_tar = frrouting_src + ".tar.gz" + # FRRouting no longer ships release assets; use the tag archive. It + # contains no generated configure, so bootstrap it with autotools. sh( - f"wget https://github.com/FRRouting/frr/releases/download/frr-{FRRoutingVersion}/" - f"frr-{FRRoutingVersion}.tar.gz", + f"wget https://github.com/FRRouting/frr/archive/refs/tags/frr-{FRRoutingVersion}.tar.gz" + f" -O '{frrouting_tar}'", f"tar -zxvf '{frrouting_tar}'", + # The tag archive extracts as frr-frr-; normalize it. + f"mv 'frr-frr-{FRRoutingVersion}' 'frr-{FRRoutingVersion}'", cwd=output_dir, ) @@ -290,7 +250,13 @@ def install_frrouting(output_dir: str): filter(None, [python_libdir, env.get("LD_LIBRARY_PATH")]) ) sh( - f"./configure '--prefix={frrouting_install}'", + "./bootstrap.sh", + # protobuf is a build-time experiment requiring protobuf-c headers; + # we do not use the gRPC/protobuf northbound plugin. + # Standard system paths: FRR 9.2+ appends /frr and /run/frr itself. + f"./configure '--prefix={frrouting_install}'" + " --sysconfdir=/etc --localstatedir=/var --runstatedir=/var/run" + " --enable-protobuf=no", "make", "make install", cwd=frrouting_src, diff --git a/ipmininet/router/config/__init__.py b/ipmininet/router/config/__init__.py index 29ddf500..37e5ddeb 100644 --- a/ipmininet/router/config/__init__.py +++ b/ipmininet/router/config/__init__.py @@ -55,7 +55,7 @@ from .ripng import RIPng from .sshd import SSHd from .staticd import STATIC, StaticRoute -from .zebra import Zebra +from .zebra import Mgmtd, Zebra __all__ = [ "AF_INET", @@ -93,6 +93,7 @@ "IPTables", "InputFilter", "InterfaceClause", + "Mgmtd", "NodeConfig", "OSPFArea", "OutputFilter", diff --git a/ipmininet/router/config/bgp.py b/ipmininet/router/config/bgp.py index 97e2d0b2..ebcaddc1 100644 --- a/ipmininet/router/config/bgp.py +++ b/ipmininet/router/config/bgp.py @@ -615,6 +615,15 @@ def STARTUP_LINE_EXTRA(self): """We add the port to the standard startup line""" return f"-p {self.port}" + @property + def dry_run(self): + # FRR 10.7.1's bgpd crashes in the config-check path (-C) when a + # "neighbor ... activate" line is parsed: peer_activate_af() calls + # bgp_keepalives_off() while the keepalive pthread is not running yet + # (bgp_keepalives.c assertion fpt->running). The daemon applies the + # same validation at real startup, so skip the pre-flight check. + return "true" + def __init__(self, node, port=BGP_DEFAULT_PORT, *args, **kwargs): super().__init__(*args, node=node, **kwargs) self.port = port diff --git a/ipmininet/router/config/ripng.py b/ipmininet/router/config/ripng.py index af4c762d..df111602 100644 --- a/ipmininet/router/config/ripng.py +++ b/ipmininet/router/config/ripng.py @@ -6,20 +6,19 @@ from ipmininet.utils import L3Router from .utils import ConfigDict -from .zebra import QuaggaDaemon, Zebra +from .zebra import MgmtdBackendDaemon UPDATE_TIMER = 30 TIMEOUT_TIMER = 180 GARBAGE_TIMER = 120 -class RIPng(QuaggaDaemon): +class RIPng(MgmtdBackendDaemon): """This class provides a simple configuration for an RIP daemon. It advertizes one network per interface (the primary one), and set interfaces not facing another L3Router to passive""" NAME = "ripngd" - DEPENDS = (Zebra,) KILL_PATTERNS = (NAME,) def build(self): diff --git a/ipmininet/router/config/staticd.py b/ipmininet/router/config/staticd.py index f883f36f..5a37786b 100644 --- a/ipmininet/router/config/staticd.py +++ b/ipmininet/router/config/staticd.py @@ -1,11 +1,10 @@ from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network -from ipmininet.router.config.zebra import QuaggaDaemon, Zebra +from ipmininet.router.config.zebra import MgmtdBackendDaemon -class STATIC(QuaggaDaemon): +class STATIC(MgmtdBackendDaemon): NAME = "staticd" - DEPENDS = (Zebra,) KILL_PATTERNS = (NAME,) def build(self): diff --git a/ipmininet/router/config/templates/ospf6d.mako b/ipmininet/router/config/templates/ospf6d.mako index 865deb54..127ca833 100644 --- a/ipmininet/router/config/templates/ospf6d.mako +++ b/ipmininet/router/config/templates/ospf6d.mako @@ -22,6 +22,7 @@ interface ${intf.name} ipv6 ospf6 passive % endif ipv6 ospf6 instance-id ${intf.instance_id} + ipv6 ospf6 area ${intf.area} <%block name="interface"/> ! % endfor @@ -31,9 +32,6 @@ router ospf6 % for r in node.ospf6d.redistribute: redistribute ${r.subtype} % endfor - % for itf in node.ospf6d.interfaces: - interface ${itf.name} area ${itf.area} - % endfor <%block name="router"/> ! diff --git a/ipmininet/router/config/zebra.py b/ipmininet/router/config/zebra.py index d8816716..6901d645 100644 --- a/ipmininet/router/config/zebra.py +++ b/ipmininet/router/config/zebra.py @@ -61,6 +61,49 @@ def dry_run(self): return f"{self.NAME} -Cf {self.cfg_filename} -u root" +class Mgmtd(RouterDaemon): + """The FRR centralized management daemon. + + FRR 9.0+ routes configuration for backend daemons (currently staticd) + through mgmtd; those daemons reject per-daemon config files (-f) and the + config-check flag (-C) entirely, so this daemon is started ahead of them + and the per-node config is pushed afterwards via vtysh. + """ + + NAME = "mgmtd" + PRIO = -10 + DEPENDS = () + KILL_PATTERNS = (NAME,) + + @property + def cfg_filenames(self) -> list[str]: + # mgmtd takes its configuration through vtysh, not a per-daemon file. + return [] + + @property + def startup_line(self): + return "mgmtd -i {pid} -u root".format(pid=self._file("pid")) + + @property + def dry_run(self): + # mgmtd rejects -C; the config is validated when vtysh applies it. + return "true" + + def build(self): + return super().build() + + def set_defaults(self, defaults): + super().set_defaults(defaults) + + def has_started(self, node_exec=None) -> bool: + """Return whether mgmtd is accepting connections, by probing its + frontend unix socket from inside the node (where /var/run/frr is a + private mount).""" + if node_exec is None: + return False + return "mgmtd_fe.sock" in node_exec.call("ls /var/run/frr/") + + class Zebra(QuaggaDaemon): NAME = "zebra" PRIO = 0 @@ -107,6 +150,58 @@ def listening(self) -> bool: return True +class MgmtdBackendDaemon(QuaggaDaemon): + """Base class for FRR daemons that are mgmtd backends. + + Since FRR 9.0 the configuration of these daemons (currently staticd, + ripd and ripngd) is delivered through mgmtd: they reject per-daemon + config files (-f) and the config-check flag (-C) entirely. We start + mgmtd ahead of them (see DEPENDS) and push the rendered config with + vtysh once the daemon is up. + """ + + DEPENDS = (Mgmtd, Zebra) + _config_pushed = False + + @property + def startup_line(self): + return "{name} -i {pid} -z {api} -u root".format( + name=self.NAME, + pid=self._file("pid"), + api=self.zebra_socket, + ) + + @property + def dry_run(self): + # mgmtd backends reject -C; the config is validated when vtysh + # applies it. + return "true" + + def has_started(self, node_exec=None) -> bool: + """Wait until the daemon is up (its vty socket exists), then push its + configuration through mgmtd exactly once via vtysh.""" + if node_exec is None or not self._daemon_up(node_exec): + return False + if self._config_pushed: + return True + self._push_config(node_exec) + self._config_pushed = True + return True + + def _daemon_up(self, node_exec) -> bool: + """Return whether the daemon has created its vty socket (i.e. it is + running and has registered with mgmtd).""" + return f"{self.NAME}.vty" in node_exec.call("ls /var/run/frr/") + + def _push_config(self, node_exec) -> None: + """Load the rendered config into mgmtd with vtysh. + + vtysh reads the config file and distributes this daemon's section + through the mgmtd backend, which then programs the routes. + """ + node_exec.call("vtysh", "-f", self.cfg_filename) + + class CommunityList: """A zebra community-list entry""" diff --git a/ipmininet/tests/test_ospf6.py b/ipmininet/tests/test_ospf6.py index 12d9a219..ea8c2415 100644 --- a/ipmininet/tests/test_ospf6.py +++ b/ipmininet/tests/test_ospf6.py @@ -87,7 +87,7 @@ def test_ospf6_example(): @pytest.mark.parametrize( "node_params,ospf6_params,link_params,exp_cfg,exp_paths", [ - ({}, {}, {}, [" interface r1-eth0 area 0.0.0.0"], unit_igp_cost_paths), + ({}, {}, {}, [" ipv6 ospf6 area 0.0.0.0"], unit_igp_cost_paths), ( {}, {"debug": ["flooding"]}, @@ -100,14 +100,20 @@ def test_ospf6_example(): {}, {}, {"igp_area": "1.1.1.1"}, - [" interface r1-eth0 area 1.1.1.1", " interface lo area 0.0.0.0"], + [ + " ipv6 ospf6 area 1.1.1.1", + " ipv6 ospf6 area 0.0.0.0", + ], high_igp_cost_paths, ), ( {"igp_area": "1.1.1.1"}, {}, {}, - [" interface lo area 1.1.1.1", " interface r1-eth0 area 0.0.0.0"], + [ + " ipv6 ospf6 area 1.1.1.1", + " ipv6 ospf6 area 0.0.0.0", + ], unit_igp_cost_paths, ), ( From 3b55203f7c3c44db8e99c5e0c718ff3d4b9cf8be Mon Sep 17 00:00:00 2001 From: iakov Date: Thu, 3 Sep 2026 01:25:45 +0300 Subject: [PATCH 53/69] feat: migrate ExaBGP to 5.0.13 and install it via pip (#37) * feat(install): migrate ExaBGP to 5.0.13 and install it via pip ExaBGP 5.x moved to a src/ layout and a console script (entry point exabgp.application.main:main); the 4.x zipapp build no longer applies. Install with pip and symlink the entry point to /usr/sbin/exabgp. * fix(exabgp): adapt to the 5.x subcommand CLI ExaBGP 5.0.13 moved to a subcommand interface: the config is validated with `exabgp validate `, the server starts with `exabgp server ` and the env file is passed via --env-file. Update the daemon's startup/dry-run lines and the availability probe, which used the 4.x `--validate`/`--env`/global flags. * refactor(install): install ExaBGP into the uv virtualenv ExaBGP 5.0.13 now rides in the uv-managed venv (/opt/venv) that the container image copies wholesale, so the image no longer needs the /root/exabgp zipapp or a /usr/sbin/exabgp symlink. install_exabgp() prefers 'uv pip install' inside a virtualenv and falls back to the system pip. CI dep caching no longer snapshots exabgp: it is a pure pip install, cheap enough to redo every run. Also fix _needs_rebuild() accepting None paths so the exabgp guard does not raise a TypeError on systems where the entry point is absent. * fix(exabgp): use the CRITICAL log level accepted by ExaBGP 5.x ExaBGP 4.x read syslog priority names for exabgp.log.level (e.g. CRIT), but 5.x validates the value against its Python logging levels and aborts at startup with 'invalid value for log.level : CRIT' when the env file contains one. Since the daemon died before its BGP session with FRRouting was ever set up, the injected routes never reached the RIB and the exabgp tests timed out. Use CRITICAL, and pass the env file to 'exabgp validate' (as the 4.x dry-run did via --validate --env) so a bad env file fails the router config check instead of silently killing the daemon at launch. --- Containerfile | 4 +-- ipmininet/install/install.py | 47 +++++++++++++------------------ ipmininet/router/config/exabgp.py | 12 +++++--- ipmininet/tests/__init__.py | 34 ++++++++++++---------- scripts/ci-restore-deps.sh | 6 ++-- scripts/ci-save-deps.sh | 9 +++--- 6 files changed, 54 insertions(+), 58 deletions(-) diff --git a/Containerfile b/Containerfile index f2ce1088..7403ca24 100644 --- a/Containerfile +++ b/Containerfile @@ -71,10 +71,8 @@ COPY --from=builder /usr/lib/x86_64-linux-gnu/libyang* /usr/lib/x86_64-linux-gnu COPY --from=builder /usr/include/libyang /usr/include/ COPY --from=builder /usr/share/yang/modules/libyang /usr/share/yang/modules/ COPY --from=builder /usr/bin/yanglint /usr/bin/yanglint -# mininet mnexec and exabgp. +# mininet mnexec (exabgp rides in the /opt/venv copied below). COPY --from=builder /usr/local/bin/mnexec /usr/local/bin/mnexec -COPY --from=builder /root/exabgp /root/exabgp -COPY --from=builder /usr/sbin/exabgp /usr/sbin/exabgp # mimidump. COPY --from=builder /usr/local/bin/mimidump /usr/local/bin/mimidump # The uv-managed virtualenv and its CPython toolchain (the venv's diff --git a/ipmininet/install/install.py b/ipmininet/install/install.py index a22ffca7..e7490d04 100644 --- a/ipmininet/install/install.py +++ b/ipmininet/install/install.py @@ -6,12 +6,12 @@ # For imports to work during setup and afterwards sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from utils import identify_distribution, sh, supported_distributions +from utils import find_executable, identify_distribution, sh, supported_distributions MininetVersion = "2.3.0" FRRoutingVersion = "10.7.1" LibyangVersion = "v3.13.6" -ExaBGPVersion = "4.2.25" +ExaBGPVersion = "5.0.13" # XXX: We need the explicit script until the following issue is fixed: # https://github.com/mininet/mininet/issues/1120 @@ -20,12 +20,12 @@ os.environ["PATH"] = "{}:/sbin:/usr/sbin/:/usr/local/sbin".format(os.environ["PATH"]) -def _needs_rebuild(*paths: str) -> bool: +def _needs_rebuild(*paths: str | None) -> bool: """Return True when a component must be (re)built: one of its artifacts is missing or the user requested a forced rebuild (IPMININET_FORCE_INSTALL=1). """ return os.environ.get("IPMININET_FORCE_INSTALL") == "1" or not all( - os.path.exists(p) for p in paths + p is not None and os.path.exists(p) for p in paths ) @@ -277,35 +277,26 @@ def install_frrouting(output_dir: str): def install_exabgp(output_dir: str, may_fail=False): - git_url = "https://github.com/Exa-Networks/exabgp.git" - exabgp_src_folder = f"exabgp-{ExaBGPVersion}-src" - exabgp_path_src_dir = os.path.join(output_dir, exabgp_src_folder) - exabgp_self_executable = os.path.join(output_dir, "exabgp") - final_link = "/usr/sbin/exabgp" - - if not _needs_rebuild(exabgp_self_executable, final_link): + # ExaBGP 5.x is a src/ layout Python package installed with pip; its + # console script lands in the interpreter's bin dir and is already on + # PATH, so nothing more is needed. Inside a uv-managed virtualenv (CI and + # the container build) it is installed into that env with `uv pip`, so the + # runtime image inherits it together with the rest of the venv. + if not _needs_rebuild(exabgp_executable()): print("IPMininet: ExaBGP already installed; skipping build") return - sh( - f"rm -rf {exabgp_src_folder}", - f"git clone {git_url} {exabgp_src_folder}", - cwd=output_dir, - may_fail=may_fail, - ) - - sh(f"git checkout {ExaBGPVersion}", cwd=exabgp_path_src_dir, may_fail=may_fail) + if find_executable("uv") and os.environ.get("VIRTUAL_ENV"): + sh(f"uv pip install -q exabgp=={ExaBGPVersion}", may_fail=may_fail) + else: + dist.pip_install(f"exabgp=={ExaBGPVersion}", may_fail=may_fail) + if not exabgp_executable(): + print("WARNING: pip did not install the exabgp entry point.", file=sys.stderr) - # create self-contained executable - sh( - f'python3 -m zipapp -o {exabgp_self_executable} -m exabgp.application:main -p "/usr/bin/env python3" lib', - cwd=exabgp_path_src_dir, - may_fail=may_fail, - ) - if os.path.lexists(final_link): - os.remove(final_link) - os.symlink(exabgp_self_executable, final_link) +def exabgp_executable() -> str | None: + """Return the path to the exabgp console script, if installed.""" + return find_executable("exabgp") def update_grub(): diff --git a/ipmininet/router/config/exabgp.py b/ipmininet/router/config/exabgp.py index 818fdf06..4be0d73c 100644 --- a/ipmininet/router/config/exabgp.py +++ b/ipmininet/router/config/exabgp.py @@ -311,11 +311,15 @@ def template_filenames(self): @property def startup_line(self) -> str: - return f"{self.NAME} --env {self.env_filename} {self.cfg_filename}" + # ExaBGP 5.x moved to a subcommand CLI: the env file is passed with + # --env-file and the config is the trailing argument. + return f"{self.NAME} --env-file {self.env_filename} server {self.cfg_filename}" @property def dry_run(self) -> str: - return f"{self.NAME} --validate --env {self.env_filename} {self.cfg_filename}" + return ( + f"{self.NAME} --env-file {self.env_filename} validate {self.cfg_filename}" + ) def set_defaults(self, defaults): """ @@ -336,7 +340,7 @@ def set_defaults(self, defaults): * daemon.pid = .pid> - * log.level = 'CRIT' + * log.level = 'CRITICAL' * log.destination = .log> * log.reactor = 'false' @@ -358,7 +362,7 @@ def set_defaults(self, defaults): user="root", drop="false", daemonize="false", pid=self._file("pid") ), log=ConfigDict( - level="CRIT", + level="CRITICAL", destination=self._file("log"), reactor="false", processes="false", diff --git a/ipmininet/tests/__init__.py b/ipmininet/tests/__init__.py index 82d40128..6a556c23 100644 --- a/ipmininet/tests/__init__.py +++ b/ipmininet/tests/__init__.py @@ -28,24 +28,28 @@ def _exabgp_usable() -> bool: """Return whether a working ExaBGP daemon is available. ExaBGP 4.2.11 shipped a vendored six too old for Python 3.12 (broken - ``six.moves``), so simply being on PATH is not enough: the daemon must - also start (--version) cleanly. + ``six.moves``); ExaBGP 5.x runs on Python 3 but moved to a subcommand + CLI. Either way, simply being on PATH is not enough: the daemon must + also report its version cleanly. """ if not has_cmd("exabgp"): return False - try: - return ( - subprocess.run( - ["exabgp", "--version"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=10, - check=False, - ).returncode - == 0 - ) - except (OSError, subprocess.SubprocessError): - return False + for probe in (["exabgp", "version"], ["exabgp", "--version"]): + try: + if ( + subprocess.run( + probe, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ).returncode + == 0 + ): + return True + except (OSError, subprocess.SubprocessError): + pass + return False require_exabgp = pytest.mark.skipif( diff --git a/scripts/ci-restore-deps.sh b/scripts/ci-restore-deps.sh index 40e66cba..ef5cf8c2 100755 --- a/scripts/ci-restore-deps.sh +++ b/scripts/ci-restore-deps.sh @@ -3,7 +3,8 @@ # $CI_DEPS_DIR (default: $HOME/ci-deps) into their install locations so the # install step can skip recompiling (see the guards in ipmininet/install/install.py). # The FRRouting symlinks under /usr/sbin and /usr/bin are recreated by the -# install step itself. Run as the (non-root) runner user before the install step. +# install step itself. ExaBGP 5.x is pip-installed and is not cached here. +# Run as the (non-root) runner user before the install step. set -euo pipefail SRC="${CI_DEPS_DIR:-$HOME/ci-deps}" [ -d "$SRC" ] || { echo "no compiled deps cache at $SRC"; exit 0; } @@ -16,6 +17,3 @@ echo "==> Restoring compiled dependencies from $SRC" [ -f "$SRC/usr.tar" ] && (cd / && $SUDO tar -xf "$SRC/usr.tar") [ -f "$SRC/libyang.tar" ] && (cd / && $SUDO tar -xf "$SRC/libyang.tar") [ -f "$SRC/root.tar" ] && $SUDO tar -C /root -xf "$SRC/root.tar" - -# Recreate the /usr/sbin/exabgp symlink (points at the /root/exabgp zipapp) -$SUDO ln -sfn /root/exabgp /usr/sbin/exabgp \ No newline at end of file diff --git a/scripts/ci-save-deps.sh b/scripts/ci-save-deps.sh index b02e918f..8e3ce1d2 100755 --- a/scripts/ci-save-deps.sh +++ b/scripts/ci-save-deps.sh @@ -2,7 +2,8 @@ # Snapshot the compiled system dependencies produced by `ipmininet.install -a` # into $CI_DEPS_DIR (default: $HOME/ci-deps) so a later CI run can restore them # (ci-restore-deps.sh) and the install step skips recompiling FRRouting, -# libyang, mininet and exabgp (see the guards in ipmininet/install/install.py). +# libyang and mininet (see the guards in ipmininet/install/install.py). +# ExaBGP 5.x is pip-installed and is not cached here. # Run as the (non-root) runner user after the install step. set -euo pipefail shopt -s nullglob @@ -15,15 +16,15 @@ mkdir -p "$DEST" # apt archives, so apt-get in the install step reuses .debs instead of downloading $SUDO cp -a /var/cache/apt/archives "$DEST/apt-archives" -# System-installed artifacts: libyang, the mnexec binary and the /usr/sbin/exabgp symlink. +# System-installed artifacts: libyang and the mnexec binary. # Glob expansion happens in a subshell rooted at / so tar stores relative members. ( cd / - $SUDO tar -cf "$DEST/usr.tar" usr/bin/yanglint usr/local/bin/mnexec usr/sbin/exabgp || true + $SUDO tar -cf "$DEST/usr.tar" usr/bin/yanglint usr/local/bin/mnexec || true $SUDO tar -cf "$DEST/libyang.tar" \ usr/lib/libyang* usr/lib/*/libyang* \ usr/include/libyang* usr/share/libyang* || true ) # Per-run install prefix ($HOME of the sudo install step, e.g. /root) -$SUDO tar -C /root -cf "$DEST/root.tar" frr exabgp || true \ No newline at end of file +$SUDO tar -C /root -cf "$DEST/root.tar" frr || true From 030c65912d61226ce2744e4066767ce600330b4f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:28:48 +0300 Subject: [PATCH 54/69] build(deps): bump the actions-dependencies group across 1 directory with 2 updates (#34) Bumps the actions-dependencies group with 2 updates in the / directory: [actions/upload-artifact](https://github.com/actions/upload-artifact) and [codecov/codecov-action](https://github.com/codecov/codecov-action). Updates `actions/upload-artifact` from 4 to 7 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) Updates `codecov/codecov-action` from 4 to 7 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-dependencies - dependency-name: codecov/codecov-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yaml | 2 +- .github/workflows/heavy-test.yaml | 4 ++-- .github/workflows/test.yaml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 744ae44c..cc697fdf 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -28,7 +28,7 @@ jobs: - name: Build documentation run: uv run --group docs sphinx-build -b html -W docs docs/_build/html - name: Upload documentation - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ipmininet-docs path: docs/_build/html diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index 7a818241..f9624d2b 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -60,7 +60,7 @@ jobs: uv run coverage html -d htmlcov ' - name: Upload coverage report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-full path: | @@ -70,7 +70,7 @@ jobs: if: env.CODECOV_TOKEN != '' env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: files: coverage.xml flags: full diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ff38c740..2823acbc 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -49,7 +49,7 @@ jobs: ipmininet/tests/test_link.py \ ipmininet/tests/test_network_capture.py - name: Upload coverage report (rootless) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-rootless path: | @@ -59,7 +59,7 @@ jobs: if: env.CODECOV_TOKEN != '' env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: files: coverage.xml flags: rootless From ad3713a997409a7af659b00bad8b4b227b6c9645 Mon Sep 17 00:00:00 2001 From: iakov Date: Thu, 3 Sep 2026 09:32:50 +0300 Subject: [PATCH 55/69] ci: run the bare-metal suite on pull requests only (#39) test.yaml, heavy-test.yaml and container-test.yaml all ran the same full test suite on every master push, and master only changes through merged PRs whose checks already ran all three. Restrict test.yaml to pull requests: master pushes now run the coverage-gated heavy-test job plus container-test, dropping the duplicated bare-metal run (~23 min/push). Also refresh the stale heavy-test header, which still described ExaBGP as a known-broken daemon skipped in its own test module. --- .github/workflows/heavy-test.yaml | 8 +++++--- .github/workflows/test.yaml | 9 ++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index f9624d2b..8d252336 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -1,7 +1,9 @@ -# Full test suite, run on master and on demand. +# Full test suite with coverage, run on master and on demand. # -# Performs the full `ipmininet.install -a` and runs every test except the -# known-broken exabgp daemon, which is skipped in its own test module. +# Performs the full `ipmininet.install -a` and runs every test with the +# coverage gate. It complements `test.yaml`, which runs the same suite on pull +# requests: master only changes through merged PRs, so this job is the single +# bare-metal full-suite run per master push and enforces the coverage floor. # Lint/format checks are run in the `rootless` job of test.yaml. name: Heavy test on: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2823acbc..11a78e47 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,4 +1,4 @@ -# Bare-metal CI. +# Bare-metal CI, run on pull requests. # # Two jobs: # - rootless: installs the venv with uv and runs the test suite as the @@ -8,11 +8,14 @@ # mininet, ...) and runs the complete test suite with root privileges. # # Lint/format checks run in the `rootless` job below, before the light tests. +# +# This workflow only runs on pull requests: master changes arrive through +# merged PRs whose checks already ran here, so re-running this suite on every +# master push would duplicate the coverage-gated `heavy-test.yaml` job. Master +# pushes keep that job plus `container-test.yaml` and `docs.yaml`. name: Test IPMininet on: pull_request: - push: - branches: [master] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true From 55e79f3e45cb68b49354897d77e02a962d902251 Mon Sep 17 00:00:00 2001 From: iakov Date: Thu, 3 Sep 2026 15:21:56 +0300 Subject: [PATCH 56/69] test(srv6): wait for IPv6 connectivity before measuring paths (#38) test(srv6): wait for the measured IPv6 paths to converge The path assertions measure IPv6 routes, but the test only waited for IPv4 connectivity first. Under parallel CI load OSPFv6 could still be settling when sr_path started probing, so it returned an empty path and the test flaked on the routesN parametrizations. Only the endpoints exercised by each assertion are guaranteed to become reachable over IPv6 in this topology, so wait for the exact src/dst pair each path measures instead of asserting full IPv6 host connectivity. --- ipmininet/tests/test_srv6.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/ipmininet/tests/test_srv6.py b/ipmininet/tests/test_srv6.py index 6a60f462..9f0474cc 100644 --- a/ipmininet/tests/test_srv6.py +++ b/ipmininet/tests/test_srv6.py @@ -285,6 +285,27 @@ def _pcap_size(node): return path +def _wait_v6_connectivity(net: IPNet, src: str, dst_ip: str, timeout=120) -> None: + """Wait until ``src`` can ping ``dst_ip`` over IPv6. + + The path assertions below measure IPv6 routes, but the routing only + converges after the IPv4 one asserted by ``assert_connectivity``: under + parallel CI load OSPFv6 could still be settling when ``sr_path`` started + probing, which made it return an empty path and flaked the test. + """ + + def _connected() -> bool: + out = net[src].cmd(shlex.split(f"ping -6 -c 1 -W 1 {dst_ip}")) + return ", 0% packet loss" in out + + wait_until( + _connected, + timeout=timeout, + interval=1, + description=lambda: f"IPv6 connectivity from {src} to {dst_ip} to converge", + ) + + @require_root @pytest.mark.parametrize( "routes,paths,through", @@ -436,6 +457,8 @@ def test_static_examples(routes, paths, through): assert_connectivity(net, v6=False) for i, p in enumerate(paths): + dst_ip = net[p[-1]].defaultIntf().ip6 + _wait_v6_connectivity(net, p[0], dst_ip) assert_path( net, p, From 2e518a4217797c6054200163947dc0767771b3ab Mon Sep 17 00:00:00 2001 From: iakov Date: Fri, 4 Sep 2026 01:42:56 +0300 Subject: [PATCH 57/69] test(iptables): poll for the IPv4 ping instead of a single-shot assert (#40) --- ipmininet/tests/test_iptables.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/ipmininet/tests/test_iptables.py b/ipmininet/tests/test_iptables.py index b193ae97..9fe2eb5b 100644 --- a/ipmininet/tests/test_iptables.py +++ b/ipmininet/tests/test_iptables.py @@ -16,11 +16,24 @@ def test_iptables_example(): ip = net["r2"].intf("r2-eth0").ip cmd = f"ping -W 1 -c 1 {ip}" - p = net["r1"].popen(cmd.split(" ")) - out, err = p.communicate() - ret = p.poll() - assert ret == 0, ( - f"Pings over IPv4 should not be blocked.\n[stdout]\n{out}\n[stderr]\n{err}" + last_out = "" + last_err = "" + + def _ipv4_ping_ok(): + nonlocal last_out, last_err + p = net["r1"].popen(cmd.split(" ")) + ret = p.wait() + last_out, last_err = p.communicate() + return ret == 0 + + wait_until( + _ipv4_ping_ok, + timeout=30, + interval=0.5, + description=lambda: ( + "IPv4 pings from {} to {} to not be blocked\n" + "[stdout]\n{}\n[stderr]\n{}".format(net["r1"], ip, last_out, last_err) + ), ) ip6 = net["r2"].intf("r2-eth0").ip6 From 13c01c000fd58904ff0cf3beba68af1a834380b7 Mon Sep 17 00:00:00 2001 From: iakov Date: Fri, 4 Sep 2026 11:38:50 +0300 Subject: [PATCH 58/69] ci(coverage): raise full-suite gate to 85 and clear product PLR2004 ignores (#41) ci(coverage): raise gate to 85 and clear product PLR2004 ignores Raise the full-suite coverage floor from 84 to 85%. To make room for removing PLR2004 per-file ignores, introduce IP_V4/IP_V6 constants in ipmininet/utils.py and use them for the product IP-version comparisons in named, ipnet, link, router/config/utils, and zebra. Give the legacy LinkDescription integer indexes (0/1/3) named constants as well. Delete now-dead ignore entries (install.py E501+TRY003, test_gre/test_sshd PLR2004) that had zero live violations. --- ipmininet/host/config/named.py | 6 +++--- ipmininet/ipnet.py | 6 ++++-- ipmininet/link.py | 6 +++--- ipmininet/node_description.py | 12 +++++++++--- ipmininet/router/config/utils.py | 4 +++- ipmininet/router/config/zebra.py | 6 ++++-- ipmininet/utils.py | 4 ++++ pyproject.toml | 16 ++++++---------- 8 files changed, 36 insertions(+), 24 deletions(-) diff --git a/ipmininet/host/config/named.py b/ipmininet/host/config/named.py index 84e6ae7a..b1f3c576 100644 --- a/ipmininet/host/config/named.py +++ b/ipmininet/host/config/named.py @@ -11,7 +11,7 @@ from ipmininet.overlay import Overlay from ipmininet.router.config.utils import ConfigDict -from ipmininet.utils import find_node, has_cmd, realIntfList +from ipmininet.utils import IP_V4, IP_V6, find_node, has_cmd, realIntfList from .base import HostDaemon @@ -293,7 +293,7 @@ def __hash__(self): class ARecord(DNSRecord): def __init__(self, domain_name, address: str | IPv4Address | IPv6Address, ttl=60): self.address = ip_address(str(address)) - rtype = "A" if self.address.version == 4 else "AAAA" + rtype = "A" if self.address.version == IP_V4 else "AAAA" super().__init__(rtype=rtype, domain_name=domain_name, ttl=ttl) @property @@ -318,7 +318,7 @@ def __init__( @property def v6(self): - return self.address.version == 6 + return self.address.version == IP_V6 @property def rdata(self): diff --git a/ipmininet/ipnet.py b/ipmininet/ipnet.py index 05abbce2..fea9b2ca 100644 --- a/ipmininet/ipnet.py +++ b/ipmininet/ipnet.py @@ -28,6 +28,8 @@ from .router import Router from .router.config import BasicRouterConfig, RouterConfig from .utils import ( + IP_V4, + IP_V6, L3Router, address_pair, has_cmd, @@ -841,8 +843,8 @@ def use_ip_version(self, ip_version) -> bool: enabling this IP version """ for i in self.interfaces: - if (i.node.use_v4 and ip_version == 4) or ( - i.node.use_v6 and ip_version == 6 + if (i.node.use_v4 and ip_version == IP_V4) or ( + i.node.use_v6 and ip_version == IP_V6 ): return True return False diff --git a/ipmininet/link.py b/ipmininet/link.py index 75eb4f17..24f8330a 100644 --- a/ipmininet/link.py +++ b/ipmininet/link.py @@ -15,7 +15,7 @@ from mininet.node import Node from . import MIN_IGP_METRIC, OSPF_DEFAULT_AREA -from .utils import is_container, otherIntf +from .utils import IP_V4, IP_V6, is_container, otherIntf class IPIntf(_m.TCIntf): @@ -200,10 +200,10 @@ def _set_ip( # Prepare assignment commands cmds.append(f"ip address add dev {self.name} {addr.with_prefixlen}") # Record assignment family - if addr.version == 4: + if addr.version == IP_V4: setv4 = True lb_v4_update = addr.is_loopback or lb_v4_update - elif addr.version == 6: + elif addr.version == IP_V6: setv6 = True lb_v6_update = addr.is_loopback or lb_v6_update # Clean-up old addresses diff --git a/ipmininet/node_description.py b/ipmininet/node_description.py index 393ee35b..6d7bea7e 100644 --- a/ipmininet/node_description.py +++ b/ipmininet/node_description.py @@ -74,6 +74,12 @@ def addDaemon( @functools.total_ordering class LinkDescription: + # Integer indexes used to access the two node interfaces and the key + # of a link description, mirroring mininet.topo.Topo indexing. + SRC_INDEX = 0 + DST_INDEX = 1 + KEY_INDEX = 3 + def __init__(self, topo: "IPTopo", src: str, dst: str, key, link_attrs: dict): self.src = src self.dst = dst @@ -89,11 +95,11 @@ def __init__(self, topo: "IPTopo", src: str, dst: str, key, link_attrs: dict): def __getitem__(self, item): if isinstance(item, int): - if item == 0: + if item == self.SRC_INDEX: return self.src_intf - if item == 1: + if item == self.DST_INDEX: return self.dst_intf - if item == 3: + if item == self.KEY_INDEX: return self.key raise IndexError("Links have only two nodes and one key") diff --git a/ipmininet/router/config/utils.py b/ipmininet/router/config/utils.py index 81d44426..b7184ead 100644 --- a/ipmininet/router/config/utils.py +++ b/ipmininet/router/config/utils.py @@ -2,6 +2,8 @@ from ipaddress import IPv4Address, IPv6Address, ip_interface +from ipmininet.utils import IP_V6 + class ConfigDict(dict): """A dictionary whose attributes are its keys. @@ -35,4 +37,4 @@ def ip_statement(ip: int | str | IPv6Address | IPv4Address): :type ip: ip_interface, ip_network, ip_address, int, str""" if not isinstance(ip, int): ip = ip_interface(str(ip)).version - return "ipv6" if ip == 6 else "ip" + return "ipv6" if ip == IP_V6 else "ip" diff --git a/ipmininet/router/config/zebra.py b/ipmininet/router/config/zebra.py index 6901d645..f7f9a5a9 100644 --- a/ipmininet/router/config/zebra.py +++ b/ipmininet/router/config/zebra.py @@ -5,6 +5,8 @@ from ipaddress import IPv4Network, IPv6Network, ip_network from typing import Union +from ipmininet.utils import IP_V4, IP_V6 + from .base import RouterDaemon from .utils import ConfigDict @@ -15,9 +17,9 @@ def get_family(prefix: str | IPv4Network | IPv6Network) -> str | None: pfx = ip_network(prefix) if isinstance(prefix, str) else prefix - if pfx.version == 4: + if pfx.version == IP_V4: return "ipv4" - if pfx.version == 6: + if pfx.version == IP_V6: return "ipv6" return None diff --git a/ipmininet/utils.py b/ipmininet/utils.py index 8cba0ea3..1f303ea0 100644 --- a/ipmininet/utils.py +++ b/ipmininet/utils.py @@ -9,6 +9,10 @@ from mininet.log import lg as log from mininet.node import Node +# Magic values for the IP protocol version +IP_V4 = 4 +IP_V6 = 6 + if TYPE_CHECKING: from ipmininet.link import IPIntf diff --git a/pyproject.toml b/pyproject.toml index 87f656cb..a0264cb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ omit = [ [tool.coverage.report] show_missing = true skip_covered = true -fail_under = 84 +fail_under = 85 [tool.ipmininet] duplication_max = 51 @@ -111,7 +111,6 @@ select = [ # E501: lines that cannot be reflowed (ASCII-art diagram, shell command # strings, a literal `ip addr` output fixture). "ipmininet/examples/simple_ospf_network.py" = ["E501"] -"ipmininet/install/install.py" = ["E501", "TRY003"] "ipmininet/tests/test_pure.py" = ["E501"] # Architectural rules deferred for existing files (see NOTES.md): # - PLC0415: intentional circular-import avoidance @@ -121,29 +120,26 @@ select = [ "ipmininet/examples/exabgp_prefix_injector.py" = ["PLR2004"] "ipmininet/examples/ripng_network_adjust.py" = ["PLR0913", "PLR0917"] "ipmininet/examples/spanning_tree_adjust.py" = ["PLR0913", "PLR0917"] -"ipmininet/host/config/named.py" = ["PLR0912", "PLR0913", "PLR0917", "PLR2004"] +"ipmininet/host/config/named.py" = ["PLR0912", "PLR0913", "PLR0917"] "ipmininet/install/utils.py" = ["PLC0415", "TRY003"] -"ipmininet/ipnet.py" = ["PLR0912", "PLR0913", "PLR0917", "PLR2004", "TRY003"] +"ipmininet/ipnet.py" = ["PLR0912", "PLR0913", "PLR0917", "TRY003"] "ipmininet/ipovs_switch.py" = ["PLR0913", "PLR0917", "TRY003"] "ipmininet/ipswitch.py" = ["PLR0913", "PLR0917"] "ipmininet/iptopo.py" = ["TRY003"] -"ipmininet/link.py" = ["PLR0911", "PLR2004", "TRY003"] -"ipmininet/node_description.py" = ["PLR2004", "TRY003"] +"ipmininet/link.py" = ["PLR0911", "TRY003"] +"ipmininet/node_description.py" = ["TRY003"] "ipmininet/overlay.py" = ["TRY003"] "ipmininet/router/__router.py" = ["PLR0913", "PLR0917"] "ipmininet/router/config/base.py" = ["PLC0415", "TRY003"] "ipmininet/router/config/bgp.py" = ["PLR0913", "PLR0917", "TRY003"] "ipmininet/router/config/exabgp.py" = ["TRY003"] "ipmininet/router/config/iptables.py" = ["TRY003"] -"ipmininet/router/config/utils.py" = ["PLR2004"] -"ipmininet/router/config/zebra.py" = ["PLR0913", "PLR0917", "PLR2004", "TRY003"] +"ipmininet/router/config/zebra.py" = ["PLR0913", "PLR0917", "TRY003"] "ipmininet/srv6.py" = ["PLR0913", "PLR0917", "PLR2004", "TRY003"] -"ipmininet/tests/test_gre.py" = ["PLR2004"] "ipmininet/tests/test_link.py" = ["PLR2004"] "ipmininet/tests/test_misc.py" = ["PLR2004"] "ipmininet/tests/test_radv.py" = ["TRY003"] "ipmininet/tests/test_srv6.py" = ["PLR0912", "PLR0915", "PLR2004"] -"ipmininet/tests/test_sshd.py" = ["PLR2004"] "ipmininet/tests/test_tc.py" = ["PLR2004"] "ipmininet/tests/utils.py" = ["PLR0913", "PLR0917", "PLR2004", "TRY003"] "ipmininet/topologydb.py" = ["TRY003"] From ca5cc37f44f4c7ce65df03ba0bd16b75966f59bd Mon Sep 17 00:00:00 2001 From: iakov Date: Fri, 4 Sep 2026 18:47:17 +0300 Subject: [PATCH 59/69] refactor(lint): name all remaining magic comparison constants (#42) Replace every remaining PLR2004 (magic-value-comparison) violation in product code, tests and examples with named constants, and drop the PLR2004 per-file ignores so the rule now covers the whole tree: - srv6.py: lowest reserved Linux table number (MIN_RESERVED_TABLE) - examples/exabgp_prefix_injector.py: max prefixes per AFI - tests/test_link.py: default/updated IPv4 and IPv6 prefix lengths - tests/test_misc.py: MAC length plus utils.IP_V4/IP_V6 reuse - tests/test_srv6.py: probe-grouping window and tshark field count - tests/test_tc.py: ping count and required matching samples - tests/utils.py: identical traceroutes before declaring convergence No per-file-ignores for PLR2004 remain, so any new magic constant in a comparison now fails ruff check in CI and pre-commit. --- ipmininet/examples/exabgp_prefix_injector.py | 4 ++- ipmininet/srv6.py | 6 +++- ipmininet/tests/test_link.py | 33 ++++++++++++++------ ipmininet/tests/test_misc.py | 9 ++++-- ipmininet/tests/test_srv6.py | 9 ++++-- ipmininet/tests/test_tc.py | 11 +++++-- ipmininet/tests/utils.py | 6 +++- pyproject.toml | 11 ++----- 8 files changed, 60 insertions(+), 29 deletions(-) diff --git a/ipmininet/examples/exabgp_prefix_injector.py b/ipmininet/examples/exabgp_prefix_injector.py index af4f618f..9a17206a 100644 --- a/ipmininet/examples/exabgp_prefix_injector.py +++ b/ipmininet/examples/exabgp_prefix_injector.py @@ -27,6 +27,8 @@ __MAX_UINT128_ = 340282366920938463463374607431768211455 __MAX_UINT32_ = 4294967295 __MAX_UINT16_ = 65535 +# Maximum number of prefixes to generate and inject per address family +MAX_PREFIXES_PER_AFI = 5 _1_0_0_0 = 16777216 # int repr of IPv4 address 1.0.0.0 _233_255_255_255 = 3758096383 @@ -203,7 +205,7 @@ def __init__( for afi in ("ipv4", "ipv6"): prefixes = set() for x in itertools.takewhile( - lambda _, prefixes=prefixes: len(prefixes) <= 5, + lambda _, prefixes=prefixes: len(prefixes) <= MAX_PREFIXES_PER_AFI, gen_ip_prefix(afi), ): prefixes.add(x) diff --git a/ipmininet/srv6.py b/ipmininet/srv6.py index e4872db0..611a1f56 100644 --- a/ipmininet/srv6.py +++ b/ipmininet/srv6.py @@ -21,6 +21,10 @@ from .router import IPNode from .utils import address_pair, realIntfList +# Lowest reserved Linux routing table number (253, 254 and 255 are the +# default, main and local tables), hence the highest usable value is 252. +MIN_RESERVED_TABLE = 253 + def enable_srv6(node: IPNode): """ @@ -110,7 +114,7 @@ def __init__( self.num = 1 while self.num in tables: self.num += 1 - if self.num >= 253: + if self.num >= MIN_RESERVED_TABLE: raise RuntimeError("Cannot find a free table number on the host") # Create the table diff --git a/ipmininet/tests/test_link.py b/ipmininet/tests/test_link.py index dcd6c3e3..90b2e6f8 100644 --- a/ipmininet/tests/test_link.py +++ b/ipmininet/tests/test_link.py @@ -8,6 +8,13 @@ from ipmininet.link import OrderedAddress from ipmininet.tests import require_root +# Default prefix lengths when setting an address, and the lengths we update +# them to in test_addr_intf. +DEFAULT_IPV4_PREFIXLEN = 24 +UPDATED_IPV4_PREFIXLEN = 28 +DEFAULT_IPV6_PREFIXLEN = 64 +UPDATED_IPV6_PREFIXLEN = 48 + @pytest.mark.parametrize( "unsorted_list,sorted_list", @@ -73,22 +80,28 @@ def test_addr_intf(): # Check IP6 update itf.ip6 = "2001:21::1" ip6s = list(itf.ip6s(exclude_lls=True)) - assert len(ip6s) == 1 and ip6s[0].with_prefixlen == "2001:21::1/64", ( - "Cannot update an IPv6 address" + assert ( + len(ip6s) == 1 + and ip6s[0].with_prefixlen == f"2001:21::1/{DEFAULT_IPV6_PREFIXLEN}" + ), "Cannot update an IPv6 address" + assert itf.prefixLen6 == DEFAULT_IPV6_PREFIXLEN + itf.prefixLen6 = UPDATED_IPV6_PREFIXLEN + assert itf.prefixLen6 == UPDATED_IPV6_PREFIXLEN, ( + "Cannot update prefix len of an IPv6 address" ) - assert itf.prefixLen6 == 64 - itf.prefixLen6 = 48 - assert itf.prefixLen6 == 48, "Cannot update prefix len of an IPv6 address" # Check IP update itf.ip = "10.1.2.1" ips = list(itf.ips()) - assert len(ips) == 1 and ips[0].with_prefixlen == "10.1.2.1/24", ( - "Cannot update an IPv4 address" + assert ( + len(ips) == 1 + and ips[0].with_prefixlen == f"10.1.2.1/{DEFAULT_IPV4_PREFIXLEN}" + ), "Cannot update an IPv4 address" + assert itf.prefixLen == DEFAULT_IPV4_PREFIXLEN + itf.prefixLen = UPDATED_IPV4_PREFIXLEN + assert itf.prefixLen == UPDATED_IPV4_PREFIXLEN, ( + "Cannot update prefix len of an IPv4 address" ) - assert itf.prefixLen == 24 - itf.prefixLen = 28 - assert itf.prefixLen == 28, "Cannot update prefix len of an IPv4 address" # Check MAC getters assert itf.updateMAC() == itf.updateAddr()[1], ( diff --git a/ipmininet/tests/test_misc.py b/ipmininet/tests/test_misc.py index b17a0aef..8ad23a92 100644 --- a/ipmininet/tests/test_misc.py +++ b/ipmininet/tests/test_misc.py @@ -13,6 +13,9 @@ from . import require_root +# An Ethernet MAC address is made of 6 octets +MAC_LEN = 6 + @pytest.mark.parametrize( "address", ["::/0", "0.0.0.0/0", "1.2.3.0/24", "2001:db8:1234::/48"] @@ -74,15 +77,15 @@ def test_ip_address_format(): ) mac, v4, v6 = _parse_addresses(out) assert mac is not None - assert len(mac.split(":")) == 6 + assert len(mac.split(":")) == MAC_LEN assert mac in out assert len(v4) > 0 for a in v4: - assert a.version == 4 + assert a.version == utils.IP_V4 assert a.with_prefixlen in out assert len(v6) > 0 for a in v6: - assert a.version == 6 + assert a.version == utils.IP_V6 assert a.with_prefixlen in out # IF status, MAC, inet, valid, inet, valid, ..., inet6, valid, ... assert len(out.strip("\n").split("\n")) == (2 + 2 * len(v4) + 2 * len(v6)) diff --git a/ipmininet/tests/test_srv6.py b/ipmininet/tests/test_srv6.py index 9f0474cc..e74c2cd7 100644 --- a/ipmininet/tests/test_srv6.py +++ b/ipmininet/tests/test_srv6.py @@ -25,6 +25,11 @@ from ipmininet.utils import require_cmd MAIN_TABLE = 254 +# Two ICMP echo-request probes are considered part of the same traversal if +# they are captured within this many seconds of each other on a node. +PROBE_GROUP_WINDOW_S = 0.25 +# tshark field list length (icmpv6.type, ipv6.dst, frame.time_epoch) +TSHARK_FIELDS = 3 class SRv6TestTopo(SRv6Topo): @@ -102,7 +107,7 @@ def _infer_sub_paths( ordered = sorted(events) probes = [] # type: List[List[Tuple[float, str]]] for capture_time, node in ordered: - if probes and capture_time - probes[-1][-1][0] < 0.25: + if probes and capture_time - probes[-1][-1][0] < PROBE_GROUP_WINDOW_S: probes[-1].append((capture_time, node)) else: probes.append([(capture_time, node)]) @@ -213,7 +218,7 @@ def _pcap_size(node): data = out.split("\n")[1:-1] for line in data: values = line.strip().split("\t") - if len(values) < 3: + if len(values) < TSHARK_FIELDS: continue icmp_type = values[0] # On newer tshark, ipv6.dst lists every IPv6 header of an diff --git a/ipmininet/tests/test_tc.py b/ipmininet/tests/test_tc.py index 83ceaad8..11457de4 100644 --- a/ipmininet/tests/test_tc.py +++ b/ipmininet/tests/test_tc.py @@ -18,6 +18,11 @@ delay_regex = re.compile(r"time=(\d+\.?\d*) ms") +# Number of probes sent to measure a link, and how many of them must fall +# within tolerance for the assertion to hold. +PING_COUNT = 10 +MIN_MATCHING_SAMPLES = PING_COUNT // 2 + def assert_delay( src: IPNode, dst: IPNode, delay_target: float, tolerance=1.5, v6=False @@ -28,7 +33,7 @@ def assert_delay( help_str=f"{executable} is required to run tests", ) - cmd = f"{executable} -c 10 {dst.intf().ip6 if v6 else dst.intf().ip}" + cmd = f"{executable} -c {PING_COUNT} {dst.intf().ip6 if v6 else dst.intf().ip}" out, err, exitcode = src.pexec(cmd) assert exitcode == 0, f"Cannot ping between {src} and {dst}: {err}" @@ -40,7 +45,7 @@ def assert_delay( delay = float(match.group(1)) if delay_target - tolerance <= delay <= delay_target + tolerance: delays.append(delay) - assert len(delays) >= 5, ( + assert len(delays) >= MIN_MATCHING_SAMPLES, ( f"Less than half of the pings between {src} and {dst} had the desired latency" ) @@ -75,7 +80,7 @@ def assert_bw(src: IPNode, dst: IPNode, bw_target: float, tolerance=1, v6=False) bw = int(sample["sum"]["bits_per_second"]) / 10**6 if bw_target - tolerance <= bw <= bw_target + tolerance: bws.append(bw) - assert len(bws) >= 5, ( + assert len(bws) >= MIN_MATCHING_SAMPLES, ( f"Less than half of the pings between {src} and {dst} had the desired latency" ) diff --git a/ipmininet/tests/utils.py b/ipmininet/tests/utils.py index c80ed731..e85c6a81 100644 --- a/ipmininet/tests/utils.py +++ b/ipmininet/tests/utils.py @@ -16,6 +16,10 @@ from ipmininet.router import IPNode from ipmininet.utils import require_cmd +# Number of identical successive traceroutes required to consider that the +# network has converged on a stable path. +CONVERGED_PATH_COUNT = 2 + def wait_until( predicate: Callable[[], bool], @@ -85,7 +89,7 @@ def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300, poll=1.0) -> list and old_path_ips == path_ips ): same_path_count += 1 - if same_path_count > 2: + if same_path_count > CONVERGED_PATH_COUNT: # Network has converged return path_ips else: diff --git a/pyproject.toml b/pyproject.toml index a0264cb0..84a9891d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,9 +115,7 @@ select = [ # Architectural rules deferred for existing files (see NOTES.md): # - PLC0415: intentional circular-import avoidance # - TRY003: requires per-message exception classes -# - PLR2004: requires named constants for IP/protocol magic values # - PLR0913/PLR0917/PLR0912/PLR0911/PLR0915: require function refactors -"ipmininet/examples/exabgp_prefix_injector.py" = ["PLR2004"] "ipmininet/examples/ripng_network_adjust.py" = ["PLR0913", "PLR0917"] "ipmininet/examples/spanning_tree_adjust.py" = ["PLR0913", "PLR0917"] "ipmininet/host/config/named.py" = ["PLR0912", "PLR0913", "PLR0917"] @@ -135,13 +133,10 @@ select = [ "ipmininet/router/config/exabgp.py" = ["TRY003"] "ipmininet/router/config/iptables.py" = ["TRY003"] "ipmininet/router/config/zebra.py" = ["PLR0913", "PLR0917", "TRY003"] -"ipmininet/srv6.py" = ["PLR0913", "PLR0917", "PLR2004", "TRY003"] -"ipmininet/tests/test_link.py" = ["PLR2004"] -"ipmininet/tests/test_misc.py" = ["PLR2004"] +"ipmininet/srv6.py" = ["PLR0913", "PLR0917", "TRY003"] "ipmininet/tests/test_radv.py" = ["TRY003"] -"ipmininet/tests/test_srv6.py" = ["PLR0912", "PLR0915", "PLR2004"] -"ipmininet/tests/test_tc.py" = ["PLR2004"] -"ipmininet/tests/utils.py" = ["PLR0913", "PLR0917", "PLR2004", "TRY003"] +"ipmininet/tests/test_srv6.py" = ["PLR0912", "PLR0915"] +"ipmininet/tests/utils.py" = ["PLR0913", "PLR0917", "TRY003"] "ipmininet/topologydb.py" = ["TRY003"] "ipmininet/utils.py" = ["PLC0415", "TRY003"] From c3087456e538b797f0586baac5c525c05f4d5e0e Mon Sep 17 00:00:00 2001 From: iakov Date: Fri, 4 Sep 2026 22:53:06 +0300 Subject: [PATCH 60/69] test(coverage): cover the dnsmasq, DHCPRelay and IPOVSSwitch modules (#43) * test(coverage): cover the dnsmasq, DHCPRelay and IPOVSSwitch modules * test(coverage): fix rstp substring assert and xdist basetemp race --- .github/workflows/test.yaml | 3 +- ipmininet/tests/test_dhcp.py | 141 +++++++++++++++++++++++++++ ipmininet/tests/test_ipovs_switch.py | 122 +++++++++++++++++++++++ scripts/run-tests-parallel.sh | 2 + 4 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 ipmininet/tests/test_dhcp.py create mode 100644 ipmininet/tests/test_ipovs_switch.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 11a78e47..5128b0c0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -50,7 +50,8 @@ jobs: ipmininet/tests/test_pure.py \ ipmininet/tests/test_misc.py \ ipmininet/tests/test_link.py \ - ipmininet/tests/test_network_capture.py + ipmininet/tests/test_network_capture.py \ + ipmininet/tests/test_dhcp.py - name: Upload coverage report (rootless) uses: actions/upload-artifact@v7 with: diff --git a/ipmininet/tests/test_dhcp.py b/ipmininet/tests/test_dhcp.py new file mode 100644 index 00000000..aebff76c --- /dev/null +++ b/ipmininet/tests/test_dhcp.py @@ -0,0 +1,141 @@ +"""This module tests the Dnsmasq and DHCPRelay daemon configurations.""" + +import os +import shutil +import tempfile +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from ipmininet.host.config.dnsmasq import Dnsmasq +from ipmininet.router.config.dhcprelay import DHCPRelay + +SERVER_IP = "192.168.0.1" +LISTENING_IP = "192.168.0.254" +GW_IP = "192.168.1.1" +DHCP_RANGE = "192.168.1.100,192.168.1.200" +MASK = "255.255.255.0" +PID = "1234" + + +@pytest.fixture +def tmp_cwd(): + cwd = tempfile.mkdtemp(dir="/tmp") + yield cwd + shutil.rmtree(cwd, ignore_errors=True) + + +def _node(name, cwd, routerid=None): + node = SimpleNamespace(name=name, cwd=cwd) + node.cmd = MagicMock() + if routerid is not None: + node.nconfig = SimpleNamespace(routerid=routerid) + return node + + +def _dnsmasq(tmp_cwd, intfs=None): + node = _node("h1", tmp_cwd) + daemon = Dnsmasq(node, DHCP_RANGE, MASK, GW_IP, intfs or ["eth0"]) + return node, daemon + + +def test_dnsmasq_build_and_files(tmp_cwd): + node, daemon = _dnsmasq(tmp_cwd, ["eth0", "eth1"]) + + cfg = daemon.build() + + assert cfg.pid_file == daemon.pid_file + assert cfg.ip_range == DHCP_RANGE + assert cfg.mask == MASK + assert cfg.gw == GW_IP + assert cfg.interfaces == ["eth0", "eth1"] + assert cfg.opts["dhcp-range"] == f"{DHCP_RANGE},{MASK}" + assert cfg.opts["dhcp-option"] == f"3,{GW_IP}" + assert cfg.opts["interface"] == ["eth0", "eth1"] + assert cfg.opts["port"] == 0 + assert cfg.opts["log-queries"] is None + + filename = daemon.cfg_filenames[0] + assert filename == os.path.join(str(tmp_cwd), "dnsmasq_h1.eth0_eth1.cfg") + assert daemon.startup_line == f"dnsmasq --conf-file={filename}" + assert daemon.dry_run == "" + assert daemon.template_filenames == ["dnsmasq.mako", "dnsmasq.mako"] + assert node.cmd.call_count == 0 + + +def test_dnsmasq_pids_found(tmp_cwd): + _, daemon = _dnsmasq(tmp_cwd) + daemon.node.cmd.return_value = ( + 'udp 0 0 0.0.0.0:53 users:(("dnsmasq",pid=1234,fd=5))\n' + 'tcp 0 0 0.0.0.0:53 users:(("dnsmasq",pid=5678,fd=6))\n' + ) + + assert daemon.pids == ["1234", "5678"] + daemon.node.cmd.assert_called_once_with("ss -tulnp | grep dnsmasq") + + +def test_dnsmasq_pids_without_match(tmp_cwd): + _, daemon = _dnsmasq(tmp_cwd) + daemon.node.cmd.return_value = "ss output without any pid" + + assert daemon.pids is None + + +def test_dnsmasq_pids_empty_output(tmp_cwd): + _, daemon = _dnsmasq(tmp_cwd) + daemon.node.cmd.return_value = "" + + assert daemon.pids is None + + +def test_dnsmasq_kill(tmp_cwd): + node, daemon = _dnsmasq(tmp_cwd) + node.cmd.side_effect = [f" {PID} \n", ""] + + daemon.kill() + + assert node.cmd.call_args_list[0].args[0] == f"cat {daemon.pid_file}" + assert node.cmd.call_args_list[1].args[0] == f"kill -9 {PID}" + + +def test_dnsmasq_cleanup(tmp_cwd): + node, daemon = _dnsmasq(tmp_cwd) + node.cmd.side_effect = [f" {PID} \n", ""] + + daemon.cleanup() + + assert node.cmd.call_args_list[1].args[0] == f"kill -9 {PID}" + + +def test_dhcprelay_build_and_files(tmp_cwd): + node = _node("r1", str(tmp_cwd), routerid="10.0.0.1") + daemon = DHCPRelay(node, SERVER_IP, LISTENING_IP) + + cfg = daemon.build() + + assert cfg.pid_file == daemon.pid_file + assert cfg.dhcp_server_ip == SERVER_IP + assert cfg.listening_ip == LISTENING_IP + assert cfg.routerid == "10.0.0.1" + + filename = daemon.cfg_filenames[0] + assert filename == os.path.join(str(tmp_cwd), f"dhcprelay_r1.{LISTENING_IP}.cfg") + assert daemon.startup_line == f"dnsmasq --conf-file={filename}" + assert daemon.dry_run == "" + assert daemon.KILL_PATTERNS == ("dnsmasq",) + assert node.cmd.call_count == 0 + + +def test_dhcprelay_kill_and_cleanup(tmp_cwd): + node = _node("r1", str(tmp_cwd)) + daemon = DHCPRelay(node, SERVER_IP, LISTENING_IP) + node.cmd.side_effect = [f" {PID} \n", "", f" {PID} \n", ""] + + daemon.kill() + daemon.cleanup() + + assert node.cmd.call_args_list[0].args[0] == f"cat {daemon.pid_file}" + assert node.cmd.call_args_list[1].args[0] == f"kill -9 {PID}" + assert node.cmd.call_args_list[2].args[0] == f"cat {daemon.pid_file}" + assert node.cmd.call_args_list[3].args[0] == f"kill -9 {PID}" diff --git a/ipmininet/tests/test_ipovs_switch.py b/ipmininet/tests/test_ipovs_switch.py new file mode 100644 index 00000000..3ba7c00a --- /dev/null +++ b/ipmininet/tests/test_ipovs_switch.py @@ -0,0 +1,122 @@ +"""This module tests the IPOVSSwitch class.""" + +from unittest import mock + +import pytest + +from ipmininet.ipovs_switch import IPOVSSwitch + +TEST_PRIORITY = 100 + + +def test_constructor_defaults(): + switch = IPOVSSwitch("s1") + + assert switch.rstp is False + assert switch.stp is False + assert switch.priority is None + assert switch.failMode == "standalone" + + +@pytest.mark.parametrize( + "rstp,stp,hub,exp_rstp,exp_stp", + [ + (True, False, False, True, False), + (False, True, False, False, True), + (True, True, False, True, True), + (True, True, True, False, False), + (True, False, True, False, False), + ], +) +def test_constructor_stp_flags(rstp, stp, hub, exp_rstp, exp_stp): + switch = IPOVSSwitch("s1", stp=stp, rstp=rstp, hub=hub) + + assert switch.rstp is exp_rstp + assert switch.stp is exp_stp + + +def test_constructor_priority(): + switch = IPOVSSwitch("s1", priority=TEST_PRIORITY) + + assert switch.priority == TEST_PRIORITY + assert switch.failMode == "standalone" + + +def _bridge_opts(**kwargs): + switch = IPOVSSwitch("s1", **kwargs) + with mock.patch.object(IPOVSSwitch, "isOldOVS", return_value=False): + return switch, switch.bridgeOpts() + + +def test_bridge_opts_defaults(): + switch, opts = _bridge_opts() + + assert "other_config:datapath-id=" in opts + assert "fail_mode=standalone" in opts + assert "other_config:disable-in-band=true" in opts + assert "other_config:dp-desc=s1" in opts + assert " stp_enable=true" not in opts + assert " rstp_enable=true" not in opts + assert switch.priority is None + + +def test_bridge_opts_priority(): + _, opts = _bridge_opts(priority=TEST_PRIORITY) + + assert f"other_config:stp-priority={TEST_PRIORITY}" in opts + assert f"other_config:rstp-priority={TEST_PRIORITY}" in opts + + +def test_bridge_opts_rstp_enabled(): + _, opts = _bridge_opts(rstp=True) + + assert " rstp_enable=true" in opts + assert " stp_enable=true" not in opts + + +def test_bridge_opts_stp_enabled(): + _, opts = _bridge_opts(stp=True) + + assert " stp_enable=true" in opts + assert " rstp_enable=true" not in opts + + +def test_bridge_opts_stp_blocked_by_controller_mode(): + _, opts = _bridge_opts(stp=True, failMode="secure") + + assert " stp_enable=true" not in opts + assert " rstp_enable=true" not in opts + + +def test_bridge_opts_user_datapath(): + _, opts = _bridge_opts(datapath="user") + + assert "datapath_type=netdev" in opts + + +def test_bridge_opts_protocols_on_new_ovs(): + _, opts = _bridge_opts(protocols="OpenFlow13") + + assert "protocols=OpenFlow13" in opts + + +def test_bridge_opts_no_protocols_on_old_ovs(): + switch = IPOVSSwitch("s1", protocols="OpenFlow13") + with mock.patch.object(IPOVSSwitch, "isOldOVS", return_value=True): + opts = switch.bridgeOpts() + + assert "protocols=" not in opts + + +def test_bridge_opts_inband_keeps_in_band_control(): + _, opts = _bridge_opts(inband=True) + + assert "other_config:disable-in-band=true" not in opts + + +def test_start_in_namespace_raises(): + switch = IPOVSSwitch("s1") + switch.inNamespace = True + + with pytest.raises(RuntimeError, match="does not work in a namespace"): + switch.start(controllers=[]) diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index 15cd732e..9870f383 100755 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -48,6 +48,8 @@ if [ ${#ARGS[@]} -eq 0 ]; then ipmininet/tests/test_pure.py ipmininet/tests/test_physicalinterface.py ipmininet/tests/test_address_alllocation.py + ipmininet/tests/test_dhcp.py + ipmininet/tests/test_ipovs_switch.py ) fi From 1ac7cc902ed25de3877cb424c5104546876e84f4 Mon Sep 17 00:00:00 2001 From: iakov Date: Fri, 4 Sep 2026 22:53:13 +0300 Subject: [PATCH 61/69] refactor(lint): encode TRY003 messages in exception classes (#44) --- ipmininet/iptopo.py | 14 ++++++++--- ipmininet/node_description.py | 18 ++++++++++++-- ipmininet/overlay.py | 9 ++++++- ipmininet/router/config/exabgp.py | 18 ++++++++++++-- ipmininet/router/config/iptables.py | 38 ++++++++++++++++++++++++----- ipmininet/tests/test_radv.py | 4 +-- ipmininet/topologydb.py | 27 +++++++++++++++++--- pyproject.toml | 7 ------ 8 files changed, 109 insertions(+), 26 deletions(-) diff --git a/ipmininet/iptopo.py b/ipmininet/iptopo.py index a83bec1f..47f0c943 100644 --- a/ipmininet/iptopo.py +++ b/ipmininet/iptopo.py @@ -24,6 +24,16 @@ from ipmininet.utils import get_set, is_container +class UnknownTopologyAttributeError(AttributeError): + """Raised when accessing an attribute that is neither an IPTopo method + nor a known overlay.""" + + def __init__(self, item): + super().__init__( + f"{item} is neither a method of IPTopo nor refers to any known overlay" + ) + + class IPTopo(Topo): """A topology that supports L3 routers""" @@ -233,9 +243,7 @@ def __getattr__(self, item): return OverlayWrapper(self, self.OVERLAYS[item[3:]]) except KeyError: pass - raise AttributeError( - f"{item} is neither a method of IPTopo nor refers to any known overlay" - ) + raise UnknownTopologyAttributeError(item) def getNodeInfo(self, n: str, key, default: type): """Attempt to retrieve the information for the given node/key diff --git a/ipmininet/node_description.py b/ipmininet/node_description.py index 6d7bea7e..68c8ecc3 100644 --- a/ipmininet/node_description.py +++ b/ipmininet/node_description.py @@ -9,6 +9,20 @@ from ipmininet.iptopo import IPTopo +class LinkIndexError(IndexError): + """Raised when a link is indexed with an unsupported integer index.""" + + def __init__(self): + super().__init__("Links have only two nodes and one key") + + +class NodeNotOnLinkError(KeyError): + """Raised when a node that is not an endpoint indexes a link.""" + + def __init__(self, node): + super().__init__(f"Node '{node}' is not on this link") + + class NodeDescription(str): def __new__(cls, value, *args, **kwargs): return super().__new__(cls, value) @@ -101,13 +115,13 @@ def __getitem__(self, item): return self.dst_intf if item == self.KEY_INDEX: return self.key - raise IndexError("Links have only two nodes and one key") + raise LinkIndexError() if item == self.src: return self.src_intf if item == self.dst: return self.dst_intf - raise KeyError(f"Node '{item}' is not on this link") + raise NodeNotOnLinkError(item) # The following methods allow this object to behave like an edge key # for mininet.topo.MultiGraph diff --git a/ipmininet/overlay.py b/ipmininet/overlay.py index 5837fcaf..e1df65a4 100644 --- a/ipmininet/overlay.py +++ b/ipmininet/overlay.py @@ -19,6 +19,13 @@ from ipmininet.router import IPNode +class NoCaptureAnchorError(ValueError): + """Raised when a capture is started without a node or an interface.""" + + def __init__(self): + super().__init__("The Network capture need a router or an interface to run") + + class Overlay: """This overlay simply defines groups of nodes and links, and properties that are common to all of them. It then registers these properties to the @@ -350,7 +357,7 @@ def start( self._drain_stderr(intf.name, process) self.ongoing_captures[intf.name] = process return process - raise ValueError("The Network capture need a router or an interface to run") + raise NoCaptureAnchorError() def stop(self, node: Optional["IPNode"] = None, intf: Optional["IPIntf"] = None): for anchor in [node, intf]: diff --git a/ipmininet/router/config/exabgp.py b/ipmininet/router/config/exabgp.py index 4be0d73c..aeb46f6b 100644 --- a/ipmininet/router/config/exabgp.py +++ b/ipmininet/router/config/exabgp.py @@ -6,6 +6,20 @@ from .utils import ConfigDict +class NotHexRepresentableError(ValueError): + """Raised when an ExaBGP list is used for an hexadecimal representation.""" + + def __init__(self): + super().__init__("Must not be used for an Hexadecimal representation") + + +class UnknownBGPAttributeError(ValueError): + """Raised when a BGP attribute type is not known to ExaBGP.""" + + def __init__(self, attr_type): + super().__init__(f"{attr_type!s} is not a known attribute") + + class Representable(ABC): """ String representation for ExaBGP configuration @@ -73,7 +87,7 @@ class ExaList(HexRepresentable): """ def hex_repr(self) -> str: - raise ValueError("Must not be used for an Hexadecimal representation") + raise NotHexRepresentableError() @property def val(self): @@ -217,7 +231,7 @@ def __init__( if flags is None: if str(attr_type) not in self._known_attr: - raise ValueError(f"{attr_type!s} is not a known attribute") + raise UnknownBGPAttributeError(attr_type) else: assert isinstance(val, HexRepresentable), ( "If flags are set, val must be of type 'HexRepresentable'" diff --git a/ipmininet/router/config/iptables.py b/ipmininet/router/config/iptables.py index 1b0f6b24..eef396ea 100644 --- a/ipmininet/router/config/iptables.py +++ b/ipmininet/router/config/iptables.py @@ -12,6 +12,34 @@ from .base import Daemon +class UnknownTableError(ValueError): + """Raised when building a chain for an unknown IPTables table.""" + + def __init__(self, table): + super().__init__(f"{table} does not match to an IPTables table name") + + +class UnknownChainError(ValueError): + """Raised when building a chain that does not exist in the table.""" + + def __init__(self, name, table): + super().__init__(f"{name} is not an allowed Chain for table {table}") + + +class InvalidDefaultPolicyError(ValueError): + """Raised when a chain default verdict is neither DROP nor ACCEPT.""" + + def __init__(self, default): + super().__init__(f"{default} is an invalid default policy") + + +class UnknownRuleParameterError(ValueError): + """Raised when a rule is given a parameter that has no iptables alias.""" + + def __init__(self, unknown_args): + super().__init__(f"Unknown parameters: {unknown_args}") + + class IPTables(Daemon): """iptables: the default Linux firewall/ACL engine for IPv4. This is currently mainly a proxy class to generate a list of static rules @@ -113,19 +141,17 @@ def __init__(self, table="filter", name="INPUT", default="DROP", rules=()): _table = str(table).lower() allowed_chains = self.TABLE_CHAINS[_table] except KeyError: - raise ValueError( - f"{table} does not match to an IPTables table name" - ) from None + raise UnknownTableError(table) from None self.table = _table _name = str(name).upper() if _name not in allowed_chains: - raise ValueError(f"{name} is not an allowed Chain for table {table}") + raise UnknownChainError(name, table) self.name = _name _default = str(default).upper() if _default != "DROP" and _default != "ACCEPT": - raise ValueError(f"{default} is an invalid default policy") + raise InvalidDefaultPolicyError(default) self.default = _default self.rules = rules @@ -175,7 +201,7 @@ def __init__(self, action="DROP", **kwargs): unknown_args = set(kwargs.keys()).difference(self.ALIASES.keys()) if unknown_args: - raise ValueError(f"Unknown parameters: {unknown_args}") + raise UnknownRuleParameterError(unknown_args) args = {self.ALIASES[k]: v for k, v in kwargs.items()} self.oif = InterfaceClause("o", args) diff --git a/ipmininet/tests/test_radv.py b/ipmininet/tests/test_radv.py index c2092c06..43fc39cc 100644 --- a/ipmininet/tests/test_radv.py +++ b/ipmininet/tests/test_radv.py @@ -119,9 +119,9 @@ def test_radvd_cleanup(): try: net["r"].nconfig.daemon(RADVD).cleanup() except Exception as e: - raise AssertionError( + pytest.fail( f"An exception '{e}' was raised while cleaning twice RADVD daemon" - ) from e + ) net.stop() finally: cleanup() diff --git a/ipmininet/topologydb.py b/ipmininet/topologydb.py index 90549323..9b4172aa 100644 --- a/ipmininet/topologydb.py +++ b/ipmininet/topologydb.py @@ -10,6 +10,27 @@ from .utils import otherIntf, realIntfList +class NoSuchNodeError(ValueError): + """Raised when looking up a node that is not in the topology database.""" + + def __init__(self, node): + super().__init__(f"No node named {node} in the network") + + +class NoSuchLinkError(ValueError): + """Raised when looking up a link between two unknown nodes.""" + + def __init__(self, x, y): + super().__init__(f"The link {x}-{y} does not exist") + + +class NotARouterError(TypeError): + """Raised when asking for the router id of a non-router node.""" + + def __init__(self, node): + super().__init__(f"{node} is not a router") + + class TopologyDB: """A convenience store for auto-allocated mininet properties. This is *NOT* to be used as IGP graph as it does not reflect the actual @@ -54,7 +75,7 @@ def _node(self, x): try: return self._network[x] except KeyError: - raise ValueError(f"No node named {x} in the network") from None + raise NoSuchNodeError(x) from None __getitem__ = node = _node @@ -62,7 +83,7 @@ def _interface(self, x, y): try: return self._node(x)[y] except KeyError: - raise ValueError(f"The link {x}-{y} does not exist") from None + raise NoSuchLinkError(x, y) from None def interface(self, x, y): """Return the ip address of the interface of x facing y @@ -103,7 +124,7 @@ def routerid(self, x): :return: the routerid""" n = self._node(x) if n["type"] != "router": - raise TypeError(f"{x} is not a router") + raise NotARouterError(x) return n["routerid"] def parse_net(self, net): diff --git a/pyproject.toml b/pyproject.toml index 84a9891d..c76e7e23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,21 +123,14 @@ select = [ "ipmininet/ipnet.py" = ["PLR0912", "PLR0913", "PLR0917", "TRY003"] "ipmininet/ipovs_switch.py" = ["PLR0913", "PLR0917", "TRY003"] "ipmininet/ipswitch.py" = ["PLR0913", "PLR0917"] -"ipmininet/iptopo.py" = ["TRY003"] "ipmininet/link.py" = ["PLR0911", "TRY003"] -"ipmininet/node_description.py" = ["TRY003"] -"ipmininet/overlay.py" = ["TRY003"] "ipmininet/router/__router.py" = ["PLR0913", "PLR0917"] "ipmininet/router/config/base.py" = ["PLC0415", "TRY003"] "ipmininet/router/config/bgp.py" = ["PLR0913", "PLR0917", "TRY003"] -"ipmininet/router/config/exabgp.py" = ["TRY003"] -"ipmininet/router/config/iptables.py" = ["TRY003"] "ipmininet/router/config/zebra.py" = ["PLR0913", "PLR0917", "TRY003"] "ipmininet/srv6.py" = ["PLR0913", "PLR0917", "TRY003"] -"ipmininet/tests/test_radv.py" = ["TRY003"] "ipmininet/tests/test_srv6.py" = ["PLR0912", "PLR0915"] "ipmininet/tests/utils.py" = ["PLR0913", "PLR0917", "TRY003"] -"ipmininet/topologydb.py" = ["TRY003"] "ipmininet/utils.py" = ["PLC0415", "TRY003"] [tool.mdformat] From 0188094c72299aaea753edd74df189f7a904dc77 Mon Sep 17 00:00:00 2001 From: iakov Date: Sat, 5 Sep 2026 01:51:24 +0300 Subject: [PATCH 62/69] refactor(lint): split overly-complex helpers and tests (#45) --- ipmininet/host/config/named.py | 101 +++++++++------ ipmininet/ipnet.py | 64 ++++++---- ipmininet/tests/test_srv6.py | 223 +++++++++++++++++++++------------ pyproject.toml | 1 - 4 files changed, 238 insertions(+), 151 deletions(-) diff --git a/ipmininet/host/config/named.py b/ipmininet/host/config/named.py index b1f3c576..3d688442 100644 --- a/ipmininet/host/config/named.py +++ b/ipmininet/host/config/named.py @@ -118,6 +118,24 @@ def build_zone(self, zone: "DNSZone") -> ConfigDict: delegation_servers=zone.delegation_servers, ) + def _ptr_record(self, zone: ConfigDict, record: "ARecord") -> "PTRRecord": + if record.full_domain_name: + domain_name = record.domain_name + else: + domain_name = dns_join_name(record.domain_name, zone.name) + return PTRRecord(record.address, domain_name, ttl=record.ttl) + + def _fill_reverse_zones(self, cfg_zones: ConfigDict, records: list) -> list: + """Return the records of ``records`` appended to an existing reverse zone.""" + placed_records = [] + for record in records: + for zone in cfg_zones.values(): + if zone.name in record.domain_name and is_reverse_zone(zone.name): + zone.records.append(record) + placed_records.append(record) + break + return placed_records + def build_reverse_zone(self, cfg_zones: ConfigDict): """ Build non-existing PTR records. Then, adds them to an existing reverse @@ -128,16 +146,10 @@ def build_reverse_zone(self, cfg_zones: ConfigDict): ptr_records = set() for zone in cfg_zones.values(): for record in zone.records: - if not isinstance(record, ARecord) or ( - record.domain_name in zone.delegation_servers + if isinstance(record, ARecord) and ( + record.domain_name not in zone.delegation_servers ): - continue - - if record.full_domain_name: - domain_name = record.domain_name - else: - domain_name = dns_join_name(record.domain_name, zone.name) - ptr_records.add(PTRRecord(record.address, domain_name, ttl=record.ttl)) + ptr_records.add(self._ptr_record(zone, record)) existing_records = [ record @@ -146,27 +158,24 @@ def build_reverse_zone(self, cfg_zones: ConfigDict): if isinstance(record, PTRRecord) ] - ptr_v6_records = [] - ptr_v4_records = [] - for record in ptr_records: - # Filter out existing PTR records - if record in existing_records: + # Filter out existing PTR records and place the rest in existing + # reverse DNS zones + new_records = [ + record for record in ptr_records if record not in existing_records + ] + placed_records = self._fill_reverse_zones(cfg_zones, new_records) + + ptr_v6_records: list = [] + ptr_v4_records: list = [] + for record in new_records: + # The records that were not placed need a new reverse DNS zone + if record in placed_records: continue - # Try to place the rest in existing reverse DNS zones - found = False - for zone in cfg_zones.values(): - if zone.name in record.domain_name and is_reverse_zone(zone.name): - zone.records.append(record) - found = True - break - # The rest needs a new DNS zone - if not found: - if record.v6: - ptr_v6_records.append(record) - else: - ptr_v4_records.append(record) - - # Create new reverse DNS zones for remaining PTR records + if record.v6: + ptr_v6_records.append(record) + else: + ptr_v4_records.append(record) + if len(ptr_v6_records) > 0: self.build_largest_reverse_zone(cfg_zones, ptr_v6_records) if len(ptr_v4_records) > 0: @@ -459,11 +468,9 @@ def add_record(self, record: DNSRecord): if record not in self._records: self._records.append(record) - def apply(self, topo): - super().apply(topo) - if not self.consistent: - return - + def _peer_overlays(self, topo) -> list: + """Return the peer zones that want to hint root name servers and + register the direct subdomains of this zone as delegated zones.""" zones = [] for overlay in topo.overlays: if ( @@ -486,15 +493,27 @@ def apply(self, topo): self.delegated_zones.append(overlay) + return zones + + def _hint_root_zone_servers(self, topo, zones: list) -> None: + for zone in zones: + for n in zone.nodes: + if "root_zone" not in topo.nodeInfo(n): + hint_root_zone = copy.deepcopy(self) + hint_root_zone.soa_record = None + hint_root_zone._records = hint_root_zone.ns_records + topo.nodeInfo(n)["root_zone"] = hint_root_zone + + def apply(self, topo): + super().apply(topo) + if not self.consistent: + return + + zones = self._peer_overlays(topo) + # If this is root zone, add a hint zone for the NS servers if self.name == DNS_ROOT: - for zone in zones: - for n in zone.nodes: - if "root_zone" not in topo.nodeInfo(n): - hint_root_zone = copy.deepcopy(self) - hint_root_zone.soa_record = None - hint_root_zone._records = hint_root_zone.ns_records - topo.nodeInfo(n)["root_zone"] = hint_root_zone + self._hint_root_zone_servers(topo, zones) for zone in self.delegated_zones: # Create NSRecords for the delegated subdomains diff --git a/ipmininet/ipnet.py b/ipmininet/ipnet.py index fea9b2ca..0fdeec79 100644 --- a/ipmininet/ipnet.py +++ b/ipmininet/ipnet.py @@ -507,6 +507,35 @@ def _ping_set( return lost, packets + def _ping_addressing( + self, src: Node, host_list: list[Node], use_v4: bool, use_v6: bool + ) -> tuple[dict, dict, set]: + """Return the IPv4/IPv6 destinations reachable from ``src`` and the + pairs of hosts that cannot ping each other. + + :param src: origin of the pings + :param host_list: the candidate destinations + :param use_v4: whether IPv4 addresses can be used + :param use_v6: whether IPv6 addresses can be used + :return: a tuple (v4 destinations, v6 destinations, incompatible pairs) + """ + src_ip, src_ip6 = address_pair(src, use_v4, use_v6) + ping_dict = {} + ping6_dict = {} + incompatible_pairs = set() + for dst in host_list: + if src == dst: + continue + dst_ip, dst_ip6 = address_pair(dst, src_ip is not None, src_ip6 is not None) + if dst_ip is not None: + ping_dict[dst] = dst_ip + if dst_ip6 is not None: + ping6_dict[dst] = dst_ip6 + if use_v4 and dst_ip is None and use_v6 and dst_ip6 is None: + node1, node2 = sorted((src, dst), key=lambda x: x.name) + incompatible_pairs.add((node1.name, node2.name)) + return ping_dict, ping6_dict, incompatible_pairs + def ping( self, hosts: list[Node] | None = None, @@ -529,11 +558,7 @@ def ping( :return: the packet loss percentage of IPv4 connectivity if self.use_v4 is set the loss percentage of IPv6 connectivity otherwise""" - packets = lost = 0 - host_list = self.hosts - if hosts is not None: - host_list = hosts - incompatible_hosts = {} # type: Dict[str, Set[str]] + host_list = self.hosts if hosts is None else hosts if not use_v4 and not use_v6: log.output("*** Warning: Parameters forbid both IPv4 and IPv6 for pings\n") return 0 @@ -545,26 +570,14 @@ def ping( "IPv6" if use_v6 else "", ) ) + incompatible_hosts = {} # type: Dict[str, Set[str]] + packets = lost = 0 for src in host_list: - src_ip, src_ip6 = address_pair(src, use_v4, use_v6) - ping_dict = {} - ping6_dict = {} - for dst in host_list: - if src != dst: - dst_ip, dst_ip6 = address_pair( - dst, src_ip is not None, src_ip6 is not None - ) - if dst_ip is not None: - ping_dict[dst] = dst_ip - if dst_ip6 is not None: - ping6_dict[dst] = dst_ip6 - if use_v4 and dst_ip is None and use_v6 and dst_ip6 is None: - node1 = src if src.name <= dst.name else dst - node2 = src if node1 != src else dst - if node2.name not in incompatible_hosts.setdefault( - node1.name, set() - ): - incompatible_hosts[node1.name].add(node2.name) + ping_dict, ping6_dict, incompatible_pairs = self._ping_addressing( + src, host_list, use_v4, use_v6 + ) + for node1, node2 in incompatible_pairs: + incompatible_hosts.setdefault(node1, set()).add(node2) result = self._ping_set(src, ping_dict, timeout, True) lost += result[0] @@ -582,9 +595,8 @@ def ping( if packets > 0: ploss = 100.0 * lost / packets - received = packets - lost log.output( - f"*** Results: {ploss}% dropped ({received}/{packets} received)\n" + f"*** Results: {ploss}% dropped ({packets - lost}/{packets} received)\n" ) else: ploss = 0 diff --git a/ipmininet/tests/test_srv6.py b/ipmininet/tests/test_srv6.py index e74c2cd7..2895526c 100644 --- a/ipmininet/tests/test_srv6.py +++ b/ipmininet/tests/test_srv6.py @@ -134,80 +134,73 @@ def _infer_sub_paths( return sub_paths -def sr_path(net: IPNet, src: str, dst_ip: str, timeout=1, through=()) -> list[str]: - require_cmd("tshark", help_str="tshark is required to run tests") - require_cmd("nmap", help_str="nmap is required to run tests") +def _pcap_size(node) -> int: + """Return the size in bytes of the capture file of ``node``, 0 if absent.""" + path = f"/tmp/{node.name}.pcap" + return os.path.getsize(path) if os.path.isfile(path) else 0 + + +def _probe(net: IPNet, src: str, dst_ip: str, probe_cmd: str) -> None: + """Send a ping probe and assert the destination is still reachable.""" + out = net[src].cmd(shlex.split(probe_cmd)) + assert "100% packet loss" not in out, ( + f"Connectivity from {src} to {dst_ip} is not ensured, " + "so we cannot infer the path." + ) - # Check connectivity - ping_cmd = f"ping -6 -c 1 -W {int(timeout)} {dst_ip}" - out = net[src].cmd(shlex.split(ping_cmd)) - if ", 0% packet loss" not in out: - return [] - # Start captures of the IPv6 traffic on every node - tsharks = [] +def _wait_announcing(tsharks: list) -> None: + """Wait for tshark to announce it started capturing on every node. + + tshark prints "Capturing on " to stderr once the capture is + live. Waiting on that notification is more reliable than checking that the + process is merely alive: a just-started tshark may not have attached to + the interface yet, and the measurement ping would then be missed. + """ stderr_buf = {} - nodes = net.routers + net.hosts - try: - for n in nodes: - p = n.popen(shlex.split(f"tshark -n -i any -f 'ip6' -w /tmp/{n.name}.pcap")) - tsharks.append(p) - # Wait for tshark to announce it started capturing. A freshly started - # tshark only becomes functionally live a moment *after* printing this - # message, so we then wait for each capture to record packets. - wait_until( - lambda: _tshark_capturing(tsharks, stderr_buf), - timeout=30, - interval=0.2, - description="tshark to start capturing on every node", - ) + wait_until( + lambda: _tshark_capturing(tsharks, stderr_buf), + timeout=30, + interval=0.2, + description="tshark to start capturing on every node", + ) - probe_cmd = f"ping -6 -c 1 -W {int(timeout)} {dst_ip}" - - def _pcap_size(node): - path = f"/tmp/{node.name}.pcap" - return os.path.getsize(path) if os.path.isfile(path) else 0 - - # Phase 1: a capture is live once its file grew past the header - # written at start. Probe until every capture is live; the - # announcement alone is not a reliable signal under load. - baseline = {n.name: _pcap_size(n) for n in nodes} - deadline = time.monotonic() + 20 - while ( - not all(_pcap_size(n) > baseline[n.name] for n in nodes) - and time.monotonic() < deadline - ): - out = net[src].cmd(shlex.split(probe_cmd)) - assert "100% packet loss" not in out, ( - f"Connectivity from {src} to {dst_ip} is not ensured, " - "so we cannot infer the path." - ) - time.sleep(0.5) - # Phase 2: every capture is live; send the single measurement probe. - # Nodes off the measurement path never see it, so we cannot wait for - # every capture to record it: stopping with SIGINT flushes whatever - # was captured, and the read-back assert below is the confirmation - # that the measurement actually landed on the path. - out = net[src].cmd(shlex.split(probe_cmd)) - assert "100% packet loss" not in out, ( - f"Connectivity from {src} to {dst_ip} is not ensured, " - "so we cannot infer the path." - ) +def _wait_captures_live( + net: IPNet, src: str, dst_ip: str, probe_cmd: str, nodes: list +) -> None: + """Probe until every capture grew past the header written at start. + + A capture is functionally live once its file recorded a packet, which the + "Capturing on ..." announcement alone does not guarantee under load. + """ + baseline = {n.name: _pcap_size(n) for n in nodes} + deadline = time.monotonic() + 20 + while ( + not all(_pcap_size(n) > baseline[n.name] for n in nodes) + and time.monotonic() < deadline + ): + _probe(net, src, dst_ip, probe_cmd) + time.sleep(0.5) + + +def _stop_captures(tsharks: list) -> None: + """Stop the captures; SIGINT makes tshark flush the packets to disk.""" + for p in tsharks: + p.send_signal(signal.SIGINT) + p.wait() - for p in tsharks: - assert p.poll() is None, ( - f"tshark stopped unexpectedly:stderr '{p.stderr.read()}'" - ) - finally: - # Stop captures; SIGINT makes tshark flush the captured packets to disk - for p in tsharks: - p.send_signal(signal.SIGINT) - p.wait() - # Retrieve packet captures +def _read_captures(nodes: list) -> dict[str, list[tuple[float, str]]]: + """Return, per node, the captured ping requests as (time, destination). + + On newer tshark, ipv6.dst lists every IPv6 header of an encapsulated + packet (e.g. the SRv6 SID and the inner destination). Keep the outermost + one, which is the destination each router actually forwards on, and + matches the addresses used by the ``through`` segments of the test. + """ captures = {} # type: Dict[str, List[Tuple[float, str]]] - for n in net.routers + net.hosts: + for n in nodes: out = n.cmd( shlex.split( f"tshark -n -r /tmp/{n.name}.pcap -T fields " @@ -221,35 +214,48 @@ def _pcap_size(node): if len(values) < TSHARK_FIELDS: continue icmp_type = values[0] - # On newer tshark, ipv6.dst lists every IPv6 header of an - # encapsulated packet (e.g. the SRv6 SID and the inner - # destination). Keep the outermost one, which is the - # destination each router actually forwards on, and matches - # the addresses used by the `through` segments of the test. data_dst = values[1].split(",")[0] data_time = values[-1] if icmp_type == "128": captures.setdefault(n.name, []).append((float(data_time), data_dst)) + return captures + - # Verify the measurement actually landed in the captures: this is the - # notification that tshark was capturing when the probes were sent. +def _reconstruct_sub_paths( + captures: dict[str, list[tuple[float, str]]], dst_ip: str +) -> dict[str, list[str]]: + """Verify the measurement ping landed and infer the observed sub paths. + + The read-back assert is the confirmation that tshark was capturing when + the probes were sent. + """ captured_dests = {dest for _, packets in captures.items() for _, dest in packets} assert dst_ip in captured_dests, ( f"No capture recorded the ping to {dst_ip} (captured destinations: " f"{sorted(captured_dests)}). tshark was probably not capturing yet." ) - # Analyze results - packet_received = {} # type: Dict[str, List[Tuple[float, str]]] for n, packets in captures.items(): for data_time, destination in packets: packet_received.setdefault(destination, []).append((data_time, n)) - sub_paths = _infer_sub_paths(packet_received) + return _infer_sub_paths(packet_received) - # Order sub paths with the trough list - path = [] # type: List[str] + +def _extend_through_waypoints( + net: IPNet, + sub_paths: dict[str, list[str]], + through: tuple, + dst_ip: str, + path: list[str], +) -> bool: + """Append the sub path of each ``through`` waypoint to ``path``. + + Return False as soon as a waypoint was not observed, leaving ``path`` + holding the partial ordering gathered so far; otherwise append the sub + path of the destination and return True. + """ for intermediate in through: found = False @@ -268,8 +274,62 @@ def _pcap_size(node): path.extend(sub_paths[ip]) break if not found: - return path + return False path.extend(sub_paths[dst_ip]) + return True + + +def _remove_captures(nodes: list) -> None: + """Clean up the per-node captures so no stale file lingers between the + parametrized cases of this module.""" + for n in nodes: + pcap_path = f"/tmp/{n.name}.pcap" + if os.path.isfile(pcap_path): + os.unlink(pcap_path) + + +def sr_path(net: IPNet, src: str, dst_ip: str, timeout=1, through=()) -> list[str]: + require_cmd("tshark", help_str="tshark is required to run tests") + require_cmd("nmap", help_str="nmap is required to run tests") + + # Check connectivity + probe_cmd = f"ping -6 -c 1 -W {int(timeout)} {dst_ip}" + out = net[src].cmd(shlex.split(probe_cmd)) + if ", 0% packet loss" not in out: + return [] + + # Start captures of the IPv6 traffic on every node + nodes = net.routers + net.hosts + tsharks = [] + try: + for n in nodes: + p = n.popen(shlex.split(f"tshark -n -i any -f 'ip6' -w /tmp/{n.name}.pcap")) + tsharks.append(p) + _wait_announcing(tsharks) + # Phase 1: a capture is live once it recorded a probe. + _wait_captures_live(net, src, dst_ip, probe_cmd, nodes) + # Phase 2: every capture is live; send the single measurement probe. + # Nodes off the measurement path never see it, so we cannot wait for + # every capture to record it: stopping with SIGINT flushes whatever + # was captured, and the read-back assert below is the confirmation + # that the measurement actually landed on the path. + _probe(net, src, dst_ip, probe_cmd) + + for p in tsharks: + assert p.poll() is None, ( + f"tshark stopped unexpectedly:stderr '{p.stderr.read()}'" + ) + finally: + _stop_captures(tsharks) + + # Retrieve packet captures and analyze results + captures = _read_captures(nodes) + sub_paths = _reconstruct_sub_paths(captures, dst_ip) + + # Order sub paths with the through list + path = [] # type: List[str] + if not _extend_through_waypoints(net, sub_paths, through, dst_ip, path): + return path # Remove duplicates compressed_path = [] # type: List[str] @@ -282,10 +342,7 @@ def _pcap_size(node): # Clean up the per-node captures so no stale file lingers between the # parametrized cases of this module. - for n in net.routers + net.hosts: - pcap_path = f"/tmp/{n.name}.pcap" - if os.path.isfile(pcap_path): - os.unlink(pcap_path) + _remove_captures(nodes) return path diff --git a/pyproject.toml b/pyproject.toml index c76e7e23..dde1bf53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,7 +129,6 @@ select = [ "ipmininet/router/config/bgp.py" = ["PLR0913", "PLR0917", "TRY003"] "ipmininet/router/config/zebra.py" = ["PLR0913", "PLR0917", "TRY003"] "ipmininet/srv6.py" = ["PLR0913", "PLR0917", "TRY003"] -"ipmininet/tests/test_srv6.py" = ["PLR0912", "PLR0915"] "ipmininet/tests/utils.py" = ["PLR0913", "PLR0917", "TRY003"] "ipmininet/utils.py" = ["PLC0415", "TRY003"] From 04e6f7d9966e4d39b37fe64961e51ae762285ba2 Mon Sep 17 00:00:00 2001 From: iakov Date: Sat, 5 Sep 2026 02:45:45 +0300 Subject: [PATCH 63/69] refactor(lint): remove PLR/PLC/TRY003 file-wide ignores (#46) * refactor(lint): drop circular-import workarounds and split router configs * style(lint): replace file-wide PLR/PLC/TRY003 ignores with targeted noqa --- ipmininet/examples/ripng_network_adjust.py | 2 +- ipmininet/examples/spanning_tree_adjust.py | 2 +- ipmininet/host/config/named.py | 2 +- ipmininet/install/utils.py | 5 +- ipmininet/ipnet.py | 18 +++--- ipmininet/ipovs_switch.py | 6 +- ipmininet/ipswitch.py | 2 +- ipmininet/link.py | 10 +++- ipmininet/router/__router.py | 2 +- ipmininet/router/config/__init__.py | 6 +- ipmininet/router/config/base.py | 67 ++------------------- ipmininet/router/config/bgp.py | 8 ++- ipmininet/router/config/routing.py | 70 ++++++++++++++++++++++ ipmininet/router/config/zebra.py | 14 +++-- ipmininet/srv6.py | 20 ++++--- ipmininet/tests/utils.py | 6 +- ipmininet/utils.py | 13 ++-- pyproject.toml | 19 ------ 18 files changed, 138 insertions(+), 134 deletions(-) create mode 100644 ipmininet/router/config/routing.py diff --git a/ipmininet/examples/ripng_network_adjust.py b/ipmininet/examples/ripng_network_adjust.py index 72550306..ba1b0698 100644 --- a/ipmininet/examples/ripng_network_adjust.py +++ b/ipmininet/examples/ripng_network_adjust.py @@ -7,7 +7,7 @@ class RIPngNetworkAdjust(IPTopo): - def __init__( + def __init__( # noqa: PLR0913, PLR0917 self, lr1r2_cost=1, lr1r3_cost=1, diff --git a/ipmininet/examples/spanning_tree_adjust.py b/ipmininet/examples/spanning_tree_adjust.py index c85bb84b..2ab889bf 100644 --- a/ipmininet/examples/spanning_tree_adjust.py +++ b/ipmininet/examples/spanning_tree_adjust.py @@ -2,7 +2,7 @@ class SpanningTreeAdjust(IPTopo): - def __init__( + def __init__( # noqa: PLR0913, PLR0917 self, l1_start=None, l1_end=None, diff --git a/ipmininet/host/config/named.py b/ipmininet/host/config/named.py index 3d688442..be0f420d 100644 --- a/ipmininet/host/config/named.py +++ b/ipmininet/host/config/named.py @@ -379,7 +379,7 @@ def rdata(self): class DNSZone(Overlay): - def __init__( + def __init__( # noqa: PLR0913, PLR0917 self, name: str, dns_master: str, diff --git a/ipmininet/install/utils.py b/ipmininet/install/utils.py index 70b9721e..f73b38dc 100644 --- a/ipmininet/install/utils.py +++ b/ipmininet/install/utils.py @@ -4,6 +4,7 @@ import sys from distutils.spawn import find_executable +from packaging.version import parse as parse_version def sh(*cmds, **kwargs) -> subprocess.Popen | None: @@ -60,8 +61,6 @@ def __init__(self): self.pip_args = self.check_pip_version(self.PIP_CMD) def check_pip_version(self, pip: str) -> str: - from packaging.version import parse as parse_version - if find_executable(pip) is None: return "" p = sh(f"{pip} -V", output_stdout=False) @@ -104,7 +103,7 @@ def pip_install(self, *packages: str, **kwargs): def require_pip(self): if find_executable(self.PIP_CMD) is None: - raise RuntimeError(f"Cannot find {self.PIP_CMD}") + raise RuntimeError(f"Cannot find {self.PIP_CMD}") # noqa: TRY003 class Ubuntu(Distribution): diff --git a/ipmininet/ipnet.py b/ipmininet/ipnet.py index 0fdeec79..2da56333 100644 --- a/ipmininet/ipnet.py +++ b/ipmininet/ipnet.py @@ -45,7 +45,7 @@ class IPNet(Mininet): """IPNet: An IP-aware Mininet""" - def __init__( + def __init__( # noqa: PLR0913, PLR0917 self, router: type[Router] = Router, config: type[RouterConfig] = BasicRouterConfig, @@ -140,7 +140,7 @@ def buildFromTopo(self, topo): self.physical_interface.update(topo.phys_interface_capture) super().buildFromTopo(topo) - def addLink( + def addLink( # noqa: PLR0913, PLR0917 self, node1: Node, node2: Node, @@ -341,7 +341,7 @@ def _allocate_ipv6(self): self._ip_allocs[ip.ip.compressed] = intf.node @staticmethod - def _allocate_subnets( + def _allocate_subnets( # noqa: PLR0913, PLR0917 subnets: list[IPv4Network | IPv6Network], domains: list["BroadcastDomain"], domainlen="len_v4", @@ -387,12 +387,12 @@ def _allocate_subnets( if not d.use_ip_version(ip_version): continue if not subnets: - raise ValueError( + raise ValueError( # noqa: TRY003 "No subnet left in the prefix space for allbroadcast domains." ) plen = min(max_prefixlen, getattr(d, size_key)) if plen < subnets[-1].prefixlen: - raise ValueError( + raise ValueError( # noqa: TRY003 "Could not find a subnet big enough for a broadcast domain." ) log.debug("Allocating prefix", plen, "for interfaces", d.interfaces) @@ -825,13 +825,13 @@ def next_ipv4(self) -> IPv4Interface: :return ip_interface:""" if self.net is None: - raise ValueError("No associated IPv4 subnet") + raise ValueError("No associated IPv4 subnet") # noqa: TRY003 try: addr = self.net[self._allocated_v4] self._allocated_v4 += 1 return ip_interface(f"{addr}/{self.net.prefixlen}") except IndexError: - raise ValueError("No more available IPv4 address") from None + raise ValueError("No more available IPv4 address") from None # noqa: TRY003 def next_ipv6(self) -> IPv6Interface: """Allocate and return the next available IPv6 address in this @@ -839,13 +839,13 @@ def next_ipv6(self) -> IPv6Interface: :return ip_interface:""" if self.net6 is None: - raise ValueError("No associated IPv6 subnet") + raise ValueError("No associated IPv6 subnet") # noqa: TRY003 try: addr = self.net6[self._allocated_v6] self._allocated_v6 += 1 return ip_interface(f"{addr}/{self.net6.prefixlen}") except IndexError: - raise ValueError("No more available IPv6 address") from None + raise ValueError("No more available IPv6 address") from None # noqa: TRY003 def use_ip_version(self, ip_version) -> bool: """Checks whether there are nodes using this IP version diff --git a/ipmininet/ipovs_switch.py b/ipmininet/ipovs_switch.py index 9114bdb0..88e71253 100644 --- a/ipmininet/ipovs_switch.py +++ b/ipmininet/ipovs_switch.py @@ -9,7 +9,7 @@ class IPOVSSwitch(OVSSwitch): working directory and a configurable priority. """ - def __init__( + def __init__( # noqa: PLR0913, PLR0917 self, name: str, stp=False, @@ -43,7 +43,9 @@ def __init__( def start(self, controllers): """Start up a new OVS OpenFlow switch using ovs-vsctl""" if self.inNamespace: - raise RuntimeError("OVS kernel switch does not work in a namespace") + raise RuntimeError( # noqa: TRY003 + "OVS kernel switch does not work in a namespace" + ) self.cmd("ifconfig", self, "down") int(self.dpid, 16) # DPID must be a hex string diff --git a/ipmininet/ipswitch.py b/ipmininet/ipswitch.py index 94ccfba0..7a175edd 100644 --- a/ipmininet/ipswitch.py +++ b/ipmininet/ipswitch.py @@ -10,7 +10,7 @@ class IPSwitch(LinuxBridge): """Linux Bridge (with optional spanning tree) extended to include the hubs""" - def __init__( + def __init__( # noqa: PLR0913, PLR0917 self, name: str, stp=True, diff --git a/ipmininet/link.py b/ipmininet/link.py index 24f8330a..416d2c2e 100644 --- a/ipmininet/link.py +++ b/ipmininet/link.py @@ -356,7 +356,7 @@ def __lt__(self, other): return address_comparator(self.addr, other.addr) < 0 -def address_comparator(a, b): +def address_comparator(a, b): # noqa: PLR0911 """Return -1, 0, 1 if a is less, equally, more visible than b. We define visibility according to IP version, address scope, address class, and address value""" @@ -403,13 +403,17 @@ def __init__(self, name: str, *args, **kw): try: node = kw["node"] except KeyError: - raise ValueError("PhysicalInterface() requires a node= argument") from None + raise ValueError( # noqa: TRY003 + "PhysicalInterface() requires a node= argument" + ) from None # Save the addresses from the root namespace try: _, v4, v6 = _addresses_of(name, node=None) except (subprocess.CalledProcessError, OSError): log.error("Cannot retrieve the addresses of interface", name, "!") - raise ValueError("Unknown physical interface name") from None + raise ValueError( # noqa: TRY003 + "Unknown physical interface name" + ) from None # cfr man ip-link; some devices cannot change of net ns if node.inNamespace and "netns-local: on" in subprocess.check_output( ("ethtool", "-k", name) diff --git a/ipmininet/router/__router.py b/ipmininet/router/__router.py index 3b593397..05420de5 100644 --- a/ipmininet/router/__router.py +++ b/ipmininet/router/__router.py @@ -84,7 +84,7 @@ def terminate(self): class IPNode(Node): """A Node which manages a set of daemons""" - def __init__( + def __init__( # noqa: PLR0913, PLR0917 self, name: str, config: type[NodeConfig] | tuple[type[NodeConfig], dict] = NodeConfig, diff --git a/ipmininet/router/config/__init__.py b/ipmininet/router/config/__init__.py index 37e5ddeb..aac31058 100644 --- a/ipmininet/router/config/__init__.py +++ b/ipmininet/router/config/__init__.py @@ -2,8 +2,6 @@ that can be used in a router.""" from .base import ( - BasicRouterConfig, - BorderRouterConfig, NodeConfig, RouterConfig, ) @@ -53,6 +51,10 @@ from .pimd import PIMD from .radvd import RADVD, AdvConnectedPrefix, AdvPrefix, AdvRDNSS from .ripng import RIPng +from .routing import ( + BasicRouterConfig, + BorderRouterConfig, +) from .sshd import SSHd from .staticd import STATIC, StaticRoute from .zebra import Mgmtd, Zebra diff --git a/ipmininet/router/config/base.py b/ipmininet/router/config/base.py index 02d4e194..338e8e7b 100644 --- a/ipmininet/router/config/base.py +++ b/ipmininet/router/config/base.py @@ -114,7 +114,7 @@ def register_daemon(self, cls: DaemonOption, **daemon_opts): try: cls, kw = cls except ValueError: - raise TypeError( + raise TypeError( # noqa: TRY003 f"Expected a tuple (Daemon, dict) but got {cls!s}" ) from None daemon_opts.update(kw) @@ -124,7 +124,7 @@ def register_daemon(self, cls: DaemonOption, **daemon_opts): if issubclass(cls, Daemon): cls = cls(self._node, **daemon_opts) else: - raise TypeError( + raise TypeError( # noqa: TRY003 f"Expected an object or a subclass of Daemon, got {cls} instead" ) else: @@ -149,7 +149,7 @@ def sysctl(self, *values: str): key, val = value.split("=") self._sysctl[key] = val except ValueError: - raise ValueError( + raise ValueError( # noqa: TRY003 f"sysctl must be specified using `key=val` format. Ignoring {value}" ) from None @@ -348,7 +348,7 @@ def render(self, cfg, **kwargs) -> dict[str, str]: "Couldnt render a config file(", self.template_filenames[i], ")" ) log.error(mako.exceptions.text_error_template().render()) - raise ValueError( + raise ValueError( # noqa: TRY003 f"Cannot render a configuration [{self._node.name}: {self.NAME}]" ) from None return cfg_content @@ -439,62 +439,3 @@ def build(self): def set_defaults(self, defaults): """:param logfile: the path to the logfile for the daemon :param routerid: the router id for this daemon""" - - -class BasicRouterConfig(RouterConfig): - """A basic router that will run an OSPF daemon""" - - def __init__( - self, - node: "Router", - daemons: Iterable[DaemonOption] = (), - additional_daemons: Iterable[DaemonOption] = (), - *args, - **kwargs, - ): - """A simple router made of at least an OSPF daemon - - :param additional_daemons: Other daemons that should be used""" - # Importing here to avoid circular import - from .ospf import OSPF - from .ospf6 import OSPF6 - - # We don't want any zebra-specific settings, so we rely on the - # OSPF/OSPF6 DEPENDS list for that daemon to run it with default - # settings. We also don't want specific settings beside the defaults, - # so we don't provide an instance but the class instead - d = list(daemons) - if node.use_v4: - d.append(OSPF) - if node.use_v6: - d.append(OSPF6) - d.extend(additional_daemons) - super().__init__(node, *args, daemons=d, **kwargs) - - -class BorderRouterConfig(BasicRouterConfig): - """A router config that will run both OSPF and BGP, and redistribute all - connected router into BGP.""" - - def __init__( - self, - node: "Router", - daemons: Iterable[DaemonOption] = (), - additional_daemons: Iterable[DaemonOption] = (), - *args, - **kwargs, - ): - """A simple router made of at least an OSPF daemon and a BGP daemon - - :param additional_daemons: Other daemons that should be used""" - from .bgp import AF_INET, AF_INET6, BGP - - af = [] - if node.use_v4: - af.append(AF_INET(redistribute=("connected", "ospf"))) - if node.use_v6: - af.append(AF_INET6(redistribute=("connected", "ospf6"))) - if af: - d = list(daemons) - d.append((BGP, {"address_families": af})) - super().__init__(node, *args, daemons=d, **kwargs) diff --git a/ipmininet/router/config/bgp.py b/ipmininet/router/config/bgp.py index ebcaddc1..fcad7516 100644 --- a/ipmininet/router/config/bgp.py +++ b/ipmininet/router/config/bgp.py @@ -337,7 +337,7 @@ def set_community( ) return self - def filter( + def filter( # noqa: PLR0913, PLR0917 self, name: str | None = None, policy=DENY, @@ -499,7 +499,9 @@ def filters_to_match_cond( if f not in prefix_list: prefix_list.append(f) else: - raise TypeError(f"Filter not yet implemented for {type(f).__name__}") + raise TypeError( # noqa: TRY003 + f"Filter not yet implemented for {type(f).__name__}" + ) return match_cond def add_set_action( @@ -761,7 +763,7 @@ def family(self): return "ip" if self.name == "ipv6": return "ip6" - raise ValueError(f"Unsupported AddressFamily {self.name}") + raise ValueError(f"Unsupported AddressFamily {self.name}") # noqa: TRY003 def AF_INET(*args, **kwargs): diff --git a/ipmininet/router/config/routing.py b/ipmininet/router/config/routing.py new file mode 100644 index 00000000..8d5b0ed2 --- /dev/null +++ b/ipmininet/router/config/routing.py @@ -0,0 +1,70 @@ +"""Router configurations that add the routing daemons themselves. + +``BasicRouterConfig`` and ``BorderRouterConfig`` need the OSPF, OSPF6 and BGP +daemon configuration classes, which in turn import ``.base``. They live in +their own module so that ``.base`` does not import those daemons. +""" + +from collections.abc import Iterable +from typing import TYPE_CHECKING + +from .base import DaemonOption, RouterConfig +from .bgp import AF_INET, AF_INET6, BGP +from .ospf import OSPF +from .ospf6 import OSPF6 + +if TYPE_CHECKING: + from ipmininet.router import Router + + +class BasicRouterConfig(RouterConfig): + """A basic router that will run an OSPF daemon""" + + def __init__( + self, + node: "Router", + daemons: Iterable[DaemonOption] = (), + additional_daemons: Iterable[DaemonOption] = (), + *args, + **kwargs, + ): + """A simple router made of at least an OSPF daemon + + :param additional_daemons: Other daemons that should be used""" + # We don't want any zebra-specific settings, so we rely on the + # OSPF/OSPF6 DEPENDS list for that daemon to run it with default + # settings. We also don't want specific settings beside the defaults, + # so we don't provide an instance but the class instead + d = list(daemons) + if node.use_v4: + d.append(OSPF) + if node.use_v6: + d.append(OSPF6) + d.extend(additional_daemons) + super().__init__(node, *args, daemons=d, **kwargs) + + +class BorderRouterConfig(BasicRouterConfig): + """A router config that will run both OSPF and BGP, and redistribute all + connected router into BGP.""" + + def __init__( + self, + node: "Router", + daemons: Iterable[DaemonOption] = (), + additional_daemons: Iterable[DaemonOption] = (), + *args, + **kwargs, + ): + """A simple router made of at least an OSPF daemon and a BGP daemon + + :param additional_daemons: Other daemons that should be used""" + af = [] + if node.use_v4: + af.append(AF_INET(redistribute=("connected", "ospf"))) + if node.use_v6: + af.append(AF_INET6(redistribute=("connected", "ospf6"))) + if af: + d = list(daemons) + d.append((BGP, {"address_families": af})) + super().__init__(node, *args, daemons=d, **kwargs) diff --git a/ipmininet/router/config/zebra.py b/ipmininet/router/config/zebra.py index f7f9a5a9..e1288982 100644 --- a/ipmininet/router/config/zebra.py +++ b/ipmininet/router/config/zebra.py @@ -362,7 +362,7 @@ def __init__( elif isinstance(e, (IPv4Network, IPv6Network, str)): self.entries.append(self.Entry(prefix=e)) else: - raise ValueError( + raise ValueError( # noqa: TRY003 f'"{e}" is not a valid prefix entry for the {family} family' ) @@ -440,7 +440,7 @@ def zebra_family(self): if self.family == "community": return "community" - raise ValueError(f"Unsupported family; {self.family}") + raise ValueError(f"Unsupported family; {self.family}") # noqa: TRY003 def __eq__(self, other): return ( @@ -472,7 +472,7 @@ def __eq__(self, other): class RouteMapEntry: - def __init__( + def __init__( # noqa: PLR0913, PLR0917 self, family: str, match_policy=PERMIT, @@ -535,7 +535,9 @@ def append_set_action(self, set_actions): def update(self, rm_entry: "RouteMapEntry"): if not self.can_merge(rm_entry): - raise ValueError("Attempting to merge incompatible RouteMap Entries") + raise ValueError( # noqa: TRY003 + "Attempting to merge incompatible RouteMap Entries" + ) self.append_set_action(rm_entry.set_actions) self.append_match_cond(rm_entry.match_cond) @@ -625,7 +627,9 @@ def remove_default_policy(self): def update(self, rm: "RouteMap"): if self != rm: - raise ValueError("Attempting to update incompatible RouteMaps") + raise ValueError( # noqa: TRY003 + "Attempting to update incompatible RouteMaps" + ) for order in rm.entries: self.entry(rm.entries[order], order) diff --git a/ipmininet/srv6.py b/ipmininet/srv6.py index 611a1f56..5cec18f1 100644 --- a/ipmininet/srv6.py +++ b/ipmininet/srv6.py @@ -59,7 +59,7 @@ def srv6_segment_space( """ if isinstance(intf, str): if not isinstance(node, IPNode): - raise TypeError( + raise TypeError( # noqa: TRY003 "Cannot retrieve an IPIntf from name without " "passing its node as parameter" ) @@ -96,7 +96,7 @@ def __init__( elif isinstance(destination, IPIntf): self.prefixes.extend(srv6_segment_space(intf=destination)) else: - raise TypeError( + raise TypeError( # noqa: TRY003 "The LocalSIDTable cannot be created because " f"the destination {destination} cannot be matched" ) @@ -115,7 +115,9 @@ def __init__( while self.num in tables: self.num += 1 if self.num >= MIN_RESERVED_TABLE: - raise RuntimeError("Cannot find a free table number on the host") + raise RuntimeError( # noqa: TRY003 + "Cannot find a free table number on the host" + ) # Create the table self.create() @@ -173,7 +175,7 @@ def __init__( self.source = node if not isinstance(node, str) else net[node] itfs = realIntfList(self.source) if len(itfs) == 0: - raise ValueError( + raise ValueError( # noqa: TRY003 f"Cannot install SRv6Route {self} without" f" a real interface on node {self.source.name}\n" ) @@ -181,7 +183,7 @@ def __init__( # Check SRv6 capability if not self.is_available(): - raise ValueError( + raise ValueError( # noqa: TRY003 f"Cannot create {self} because the distribution does not support it" ) @@ -258,7 +260,7 @@ def nexthops_to_ips( elif isinstance(h, IPIntf): ip = h.ip6 if v6 else h.ip if ip is None: - raise ValueError( + raise ValueError( # noqa: TRY003 f"Cannot find for the nexthop {nh}" f" a global IPv{6 if v6 else 4} address\n" ) from None @@ -315,7 +317,7 @@ class SRv6Encap(SRv6Route): ENCAP = "encap" INLINE = "inline" - def __init__( + def __init__( # noqa: PLR0913, PLR0917 self, net: IPNet, node: IPNode | str, @@ -342,7 +344,7 @@ def __init__( preferred if the destination prefix is the same. """ if len(through) == 0: - raise ValueError( + raise ValueError( # noqa: TRY003 "It does not make sense to use Segment Routing without any redirection." ) self.nexthops = list(through) @@ -573,7 +575,7 @@ def __init__( cases, the default IPv6 address is selected. """ if len(segments) == 0: - raise ValueError( + raise ValueError( # noqa: TRY003 "It does not make sense to use Segment Routing without any segment." ) self.segments = self.nexthops_to_ips(segments) diff --git a/ipmininet/tests/utils.py b/ipmininet/tests/utils.py index e85c6a81..a376542f 100644 --- a/ipmininet/tests/utils.py +++ b/ipmininet/tests/utils.py @@ -106,7 +106,7 @@ def traceroute(net: IPNet, src: str, dst_ip: str, timeout=300, poll=1.0) -> list return [] -def assert_path( +def assert_path( # noqa: PLR0913, PLR0917 net: IPNet, expected_path: list[str], v6=False, @@ -219,7 +219,7 @@ def assert_connectivity(net: IPNet, v6=False, attempts=1500, translate_address=T assert connected, "Cannot ping all hosts over %s" % ("IPv4" if not v6 else "IPv6") -def check_tcp_connectivity( +def check_tcp_connectivity( # noqa: PLR0913, PLR0917 client: IPNode, server: IPNode, v6=False, @@ -243,7 +243,7 @@ def check_tcp_connectivity( t += 1 if server_p.poll() is not None: out, err = server_p.communicate() - raise AssertionError( + raise AssertionError( # noqa: TRY003 "The netcat server used to check TCP connectivity failed" f" with the output:\n[stdout]\n{out}\n[stderr]\n{err}" ) diff --git a/ipmininet/utils.py b/ipmininet/utils.py index 1f303ea0..2373ad72 100644 --- a/ipmininet/utils.py +++ b/ipmininet/utils.py @@ -43,7 +43,7 @@ def require_cmd(cmd: str, help_str: str | None = None): if help_str: log.error(help_str) - raise RuntimeError(f"[{cmd}] is not available in $PATH") + raise RuntimeError(f"[{cmd}] is not available in $PATH") # noqa: TRY003 def otherIntf(intf: Intf) -> Optional["IPIntf"]: @@ -60,14 +60,11 @@ def realIntfList(n: Node) -> list["IPIntf"]: def address_pair(n: Node, use_v4=True, use_v6=True) -> tuple[str | None, str | None]: """Returns a tuple (ip, ip6) with ip/ip6 being one of the IPv4/IPv6 addresses of the node n""" - from .link import IPIntf # Prevent circular imports - v4_str = v6_str = None for itf in n.intfList(): - # Mininet switches have a loopback interface - # declared as an Intf. + # Mininet switches have a loopback interface declared as an Intf. # This object does not have ips() or ip6s() methods. - if not isinstance(itf, IPIntf): + if not hasattr(itf, "ips"): continue if use_v4 and v4_str is None: @@ -91,9 +88,9 @@ def is_subnet_of(a: IPv4Network | IPv6Network, b: IPv4Network | IPv6Network) -> try: # Always false if one is v4 and the other is v6. if a.version != b.version: - raise TypeError(f"{a} and {b} are not of the same version") + raise TypeError(f"{a} and {b} are not of the same version") # noqa: TRY003 except AttributeError: - raise TypeError( + raise TypeError( # noqa: TRY003 f"Unable to test subnet containment between {a} and {b}" ) from None else: diff --git a/pyproject.toml b/pyproject.toml index dde1bf53..b25370ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,25 +112,6 @@ select = [ # strings, a literal `ip addr` output fixture). "ipmininet/examples/simple_ospf_network.py" = ["E501"] "ipmininet/tests/test_pure.py" = ["E501"] -# Architectural rules deferred for existing files (see NOTES.md): -# - PLC0415: intentional circular-import avoidance -# - TRY003: requires per-message exception classes -# - PLR0913/PLR0917/PLR0912/PLR0911/PLR0915: require function refactors -"ipmininet/examples/ripng_network_adjust.py" = ["PLR0913", "PLR0917"] -"ipmininet/examples/spanning_tree_adjust.py" = ["PLR0913", "PLR0917"] -"ipmininet/host/config/named.py" = ["PLR0912", "PLR0913", "PLR0917"] -"ipmininet/install/utils.py" = ["PLC0415", "TRY003"] -"ipmininet/ipnet.py" = ["PLR0912", "PLR0913", "PLR0917", "TRY003"] -"ipmininet/ipovs_switch.py" = ["PLR0913", "PLR0917", "TRY003"] -"ipmininet/ipswitch.py" = ["PLR0913", "PLR0917"] -"ipmininet/link.py" = ["PLR0911", "TRY003"] -"ipmininet/router/__router.py" = ["PLR0913", "PLR0917"] -"ipmininet/router/config/base.py" = ["PLC0415", "TRY003"] -"ipmininet/router/config/bgp.py" = ["PLR0913", "PLR0917", "TRY003"] -"ipmininet/router/config/zebra.py" = ["PLR0913", "PLR0917", "TRY003"] -"ipmininet/srv6.py" = ["PLR0913", "PLR0917", "TRY003"] -"ipmininet/tests/utils.py" = ["PLR0913", "PLR0917", "TRY003"] -"ipmininet/utils.py" = ["PLC0415", "TRY003"] [tool.mdformat] wrap = "keep" From 2dd70d0b1a1331525b1f6645ab3d5892b16e1856 Mon Sep 17 00:00:00 2001 From: iakov Date: Sat, 5 Sep 2026 19:17:56 +0300 Subject: [PATCH 64/69] test(coverage): raise the full-suite coverage gate to 90 with scenario tests (#47) * test(coverage): expand scenario coverage toward the 90 gate Add rootless scenario tests exercising the zebra routing-policy DSL and the IPTopo/Subnet/NetworkCapture APIs, extend the rootful topology DB and link-failure suites with error-path and API corner-case scenarios, and enable workflow_dispatch on the pull-request test workflows so the full suite can be measured on this branch. Make the heavy-test coverage gate enforcement fail the job again by running 'coverage report -m' last. * test(topologydb): resolve the h1-r1 interface by link lookup mininet indexes interfaces by their own name, not by the peer node name, so looking up net['h1'].intf('r1') raises KeyError. Resolve the interface through connectionsTo() instead. * test(topologydb): make the DB lookup scenarios robust to IP layout Derive interface lookups from the recorded interface names and the DB records themselves instead of assuming naming or address-format details of the underlying interfaces, which differ before the network is started. * ci(coverage): raise the full-suite gate to 90 The scenario tests push the blended total (statements + branch arcs) to 90.9%, so the gated master heavy-test can now enforce fail_under = 90. --- .github/workflows/container-test.yaml | 3 + .github/workflows/heavy-test.yaml | 2 +- .github/workflows/test.yaml | 12 +- ipmininet/tests/test_linkfailure.py | 24 ++ .../tests/test_policy_config_scenarios.py | 253 ++++++++++++++++++ ipmininet/tests/test_topo_scenarios.py | 157 +++++++++++ ipmininet/tests/test_topologydb.py | 68 ++++- pyproject.toml | 2 +- scripts/run-tests-parallel.sh | 2 + 9 files changed, 515 insertions(+), 8 deletions(-) create mode 100644 ipmininet/tests/test_policy_config_scenarios.py create mode 100644 ipmininet/tests/test_topo_scenarios.py diff --git a/.github/workflows/container-test.yaml b/.github/workflows/container-test.yaml index 2ffa8f40..d9586e21 100644 --- a/.github/workflows/container-test.yaml +++ b/.github/workflows/container-test.yaml @@ -6,11 +6,14 @@ # artifacts. Runs the full test suite inside it with root privileges. Every # worker runs in its own isolated namespace (scripts/run-tests-parallel.sh), # mirroring the bare-metal job but in a reproducible environment. +# +# Runs on pull requests and on demand; master pushes keep this job too. name: Container test on: pull_request: push: branches: [master] + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/heavy-test.yaml b/.github/workflows/heavy-test.yaml index 8d252336..f0123479 100644 --- a/.github/workflows/heavy-test.yaml +++ b/.github/workflows/heavy-test.yaml @@ -57,9 +57,9 @@ jobs: run: | sudo env "PATH=$PATH" bash -c ' uv run coverage combine || true - uv run coverage report -m uv run coverage xml -o coverage.xml uv run coverage html -d htmlcov + uv run coverage report -m ' - name: Upload coverage report uses: actions/upload-artifact@v7 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5128b0c0..93e3fdb3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,4 +1,4 @@ -# Bare-metal CI, run on pull requests. +# Bare-metal CI, run on pull requests and on demand. # # Two jobs: # - rootless: installs the venv with uv and runs the test suite as the @@ -9,13 +9,15 @@ # # Lint/format checks run in the `rootless` job below, before the light tests. # -# This workflow only runs on pull requests: master changes arrive through -# merged PRs whose checks already ran here, so re-running this suite on every -# master push would duplicate the coverage-gated `heavy-test.yaml` job. Master -# pushes keep that job plus `container-test.yaml` and `docs.yaml`. +# Runs on pull requests and via manual dispatch (so the full suite can be +# exercised on any branch before merge): master changes arrive through merged +# PRs whose checks already ran here, so re-running this suite on every master +# push would duplicate the coverage-gated `heavy-test.yaml` job. Master pushes +# keep that job plus `container-test.yaml` and `docs.yaml`. name: Test IPMininet on: pull_request: + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/ipmininet/tests/test_linkfailure.py b/ipmininet/tests/test_linkfailure.py index b1764a4c..6d69377a 100644 --- a/ipmininet/tests/test_linkfailure.py +++ b/ipmininet/tests/test_linkfailure.py @@ -150,3 +150,27 @@ def test_randomFailureOnTargetedLink(): net.stop() finally: cleanup() + + +@require_root +def test_ping_and_failure_api_edges(): + """Exercise the corner cases of the ping and failure APIs on a live net.""" + try: + net = IPNet(topo=Topo()) + net.start() + + # Wait for OSPF convergence before touching the failure APIs + assert_connectivity(net, v6=False) + assert_connectivity(net, v6=True) + + # Ping API corner cases + assert net.ping(use_v4=False, use_v6=False) == 0 + assert net.ping(hosts=[]) == 0 + + # Failure API corner cases: bogus nodes and too many downed links + assert net.runFailurePlan([("ghost1", "ghost2")]) == [] + assert net.randomFailure(99) == [] + + net.stop() + finally: + cleanup() diff --git a/ipmininet/tests/test_policy_config_scenarios.py b/ipmininet/tests/test_policy_config_scenarios.py new file mode 100644 index 00000000..9bd21bf3 --- /dev/null +++ b/ipmininet/tests/test_policy_config_scenarios.py @@ -0,0 +1,253 @@ +"""Scenarios building routing policy and config objects. + +These tests exercise the zebra configuration domain-specific language the way +a real topology would compose it (access-lists, prefix-lists, route-maps and +their merge/update lifecycle) plus a few configuration edge cases. They never +require root privileges and never start a network. +""" + +from ipaddress import ip_network +from typing import cast +from unittest.mock import Mock + +import pytest + +from ipmininet.router import IPNode +from ipmininet.router.config.base import NodeConfig +from ipmininet.router.config.utils import ConfigDict +from ipmininet.router.config.zebra import ( + AccessList, + AccessListEntry, + CommunityList, + PrefixList, + PrefixListEntry, + RouteMap, + RouteMapEntry, + RouteMapMatchCond, + RouteMapSetAction, +) +from ipmininet.utils import require_cmd + +_V4_MAX_PREFIX = 32 +_V6_MAX_PREFIX = 128 +_V4_LE_BOUND = 24 +_V4_GE_BOUND = 16 +_TWO = 2 +_THREE = 3 + + +def _v4_acl_entry(prefix: str) -> AccessListEntry: + return AccessListEntry(prefix, family="ipv4") + + +def test_access_list_entries_with_family(): + v4 = _v4_acl_entry("10.0.0.0/8") + assert v4.prefix == ip_network("10.0.0.0/8") + assert v4.family == "ipv4" + assert v4.action == "permit" + assert v4.zebra_family == "ip" + + v6 = AccessListEntry("2001:db8::/32", family="ipv6") + assert v6.family == "ipv6" + assert v6.zebra_family == "ipv6" + + with pytest.raises(AssertionError): + AccessListEntry("10.0.0.0/8", family="ipv6") + + +def test_access_list_auto_name_and_family(): + acl = AccessList("ipv4", entries=("any",)) + assert acl.name.startswith("acl") + assert acl.entries[0].family == "ipv4" + assert acl.zebra_family == "" + + acl6 = AccessList("ipv6", entries=("any",)) + assert acl6.name.startswith("acl") + assert acl6.entries[0].family == "ipv6" + assert acl6.zebra_family == "ipv6 " + + +def test_prefix_list_any_entry(): + pl4 = PrefixList("ipv4", entries=("any",)) + entry4 = pl4.entries[0] + assert entry4.prefix == ip_network("0.0.0.0/0") + assert entry4.le == _V4_MAX_PREFIX + assert not hasattr(entry4, "ge") + + pl6 = PrefixList("ipv6", entries=("any",)) + entry6 = pl6.entries[0] + assert entry6.prefix == ip_network("::/0") + assert entry6.le == _V6_MAX_PREFIX + + +def test_prefix_list_le_ge_bounds(): + entry = PrefixListEntry("10.0.0.0/8", le=_V4_LE_BOUND) + assert entry.le == _V4_LE_BOUND + assert entry.ge is None + + entry = PrefixListEntry("10.0.0.0/8", ge=_V4_GE_BOUND) + assert entry.ge == _V4_GE_BOUND + assert entry.le is None + + entry = PrefixListEntry("10.0.0.0/8", le=_V4_LE_BOUND, ge=_V4_GE_BOUND) + assert entry.le == _V4_LE_BOUND + assert entry.ge == _V4_GE_BOUND + + with pytest.raises(AssertionError): + PrefixListEntry("10.0.0.0/8", le=_V4_GE_BOUND, ge=_V4_LE_BOUND) + with pytest.raises(AssertionError): + PrefixListEntry("10.0.0.0/8", le=_V4_MAX_PREFIX + 1) + + +def test_prefix_list_auto_name(): + pl = PrefixList("ipv4") + assert pl.name.startswith("pfxl") + + +def test_prefix_list_entry_types(): + prefix_list = PrefixList( + "ipv4", + entries=[ + ip_network("192.168.0.0/16"), + "172.16.0.0/12", + PrefixListEntry("10.0.0.0/8", family="ipv4"), + ], + ) + assert len(prefix_list.entries) == _THREE + + with pytest.raises(ValueError): + PrefixList("ipv4", entries=("10.0.0.0/8", 3)) + + +def test_route_map_entry_from_tuples(): + entry = RouteMapEntry( + "ipv4", + match_cond=[ + ("acl", "my_acl"), + RouteMapMatchCond(cond_type="prefix-list", condition="pfx"), + ], + set_actions=[ + ("localpref", 120), + RouteMapSetAction(action_type="community", value="65000:1"), + ], + ) + assert len(entry.match_cond) == _TWO + assert all(isinstance(c, RouteMapMatchCond) for c in entry.match_cond) + assert len(entry.set_actions) == _TWO + assert all(isinstance(a, RouteMapSetAction) for a in entry.set_actions) + + entry.append_match_cond([RouteMapMatchCond("acl", "extra")]) + assert len(entry.match_cond) == _THREE + entry.append_match_cond([RouteMapMatchCond("acl", "extra")]) + assert len(entry.match_cond) == _THREE + + entry.append_set_action([RouteMapSetAction("community", "65000:2")]) + assert len(entry.set_actions) == _THREE + entry.append_set_action([RouteMapSetAction("community", "65000:2")]) + assert len(entry.set_actions) == _THREE + + +def test_route_map_entry_merge(): + base = RouteMapEntry("ipv4") + base.append_set_action([RouteMapSetAction("localpref", 200)]) + other = RouteMapEntry("ipv4") + other.append_match_cond([RouteMapMatchCond("acl", "extra")]) + base.update(other) + assert len(base.match_cond) == 1 + assert len(base.set_actions) == 1 + + incompatible = RouteMapEntry("ipv6") + with pytest.raises(ValueError): + base.update(incompatible) + + +def test_route_map_management(): + route_map = RouteMap("ipv4") + assert route_map.name.startswith("rm") + assert route_map.describe == "route-map" + + first = RouteMapEntry("ipv4") + denied = RouteMapEntry("ipv4", match_policy="deny") + route_map.entry(first) + route_map.entry(denied, order=5) + assert len(route_map) == _TWO + + route_map.remove_entry(5) + assert len(route_map) == 1 + route_map.remove_entry(5) + assert len(route_map) == 1 + + route_map.remove_default_policy() + + with pytest.raises(ValueError): + route_map.update(RouteMap("ipv4")) + + +def test_route_map_default_policy(): + route_map = RouteMap("ipv6") + assert route_map.default_policy_set() is False + route_map.entry(RouteMapEntry("ipv6"), order=RouteMap.DEFAULT_POLICY) + assert route_map.default_policy_set() is True + route_map.remove_default_policy() + assert route_map.default_policy_set() is True + assert RouteMap.DEFAULT_POLICY not in route_map.entries + + +def test_route_map_find_entry(): + condition = RouteMapMatchCond("acl", "found") + matching = RouteMapEntry("ipv4", match_cond=[condition]) + route_map = RouteMap("ipv4") + route_map.entry(matching) + route_map.entry(RouteMapEntry("ipv4")) + + assert route_map.find_entry_by_match_condition([condition]) is matching + assert ( + route_map.find_entry_by_match_condition([RouteMapMatchCond("acl", "missing")]) + is None + ) + + +def test_route_map_match_condition_families(): + community = RouteMapMatchCond("community", "65000:1", family="community") + assert community.zebra_family == "community" + + v6 = RouteMapMatchCond("prefix-list", "pfx", family="ipv6") + assert v6.zebra_family == "ipv6" + assert v6 == RouteMapMatchCond("prefix-list", "pfx", family="ipv6") + + unfamilied = RouteMapMatchCond("acl", "some-acl") + with pytest.raises(ValueError): + _ = unfamilied.zebra_family + + +def test_community_list_auto_name(): + community = CommunityList(community=1) + assert community.name.startswith("cml") + assert community.family == "community" + assert community.action == "permit" + + +def test_config_dict_usage(): + cfg = ConfigDict() + cfg.name = "router" + cfg["zebra"] = {"static_routes": []} + assert cfg.name == "router" + assert cfg["zebra"]["static_routes"] == [] + assert cfg.missing_attr is None + + +def test_node_config_sysctl_format(): + cfg = NodeConfig(node=cast(IPNode, Mock(name="node"))) + cfg.sysctl = "net.ipv4.ip_forward=1" + assert ("net.ipv4.ip_forward", "1") in list(cfg.sysctl) + + with pytest.raises(ValueError): + cfg.sysctl = "net.ipv4.ip_forward" + + +def test_require_cmd_reports_help(): + with pytest.raises(RuntimeError): + require_cmd( + "definitely-missing-ipmininet-test-command", + help_str="missing command", + ) diff --git a/ipmininet/tests/test_topo_scenarios.py b/ipmininet/tests/test_topo_scenarios.py new file mode 100644 index 00000000..83eb7831 --- /dev/null +++ b/ipmininet/tests/test_topo_scenarios.py @@ -0,0 +1,157 @@ +"""Scenarios exercising the IPTopo DSL, the Subnet overlay and NetworkCapture. + +These tests build real IPTopo descriptions the way a scenario would, without +starting any network or requiring root privileges. The Subnet and NetworkCapture +overlays are applied through ``topo.build()`` just like in a real experiment. +""" + +import pytest + +from ipmininet.iptopo import IPTopo, UnknownTopologyAttributeError +from ipmininet.overlay import NetworkCapture, NoCaptureAnchorError, Subnet + +_COST = 5 + + +def _ips_per_node(topo: IPTopo) -> dict[str, list[str]]: + """Return the addresses assigned to each node's LAN interfaces.""" + ips: dict[str, list[str]] = {} + for src, dst, _key, attrs in topo.iterLinks(withInfo=True, withKeys=True): + for node, params in ((src, "params1"), (dst, "params2")): + ips.setdefault(node, []).extend(attrs.get(params, {}).get("ip", ())) + return ips + + +def _build_lan(prefix="s") -> IPTopo: + topo = IPTopo() + topo.addHost("h1") + topo.addSwitch(prefix) + topo.addHost("h2") + topo.addLink("h1", prefix) + topo.addLink(prefix, "h2") + return topo + + +def test_topo_node_type_helpers(): + topo = IPTopo() + topo.addRouter("r1") + topo.addHost("h1") + topo.addHub("hub1") + topo.addLink("r1", "hub1") + topo.addLink("hub1", "h1") + topo.build() + + assert topo.isRouter("r1") + assert not topo.isRouter("h1") + assert topo.isHub("hub1") + assert not topo.isHub("h1") + assert not topo.isRouter("ghost") + assert [str(r) for r in topo.routers()] == ["r1"] + assert topo.hubs() == ["hub1"] + assert [str(h) for h in topo.hosts()] == ["h1"] + + +def test_topo_overlay_dispatch(): + topo = IPTopo() + topo.addHost("h1") + topo.addSwitch("s1") + topo.addLink("h1", "s1") + assert topo.overlays == [] + + topo.addSubnet(nodes=["h1"], subnets=["10.0.0.0/24"]) + topo.addNetworkCapture() + assert [type(o).__name__ for o in topo.overlays] == ["Subnet", "NetworkCapture"] + assert isinstance(topo.overlays[0], Subnet) + assert isinstance(topo.overlays[1], NetworkCapture) + + +def test_topo_unknown_attribute_raises(): + topo = IPTopo() + with pytest.raises(UnknownTopologyAttributeError): + topo.addNoSuchOverlay() + with pytest.raises(UnknownTopologyAttributeError): + _ = topo.no_such_attribute + + +def test_subnet_assigns_addresses_in_order(): + topo = _build_lan() + topo.addSubnet(nodes=["h1", "h2"], subnets=["10.0.0.0/24"]) + topo.build() + + assert topo.overlays[0].consistent + ips = _ips_per_node(topo) + assert ips["h1"] == ["10.0.0.1/24"] + assert ips["h2"] == ["10.0.0.2/24"] + + +def test_subnet_multiple_subnets_same_interface(): + topo = _build_lan() + topo.addSubnet(nodes=["h1", "h2"], subnets=["10.0.0.0/24", "192.168.0.0/30"]) + topo.build() + + ips = _ips_per_node(topo) + assert ips["h1"] == ["10.0.0.1/24", "192.168.0.1/30"] + assert ips["h2"] == ["10.0.0.2/24", "192.168.0.2/30"] + + +def test_subnet_nodes_on_distinct_lans_are_inconsistent(): + topo = IPTopo() + topo.addHost("h1") + topo.addHost("h2") + topo.addSwitch("s1") + topo.addSwitch("s2") + topo.addLink("h1", "s1") + topo.addLink("h2", "s2") + topo.addSubnet(nodes=["h1", "h2"], subnets=["10.0.0.0/24"]) + topo.build() + + assert not topo.overlays[0].consistent + + +def test_subnet_too_small_is_inconsistent(): + topo = IPTopo() + for host in ("h1", "h2", "h3", "h4"): + topo.addHost(host) + topo.addSwitch("s1") + for host in ("h1", "h2", "h3", "h4"): + topo.addLink(host, "s1") + topo.addSubnet(nodes=["h1", "h2", "h3", "h4"], subnets=["10.0.0.0/30"]) + topo.build() + + assert not topo.overlays[0].consistent + + +def test_subnet_invalid_network_is_inconsistent(): + topo = _build_lan() + topo.addSubnet(nodes=["h1", "h2"], subnets=["banana"]) + topo.build() + + assert not topo.overlays[0].consistent + + +def test_subnet_without_nodes_is_consistent(): + topo = _build_lan() + topo.addSubnet(nodes=[], subnets=["10.0.0.0/24"]) + topo.build() + + assert topo.overlays[0].consistent + assert not any(_ips_per_node(topo).values()) + + +def test_overlay_element_properties(): + subnet = Subnet(nodes=["h1"], subnets=["10.0.0.0/24"]) + subnet.add_node("h2") + subnet.set_node_property("h1", "some_key", True) + subnet.set_link_property("h1-h2", "cost", _COST) + + assert subnet.nodes == ["h1", "h2"] + assert subnet.node_property("h1")["some_key"] + assert not subnet.node_property("h2").get("some_key") + assert subnet.link_property("h1-h2")["cost"] == _COST + + +def test_network_capture_requires_anchor(): + capture = NetworkCapture() + assert not capture.check_consistency(IPTopo()) + with pytest.raises(NoCaptureAnchorError): + capture.start() diff --git a/ipmininet/tests/test_topologydb.py b/ipmininet/tests/test_topologydb.py index 9e42a257..607524ba 100644 --- a/ipmininet/tests/test_topologydb.py +++ b/ipmininet/tests/test_topologydb.py @@ -4,6 +4,7 @@ import os import shutil import tempfile +from ipaddress import ip_interface import pytest @@ -18,9 +19,34 @@ from ipmininet.iptopo import IPTopo from ipmininet.router import Router from ipmininet.tests import require_root -from ipmininet.topologydb import TopologyDB +from ipmininet.topologydb import ( + NoSuchLinkError, + NoSuchNodeError, + NotARouterError, + TopologyDB, +) from ipmininet.utils import otherIntf, realIntfList +_MIN_SUBNET_ADDRESSES = 2 + + +class _MixedNetworkTopo(IPTopo): + """A small network mixing routers, hosts and a switch""" + + def build(self, *args, **kwargs): + r1 = self.addRouter("r1") + r2 = self.addRouter("r2") + h1 = self.addHost("h1") + s1 = self.addSwitch("s1") + h2 = self.addHost("h2") + h3 = self.addHost("h3") + self.addLink(r1, r2) + self.addLink(h1, r1) + self.addLink(r1, s1) + self.addLink(s1, h2) + self.addLink(s1, h3) + super().build(*args, **kwargs) + @require_root @pytest.mark.parametrize( @@ -117,3 +143,43 @@ def test_topologydb(topology: type[IPTopo]): finally: net.stop() shutil.rmtree(db_dir, ignore_errors=True) + + +@require_root +def test_topologydb_lookups_and_errors(): + net = IPNet(topo=_MixedNetworkTopo()) + try: + db = TopologyDB(net=net) + + assert db["r1"]["type"] == "router" + assert db.node("h1")["type"] == "host" + + itf_names = db.interfaces("h1") + assert itf_names + itf = net["h1"].intf(itf_names[0]) + assert otherIntf(itf) is not None and otherIntf(itf).node.name == "r1" + assert db.interface("h1", "r1") == ip_interface(db["h1"]["r1"]["ip"]) + assert db.subnet("h1", "r1").num_addresses >= _MIN_SUBNET_ADDRESSES + + assert db.interface_bandwidth("h1", "r1") in (None, -1) + + interfaces = {itf.name for itf in realIntfList(net["r1"])} + assert set(db.interfaces("r1")) == interfaces + + with pytest.raises(NoSuchNodeError): + db["ghost"] + with pytest.raises(NoSuchNodeError): + db.interface("ghost", "r1") + with pytest.raises(NoSuchLinkError): + db.interface("h1", "ghost") + with pytest.raises(NoSuchLinkError): + db.interface_bandwidth("h1", "ghost") + with pytest.raises(NotARouterError): + db.routerid("h1") + finally: + net.stop() + + +def test_topologydb_without_data(): + db = TopologyDB() + assert db._network == {} diff --git a/pyproject.toml b/pyproject.toml index b25370ad..28106581 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ omit = [ [tool.coverage.report] show_missing = true skip_covered = true -fail_under = 85 +fail_under = 90 [tool.ipmininet] duplication_max = 51 diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index 9870f383..b919bf66 100755 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -46,6 +46,8 @@ if [ ${#ARGS[@]} -eq 0 ]; then ipmininet/tests/test_cli.py ipmininet/tests/test_misc.py ipmininet/tests/test_pure.py + ipmininet/tests/test_topo_scenarios.py + ipmininet/tests/test_policy_config_scenarios.py ipmininet/tests/test_physicalinterface.py ipmininet/tests/test_address_alllocation.py ipmininet/tests/test_dhcp.py From cac375b7160f249efaab80ce78cbab3630e15c2e Mon Sep 17 00:00:00 2001 From: iakov Date: Sun, 6 Sep 2026 03:22:42 +0300 Subject: [PATCH 65/69] fix(zebra): always set PrefixListEntry.ge and extend coverage to 92% (#48) * fix(zebra): always set PrefixListEntry.ge, including for 'any' The 'any' prefix-list entry returned early from __init__ before assigning .ge, leaving it undefined. Template code that renders the prefix-list then raises AttributeError when it checks 'if e.ge'. Initialize .ge to None so 'all' entries expose a consistent attribute surface. * test(coverage): cover ExaBGP attribute model, link descriptions and capture headers Adds rootless scenario tests that exercise code previously only reachable inside rootful or hardware flows: - test_exabgp_model_scenarios builds known and hex-encoded attributes, flag bit layouts and route lookups, closing gaps in the ExaBGP value model. - test_topo_scenarios now drives LinkDescription indexing/ordering/errors and the pcap/pcapng magic-byte sniffing, covering the description and capture header helpers rootlessly. - Registers the new module in the heavy suite file list. * test(topo_scenarios): drop unused os import in capture header test * test(topo_scenarios): write capture headers outside the pytest basetemp The xdist workers run in a private mount namespace with a fresh /tmp (scripts/py-unshare.sh), so pytest's basetemp directory does not survive into the worker and the tmp_path fixture flaked at session wrap-up on the heavy-test run. Follow the TemporaryDirectory convention already used by test_network_capture.py for these rootless capture-header tests. --- ipmininet/router/config/zebra.py | 1 + .../tests/test_exabgp_model_scenarios.py | 134 ++++++++++++++++++ .../tests/test_policy_config_scenarios.py | 3 +- ipmininet/tests/test_topo_scenarios.py | 96 ++++++++++++- scripts/run-tests-parallel.sh | 1 + 5 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 ipmininet/tests/test_exabgp_model_scenarios.py diff --git a/ipmininet/router/config/zebra.py b/ipmininet/router/config/zebra.py index e1288982..cb54f7c2 100644 --- a/ipmininet/router/config/zebra.py +++ b/ipmininet/router/config/zebra.py @@ -293,6 +293,7 @@ def __init__( ip_network("0.0.0.0/0") if self.family == "ipv4" else ip_network("::/0") ) self.le = type_mask[self.family] + self.ge = None return if le is not None: diff --git a/ipmininet/tests/test_exabgp_model_scenarios.py b/ipmininet/tests/test_exabgp_model_scenarios.py new file mode 100644 index 00000000..22de6f94 --- /dev/null +++ b/ipmininet/tests/test_exabgp_model_scenarios.py @@ -0,0 +1,134 @@ +"""Scenarios building ExaBGP route attributes the way an experiment would. + +These tests compose BGP routes from attributes and verify their ExaBGP string +and hexadecimal renderings, mirroring what the exabgp daemon config generation +produces (known attributes, unknown attributes described in hex and flag +overrides). They never require root privileges and never start a network. +""" + +from ipaddress import ip_network + +import pytest + +from ipmininet.router.config.exabgp import ( + BGPAttribute, + BGPAttributeFlags, + BGPRoute, + ExaList, + HexRepresentable, + NotHexRepresentableError, + UnknownBGPAttributeError, +) + +_MAX_UINT16 = 65535 +_MED_VALUE = 42 +_UNKNOWN_ATTR_ID = 42 +_HEX_VALUE = 2658 +_ASN_FIRST = 1 +_ASN_SECOND = 56 +_ASN_THIRD = 97 +_COMMUNITY_FIRST = "1:666" +_COMMUNITY_SECOND = "468:45687" +_KNOWN_MED = "med" + + +class _Uint16HexAttr(HexRepresentable): + """A user-defined attribute stored as an unsigned 16-bits value.""" + + def __init__(self, value): + assert 0 <= value <= _MAX_UINT16 + self.value = value + + def hex_repr(self): + return f"0X{self.value:04X}" + + def __str__(self): + return self.hex_repr() + + +def test_exalist_renders_known_values(): + as_path = ExaList([_ASN_FIRST, _ASN_SECOND, _ASN_THIRD]) + assert as_path.val == [_ASN_FIRST, _ASN_SECOND, _ASN_THIRD] + assert str(as_path) == "[ 1 56 97 ]" + + communities = ExaList([_COMMUNITY_FIRST, _COMMUNITY_SECOND]) + assert str(communities) == "[ 1:666 468:45687 ]" + + +def test_exalist_rejects_non_list(): + with pytest.raises(AssertionError): + ExaList(_COMMUNITY_FIRST) + + +def test_exalist_cannot_be_rendered_as_hex(): + with pytest.raises(NotHexRepresentableError): + ExaList([_ASN_FIRST, _ASN_SECOND]).hex_repr() + + +def test_known_attribute_string_rendering(): + med = BGPAttribute(_KNOWN_MED, _MED_VALUE) + assert med.flags is None + assert str(med) == "med 42" + + origin = BGPAttribute("origin", "igp") + assert str(origin) == "origin igp" + + as_path = BGPAttribute("as-path", ExaList([_ASN_FIRST, _ASN_SECOND])) + assert str(as_path) == "as-path [ 1 56 ]" + + +def test_unknown_attribute_name_raises(): + with pytest.raises(UnknownBGPAttributeError, match="not a known attribute"): + BGPAttribute("not-an-attribute", _MED_VALUE) + + +def test_unknown_attribute_hex_rendering(): + flags = BGPAttributeFlags(optional=1, transitive=1, partial=0, extended=0) + attr = BGPAttribute(_UNKNOWN_ATTR_ID, _Uint16HexAttr(_HEX_VALUE), flags) + assert str(attr) == "attribute [ 0x2a 0XC0 0X0A62 ]" + assert repr(attr) == "BGPAttribute(attr_type=42, val=0X0A62 flags=0XC0)" + + +def test_hex_attribute_requires_hex_value(): + with pytest.raises(AssertionError): + BGPAttribute(_UNKNOWN_ATTR_ID, _HEX_VALUE, BGPAttributeFlags(1, 1, 0, 0)) + + +def test_attribute_flags_rendering(): + flags = BGPAttributeFlags(optional=1, transitive=1, partial=1, extended=1) + assert flags.hex_repr() == "0XF0" + assert str(flags) == "0XF0" + assert "opt=1" in repr(flags) + + none = BGPAttributeFlags(0, 0, 0, 0) + assert str(none) == "0X0" + + +def test_attribute_flags_reject_invalid_bits(): + with pytest.raises(AssertionError): + BGPAttributeFlags(2, 0, 0, 0) + with pytest.raises(AssertionError): + BGPAttributeFlags(0, -1, 0, 0) + + +def test_bgp_route_string_rendering(): + route = BGPRoute( + ip_network("8.8.8.0/24"), + [BGPAttribute("next-hop", "self"), BGPAttribute(_KNOWN_MED, _MED_VALUE)], + ) + assert str(route) == "unicast 8.8.8.0/24 next-hop self med 42" + + +def test_bgp_route_attribute_lookup(): + route = BGPRoute( + ip_network("8.8.8.0/24"), + [ + BGPAttribute(_KNOWN_MED, _MED_VALUE), + BGPAttribute("origin", "igp"), + ], + ) + assert route["network"] == ip_network("8.8.8.0/24") + assert route["IPnetwork"] == ip_network("8.8.8.0/24") + assert route["med"].val == _MED_VALUE + assert route["origin"].val == "igp" + assert route["local-preference"] is None diff --git a/ipmininet/tests/test_policy_config_scenarios.py b/ipmininet/tests/test_policy_config_scenarios.py index 9bd21bf3..0bd1e305 100644 --- a/ipmininet/tests/test_policy_config_scenarios.py +++ b/ipmininet/tests/test_policy_config_scenarios.py @@ -72,12 +72,13 @@ def test_prefix_list_any_entry(): entry4 = pl4.entries[0] assert entry4.prefix == ip_network("0.0.0.0/0") assert entry4.le == _V4_MAX_PREFIX - assert not hasattr(entry4, "ge") + assert entry4.ge is None pl6 = PrefixList("ipv6", entries=("any",)) entry6 = pl6.entries[0] assert entry6.prefix == ip_network("::/0") assert entry6.le == _V6_MAX_PREFIX + assert entry6.ge is None def test_prefix_list_le_ge_bounds(): diff --git a/ipmininet/tests/test_topo_scenarios.py b/ipmininet/tests/test_topo_scenarios.py index 83eb7831..5fce0654 100644 --- a/ipmininet/tests/test_topo_scenarios.py +++ b/ipmininet/tests/test_topo_scenarios.py @@ -5,12 +5,32 @@ overlays are applied through ``topo.build()`` just like in a real experiment. """ +import tempfile +from pathlib import Path +from unittest.mock import Mock + import pytest from ipmininet.iptopo import IPTopo, UnknownTopologyAttributeError -from ipmininet.overlay import NetworkCapture, NoCaptureAnchorError, Subnet +from ipmininet.node_description import ( + LinkDescription, + LinkIndexError, + NodeDescription, + NodeNotOnLinkError, +) +from ipmininet.overlay import ( + NetworkCapture, + NoCaptureAnchorError, + Subnet, + _capture_header_size, +) _COST = 5 +_PCAP_MAGIC_LE = b"\xd4\xc3\xb2\xa1" +_PCAP_MAGIC_BE = b"\xa1\xb2\xc3\xd4" +_PCAPNG_MAGIC = b"\x0a\x0d\x0d\x0a" +_PCAP_GLOBAL_HEADER_SIZE = 24 +_PCAPNG_SECTION_HEADER_SIZE = 28 def _ips_per_node(topo: IPTopo) -> dict[str, list[str]]: @@ -155,3 +175,77 @@ def test_network_capture_requires_anchor(): assert not capture.check_consistency(IPTopo()) with pytest.raises(NoCaptureAnchorError): capture.start() + + +def test_link_description_indexing(): + topo = IPTopo() + topo.addHost("h1") + topo.addHost("h2") + link = topo.addLink("h1", "h2") + + assert link[0].node == "h1" + assert link[1].node == "h2" + assert link[LinkDescription.KEY_INDEX] == link.key + with pytest.raises(LinkIndexError): + _ = link[2] + + +def test_link_description_node_lookup_errors(): + topo = IPTopo() + topo.addHost("h1") + topo.addHost("h2") + link = topo.addLink("h1", "h2") + + assert link["h1"].node == "h1" + assert link["h2"].node == "h2" + with pytest.raises(NodeNotOnLinkError, match="is not on this link"): + _ = link["ghost"] + + +def test_link_description_ordering_by_key(): + topo = IPTopo() + topo.addHost("h1") + topo.addHost("h2") + first = topo.addLink("h1", "h2", key=1) + second = topo.addLink("h1", "h2", key=2) + + assert first < second.key + assert hash(first) == hash(1) + assert first == 1 + + +def test_node_description_without_topo_is_inert(): + node = NodeDescription("h1") + + node.addDaemon(Mock()) + assert node.get_config(Mock()) is None + + +def test_capture_header_size_reads_magic(): + with tempfile.TemporaryDirectory() as tmp: + for magic, expected in ( + (_PCAP_MAGIC_LE, _PCAP_GLOBAL_HEADER_SIZE), + (_PCAP_MAGIC_BE, _PCAP_GLOBAL_HEADER_SIZE), + (_PCAPNG_MAGIC, _PCAPNG_SECTION_HEADER_SIZE), + ): + capture = Path(tmp) / "capture.pcap" + capture.write_bytes(magic + b"\x00" * 40) + assert _capture_header_size(str(capture)) == expected + + +def test_capture_header_size_defaults(): + with tempfile.TemporaryDirectory() as tmp: + capture = Path(tmp) / "capture.unknown" + capture.write_bytes(b"\xde\xad\xbe\xef" + b"\x00" * 40) + assert _capture_header_size(str(capture)) == _PCAP_GLOBAL_HEADER_SIZE + + assert _capture_header_size(str(Path(tmp) / "missing.pcap")) == ( + _PCAP_GLOBAL_HEADER_SIZE + ) + + +def test_capture_header_size_ignores_empty_output(): + with tempfile.TemporaryDirectory() as tmp: + capture = Path(tmp) / "empty.pcap" + capture.write_bytes(b"") + assert _capture_header_size(str(capture)) == _PCAP_GLOBAL_HEADER_SIZE diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index b919bf66..a35cfea9 100755 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -48,6 +48,7 @@ if [ ${#ARGS[@]} -eq 0 ]; then ipmininet/tests/test_pure.py ipmininet/tests/test_topo_scenarios.py ipmininet/tests/test_policy_config_scenarios.py + ipmininet/tests/test_exabgp_model_scenarios.py ipmininet/tests/test_physicalinterface.py ipmininet/tests/test_address_alllocation.py ipmininet/tests/test_dhcp.py From bc9dca2aea1490dcead27efd7724c33a585e4dd2 Mon Sep 17 00:00:00 2001 From: iakov Date: Sun, 6 Sep 2026 14:57:24 +0300 Subject: [PATCH 66/69] test: re-architect the test suite and enforce zero duplicate-code (#49) * test: reuse net teardown and parametrize rootless scenario tables - add run_ipnet() context manager to tests/utils.py (net.start with a guaranteed net.stop()+cleanup()) and use it in test_misc, test_network_capture and test_topologydb instead of hand-rolled try/finally scaffolding - parametrize test_dhcp dnsmasq pid lookups and the test_topo_scenarios subnet overlay cases into data tables - drop test_pure duplicates (ConfigDict, ip_statement) covered by richer sibling tests; import the pcap magic constants instead of redefining them * test: collapse IGP/BGP network tests onto shared scenario helpers Add assert_config_file, assert_all_paths, run_topology_scenario and the shared IGP path tables to tests/utils.py, then rewrite the rootful daemon tests (ospf/ospf6/ripng/radv/static/bgp/switch/linkfailure) to use run_ipnet and the scenario runner instead of hand-rolled start/assert/stop/cleanup scaffolding. test_bgp local_pref/med/rr/full become one parametrized test. * refactor: de-duplicate switch capture and IGP config scaffolding - factor the per-node/per-interface capture start/stop loops of IPSwitch and IPOVSSwitch into start_captures/stop_captures helpers - share is_active_interface and the per-interface ConfigDict builder between the OSPF and RIPng configs, and the interface/network population step on the common QuaggaDaemon base - replace the duplicated visited-set BFS in base.py router-id scan and utils.find_node by a shared walk_unvisited() generator * lint: enforce a zero-tolerance duplicate-code gate The product code and the test suite no longer contain duplicated blocks, so drop the duplication_max ceiling to 0, scope scripts/check-duplicates.sh to exclude the standalone examples/install demos, and add a local pre-commit hook that runs it before every push. * fix(ospf): keep OSPF.is_active_interface for the ospf6 subclass OSPF6 subclasses OSPF and calls is_active_interface from its own _build_interfaces; delegate to the shared is_l3router_interface helper instead of dropping the method. * fix(utils): expand whole router interface lists in find_node walk_unvisited must enqueue realIntfList(n.node) for every L3 neighbour like the original code did, not just the single neighbour interface, otherwise the BFS never leaves the first broadcast domain (breaks find_node and the DNS zone server resolution that relies on it). * style(utils): ruff format find_node --- .pre-commit-config.yaml | 8 ++ ipmininet/ipovs_switch.py | 16 +-- ipmininet/ipswitch.py | 32 +++--- ipmininet/router/config/base.py | 32 +++--- ipmininet/router/config/ospf.py | 38 +++---- ipmininet/router/config/ripng.py | 35 ++----- ipmininet/router/config/utils.py | 28 ++++- ipmininet/router/config/zebra.py | 11 ++ ipmininet/tests/test_bgp.py | 117 +++++---------------- ipmininet/tests/test_dhcp.py | 35 +++---- ipmininet/tests/test_linkfailure.py | 50 ++------- ipmininet/tests/test_misc.py | 17 +--- ipmininet/tests/test_network_capture.py | 20 +--- ipmininet/tests/test_ospf.py | 85 +++++----------- ipmininet/tests/test_ospf6.py | 80 +++++---------- ipmininet/tests/test_pure.py | 30 ------ ipmininet/tests/test_radv.py | 55 +++------- ipmininet/tests/test_ripng.py | 48 +++------ ipmininet/tests/test_static.py | 31 +----- ipmininet/tests/test_switch.py | 40 +++----- ipmininet/tests/test_topo_scenarios.py | 130 ++++++++++++------------ ipmininet/tests/test_topologydb.py | 16 +-- ipmininet/tests/utils.py | 93 ++++++++++++++++- ipmininet/utils.py | 38 +++++-- pyproject.toml | 5 +- scripts/check-duplicates.sh | 4 +- 26 files changed, 457 insertions(+), 637 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4cd79933..3585eff0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,3 +24,11 @@ repos: name: mdformat (strict check) args: [--check] stages: [pre-push] + - repo: local + hooks: + - id: duplicate-code + name: pylint duplicate-code (zero tolerance) + entry: scripts/check-duplicates.sh + language: system + pass_filenames: false + stages: [pre-push] diff --git a/ipmininet/ipovs_switch.py b/ipmininet/ipovs_switch.py index 88e71253..e9880962 100644 --- a/ipmininet/ipovs_switch.py +++ b/ipmininet/ipovs_switch.py @@ -1,5 +1,7 @@ from mininet.node import OVSSwitch +from ipmininet.ipswitch import start_captures, stop_captures + class IPOVSSwitch(OVSSwitch): """A switch that supports IPMininet-specific features. @@ -73,12 +75,7 @@ def start(self, controllers): self.vsctl(f" add-br {self}") self.vsctl(f" set bridge {self}" + self.bridgeOpts()) self.vsctl(intfs) - # Start the captures on this switch - for capture in self.params.get("captures", []): - capture.start(node=self) - for intf in self.intfList(): - for capture in intf.params.get("captures", []): - capture.start(intf=intf) + start_captures(self) self.cmd("ifconfig", self, "up") def bridgeOpts(self): @@ -103,10 +100,5 @@ def bridgeOpts(self): return opts def stop(self, deleteIntfs=True): - # Stop the captures on this switch - for capture in self.params.get("captures", []): - capture.stop(node=self) - for intf in self.intfList(): - for capture in intf.params.get("captures", []): - capture.stop(intf=intf) + stop_captures(self) OVSSwitch.stop(self, deleteIntfs) diff --git a/ipmininet/ipswitch.py b/ipmininet/ipswitch.py index 7a175edd..d758acae 100644 --- a/ipmininet/ipswitch.py +++ b/ipmininet/ipswitch.py @@ -6,6 +6,24 @@ from ipmininet.utils import require_cmd +def start_captures(node): + """Start the captures configured on a node and on each of its interfaces.""" + for capture in node.params.get("captures", []): + capture.start(node=node) + for intf in node.intfList(): + for capture in intf.params.get("captures", []): + capture.start(intf=intf) + + +def stop_captures(node): + """Stop the captures configured on a node and on each of its interfaces.""" + for capture in node.params.get("captures", []): + capture.stop(node=node) + for intf in node.intfList(): + for capture in intf.params.get("captures", []): + capture.stop(intf=intf) + + class IPSwitch(LinuxBridge): """Linux Bridge (with optional spanning tree) extended to include the hubs""" @@ -62,19 +80,9 @@ def start(self, _controllers): f"brctl setpathcost {self.name} {i.name} " f"{i.params.get('stp_cost', 1)}" ) - # Start the captures on this switch - for capture in self.params.get("captures", []): - capture.start(node=self) - for intf in self.intfList(): - for capture in intf.params.get("captures", []): - capture.start(intf=intf) + start_captures(self) self.cmd("ifconfig", self, "up") def stop(self, deleteIntfs=True): - # Stop the captures on this switch - for capture in self.params.get("captures", []): - capture.stop(node=self) - for intf in self.intfList(): - for capture in intf.params.get("captures", []): - capture.stop(intf=intf) + stop_captures(self) super().stop(deleteIntfs=deleteIntfs) diff --git a/ipmininet/router/config/base.py b/ipmininet/router/config/base.py index 338e8e7b..19dc433b 100644 --- a/ipmininet/router/config/base.py +++ b/ipmininet/router/config/base.py @@ -19,7 +19,7 @@ from mininet.log import lg as log from ipmininet.link import OrderedAddress -from ipmininet.utils import realIntfList, require_cmd +from ipmininet.utils import realIntfList, require_cmd, walk_unvisited from .utils import ConfigDict, ip_statement @@ -252,21 +252,21 @@ def compute_routerid(self) -> str: key=OrderedAddress, ) if len(ip_list) == 0: - to_visit = realIntfList(self._node) - # Explore all routers to check that none has the same router id - while to_visit: - self.incr_last_routerid() - visited = set() # type: Set[IPIntf] - while to_visit: - i = to_visit.pop() - if i in visited: - continue - visited.add(i) - for n in i.broadcast_domain.routers: - if self._equal_routerid(n.node): - break # We need to change the router id - to_visit.extend(realIntfList(n.node)) - to_visit = realIntfList(self._node) if to_visit else [] + # No IPv4 address: generate a router id that clashes with none of + # the other routers reachable over the L3 graph. + self.incr_last_routerid() + + def _expand(i): + if any( + self._equal_routerid(n.node) for n in i.broadcast_domain.routers + ): + return [] + return [ + x for n in i.broadcast_domain.routers for x in realIntfList(n.node) + ] + + for _ in walk_unvisited(realIntfList(self._node), _expand): + pass return self._last_routerid.compressed return ip_list.pop().ip.compressed diff --git a/ipmininet/router/config/ospf.py b/ipmininet/router/config/ospf.py index d1c2e468..52926279 100644 --- a/ipmininet/router/config/ospf.py +++ b/ipmininet/router/config/ospf.py @@ -5,9 +5,8 @@ from ipmininet.link import IPIntf from ipmininet.overlay import Overlay -from ipmininet.utils import L3Router -from .utils import ConfigDict +from .utils import ConfigDict, interface_props, is_l3router_interface from .zebra import QuaggaDaemon, Zebra @@ -59,10 +58,7 @@ def __init__(self, node, *args, **kwargs): def build(self): cfg = super().build() cfg.redistribute = self.options.redistribute - interfaces = self._node.intfList() - cfg.interfaces = self._build_interfaces(interfaces) - cfg.networks = self._build_networks(interfaces) - return cfg + return self.build_interface_config(cfg) @staticmethod def _build_networks(interfaces: list[IPIntf]) -> list["OSPFNetwork"]: @@ -78,21 +74,17 @@ def _build_networks(interfaces: list[IPIntf]) -> list["OSPFNetwork"]: def _build_interfaces(self, interfaces: list[IPIntf]) -> list[ConfigDict]: """Return the list of OSPF interface properties from the list of active interfaces""" - return [ - ConfigDict( - description=i.describe, - name=i.name, - # Is the interface between two routers? - active=self.is_active_interface(i), - priority=i.get("ospf_priority", self.options.priority), - dead_int=i.get("ospf_dead_int", self.options.dead_int), - hello_int=i.get("ospf_hello_int", self.options.hello_int), - cost=i.igp_metric, + return interface_props( + interfaces, + lambda i: { + "priority": i.get("ospf_priority", self.options.priority), + "dead_int": i.get("ospf_dead_int", self.options.dead_int), + "hello_int": i.get("ospf_hello_int", self.options.hello_int), + "cost": i.igp_metric, # Is the interface forcefully disabled? - passive=i.get("igp_passive", False), - ) - for i in interfaces - ] + "passive": i.get("igp_passive", False), + }, + ) def set_defaults(self, defaults): """:param debug: the set of debug events that should be logged @@ -109,11 +101,7 @@ def set_defaults(self, defaults): @staticmethod def is_active_interface(itf) -> bool: """Return whether an interface is active or not for the OSPF daemon""" - if itf.broadcast_domain is None: - return False - return any( - L3Router.is_l3router_intf(i) for i in itf.broadcast_domain if i != itf - ) + return is_l3router_interface(itf) class OSPFNetwork: diff --git a/ipmininet/router/config/ripng.py b/ipmininet/router/config/ripng.py index df111602..9e4d2c2b 100644 --- a/ipmininet/router/config/ripng.py +++ b/ipmininet/router/config/ripng.py @@ -3,9 +3,8 @@ from ipaddress import IPv6Interface, ip_interface from ipmininet.link import IPIntf -from ipmininet.utils import L3Router -from .utils import ConfigDict +from .utils import ConfigDict, interface_props from .zebra import MgmtdBackendDaemon UPDATE_TIMER = 30 @@ -29,10 +28,7 @@ def build(self): cfg.update_timer = self.options.update_timer cfg.timeout_timer = self.options.timeout_timer cfg.garbage_timer = self.options.garbage_timer - interfaces = self._node.intfList() - cfg.interfaces = self._build_interfaces(interfaces) - cfg.networks = self._build_networks(interfaces) - return cfg + return self.build_interface_config(cfg) @staticmethod def _build_networks(interfaces: list[IPIntf]) -> list["RIPNetwork"]: @@ -47,17 +43,13 @@ def _build_networks(interfaces: list[IPIntf]) -> list["RIPNetwork"]: def _build_interfaces(self, interfaces: list[IPIntf]) -> list[ConfigDict]: """Return the list of RIP interface properties from the list of active interfaces""" - return [ - ConfigDict( - description=i.describe, - name=i.name, - # Is the interface between two routers? - active=self.is_active_interface(i), - cost=i.igp_metric - 1, - domain=ip_interface(f"{i.ip6}/{i.prefixLen6}"), - ) - for i in interfaces - ] + return interface_props( + interfaces, + lambda i: { + "cost": i.igp_metric - 1, + "domain": ip_interface(f"{i.ip6}/{i.prefixLen6}"), + }, + ) def set_defaults(self, defaults): """:param debug: the set of debug events that should be logged @@ -84,15 +76,6 @@ def set_defaults(self, defaults): defaults.garbage_timer = GARBAGE_TIMER super().set_defaults(defaults) - @staticmethod - def is_active_interface(itf) -> bool: - """Return whether an interface is active or not for the RIPng daemon""" - if itf.broadcast_domain is None: - return False - return any( - L3Router.is_l3router_intf(i) for i in itf.broadcast_domain if i != itf - ) - class RIPNetwork: """A class holding an RIP network properties""" diff --git a/ipmininet/router/config/utils.py b/ipmininet/router/config/utils.py index b7184ead..d117e763 100644 --- a/ipmininet/router/config/utils.py +++ b/ipmininet/router/config/utils.py @@ -2,7 +2,33 @@ from ipaddress import IPv4Address, IPv6Address, ip_interface -from ipmininet.utils import IP_V6 +from ipmininet.utils import IP_V6, L3Router + + +def is_l3router_interface(itf) -> bool: + """Return whether an interface is active for an IGP daemon (i.e. whether + it faces another L3 router).""" + if itf.broadcast_domain is None: + return False + return any(L3Router.is_l3router_intf(i) for i in itf.broadcast_domain if i != itf) + + +def interface_props(interfaces, props): + """Build the per-interface ConfigDict list shared by the IGP daemons. + + Each entry holds the interface description, name and activeness, decorated + with the daemon-specific keys returned by ``props(intf)``. + """ + return [ + ConfigDict( + description=i.describe, + name=i.name, + # Is the interface between two routers? + active=is_l3router_interface(i), + **props(i), + ) + for i in interfaces + ] class ConfigDict(dict): diff --git a/ipmininet/router/config/zebra.py b/ipmininet/router/config/zebra.py index cb54f7c2..5a94c3f6 100644 --- a/ipmininet/router/config/zebra.py +++ b/ipmininet/router/config/zebra.py @@ -53,6 +53,17 @@ def build(self): cfg.debug = self.options.debug return cfg + def build_interface_config(self, cfg): + """Populate the per-interface and per-network config lists. + + Called by the IGP daemon subclasses (OSPF, RIPng) which each implement + ``_build_interfaces``/``_build_networks`` for their own address family. + """ + interfaces = self._node.intfList() + cfg.interfaces = self._build_interfaces(interfaces) + cfg.networks = self._build_networks(interfaces) + return cfg + def set_defaults(self, defaults): """:param debug: the set of debug events that should be logged""" defaults.debug = () diff --git a/ipmininet/tests/test_bgp.py b/ipmininet/tests/test_bgp.py index 610fcfd2..121619ca 100644 --- a/ipmininet/tests/test_bgp.py +++ b/ipmininet/tests/test_bgp.py @@ -2,7 +2,6 @@ import pytest -from ipmininet.clean import cleanup from ipmininet.examples.bgp_full_config import BGPTopoFull from ipmininet.examples.bgp_local_pref import BGPTopoLocalPref from ipmininet.examples.bgp_med import BGPTopoMed @@ -14,12 +13,16 @@ from ipmininet.examples.bgp_policies_adjust import BGPPoliciesAdjustTopo from ipmininet.examples.bgp_rr import BGPTopoRR from ipmininet.examples.simple_bgp_network import SimpleBGPTopo -from ipmininet.ipnet import IPNet from ipmininet.iptopo import IPTopo from ipmininet.router.config import AS, BGP, bgp_peering, iBGPFullMesh from ipmininet.router.config.base import RouterConfig from ipmininet.router.config.bgp import AF_INET, AF_INET6, CLIENT_PROVIDER -from ipmininet.tests.utils import assert_connectivity, assert_path +from ipmininet.tests.utils import ( + assert_all_paths, + assert_config_file, + assert_connectivity, + run_ipnet, +) from . import require_root @@ -117,14 +120,9 @@ def build(self, *args, **kwargs): @require_root def test_bgp_example(): - try: - net = IPNet(topo=SimpleBGPTopo()) - net.start() + with run_ipnet(SimpleBGPTopo()) as net: assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() @require_root @@ -162,25 +160,13 @@ def test_bgp_example(): ], ) def test_bgp_daemon_params(bgp_params, expected_cfg): - try: - net = IPNet(topo=BGPTopo(bgp_params), allocate_IPs=False) - net.start() - + with run_ipnet(BGPTopo(bgp_params), allocate_IPs=False) as net: # Check generated configuration - with open("/tmp/bgpd_as2r1.cfg") as fileobj: - cfg = [line for line in (line.strip() for line in fileobj) if line] - for line in expected_cfg: - assert line in cfg, ( - "Cannot find the line '{}' in the generated " - "configuration:\n{}".format(line, "".join(cfg)) - ) + assert_config_file("/tmp/bgpd_as2r1.cfg", expected_cfg, strip=True) # Check reachability assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() local_pref_paths = [ @@ -192,19 +178,6 @@ def test_bgp_daemon_params(bgp_params, expected_cfg): ["as1r6", "as4r1", "as4h1"], ] - -@require_root -def test_bgp_local_pref(): - try: - net = IPNet(topo=BGPTopoLocalPref()) - net.start() - for path in local_pref_paths: - assert_path(net, path, v6=True) - net.stop() - finally: - cleanup() - - med_paths = [ ["as1r1", "as1r6", "as1r5", "as4r2", "as4h1"], ["as1r2", "as1r3", "as1r6", "as1r5", "as4r2", "as4h1"], @@ -214,19 +187,6 @@ def test_bgp_local_pref(): ["as1r6", "as1r5", "as4r2", "as4h1"], ] - -@require_root -def test_bgp_med(): - try: - net = IPNet(topo=BGPTopoMed()) - net.start() - for path in med_paths: - assert_path(net, path, v6=True) - net.stop() - finally: - cleanup() - - rr_paths = [ ["as1r1", "as1r6", "as5r1", "as2r1", "as2h1"], ["as1r2", "as1r3", "as1r6", "as5r1", "as2r1", "as2h1"], @@ -236,19 +196,6 @@ def test_bgp_med(): ["as1r6", "as5r1", "as2r1", "as2h1"], ] - -@require_root -def test_bgp_rr(): - try: - net = IPNet(topo=BGPTopoRR()) - net.start() - for path in rr_paths: - assert_path(net, path, v6=True) - net.stop() - finally: - cleanup() - - full_paths = [ ["as1r1", "as1r6", "as4r1", "as4h1"], ["as1r2", "as1r3", "as1r6", "as4r1", "as4h1"], @@ -258,17 +205,19 @@ def test_bgp_rr(): ["as1r6", "as4r1", "as4h1"], ] +_bgp_path_examples = [ + (BGPTopoLocalPref, local_pref_paths), + (BGPTopoMed, med_paths), + (BGPTopoRR, rr_paths), + (BGPTopoFull, full_paths), +] + @require_root -def test_bgp_full_config(): - try: - net = IPNet(topo=BGPTopoFull()) - net.start() - for path in full_paths: - assert_path(net, path, v6=True) - net.stop() - finally: - cleanup() +@pytest.mark.parametrize("topo,paths", _bgp_path_examples) +def test_bgp_paths(topo, paths): + with run_ipnet(topo()) as net: + assert_all_paths(net, paths, v6=True) policies_paths = { @@ -322,28 +271,16 @@ def test_bgp_full_config(): ], ) def test_bgp_policies(topology): - try: - net = IPNet(topo=topology()) - net.start() - for path in policies_paths[topology.__name__]: - assert_path(net, path, v6=True) - net.stop() - finally: - cleanup() + with run_ipnet(topology()) as net: + assert_all_paths(net, policies_paths[topology.__name__], v6=True) @require_root def test_bgp_policies_adjust(): - try: - # Adding this new peering link should enable all hosts - # to ping each others - net = IPNet( - topo=BGPPoliciesAdjustTopo( - as_start="as5r", as_end="as2r", bgp_policy=CLIENT_PROVIDER - ) + # Adding this new peering link should enable all hosts to ping each others + with run_ipnet( + BGPPoliciesAdjustTopo( + as_start="as5r", as_end="as2r", bgp_policy=CLIENT_PROVIDER ) - net.start() + ) as net: assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() diff --git a/ipmininet/tests/test_dhcp.py b/ipmininet/tests/test_dhcp.py index aebff76c..0f7f6615 100644 --- a/ipmininet/tests/test_dhcp.py +++ b/ipmininet/tests/test_dhcp.py @@ -64,31 +64,26 @@ def test_dnsmasq_build_and_files(tmp_cwd): assert node.cmd.call_count == 0 -def test_dnsmasq_pids_found(tmp_cwd): +@pytest.mark.parametrize( + "ss_output,expected", + [ + ( + 'udp 0 0 0.0.0.0:53 users:(("dnsmasq",pid=1234,fd=5))\n' + 'tcp 0 0 0.0.0.0:53 users:(("dnsmasq",pid=5678,fd=6))\n', + ["1234", "5678"], + ), + ("ss output without any pid", None), + ("", None), + ], +) +def test_dnsmasq_pids(tmp_cwd, ss_output, expected): _, daemon = _dnsmasq(tmp_cwd) - daemon.node.cmd.return_value = ( - 'udp 0 0 0.0.0.0:53 users:(("dnsmasq",pid=1234,fd=5))\n' - 'tcp 0 0 0.0.0.0:53 users:(("dnsmasq",pid=5678,fd=6))\n' - ) + daemon.node.cmd.return_value = ss_output - assert daemon.pids == ["1234", "5678"] + assert daemon.pids == expected daemon.node.cmd.assert_called_once_with("ss -tulnp | grep dnsmasq") -def test_dnsmasq_pids_without_match(tmp_cwd): - _, daemon = _dnsmasq(tmp_cwd) - daemon.node.cmd.return_value = "ss output without any pid" - - assert daemon.pids is None - - -def test_dnsmasq_pids_empty_output(tmp_cwd): - _, daemon = _dnsmasq(tmp_cwd) - daemon.node.cmd.return_value = "" - - assert daemon.pids is None - - def test_dnsmasq_kill(tmp_cwd): node, daemon = _dnsmasq(tmp_cwd) node.cmd.side_effect = [f" {PID} \n", ""] diff --git a/ipmininet/tests/test_linkfailure.py b/ipmininet/tests/test_linkfailure.py index 6d69377a..b62c26fd 100644 --- a/ipmininet/tests/test_linkfailure.py +++ b/ipmininet/tests/test_linkfailure.py @@ -4,13 +4,17 @@ import pytest -from ipmininet.clean import cleanup from ipmininet.ipnet import IPNet from ipmininet.iptopo import IPTopo from ..examples.link_failure import FailureTopo from . import require_root -from .utils import assert_connectivity, assert_node_not_connected, assert_routing_table +from .utils import ( + assert_connectivity, + assert_node_not_connected, + assert_routing_table, + run_ipnet, +) def _wait_reconvergence(net: IPNet, timeout=180): @@ -42,18 +46,11 @@ def build(self, *args, **kwargs): @require_root def test_failure_topo(): - try: - net = IPNet(topo=FailureTopo()) - net.start() - + with run_ipnet(FailureTopo()) as net: # Check example connectivity assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() - @require_root @pytest.mark.parametrize( @@ -65,10 +62,7 @@ def test_failure_topo(): ], ) def test_failurePlan(plan): - try: - net = IPNet(topo=Topo()) - net.start() - + with run_ipnet(Topo()) as net: # Wait for OSPF convergence assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) @@ -88,18 +82,12 @@ def test_failurePlan(plan): # Check link restoration assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() @require_root @pytest.mark.parametrize("downed_links", [1, 2, 3]) def test_randomFailure(downed_links): - try: - net = IPNet(topo=Topo()) - net.start() - + with run_ipnet(Topo()) as net: # Wait for OSPF convergence assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) @@ -118,17 +106,11 @@ def test_randomFailure(downed_links): # Check link restoration assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() @require_root def test_randomFailureOnTargetedLink(): - try: - net = IPNet(topo=Topo()) - net.start() - + with run_ipnet(Topo()) as net: # Wait for OSPF convergence assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) @@ -147,18 +129,12 @@ def test_randomFailureOnTargetedLink(): # Check link restoration assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() @require_root def test_ping_and_failure_api_edges(): """Exercise the corner cases of the ping and failure APIs on a live net.""" - try: - net = IPNet(topo=Topo()) - net.start() - + with run_ipnet(Topo()) as net: # Wait for OSPF convergence before touching the failure APIs assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) @@ -170,7 +146,3 @@ def test_ping_and_failure_api_edges(): # Failure API corner cases: bogus nodes and too many downed links assert net.runFailurePlan([("ghost1", "ghost2")]) == [] assert net.randomFailure(99) == [] - - net.stop() - finally: - cleanup() diff --git a/ipmininet/tests/test_misc.py b/ipmininet/tests/test_misc.py index 8ad23a92..31d5725f 100644 --- a/ipmininet/tests/test_misc.py +++ b/ipmininet/tests/test_misc.py @@ -5,13 +5,12 @@ import pytest from ipmininet import utils -from ipmininet.clean import cleanup from ipmininet.examples.static_address_network import StaticAddressNet -from ipmininet.ipnet import IPNet from ipmininet.link import _parse_addresses from ipmininet.router.config.utils import ip_statement from . import require_root +from .utils import run_ipnet # An Ethernet MAC address is made of 6 octets MAC_LEN = 6 @@ -121,13 +120,8 @@ def test_require_cmd(cmd, present): ) @require_root def test_address_pair(node, use_v4, use_v6, expected): - try: - net = IPNet(topo=StaticAddressNet()) - net.start() + with run_ipnet(StaticAddressNet()) as net: assert utils.address_pair(net[node], use_v4, use_v6) == expected - net.stop() - finally: - cleanup() @pytest.mark.parametrize( @@ -143,9 +137,7 @@ def test_address_pair(node, use_v4, use_v6, expected): ) @require_root def test_find_node(start, node, present): - try: - net = IPNet(topo=StaticAddressNet()) - net.start() + with run_ipnet(StaticAddressNet()) as net: i = utils.find_node(net[start], node) if present: assert i is not None, f"Node {node} not found from node {start}" @@ -154,9 +146,6 @@ def test_find_node(start, node, present): ) else: assert i is None, f"Node {node} should not be found from node {start}" - net.stop() - finally: - cleanup() @pytest.mark.parametrize( diff --git a/ipmininet/tests/test_network_capture.py b/ipmininet/tests/test_network_capture.py index 2b0fbad5..3f982ed3 100644 --- a/ipmininet/tests/test_network_capture.py +++ b/ipmininet/tests/test_network_capture.py @@ -8,8 +8,6 @@ import pytest -from ipmininet.clean import cleanup -from ipmininet.ipnet import IPNet from ipmininet.overlay import ( _PCAP_GLOBAL_HEADER_SIZE, _PCAP_MAGIC_BE, @@ -21,15 +19,13 @@ from ..examples.network_capture import NetworkCaptureTopo from . import require_mimidump, require_root -from .utils import assert_connectivity +from .utils import assert_connectivity, run_ipnet @require_mimidump @require_root def test_network_capture_example(): - try: - net = IPNet(topo=NetworkCaptureTopo()) - net.start() + with run_ipnet(NetworkCaptureTopo()) as net: overlay = next(o for o in net.topo.overlays if isinstance(o, NetworkCapture)) # Capture readiness is asynchronous; wait for it instead of asserting @@ -43,17 +39,11 @@ def test_network_capture_example(): assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() - @require_mimidump @require_root def test_network_capture_wait_until_capturing(): - try: - net = IPNet(topo=NetworkCaptureTopo()) - net.start() + with run_ipnet(NetworkCaptureTopo()) as net: overlay = next(o for o in net.topo.overlays if isinstance(o, NetworkCapture)) # Captures on interfaces (mimidump) become live once the READY signal @@ -68,10 +58,6 @@ def test_network_capture_wait_until_capturing(): # Captures that were never started are not live assert not overlay.wait_until_capturing("does-not-exist", timeout=1) - net.stop() - finally: - cleanup() - class _FakeProc: """Popen stand-in that stays alive until poll() is called.""" diff --git a/ipmininet/tests/test_ospf.py b/ipmininet/tests/test_ospf.py index acf5f059..d56b75e0 100644 --- a/ipmininet/tests/test_ospf.py +++ b/ipmininet/tests/test_ospf.py @@ -2,14 +2,18 @@ import pytest -from ipmininet.clean import cleanup from ipmininet.examples.simple_ospf_network import SimpleOSPFNet -from ipmininet.ipnet import IPNet from ipmininet.iptopo import IPTopo from ipmininet.router.config import OSPF from ipmininet.router.config.base import RouterConfig from ipmininet.router.config.ospf import OSPFRedistributedRoute -from ipmininet.tests.utils import assert_connectivity, assert_path +from ipmininet.tests.utils import ( + IGP_DETOUR_PATHS, + IGP_UNIT_PATHS, + assert_connectivity, + run_ipnet, + run_topology_scenario, +) from . import require_root @@ -79,32 +83,8 @@ def build(self, *args, **kwargs): @require_root def test_ospf_example(): - try: - net = IPNet(topo=SimpleOSPFNet()) - net.start() + with run_ipnet(SimpleOSPFNet()) as net: assert_connectivity(net) - net.stop() - finally: - cleanup() - - -unit_igp_cost_paths = [ - ["h1", "r1", "r2", "h2"], - ["h2", "r2", "r1", "h1"], - ["h1", "r1", "r3", "h3"], - ["h3", "r3", "r1", "h1"], - ["h2", "r2", "r3", "h3"], - ["h3", "r3", "r2", "h2"], -] - -detour_paths = [ - ["h1", "r1", "r3", "r2", "h2"], - ["h2", "r2", "r3", "r1", "h1"], - ["h1", "r1", "r3", "h3"], - ["h3", "r3", "r1", "h1"], - ["h2", "r2", "r3", "h3"], - ["h3", "r3", "r2", "h2"], -] @require_root @@ -116,10 +96,10 @@ def test_ospf_example(): {}, {}, [" network 10.0.0.1/24 area 0.0.0.0", "interface r1-eth0"], - unit_igp_cost_paths, + IGP_UNIT_PATHS, ), - ({}, {"debug": ["lsa"]}, {}, ["debug ospf lsa"], unit_igp_cost_paths), - ({}, {}, {"igp_metric": 5}, [" ip ospf cost 5"], detour_paths), + ({}, {"debug": ["lsa"]}, {}, ["debug ospf lsa"], IGP_UNIT_PATHS), + ({}, {}, {"igp_metric": 5}, [" ip ospf cost 5"], IGP_DETOUR_PATHS), ( {}, {}, @@ -128,7 +108,7 @@ def test_ospf_example(): " network 10.0.0.1/24 area 1.1.1.1", " network 127.0.0.1/8 area 0.0.0.0", ], - detour_paths, + IGP_DETOUR_PATHS, ), ( {"igp_area": "1.1.1.1"}, @@ -138,21 +118,21 @@ def test_ospf_example(): " network 127.0.0.1/8 area 1.1.1.1", " network 10.0.0.1/24 area 0.0.0.0", ], - unit_igp_cost_paths, + IGP_UNIT_PATHS, ), ( {}, {}, {"params1": {"ospf_priority": 1}}, [" ip ospf priority 1"], - unit_igp_cost_paths, + IGP_UNIT_PATHS, ), ( {}, {}, {"params1": {"ospf_dead_int": "minimal hello-multiplier 2"}}, [" ip ospf dead-interval minimal hello-multiplier 2"], - unit_igp_cost_paths, + IGP_UNIT_PATHS, ), ( {}, @@ -167,33 +147,16 @@ def test_ospf_example(): " redistribute connected metric-type 1 metric 15", " redistribute static metric-type 2 metric 50", ], - unit_igp_cost_paths, + IGP_UNIT_PATHS, ), ], ) def test_ospf_daemon_params(node_params, ospf_params, link_params, exp_cfg, exp_paths): - try: - net = IPNet( - topo=MinimalOSPFNet(node_params, ospf_params, link_params), - allocate_IPs=False, - use_v6=False, - ) - net.start() - - # Check generated configuration - with open("/tmp/ospfd_r1.cfg") as fileobj: - cfg = fileobj.readlines() - for line in exp_cfg: - assert line + "\n" in cfg, ( - "Cannot find the line '{}' in the generated " - "configuration:\n{}".format(line, "".join(cfg)) - ) - - # Check reachability and paths - assert_connectivity(net) - for path in exp_paths: - assert_path(net, path) - - net.stop() - finally: - cleanup() + run_topology_scenario( + MinimalOSPFNet(node_params, ospf_params, link_params), + cfg_file="/tmp/ospfd_r1.cfg", + exp_cfg=exp_cfg, + paths=exp_paths, + allocate_IPs=False, + use_v6=False, + ) diff --git a/ipmininet/tests/test_ospf6.py b/ipmininet/tests/test_ospf6.py index ea8c2415..e649fdce 100644 --- a/ipmininet/tests/test_ospf6.py +++ b/ipmininet/tests/test_ospf6.py @@ -2,14 +2,18 @@ import pytest -from ipmininet.clean import cleanup from ipmininet.examples.simple_ospfv3_network import SimpleOSPFv3Net -from ipmininet.ipnet import IPNet from ipmininet.iptopo import IPTopo from ipmininet.router.config import OSPF6 from ipmininet.router.config.base import RouterConfig from ipmininet.router.config.ospf6 import OSPF6RedistributedRoute -from ipmininet.tests.utils import assert_connectivity, assert_path +from ipmininet.tests.utils import ( + IGP_DETOUR_PATHS, + IGP_UNIT_PATHS, + assert_connectivity, + run_ipnet, + run_topology_scenario, +) from . import require_root @@ -55,47 +59,23 @@ def build(self, *args, **kwargs): @require_root def test_ospf6_example(): - try: - net = IPNet(topo=SimpleOSPFv3Net()) - net.start() + with run_ipnet(SimpleOSPFv3Net()) as net: assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() - - -unit_igp_cost_paths = [ - ["h1", "r1", "r2", "h2"], - ["h2", "r2", "r1", "h1"], - ["h1", "r1", "r3", "h3"], - ["h3", "r3", "r1", "h1"], - ["h2", "r2", "r3", "h3"], - ["h3", "r3", "r2", "h2"], -] - -high_igp_cost_paths = [ - ["h1", "r1", "r3", "r2", "h2"], - ["h2", "r2", "r3", "r1", "h1"], - ["h1", "r1", "r3", "h3"], - ["h3", "r3", "r1", "h1"], - ["h2", "r2", "r3", "h3"], - ["h3", "r3", "r2", "h2"], -] @require_root @pytest.mark.parametrize( "node_params,ospf6_params,link_params,exp_cfg,exp_paths", [ - ({}, {}, {}, [" ipv6 ospf6 area 0.0.0.0"], unit_igp_cost_paths), + ({}, {}, {}, [" ipv6 ospf6 area 0.0.0.0"], IGP_UNIT_PATHS), ( {}, {"debug": ["flooding"]}, {}, ["debug ospf6 flooding"], - unit_igp_cost_paths, + IGP_UNIT_PATHS, ), - ({}, {}, {"igp_metric": 5}, [" ipv6 ospf6 cost 5"], high_igp_cost_paths), + ({}, {}, {"igp_metric": 5}, [" ipv6 ospf6 cost 5"], IGP_DETOUR_PATHS), ( {}, {}, @@ -104,7 +84,7 @@ def test_ospf6_example(): " ipv6 ospf6 area 1.1.1.1", " ipv6 ospf6 area 0.0.0.0", ], - high_igp_cost_paths, + IGP_DETOUR_PATHS, ), ( {"igp_area": "1.1.1.1"}, @@ -114,21 +94,21 @@ def test_ospf6_example(): " ipv6 ospf6 area 1.1.1.1", " ipv6 ospf6 area 0.0.0.0", ], - unit_igp_cost_paths, + IGP_UNIT_PATHS, ), ( {}, {}, {"params1": {"ospf6_priority": 1}}, [" ipv6 ospf6 priority 1"], - unit_igp_cost_paths, + IGP_UNIT_PATHS, ), ( {}, {}, {"params1": {"ospf_dead_int": "minimal hello-multiplier 2"}}, [f" ipv6 ospf6 dead-interval {OSPF6.DEAD_INT}"], - unit_igp_cost_paths, + IGP_UNIT_PATHS, ), ( {}, @@ -140,31 +120,17 @@ def test_ospf6_example(): }, {}, [" redistribute connected", " redistribute static"], - unit_igp_cost_paths, + IGP_UNIT_PATHS, ), ], ) def test_ospf6_daemon_params( node_params, ospf6_params, link_params, exp_cfg, exp_paths ): - try: - net = IPNet(topo=MinimalOSPFv3Net(node_params, ospf6_params, link_params)) - net.start() - - # Check generated configuration - with open("/tmp/ospf6d_r1.cfg") as fileobj: - cfg = fileobj.readlines() - for line in exp_cfg: - assert line + "\n" in cfg, ( - "Cannot find the line '{}' in the generated" - " configuration:\n{}".format(line, "".join(cfg)) - ) - - # Check reachability - assert_connectivity(net, v6=True) - for path in exp_paths: - assert_path(net, path, v6=True) - - net.stop() - finally: - cleanup() + run_topology_scenario( + MinimalOSPFv3Net(node_params, ospf6_params, link_params), + cfg_file="/tmp/ospf6d_r1.cfg", + exp_cfg=exp_cfg, + paths=exp_paths, + v6=True, + ) diff --git a/ipmininet/tests/test_pure.py b/ipmininet/tests/test_pure.py index 1bff7a00..c234906f 100644 --- a/ipmininet/tests/test_pure.py +++ b/ipmininet/tests/test_pure.py @@ -13,7 +13,6 @@ import pytest from ipmininet.link import _parse_addresses -from ipmininet.router.config.utils import ConfigDict, ip_statement from ipmininet.tests.test_srv6 import _infer_sub_paths from ipmininet.tests.utils import wait_until from ipmininet.utils import get_set, is_container, is_subnet_of @@ -226,32 +225,3 @@ def test_missing_key(self): d = {} assert get_set(d, "b", list) == [] assert d == {"b": []} - - -class TestConfigDict: - def test_attribute_access(self): - d = ConfigDict(foo="bar") - assert d.foo == "bar" - assert d["foo"] == "bar" - - def test_missing_attribute_is_none(self): - d = ConfigDict() - assert d.missing is None - - def test_attribute_assignment(self): - d = ConfigDict() - d.foo = "bar" - assert d["foo"] == "bar" - - -@pytest.mark.parametrize( - "ip,expected", - [ - ("10.0.0.0/8", "ip"), - (4, "ip"), - ("2001:db8::/32", "ipv6"), - (6, "ipv6"), - ], -) -def test_ip_statement(ip, expected): - assert ip_statement(ip) == expected diff --git a/ipmininet/tests/test_radv.py b/ipmininet/tests/test_radv.py index 43fc39cc..91c14959 100644 --- a/ipmininet/tests/test_radv.py +++ b/ipmininet/tests/test_radv.py @@ -2,12 +2,10 @@ import pytest -from ipmininet.clean import cleanup from ipmininet.examples.router_adv_network import RouterAdvNet -from ipmininet.ipnet import IPNet from ipmininet.iptopo import IPTopo from ipmininet.router.config import RADVD, AdvPrefix, AdvRDNSS -from ipmininet.tests.utils import assert_connectivity +from ipmininet.tests.utils import assert_connectivity, run_ipnet, run_topology_scenario from . import require_root @@ -38,13 +36,10 @@ def build(self, *args, **kwargs): @require_root def test_radvd_example(): - try: - net = IPNet(topo=RouterAdvNet(), use_v4=False, use_v6=True, allocate_IPs=False) - net.start() + with run_ipnet( + RouterAdvNet(), use_v4=False, use_v6=True, allocate_IPs=False + ) as net: assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() @require_root @@ -84,37 +79,22 @@ def test_radvd_example(): ], ) def test_radvd_daemon_params(link_params, expected_cfg): - try: - net = IPNet( - topo=CustomRouterAdvNet(link_params), - use_v4=False, - use_v6=True, - allocate_IPs=False, - ) - net.start() - - # Check generated configuration - with open("/tmp/radvd_r.cfg") as fileobj: - cfg = fileobj.readlines() - for line in expected_cfg: - assert line + "\n" in cfg, ( - "Cannot find the line '{}' in the generated" - " configuration:\n{}".format(line, "".join(cfg)) - ) - - # Check reachability - assert_connectivity(net, v6=True) - - net.stop() - finally: - cleanup() + run_topology_scenario( + CustomRouterAdvNet(link_params), + cfg_file="/tmp/radvd_r.cfg", + exp_cfg=expected_cfg, + v6=True, + use_v4=False, + use_v6=True, + allocate_IPs=False, + ) @require_root def test_radvd_cleanup(): - try: - net = IPNet(topo=RouterAdvNet(), use_v4=False, use_v6=True, allocate_IPs=False) - net.start() + with run_ipnet( + RouterAdvNet(), use_v4=False, use_v6=True, allocate_IPs=False + ) as net: net["r"].nconfig.daemon(RADVD).cleanup() try: net["r"].nconfig.daemon(RADVD).cleanup() @@ -122,6 +102,3 @@ def test_radvd_cleanup(): pytest.fail( f"An exception '{e}' was raised while cleaning twice RADVD daemon" ) - net.stop() - finally: - cleanup() diff --git a/ipmininet/tests/test_ripng.py b/ipmininet/tests/test_ripng.py index 6c079081..a3f21059 100644 --- a/ipmininet/tests/test_ripng.py +++ b/ipmininet/tests/test_ripng.py @@ -2,14 +2,16 @@ import pytest -from ipmininet.clean import cleanup from ipmininet.examples.ripng_network import RIPngNetwork from ipmininet.examples.ripng_network_adjust import RIPngNetworkAdjust -from ipmininet.ipnet import IPNet from ipmininet.iptopo import IPTopo from ipmininet.router.config import RIPng from ipmininet.router.config.base import RouterConfig -from ipmininet.tests.utils import assert_connectivity, assert_path, assert_routing_table +from ipmininet.tests.utils import ( + assert_routing_table, + run_ipnet, + run_topology_scenario, +) from . import require_root @@ -137,40 +139,25 @@ def addRouter_v6(self, name): ids=lambda v: v.__name__, ) def test_ripng_examples(topo): - try: - net = IPNet(topo=topo(ripng_timers=RIPNG_FAST_TIMERS)) - net.start() - assert_connectivity(net, v6=True) - for path in expected_paths[topo.__name__]: - assert_path(net, path, v6=True) - - net.stop() - finally: - cleanup() + run_topology_scenario( + topo(ripng_timers=RIPNG_FAST_TIMERS), + paths=expected_paths[topo.__name__], + v6=True, + ) @require_root def test_ripng_adjust(): - try: - net = IPNet( - topo=RIPngNetworkAdjust(lr1r5_cost=5, ripng_timers=RIPNG_FAST_TIMERS) - ) - net.start() - assert_connectivity(net, v6=True) - for path in expected_paths["RIPngNetworkAdjust-mod"]: - assert_path(net, path, v6=True) - - net.stop() - finally: - cleanup() + run_topology_scenario( + RIPngNetworkAdjust(lr1r5_cost=5, ripng_timers=RIPNG_FAST_TIMERS), + paths=expected_paths["RIPngNetworkAdjust-mod"], + v6=True, + ) @require_root def test_ripng_flush_routing_tables(): - try: - net = IPNet(topo=MinimalRIPngNet(is_test_flush=True)) - net.start() - + with run_ipnet(MinimalRIPngNet(is_test_flush=True)) as net: routing_tables = { "r1": ["2042:22::/64", "2042:33::/64", "2042:23::/64"], "r2": ["2042:11::/64", "2042:33::/64", "2042:13::/64"], @@ -178,6 +165,3 @@ def test_ripng_flush_routing_tables(): } for router, expected_ipv6 in routing_tables.items(): assert_routing_table(net[router], expected_ipv6, present=False) - net.stop() - finally: - cleanup() diff --git a/ipmininet/tests/test_static.py b/ipmininet/tests/test_static.py index d60cd120..571bfaac 100644 --- a/ipmininet/tests/test_static.py +++ b/ipmininet/tests/test_static.py @@ -2,25 +2,20 @@ import pytest -from ipmininet.clean import cleanup from ipmininet.examples.partial_static_address_network import PartialStaticAddressNet from ipmininet.examples.static_address_network import StaticAddressNet from ipmininet.examples.static_routing import StaticRoutingNet from ipmininet.examples.static_routing_failure import StaticRoutingNetFailure from ipmininet.examples.static_routing_network_basic import StaticRoutingNetBasic from ipmininet.examples.static_routing_network_complex import StaticRoutingNetComplex -from ipmininet.ipnet import IPNet -from ipmininet.tests.utils import assert_connectivity, assert_path +from ipmininet.tests.utils import assert_connectivity, assert_path, run_ipnet from . import require_root @require_root def test_static_example(): - try: - net = IPNet(topo=StaticAddressNet()) - net.start() - + with run_ipnet(StaticAddressNet()) as net: # Check allocated addresses assert net["h1"].intf("h1-eth0").ip == "10.0.0.2" assert net["h1"].intf("h1-eth0").ip6 == "2001:1a::2" @@ -54,17 +49,10 @@ def test_static_example(): assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() - @require_root def test_partial_static_example(): - try: - net = IPNet(topo=PartialStaticAddressNet()) - net.start() - + with run_ipnet(PartialStaticAddressNet()) as net: # Check allocated addresses assert net["h3"].intf("h3-eth0").ip == "192.168.1.2" assert net["h3"].intf("h3-eth0").ip6 == "fc00:1::2" @@ -82,10 +70,6 @@ def test_partial_static_example(): assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) - net.stop() - finally: - cleanup() - static_paths = { StaticRoutingNet.__name__: [["h1", "r1", "r2", "h2"], ["h2", "r2", "r1", "h1"]], @@ -132,10 +116,7 @@ def test_partial_static_example(): ], ) def test_static_examples(topo, connected, v4, v6): - try: - net = IPNet(topo=topo()) - net.start() - + with run_ipnet(topo()) as net: if connected and v4: assert_connectivity(net, v6=False) if connected and v6: @@ -146,7 +127,3 @@ def test_static_examples(topo, connected, v4, v6): assert_path(net, p, v6=False) if v6: assert_path(net, p, v6=True) - - net.stop() - finally: - cleanup() diff --git a/ipmininet/tests/test_switch.py b/ipmininet/tests/test_switch.py index 56167ad7..9f3fecac 100644 --- a/ipmininet/tests/test_switch.py +++ b/ipmininet/tests/test_switch.py @@ -4,7 +4,6 @@ import pytest -from ipmininet.clean import cleanup from ipmininet.examples.spanning_tree import SpanningTreeNet from ipmininet.examples.spanning_tree_adjust import SpanningTreeAdjust from ipmininet.examples.spanning_tree_bus import SpanningTreeBus @@ -12,11 +11,10 @@ from ipmininet.examples.spanning_tree_full_mesh import SpanningTreeFullMesh from ipmininet.examples.spanning_tree_hub import SpanningTreeHub from ipmininet.examples.spanning_tree_intermediate import SpanningTreeIntermediate -from ipmininet.ipnet import IPNet from ipmininet.ipswitch import IPSwitch from ipmininet.iptopo import IPTopo from ipmininet.tests import require_root -from ipmininet.tests.utils import assert_connectivity, assert_stp_state +from ipmininet.tests.utils import assert_connectivity, assert_stp_state, run_ipnet # Accelerate STP convergence (kernel forward delay is 15s by default). FastSTPSwitch = functools.partial(IPSwitch, stp_forward_delay=4) @@ -220,40 +218,28 @@ def build(self, *args, **kwargs): ], ) def test_stp(topo): - try: - net = IPNet(topo=topo(), switch=FastSTPSwitch) - net.start() - + with run_ipnet(topo(), switch=FastSTPSwitch) as net: assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) for switch, states in expected_states[topo.__name__].items(): assert_stp_state(net[switch], states) - net.stop() - finally: - cleanup() def test_stp_adjust(): - try: - net = IPNet( - topo=SpanningTreeAdjust( - l1_start="s2-eth1", - l1_end="s1-eth1", - l1_cost=2, - l2_start="s1-eth3", - l2_end="s3-eth1", - l2_cost=3, - ), - switch=FastSTPSwitch, - ) - net.start() - + with run_ipnet( + SpanningTreeAdjust( + l1_start="s2-eth1", + l1_end="s1-eth1", + l1_cost=2, + l2_start="s1-eth3", + l2_end="s3-eth1", + l2_cost=3, + ), + switch=FastSTPSwitch, + ) as net: assert_connectivity(net, v6=False) assert_connectivity(net, v6=True) for switch, states in expected_states["SpanningTreeAdjust-mod"].items(): assert_stp_state(net[switch], states) - net.stop() - finally: - cleanup() diff --git a/ipmininet/tests/test_topo_scenarios.py b/ipmininet/tests/test_topo_scenarios.py index 5fce0654..218dedc7 100644 --- a/ipmininet/tests/test_topo_scenarios.py +++ b/ipmininet/tests/test_topo_scenarios.py @@ -19,6 +19,11 @@ NodeNotOnLinkError, ) from ipmininet.overlay import ( + _PCAP_GLOBAL_HEADER_SIZE, + _PCAP_MAGIC_BE, + _PCAP_MAGIC_LE, + _PCAPNG_MAGIC, + _PCAPNG_SECTION_HEADER_SIZE, NetworkCapture, NoCaptureAnchorError, Subnet, @@ -26,11 +31,6 @@ ) _COST = 5 -_PCAP_MAGIC_LE = b"\xd4\xc3\xb2\xa1" -_PCAP_MAGIC_BE = b"\xa1\xb2\xc3\xd4" -_PCAPNG_MAGIC = b"\x0a\x0d\x0d\x0a" -_PCAP_GLOBAL_HEADER_SIZE = 24 -_PCAPNG_SECTION_HEADER_SIZE = 28 def _ips_per_node(topo: IPTopo) -> dict[str, list[str]]: @@ -52,6 +52,29 @@ def _build_lan(prefix="s") -> IPTopo: return topo +def _build_split_lan() -> IPTopo: + """Two hosts on two distinct LANs.""" + topo = IPTopo() + topo.addHost("h1") + topo.addSwitch("s1") + topo.addHost("h2") + topo.addSwitch("s2") + topo.addLink("h1", "s1") + topo.addLink("h2", "s2") + return topo + + +def _build_star_lan(nhosts=4) -> IPTopo: + """Four hosts on the same switch.""" + topo = IPTopo() + for host in (f"h{i}" for i in range(1, nhosts + 1)): + topo.addHost(host) + topo.addSwitch("s1") + for host in (f"h{i}" for i in range(1, nhosts + 1)): + topo.addLink(host, "s1") + return topo + + def test_topo_node_type_helpers(): topo = IPTopo() topo.addRouter("r1") @@ -93,69 +116,46 @@ def test_topo_unknown_attribute_raises(): _ = topo.no_such_attribute -def test_subnet_assigns_addresses_in_order(): - topo = _build_lan() - topo.addSubnet(nodes=["h1", "h2"], subnets=["10.0.0.0/24"]) - topo.build() - - assert topo.overlays[0].consistent - ips = _ips_per_node(topo) - assert ips["h1"] == ["10.0.0.1/24"] - assert ips["h2"] == ["10.0.0.2/24"] - - -def test_subnet_multiple_subnets_same_interface(): - topo = _build_lan() - topo.addSubnet(nodes=["h1", "h2"], subnets=["10.0.0.0/24", "192.168.0.0/30"]) +@pytest.mark.parametrize( + "builder,nodes,subnets,expected_ips,consistent", + [ + ( + _build_lan, + ["h1", "h2"], + ["10.0.0.0/24"], + {"h1": ["10.0.0.1/24"], "h2": ["10.0.0.2/24"]}, + True, + ), + ( + _build_lan, + ["h1", "h2"], + ["10.0.0.0/24", "192.168.0.0/30"], + { + "h1": ["10.0.0.1/24", "192.168.0.1/30"], + "h2": ["10.0.0.2/24", "192.168.0.2/30"], + }, + True, + ), + (_build_split_lan, ["h1", "h2"], ["10.0.0.0/24"], None, False), + (_build_star_lan, ["h1", "h2", "h3", "h4"], ["10.0.0.0/30"], None, False), + (_build_lan, ["h1", "h2"], ["banana"], None, False), + (_build_lan, [], ["10.0.0.0/24"], {}, True), + ], +) +def test_subnet_overlay_consistency(builder, nodes, subnets, expected_ips, consistent): + topo = builder() + topo.addSubnet(nodes=nodes, subnets=subnets) topo.build() + assert bool(topo.overlays[0].consistent) is consistent + if expected_ips is None: + return ips = _ips_per_node(topo) - assert ips["h1"] == ["10.0.0.1/24", "192.168.0.1/30"] - assert ips["h2"] == ["10.0.0.2/24", "192.168.0.2/30"] - - -def test_subnet_nodes_on_distinct_lans_are_inconsistent(): - topo = IPTopo() - topo.addHost("h1") - topo.addHost("h2") - topo.addSwitch("s1") - topo.addSwitch("s2") - topo.addLink("h1", "s1") - topo.addLink("h2", "s2") - topo.addSubnet(nodes=["h1", "h2"], subnets=["10.0.0.0/24"]) - topo.build() - - assert not topo.overlays[0].consistent - - -def test_subnet_too_small_is_inconsistent(): - topo = IPTopo() - for host in ("h1", "h2", "h3", "h4"): - topo.addHost(host) - topo.addSwitch("s1") - for host in ("h1", "h2", "h3", "h4"): - topo.addLink(host, "s1") - topo.addSubnet(nodes=["h1", "h2", "h3", "h4"], subnets=["10.0.0.0/30"]) - topo.build() - - assert not topo.overlays[0].consistent - - -def test_subnet_invalid_network_is_inconsistent(): - topo = _build_lan() - topo.addSubnet(nodes=["h1", "h2"], subnets=["banana"]) - topo.build() - - assert not topo.overlays[0].consistent - - -def test_subnet_without_nodes_is_consistent(): - topo = _build_lan() - topo.addSubnet(nodes=[], subnets=["10.0.0.0/24"]) - topo.build() - - assert topo.overlays[0].consistent - assert not any(_ips_per_node(topo).values()) + if not expected_ips: + assert not any(ips.values()) + else: + for node, expected in expected_ips.items(): + assert ips[node] == expected def test_overlay_element_properties(): diff --git a/ipmininet/tests/test_topologydb.py b/ipmininet/tests/test_topologydb.py index 607524ba..8f5a07d3 100644 --- a/ipmininet/tests/test_topologydb.py +++ b/ipmininet/tests/test_topologydb.py @@ -2,7 +2,6 @@ import itertools import os -import shutil import tempfile from ipaddress import ip_interface @@ -14,11 +13,11 @@ from ipmininet.examples.spanning_tree import SpanningTreeNet from ipmininet.examples.static_address_network import StaticAddressNet from ipmininet.host import IPHost -from ipmininet.ipnet import IPNet from ipmininet.ipswitch import IPSwitch from ipmininet.iptopo import IPTopo from ipmininet.router import Router from ipmininet.tests import require_root +from ipmininet.tests.utils import run_ipnet from ipmininet.topologydb import ( NoSuchLinkError, NoSuchNodeError, @@ -60,9 +59,7 @@ def build(self, *args, **kwargs): ], ) def test_topologydb(topology: type[IPTopo]): - net = IPNet(topo=topology()) - db_dir = tempfile.mkdtemp() - try: + with tempfile.TemporaryDirectory() as db_dir, run_ipnet(topology()) as net: db = TopologyDB(net=net) db_path = os.path.join(db_dir, "topology.json") @@ -140,15 +137,10 @@ def test_topologydb(topology: type[IPTopo]): "do not match" ) - finally: - net.stop() - shutil.rmtree(db_dir, ignore_errors=True) - @require_root def test_topologydb_lookups_and_errors(): - net = IPNet(topo=_MixedNetworkTopo()) - try: + with run_ipnet(_MixedNetworkTopo()) as net: db = TopologyDB(net=net) assert db["r1"]["type"] == "router" @@ -176,8 +168,6 @@ def test_topologydb_lookups_and_errors(): db.interface_bandwidth("h1", "ghost") with pytest.raises(NotARouterError): db.routerid("h1") - finally: - net.stop() def test_topologydb_without_data(): diff --git a/ipmininet/tests/utils.py b/ipmininet/tests/utils.py index a376542f..9a7f0b9a 100644 --- a/ipmininet/tests/utils.py +++ b/ipmininet/tests/utils.py @@ -1,8 +1,9 @@ import re import signal import time -from collections.abc import Callable +from collections.abc import Callable, Iterator from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager from io import StringIO from ipaddress import ip_address, ip_network from re import Match, Pattern @@ -10,9 +11,11 @@ import mininet.log import pytest +from ipmininet.clean import cleanup from ipmininet.host.config.named import DNSRecord from ipmininet.ipnet import IPNet from ipmininet.ipswitch import IPSwitch +from ipmininet.iptopo import IPTopo from ipmininet.router import IPNode from ipmininet.utils import require_cmd @@ -20,6 +23,94 @@ # network has converged on a stable path. CONVERGED_PATH_COUNT = 2 +# Paths through the triangular three-router IGP topology shared by the OSPF and +# RIPng daemon tests: the unit-cost shortest paths, and the detour taken once +# the r1-r2 link cost is raised. +IGP_UNIT_PATHS = [ + ["h1", "r1", "r2", "h2"], + ["h2", "r2", "r1", "h1"], + ["h1", "r1", "r3", "h3"], + ["h3", "r3", "r1", "h1"], + ["h2", "r2", "r3", "h3"], + ["h3", "r3", "r2", "h2"], +] +IGP_DETOUR_PATHS = [ + ["h1", "r1", "r3", "r2", "h2"], + ["h2", "r2", "r3", "r1", "h1"], + ["h1", "r1", "r3", "h3"], + ["h3", "r3", "r1", "h1"], + ["h2", "r2", "r3", "h3"], + ["h3", "r3", "r2", "h2"], +] + + +@contextmanager +def run_ipnet(topo: IPTopo, **net_kwargs) -> Iterator[IPNet]: + """Start an IPNet built from ``topo`` and guarantee its teardown. + + Runs ``net.stop()`` and the global ``cleanup()`` in a ``finally`` block so + a test never leaks namespaces, daemons or interfaces, even when an + assertion fails mid-test. + """ + net = IPNet(topo=topo, **net_kwargs) + net.start() + try: + yield net + finally: + net.stop() + cleanup() + + +def assert_config_file(cfg_file: str, exp_cfg: list[str], strip=False): + """Assert that every expected line appears in a generated daemon config. + + :param strip: Whether to compare lines with surrounding whitespace and + blank lines removed (used by daemons that indent config). + """ + with open(cfg_file) as fileobj: + if strip: + cfg = [line.strip() for line in fileobj if line.strip()] + else: + cfg = fileobj.readlines() + for line in exp_cfg: + expected = line if strip else line + "\n" + assert expected in cfg, ( + "Cannot find the line '{}' in the generated configuration:\n{}".format( + line, "".join(cfg) + ) + ) + + +def assert_all_paths(net: IPNet, paths: list[list[str]], v6=False): + """Assert the network has converged on each of the expected paths.""" + for path in paths: + assert_path(net, path, v6=v6) + + +def run_topology_scenario( + topo: IPTopo, + exp_cfg: list[str] | None = None, + cfg_file: str | None = None, + paths: list[list[str]] | None = None, + v6=False, + **net_kwargs, +): + """Start ``topo`` and check its config, connectivity and converged paths. + + :param topo: The topology to start. + :param cfg_file: Path of the generated daemon config to check, whose lines + must contain every entry of ``exp_cfg``. + :param exp_cfg: Lines expected to appear in ``cfg_file``. + :param paths: Expected converged paths (checked with ``assert_path``). + :param v6: Whether to check IPv6 connectivity and paths. + """ + with run_ipnet(topo, **net_kwargs) as net: + if cfg_file is not None: + assert_config_file(cfg_file, exp_cfg or []) + assert_connectivity(net, v6=v6) + if paths is not None: + assert_all_paths(net, paths, v6=v6) + def wait_until( predicate: Callable[[], bool], diff --git a/ipmininet/utils.py b/ipmininet/utils.py index 2373ad72..1b14e308 100644 --- a/ipmininet/utils.py +++ b/ipmininet/utils.py @@ -146,6 +146,24 @@ def get_set(d: dict, key, default: type): return x +def walk_unvisited(intfs, expand): + """Yield the interfaces reachable from ``intfs`` without revisiting any. + + Each yielded interface is popped from a LIFO work queue and marked as + visited; ``expand(intf)`` must return the neighbour interfaces to enqueue + after visiting ``intf`` (an empty list stops that branch). + """ + visited = set() + to_visit = list(intfs) + while to_visit: + i = to_visit.pop() + if i in visited: + continue + visited.add(i) + yield i + to_visit.extend(expand(i)) + + def find_node(start: Node, node_name: str) -> Intf | None: """ :param start: The starting node of the search @@ -156,18 +174,16 @@ def find_node(start: Node, node_name: str) -> Intf | None: if start.name == node_name: return start.intf() - visited = set() # type: Set[IPIntf] - to_visit = realIntfList(start) - # Explore all interfaces recursively, until we find one - # connected to the node - while to_visit: - i = to_visit.pop() - if i in visited: - continue - visited.add(i) + def _expand(i): + return [ + intf + for n in i.broadcast_domain.interfaces + if L3Router.is_l3router_intf(n) + for intf in realIntfList(n.node) + ] + + for i in walk_unvisited(realIntfList(start), _expand): for n in i.broadcast_domain.interfaces: if n.node.name == node_name: return n - if L3Router.is_l3router_intf(n): - to_visit.extend(realIntfList(n.node)) return None diff --git a/pyproject.toml b/pyproject.toml index 28106581..a8917f9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,10 @@ skip_covered = true fail_under = 90 [tool.ipmininet] -duplication_max = 51 +# Zero-tolerance duplicate-code gate for the product and the test suite. +# The standalone examples under ipmininet/examples (and install helpers) are +# deliberately excluded: they double as user documentation, each kept small. +duplication_max = 0 [tool.pylint] diff --git a/scripts/check-duplicates.sh b/scripts/check-duplicates.sh index 0fef89b1..d7f94a49 100755 --- a/scripts/check-duplicates.sh +++ b/scripts/check-duplicates.sh @@ -2,13 +2,15 @@ # Enforce a ceiling on duplicated source/test code blocks (pylint duplicate-code). # The allowed count is read from pyproject.toml [tool.ipmininet] duplication_max, # so the project config file stays the single source of truth. +# The standalone examples under ipmininet/examples and the install helpers are +# excluded: they act as self-contained user documentation. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" BASELINE="$(uv run python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["tool"]["ipmininet"].get("duplication_max", 0))')" -OUT="$(uv run pylint ipmininet/ 2>&1 || true)" +OUT="$(uv run pylint ipmininet/ --ignore=examples,install 2>&1 || true)" COUNT="$(printf '%s\n' "$OUT" | grep -c "R0801" || true)" if [ "$COUNT" -gt "$BASELINE" ]; then From d3ce325808a32b5f8b2d6757a454cdb461637788 Mon Sep 17 00:00:00 2001 From: iakov Date: Sun, 6 Sep 2026 20:05:21 +0300 Subject: [PATCH 67/69] build: prepare v1.3.0 release (#50) build: prepare v1.3.0 (version bump, metadata and install docs refresh) - bump version to 1.3.0 and refresh the Python classifiers (>=3.12 only) - regenerate uv.lock for the new version - document that install.py provisions FRRouting 10.7.1 (mgmtd; FRR >= 9 required, so existing environments must be re-provisioned) and ExaBGP 5.0.13 via pip; bump the pinned install tag in docs - drop the dead upstream Python 3.7 Jenkins badges from the README --- README.md | 6 ++++-- docs/install.rst | 8 +++++++- pyproject.toml | 4 ++-- uv.lock | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e6fb7162..1ae83981 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@ # IPMininet [![Pypi Version](https://img.shields.io/pypi/v/ipmininet.svg)](https://pypi.python.org/pypi/ipmininet/) -[![Build Status](https://jenkins-mininet.info.ucl.ac.be/buildStatus/icon?job=ipmininet%2Fpythonversion%3Dipmininet-py37%2Ctestname%3Dunittest&subject=Python3.7)](https://jenkins-mininet.info.ucl.ac.be/job/ipmininet/pythonversion=ipmininet-py37,testname=unittest) [![Documentation Status](https://readthedocs.org/projects/ipmininet/badge/?version=latest)](http://ipmininet.readthedocs.io/?badge=latest) -[![Build Status](https://jenkins-mininet.info.ucl.ac.be/buildStatus/icon?job=ipmininet%2Fpythonversion%3Dipmininet-py37%2Ctestname%3Ddoctest&subject=doc%20examples)](https://jenkins-mininet.info.ucl.ac.be/job/ipmininet/pythonversion=ipmininet-py37,testname=doctest) [![Code Coverage](https://codecov.io/gh/mimi-net/ipmininet/branch/master/graph/badge.svg)](https://codecov.io/gh/mimi-net/ipmininet) This is a python library, extending [Mininet](http://mininet.org), in order @@ -13,3 +11,7 @@ IP addresses or router configuration files, ... The latest user documentation is available on https://ipmininet.readthedocs.io/ + +Requires Python 3.12+ and FRRouting 10.7+ (the generated FRR configs use the +mgmtd backend introduced in FRR 9). Install or re-provision the daemons with +`sudo python -m ipmininet.install -af`; see the installation docs. diff --git a/docs/install.rst b/docs/install.rst index 22ddfe24..d3a768cb 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -54,7 +54,7 @@ You can change the installed version by replacing the version tag in the followi .. code-block:: bash - $ sudo pip install --upgrade git+https://github.com/mimi-net/ipmininet.git@v1.2.6 + $ sudo pip install --upgrade git+https://github.com/mimi-net/ipmininet.git@v1.3.0 Then, you can install all the daemons: @@ -66,4 +66,10 @@ You can choose to install only a subset of the daemons by changing the options on the installation script. For the option documentations, use the ``-h`` option. +The installer provisions FRRouting **10.7.1** (built from source) and installs +ExaBGP **5.0.13** via pip. IPMininet generates FRR configs through the mgmtd +backend introduced in FRR 9, so an environment provisioned by an older +IPMininet must be **re-provisioned** (``sudo python -m ipmininet.install -af``) +after upgrading the library, or the daemon startup checks will fail. + .. _documentation: http://mininet.org/download/ diff --git a/pyproject.toml b/pyproject.toml index a8917f9b..5f121e6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "ipmininet" -version = "1.2.7" +version = "1.3.0" requires-python = ">=3.12" description = "A mininet extension providing components to emulate IPnetworks running multiple protocols." keywords = [ "networking", "OSPF", "IP", "BGP", "quagga", "mininet",] -classifiers = [ "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", "Programming Language :: Python", "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: System :: Networking", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9",] +classifiers = [ "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: System :: Networking",] dependencies = [ "setuptools", "mako>=1.1,<1.5", "mininet @ git+https://github.com/mimi-net/mininet.git",] [[project.authors]] diff --git a/uv.lock b/uv.lock index be27d433..579dbf80 100644 --- a/uv.lock +++ b/uv.lock @@ -369,7 +369,7 @@ wheels = [ [[package]] name = "ipmininet" -version = "1.2.7" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "mako" }, From ccb14ed483276eedee17fcc04b79992253cc5bcf Mon Sep 17 00:00:00 2001 From: iakov Date: Thu, 10 Sep 2026 01:39:34 +0300 Subject: [PATCH 68/69] fix(bgp): allow explicit route-map ordering of set actions (#51) set_local_pref/set_med/set_community now accept an optional order and place their route-map entry at that order, mirroring deny/permit/filter. Also fix a latent crash when two set actions on the same peer used different match conditions: the route-map lookup miss dereferenced None (AttributeError in add_set_action) instead of appending a new auto-ordered entry. Add a rootless model test exercising the merge, distinct-entry and explicit-order paths. Relates to cnp3/ipmininet#99. --- .github/workflows/test.yaml | 1 + ipmininet/router/config/bgp.py | 39 ++++++++++++---- ipmininet/tests/test_bgp_model.py | 74 +++++++++++++++++++++++++++++++ scripts/run-tests-parallel.sh | 1 + 4 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 ipmininet/tests/test_bgp_model.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 93e3fdb3..ee242c9e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -53,6 +53,7 @@ jobs: ipmininet/tests/test_misc.py \ ipmininet/tests/test_link.py \ ipmininet/tests/test_network_capture.py \ + ipmininet/tests/test_bgp_model.py \ ipmininet/tests/test_dhcp.py - name: Upload coverage report (rootless) uses: actions/upload-artifact@v7 diff --git a/ipmininet/router/config/bgp.py b/ipmininet/router/config/bgp.py index fcad7516..c9c3d02c 100644 --- a/ipmininet/router/config/bgp.py +++ b/ipmininet/router/config/bgp.py @@ -255,6 +255,7 @@ def set_local_pref( from_peer: str, matching: Sequence[AccessList | CommunityList] = (), name: str | None = None, + order: int | None = None, ) -> "BGPConfig": """Set local pref on a peering with 'from_peer' on routes matching all of the access and community lists in 'matching' @@ -263,6 +264,8 @@ def set_local_pref( :param local_pref: The local pref value to set :param from_peer: The peer on which the local pref is applied :param matching: A list of AccessList and/or CommunityList + :param order: The order in which the route-map entry is applied, + i.e., lower order means applied before :return: self """ self.add_set_action( @@ -271,6 +274,7 @@ def set_local_pref( matching=matching, direction="in", name=name, + order=order, ) return self @@ -280,6 +284,7 @@ def set_med( to_peer: str, matching: Sequence[AccessList | CommunityList] = (), name: str | None = None, + order: int | None = None, ) -> "BGPConfig": """Set MED on a peering with 'to_peer' on routes matching all of the access and community lists in 'matching' @@ -288,6 +293,8 @@ def set_med( :param med: The local pref value to set :param to_peer: The peer to which the med is applied :param matching: A list of AccessList and/or CommunityList + :param order: The order in which the route-map entry is applied, + i.e., lower order means applied before :return: self """ self.add_set_action( @@ -296,16 +303,18 @@ def set_med( matching=matching, direction="out", name=name, + order=order, ) return self - def set_community( + def set_community( # noqa: PLR0913, PLR0917 self, community: str | int, from_peer: str | None = None, to_peer: str | None = None, matching: Sequence[AccessList | CommunityList] = (), name: str | None = None, + order: int | None = None, ) -> "BGPConfig": """Set community on a routes received from 'from_peer' and routes sent to 'to_peer' on routes matching @@ -317,6 +326,8 @@ def set_community( the community :param to_peer: The peer on which sent routes have to have the community :param matching: A list of AccessList and/or CommunityList + :param order: The order in which the route-map entry is applied, + i.e., lower order means applied before :return: self """ if to_peer is not None: @@ -326,6 +337,7 @@ def set_community( matching=matching, direction="out", name=name, + order=order, ) if from_peer is not None: self.add_set_action( @@ -334,6 +346,7 @@ def set_community( matching=matching, direction="in", name=name, + order=order, ) return self @@ -504,13 +517,14 @@ def filters_to_match_cond( ) return match_cond - def add_set_action( + def add_set_action( # noqa: PLR0913, PLR0917 self, peer: str, set_action: RouteMapSetAction, name: str | None, matching: Sequence[AccessList | CommunityList], direction: str, + order: int | None = None, ) -> "BGPConfig": """Add a 'RouteMapSetAction' to a BGP peering between two nodes @@ -519,6 +533,10 @@ def add_set_action( :param set_action: The RouteMapSetAction to set :param matching: A list of filter, can be empty :param direction: direction of the route map: 'in', 'out' or 'both' + :param order: The order in which the route-map entry is applied, + i.e., lower order means applied before. If None, the action is merged + into the entry of the route map matching the filters, or appended as a + new entry if none does :return: self """ route_maps = self.topo.getNodeInfo(self.router, "bgp_route_maps", list) @@ -542,14 +560,17 @@ def add_set_action( try: idx = route_maps.index(rm) - entry = route_maps[idx].find_entry_by_match_condition(match_cond) - if entry: - entry.append_set_action([set_action]) - else: - entry.entry(rm_entry) - + existing_rm = route_maps.pop(idx) + if order is None: + entry = existing_rm.find_entry_by_match_condition(match_cond) + if entry is not None: + entry.append_set_action([set_action]) + route_maps.append(existing_rm) + continue + existing_rm.entry(rm_entry, order) + route_maps.append(existing_rm) except ValueError: - rm.entry(rm_entry) + rm.entry(rm_entry, order) route_maps.append(rm) return self diff --git a/ipmininet/tests/test_bgp_model.py b/ipmininet/tests/test_bgp_model.py new file mode 100644 index 00000000..38aa70d9 --- /dev/null +++ b/ipmininet/tests/test_bgp_model.py @@ -0,0 +1,74 @@ +"""Rootless unit tests for the BGPConfig route-map builder. + +These drive BGPConfig on an in-memory stand-in for the topology object, so +they exercise the route-map data model without starting a network. +""" + +from ipmininet.router.config import AccessList +from ipmininet.router.config.bgp import BGPConfig + +# Number of set actions merged into a single route-map entry when the match +# conditions are identical. +_MERGED_ACTION_COUNT = 2 +# Explicit route-map order used to place a community set action. +_CUSTOM_ORDER = 15 + + +class _NodeInfoTopo: + """In-memory stand-in for IPTopo node attribute storage.""" + + def __init__(self): + self._attrs = {} + + def getNodeInfo(self, node, attr, default=None): + key = (node, attr) + if key not in self._attrs: + self._attrs[key] = default() if callable(default) else default + return self._attrs[key] + + +def _new_cfg(): + topo = _NodeInfoTopo() + return BGPConfig(topo, "r1"), topo + + +def _acl(name): + return AccessList("ipv4", name=name, entries=("any",)) + + +def _ipv4_route_map(topo): + route_maps = topo.getNodeInfo("r1", "bgp_route_maps", list) + return next(rm for rm in route_maps if rm.family == "ipv4") + + +def test_set_action_distinct_match_conditions_do_not_crash(): + cfg, topo = _new_cfg() + cfg.set_local_pref(150, from_peer="r2", matching=(_acl("a"),)) + cfg.set_local_pref(450, from_peer="r2", matching=(_acl("b"),)) + entries = _ipv4_route_map(topo).entries + assert sorted(entries) == [10, 20] + + +def test_set_action_same_match_conditions_merge(): + cfg, topo = _new_cfg() + acl = _acl("a") + cfg.set_local_pref(150, from_peer="r2", matching=(acl,)) + cfg.set_local_pref(450, from_peer="r2", matching=(acl,)) + entries = _ipv4_route_map(topo).entries + assert sorted(entries) == [10] + assert len(next(iter(entries.values())).set_actions) == _MERGED_ACTION_COUNT + + +def test_set_action_explicit_orders(): + cfg, topo = _new_cfg() + cfg.set_local_pref(150, from_peer="r2", matching=(_acl("a"),), order=20) + cfg.set_local_pref(450, from_peer="r2", matching=(_acl("b"),), order=10) + entries = _ipv4_route_map(topo).entries + assert sorted(entries) == [10, 20] + assert all(len(entry.set_actions) == 1 for entry in entries.values()) + + +def test_set_community_forwards_order(): + cfg, topo = _new_cfg() + cfg.set_community(3, from_peer="r2", order=_CUSTOM_ORDER) + assert _CUSTOM_ORDER in _ipv4_route_map(topo).entries diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index a35cfea9..bb122cf2 100755 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -35,6 +35,7 @@ if [ ${#ARGS[@]} -eq 0 ]; then ipmininet/tests/test_switch.py ipmininet/tests/test_static.py ipmininet/tests/test_bgp.py + ipmininet/tests/test_bgp_model.py ipmininet/tests/test_dns.py ipmininet/tests/test_radv.py ipmininet/tests/test_link.py From 9874fde3195b9a35b8e72c18f93cd42e5e51482e Mon Sep 17 00:00:00 2001 From: iakov Date: Thu, 10 Sep 2026 08:38:58 +0300 Subject: [PATCH 69/69] build: prepare v1.3.1 release (#52) build: prepare v1.3.1 Version bump 1.3.0 -> 1.3.1 (patch: bugfix + additive route-map order parameter, no API or environment change) and bump the pinned install tag in the docs. --- docs/install.rst | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index d3a768cb..44655eee 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -54,7 +54,7 @@ You can change the installed version by replacing the version tag in the followi .. code-block:: bash - $ sudo pip install --upgrade git+https://github.com/mimi-net/ipmininet.git@v1.3.0 + $ sudo pip install --upgrade git+https://github.com/mimi-net/ipmininet.git@v1.3.1 Then, you can install all the daemons: diff --git a/pyproject.toml b/pyproject.toml index 5f121e6c..9e46790f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ipmininet" -version = "1.3.0" +version = "1.3.1" requires-python = ">=3.12" description = "A mininet extension providing components to emulate IPnetworks running multiple protocols." keywords = [ "networking", "OSPF", "IP", "BGP", "quagga", "mininet",] diff --git a/uv.lock b/uv.lock index 579dbf80..3e87af3e 100644 --- a/uv.lock +++ b/uv.lock @@ -369,7 +369,7 @@ wheels = [ [[package]] name = "ipmininet" -version = "1.3.0" +version = "1.3.1" source = { editable = "." } dependencies = [ { name = "mako" },