From 922715608399002c3202f354ce636ba6cb0ab6b6 Mon Sep 17 00:00:00 2001 From: Florent Carli Date: Mon, 3 Aug 2026 09:59:45 +0200 Subject: [PATCH 1/3] pyproject: declare the PyYAML dependency setup_ovs.py imports yaml to read .yaml and .yml configuration files, but the project declared no dependency at all. An installed package therefore raised ModuleNotFoundError on those files unless PyYAML happened to be present for another reason. Signed-off-by: Florent Carli --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 1e683f1..690245d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ authors = [ license = { text = "Apache-2.0" } readme = "README.md" requires-python = ">=3.6" +dependencies = [ + "PyYAML", +] [project.scripts] setup_ovs = "setup_ovs.setup_ovs:main" From f785c090aad5751978f36b20ed1f73fa90e89ca2 Mon Sep 17 00:00:00 2001 From: Florent Carli Date: Mon, 3 Aug 2026 09:59:54 +0200 Subject: [PATCH 2/3] tests: add a unit test suite and measure coverage The project had no test at all, so the two OpenSSF gold coverage criteria (test_statement_coverage90 and test_branch_coverage80) could not be evaluated: they ask for a measured figure, not for a suite that merely exists. Add a pytest suite covering the five modules. Everything that touches the system is mocked (subprocess, sysfs, /proc, the network stack), so the suite needs neither root nor a cluster and runs anywhere. It measures 99.09 percent of statements and 98.65 percent of branches. Enable branch coverage in pyproject.toml and add a test extra. No fail_under yet: the point of this commit is an honest baseline. Five tests are marked xfail(strict=True). Each pins a defect found while writing the suite rather than encoding it as expected behaviour, so the suite fails again once the defect is fixed and the marker has to go. All five predate this branch, they come from the initial import of the repository. Signed-off-by: Florent Carli --- .gitignore | 5 + README.md | 25 ++ pyproject.toml | 21 ++ tests/conftest.py | 53 ++++ tests/test_check.py | 547 +++++++++++++++++++++++++++++++++++++ tests/test_helpers.py | 229 ++++++++++++++++ tests/test_openflow.py | 254 +++++++++++++++++ tests/test_ovs.py | 586 ++++++++++++++++++++++++++++++++++++++++ tests/test_setup_ovs.py | 258 ++++++++++++++++++ 9 files changed, 1978 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_check.py create mode 100644 tests/test_helpers.py create mode 100644 tests/test_openflow.py create mode 100644 tests/test_ovs.py create mode 100644 tests/test_setup_ovs.py diff --git a/.gitignore b/.gitignore index 56aa9cc..23eba2a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ build/ __pycache__/ dist *.egg-info +.coverage +coverage.xml +coverage.json +htmlcov/ +.pytest_cache/ diff --git a/README.md b/README.md index cea6aa6..da899ea 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,28 @@ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=seapath_python3-setup-ovs&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=seapath_python3-setup-ovs) # python3-setup-ovs Python tool to setup the ovs topology in a Seapath cluster + +## Tests + +The test suite runs entirely off-target: every call to OVS, to the network +stack and to sysfs is mocked, so no cluster, no root access and no real +Open vSwitch are needed. + +```sh +pip install -e ".[test]" +pytest +``` + +To reproduce the coverage figures the CI publishes in its run summary: + +```sh +pytest --cov=setup_ovs --cov-report=term-missing --cov-report=xml +``` + +Branch coverage is enabled in `pyproject.toml`, so the report covers both +the statement and the branch criteria. + +A handful of tests are marked `xfail(strict=True)`. Each one documents a bug +found while writing the suite and pins the current, wrong behaviour: the +suite fails again the day the bug is fixed, which forces the marker to be +removed along with the fix. Their `reason` field states the defect. diff --git a/pyproject.toml b/pyproject.toml index 690245d..1b41c1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,13 @@ dependencies = [ "PyYAML", ] +[project.optional-dependencies] +test = [ + "pytest", + "pytest-cov", + "coverage[toml]", +] + [project.scripts] setup_ovs = "setup_ovs.setup_ovs:main" @@ -25,3 +32,17 @@ Homepage = "https://github.com/seapath/python3-setup-ovs" [build-system] requires = ["setuptools>=61", "wheel"] build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.coverage.run] +branch = true +source = ["setup_ovs"] + +[tool.coverage.report] +show_missing = true +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", +] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e168ccf --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from setup_ovs import helpers + + +@pytest.fixture(autouse=True) +def reset_helpers_state(monkeypatch): + """ + helpers.dry_run is a module global and find_command is memoized. + Both leak between tests, so reset them around every test. monkeypatch + restores dry_run on teardown, tests that need it on just set it the + same way. + """ + # Captured before the test body runs, so the teardown still clears the + # real memoized function even when a test monkeypatches find_command. + real_find_command = helpers.find_command + real_find_command.cache_clear() + monkeypatch.setattr(helpers, "dry_run", False) + yield + real_find_command.cache_clear() + + +@pytest.fixture +def run_command(monkeypatch): + """ + Replace helpers.run_command by a recorder shared by every module. + + The modules under test call helpers.run_command through the module + object, so patching the attribute once covers ovs, openflow and check. + """ + + class Recorder: + def __init__(self): + self.calls = [] + + def __call__(self, *cmd_args, **kwargs): + self.calls.append((cmd_args, kwargs)) + return None + + @property + def commands(self): + """Every call flattened to a single string, for substring asserts.""" + return [" ".join(map(str, args)) for args, _ in self.calls] + + def commands_containing(self, needle): + return [cmd for cmd in self.commands if needle in cmd] + + recorder = Recorder() + monkeypatch.setattr(helpers, "run_command", recorder) + return recorder diff --git a/tests/test_check.py b/tests/test_check.py new file mode 100644 index 0000000..8de7af2 --- /dev/null +++ b/tests/test_check.py @@ -0,0 +1,547 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +import subprocess + +import pytest + +from setup_ovs import check, helpers +from setup_ovs.setup_ovs_exception import SetupOVSConfigException + + +IPV4_CONF_ROOT = "/proc/sys/net/ipv4/conf" + + +@pytest.fixture +def system_interfaces(monkeypatch): + """ + Answer os.path.isdir for /proc/sys/net/ipv4/conf lookups only. + + check.os is the stdlib os module, so the patch is global: every other + path must keep its real behaviour or pytest's own internals break. + """ + real_isdir = check.os.path.isdir + + def configure(answer): + def fake_isdir(path): + if str(path).startswith(IPV4_CONF_ROOT): + return answer + return real_isdir(path) + + monkeypatch.setattr(check.os.path, "isdir", fake_isdir) + + return configure + + +@pytest.fixture +def existing_interfaces(system_interfaces): + """Pretend every network interface referenced by a config exists.""" + system_interfaces(True) + + +def bridge_with_port(port, **bridge_extra): + bridge = {"name": "br0", "ports": [port]} + bridge.update(bridge_extra) + return {"bridges": [bridge]} + + +def assert_rejects(config, match): + """ + Assert that configuration_check refuses config. + + The configuration is built by the caller so that the assertion block + holds a single call, which keeps what is under test unambiguous. + """ + with pytest.raises(SetupOVSConfigException, match=match): + check.configuration_check(config) + + +class TestSystemCheck: + def test_passes_when_ovs_answers(self, run_command): + check.system_check() + + assert run_command.commands == ["/usr/bin/ovs-vsctl show"] + + def test_reraises_when_ovs_is_down(self, monkeypatch): + def fake_run_command(*args, **kwargs): + raise subprocess.CalledProcessError(1, args) + + monkeypatch.setattr(helpers, "run_command", fake_run_command) + + with pytest.raises(subprocess.CalledProcessError): + check.system_check() + + +class TestConfigurationShape: + @pytest.mark.parametrize("config", [[], "string", 42, None]) + def test_rejects_non_dict_configuration(self, config): + assert_rejects(config, "should be a dictionary") + + def test_accepts_empty_configuration(self): + check.configuration_check({}) + + def test_rejects_non_list_bridges(self): + assert_rejects({"bridges": {"name": "br0"}}, "should be a list") + + def test_rejects_non_dict_bridge(self): + assert_rejects({"bridges": ["br0"]}, "must be a dictionary") + + def test_rejects_bridge_without_name(self): + assert_rejects({"bridges": [{"ports": []}]}, "without name") + + def test_accepts_bridge_without_ports(self): + check.configuration_check({"bridges": [{"name": "br0"}]}) + + def test_rejects_non_list_ports(self): + config = {"bridges": [{"name": "br0", "ports": {"name": "p0"}}]} + + assert_rejects(config, "ports must be a list") + + def test_rejects_non_dict_port(self): + config = {"bridges": [{"name": "br0", "ports": ["p0"]}]} + + assert_rejects(config, "port must be a dictionary") + + +class TestBridgeAttributes: + def test_accepts_other_config_as_string(self): + check.configuration_check( + {"bridges": [{"name": "br0", "other_config": "a=b"}]} + ) + + def test_accepts_other_config_as_string_list(self): + check.configuration_check( + {"bridges": [{"name": "br0", "other_config": ["a=b", "c=d"]}]} + ) + + def test_rejects_other_config_of_wrong_type(self): + config = {"bridges": [{"name": "br0", "other_config": {"a": "b"}}]} + + assert_rejects(config, "other_config") + + def test_rejects_other_config_list_with_non_string(self): + config = {"bridges": [{"name": "br0", "other_config": ["a=b", 3]}]} + + assert_rejects(config, "other_config") + + @pytest.mark.parametrize("attribute", ["rstp_enable", "enable_ipv6"]) + def test_rejects_non_boolean_flags(self, attribute): + config = {"bridges": [{"name": "br0", attribute: "yes"}]} + + assert_rejects(config, "must be a boolean") + + @pytest.mark.parametrize("attribute", ["rstp_enable", "enable_ipv6"]) + def test_accepts_boolean_flags(self, attribute): + check.configuration_check({"bridges": [{"name": "br0", attribute: True}]}) + + +class TestUnbindPciAddress: + def test_accepts_valid_addresses(self): + check.configuration_check({"unbind_pci_address": ["0000:3b:00.0", "5e:00.1"]}) + + def test_rejects_non_list(self): + assert_rejects({"unbind_pci_address": "0000:3b:00.0"}, "addresses list") + + def test_rejects_non_string_entry(self): + assert_rejects({"unbind_pci_address": [42]}, "must be a string") + + def test_rejects_malformed_address(self): + assert_rejects( + {"unbind_pci_address": ["not-a-pci"]}, "is not a PCI address" + ) + + +class TestPortBasics: + def test_rejects_port_without_name(self): + assert_rejects(bridge_with_port({"type": "tap"}), "without name") + + def test_rejects_port_without_type(self): + assert_rejects(bridge_with_port({"name": "p0"}), "without type") + + def test_rejects_unknown_type(self): + config = bridge_with_port({"name": "p0", "type": "wormhole"}) + + assert_rejects(config, "Bad type value") + + @pytest.mark.parametrize( + "port_type", + ["internal", "tap", "dpdkvhostuserclient", "vxlan"], + ) + def test_accepts_types_needing_no_interface(self, port_type): + check.configuration_check( + bridge_with_port({"name": "p0", "type": port_type}) + ) + + def test_warns_when_interface_is_ignored(self, caplog): + check.configuration_check( + bridge_with_port( + {"name": "p0", "type": "tap", "interface": "eth0"} + ) + ) + + assert "interface is ignored" in caplog.text + + +class TestSystemPort: + def test_accepts_existing_interface(self, existing_interfaces): + check.configuration_check( + bridge_with_port( + {"name": "p0", "type": "system", "interface": "eth0"} + ) + ) + + def test_rejects_missing_interface_attribute(self): + config = bridge_with_port({"name": "p0", "type": "system"}) + + assert_rejects(config, "interface is required") + + def test_rejects_unknown_interface(self, system_interfaces): + system_interfaces(False) + config = bridge_with_port( + {"name": "p0", "type": "system", "interface": "eth9"} + ) + + assert_rejects(config, "could not find the network") + + def test_only_logs_unknown_interface_in_dry_run( + self, system_interfaces, monkeypatch, caplog + ): + system_interfaces(False) + monkeypatch.setattr(helpers, "dry_run", True) + + check.configuration_check( + bridge_with_port( + {"name": "p0", "type": "system", "interface": "eth9"} + ) + ) + + assert "could not find the network" in caplog.text + + +class TestDpdkPort: + def test_rejects_missing_interface_attribute(self): + config = bridge_with_port({"name": "p0", "type": "dpdk"}) + + assert_rejects(config, "interface is required") + + def test_rejects_non_pci_interface(self): + config = bridge_with_port( + {"name": "p0", "type": "dpdk", "interface": "eth0"} + ) + + assert_rejects(config, "not a PCI address") + + def test_looks_the_nic_up_with_lspci(self, run_command): + check.configuration_check( + bridge_with_port( + {"name": "p0", "type": "dpdk", "interface": "0000:3b:00.0"} + ) + ) + + assert run_command.commands_containing("lspci") + assert "3b:00.0" in run_command.commands[0] + + def test_normalises_the_pci_address_for_lspci(self, run_command): + check.configuration_check( + bridge_with_port( + {"name": "p0", "type": "dpdk", "interface": "0:3:0.0"} + ) + ) + + assert "03:00.0" in run_command.commands[0] + + def test_rejects_nic_absent_from_lspci(self, monkeypatch): + def fake_run_command(*args, **kwargs): + raise subprocess.CalledProcessError(1, args) + + monkeypatch.setattr(helpers, "run_command", fake_run_command) + config = bridge_with_port( + {"name": "p0", "type": "dpdk", "interface": "0000:3b:00.0"} + ) + + assert_rejects(config, "Can't find the NIC") + + def test_skips_the_lspci_lookup_in_dry_run(self, run_command, monkeypatch): + monkeypatch.setattr(helpers, "dry_run", True) + + check.configuration_check( + bridge_with_port( + {"name": "p0", "type": "dpdk", "interface": "0000:3b:00.0"} + ) + ) + + assert run_command.calls == [] + + +class TestVlanAttributes: + @pytest.mark.parametrize( + "vlan_mode", ["access", "native-tagged", "native-untagged", "trunk"] + ) + def test_accepts_known_vlan_modes(self, vlan_mode): + check.configuration_check( + bridge_with_port( + {"name": "p0", "type": "tap", "vlan_mode": vlan_mode} + ) + ) + + def test_rejects_unknown_vlan_mode(self): + config = bridge_with_port( + {"name": "p0", "type": "tap", "vlan_mode": "sideways"} + ) + + assert_rejects(config, "Bad vlan_mode") + + def test_accepts_single_trunk(self): + check.configuration_check( + bridge_with_port({"name": "p0", "type": "tap", "trunks": 100}) + ) + + def test_accepts_trunk_list(self): + check.configuration_check( + bridge_with_port({"name": "p0", "type": "tap", "trunks": [1, 2, 3]}) + ) + + def test_rejects_trunks_of_wrong_type(self): + config = bridge_with_port( + {"name": "p0", "type": "tap", "trunks": {"a": 1}} + ) + + assert_rejects(config, "trunks") + + def test_rejects_non_integer_trunk(self): + config = bridge_with_port({"name": "p0", "type": "tap", "trunks": ["1"]}) + + assert_rejects(config, "must be an integer") + + @pytest.mark.parametrize("value", [-1, 4096]) + def test_rejects_out_of_range_trunk(self, value): + config = bridge_with_port( + {"name": "p0", "type": "tap", "trunks": [value]} + ) + + assert_rejects(config, "range 0 to") + + @pytest.mark.xfail( + strict=True, + reason="the tag range check is guarded by 'vlan' in port while the " + "attribute consumed by ovs._create_bridges is 'tag', so tag is never " + "validated", + ) + def test_rejects_out_of_range_tag(self): + config = bridge_with_port({"name": "p0", "type": "tap", "tag": 9999}) + + assert_rejects(config, "range 0 to") + + +class TestPolicingAndVxlan: + @pytest.mark.parametrize( + "attribute", ["ingress_policing_rate", "ingress_policing_burst"] + ) + def test_accepts_integer_policing(self, attribute): + check.configuration_check( + bridge_with_port({"name": "p0", "type": "tap", attribute: 1000}) + ) + + @pytest.mark.parametrize( + "attribute", ["ingress_policing_rate", "ingress_policing_burst"] + ) + def test_rejects_non_integer_policing(self, attribute): + config = bridge_with_port( + {"name": "p0", "type": "tap", attribute: "1000"} + ) + + assert_rejects(config, "must be an integer") + + def test_accepts_complete_vxlan_port(self): + check.configuration_check( + bridge_with_port( + { + "name": "p0", + "type": "vxlan", + "key": "42", + "remote_ip": "10.0.0.1", + "remote_port": 4000, + } + ) + ) + + def test_rejects_non_integer_remote_port(self): + config = bridge_with_port( + {"name": "p0", "type": "vxlan", "remote_port": "4789"} + ) + + assert_rejects(config, "must be an integer") + + def test_rejects_negative_remote_port(self): + config = bridge_with_port( + {"name": "p0", "type": "vxlan", "remote_port": -1} + ) + + assert_rejects(config, "range 0 to") + + @pytest.mark.xfail( + strict=True, + reason="_attribute_is_a_port enforces the VLAN tag range 0-4095 on " + "remote_port too, so the IANA VXLAN port 4789 is refused. A TCP/UDP " + "port goes up to 65535", + ) + def test_accepts_the_iana_vxlan_port(self): + check.configuration_check( + bridge_with_port( + { + "name": "p0", + "type": "vxlan", + "key": "42", + "remote_ip": "10.0.0.1", + "remote_port": 4789, + } + ) + ) + + def test_rejects_non_string_key(self): + config = bridge_with_port({"name": "p0", "type": "vxlan", "key": 42}) + + assert_rejects(config, "must be a string") + + def test_rejects_malformed_remote_ip(self): + config = bridge_with_port( + {"name": "p0", "type": "vxlan", "remote_ip": "10.0.0"} + ) + + assert_rejects(config, "IPv4 address") + + def test_warns_when_vxlan_attributes_are_ignored(self, caplog): + check.configuration_check( + bridge_with_port({"name": "p0", "type": "tap", "key": "42"}) + ) + + assert "ignored" in caplog.text + + def test_rejects_non_string_hook_file(self): + config = bridge_with_port( + {"name": "p0", "type": "tap", "hook_file": 1} + ) + + assert_rejects(config, "must be a string") + + @pytest.mark.xfail( + strict=True, + reason="the 'must be set if type is vxlan' branch is unreachable: it " + "sits inside 'if attribute in port' and then tests " + "'attribute not in port'. ovs._create_bridges later raises KeyError " + "on such a config", + ) + def test_rejects_vxlan_port_without_key_nor_remote_ip(self): + config = bridge_with_port({"name": "p0", "type": "vxlan"}) + + assert_rejects(config, "vxlan") + + +class TestIpAndMac: + @pytest.mark.parametrize("port_type", ["tap", "dpdkvhostuserclient"]) + def test_accepts_ip_on_supported_types(self, port_type): + check.configuration_check( + bridge_with_port( + {"name": "p0", "type": port_type, "ip": "10.0.0.1"} + ) + ) + + def test_accepts_ip_list(self): + check.configuration_check( + bridge_with_port( + {"name": "p0", "type": "tap", "ip": ["10.0.0.1", "10.0.0.2"]} + ) + ) + + def test_rejects_ip_on_unsupported_type(self): + config = bridge_with_port( + {"name": "p0", "type": "internal", "ip": "10.0.0.1"} + ) + + assert_rejects(config, "only works if") + + def test_rejects_malformed_ip(self): + config = bridge_with_port({"name": "p0", "type": "tap", "ip": ["nope"]}) + + assert_rejects(config, "IPv4 address") + + def test_rejects_ip_of_wrong_container_type(self): + config = bridge_with_port({"name": "p0", "type": "tap", "ip": {"a": 1}}) + + assert_rejects(config, "string or") + + def test_accepts_port_other_config_string(self): + check.configuration_check( + bridge_with_port( + {"name": "p0", "type": "tap", "other_config": "a=b"} + ) + ) + + def test_rejects_port_other_config_non_string_element(self): + config = bridge_with_port( + {"name": "p0", "type": "tap", "other_config": [1]} + ) + + assert_rejects(config, "other_config") + + @pytest.mark.parametrize("port_type", ["tap", "dpdkvhostuserclient"]) + def test_accepts_mac_on_supported_types(self, port_type): + check.configuration_check( + bridge_with_port( + {"name": "p0", "type": port_type, "mac": "de:ad:be:ef:00:01"} + ) + ) + + @pytest.mark.parametrize("mac", ["DE:AD:BE:EF:00:01", "de-ad-be-ef-00-01", 42]) + def test_rejects_malformed_mac(self, mac): + config = bridge_with_port({"name": "p0", "type": "tap", "mac": mac}) + + assert_rejects(config, "MAC address") + + def test_rejects_mac_on_unsupported_type(self): + config = bridge_with_port( + {"name": "p0", "type": "internal", "mac": "de:ad:be:ef:00:01"} + ) + + assert_rejects(config, "only works if") + + def test_mac_rejection_message_names_the_mac_attribute(self): + config = bridge_with_port( + {"name": "p0", "type": "internal", "mac": "de:ad:be:ef:00:01"} + ) + + with pytest.raises(SetupOVSConfigException) as excinfo: + check.configuration_check(config) + + assert "attribute mac only works" in str(excinfo.value) + + +class TestDuplicateInterfaces: + @pytest.mark.xfail( + strict=True, + reason="dpdk_interfaces/system_interfaces are locals of " + "_check_port_configuration, which runs once per port, so the " + "duplicate-NIC guard can never fire", + ) + def test_rejects_the_same_dpdk_nic_on_two_ports(self, run_command): + config = { + "bridges": [ + { + "name": "br0", + "ports": [ + { + "name": "p0", + "type": "dpdk", + "interface": "0000:3b:00.0", + }, + { + "name": "p1", + "type": "dpdk", + "interface": "0000:3b:00.0", + }, + ], + } + ] + } + + assert_rejects(config, "already used") diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..f821214 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,229 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +import logging +import os +import subprocess + +import pytest + +from setup_ovs import helpers + + +class TestFindCommand: + def test_absolute_candidate_is_returned_when_executable(self, tmp_path): + script = tmp_path / "dpdk-devbind" + script.write_text("#!/bin/sh\n") + script.chmod(0o755) + + assert helpers.find_command("devbind", str(script)) == str(script) + + def test_absolute_candidate_is_skipped_when_not_executable(self, tmp_path): + not_exec = tmp_path / "not-exec" + not_exec.write_text("") + not_exec.chmod(0o644) + usable = tmp_path / "usable" + usable.write_text("#!/bin/sh\n") + usable.chmod(0o755) + + found = helpers.find_command("devbind", str(not_exec), str(usable)) + + assert found == str(usable) + + def test_absolute_candidate_is_skipped_when_missing(self, tmp_path): + usable = tmp_path / "usable" + usable.write_text("#!/bin/sh\n") + usable.chmod(0o755) + + found = helpers.find_command( + "devbind", str(tmp_path / "nope"), str(usable) + ) + + assert found == str(usable) + + def test_relative_candidate_is_resolved_through_path(self, monkeypatch): + monkeypatch.setattr( + helpers.shutil, "which", lambda name: "/usr/bin/" + name + ) + + assert helpers.find_command("ovs", "ovs-vsctl") == "/usr/bin/ovs-vsctl" + + def test_relative_candidate_falls_through_when_not_on_path( + self, monkeypatch + ): + monkeypatch.setattr( + helpers.shutil, + "which", + lambda name: "/usr/bin/second" if name == "second" else None, + ) + + assert helpers.find_command("cmd", "first", "second") == "/usr/bin/second" + + def test_raises_when_no_candidate_matches(self, monkeypatch): + monkeypatch.setattr(helpers.shutil, "which", lambda name: None) + + with pytest.raises(FileNotFoundError) as excinfo: + helpers.find_command("devbind", "nope", "/absolute/nope") + + message = str(excinfo.value) + assert "devbind" in message + assert "nope" in message + assert "/absolute/nope" in message + + def test_result_is_memoized(self, monkeypatch): + calls = [] + + def fake_which(name): + calls.append(name) + return "/usr/bin/" + name + + monkeypatch.setattr(helpers.shutil, "which", fake_which) + + helpers.find_command("ovs", "ovs-vsctl") + helpers.find_command("ovs", "ovs-vsctl") + + assert calls == ["ovs-vsctl"] + + +class TestRunCommand: + def test_runs_the_command_with_check_enabled(self, monkeypatch): + recorded = {} + + def fake_run(cmd_args, **kwargs): + recorded["cmd_args"] = cmd_args + recorded["kwargs"] = kwargs + return "result" + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = helpers.run_command("/usr/bin/ovs-vsctl", "show") + + assert result == "result" + assert recorded["cmd_args"] == ("/usr/bin/ovs-vsctl", "show") + assert recorded["kwargs"]["check"] is True + + def test_stdout_is_silenced_outside_debug(self, monkeypatch): + recorded = {} + monkeypatch.setattr( + subprocess, "run", lambda a, **k: recorded.update(k) + ) + logging.getLogger().setLevel(logging.WARNING) + + helpers.run_command("/bin/true") + + assert recorded["stdout"] is subprocess.DEVNULL + + def test_stdout_is_kept_in_debug(self, monkeypatch): + recorded = {} + monkeypatch.setattr( + subprocess, "run", lambda a, **k: recorded.update(k) + ) + logging.getLogger().setLevel(logging.DEBUG) + try: + helpers.run_command("/bin/true") + finally: + logging.getLogger().setLevel(logging.WARNING) + + assert "stdout" not in recorded + + def test_stdout_is_not_overridden_when_caller_sets_it(self, monkeypatch): + recorded = {} + monkeypatch.setattr( + subprocess, "run", lambda a, **k: recorded.update(k) + ) + + helpers.run_command("/bin/true", stdout=None) + + assert recorded["stdout"] is None + + def test_stdout_is_not_overridden_when_capturing_output(self, monkeypatch): + recorded = {} + monkeypatch.setattr( + subprocess, "run", lambda a, **k: recorded.update(k) + ) + + helpers.run_command("/bin/true", capture_output=True) + + assert "stdout" not in recorded + assert recorded["capture_output"] is True + + def test_stdout_is_silenced_when_capture_output_is_false( + self, monkeypatch + ): + recorded = {} + monkeypatch.setattr( + subprocess, "run", lambda a, **k: recorded.update(k) + ) + + helpers.run_command("/bin/true", capture_output=False) + + assert recorded["stdout"] is subprocess.DEVNULL + + def test_dry_run_does_not_execute_anything(self, monkeypatch): + def boom(*args, **kwargs): + raise AssertionError("subprocess.run must not be called") + + monkeypatch.setattr(subprocess, "run", boom) + monkeypatch.setattr(helpers, "dry_run", True) + + assert helpers.run_command("/usr/bin/ovs-vsctl", "del-br", "br0") is None + + def test_propagates_called_process_error(self, monkeypatch): + def fake_run(cmd_args, **kwargs): + raise subprocess.CalledProcessError(1, cmd_args) + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(subprocess.CalledProcessError): + helpers.run_command("/bin/false") + + @pytest.mark.xfail( + strict=True, + reason="run_command returns without running anything when the caller " + "passes check explicitly: the subprocess.run call sits inside the " + "'if \"check\" not in kargs' branch", + ) + def test_explicit_check_still_runs_the_command(self, monkeypatch): + recorded = {} + monkeypatch.setattr( + subprocess, + "run", + lambda a, **k: recorded.update(cmd_args=a, kwargs=k), + ) + + helpers.run_command("/bin/false", check=False) + + assert recorded["cmd_args"] == ("/bin/false",) + + +class TestMatchers: + @pytest.mark.parametrize( + "address", ["0000:3b:00.0", "3b:00.0", "0:01:02.3", "ff:ff.7"] + ) + def test_pci_matcher_accepts_valid_addresses(self, address): + assert helpers.PCI_ADDRESS_MATCHER.match(address) + + @pytest.mark.parametrize( + "address", ["", "3b:00", "zz:00.0", "3B:00.0", "3b-00.0", "3b:00.00"] + ) + def test_pci_matcher_rejects_invalid_addresses(self, address): + assert not helpers.PCI_ADDRESS_MATCHER.match(address) + + @pytest.mark.parametrize("address", ["10.0.0.1", "192.168.1.254", "0.0.0.0"]) + def test_ipv4_matcher_accepts_valid_addresses(self, address): + assert helpers.IPv4_ADDRESS_MATCHER.match(address) + + @pytest.mark.parametrize("address", ["10.0.0", "10.0.0.1.2", "abc"]) + def test_ipv4_matcher_rejects_invalid_addresses(self, address): + assert not helpers.IPv4_ADDRESS_MATCHER.match(address) + + @pytest.mark.parametrize("address", ["00:11:22:33:44:55", "de:ad:be:ef:00:01"]) + def test_mac_matcher_accepts_valid_addresses(self, address): + assert helpers.MAC_ADDRESS_MATCHER.match(address) + + @pytest.mark.parametrize( + "address", + ["DE:AD:BE:EF:00:01", "00:11:22:33:44", "00-11-22-33-44-55", "xx:11:22:33:44:55"], + ) + def test_mac_matcher_rejects_invalid_addresses(self, address): + assert not helpers.MAC_ADDRESS_MATCHER.match(address) diff --git a/tests/test_openflow.py b/tests/test_openflow.py new file mode 100644 index 0000000..02c9854 --- /dev/null +++ b/tests/test_openflow.py @@ -0,0 +1,254 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from setup_ovs import helpers +from setup_ovs.openflow import SetupOpenFlow + + +SECURED_PORT = { + "name": "tap0", + "type": "tap", + "mac": "de:ad:be:ef:00:01", + "ip": "10.0.0.1", +} + + +def flows(run_command): + """Only the add-flow rule arguments, one string per rule.""" + return [args[-1] for args, _ in run_command.calls if args[1] == "add-flow"] + + +class TestConstructor: + def test_does_nothing_without_bridges(self, run_command): + SetupOpenFlow({}) + + assert run_command.calls == [] + + def test_walks_every_bridge(self, run_command): + SetupOpenFlow( + {"bridges": [{"name": "br0", "ports": []}, {"name": "br1", "ports": []}]} + ) + + assert run_command.commands_containing("del-flows br0") + assert run_command.commands_containing("del-flows br1") + + +class TestBridgeFlows: + def test_bridge_without_ports_key_gets_no_flow(self, run_command): + SetupOpenFlow({"bridges": [{"name": "br0"}]}) + + assert run_command.calls == [] + + def test_default_filters_are_cleared_first(self, run_command): + SetupOpenFlow({"bridges": [{"name": "br0", "ports": []}]}) + + assert run_command.commands[0] == "ovs-ofctl del-flows br0" + + def test_ipv6_is_dropped_by_default(self, run_command): + SetupOpenFlow({"bridges": [{"name": "br0", "ports": []}]}) + + ipv6_rules = [rule for rule in flows(run_command) if "ipv6" in rule] + assert len(ipv6_rules) == 2 + assert all("action=drop" in rule for rule in ipv6_rules) + + def test_ipv6_can_be_forced_to_normal(self, run_command, caplog): + SetupOpenFlow( + {"bridges": [{"name": "br0", "ports": [], "enable_ipv6": True}]} + ) + + ipv6_rules = [rule for rule in flows(run_command) if "ipv6" in rule] + assert all("action=normal" in rule for rule in ipv6_rules) + assert "Force enabling IPv6" in caplog.text + + def test_ipv6_stays_dropped_when_flag_is_false(self, run_command): + SetupOpenFlow( + {"bridges": [{"name": "br0", "ports": [], "enable_ipv6": False}]} + ) + + ipv6_rules = [rule for rule in flows(run_command) if "ipv6" in rule] + assert all("action=drop" in rule for rule in ipv6_rules) + + def test_catch_all_rule_allows_everything(self, run_command): + SetupOpenFlow({"bridges": [{"name": "br0", "ports": []}]}) + + assert any( + "table=0" in rule and "priority=0" in rule and "action=normal" in rule + for rule in flows(run_command) + ) + + @pytest.mark.parametrize( + "port", + [ + {"name": "p0", "type": "internal", "mac": "de:ad:be:ef:00:01", "ip": "10.0.0.1"}, + {"name": "p0", "type": "tap", "ip": "10.0.0.1"}, + {"name": "p0", "type": "tap", "mac": "de:ad:be:ef:00:01"}, + {"name": "p0", "type": "tap", "mac": "", "ip": "10.0.0.1"}, + {"name": "p0", "type": "tap", "mac": "de:ad:be:ef:00:01", "ip": ""}, + ], + ) + def test_port_without_full_mac_and_ip_is_not_secured(self, run_command, port): + SetupOpenFlow({"bridges": [{"name": "br0", "ports": [port]}]}) + + assert not [rule for rule in flows(run_command) if "in_port=p0" in rule] + + +class TestPortFlows: + def test_mac_spoofing_is_blocked(self, run_command): + SetupOpenFlow({"bridges": [{"name": "br0", "ports": [SECURED_PORT]}]}) + + rules = flows(run_command) + assert any( + "dl_src=de:ad:be:ef:00:01" in rule and "action=goto_table:1" in rule + for rule in rules + ) + assert any( + "in_port=tap0" in rule and "priority=39" in rule and "action=drop" in rule + for rule in rules + ) + + def test_ingress_defaults_to_drop(self, run_command): + SetupOpenFlow({"bridges": [{"name": "br0", "ports": [SECURED_PORT]}]}) + + assert any( + "table=1" in rule and "priority=0" in rule and "action=drop" in rule + for rule in flows(run_command) + ) + + def test_source_ip_is_allowed(self, run_command): + SetupOpenFlow({"bridges": [{"name": "br0", "ports": [SECURED_PORT]}]}) + + assert any( + "ip nw_src=10.0.0.1" in rule and "action=normal" in rule + for rule in flows(run_command) + ) + + def test_every_ip_of_a_list_is_allowed(self, run_command): + port = dict(SECURED_PORT, ip=["10.0.0.1", "10.0.0.2"]) + + SetupOpenFlow({"bridges": [{"name": "br0", "ports": [port]}]}) + + rules = flows(run_command) + assert any("ip nw_src=10.0.0.1" in rule for rule in rules) + assert any("ip nw_src=10.0.0.2" in rule for rule in rules) + + def test_arp_is_restricted_to_the_declared_ip(self, run_command): + SetupOpenFlow({"bridges": [{"name": "br0", "ports": [SECURED_PORT]}]}) + + rules = flows(run_command) + assert any( + "arp arp_sha=de:ad:be:ef:00:01 arp_spa=10.0.0.1" in rule + and "action=normal" in rule + for rule in rules + ) + assert any( + rule.count("arp_sha=de:ad:be:ef:00:01") and "arp_spa" not in rule + and "action=drop" in rule + for rule in rules + ) + + def test_dhcp_server_traffic_is_dropped(self, run_command): + SetupOpenFlow({"bridges": [{"name": "br0", "ports": [SECURED_PORT]}]}) + + assert any( + "udp udp_src=67" in rule and "action=drop" in rule + for rule in flows(run_command) + ) + + def test_priorities_stay_ordered_within_table_1(self, run_command): + port = dict(SECURED_PORT, ip=["10.0.0.1", "10.0.0.2"]) + + SetupOpenFlow({"bridges": [{"name": "br0", "ports": [port]}]}) + + priorities = [ + int(rule.split("priority=")[1].split(" ")[0]) + for rule in flows(run_command) + if "table=1" in rule and "in_port=tap0" in rule + ] + assert priorities == sorted(priorities) + + def test_dpdkvhostuserclient_ports_are_secured_too(self, run_command): + port = dict(SECURED_PORT, type="dpdkvhostuserclient", name="vhost0") + + SetupOpenFlow({"bridges": [{"name": "br0", "ports": [port]}]}) + + assert [rule for rule in flows(run_command) if "in_port=vhost0" in rule] + + +class TestAddFlow: + def test_omits_in_port_when_not_given(self, run_command): + SetupOpenFlow.add_flow("br0", 0, 10, "normal", "ip") + + assert "in_port" not in run_command.commands[0] + + def test_includes_in_port_when_given(self, run_command): + SetupOpenFlow.add_flow("br0", 0, 10, "normal", "ip", port="tap0") + + assert "in_port=tap0" in run_command.commands[0] + + def test_builds_the_expected_rule(self, run_command): + SetupOpenFlow.add_flow("br0", 1, 20, "drop", "arp") + + args, _ = run_command.calls[0] + assert args[:3] == ("ovs-ofctl", "add-flow", "br0") + assert "table=1" in args[3] + assert "priority=20" in args[3] + assert "action=drop" in args[3] + + +class TestFlowsOverride: + def test_override_replaces_the_flows_from_a_temp_file(self, run_command): + SetupOpenFlow( + { + "bridges": [ + {"name": "br0", "flows_override": "priority=0,action=drop\n"} + ] + } + ) + + replace = run_command.commands_containing("replace-flows") + assert len(replace) == 1 + assert "--bundle" in replace[0] + assert "br0" in replace[0] + + def test_override_content_is_written_before_the_call(self, monkeypatch): + written = {} + + def spy(*args, **kwargs): + if "replace-flows" in args: + with open(args[-1]) as handle: + written["content"] = handle.read() + + monkeypatch.setattr(helpers, "run_command", spy) + + SetupOpenFlow( + { + "bridges": [ + {"name": "br0", "flows_override": "priority=0,action=drop\n"} + ] + } + ) + + assert written["content"] == "priority=0,action=drop\n" + + def test_empty_override_is_ignored(self, run_command): + SetupOpenFlow({"bridges": [{"name": "br0", "flows_override": ""}]}) + + assert not run_command.commands_containing("replace-flows") + + def test_override_combines_with_port_rules(self, run_command): + SetupOpenFlow( + { + "bridges": [ + { + "name": "br0", + "ports": [SECURED_PORT], + "flows_override": "priority=0,action=drop\n", + } + ] + } + ) + + assert run_command.commands_containing("replace-flows") + assert run_command.commands_containing("del-flows br0") diff --git a/tests/test_ovs.py b/tests/test_ovs.py new file mode 100644 index 0000000..5452fe7 --- /dev/null +++ b/tests/test_ovs.py @@ -0,0 +1,586 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +import logging + +import pytest + +from setup_ovs import helpers, ovs + + +DPDK_PORT = {"name": "dpdk0", "type": "dpdk", "interface": "0000:3b:00.0"} + +NET_ROOT = "/sys/class/net" +PCI_ROOT = "/sys/bus/pci" + + +@pytest.fixture +def net_isdir(monkeypatch): + """ + Answer os.path.isdir for /sys/class/net lookups only. + + ovs.os is the stdlib os module, so the patch is global: anything outside + the prefix under test must keep the real behaviour or pytest's own + internals break. + """ + real_isdir = ovs.os.path.isdir + + def configure(answer): + def fake_isdir(path): + if str(path).startswith(NET_ROOT): + return answer + return real_isdir(path) + + monkeypatch.setattr(ovs.os.path, "isdir", fake_isdir) + + return configure + + +@pytest.fixture +def devbind(monkeypatch): + """Make find_command resolve dpdk-devbind without touching the system.""" + monkeypatch.setattr( + helpers, "find_command", lambda name, *c: "/usr/bin/dpdk-devbind.py" + ) + return "/usr/bin/dpdk-devbind.py" + + +def one_bridge(*ports, **bridge_extra): + bridge = {"name": "br0"} + if ports: + bridge["ports"] = list(ports) + bridge.update(bridge_extra) + return {"bridges": [bridge]} + + +class TestSetupOvsToplevel: + def test_does_nothing_without_bridges_key(self, run_command): + ovs.setup_ovs({}) + + assert run_command.calls == [] + + def test_does_nothing_with_empty_bridges(self, run_command): + ovs.setup_ovs({"bridges": []}) + + assert run_command.calls == [] + + def test_disables_dpdk_when_no_dpdk_port(self, run_command): + ovs.setup_ovs(one_bridge({"name": "p0", "type": "internal"})) + + assert run_command.commands_containing("dpdk-init=false") + assert run_command.commands_containing("vhost-iommu-support=false") + assert not run_command.commands_containing("dpdk-init=true") + + def test_enables_dpdk_when_a_dpdk_port_exists(self, run_command, devbind): + ovs.setup_ovs(one_bridge(DPDK_PORT)) + + assert run_command.commands_containing("dpdk-init=true") + assert run_command.commands_containing("vhost-iommu-support=true") + + def test_binds_every_dpdk_nic(self, run_command, devbind): + config = one_bridge( + DPDK_PORT, + {"name": "dpdk1", "type": "dpdk", "interface": "0000:3b:00.1"}, + ) + + ovs.setup_ovs(config) + + bind_calls = run_command.commands_containing("--bind=vfio-pci") + assert len(bind_calls) == 2 + assert any("0000:3b:00.0" in call for call in bind_calls) + assert any("0000:3b:00.1" in call for call in bind_calls) + + def test_does_not_look_for_devbind_without_dpdk_port( + self, run_command, monkeypatch + ): + def boom(*args, **kwargs): + raise AssertionError("find_command must not be called") + + monkeypatch.setattr(helpers, "find_command", boom) + + ovs.setup_ovs(one_bridge({"name": "p0", "type": "internal"})) + + def test_bridge_without_ports_is_created(self, run_command): + ovs.setup_ovs(one_bridge()) + + assert run_command.commands_containing("add-br br0") + + +class TestCreateBridges: + def test_plain_bridge_uses_a_simple_add_br(self, run_command): + ovs.setup_ovs(one_bridge({"name": "p0", "type": "internal"})) + + assert "/usr/bin/ovs-vsctl add-br br0" in run_command.commands + + def test_dpdk_bridge_gets_the_netdev_datapath(self, run_command, devbind): + ovs.setup_ovs(one_bridge(DPDK_PORT)) + + assert run_command.commands_containing("datapath_type=netdev") + + def test_tap_port_creates_the_tun_interface(self, run_command, net_isdir): + net_isdir(False) + + ovs.setup_ovs(one_bridge({"name": "tap0", "type": "tap"})) + + assert run_command.commands_containing("tuntap add mode tap name tap0") + assert run_command.commands_containing("link set tap0 up") + + def test_existing_tap_interface_is_not_recreated( + self, run_command, net_isdir + ): + net_isdir(True) + + ovs.setup_ovs(one_bridge({"name": "tap0", "type": "tap"})) + + assert not run_command.commands_containing("tuntap add") + assert run_command.commands_containing("link set tap0 up") + + def test_system_port_is_named_after_its_interface(self, run_command): + ovs.setup_ovs( + one_bridge({"name": "p0", "type": "system", "interface": "eth0"}) + ) + + assert run_command.commands_containing("add-port br0 eth0") + + def test_system_port_gets_no_type_option(self, run_command): + ovs.setup_ovs( + one_bridge({"name": "p0", "type": "system", "interface": "eth0"}) + ) + + assert not run_command.commands_containing("type=system") + + def test_internal_port_gets_its_type_option(self, run_command): + ovs.setup_ovs(one_bridge({"name": "p0", "type": "internal"})) + + assert run_command.commands_containing("type=internal") + + def test_dpdk_port_carries_the_devargs(self, run_command, devbind): + ovs.setup_ovs(one_bridge(DPDK_PORT)) + + assert run_command.commands_containing( + "options:dpdk-devargs=0000:3b:00.0" + ) + + def test_vhostuserclient_port_gets_a_socket_path(self, run_command): + ovs.setup_ovs(one_bridge({"name": "vhost0", "type": "dpdkvhostuserclient"})) + + assert run_command.commands_containing( + "options:vhost-server-path=/var/run/openvswitch/" + "vm-sockets/dpdkvhostuser_vhost0" + ) + + def test_vxlan_port_carries_remote_ip_and_key(self, run_command): + ovs.setup_ovs( + one_bridge( + { + "name": "vx0", + "type": "vxlan", + "remote_ip": "10.0.0.1", + "key": "42", + } + ) + ) + + assert run_command.commands_containing("options:remote_ip=10.0.0.1") + assert run_command.commands_containing("options:key=42") + + def test_vxlan_remote_port_is_optional(self, run_command): + ovs.setup_ovs( + one_bridge( + { + "name": "vx0", + "type": "vxlan", + "remote_ip": "10.0.0.1", + "key": "42", + "remote_port": "4789", + } + ) + ) + + assert run_command.commands_containing("options:remote_port=4789") + + @pytest.mark.parametrize( + "attribute,value,expected", + [ + ("vlan_mode", "access", "vlan_mode=access"), + ("tag", 10, "tag=10"), + ("trunks", [1, 2], "trunks=1,2"), + ("ofport_request", 7, "ofport_request=7"), + ], + ) + def test_optional_port_attributes( + self, run_command, attribute, value, expected + ): + ovs.setup_ovs( + one_bridge({"name": "p0", "type": "internal", attribute: value}) + ) + + assert run_command.commands_containing(expected) + + def test_port_external_ids_accepts_a_string(self, run_command): + ovs.setup_ovs( + one_bridge( + {"name": "p0", "type": "internal", "external-ids": "a=b"} + ) + ) + + assert run_command.commands_containing("external-ids:a=b") + + def test_port_external_ids_accepts_a_list(self, run_command): + ovs.setup_ovs( + one_bridge( + { + "name": "p0", + "type": "internal", + "external-ids": ["a=b", "c=d"], + } + ) + ) + + assert run_command.commands_containing("external-ids:a=b") + assert run_command.commands_containing("external-ids:c=d") + + def test_port_other_config_accepts_a_string(self, run_command): + ovs.setup_ovs( + one_bridge({"name": "p0", "type": "internal", "other_config": "a=b"}) + ) + + assert run_command.commands_containing("set Port p0 other_config=a=b") + + def test_port_other_config_accepts_a_list(self, run_command): + ovs.setup_ovs( + one_bridge( + { + "name": "p0", + "type": "internal", + "other_config": ["a=b", "c=d"], + } + ) + ) + + assert len(run_command.commands_containing("set Port p0")) == 2 + + @pytest.mark.parametrize( + "attribute", ["ingress_policing_rate", "ingress_policing_burst"] + ) + def test_ingress_policing_is_applied(self, run_command, attribute): + ovs.setup_ovs( + one_bridge({"name": "p0", "type": "internal", attribute: 1000}) + ) + + assert run_command.commands_containing("{}=1000".format(attribute)) + + def test_hook_file_is_called_with_bridge_and_port(self, run_command): + ovs.setup_ovs( + one_bridge( + {"name": "p0", "type": "internal", "hook_file": "/opt/hook.sh"} + ) + ) + + assert "/opt/hook.sh br0 p0" in run_command.commands + + def test_rstp_is_enabled_when_requested(self, run_command): + ovs.setup_ovs(one_bridge(rstp_enable=True)) + + assert run_command.commands_containing("rstp_enable=true") + + def test_rstp_is_skipped_when_false(self, run_command): + ovs.setup_ovs(one_bridge(rstp_enable=False)) + + assert not run_command.commands_containing("rstp_enable=true") + + def test_bridge_other_config_accepts_a_string(self, run_command): + ovs.setup_ovs(one_bridge(other_config="a=b")) + + assert run_command.commands_containing("set Bridge br0 other_config=a=b") + + def test_bridge_other_config_accepts_a_list(self, run_command): + ovs.setup_ovs(one_bridge(other_config=["a=b", "c=d"])) + + assert len(run_command.commands_containing("set Bridge br0")) == 2 + + def test_extra_ovsvsctl_command_is_split_on_spaces(self, run_command): + ovs.setup_ovs(one_bridge(ovsvsctl_extra_cmds="set Bridge br0 stp_enable=true")) + + assert ( + "/usr/bin/ovs-vsctl set Bridge br0 stp_enable=true" + in run_command.commands + ) + + def test_extra_ovsvsctl_commands_accept_a_list(self, run_command): + ovs.setup_ovs( + one_bridge(ovsvsctl_extra_cmds=["set A b", "set C d"]) + ) + + assert "/usr/bin/ovs-vsctl set A b" in run_command.commands + assert "/usr/bin/ovs-vsctl set C d" in run_command.commands + + +class TestClearOvs: + def test_deletes_every_listed_bridge(self, run_command, monkeypatch): + class Result: + stdout = b"br0\nbr1\n" + + monkeypatch.setattr( + helpers, + "run_command", + lambda *a, **k: Result() if "list-br" in a else run_command(*a, **k), + ) + + ovs.clear_ovs({}) + + assert run_command.commands_containing("del-br br0") + assert run_command.commands_containing("del-br br1") + + def test_keeps_ignored_bridges(self, run_command, monkeypatch): + class Result: + stdout = b"br0\nbr1\n" + + monkeypatch.setattr( + helpers, + "run_command", + lambda *a, **k: Result() if "list-br" in a else run_command(*a, **k), + ) + + ovs.clear_ovs({"ignored_bridges": ["br1"]}) + + assert run_command.commands_containing("del-br br0") + assert not run_command.commands_containing("del-br br1") + + def test_skips_blank_lines_in_the_bridge_list(self, run_command, monkeypatch): + """A blank line between two names must not become a del-br target.""" + + class Result: + stdout = b"br0\n\nbr1\n" + + monkeypatch.setattr( + helpers, + "run_command", + lambda *a, **k: Result() if "list-br" in a else run_command(*a, **k), + ) + + ovs.clear_ovs({}) + + assert len(run_command.commands_containing("del-br")) == 2 + assert run_command.commands_containing("del-br br0") + assert run_command.commands_containing("del-br br1") + + def test_ignored_bridge_that_is_not_up_is_harmless( + self, run_command, monkeypatch + ): + """An ignored_bridges entry naming an absent bridge must be a no-op.""" + + class Result: + stdout = b"br0\n" + + monkeypatch.setattr( + helpers, + "run_command", + lambda *a, **k: Result() if "list-br" in a else run_command(*a, **k), + ) + + ovs.clear_ovs({"ignored_bridges": ["br-absent"]}) + + assert run_command.commands_containing("del-br br0") + + def test_handles_an_empty_bridge_list(self, run_command, monkeypatch): + class Result: + stdout = b"" + + monkeypatch.setattr( + helpers, + "run_command", + lambda *a, **k: Result() if "list-br" in a else run_command(*a, **k), + ) + + ovs.clear_ovs({}) + + assert run_command.calls == [] + + def test_deletes_nothing_in_dry_run(self, run_command, monkeypatch): + monkeypatch.setattr(helpers, "dry_run", True) + + ovs.clear_ovs({}) + + assert run_command.calls == [] + + +class TestClearTap: + @pytest.fixture + def net_devices(self, monkeypatch): + real_listdir = ovs.os.listdir + real_isfile = ovs.os.path.isfile + + def configure(names, tap_names): + def fake_listdir(path): + if str(path).startswith(NET_ROOT): + return names + return real_listdir(path) + + def fake_isfile(path): + if str(path).startswith(NET_ROOT): + return any( + "/{}/tun_flags".format(tap) in str(path) + for tap in tap_names + ) + return real_isfile(path) + + monkeypatch.setattr(ovs.os, "listdir", fake_listdir) + monkeypatch.setattr(ovs.os.path, "isfile", fake_isfile) + + return configure + + def test_removes_tap_interfaces_only(self, run_command, net_devices): + net_devices(["eth0", "tap0", "tap1"], ["tap0", "tap1"]) + + ovs.clear_tap({}) + + assert run_command.commands_containing("name tap0") + assert run_command.commands_containing("name tap1") + assert not run_command.commands_containing("name eth0") + + def test_keeps_ignored_taps(self, run_command, net_devices): + net_devices(["tap0", "tap1"], ["tap0", "tap1"]) + + ovs.clear_tap({"ignored_taps": ["tap1"]}) + + assert run_command.commands_containing("name tap0") + assert not run_command.commands_containing("name tap1") + + def test_empty_ignored_taps_is_ignored(self, run_command, net_devices): + net_devices(["tap0"], ["tap0"]) + + ovs.clear_tap({"ignored_taps": []}) + + assert run_command.commands_containing("name tap0") + + +class TestUnbindPci: + @pytest.fixture + def sysfs(self, monkeypatch): + """ + Simulate /sys/bus/pci for unbind_pci. + + os.path.exists and builtins.open are global, so every path outside + /sys/bus/pci keeps its real behaviour. + """ + writes = [] + real_exists = ovs.os.path.exists + real_open = open + + def configure(existing, driver): + def fake_exists(path): + path = str(path) + if not path.startswith(PCI_ROOT): + return real_exists(path) + if path.endswith("/driver"): + return existing and driver is not None + return existing + + monkeypatch.setattr(ovs.os.path, "exists", fake_exists) + monkeypatch.setattr( + ovs.os, + "readlink", + lambda path: "../../../bus/pci/drivers/" + (driver or ""), + ) + + class FakeFile: + def __init__(self, path): + self.path = path + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def write(self, data): + writes.append((self.path, data)) + + def fake_open(path, mode="r", *args, **kwargs): + if str(path).startswith(PCI_ROOT): + return FakeFile(str(path)) + return real_open(path, mode, *args, **kwargs) + + monkeypatch.setattr("builtins.open", fake_open) + + configure.writes = writes + return configure + + def test_does_nothing_without_the_key(self, sysfs): + sysfs(existing=True, driver="ixgbe") + + ovs.unbind_pci({}) + + assert sysfs.writes == [] + + def test_unbinds_a_bound_device(self, sysfs): + sysfs(existing=True, driver="ixgbe") + + ovs.unbind_pci({"unbind_pci_address": ["0000:3b:00.0"]}) + + assert sysfs.writes == [ + ("/sys/bus/pci/drivers/ixgbe/unbind", "0000:3b:00.0") + ] + + def test_normalises_a_short_pci_address(self, sysfs): + sysfs(existing=True, driver="ixgbe") + + ovs.unbind_pci({"unbind_pci_address": ["3:0.0"]}) + + assert sysfs.writes == [ + ("/sys/bus/pci/drivers/ixgbe/unbind", "0000:03:00.0") + ] + + def test_skips_an_absent_device(self, sysfs, caplog): + sysfs(existing=False, driver="ixgbe") + + ovs.unbind_pci({"unbind_pci_address": ["0000:3b:00.0"]}) + + assert sysfs.writes == [] + assert "not found" in caplog.text + + def test_skips_a_device_without_driver(self, sysfs, caplog): + caplog.set_level(logging.INFO) + sysfs(existing=True, driver=None) + + ovs.unbind_pci({"unbind_pci_address": ["0000:3b:00.0"]}) + + assert sysfs.writes == [] + assert "no driver to bind" in caplog.text + + def test_skips_a_device_already_on_vfio(self, sysfs, caplog): + caplog.set_level(logging.INFO) + sysfs(existing=True, driver="vfio_pci") + + ovs.unbind_pci({"unbind_pci_address": ["0000:3b:00.0"]}) + + assert sysfs.writes == [] + assert "already bound" in caplog.text + + +class TestBindDpdkInterfaces: + def test_uses_the_first_available_devbind(self, run_command, monkeypatch): + seen = {} + + def fake_find(name, *candidates): + seen["candidates"] = candidates + return "/usr/sbin/dpdk-devbind" + + monkeypatch.setattr(helpers, "find_command", fake_find) + + ovs._bind_dpdk_interfaces(["0000:3b:00.0"]) + + assert seen["candidates"] == ovs._DPDK_DEVBIND_CANDIDATES + assert run_command.commands == [ + "/usr/sbin/dpdk-devbind --force --bind=vfio-pci 0000:3b:00.0" + ] + + def test_propagates_a_missing_devbind(self, monkeypatch): + def fake_find(name, *candidates): + raise FileNotFoundError("nope") + + monkeypatch.setattr(helpers, "find_command", fake_find) + + with pytest.raises(FileNotFoundError): + ovs._bind_dpdk_interfaces(["0000:3b:00.0"]) diff --git a/tests/test_setup_ovs.py b/tests/test_setup_ovs.py new file mode 100644 index 0000000..a5cfe67 --- /dev/null +++ b/tests/test_setup_ovs.py @@ -0,0 +1,258 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +import json +import logging + +import pytest + +from setup_ovs import helpers +from setup_ovs.setup_ovs import main +from setup_ovs.setup_ovs_exception import SetupOVSConfigException + + +CONFIG = { + "bridges": [ + {"name": "br0", "ports": [{"name": "tap0", "type": "tap"}]} + ] +} + + +@pytest.fixture +def steps(monkeypatch): + """ + Replace every step main() drives, and record the order they run in. + + Patching the names inside setup_ovs.setup_ovs is what the module + actually calls, so this covers the argument dispatch without touching + OVS. + """ + import setup_ovs.setup_ovs as cli + + called = [] + + def recorder(name, result=None): + def step(*args, **kwargs): + called.append(name) + return result + + return step + + monkeypatch.setattr(cli.check, "configuration_check", recorder("check_config")) + monkeypatch.setattr(cli.check, "system_check", recorder("system_check")) + monkeypatch.setattr(cli.ovs, "clear_ovs", recorder("clear_ovs")) + monkeypatch.setattr(cli.ovs, "clear_tap", recorder("clear_tap")) + monkeypatch.setattr(cli.ovs, "unbind_pci", recorder("unbind_pci")) + monkeypatch.setattr(cli.ovs, "setup_ovs", recorder("setup_ovs")) + monkeypatch.setattr(cli.openflow, "SetupOpenFlow", recorder("openflow")) + return called + + +@pytest.fixture +def config_file(tmp_path): + def write(content, name="ovs_configuration.json"): + path = tmp_path / name + path.write_text( + content if isinstance(content, str) else json.dumps(content) + ) + return str(path) + + return write + + +def run_cli(monkeypatch, *argv): + monkeypatch.setattr("sys.argv", ["setup_ovs"] + list(argv)) + main() + + +class TestConfigurationLoading: + def test_reads_a_json_file(self, monkeypatch, steps, config_file): + run_cli(monkeypatch, "-f", config_file(CONFIG)) + + assert "check_config" in steps + + def test_reads_a_yaml_file(self, monkeypatch, steps, config_file): + path = config_file("bridges:\n - name: br0\n", name="conf.yaml") + + run_cli(monkeypatch, "-f", path) + + assert "check_config" in steps + + def test_reads_a_yml_file(self, monkeypatch, steps, config_file): + path = config_file("bridges:\n - name: br0\n", name="conf.yml") + + run_cli(monkeypatch, "-f", path) + + assert "check_config" in steps + + def test_passes_the_parsed_configuration_along( + self, monkeypatch, config_file + ): + import setup_ovs.setup_ovs as cli + + seen = {} + monkeypatch.setattr( + cli.check, + "configuration_check", + lambda config: seen.update(config=config), + ) + + run_cli(monkeypatch, "-c", "-f", config_file(CONFIG)) + + assert seen["config"] == CONFIG + + def test_exits_when_the_file_is_missing(self, monkeypatch, steps, tmp_path): + absent = str(tmp_path / "absent.json") + + with pytest.raises(SystemExit): + run_cli(monkeypatch, "-f", absent) + + assert steps == [] + + def test_warns_when_the_file_is_missing( + self, monkeypatch, steps, tmp_path, caplog + ): + absent = str(tmp_path / "absent.json") + + with pytest.raises(SystemExit): + run_cli(monkeypatch, "-f", absent) + + assert "not found" in caplog.text + + def test_propagates_invalid_json(self, monkeypatch, steps, config_file): + path = config_file("{not json", name="broken.json") + + with pytest.raises(json.JSONDecodeError): + run_cli(monkeypatch, "-f", path) + + def test_propagates_a_configuration_error(self, monkeypatch, config_file): + import setup_ovs.setup_ovs as cli + + def boom(config): + raise SetupOVSConfigException("bad config") + + monkeypatch.setattr(cli.check, "configuration_check", boom) + path = config_file(CONFIG) + + with pytest.raises(SetupOVSConfigException): + run_cli(monkeypatch, "-f", path) + + +class TestStepDispatch: + def test_default_run_executes_every_step( + self, monkeypatch, steps, config_file + ): + run_cli(monkeypatch, "-f", config_file(CONFIG)) + + assert steps == [ + "check_config", + "system_check", + "clear_ovs", + "clear_tap", + "unbind_pci", + "setup_ovs", + "openflow", + ] + + def test_check_only_stops_after_the_configuration_check( + self, monkeypatch, steps, config_file + ): + run_cli(monkeypatch, "-c", "-f", config_file(CONFIG)) + + assert steps == ["check_config"] + + @pytest.mark.parametrize( + "flag,skipped", + [ + ("--no-remove-bridges", "clear_ovs"), + ("--no-remove-interfaces", "clear_tap"), + ("--no-unbind", "unbind_pci"), + ("--no-ovs", "setup_ovs"), + ("--no-openflow", "openflow"), + ], + ) + def test_each_no_flag_skips_its_step( + self, monkeypatch, steps, config_file, flag, skipped + ): + run_cli(monkeypatch, flag, "-f", config_file(CONFIG)) + + assert skipped not in steps + assert "check_config" in steps + + def test_flags_combine(self, monkeypatch, steps, config_file): + run_cli( + monkeypatch, + "--no-remove-bridges", + "--no-remove-interfaces", + "--no-unbind", + "--no-openflow", + "-f", + config_file(CONFIG), + ) + + assert steps == ["check_config", "system_check", "setup_ovs"] + + +class TestFlagsAndLogging: + @pytest.fixture + def basic_config_level(self, monkeypatch): + """ + Capture the level main() asks logging.basicConfig for. + + Asserting on the root logger's effective level would not work here: + basicConfig is a no-op once handlers exist, and pytest installs its + own before the test runs. + """ + levels = [] + monkeypatch.setattr( + logging, "basicConfig", lambda **kwargs: levels.append(kwargs["level"]) + ) + return levels + + def test_verbose_enables_debug_logging( + self, monkeypatch, steps, config_file, basic_config_level + ): + run_cli(monkeypatch, "-v", "-f", config_file(CONFIG)) + + assert basic_config_level == [logging.DEBUG] + + def test_default_logging_is_warning( + self, monkeypatch, steps, config_file, basic_config_level + ): + run_cli(monkeypatch, "-f", config_file(CONFIG)) + + assert basic_config_level == [logging.WARNING] + + def test_dry_run_sets_the_helpers_flag( + self, monkeypatch, steps, config_file + ): + run_cli(monkeypatch, "-d", "-f", config_file(CONFIG)) + + assert helpers.dry_run is True + + def test_dry_run_is_off_by_default( + self, monkeypatch, steps, config_file + ): + run_cli(monkeypatch, "-f", config_file(CONFIG)) + + assert helpers.dry_run is False + + def test_default_configuration_path(self, monkeypatch, steps): + """Without -f the CLI looks at /etc/ovs_configuration.json.""" + import setup_ovs.setup_ovs as cli + + seen = {} + monkeypatch.setattr( + cli.os.path, + "isfile", + lambda path: seen.setdefault("path", path) and False, + ) + + with pytest.raises(SystemExit): + run_cli(monkeypatch) + + assert seen["path"] == "/etc/ovs_configuration.json" + + def test_rejects_an_unknown_flag(self, monkeypatch, steps): + with pytest.raises(SystemExit): + run_cli(monkeypatch, "--no-such-option") From e5917f615da132983891a008ae521ecb3052d9ea Mon Sep 17 00:00:00 2001 From: Florent Carli Date: Mon, 3 Aug 2026 10:00:06 +0200 Subject: [PATCH 3/3] ci: add a workflow for the tests, the coverage and the build The repository had no CI. Add a workflow that runs the suite on python 3.9 to 3.13, publishes the coverage table in the run summary, and checks that the wheel builds reproducibly. Coverage evidence does not depend on a third party: the OpenSSF criteria are self-asserted and only require a FLOSS tool able to measure them, which coverage.py is. The run summary is therefore enough on its own. Reproducible build: two builds with SOURCE_DATE_EPOCH pinned produce byte-identical wheels, without it they differ because setuptools stamps the archive with the source mtimes. The job builds the wheel twice and compares the SHA-256. It builds outside the work tree, since the project uses a flat layout and an output directory next to setup_ovs/ would be picked up as a second top-level package. Validation happens on pull requests, which build the simulated merge commit. main is built too, but minimally, one python version and no reproducible build job: SonarCloud needs an analysis of main as the reference for the new code comparison, and that analysis needs a coverage report. Actions are pinned to full commit SHAs and the CI toolchain is pinned in requirements-ci.txt, so a run cannot silently pick up a new release. sonar-project.properties declares the coverage report path. It only takes effect once Automatic Analysis is turned off on the SonarCloud project, since that mode never runs the tests. The scanner step stays skipped while SONAR_TOKEN is unset. Signed-off-by: Florent Carli --- .github/workflows/ci.yml | 131 +++++++++++++++++++++++++++++++++++++++ README.md | 13 ++++ requirements-ci.txt | 13 ++++ sonar-project.properties | 11 ++++ 4 files changed, 168 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 requirements-ci.txt create mode 100644 sonar-project.properties diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d4b0014 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,131 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +name: CI + +# Validation happens on pull requests: a pull_request run already builds the +# simulated merge commit, so it checks the merged result and not just the +# branch tip. +# +# main is built too, but only to keep SonarCloud fed. Without an analysis of +# main there is no reference for the new code comparison on pull requests, +# and the project dashboard freezes. That run is kept minimal, see the matrix +# below and the reproducible-build condition. +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test (python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The whole range on a pull request, where the validation happens. + # On main, only the version that produces the coverage report the + # Sonar job consumes, so the merge is not revalidated five times. + python-version: >- + ${{ github.event_name == 'pull_request' + && fromJSON('["3.9", "3.10", "3.11", "3.12", "3.13"]') + || fromJSON('["3.12"]') }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install the package and its test dependencies + run: python -m pip install -e ".[test]" -c requirements-ci.txt + + - name: Run the test suite + run: python -m pytest --cov=setup_ovs --cov-report=xml --cov-report=term-missing + + # Published in the run summary so the coverage figures are readable and + # linkable without any third party service. This is what the OpenSSF + # statement and branch coverage criteria are evidenced with. + - name: Publish the coverage figures in the run summary + if: matrix.python-version == '3.12' + run: | + echo "## Coverage" >> "$GITHUB_STEP_SUMMARY" + python -m coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY" + + - name: Keep the coverage report for the Sonar job + if: matrix.python-version == '3.12' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage + path: coverage.xml + + reproducible-build: + name: Reproducible build + runs-on: ubuntu-latest + # Validation only, nothing to feed on main. + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - run: python -m pip install build -c requirements-ci.txt + + # Without SOURCE_DATE_EPOCH setuptools stamps the wheel with the file + # mtimes, which differ on every checkout. Pinning it to the commit date + # makes the build a pure function of the source tree. + # The output directories live outside the work tree on purpose: the + # project uses a flat layout, so a build/ directory sitting next to + # setup_ovs/ would be picked up as a second top-level package by + # setuptools auto-discovery and break the following build. + - name: Build the wheel twice and compare + run: | + SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) + export SOURCE_DATE_EPOCH + echo "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" + python -m build --wheel --outdir "$RUNNER_TEMP/first" + python -m build --wheel --outdir "$RUNNER_TEMP/second" + first_hash=$(sha256sum "$RUNNER_TEMP"/first/*.whl | cut -d' ' -f1) + second_hash=$(sha256sum "$RUNNER_TEMP"/second/*.whl | cut -d' ' -f1) + echo "first $first_hash" + echo "second $second_hash" + test "$first_hash" = "$second_hash" + + sonar: + # Not named "SonarCloud": that is already the name of the code scanning + # check SonarCloud pushes through GitHub Advanced Security, and two + # identical names make it impossible to tell which one is failing. + name: Sonar scanner + runs-on: ubuntu-latest + needs: test + if: ${{ !github.event.pull_request.head.repo.fork }} + # Declared at job level so the step below can test it in its `if`. + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + # Sonar needs the full history to assign blame, and therefore to + # tell new code from old code. + fetch-depth: 0 + + - name: Download the coverage report + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: coverage + + # Requires Automatic Analysis to be turned off in the SonarCloud + # project settings, otherwise SonarCloud rejects the CI analysis and + # fails the build. Skipped while SONAR_TOKEN is unset, which is also + # the case for pull requests opened from a fork. + - name: Run the scanner + if: env.SONAR_TOKEN != '' + uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1 + env: + SONAR_HOST_URL: https://sonarcloud.io diff --git a/README.md b/README.md index da899ea..3573950 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,16 @@ A handful of tests are marked `xfail(strict=True)`. Each one documents a bug found while writing the suite and pins the current, wrong behaviour: the suite fails again the day the bug is fixed, which forces the marker to be removed along with the fix. Their `reason` field states the defect. + +## Reproducible build + +The wheel is byte-for-byte reproducible provided `SOURCE_DATE_EPOCH` is set. +Without it setuptools stamps the archive with the source file mtimes, which +differ on every checkout: + +```sh +SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) python -m build --wheel +``` + +The `reproducible-build` CI job builds the wheel twice this way and compares +the SHA-256 of the two archives. diff --git a/requirements-ci.txt b/requirements-ci.txt new file mode 100644 index 0000000..35ab7a2 --- /dev/null +++ b/requirements-ci.txt @@ -0,0 +1,13 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 +# +# Pinned toolchain for the CI jobs, so a run is reproducible and does not +# silently pick up a new release between two builds. Used as a pip +# constraints file, transitive dependencies are still resolved normally. +# Every pin below supports Python 3.9 to 3.13. + +pytest==8.3.5 +pytest-cov==5.0.0 +coverage==7.6.1 +PyYAML==6.0.2 +build==1.2.2.post1 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..ba5f588 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,11 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +sonar.projectKey=seapath_python3-setup-ovs +sonar.organization=seapath + +sonar.sources=setup_ovs +sonar.tests=tests + +sonar.python.version=3.9, 3.10, 3.11, 3.12, 3.13 +sonar.python.coverage.reportPaths=coverage.xml