diff --git a/changelog/62366.added b/changelog/62366.added new file mode 100644 index 00000000000..00871a8ca85 --- /dev/null +++ b/changelog/62366.added @@ -0,0 +1 @@ +Added the ability to remove a KB using the DISM state/execution modules diff --git a/salt/modules/win_dism.py b/salt/modules/win_dism.py index f574f5c70ac..179a18228d8 100644 --- a/salt/modules/win_dism.py +++ b/salt/modules/win_dism.py @@ -11,6 +11,7 @@ import re import salt.utils.platform import salt.utils.versions +from salt.exceptions import CommandExecutionError log = logging.getLogger(__name__) __virtualname__ = "dism" @@ -517,12 +518,13 @@ def remove_package(package, image=None, restart=False): Args: package (str): The full path to the package. Can be either a .cab file or a folder. Should point to the original source of the package, not - to where the file is installed. This can also be the name of a package as listed in - ``dism.installed_packages`` + to where the file is installed. This can also be the name of a + package as listed in ``dism.installed_packages`` image (Optional[str]): The path to the root directory of an offline Windows image. If `None` is passed, the running operating system is targeted. Default is None. - restart (Optional[bool]): Reboot the machine if required by the install + restart (Optional[bool]): Reboot the machine if required by the + uninstall Returns: dict: A dictionary containing the results of the command @@ -555,6 +557,73 @@ def remove_package(package, image=None, restart=False): return __salt__["cmd.run_all"](cmd) +def get_kg_package_name(kb, image=None): + """ + Get the actual package name on the system based on the KB name + + Args: + kb (str): The name of the KB to remove. Can also be just the KB number + image (Optional[str]): The path to the root directory of an offline + Windows image. If `None` is passed, the running operating system is + targeted. Default is None. + + Returns: + str: The name of the package found on the system + None: If the package is not installed on the system + + CLI Example: + + .. code-block:: bash + + # Get the package name for KB1231231 + salt '*' dism.get_kb_package_name KB1231231 + + # Get the package name for KB1231231 using just the number + salt '*' dism.get_kb_package_name 1231231 + """ + packages = installed_packages(image=image) + search = kb.upper() if kb.lower().startswith("kb") else "KB{}".format(kb) + for package in packages: + if "_{}~".format(search) in package: + return package + return None + + +def remove_kb(kb, image=None, restart=False): + """ + Remove a package by passing a KB number. This searches the installed + packages to get the full package name of the KB. It then calls the + ``dism.remove_package`` function to remove the package. + + Args: + kb (str): The name of the KB to remove. Can also be just the KB number + image (Optional[str]): The path to the root directory of an offline + Windows image. If `None` is passed, the running operating system is + targeted. Default is None. + restart (Optional[bool]): Reboot the machine if required by the + uninstall + + Returns: + dict: A dictionary containing the results of the command + + CLI Example: + + .. code-block:: bash + + # Remove the KB5007575 just passing the number + salt '*' dism.remove_kb 5007575 + + # Remove the KB5007575 just passing the full name + salt '*' dism.remove_kb KB5007575 + """ + pkg_name = get_kb_package_name(kb=kb, image=image) + if pkg_name is None: + msg = "{} not installed".format(search) + raise CommandExecutionError(msg) + log.debug("Found: %s", pkg_name) + return remove_package(package=pkg_name, image=image, restart=restart) + + def installed_packages(image=None): """ List the packages installed on the system @@ -573,7 +642,12 @@ def installed_packages(image=None): salt '*' dism.installed_packages """ - return _get_components("Package Identity", "Packages", "Installed") + return _get_components( + type_regex="Package Identity", + plural_type="Packages", + install_value="Installed", + image=image, + ) def package_info(package, image=None): diff --git a/salt/states/win_dism.py b/salt/states/win_dism.py index 62aa15ec57f..1e07da94df1 100644 --- a/salt/states/win_dism.py +++ b/salt/states/win_dism.py @@ -99,7 +99,8 @@ def capability_removed(name, image=None, restart=False): image (Optional[str]): The path to the root directory of an offline Windows image. If `None` is passed, the running operating system is targeted. Default is None. - restart (Optional[bool]): Reboot the machine if required by the install + restart (Optional[bool]): Reboot the machine if required by the + uninstall Example: Run ``dism.installed_capabilities`` to get a list of installed @@ -223,7 +224,8 @@ def feature_removed(name, remove_payload=False, image=None, restart=False): image (Optional[str]): The path to the root directory of an offline Windows image. If `None` is passed, the running operating system is targeted. Default is None. - restart (Optional[bool]): Reboot the machine if required by the install + restart (Optional[bool]): Reboot the machine if required by the + uninstall Example: Run ``dism.installed_features`` to get a list of installed features. @@ -352,7 +354,8 @@ def package_removed(name, image=None, restart=False): image (Optional[str]): The path to the root directory of an offline Windows image. If `None` is passed, the running operating system is targeted. Default is None. - restart (Optional[bool]): Reboot the machine if required by the install + restart (Optional[bool]): Reboot the machine if required by the + uninstall Example: @@ -414,3 +417,66 @@ def package_removed(name, image=None, restart=False): ret["changes"]["package"] = changes return ret + + +def kb_removed(name, image=None, restart=False): + """ + Uninstall a KB package + + Args: + name (str): The name of the KB. Can be with or without the KB at the + beginning. + image (Optional[str]): The path to the root directory of an offline + Windows image. If `None` is passed, the running operating system is + targeted. Default is None. + restart (Optional[bool]): Reboot the machine if required by the + uninstall + + Example: + + .. code-block:: yaml + + # Example using full KB name + remove_KB1231231: + dism.package_installed: + - name: KB1231231 + + # Example using just he KB number + remove_KB1231231: + dism.package_installed: + - name: 1231231 + """ + ret = {"name": name, "result": True, "comment": "", "changes": {}} + + pkg_name = __salt__["dism.get_kb_package_name"](kb=name, image=image) + + # If pkg_name is None, the package is not installed + if pkg_name is None: + ret["comment"] = "{} is not installed".format(name) + return ret + + if __opts__["test"]: + ret["changes"]["package"] = "{} will be removed".format(name) + ret["result"] = None + return ret + + # Fail if using a non-existent package path + old = __salt__["dism.installed_packages"]() + + # Remove the package + status = __salt__["dism.remove_kb"](kb=name, image=image, restart=restart) + + if status["retcode"] not in [0, 1641, 3010]: + ret["comment"] = "Failed to remove {}: {}".format(name, status["stdout"]) + ret["result"] = False + return ret + + new = __salt__["dism.installed_packages"]() + changes = salt.utils.data.compare_lists(old, new) + + if changes: + ret["comment"] = "Removed {}".format(name) + ret["changes"] = status + ret["changes"]["package"] = changes + + return ret diff --git a/tests/pytests/unit/modules/test_win_dism.py b/tests/pytests/unit/modules/test_win_dism.py index c841e191aa0..6f5aac16061 100644 --- a/tests/pytests/unit/modules/test_win_dism.py +++ b/tests/pytests/unit/modules/test_win_dism.py @@ -1,6 +1,7 @@ import pytest import salt.modules.win_dism as dism +from salt.exceptions import CommandExecutionError from tests.support.mock import MagicMock, patch @@ -322,6 +323,49 @@ def test_remove_package(): ) +def test_remove_kb(): + """ + Test uninstalling a KB with DISM + """ + pkg_name = "Package_for_KB1002345~31bf3856ad364e35~amd64~~22000.345.1.1" + mock_search = MagicMock(return_value=[pkg_name]) + mock_remove = MagicMock() + with patch("salt.modules.win_dism.installed_packages", mock_search): + with patch("salt.modules.win_dism.remove_package", mock_remove): + dism.remove_kb("KB1002345") + mock_remove.assert_called_once_with( + package=pkg_name, + image=None, + restart=False, + ) + + +def test_remove_kb_number(): + """ + Test uninstalling a KB with DISM with just the KB number + """ + pkg_name = "Package_for_KB1002345~31bf3856ad364e35~amd64~~22000.345.1.1" + mock_search = MagicMock(return_value=[pkg_name]) + mock_remove = MagicMock() + with patch("salt.modules.win_dism.installed_packages", mock_search): + with patch("salt.modules.win_dism.remove_package", mock_remove): + dism.remove_kb("1002345") + mock_remove.assert_called_once_with( + package=pkg_name, + image=None, + restart=False, + ) + + +def test_remove_kb_not_found(): + pkg_name = "Package_for_KB1002345~31bf3856ad364e35~amd64~~22000.345.1.1" + mock_search = MagicMock(return_value=[pkg_name]) + with patch("salt.modules.win_dism.installed_packages", mock_search): + with pytest.raises(CommandExecutionError) as err: + dism.remove_kb("1001111") + assert str(err.value) == "KB1001111 not installed" + + def test_installed_packages(): """ Test getting all the installed features diff --git a/tests/pytests/unit/states/test_win_dism.py b/tests/pytests/unit/states/test_win_dism.py index 182fd0df08e..c799a374f3b 100644 --- a/tests/pytests/unit/states/test_win_dism.py +++ b/tests/pytests/unit/states/test_win_dism.py @@ -548,3 +548,136 @@ def test_package_removed_removed(): mock_removed.assert_called_once_with() assert not mock_remove.called assert out == expected + + +def test_kb_removed(): + """ + Test removing a package using the KB number + """ + pkg_name = "Package_for_KB1231231~31bf3856ad364e35~amd64~~22000.345.1.1" + expected = { + "comment": "Removed KB1231231", + "changes": {"package": {"old": [pkg_name]}, "retcode": 0}, + "name": "KB1231231", + "result": True, + } + pre_removed = [ + "Package_for_KB5007575~31bf3856ad364e35~amd64~~22000.345.1.1", + "Package_for_KB5012170~31bf3856ad364e35~amd64~~22000.850.1.1", + pkg_name, + ] + post_removed = [ + "Package_for_KB5007575~31bf3856ad364e35~amd64~~22000.345.1.1", + "Package_for_KB5012170~31bf3856ad364e35~amd64~~22000.850.1.1", + ] + mock_get_name = MagicMock(return_value=pkg_name) + mock_installed = MagicMock(side_effect=[pre_removed, post_removed]) + mock_remove = MagicMock(return_value={"retcode": 0}) + + patch_salt = { + "dism.get_kb_package_name": mock_get_name, + "dism.installed_packages": mock_installed, + "dism.remove_kb": mock_remove, + } + + with patch.dict(dism.__salt__, patch_salt): + with patch.dict(dism.__opts__, {"test": False}): + result = dism.kb_removed("KB1231231") + mock_remove.assert_called_once_with( + kb="KB1231231", image=None, restart=False + ) + assert result == expected + + +def test_kb_removed_not_installed(): + """ + Test removing a package using the KB number when the package is not + installed + """ + pkg_name = None + expected = { + "comment": "KB1231231 is not installed", + "changes": {}, + "name": "KB1231231", + "result": True, + } + mock_get_name = MagicMock(return_value=pkg_name) + + patch_salt = {"dism.get_kb_package_name": mock_get_name} + + with patch.dict(dism.__salt__, patch_salt): + result = dism.kb_removed("KB1231231") + assert result == expected + + +def test_kb_removed_test(): + """ + Test removing a package using the KB number with test=True + """ + pkg_name = "Package_for_KB1231231~31bf3856ad364e35~amd64~~22000.345.1.1" + expected = { + "comment": "", + "changes": {"package": "KB1231231 will be removed"}, + "name": "KB1231231", + "result": None, + } + pre_removed = [ + "Package_for_KB5007575~31bf3856ad364e35~amd64~~22000.345.1.1", + "Package_for_KB5012170~31bf3856ad364e35~amd64~~22000.850.1.1", + pkg_name, + ] + post_removed = [ + "Package_for_KB5007575~31bf3856ad364e35~amd64~~22000.345.1.1", + "Package_for_KB5012170~31bf3856ad364e35~amd64~~22000.850.1.1", + ] + mock_get_name = MagicMock(return_value=pkg_name) + mock_installed = MagicMock(side_effect=[pre_removed, post_removed]) + + patch_salt = { + "dism.get_kb_package_name": mock_get_name, + "dism.installed_packages": mock_installed, + } + + with patch.dict(dism.__salt__, patch_salt): + with patch.dict(dism.__opts__, {"test": True}): + result = dism.kb_removed("KB1231231") + assert result == expected + + +def test_kb_removed_failed(): + """ + Test removing a package using the KB number with a failure + """ + pkg_name = "Package_for_KB1231231~31bf3856ad364e35~amd64~~22000.345.1.1" + expected = { + "comment": "Failed to remove KB1231231: error", + "changes": {}, + "name": "KB1231231", + "result": False, + } + pre_removed = [ + "Package_for_KB5007575~31bf3856ad364e35~amd64~~22000.345.1.1", + "Package_for_KB5012170~31bf3856ad364e35~amd64~~22000.850.1.1", + pkg_name, + ] + post_removed = [ + "Package_for_KB5007575~31bf3856ad364e35~amd64~~22000.345.1.1", + "Package_for_KB5012170~31bf3856ad364e35~amd64~~22000.850.1.1", + ] + mock_get_name = MagicMock(return_value=pkg_name) + mock_installed = MagicMock(side_effect=[pre_removed, post_removed]) + mock_remove = MagicMock(return_value={"retcode": 1, "stdout": "error"}) + + patch_salt = { + "dism.get_kb_package_name": mock_get_name, + "dism.installed_packages": mock_installed, + "dism.remove_kb": mock_remove, + } + + with patch.dict(dism.__salt__, patch_salt): + with patch.dict(dism.__opts__, {"test": False}): + result = dism.kb_removed("KB1231231") + mock_remove.assert_called_once_with( + kb="KB1231231", image=None, restart=False + ) + assert result == expected