diff --git a/changelog/63128.added b/changelog/63128.added new file mode 100644 index 000000000000..edbc81fb4817 --- /dev/null +++ b/changelog/63128.added @@ -0,0 +1 @@ +Add ethtool execution and state module functions for pause diff --git a/salt/modules/ethtool.py b/salt/modules/ethtool.py index 31256a3c28b1..16f9c4a7b538 100644 --- a/salt/modules/ethtool.py +++ b/salt/modules/ethtool.py @@ -9,8 +9,11 @@ :platform: linux """ - import logging +import os + +import salt.utils.path +from salt.exceptions import CommandExecutionError try: import ethtool @@ -299,3 +302,144 @@ def set_offload(devname, **kwargs): return "Not supported" return show_offload(devname) + + +def _ethtool_command(devname, *args, **kwargs): + """ + Helper function to build an ethtool command + """ + ethtool = salt.utils.path.which("ethtool") + if not ethtool: + raise CommandExecutionError("Command 'ethtool' cannot be found") + switches = " ".join(arg for arg in args) + params = " ".join("{} {}".format(key, val) for key, val in kwargs.items()) + cmd = "{} {} {} {}".format(ethtool, switches, devname, params).strip() + ret = __salt__["cmd.run"](cmd, ignore_retcode=True).splitlines() + if ret and ret[0].startswith("Cannot"): + raise CommandExecutionError(ret[0]) + return ret + + +def _validate_params(valid_params, kwargs): + """ + Helper function to validate parameters to ethtool commands. Boolean values + will be transformed into ``on`` and ``off`` to match expected syntax. + """ + validated = {} + for key, val in kwargs.items(): + key = key.lower() + if key in valid_params: + if val is True: + val = "on" + elif val is False: + val = "off" + validated[key] = val + if not validated: + raise CommandExecutionError( + "None of the valid parameters were provided: {}".format(valid_params) + ) + return validated + + +def show_pause(devname): + """ + .. versionadded:: 3006.0 + + Queries the specified network device for associated pause information + + CLI Example: + + .. code-block:: bash + + salt '*' ethtool.show_pause + """ + data = {} + + content = _ethtool_command(devname, "-a") + + for line in content[1:]: + if line.strip(): + (key, value) = (s.strip() for s in line.split(":", 1)) + data[key] = value == "on" + + return data + + +def set_pause(devname, **kwargs): + """ + .. versionadded:: 3006.0 + + Changes the pause parameters of the specified network device + + CLI Example: + + .. code-block:: bash + + salt '*' ethtool.set_pause autoneg=off rx=off tx=off + """ + valid_params = ["autoneg", "rx", "tx"] + params = _validate_params(valid_params, kwargs) + ret = _ethtool_command(devname, "-A", **params) + if not ret: + return True + return ret + + +def show_features(devname): + """ + .. versionadded:: 3006.0 + + Queries the specified network device for associated feature information + + CLI Example: + + .. code-block:: bash + + salt '*' ethtool.show_features + """ + data = {} + + content = _ethtool_command(devname, "-k") + + for line in content[1:]: + if ":" in line: + key, value = (s.strip() for s in line.strip().split(":", 1)) + fixed = "fixed" in value + if fixed: + value = value.split()[0].strip() + data[key.strip()] = {"on": value == "on", "fixed": fixed} + + return data + + +def set_feature(devname, **kwargs): + """ + .. versionadded:: 3006.0 + + Changes the feature parameters of the specified network device + + CLI Example: + + .. code-block:: bash + + salt '*' ethtool.set_feature sg=off + """ + valid_params = [ + "rx", + "tx", + "sg", + "tso", + "ufo", + "gso", + "gro", + "lro", + "rxvlan", + "txvlan", + "ntuple", + "rxhash", + ] + params = _validate_params(valid_params, kwargs) + ret = _ethtool_command(devname, "-K", **params) + if not ret: + return True + return os.linesep.join(ret) diff --git a/salt/states/ethtool.py b/salt/states/ethtool.py index 1564b798f67d..3d9fff691ecc 100644 --- a/salt/states/ethtool.py +++ b/salt/states/ethtool.py @@ -32,6 +32,8 @@ import logging +from salt.exceptions import CommandExecutionError + # Set up logging log = logging.getLogger(__name__) @@ -310,3 +312,85 @@ def offload(name, **kwargs): return ret return ret + + +def pause(name, **kwargs): + """ + .. versionadded:: 3006.0 + + Manage pause parameters of network device + + name + Interface name to apply pause parameters + + .. code-block:: yaml + + eth0: + ethtool.pause: + - name: eth0 + - autoneg: off + - rx: off + - tx: off + + """ + ret = { + "name": name, + "changes": {}, + "result": True, + "comment": "Network device {} pause parameters are up to date.".format(name), + } + apply_pause = False + + # Get current pause parameters + try: + old = __salt__["ethtool.show_pause"](name) + except CommandExecutionError: + ret["result"] = False + ret["comment"] = "Device {} pause parameters are not supported".format(name) + return ret + + # map ethtool command input to output text + pause_map = { + "autoneg": "Autonegotiate", + "rx": "RX", + "tx": "RX", + } + + # Process changes + new = {} + diff = [] + + for key, value in kwargs.items(): + key = key.lower() + if key in pause_map: + if value != old[pause_map[key]]: + new.update({key: value}) + if value is True: + value = "on" + elif value is False: + value = "off" + diff.append("{}: {}".format(key, value)) + + if not new: + return ret + + # Dry run + if __opts__["test"]: + ret["result"] = None + ret["comment"] = "Device {} pause parameters are set to be updated:\n{}".format( + name, "\n".join(diff) + ) + return ret + + # Apply pause parameters + try: + __salt__["ethtool.set_pause"](name, **new) + # Prepare return output + ret["comment"] = "Device {} pause parameters updated.".format(name) + ret["changes"]["ethtool_pause"] = "\n".join(diff) + except CommandExecutionError as exc: + ret["result"] = False + ret["comment"] = str(exc) + return ret + + return ret diff --git a/tests/pytests/unit/modules/test_ethtool.py b/tests/pytests/unit/modules/test_ethtool.py new file mode 100644 index 000000000000..908116aeef4a --- /dev/null +++ b/tests/pytests/unit/modules/test_ethtool.py @@ -0,0 +1,248 @@ +from textwrap import dedent + +import pytest + +import salt.modules.ethtool as ethtool +from salt.exceptions import CommandExecutionError +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return { + ethtool: { + "__salt__": {}, + } + } + + +@pytest.fixture(scope="module") +def pause_ret(): + cmdret = dedent( + """Pause parameters for eth0: + Autonegotiate: on + RX: on + TX: on + RX negotiated: off + TX negotiated: off""" + ) + return cmdret + + +@pytest.fixture(scope="module") +def features_ret(): + cmdret = dedent( + """Features for eth0: + rx-checksumming: on [fixed] + tx-checksumming: on + tx-checksum-ipv4: off [fixed] + tx-checksum-ip-generic: on + tx-checksum-ipv6: off [fixed] + tx-checksum-fcoe-crc: off [fixed] + tx-checksum-sctp: off [fixed] + scatter-gather: on + tx-scatter-gather: on + tx-scatter-gather-fraglist: off [fixed] + tcp-segmentation-offload: on + tx-tcp-segmentation: on + tx-tcp-ecn-segmentation: on + tx-tcp-mangleid-segmentation: off + tx-tcp6-segmentation: on + udp-fragmentation-offload: off + generic-segmentation-offload: on + generic-receive-offload: on + large-receive-offload: off [fixed] + rx-vlan-offload: off [fixed] + tx-vlan-offload: off [fixed] + ntuple-filters: off [fixed] + receive-hashing: off [fixed] + highdma: on [fixed] + rx-vlan-filter: on [fixed] + vlan-challenged: off [fixed] + tx-lockless: off [fixed] + netns-local: off [fixed] + tx-gso-robust: on [fixed] + tx-fcoe-segmentation: off [fixed] + tx-gre-segmentation: off [fixed] + tx-gre-csum-segmentation: off [fixed] + tx-ipxip4-segmentation: off [fixed] + tx-ipxip6-segmentation: off [fixed] + tx-udp_tnl-segmentation: off [fixed] + tx-udp_tnl-csum-segmentation: off [fixed] + tx-gso-partial: off [fixed] + tx-sctp-segmentation: off [fixed] + tx-esp-segmentation: off [fixed] + tx-udp-segmentation: off [fixed] + fcoe-mtu: off [fixed] + tx-nocache-copy: off + loopback: off [fixed] + rx-fcs: off [fixed] + rx-all: off [fixed] + tx-vlan-stag-hw-insert: off [fixed] + rx-vlan-stag-hw-parse: off [fixed] + rx-vlan-stag-filter: off [fixed] + l2-fwd-offload: off [fixed] + hw-tc-offload: off [fixed] + esp-hw-offload: off [fixed] + esp-tx-csum-hw-offload: off [fixed] + rx-udp_tunnel-port-offload: off [fixed] + tls-hw-tx-offload: off [fixed] + tls-hw-rx-offload: off [fixed] + rx-gro-hw: off [fixed] + tls-hw-record: off [fixed]""" + ) + return cmdret + + +def test_ethtool__ethtool_command_which_fail(): + with patch("salt.utils.path.which", MagicMock(return_value=None)): + with pytest.raises(CommandExecutionError): + ethtool._ethtool_command("eth0") + + +def test_ethtool__ethtool_command_operation_not_supported(): + mock_cmd_run = MagicMock( + side_effect=[ + "Pause parameters for eth0:\nCannot get device pause settings: Operation not supported", + "Cannot get device pause settings: Operation not supported", + ] + ) + with patch( + "salt.utils.path.which", MagicMock(return_value="/sbin/ethtool") + ), patch.dict(ethtool.__salt__, {"cmd.run": mock_cmd_run}): + with pytest.raises(CommandExecutionError): + ethtool._ethtool_command("eth0", "-a") + ethtool._ethtool_command("eth0", "-A", autoneg="off", rx="off", tx="off") + + +def test_ethtool__ethtool_command(pause_ret): + mock_cmd_run = MagicMock(return_value=pause_ret) + + with patch( + "salt.utils.path.which", MagicMock(return_value="/sbin/ethtool") + ), patch.dict(ethtool.__salt__, {"cmd.run": mock_cmd_run}): + ret = ethtool._ethtool_command("eth0", "-A", autoneg="off", rx="off", tx="off") + + mock_cmd_run.assert_called_once_with( + "/sbin/ethtool -A eth0 autoneg off rx off tx off", ignore_retcode=True + ) + assert pause_ret.splitlines() == ret + + +def test_ethtool__validate_params(): + with pytest.raises(CommandExecutionError): + ethtool._validate_params(["not_found"], {"eth": "tool"}) + assert ethtool._validate_params(["eth"], {"eth": "tool"}) == {"eth": "tool"} + assert ethtool._validate_params(["eth", "not_found"], {"eth": "tool"}) == { + "eth": "tool" + } + assert ethtool._validate_params(["eth", "salt"], {"eth": True, "salt": False}) == { + "eth": "on", + "salt": "off", + } + + +def test_ethtool_show_pause(pause_ret): + expected = { + "Autonegotiate": True, + "RX": True, + "RX negotiated": False, + "TX": True, + "TX negotiated": False, + } + + with patch( + "salt.modules.ethtool._ethtool_command", + MagicMock(return_value=pause_ret.splitlines()), + ): + ret = ethtool.show_pause("eth0") + + assert expected == ret + + +def test_ethtool_show_features(features_ret): + expected = { + "esp-hw-offload": {"fixed": True, "on": False}, + "esp-tx-csum-hw-offload": {"fixed": True, "on": False}, + "fcoe-mtu": {"fixed": True, "on": False}, + "generic-receive-offload": {"fixed": False, "on": True}, + "generic-segmentation-offload": {"fixed": False, "on": True}, + "highdma": {"fixed": True, "on": True}, + "hw-tc-offload": {"fixed": True, "on": False}, + "l2-fwd-offload": {"fixed": True, "on": False}, + "large-receive-offload": {"fixed": True, "on": False}, + "loopback": {"fixed": True, "on": False}, + "netns-local": {"fixed": True, "on": False}, + "ntuple-filters": {"fixed": True, "on": False}, + "receive-hashing": {"fixed": True, "on": False}, + "rx-all": {"fixed": True, "on": False}, + "rx-checksumming": {"fixed": True, "on": True}, + "rx-fcs": {"fixed": True, "on": False}, + "rx-gro-hw": {"fixed": True, "on": False}, + "rx-udp_tunnel-port-offload": {"fixed": True, "on": False}, + "rx-vlan-filter": {"fixed": True, "on": True}, + "rx-vlan-offload": {"fixed": True, "on": False}, + "rx-vlan-stag-filter": {"fixed": True, "on": False}, + "rx-vlan-stag-hw-parse": {"fixed": True, "on": False}, + "scatter-gather": {"fixed": False, "on": True}, + "tcp-segmentation-offload": {"fixed": False, "on": True}, + "tls-hw-record": {"fixed": True, "on": False}, + "tls-hw-rx-offload": {"fixed": True, "on": False}, + "tls-hw-tx-offload": {"fixed": True, "on": False}, + "tx-checksum-fcoe-crc": {"fixed": True, "on": False}, + "tx-checksum-ip-generic": {"fixed": False, "on": True}, + "tx-checksum-ipv4": {"fixed": True, "on": False}, + "tx-checksum-ipv6": {"fixed": True, "on": False}, + "tx-checksum-sctp": {"fixed": True, "on": False}, + "tx-checksumming": {"fixed": False, "on": True}, + "tx-esp-segmentation": {"fixed": True, "on": False}, + "tx-fcoe-segmentation": {"fixed": True, "on": False}, + "tx-gre-csum-segmentation": {"fixed": True, "on": False}, + "tx-gre-segmentation": {"fixed": True, "on": False}, + "tx-gso-partial": {"fixed": True, "on": False}, + "tx-gso-robust": {"fixed": True, "on": True}, + "tx-ipxip4-segmentation": {"fixed": True, "on": False}, + "tx-ipxip6-segmentation": {"fixed": True, "on": False}, + "tx-lockless": {"fixed": True, "on": False}, + "tx-nocache-copy": {"fixed": False, "on": False}, + "tx-scatter-gather": {"fixed": False, "on": True}, + "tx-scatter-gather-fraglist": {"fixed": True, "on": False}, + "tx-sctp-segmentation": {"fixed": True, "on": False}, + "tx-tcp-ecn-segmentation": {"fixed": False, "on": True}, + "tx-tcp-mangleid-segmentation": {"fixed": False, "on": False}, + "tx-tcp-segmentation": {"fixed": False, "on": True}, + "tx-tcp6-segmentation": {"fixed": False, "on": True}, + "tx-udp-segmentation": {"fixed": True, "on": False}, + "tx-udp_tnl-csum-segmentation": {"fixed": True, "on": False}, + "tx-udp_tnl-segmentation": {"fixed": True, "on": False}, + "tx-vlan-offload": {"fixed": True, "on": False}, + "tx-vlan-stag-hw-insert": {"fixed": True, "on": False}, + "udp-fragmentation-offload": {"fixed": False, "on": False}, + "vlan-challenged": {"fixed": True, "on": False}, + } + + with patch( + "salt.modules.ethtool._ethtool_command", + MagicMock(return_value=features_ret.splitlines()), + ): + ret = ethtool.show_features("eth0") + + assert expected == ret + + +def test_ethtool_set_pause(): + with patch("salt.modules.ethtool._ethtool_command", MagicMock(return_value="")): + with pytest.raises(CommandExecutionError): + ethtool.set_pause("eth0", not_there=False) + ret = ethtool.set_pause("eth0", autoneg=False) + + assert ret is True + + +def test_ethtool_set_feature(): + with patch("salt.modules.ethtool._ethtool_command", MagicMock(return_value="")): + with pytest.raises(CommandExecutionError): + ethtool.set_feature("eth0", not_there=False) + ret = ethtool.set_feature("eth0", sg=False) + + assert ret is True diff --git a/tests/pytests/unit/states/test_ethtool.py b/tests/pytests/unit/states/test_ethtool.py new file mode 100644 index 000000000000..c0f5267f811f --- /dev/null +++ b/tests/pytests/unit/states/test_ethtool.py @@ -0,0 +1,77 @@ +import pytest + +import salt.states.ethtool as ethtool +from salt.exceptions import CommandExecutionError +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return { + ethtool: { + "__opts__": {"test": False}, + "__salt__": {}, + } + } + + +def test_ethtool_pause(): + expected = { + "changes": {}, + "comment": "Network device eth0 pause parameters are up to date.", + "name": "eth0", + "result": True, + } + show_ret = { + "Autonegotiate": True, + "RX": True, + "RX negotiated": False, + "TX": True, + "TX negotiated": False, + } + mock_show = MagicMock(return_value=show_ret) + mock_set = MagicMock(return_value=True) + with patch.dict( + ethtool.__salt__, + {"ethtool.set_pause": mock_set, "ethtool.show_pause": mock_show}, + ): + # clean + ret = ethtool.pause("eth0", autoneg=True, rx=True, tx=True) + assert ret == expected + + # changes + expected["changes"] = {"ethtool_pause": "autoneg: off\nrx: off\ntx: off"} + expected["comment"] = "Device eth0 pause parameters updated." + ret = ethtool.pause("eth0", autoneg=False, rx=False, tx=False) + assert ret == expected + mock_set.assert_called_once_with("eth0", autoneg=False, rx=False, tx=False) + + # changes, test mode + mock_set.reset_mock() + with patch.dict(ethtool.__opts__, {"test": True}): + expected["result"] = None + expected["changes"] = {} + expected[ + "comment" + ] = "Device eth0 pause parameters are set to be updated:\nautoneg: off\nrx: off\ntx: off" + ret = ethtool.pause("eth0", autoneg=False, rx=False, tx=False) + assert ret == expected + mock_set.assert_not_called() + + # exceptions + with patch.dict( + ethtool.__salt__, + { + "ethtool.set_pause": MagicMock(side_effect=CommandExecutionError("blargh")), + "ethtool.show_pause": MagicMock( + side_effect=[CommandExecutionError, show_ret] + ), + }, + ): + expected["comment"] = "Device eth0 pause parameters are not supported" + expected["result"] = False + ret = ethtool.pause("eth0", autoneg=False, rx=False, tx=False) + assert ret == expected + ret = ethtool.pause("eth0", autoneg=False, rx=False, tx=False) + expected["comment"] = "blargh" + assert ret == expected