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/.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..3573950 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ [![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. + +## 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/pyproject.toml b/pyproject.toml index 1e683f1..1b41c1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,16 @@ authors = [ license = { text = "Apache-2.0" } readme = "README.md" requires-python = ">=3.6" +dependencies = [ + "PyYAML", +] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-cov", + "coverage[toml]", +] [project.scripts] setup_ovs = "setup_ovs.setup_ovs:main" @@ -22,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/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 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")