diff --git a/providers/base/bin/sriov.py b/providers/base/bin/sriov.py new file mode 100755 index 0000000000..4143b093da --- /dev/null +++ b/providers/base/bin/sriov.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +Scripts used to test sriov network functions +Copyright (C) 2025 Canonical Ltd. + +Author + Michael Reed . +""" + +import argparse +import logging +import os +import sys +from checkbox_support.lxd_support import LXD, LXDVM + +# Map vendor IDs to vendor names +VENDOR_INFO = { + "0x8086": ("Intel"), + "0x15b3": ("Mellanox"), + "0x14e4": ("Broadcom"), +} + +# Number of virutal interfaces created for SRIOV Enabled Interfaces +NUM_OF_VIRTUAL_IFACES = 1 + + +def get_release_to_test(): + try: + import distro + + if distro.id() == "ubuntu-core": + return "{}.04".format(distro.version()) + return distro.version() + except (ImportError): + import lsb_release + + return lsb_release.get_distro_information()["RELEASE"] + + +def check_ubuntu_version(): + logging.info("Check for 24.04 or greater") + try: + version = get_release_to_test() + + if float(version) < 24.04: + raise ValueError("Ubuntu 24.04 or greater is required, \ + but found {}.".format(version)) + else: + logging.info("The system is 24.04 or greater, proceed") + + except (Exception, ValueError) as e: + logging.error("Error checking Ubuntu version: {}".format(e)) + + +def check_interface_vendor(interface): + """ + Find the vendor of the network interface + """ + vendor_id_path = "/sys/class/net/{}/device/vendor".format(interface) + + try: + if not os.path.exists(vendor_id_path): + raise FileNotFoundError( + "Vendor ID path {} not found".format(vendor_id_path)) + + with open(vendor_id_path, "r", encoding="utf-8") as file: + vendor_id = file.read().strip() + + if vendor_id not in VENDOR_INFO: + raise ValueError("{} has an unknown vendor ID {}". + format(interface, vendor_id)) + + vendor_name = VENDOR_INFO[vendor_id] + if vendor_name == "Broadcom": + raise NotImplementedError( + "Broadcom SRIOV testing is not supported at this time") + + logging.info("The interface %s is a(n) %s NIC", interface, vendor_name) + + except (FileNotFoundError, ValueError, NotImplementedError) as e: + logging.info("An error occurred: {}".format(e)) + sys.exit(1) + + +def is_sriov_capable(interface): + """ + Check if the specified network interface is SR-IOV capable and + configured to support at least one Virtual Function. + """ + sriov_path = "/sys/class/net/{}/device/sriov_numvfs".format(interface) + num_vfs = NUM_OF_VIRTUAL_IFACES + + try: + # Check if the interface supports SR-IOV + logging.info("checking if sriov_numvfs exits") + if not os.path.exists(sriov_path): + raise FileNotFoundError( + "SR-IOV not supported or interface {} does not exist.".format( + interface + ) + ) + else: + logging.info("SR-IOV before change {} VFs on interface {}." + .format(num_vfs, interface)) + # First, disable VFs before changing the number to avoid issues + logging.info("Setting numvfs to zero") + with open(sriov_path, "w", encoding="utf-8") as f: + f.write("0") + + # Set the desired number of VFs + logging.info("Setting numvfs to %d", num_vfs) + with open(sriov_path, "w", encoding="utf-8") as f: + f.write(str(num_vfs)) + + logging.info( + "SR-IOV enabled with {} VFs on interface {}.".format( + num_vfs, interface + ) + ) + + except (IOError, FileNotFoundError) as e: + logging.info("Failed to enable SR-IOV on {}: {}".format(interface, e)) + sys.exit(1) + + except Exception as e: + logging.info("An error occurred: {}".format(e)) + sys.exit(1) + + +def test_lxd_sriov(args): + logging.info("Starting lxd SRIOV Test") + verify_cmds = 'bash -c "lspci | grep Virtual"' + options = ["--network", "lab_sriov"] + network_cmd = "lxc network create lab_sriov --type=sriov parent={}".format( + args.interface + ) + + check_ubuntu_version() + check_interface_vendor(args.interface) + is_sriov_capable(args.interface) + + with LXD(args.template, args.rootfs) as instance: + logging.info("Create sriov network for lxc") + instance.run("lxc network delete lab_sriov", ignore_errors=True) + instance.run(network_cmd) + + logging.info("Launching container: %s", instance.name) + instance.launch(options) + + logging.info("Waiting for %s to be up", instance.name) + instance.wait_until_running() + + instance.run(verify_cmds, on_guest=True) + + instance.run("lxc network delete lab_sriov") + + +def test_lxd_vm_sriov(args): + logging.info("Starting lxd vm SRIOV") + verify_cmds = 'bash -c "lspci | grep Virtual"' + options = ["-c", "security.secureboot=false", "--network", "lab_sriov"] + network_cmd = "lxc network create lab_sriov --type=sriov parent={}".format( + args.interface + ) + + check_ubuntu_version() + check_interface_vendor(args.interface) + is_sriov_capable(args.interface) + + with LXDVM(args.template, args.image) as instance: + logging.info("Create sriov network for lxc vm") + instance.run("lxc network delete lab_sriov", ignore_errors=True) + instance.run(network_cmd) + + logging.info("Launching virtual machine: %s", instance.name) + instance.launch(options) + + logging.info("Waiting for %s to be up", instance.name) + instance.wait_until_running() + + logging.info("running a simple command") + instance.run(verify_cmds, on_guest=True) + + instance.run("lxc network delete lab_sriov") + + +def main(): + + parser = argparse.ArgumentParser(description="SRIOV Test") + subparsers = parser.add_subparsers() + + # Main cli options + lxd_sriov_parser = subparsers.add_parser( + "lxd", help=("Run the SRIOV test on LXD validation test") + ) + lxd_vm_sriov_parser = subparsers.add_parser( + "lxdvm", help=("Run the SRIOV test on VM validation test") + ) + + parser.add_argument( + "--debug", + dest="log_level", + action="store_const", + const=logging.DEBUG, + default=logging.INFO, + ) + + parser.add_argument( + "--interface", type=str, default=None, help="SRIOV Interface" + ) + + # Sub test options + lxd_sriov_parser.add_argument( + "--template", type=str, default=os.getenv("LXD_TEMPLATE") + ) + + lxd_sriov_parser.add_argument( + "--rootfs", type=str, default=os.getenv("LXD_ROOTFS") + ) + + lxd_sriov_parser.set_defaults(func=test_lxd_sriov) + + # Sub test options + lxd_vm_sriov_parser.add_argument( + "--template", type=str, default=os.getenv("LXD_TEMPLATE") + ) + + lxd_vm_sriov_parser.add_argument( + "--image", type=str, default=os.getenv("KVM_IMAGE") + ) + + lxd_vm_sriov_parser.set_defaults(func=test_lxd_vm_sriov) + + args = parser.parse_args() + + logging.basicConfig(level=args.log_level) + + # silence normal output from requests module + logging.getLogger("requests").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + + args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/providers/base/tests/test_sriov.py b/providers/base/tests/test_sriov.py new file mode 100644 index 0000000000..39928cac3a --- /dev/null +++ b/providers/base/tests/test_sriov.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +""" +Unit Tests for sriov.py +Copyright (C) 2025 Canonical Ltd. + +Author + Michael Reed + +""" + +from unittest import TestCase +from unittest.mock import MagicMock, patch, mock_open +import sriov + + +class TestSriovFunctions(TestCase): + @patch("sriov.distro.version", return_value="24.04") + @patch("sriov.logging.info") + def test_check_ubuntu_version_valid(self, mock_logging, mock_version): + sriov.check_ubuntu_version() + mock_logging.assert_called_with( + "The system is 24.04 or greater, proceed" + ) + + @patch("sriov.distro.version", return_value="22.04") + @patch("sriov.logging.info") + @patch("sriov.sys.exit") + def test_check_ubuntu_version_invalid( + self, mock_exit, mock_logging, mock_version + ): + sriov.check_ubuntu_version() + mock_logging.assert_called_with( + "24.04 or greater is required, this is 22.04" + ) + mock_exit.assert_called_once_with(1) + + @patch("distro.version", side_effect=Exception("Mocked exception")) + @patch("sys.exit") + @patch("logging.info") + def test_check_ubuntu_version_exception( + self, mock_logging, mock_exit, mock_distro_version + ): + sriov.check_ubuntu_version() + + # Verify that the exception was logged + mock_logging.assert_any_call("An error occurred: Mocked exception") + + # Verify that sys.exit(1) was called + mock_exit.assert_called_once_with(1) + + @patch("os.path.exists", return_value=True) + @patch("builtins.open", new_callable=mock_open, read_data="0x8086") + @patch("sriov.logging.info") + def test_check_interface_vendor_intel( + self, mock_logging, mock_open, mock_exists + ): + sriov.check_interface_vendor("eth0") + mock_logging.assert_called_with("The interface eth0 is a(n) Intel NIC") + + @patch("os.path.exists", return_value=True) + @patch("builtins.open", new_callable=mock_open, read_data="0x15b3") + @patch("sriov.logging.info") + def test_check_interface_vendor_mellanox( + self, mock_logging, mock_open, mock_exists + ): + sriov.check_interface_vendor("eth0") + mock_logging.assert_called_with( + "The interface eth0 is a(n) Mellanox NIC" + ) + + @patch("os.path.exists", return_value=True) + @patch("builtins.open", new_callable=mock_open, read_data="0x14e4") + @patch("sriov.logging.info") + @patch("sriov.sys.exit") + def test_check_interface_vendor_broadcom( + self, mock_exit, mock_logging, mock_open, mock_exists + ): + sriov.check_interface_vendor("eth0") + mock_logging.assert_called_with( + "Broadcom SRIOV testing is not supported at this time" + ) + mock_exit.assert_called_once_with(1) + + @patch("os.path.exists", return_value=True) + @patch("builtins.open", new_callable=mock_open, read_data="0x0000") + @patch("sriov.logging.info") + @patch("sriov.sys.exit") + def test_check_interface_vendor_unknown( + self, mock_exit, mock_logging, mock_open, mock_exists + ): + sriov.check_interface_vendor("eth0") + mock_logging.assert_called_with( + "eth0 has an unknown vendor ID 0x0000" + ) + mock_exit.assert_called_once_with(1) + + @patch("os.path.exists", return_value=True) + @patch("builtins.open", side_effect=Exception("File read error")) + @patch("sys.exit") # Mock sys.exit to prevent actual exit + def test_check_interface_vendor_exception( + self, mock_exit, mock_open, mock_exists + ): + with self.assertLogs(level="INFO") as log: + sriov.check_interface_vendor("eth0") + + mock_exit.assert_called_once_with(1) + self.assertIn("An error occurred: File read error", log.output[0]) + + @patch("os.path.exists", return_value=True) + @patch("builtins.open", new_callable=mock_open) + @patch("sriov.logging.info") + def test_is_sriov_capable(self, mock_logging, mock_open, mock_exists): + sriov.is_sriov_capable("eth0") + mock_logging.assert_any_call( + "SR-IOV enabled with 1 VFs on interface eth0." + ) + + @patch("os.path.exists", return_value=True) + @patch("builtins.open", side_effect=IOError("Permission denied")) + @patch("sys.exit") + def test_sriov_ioerror(self, mock_exit, mock_open, mock_exists): + """Test when opening the file raises IOError.""" + sriov.is_sriov_capable("eth0") + mock_exit.assert_called_once_with(1) + + @patch("os.path.exists", return_value=True) + @patch("builtins.open", side_effect=FileNotFoundError("File not found")) + @patch("sys.exit") + def test_sriov_filenotfound(self, mock_exit, mock_open, mock_exists): + """Test when the sriov file is missing, raising FileNotFoundError.""" + sriov.is_sriov_capable("eth0") + mock_exit.assert_called_once_with(1) + + @patch("os.path.exists", return_value=True) + @patch("builtins.open", side_effect=Exception("Unknown error")) + @patch("sys.exit") + def test_sriov_general_exception(self, mock_exit, mock_open, mock_exists): + """Test when a general exception occurs in is_sriov_capable.""" + sriov.is_sriov_capable("eth0") + mock_exit.assert_called_once_with(1) + + @patch("sriov.check_ubuntu_version") + @patch("sriov.check_interface_vendor") + @patch("sriov.is_sriov_capable") + @patch("sriov.LXD") + @patch("sriov.logging.info") + def test_test_lxd_sriov( + self, + mock_logging, + mock_lxd, + mock_sriov_capable, + mock_check_vendor, + mock_check_version, + ): + mock_instance = MagicMock() + mock_lxd.return_value.__enter__.return_value = mock_instance + args = MagicMock() + args.interface = "eth0" + args.template = "template" + args.rootfs = "rootfs" + + sriov.test_lxd_sriov(args) + + mock_check_version.assert_called_once() + mock_check_vendor.assert_called_once_with("eth0") + mock_sriov_capable.assert_called_once_with("eth0") + mock_instance.run.assert_any_call( + "lxc network create lab_sriov --type=sriov parent=eth0" + ) + mock_instance.launch.assert_called_once() + mock_instance.wait_until_running.assert_called_once() + mock_instance.run.assert_any_call( + 'bash -c "lspci | grep Virtual"', on_guest=True + ) + mock_instance.run.assert_any_call("lxc network delete lab_sriov") + + @patch("sriov.check_ubuntu_version") + @patch("sriov.check_interface_vendor") + @patch("sriov.is_sriov_capable") + @patch("sriov.LXDVM") + @patch("sriov.logging.info") + def test_test_lxd_vm_sriov( + self, + mock_logging, + mock_lxdvm, + mock_sriov_capable, + mock_check_vendor, + mock_check_version, + ): + mock_instance = MagicMock() + mock_lxdvm.return_value.__enter__.return_value = mock_instance + args = MagicMock() + args.interface = "eth0" + args.template = "template" + args.image = "image" + + sriov.test_lxd_vm_sriov(args) + + mock_check_version.assert_called_once() + mock_check_vendor.assert_called_once_with("eth0") + mock_sriov_capable.assert_called_once_with("eth0") + mock_instance.run.assert_any_call( + "lxc network create lab_sriov --type=sriov parent=eth0" + ) + mock_instance.launch.assert_called_once() + mock_instance.wait_until_running.assert_called_once() + mock_instance.run.assert_any_call( + 'bash -c "lspci | grep Virtual"', on_guest=True + ) + mock_instance.run.assert_any_call("lxc network delete lab_sriov") + + @patch("sys.argv", ["sriov.py", "lxd", "--interface", "eth0"]) + @patch("sriov.test_lxd_sriov") + @patch("sriov.logging.basicConfig") + def test_main(self, mock_logging_config, mock_test_lxd_sriov): + with patch("sys.exit") as mock_exit: + sriov.main() + mock_test_lxd_sriov.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/providers/base/units/sriov/category.pxu b/providers/base/units/sriov/category.pxu new file mode 100644 index 0000000000..b5d36c6316 --- /dev/null +++ b/providers/base/units/sriov/category.pxu @@ -0,0 +1,3 @@ +unit: category +id: sriov +_name: SRIOV Tests diff --git a/providers/base/units/sriov/jobs.pxu b/providers/base/units/sriov/jobs.pxu new file mode 100644 index 0000000000..58a1db63c5 --- /dev/null +++ b/providers/base/units/sriov/jobs.pxu @@ -0,0 +1,37 @@ +unit: template +template-resource: device +template-filter: device.category == 'NETWORK' +plugin: shell +category_id: sriov +id: sriov/verify_lxd_vm_sriov{__index__}_{interface} +template-id: sriov/verify_lxd_vm_sriov__index___interface +environ: LXD_TEMPLATE KVM_IMAGE +estimated_duration: 60.0 +requires: + executable.name == 'lxc' + package.name == 'lxd-installer' or snap.name == 'lxd' +user: root +command: sriov.py --debug --interface {interface} lxdvm +_purpose: + Verifies that sriov network device can created with an LXD Virtual Machine +_summary: + Verify SRIOV NIC with LXD Virtual Machine + +unit: template +template-resource: device +template-filter: device.category == 'NETWORK' +plugin: shell +category_id: sriov +id: sriov/verify_lxd_sriov{__index__}_{interface} +template-id: sriov/verify_lxd_sriov__index___interface +environ: LXD_TEMPLATE LXD_ROOTFS +estimated_duration: 60.0 +requires: + executable.name == 'lxc' + package.name == 'lxd' or package.name == 'lxd-installer' or snap.name == 'lxd' +user: root +command: sriov.py --debug --interface {interface} lxd +_purpose: + Verifies that an SRIOV network device can be created with an LXD container +_summary: + Verify SRIOV NIC with LXD container diff --git a/providers/base/units/sriov/test-plan.pxu b/providers/base/units/sriov/test-plan.pxu new file mode 100644 index 0000000000..f6e9251c8f --- /dev/null +++ b/providers/base/units/sriov/test-plan.pxu @@ -0,0 +1,19 @@ +id: sriov-only +_name: SRIOV Only Test Plan (Only runs SRIOV tests) +unit: test plan +_description: + This test plan is intended to be used for retesting of SRIOV + capabilities only. It does not provide any testing of other hardware + and should only be run at the direction of the Cert Team when re-testing + of virtualization capabilites is requried. +nested_part: + com.canonical.certification::server-info-attachment-automated + com.canonical.certification::server-miscellaneous +include: + sriov/verify_lxd_sriov-.* certification-status=blocker + sriov/verify_lxd_vm_sriov-.* certification-status=blocker +bootstrap_include: + device + executable + package + snap