From c84b2df3a9c5ef91274a8762e41b4c482f50b645 Mon Sep 17 00:00:00 2001 From: Pedro Algarvio Date: Mon, 5 Jun 2023 21:17:01 +0100 Subject: [PATCH 01/25] Add drop Py3.7 support to release notes and changelog Signed-off-by: Pedro Algarvio --- changelog/64417.removed.md | 1 + doc/topics/releases/templates/3007.0.md.template | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 changelog/64417.removed.md diff --git a/changelog/64417.removed.md b/changelog/64417.removed.md new file mode 100644 index 00000000000..db689856f15 --- /dev/null +++ b/changelog/64417.removed.md @@ -0,0 +1 @@ +Dropped Python 3.7 support since it's EOL in 27 Jun 2023 diff --git a/doc/topics/releases/templates/3007.0.md.template b/doc/topics/releases/templates/3007.0.md.template index b503615805a..66899b0f155 100644 --- a/doc/topics/releases/templates/3007.0.md.template +++ b/doc/topics/releases/templates/3007.0.md.template @@ -6,6 +6,9 @@ Add release specific details below --> +## Python 3.7 Support Dropped +Support for python 3.7 has been dropped since it reached end-of-line in 27 Jun 2023. + ## Azure Salt Extension Starting from Salt version 3007.0, the Azure functionality previously available in the Salt code base is fully removed. To continue using Salt's features for interacting with Azure resources, users are required to utilize the Azure Salt extension. For more information, refer to the [Azure Salt Extension GitHub repository](https://github.com/salt-extensions/saltext-azurerm). From d3b82ebdccf26ae6755ae354e427194b9c649f32 Mon Sep 17 00:00:00 2001 From: Pedro Algarvio Date: Mon, 5 Jun 2023 21:17:51 +0100 Subject: [PATCH 02/25] Update supported versions in `noxfile.py` Signed-off-by: Pedro Algarvio --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 30687acf349..b91d8cc2067 100644 --- a/noxfile.py +++ b/noxfile.py @@ -82,7 +82,7 @@ if IS_WINDOWS: else: ONEDIR_PYTHON_PATH = ONEDIR_ARTIFACT_PATH / "bin" / "python3" # Python versions to run against -_PYTHON_VERSIONS = ("3", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10") +_PYTHON_VERSIONS = ("3", "3.8", "3.9", "3.10", "3.11") # Nox options # Reuse existing virtualenvs From 3a50ab534b73a44c6b82045b7e76613b28bf6102 Mon Sep 17 00:00:00 2001 From: Twangboy Date: Thu, 11 May 2023 15:40:15 -0600 Subject: [PATCH 03/25] Add appx execution module --- salt/modules/win_appx.py | 101 +++++++++++++++++++++++++++++++++++++++ salt/utils/win_pwsh.py | 69 ++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 salt/modules/win_appx.py create mode 100644 salt/utils/win_pwsh.py diff --git a/salt/modules/win_appx.py b/salt/modules/win_appx.py new file mode 100644 index 00000000000..3edcded207f --- /dev/null +++ b/salt/modules/win_appx.py @@ -0,0 +1,101 @@ +import logging + +import salt.utils.platform +import salt.utils.win_reg + +log = logging.getLogger(__name__) + +__virtualname__ = "appx" + + +def __virtual__(): + """ + Load only on Windows + """ + if not salt.utils.platform.is_windows(): + return False, "Module appx: module only works on Windows systems." + + return __virtualname__ + + +def _pkg_list(raw, field="PackageFullName"): + result = [] + if raw: + if isinstance(raw, list): + for pkg in raw: + result.append(pkg[field]) + else: + result.append(raw[field]) + else: + result = None + return result + + +def get(query=None, field="Name", include_store=False, frameworks=False, bundles=True): + cmd = [] + + if bundles: + cmd_str = "Get-AppxPackage -AllUsers -PackageTypeFilter Bundle" + else: + cmd_str = "Get-AppxPackage -AllUsers" + + if query: + cmd.append(f"{cmd_str} -Name {query}") + else: + cmd.append(f"{cmd_str}") + if not include_store: + cmd.append('Where-Object {$_.name -notlike "Microsoft.WindowsStore*"}') + if not frameworks: + cmd.append("Where-Object -Property IsFramework -eq $false") + cmd.append("Where-Object -Property NonRemovable -eq $false") + cmd.append("Sort-Object PackageFullName") + if not field: + cmd.append("Select Name, Version, PackageFullName, IsBundle, IsFramework") + return __utils__["win_pwsh.run_dict"](" | ".join(cmd)) + else: + return _pkg_list(__utils__["win_pwsh.run_dict"](" | ".join(cmd)), field) + + +def remove(query=None, include_store=False, frameworks=False, bundles=True): + packages = get( + query=query, + field=None, + include_store=include_store, + frameworks=frameworks, + bundles=bundles, + ) + + def remove_package(package): + + remove_name = package["PackageFullName"] + # If the package is part of a bundle with the same name, removal will + # fail. Let's make sure it's a bundle + if not package["IsBundle"]: + # If it's not a bundle, let's see if we can find the bundle + bundle = get( + query=f'{package["Name"]}*', + field=None, + include_store=include_store, + frameworks=frameworks, + bundles=True, + ) + if bundle and bundle["IsBundle"]: + remove_name = bundle["PackageFullName"] + + log.debug("Removing package: %s", remove_name) + __utils__["win_pwsh.run_dict"]( + f"Remove-AppxPackage -AllUsers -Package {remove_name}" + ) + + if isinstance(packages, list): + log.debug("Removing %s packages", len(packages)) + for pkg in packages: + remove_package(package=pkg) + elif packages: + log.debug("Removing a single package") + remove_package(package=packages) + else: + log.debug("Package not found: %s", query) + return None + + return True diff --git a/salt/utils/win_pwsh.py b/salt/utils/win_pwsh.py new file mode 100644 index 00000000000..1718e1053eb --- /dev/null +++ b/salt/utils/win_pwsh.py @@ -0,0 +1,69 @@ +import logging + +import salt.modules.cmdmod +import salt.utils.json +import salt.utils.platform +from salt.exceptions import CommandExecutionError + +__salt__ = {"cmd.run_all": salt.modules.cmdmod.run_all} +log = logging.getLogger(__name__) + +__virtualname__ = "win_pwsh" + + +def __virtual__(): + """ + Only load if windows + """ + if not salt.utils.platform.is_windows(): + return False, "This utility will only run on Windows" + + return __virtualname__ + + +def run_dict(cmd, cwd=None): + """ + Execute the powershell command and return the data as a dictionary + + Args: + + cmd (str): The powershell command to run + + cwd (str): The current working directory + + Returns: + dict: A dictionary containing the output of the powershell command + + Raises: + CommandExecutionError: + If an error is encountered or the command does not complete + successfully + """ + if "convertto-json" not in cmd.lower(): + cmd = "{} | ConvertTo-Json".format(cmd) + log.debug("PowerShell: %s", cmd) + ret = __salt__["cmd.run_all"](cmd, shell="powershell", cwd=cwd) + + if "pid" in ret: + del ret["pid"] + + if ret.get("stderr", ""): + error = ret["stderr"].splitlines()[0] + raise CommandExecutionError(error, info=ret) + + if "retcode" not in ret or ret["retcode"] != 0: + # run_all logs an error to log.error, fail hard back to the user + raise CommandExecutionError( + "Issue executing PowerShell {}".format(cmd), info=ret + ) + + # Sometimes Powershell returns an empty string, which isn't valid JSON + if ret["stdout"] == "": + ret["stdout"] = "{}" + + try: + ret = salt.utils.json.loads(ret["stdout"], strict=False) + except ValueError: + raise CommandExecutionError("No JSON results from PowerShell", info=ret) + + return ret From 2fc5af765b7882cf3260a5e02ddc4ff1ca3c6b1a Mon Sep 17 00:00:00 2001 From: Twangboy Date: Fri, 12 May 2023 12:55:21 -0600 Subject: [PATCH 04/25] Add ability to reprovision an app --- salt/modules/win_appx.py | 31 +++++++++++++++++++++++++------ salt/utils/win_pwsh.py | 4 +++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/salt/modules/win_appx.py b/salt/modules/win_appx.py index 3edcded207f..99ef17078a7 100644 --- a/salt/modules/win_appx.py +++ b/salt/modules/win_appx.py @@ -5,6 +5,8 @@ import salt.utils.win_reg log = logging.getLogger(__name__) +CURRENTVERSION_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion" +DEPROVISIONED_KEY = fr"{CURRENTVERSION_KEY}\Appx\AppxAllUserStore\Deprovisioned" __virtualname__ = "appx" @@ -56,7 +58,7 @@ def get(query=None, field="Name", include_store=False, frameworks=False, bundles return _pkg_list(__utils__["win_pwsh.run_dict"](" | ".join(cmd)), field) -def remove(query=None, include_store=False, frameworks=False, bundles=True): +def remove(query=None, include_store=False, frameworks=False, bundles=True, deprovision=False): packages = get( query=query, field=None, @@ -66,7 +68,6 @@ def remove(query=None, include_store=False, frameworks=False, bundles=True): ) def remove_package(package): - remove_name = package["PackageFullName"] # If the package is part of a bundle with the same name, removal will # fail. Let's make sure it's a bundle @@ -80,12 +81,16 @@ def remove(query=None, include_store=False, frameworks=False, bundles=True): bundles=True, ) if bundle and bundle["IsBundle"]: + log.debug(f'Found bundle: {bundle["PackageFullName"]}') remove_name = bundle["PackageFullName"] - log.debug("Removing package: %s", remove_name) - __utils__["win_pwsh.run_dict"]( - f"Remove-AppxPackage -AllUsers -Package {remove_name}" - ) + if deprovision: + log.debug("Deprovisioning package: %s", remove_name) + remove_cmd = f"Remove-AppxProvisionedPackage -Online -PackageName {remove_name}" + else: + log.debug("Removing package: %s", remove_name) + remove_cmd = f"Remove-AppxPackage -AllUsers -Package {remove_name}" + __utils__["win_pwsh.run_dict"](remove_cmd) if isinstance(packages, list): log.debug("Removing %s packages", len(packages)) @@ -99,3 +104,17 @@ def remove(query=None, include_store=False, frameworks=False, bundles=True): return None return True + + +def get_deprovisioned(): + return salt.utils.win_reg.list_keys(hive="HKLM", key=f"{DEPROVISIONED_KEY}") + + +def reprovision(package_name): + key = f"{DEPROVISIONED_KEY}\\{package_name}" + if salt.utils.win_reg.key_exists(hive="HKLM", key=key): + log.debug(f"Deprovisioned app found: {package_name}") + ret = salt.utils.win_reg.delete_key_recursive(hive="HKLM", key=key) + return not ret["Failed"] + log.debug(f"Deprovisioned app not found: {package_name}") + return None diff --git a/salt/utils/win_pwsh.py b/salt/utils/win_pwsh.py index 1718e1053eb..53d07322e47 100644 --- a/salt/utils/win_pwsh.py +++ b/salt/utils/win_pwsh.py @@ -40,7 +40,9 @@ def run_dict(cmd, cwd=None): successfully """ if "convertto-json" not in cmd.lower(): - cmd = "{} | ConvertTo-Json".format(cmd) + cmd = f"{cmd} | ConvertTo-Json" + if "progresspreference" not in cmd.lower(): + cmd = f"$ProgressPreference = 'SilentlyContinue'; {cmd}" log.debug("PowerShell: %s", cmd) ret = __salt__["cmd.run_all"](cmd, shell="powershell", cwd=cwd) From 634b405481844ac03875737438d3c38312386312 Mon Sep 17 00:00:00 2001 From: Twangboy Date: Tue, 16 May 2023 10:37:47 -0600 Subject: [PATCH 05/25] Add docs, filter deprovisioned --- salt/modules/win_appx.py | 120 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 7 deletions(-) diff --git a/salt/modules/win_appx.py b/salt/modules/win_appx.py index 99ef17078a7..b62b820cb96 100644 --- a/salt/modules/win_appx.py +++ b/salt/modules/win_appx.py @@ -1,3 +1,4 @@ +import fnmatch import logging import salt.utils.platform @@ -34,6 +35,54 @@ def _pkg_list(raw, field="PackageFullName"): def get(query=None, field="Name", include_store=False, frameworks=False, bundles=True): + """ + Get a list of Microsoft Store packages installed on the system. + + Args: + + query (str): + The query string to use to filter packages to be list. The string + can match multiple packages. ``None`` will return all packages. Here + are some example strings: + + - ``*teams*`` - Returns Microsoft Teams + - ``*zune*`` - Returns Windows Media Player and ZuneVideo + - ``*zuneMusic*`` - Only returns Windows Media Player + - ``*xbox*`` - Returns all xbox packages, there are 5 by default + - ``*`` - Returns everything but the Microsoft Store, unless + ``include_store=True`` + + field (str): + This function returns a list of packages on the system. It can + display a short name or a full name. If ``None`` is passed, a + dictionary will be returned with some common fields. The default is + ``Name``. Valid options are any fields returned by the powershell + command ``Get-AppxPackage``. Here are some useful fields: + + - Name + - Version + - PackageFullName + - PackageFamilyName + + include_store (bool): + Include the Microsoft Store in the results. Default is ``False`` + + frameworks (bool): + Include frameworks in the results. Default is ``False`` + + bundles (bool): + If ``True``, this will return application bundles only. If + ``False``, this will return individual packages only, even if they + are part of a bundle. + + Returns: + list: A list of packages ordered by the string passed in field + list: A list of dictionaries of package information if field is ``None`` + + Raises: + CommandExecutionError: If an error is encountered retrieving packages + + """ cmd = [] if bundles: @@ -50,21 +99,74 @@ def get(query=None, field="Name", include_store=False, frameworks=False, bundles if not frameworks: cmd.append("Where-Object -Property IsFramework -eq $false") cmd.append("Where-Object -Property NonRemovable -eq $false") - cmd.append("Sort-Object PackageFullName") if not field: - cmd.append("Select Name, Version, PackageFullName, IsBundle, IsFramework") + cmd.append("Sort-Object Name") + cmd.append("Select Name, Version, PackageFullName, PackageFamilyName, IsBundle, IsFramework") return __utils__["win_pwsh.run_dict"](" | ".join(cmd)) else: + cmd.append(f"Sort-Object {field}") return _pkg_list(__utils__["win_pwsh.run_dict"](" | ".join(cmd)), field) -def remove(query=None, include_store=False, frameworks=False, bundles=True, deprovision=False): +def remove(query=None, include_store=False, frameworks=False, deprovision_only=False): + """ + Removes Microsoft Store packages from the system. If the package is part of + a bundle, the entire bundle will be removed. + + By default, this function will remove the package for all users on the + system. It will also deprovision the packages so that it isn't re-installed + by later system updates. To only deprovision a package, set + ``deprovision_only=True``. + + Args: + + query (str): + The query string to use to select the packages to be removed. If the + string matches multiple packages, they will all be removed. Here are + some example strings: + + - ``*teams*`` - Remove Microsoft Teams + - ``*zune*`` - Remove Windows Media Player and ZuneVideo + - ``*zuneMusic*`` - Only remove Windows Media Player + - ``*xbox*`` - Remove all xbox packages, there are 5 by default + - ``*`` - Remove everything but the Microsoft Store, unless + ``include_store=True`` + + .. note:: + Use the ``appx.get`` function to make sure your query is + returning what you expect + + include_store (bool): + Include the Microsoft Store in the results of the query to be + removed. Use this with caution. It difficult to reinstall the + Microsoft Store once it has been removed with this function. Default + is ``False`` + + frameworks (bool): + Include frameworks in the results of the query to be removed. + Default is ``False`` + + deprovision_only (bool): + Deprovision the package. The package will be removed from the + current user and added to the list of deprovisioned packages. The + package will not be re-installed in future system updates. New users + of the system will not have the package installed. However, the + package will still be installed for existing users. Default is + ``False`` + + Returns: + bool: ``True`` if successful, ``None`` if no packages found + + Raises: + CommandExecutionError: On errors encountered removing the package + + """ packages = get( query=query, field=None, include_store=include_store, frameworks=frameworks, - bundles=bundles, + bundles=False, ) def remove_package(package): @@ -84,7 +186,7 @@ def remove(query=None, include_store=False, frameworks=False, bundles=True, depr log.debug(f'Found bundle: {bundle["PackageFullName"]}') remove_name = bundle["PackageFullName"] - if deprovision: + if deprovision_only: log.debug("Deprovisioning package: %s", remove_name) remove_cmd = f"Remove-AppxProvisionedPackage -Online -PackageName {remove_name}" else: @@ -106,8 +208,12 @@ def remove(query=None, include_store=False, frameworks=False, bundles=True, depr return True -def get_deprovisioned(): - return salt.utils.win_reg.list_keys(hive="HKLM", key=f"{DEPROVISIONED_KEY}") +def get_deprovisioned(query=None): + ret = salt.utils.win_reg.list_keys(hive="HKLM", key=f"{DEPROVISIONED_KEY}") + if query is None: + return ret + return fnmatch.filter(ret, query) + def reprovision(package_name): From e7785cb886a5b7abf170f0e40d8933ecbdcb308a Mon Sep 17 00:00:00 2001 From: Twangboy Date: Tue, 16 May 2023 11:01:39 -0600 Subject: [PATCH 06/25] Add ability to filter reprovision apps --- salt/modules/win_appx.py | 45 +++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/salt/modules/win_appx.py b/salt/modules/win_appx.py index b62b820cb96..def5ac490ca 100644 --- a/salt/modules/win_appx.py +++ b/salt/modules/win_appx.py @@ -41,7 +41,7 @@ def get(query=None, field="Name", include_store=False, frameworks=False, bundles Args: query (str): - The query string to use to filter packages to be list. The string + The query string to use to filter packages to be listed. The string can match multiple packages. ``None`` will return all packages. Here are some example strings: @@ -209,6 +209,27 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F def get_deprovisioned(query=None): + """ + Get a list of applications that have been deprovisioned. A deprovisioned + package will not be reinstalled during a Major system update. + + Args: + + query (str): + The query string to use to filter packages to be listed. The string + can match multiple packages. ``None`` will return all packages. Here + are some example strings: + + - ``*teams*`` - Returns Microsoft Teams + - ``*zune*`` - Returns Windows Media Player and ZuneVideo + - ``*zuneMusic*`` - Only returns Windows Media Player + - ``*xbox*`` - Returns all xbox packages, there are 5 by default + - ``*`` - Returns everything but the Microsoft Store, unless + ``include_store=True`` + + Returns: + list: A list of packages matching the query criteria + """ ret = salt.utils.win_reg.list_keys(hive="HKLM", key=f"{DEPROVISIONED_KEY}") if query is None: return ret @@ -216,11 +237,19 @@ def get_deprovisioned(query=None): -def reprovision(package_name): - key = f"{DEPROVISIONED_KEY}\\{package_name}" - if salt.utils.win_reg.key_exists(hive="HKLM", key=key): - log.debug(f"Deprovisioned app found: {package_name}") +def reprovision(query=None): + pkgs = salt.utils.win_reg.list_keys(hive="HKLM", key=f"{DEPROVISIONED_KEY}") + failed = [] + for item in fnmatch.filter(pkgs, query): + key = f"{DEPROVISIONED_KEY}\\{item}" + log.debug(f"Deprovisioning app: {item}") ret = salt.utils.win_reg.delete_key_recursive(hive="HKLM", key=key) - return not ret["Failed"] - log.debug(f"Deprovisioned app not found: {package_name}") - return None + if ret["Failed"]: + log.debug(f"Failed to deprovision: {item}") + failed.append(item) + if failed: + return {"Failed to deprovision": failed} + return True + + + From bc1217c99ded3078c5eceff2d3518ebc353e0b90 Mon Sep 17 00:00:00 2001 From: Twangboy Date: Fri, 19 May 2023 15:32:05 -0600 Subject: [PATCH 07/25] Add some documentation, add functions and states in dism modules --- salt/modules/win_appx.py | 129 +++++++++++++++++++++++++++++++-------- salt/modules/win_dism.py | 85 +++++++++++++++++++++++++- salt/states/win_dism.py | 80 +++++++++++++++++++++++- 3 files changed, 266 insertions(+), 28 deletions(-) diff --git a/salt/modules/win_appx.py b/salt/modules/win_appx.py index def5ac490ca..bfe03edcb7a 100644 --- a/salt/modules/win_appx.py +++ b/salt/modules/win_appx.py @@ -1,3 +1,45 @@ +""" +Manage provisioned apps +======================= + +Provisioned apps are part of the image and are installed for every user the +first time the user logs on. Provisioned apps are also updated and sometimes + reinstalled when the system is updated. + +Apps removed with this module will remove the app for all users and deprovision +the app. Deprovisioned apps will neither be installed for new users nor will +they be upgraded. + +An app removed with this module can only be re-provisioned on the machine, but +it can't be re-installed for all users. Also, once a package has been +deprovisioned, the only way to reinstall it is to download the package. This is +difficult. I've outlined the steps below: + +1. Obtain the Microsoft Store URL for the app: + - Open the page for the app in the Microsoft Store + - Click the share button and copy the URL + +2. Look up the packages on https://store.rg-adguard.net/: + - Ensure ``URL (link)`` is selected in the first dropdown + - Paste the URL in the search field + - Ensure Retail is selected in the 2nd dropdown + - Click the checkmark button + +This should give you a list of URLs for the package and all dependencies for all +architectures. Download the package and all dependencies for your system +architecture. These will usually have one of the following file extensions: + +- ``.appx`` +- ``.appxbundle`` +- ``.msix`` +- ``.msixbundle`` + +Dependencies will need to be installed first. + +Not all packages can be found this way, but it seems like most of them can. + +Use the ``appx.install`` function to provision the new app. +""" import fnmatch import logging @@ -6,9 +48,11 @@ import salt.utils.win_reg log = logging.getLogger(__name__) -CURRENTVERSION_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion" -DEPROVISIONED_KEY = fr"{CURRENTVERSION_KEY}\Appx\AppxAllUserStore\Deprovisioned" +CURRENT_VERSION_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion" +DEPROVISIONED_KEY = fr"{CURRENT_VERSION_KEY}\Appx\AppxAllUserStore\Deprovisioned" + __virtualname__ = "appx" +__func_alias__ = {"list_": "list"} def __virtual__(): @@ -21,7 +65,7 @@ def __virtual__(): return __virtualname__ -def _pkg_list(raw, field="PackageFullName"): +def _pkg_list(raw, field="Name"): result = [] if raw: if isinstance(raw, list): @@ -34,7 +78,7 @@ def _pkg_list(raw, field="PackageFullName"): return result -def get(query=None, field="Name", include_store=False, frameworks=False, bundles=True): +def list_(query=None, field="Name", include_store=False, frameworks=False, bundles=True): """ Get a list of Microsoft Store packages installed on the system. @@ -82,6 +126,24 @@ def get(query=None, field="Name", include_store=False, frameworks=False, bundles Raises: CommandExecutionError: If an error is encountered retrieving packages + CLI Example: + + .. code-block:: bash + + # List installed apps that contain the word "candy" + salt '*' appx.list *candy* + + # Return more information about the package + salt '*' appx.list *candy* field=None + + # List all installed apps, including the Microsoft Store + salt '*' appx.list include_store=True + + # List all installed apps, including frameworks + salt '*' appx.list frameworks=True + + # List all installed apps that are bundles + salt '*' appx.list bundles=True """ cmd = [] @@ -113,9 +175,9 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F Removes Microsoft Store packages from the system. If the package is part of a bundle, the entire bundle will be removed. - By default, this function will remove the package for all users on the - system. It will also deprovision the packages so that it isn't re-installed - by later system updates. To only deprovision a package, set + This function removes the package for all users on the system. It also + deprovisions the packages so that it isn't re-installed by later system + updates. To only deprovision a package and not remove for all users, set ``deprovision_only=True``. Args: @@ -134,7 +196,8 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F .. note:: Use the ``appx.get`` function to make sure your query is - returning what you expect + returning what you expect. Then use the same query to remove + those packages include_store (bool): Include the Microsoft Store in the results of the query to be @@ -160,8 +223,13 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F Raises: CommandExecutionError: On errors encountered removing the package + CLI Example: + + .. code-block:: bash + + salt """ - packages = get( + packages = list_( query=query, field=None, include_store=include_store, @@ -175,7 +243,7 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F # fail. Let's make sure it's a bundle if not package["IsBundle"]: # If it's not a bundle, let's see if we can find the bundle - bundle = get( + bundle = list_( query=f'{package["Name"]}*', field=None, include_store=include_store, @@ -210,8 +278,9 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F def get_deprovisioned(query=None): """ - Get a list of applications that have been deprovisioned. A deprovisioned - package will not be reinstalled during a Major system update. + When an app is deprovisioned, a registry key is created that will keep it + from being reinstalled during a major system update. This function returns a + list of keys for apps that have been deprovisioned. Args: @@ -236,20 +305,30 @@ def get_deprovisioned(query=None): return fnmatch.filter(ret, query) +def install(package): + """ + This function uses ``dism`` to provision a package. This means that it will + be made a part of the online image and added to new users on the system. If + a package has dependencies, those must be installed first. -def reprovision(query=None): - pkgs = salt.utils.win_reg.list_keys(hive="HKLM", key=f"{DEPROVISIONED_KEY}") - failed = [] - for item in fnmatch.filter(pkgs, query): - key = f"{DEPROVISIONED_KEY}\\{item}" - log.debug(f"Deprovisioning app: {item}") - ret = salt.utils.win_reg.delete_key_recursive(hive="HKLM", key=key) - if ret["Failed"]: - log.debug(f"Failed to deprovision: {item}") - failed.append(item) - if failed: - return {"Failed to deprovision": failed} - return True + If a package installed using this function has been deprovisioned + previously, the registry entry marking it as deprovisioned will be removed. + Args: + package (str): + The full path to the package to install. Can be one of the + following: + - ``.appx`` or ``.appxbundle`` + - ``.msix`` or ``.msixbundle`` + - ``.ppkg`` + + Returns: + bool: ``True`` if successful, otherwise ``False`` + """ + # I don't see a way to make the app installed for existing users on + # the system. The best we can do is provision the package for new + # users + ret = __salt__["dism.add_provisioned_package"](package) + return ret["retcode"] == 0 diff --git a/salt/modules/win_dism.py b/salt/modules/win_dism.py index c79e1c910a8..c94e38e3dd3 100644 --- a/salt/modules/win_dism.py +++ b/salt/modules/win_dism.py @@ -56,7 +56,10 @@ def _get_components(type_regex, plural_type, install_value, image=None): "/Get-{}".format(plural_type), ] out = __salt__["cmd.run"](cmd) - pattern = r"{} : (.*)\r\n.*State : {}\r\n".format(type_regex, install_value) + if install_value: + pattern = r"{} : (.*)\r\n.*State : {}\r\n".format(type_regex, install_value) + else: + pattern = r"{} : (.*)\r\n.*".format(type_regex, install_value) capabilities = re.findall(pattern, out, re.MULTILINE) capabilities.sort() return capabilities @@ -511,6 +514,58 @@ def add_package( return __salt__["cmd.run_all"](cmd) +def add_provisioned_package(package, image=None, restart=False): + """ + Provision a package using DISM. A provisioned package will install for new + users on the system. It will also be reinstalled on each user if the system + if updated. + + Args: + + package (str): + The package to install. Can be one of the following: + + - ``.appx`` or ``.appxbundle`` + - ``.msix`` or ``.msixbundle`` + - ``.ppkg`` + + 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 installation. Default is + ``False`` + + Returns: + dict: A dictionary containing the results of the command + + CLI Example: + + .. code-block:: bash + + salt '*' dism.add_provisioned_package C:\\Packages\\package.appx + salt '*' dism.add_provisioned_package C:\\Packages\\package.appxbundle + salt '*' dism.add_provisioned_package C:\\Packages\\package.msix + salt '*' dism.add_provisioned_package C:\\Packages\\package.msixbundle + salt '*' dism.add_provisioned_package C:\\Packages\\package.ppkg + """ + cmd = [ + bin_dism, + "/Quiet", + "/Image:{}".format(image) if image else "/Online", + "/Add-ProvisionedAppxPackage", + "/PackagePath:{}".format(package), + "/SkipLicense", + ] + + if not restart: + cmd.append("/NoRestart") + + return __salt__["cmd.run_all"](cmd) + + def remove_package(package, image=None, restart=False): """ Uninstall a package @@ -654,6 +709,32 @@ def installed_packages(image=None): ) +def provisioned_packages(image=None): + """ + List the packages installed on the system + + Args: + 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: + list: A list of installed packages + + CLI Example: + + .. code-block:: bash + + salt '*' dism.installed_packages + """ + return _get_components( + type_regex="PackageName", + plural_type="ProvisionedAppxPackages", + install_value="", + image=image, + ) + + def package_info(package, image=None): """ Display information about a package @@ -674,7 +755,7 @@ def package_info(package, image=None): .. code-block:: bash - salt '*' dism. package_info C:\\packages\\package.cab + salt '*' dism.package_info C:\\packages\\package.cab """ cmd = [ bin_dism, diff --git a/salt/states/win_dism.py b/salt/states/win_dism.py index 486ff9720c0..45bde06ad1d 100644 --- a/salt/states/win_dism.py +++ b/salt/states/win_dism.py @@ -2,7 +2,7 @@ Installing of Windows features using DISM ========================================= -Install windows features/capabilties with DISM +Install Windows features/capabilities/packages with DISM .. code-block:: yaml @@ -342,6 +342,84 @@ def package_installed( return ret +def provisioned_package_installed(name, image=None, restart=False): + """ + Install a package. + + Args: + + name (str): + The package to install. Can be one of the following: + + - ``.appx`` or ``.appxbundle`` + - ``.msix`` or ``.msixbundle`` + - ``.ppkg`` + + The name of the file before the file extension must match the name + of the package after it is installed. This name can be found by + running ``dism.provisioned_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 installation. Default is + ``False`` + + Example: + + .. code-block:: yaml + + install_windows_media_player: + dism.provisioned_package_installed: + - name: C:\\Packages\\Microsoft.ZuneVideo_2019.22091.10036.0_neutral_~_8wekyb3d8bbwe.Msixbundle + + """ + ret = {"name": name, "result": True, "comment": "", "changes": {}} + + # Fail if using a non-existent package path + if not os.path.exists(name): + if __opts__["test"]: + ret["result"] = None + else: + ret["result"] = False + ret["comment"] = f"Package path {name} does not exist" + return ret + + old = __salt__["dism.provisioned_packages"]() + + # Get package name so we can see if it's already installed + package_name = os.path.splitext(os.path.basename(name)) + + if package_name in old: + ret["comment"] = f"The package {name} is already installed: {package_name}" + return ret + + if __opts__["test"]: + ret["changes"]["package"] = f"{name} will be installed" + ret["result"] = None + return ret + + # Install the package + status = __salt__["dism.add_provisioned_package"](name, image, restart) + + if status["retcode"] not in [0, 1641, 3010]: + ret["comment"] = f'Failed to install {name}: {status["stdout"]}' + ret["result"] = False + + new = __salt__["dism.provisioned_packages"]() + changes = salt.utils.data.compare_lists(old, new) + + if changes: + ret["comment"] = f"Installed {name}" + ret["changes"] = status + ret["changes"]["package"] = changes + + return ret + + def package_removed(name, image=None, restart=False): """ Uninstall a package From d9b5ee5429ffbd0820440d18c440a815f18f823a Mon Sep 17 00:00:00 2001 From: Twangboy Date: Fri, 19 May 2023 17:16:27 -0600 Subject: [PATCH 08/25] Rename to list_deprovisioned --- salt/modules/win_appx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/modules/win_appx.py b/salt/modules/win_appx.py index bfe03edcb7a..9ba86520bd8 100644 --- a/salt/modules/win_appx.py +++ b/salt/modules/win_appx.py @@ -276,7 +276,7 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F return True -def get_deprovisioned(query=None): +def list_deprovisioned(query=None): """ When an app is deprovisioned, a registry key is created that will keep it from being reinstalled during a major system update. This function returns a From 608f7acfa2af9d728a369e02fb47a16b55b8f57a Mon Sep 17 00:00:00 2001 From: Twangboy Date: Tue, 23 May 2023 16:18:40 -0600 Subject: [PATCH 09/25] Add appx.absent state, remove duplicated code --- salt/modules/win_appx.py | 29 ++++--- salt/modules/win_servermanager.py | 42 +--------- salt/modules/win_wusa.py | 38 +-------- salt/states/win_appx.py | 123 ++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 84 deletions(-) create mode 100644 salt/states/win_appx.py diff --git a/salt/modules/win_appx.py b/salt/modules/win_appx.py index 9ba86520bd8..8812ee1d827 100644 --- a/salt/modules/win_appx.py +++ b/salt/modules/win_appx.py @@ -4,7 +4,7 @@ Manage provisioned apps Provisioned apps are part of the image and are installed for every user the first time the user logs on. Provisioned apps are also updated and sometimes - reinstalled when the system is updated. +reinstalled when the system is updated. Apps removed with this module will remove the app for all users and deprovision the app. Deprovisioned apps will neither be installed for new users nor will @@ -44,6 +44,7 @@ import fnmatch import logging import salt.utils.platform +import salt.utils.win_pwsh import salt.utils.win_reg log = logging.getLogger(__name__) @@ -60,7 +61,11 @@ def __virtual__(): Load only on Windows """ if not salt.utils.platform.is_windows(): - return False, "Module appx: module only works on Windows systems." + return False, "Appx module: Only available on Windows systems" + + pwsh_info = __salt__["cmd.shell_info"](shell="powershell", list_modules=False) + if not pwsh_info["installed"]: + return False, "Appx module: PowerShell not available" return __virtualname__ @@ -164,10 +169,10 @@ def list_(query=None, field="Name", include_store=False, frameworks=False, bundl if not field: cmd.append("Sort-Object Name") cmd.append("Select Name, Version, PackageFullName, PackageFamilyName, IsBundle, IsFramework") - return __utils__["win_pwsh.run_dict"](" | ".join(cmd)) + return salt.utils.win_pwsh.run_dict(" | ".join(cmd)) else: cmd.append(f"Sort-Object {field}") - return _pkg_list(__utils__["win_pwsh.run_dict"](" | ".join(cmd)), field) + return _pkg_list(salt.utils.win_pwsh.run_dict(" | ".join(cmd)), field) def remove(query=None, include_store=False, frameworks=False, deprovision_only=False): @@ -176,8 +181,8 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F a bundle, the entire bundle will be removed. This function removes the package for all users on the system. It also - deprovisions the packages so that it isn't re-installed by later system - updates. To only deprovision a package and not remove for all users, set + deprovisions the package so that it isn't re-installed by later system + updates. To only deprovision a package and not remove it for all users, set ``deprovision_only=True``. Args: @@ -195,13 +200,13 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F ``include_store=True`` .. note:: - Use the ``appx.get`` function to make sure your query is + Use the ``appx.list`` function to make sure your query is returning what you expect. Then use the same query to remove those packages include_store (bool): Include the Microsoft Store in the results of the query to be - removed. Use this with caution. It difficult to reinstall the + removed. Use this with caution. It is difficult to reinstall the Microsoft Store once it has been removed with this function. Default is ``False`` @@ -210,7 +215,7 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F Default is ``False`` deprovision_only (bool): - Deprovision the package. The package will be removed from the + Only deprovision the package. The package will be removed from the current user and added to the list of deprovisioned packages. The package will not be re-installed in future system updates. New users of the system will not have the package installed. However, the @@ -260,7 +265,7 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F else: log.debug("Removing package: %s", remove_name) remove_cmd = f"Remove-AppxPackage -AllUsers -Package {remove_name}" - __utils__["win_pwsh.run_dict"](remove_cmd) + salt.utils.win_pwsh.run_dict(remove_cmd) if isinstance(packages, list): log.debug("Removing %s packages", len(packages)) @@ -314,6 +319,10 @@ def install(package): If a package installed using this function has been deprovisioned previously, the registry entry marking it as deprovisioned will be removed. + .. NOTE:: + There is no ``appx.present`` state. Instead, use the + ``dism.provisioned_package_installed`` state. + Args: package (str): diff --git a/salt/modules/win_servermanager.py b/salt/modules/win_servermanager.py index f26ce42183d..81e8e8e5044 100644 --- a/salt/modules/win_servermanager.py +++ b/salt/modules/win_servermanager.py @@ -15,6 +15,7 @@ import salt.utils.json import salt.utils.platform import salt.utils.powershell import salt.utils.versions +import salt.utils.win_pwsh from salt.exceptions import CommandExecutionError log = logging.getLogger(__name__) @@ -51,41 +52,6 @@ def __virtual__(): return __virtualname__ -def _pshell_json(cmd, cwd=None): - """ - Execute the desired powershell command and ensure that it returns data - in JSON format and load that into python - """ - cmd = "Import-Module ServerManager; {}".format(cmd) - if "convertto-json" not in cmd.lower(): - cmd = "{} | ConvertTo-Json".format(cmd) - log.debug("PowerShell: %s", cmd) - ret = __salt__["cmd.run_all"](cmd, shell="powershell", cwd=cwd) - - if "pid" in ret: - del ret["pid"] - - if ret.get("stderr", ""): - error = ret["stderr"].splitlines()[0] - raise CommandExecutionError(error, info=ret) - - if "retcode" not in ret or ret["retcode"] != 0: - # run_all logs an error to log.error, fail hard back to the user - raise CommandExecutionError( - "Issue executing PowerShell {}".format(cmd), info=ret - ) - - # Sometimes Powershell returns an empty string, which isn't valid JSON - if ret["stdout"] == "": - ret["stdout"] = "{}" - - try: - ret = salt.utils.json.loads(ret["stdout"], strict=False) - except ValueError: - raise CommandExecutionError("No JSON results from PowerShell", info=ret) - return ret - - def list_available(): """ List available features to install @@ -129,7 +95,7 @@ def list_installed(): "-WarningAction SilentlyContinue " "| Select DisplayName,Name,Installed" ) - features = _pshell_json(cmd) + features = salt.utils.win_pwsh.run_dict(cmd) ret = {} for entry in features: @@ -230,7 +196,7 @@ def install(feature, recurse=False, restart=False, source=None, exclude=None): "-IncludeAllSubFeature" if recurse else "", "" if source is None else "-Source {}".format(source), ) - out = _pshell_json(cmd) + out = salt.utils.win_pwsh.run_dict(cmd) # Uninstall items in the exclude list # The Install-WindowsFeature command doesn't have the concept of an exclude @@ -375,7 +341,7 @@ def remove(feature, remove_payload=False, restart=False): "-Restart" if restart else "", ) try: - out = _pshell_json(cmd) + out = salt.utils.win_pwsh.run_dict(cmd) except CommandExecutionError as exc: if "ArgumentNotValid" in exc.message: raise CommandExecutionError("Invalid Feature Name", info=exc.info) diff --git a/salt/modules/win_wusa.py b/salt/modules/win_wusa.py index e6a8730f009..fb8894ed230 100644 --- a/salt/modules/win_wusa.py +++ b/salt/modules/win_wusa.py @@ -13,6 +13,7 @@ import logging import os import salt.utils.platform +import salt.utils.win_pwsh from salt.exceptions import CommandExecutionError log = logging.getLogger(__name__) @@ -35,41 +36,6 @@ def __virtual__(): return __virtualname__ -def _pshell_json(cmd, cwd=None): - """ - Execute the desired powershell command and ensure that it returns data - in JSON format and load that into python - """ - if "convertto-json" not in cmd.lower(): - cmd = "{} | ConvertTo-Json".format(cmd) - log.debug("PowerShell: %s", cmd) - ret = __salt__["cmd.run_all"](cmd, shell="powershell", cwd=cwd) - - if "pid" in ret: - del ret["pid"] - - if ret.get("stderr", ""): - error = ret["stderr"].splitlines()[0] - raise CommandExecutionError(error, info=ret) - - if "retcode" not in ret or ret["retcode"] != 0: - # run_all logs an error to log.error, fail hard back to the user - raise CommandExecutionError( - "Issue executing PowerShell {}".format(cmd), info=ret - ) - - # Sometimes Powershell returns an empty string, which isn't valid JSON - if ret["stdout"] == "": - ret["stdout"] = "{}" - - try: - ret = salt.utils.json.loads(ret["stdout"], strict=False) - except ValueError: - raise CommandExecutionError("No JSON results from PowerShell", info=ret) - - return ret - - def is_installed(name): """ Check if a specific KB is installed. @@ -229,7 +195,7 @@ def list(): salt '*' wusa.list """ kbs = [] - ret = _pshell_json("Get-HotFix | Select HotFixID") + ret = salt.utils.win_pwsh.run_dict("Get-HotFix | Select HotFixID") for item in ret: kbs.append(item["HotFixID"]) return kbs diff --git a/salt/states/win_appx.py b/salt/states/win_appx.py new file mode 100644 index 00000000000..206a06c2159 --- /dev/null +++ b/salt/states/win_appx.py @@ -0,0 +1,123 @@ +""" +Manage Microsoft Store apps on Windows. Removing an app with this modules will +deprovision the app from the online Windows image. +""" +import fnmatch +import logging + +import salt.utils.data +import salt.utils.platform + +log = logging.getLogger(__name__) +__virtualname__ = "appx" + + +def __virtual__(): + """ + Only work on Windows where the DISM module is available + """ + if not salt.utils.platform.is_windows(): + return False, "Appx state: Only available on Windows" + + pwsh_info = __salt__["cmd.shell_info"](shell="powershell", list_modules=False) + if not pwsh_info["installed"]: + return False, "Appx state: PowerShell not available" + + return __virtualname__ + + +def absent( + name, query, include_store=False, frameworks=False, deprovision_only=False +): + """ + Removes Microsoft Store packages from the system. If the package is part of + a bundle, the entire bundle will be removed. + + This function removes the package for all users on the system. It also + deprovisions the package so that it isn't re-installed by later system + updates. To only deprovision a package and not remove it for all users, set + ``deprovision_only=True``. + + Args: + + query (str): + The query string to use to select the packages to be removed. If the + string matches multiple packages, they will all be removed. Here are + some example strings: + + - ``*teams*`` - Remove Microsoft Teams + - ``*zune*`` - Remove Windows Media Player and ZuneVideo + - ``*zuneMusic*`` - Only remove Windows Media Player + - ``*xbox*`` - Remove all xbox packages, there are 5 by default + - ``*`` - Remove everything but the Microsoft Store, unless + ``include_store=True`` + + .. note:: + Use the ``appx.list`` function to make sure your query is + returning what you expect. Then use the same query to remove + those packages + + include_store (bool): + Include the Microsoft Store in the results of the query to be + removed. Use this with caution. It is difficult to reinstall the + Microsoft Store once it has been removed with this function. Default + is ``False`` + + frameworks (bool): + Include frameworks in the results of the query to be removed. + Default is ``False`` + + deprovision_only (bool): + Only deprovision the package. The package will be removed from the + current user and added to the list of deprovisioned packages. The + package will not be re-installed in future system updates. New users + of the system will not have the package installed. However, the + package will still be installed for existing users. Default is + ``False`` + + Returns: + bool: ``True`` if successful, ``None`` if no packages found + + Raises: + CommandExecutionError: On errors encountered removing the package + + CLI Example: + + .. code-block:: bash + + salt + """ + ret = {"name": name, "result": True, "comment": "", "changes": {}} + + old = __salt__["appx.list"](include_store=include_store, frameworks=frameworks) + matches = fnmatch.filter(old, query) + if not matches: + ret["comment"] = f"No apps found matching query: {query}" + return ret + + if __opts__["test"]: + ret["changes"]["removed"] = matches + ret["comment"] = "Matching apps will be removed" + ret["result"] = None + return ret + + # Install the capability + status = __salt__["appx.remove"]( + query, + include_store=include_store, + frameworks=frameworks, + deprovision_only=deprovision_only + ) + + if status is None: + ret["comment"] = f"No apps found matching query: {query}" + ret["result"] = False + + new = __salt__["appx.list"](include_store=include_store, frameworks=frameworks) + changes = salt.utils.data.compare_lists(old, new) + + if changes: + ret["comment"] = f"Removed apps matching query: {query}" + ret["changes"] = changes + + return ret From 7c6d550123487514781fedcca1e8065172560d3c Mon Sep 17 00:00:00 2001 From: Twangboy Date: Wed, 24 May 2023 18:02:38 -0600 Subject: [PATCH 10/25] Add tests --- salt/modules/win_wusa.py | 3 +- salt/states/win_appx.py | 11 +- tests/pytests/unit/modules/test_win_appx.py | 216 +++++++++++++++ .../unit/modules/test_win_servermanager.py | 12 +- tests/pytests/unit/modules/test_win_wusa.py | 245 ++++++++++++++++++ tests/pytests/unit/states/test_win_appx.py | 86 ++++++ tests/pytests/unit/utils/test_win_pwsh.py | 106 ++++++++ tests/unit/modules/test_win_wusa.py | 242 ----------------- 8 files changed, 666 insertions(+), 255 deletions(-) create mode 100644 tests/pytests/unit/modules/test_win_appx.py create mode 100644 tests/pytests/unit/modules/test_win_wusa.py create mode 100644 tests/pytests/unit/states/test_win_appx.py create mode 100644 tests/pytests/unit/utils/test_win_pwsh.py delete mode 100644 tests/unit/modules/test_win_wusa.py diff --git a/salt/modules/win_wusa.py b/salt/modules/win_wusa.py index fb8894ed230..f0c06edddb3 100644 --- a/salt/modules/win_wusa.py +++ b/salt/modules/win_wusa.py @@ -20,6 +20,7 @@ log = logging.getLogger(__name__) # Define the module's virtual name __virtualname__ = "wusa" +__func_alias__ = {"list_": "list"} def __virtual__(): @@ -181,7 +182,7 @@ def uninstall(path, restart=False): return True -def list(): +def list_(): """ Get a list of updates installed on the machine diff --git a/salt/states/win_appx.py b/salt/states/win_appx.py index 206a06c2159..0f3afc5bdbd 100644 --- a/salt/states/win_appx.py +++ b/salt/states/win_appx.py @@ -26,9 +26,7 @@ def __virtual__(): return __virtualname__ -def absent( - name, query, include_store=False, frameworks=False, deprovision_only=False -): +def absent(name, query, include_store=False, frameworks=False, deprovision_only=False): """ Removes Microsoft Store packages from the system. If the package is part of a bundle, the entire bundle will be removed. @@ -96,8 +94,9 @@ def absent( return ret if __opts__["test"]: - ret["changes"]["removed"] = matches - ret["comment"] = "Matching apps will be removed" + comment = ["The following apps will be removed:"] + comment.extend(matches) + ret["comment"] = "\n- ".join(comment) ret["result"] = None return ret @@ -106,7 +105,7 @@ def absent( query, include_store=include_store, frameworks=frameworks, - deprovision_only=deprovision_only + deprovision_only=deprovision_only, ) if status is None: diff --git a/tests/pytests/unit/modules/test_win_appx.py b/tests/pytests/unit/modules/test_win_appx.py new file mode 100644 index 00000000000..f37ca23be8e --- /dev/null +++ b/tests/pytests/unit/modules/test_win_appx.py @@ -0,0 +1,216 @@ +import pytest + +import salt.modules.win_appx as win_appx +from tests.support.mock import MagicMock, patch + +pytestmark = [ + pytest.mark.windows_whitelisted, + pytest.mark.skip_unless_on_windows, +] + + +@pytest.fixture +def configure_loader_modules(): + return {win_appx: {}} + + +def test__pkg_list_empty(): + assert win_appx._pkg_list("") is None + + +def test__pkg_list_single(): + raw = { + "Name": "MicrosoftTeams", + "Version": "22042.702.1226.2352", + "PackageFullName": "MicrosoftTeams_22042.702.1226.2352_x64__8wekyb3d8bbwe", + "PackageFamilyName": "MicrosoftTeams_8wekyb3d8bbwe", + } + assert win_appx._pkg_list(raw=raw) == ["MicrosoftTeams"] + + +def test__pkg_list_multiple(): + raw = [ + { + "Name": "MicrosoftTeams", + "Version": "22042.702.1226.2352", + "PackageFullName": "MicrosoftTeams_22042.702.1226.2352_x64__8wekyb3d8bbwe", + "PackageFamilyName": "MicrosoftTeams_8wekyb3d8bbwe", + }, + { + "Name": "Microsoft.BingWeather", + "Version": "4.53.51361.0", + "PackageFullName": "Microsoft.BingWeather_4.53.51361.0_x64__8wekyb3d8bbwe", + "PackageFamilyName": "Microsoft.BingWeather_8wekyb3d8bbwe", + }, + ] + assert win_appx._pkg_list(raw=raw) == ["MicrosoftTeams", "Microsoft.BingWeather"] + + +def test__pkg_list_single_field(): + raw = { + "Name": "MicrosoftTeams", + "Version": "22042.702.1226.2352", + "PackageFullName": "MicrosoftTeams_22042.702.1226.2352_x64__8wekyb3d8bbwe", + "PackageFamilyName": "MicrosoftTeams_8wekyb3d8bbwe", + } + assert win_appx._pkg_list(raw=raw, field="PackageFamilyName") == [ + "MicrosoftTeams_8wekyb3d8bbwe" + ] + + +def test__pkg_list_multiple_field(): + raw = [ + { + "Name": "MicrosoftTeams", + "Version": "22042.702.1226.2352", + "PackageFullName": "MicrosoftTeams_22042.702.1226.2352_x64__8wekyb3d8bbwe", + "PackageFamilyName": "MicrosoftTeams_8wekyb3d8bbwe", + }, + { + "Name": "Microsoft.BingWeather", + "Version": "4.53.51361.0", + "PackageFullName": "Microsoft.BingWeather_4.53.51361.0_x64__8wekyb3d8bbwe", + "PackageFamilyName": "Microsoft.BingWeather_8wekyb3d8bbwe", + }, + ] + assert win_appx._pkg_list(raw=raw, field="PackageFamilyName") == [ + "MicrosoftTeams_8wekyb3d8bbwe", + "Microsoft.BingWeather_8wekyb3d8bbwe", + ] + + +def test_list(): + mock_run_dict = MagicMock() + with patch("salt.utils.win_pwsh.run_dict", mock_run_dict): + win_appx.list_("*test*") + cmd = [ + "Get-AppxPackage -AllUsers -PackageTypeFilter Bundle -Name *test*", + 'Where-Object {$_.name -notlike "Microsoft.WindowsStore*"}', + "Where-Object -Property IsFramework -eq $false", + "Where-Object -Property NonRemovable -eq $false", + "Sort-Object Name", + ] + mock_run_dict.assert_called_once_with(" | ".join(cmd)) + + +def test_list_field_none(): + mock_run_dict = MagicMock() + with patch("salt.utils.win_pwsh.run_dict", mock_run_dict): + win_appx.list_("*test*", field=None) + cmd = [ + "Get-AppxPackage -AllUsers -PackageTypeFilter Bundle -Name *test*", + 'Where-Object {$_.name -notlike "Microsoft.WindowsStore*"}', + "Where-Object -Property IsFramework -eq $false", + "Where-Object -Property NonRemovable -eq $false", + "Sort-Object Name", + "Select Name, Version, PackageFullName, PackageFamilyName, IsBundle, IsFramework", + ] + mock_run_dict.assert_called_once_with(" | ".join(cmd)) + + +def test_list_other_options_flipped(): + mock_run_dict = MagicMock() + with patch("salt.utils.win_pwsh.run_dict", mock_run_dict): + win_appx.list_("*test*", include_store=True, frameworks=True, bundles=False) + cmd = [ + "Get-AppxPackage -AllUsers -Name *test*", + "Where-Object -Property NonRemovable -eq $false", + "Sort-Object Name", + ] + mock_run_dict.assert_called_once_with(" | ".join(cmd)) + + +def test_remove(): + mock_run_dict = MagicMock() + mock_list_return = { + "Name": "Microsoft.BingWeather", + "PackageFullName": "Microsoft.BingWeather_full_name", + "IsBundle": True, + } + mock_list = MagicMock(return_value=mock_list_return) + with patch("salt.utils.win_pwsh.run_dict", mock_run_dict), patch.object( + win_appx, "list_", mock_list + ): + assert win_appx.remove("*test*") is True + cmd = "Remove-AppxPackage -AllUsers -Package Microsoft.BingWeather_full_name" + mock_run_dict.assert_called_with(cmd) + + +def test_remove_deprovision_only(): + mock_run_dict = MagicMock() + mock_list_return = { + "Name": "Microsoft.BingWeather", + "PackageFullName": "Microsoft.BingWeather_full_name", + "IsBundle": True, + } + mock_list = MagicMock(return_value=mock_list_return) + with patch("salt.utils.win_pwsh.run_dict", mock_run_dict), patch.object( + win_appx, "list_", mock_list + ): + assert win_appx.remove("*test*", deprovision_only=True) is True + cmd = "Remove-AppxProvisionedPackage -Online -PackageName Microsoft.BingWeather_full_name" + mock_run_dict.assert_called_with(cmd) + + +def test_remove_non_bundle(): + mock_run_dict = MagicMock() + mock_list_return = [ + { + "Name": "Microsoft.BingWeather", + "PackageFullName": "Microsoft.BingWeather_non_bundle", + "IsBundle": False, + }, + { + "Name": "Microsoft.BingWeather", + "PackageFullName": "Microsoft.BingWeather_bundle", + "IsBundle": True, + }, + ] + mock_list = MagicMock(side_effect=mock_list_return) + with patch("salt.utils.win_pwsh.run_dict", mock_run_dict), patch.object( + win_appx, "list_", mock_list + ): + assert win_appx.remove("*test*", deprovision_only=True) is True + cmd = "Remove-AppxProvisionedPackage -Online -PackageName Microsoft.BingWeather_bundle" + mock_run_dict.assert_called_with(cmd) + + +def test_remove_not_found_empty_dict(): + mock_run_dict = MagicMock() + mock_list_return = {} + mock_list = MagicMock(return_value=mock_list_return) + with patch("salt.utils.win_pwsh.run_dict", mock_run_dict), patch.object( + win_appx, "list_", mock_list + ): + assert win_appx.remove("*test*", deprovision_only=True) is None + + +def test_remove_not_found_none(): + mock_run_dict = MagicMock() + mock_list_return = None + mock_list = MagicMock(return_value=mock_list_return) + with patch("salt.utils.win_pwsh.run_dict", mock_run_dict), patch.object( + win_appx, "list_", mock_list + ): + assert win_appx.remove("*test*", deprovision_only=True) is None + + +def test_list_deprovisioned(): + mock_list_keys = MagicMock(return_value=["Deprovisioned1", "Deprovisioned2"]) + with patch("salt.utils.win_reg.list_keys", mock_list_keys): + expected = ["Deprovisioned1", "Deprovisioned2"] + assert win_appx.list_deprovisioned() == expected + + +def test_list_deprovisioned_query(): + mock_list_keys = MagicMock(return_value=["Deprovisioned1", "Deprovisioned2"]) + with patch("salt.utils.win_reg.list_keys", mock_list_keys): + expected = ["Deprovisioned1"] + assert win_appx.list_deprovisioned(query="*ed1*") == expected + + +def test_install(): + mock_dism = MagicMock(return_value={"retcode": 0}) + with patch.dict(win_appx.__salt__, {"dism.add_provisioned_package": mock_dism}): + assert win_appx.install("C:\\Test.appx") is True + mock_dism.assert_called_once_with("C:\\Test.appx") diff --git a/tests/pytests/unit/modules/test_win_servermanager.py b/tests/pytests/unit/modules/test_win_servermanager.py index ef00470c0d6..6bd91a82ce1 100644 --- a/tests/pytests/unit/modules/test_win_servermanager.py +++ b/tests/pytests/unit/modules/test_win_servermanager.py @@ -46,9 +46,9 @@ def test_install(): } mock_reboot = MagicMock(return_value=True) - with patch.object( - win_servermanager, "_pshell_json", return_value=mock_out - ), patch.dict(win_servermanager.__salt__, {"system.reboot": mock_reboot}): + with patch("salt.utils.win_pwsh.run_dict", return_value=mock_out), patch.dict( + win_servermanager.__salt__, {"system.reboot": mock_reboot} + ): result = win_servermanager.install("XPS-Viewer") assert result == expected @@ -90,9 +90,9 @@ def test_install_restart(): } mock_reboot = MagicMock(return_value=True) - with patch.object( - win_servermanager, "_pshell_json", return_value=mock_out - ), patch.dict(win_servermanager.__salt__, {"system.reboot": mock_reboot}): + with patch("salt.utils.win_pwsh.run_dict", return_value=mock_out), patch.dict( + win_servermanager.__salt__, {"system.reboot": mock_reboot} + ): result = win_servermanager.install("XPS-Viewer", restart=True) mock_reboot.assert_called_once() assert result == expected diff --git a/tests/pytests/unit/modules/test_win_wusa.py b/tests/pytests/unit/modules/test_win_wusa.py new file mode 100644 index 00000000000..926a99f9fec --- /dev/null +++ b/tests/pytests/unit/modules/test_win_wusa.py @@ -0,0 +1,245 @@ +""" +Test the win_wusa execution module +""" + +import pytest + +import salt.modules.win_wusa as win_wusa +from salt.exceptions import CommandExecutionError +from tests.support.mock import MagicMock, patch + +pytestmark = [ + pytest.mark.windows_whitelisted, + pytest.mark.skip_unless_on_windows, +] + + +@pytest.fixture +def configure_loader_modules(): + return {win_wusa: {}} + + +def test_is_installed_false(): + """ + test is_installed function when the KB is not installed + """ + mock_retcode = MagicMock(return_value=1) + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): + assert win_wusa.is_installed("KB123456") is False + + +def test_is_installed_true(): + """ + test is_installed function when the KB is installed + """ + mock_retcode = MagicMock(return_value=0) + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): + assert win_wusa.is_installed("KB123456") is True + + +def test_list(): + """ + test list function + """ + ret = [{"HotFixID": "KB123456"}, {"HotFixID": "KB123457"}] + mock_all = MagicMock(return_value=ret) + with patch("salt.utils.win_pwsh.run_dict", mock_all): + expected = ["KB123456", "KB123457"] + returned = win_wusa.list_() + assert returned == expected + + +def test_install(): + """ + test install function + """ + mock_retcode = MagicMock(return_value=0) + path = "C:\\KB123456.msu" + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): + assert win_wusa.install(path) is True + mock_retcode.assert_called_once_with( + ["wusa.exe", path, "/quiet", "/norestart"], ignore_retcode=True + ) + + +def test_install_restart(): + """ + test install function with restart=True + """ + mock_retcode = MagicMock(return_value=0) + path = "C:\\KB123456.msu" + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): + assert win_wusa.install(path, restart=True) is True + mock_retcode.assert_called_once_with( + ["wusa.exe", path, "/quiet", "/forcerestart"], ignore_retcode=True + ) + + +def test_install_already_installed(): + """ + test install function when KB already installed + """ + retcode = 2359302 + mock_retcode = MagicMock(return_value=retcode) + path = "C:\\KB123456.msu" + name = "KB123456.msu" + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): + with pytest.raises(CommandExecutionError) as excinfo: + win_wusa.install(path) + mock_retcode.assert_called_once_with( + ["wusa.exe", path, "/quiet", "/norestart"], ignore_retcode=True + ) + assert ( + f"{name} is already installed. Additional info follows:\n\n{retcode}" + == excinfo.value.message + ) + + +def test_install_reboot_needed(): + """ + test install function when KB need a reboot + """ + retcode = 3010 + mock_retcode = MagicMock(return_value=retcode) + path = "C:\\KB123456.msu" + name = "KB123456.msu" + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): + with pytest.raises(CommandExecutionError) as excinfo: + win_wusa.install(path) + mock_retcode.assert_called_once_with( + ["wusa.exe", path, "/quiet", "/norestart"], ignore_retcode=True + ) + assert ( + f"{name} correctly installed but server reboot is needed to complete installation. Additional info follows:\n\n{retcode}" + == excinfo.value.message + ) + + +def test_install_error_87(): + """ + test install function when error 87 returned + """ + retcode = 87 + mock_retcode = MagicMock(return_value=retcode) + path = "C:\\KB123456.msu" + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): + with pytest.raises(CommandExecutionError) as excinfo: + win_wusa.install(path) + mock_retcode.assert_called_once_with( + ["wusa.exe", path, "/quiet", "/norestart"], ignore_retcode=True + ) + assert ( + f"Unknown error. Additional info follows:\n\n{retcode}" == excinfo.value.message + ) + + +def test_install_error_other(): + """ + test install function on other unknown error + """ + mock_retcode = MagicMock(return_value=1234) + path = "C:\\KB123456.msu" + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): + with pytest.raises(CommandExecutionError) as excinfo: + win_wusa.install(path) + mock_retcode.assert_called_once_with( + ["wusa.exe", path, "/quiet", "/norestart"], ignore_retcode=True + ) + assert "Unknown error: 1234" == excinfo.value.message + + +def test_uninstall_kb(): + """ + test uninstall function passing kb name + """ + mock_retcode = MagicMock(return_value=0) + kb = "KB123456" + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}), patch( + "os.path.exists", MagicMock(return_value=False) + ): + assert win_wusa.uninstall(kb) is True + mock_retcode.assert_called_once_with( + [ + "wusa.exe", + "/uninstall", + "/quiet", + "/kb:{}".format(kb[2:]), + "/norestart", + ], + ignore_retcode=True, + ) + + +def test_uninstall_path(): + """ + test uninstall function passing full path to .msu file + """ + mock_retcode = MagicMock(return_value=0) + path = "C:\\KB123456.msu" + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}), patch( + "os.path.exists", MagicMock(return_value=True) + ): + assert win_wusa.uninstall(path) is True + mock_retcode.assert_called_once_with( + ["wusa.exe", "/uninstall", "/quiet", path, "/norestart"], + ignore_retcode=True, + ) + + +def test_uninstall_path_restart(): + """ + test uninstall function with full path and restart=True + """ + mock_retcode = MagicMock(return_value=0) + path = "C:\\KB123456.msu" + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}), patch( + "os.path.exists", MagicMock(return_value=True) + ): + assert win_wusa.uninstall(path, restart=True) is True + mock_retcode.assert_called_once_with( + ["wusa.exe", "/uninstall", "/quiet", path, "/forcerestart"], + ignore_retcode=True, + ) + + +def test_uninstall_already_uninstalled(): + """ + test uninstall function when KB already uninstalled + """ + retcode = 2359303 + mock_retcode = MagicMock(return_value=retcode) + kb = "KB123456" + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): + with pytest.raises(CommandExecutionError) as excinfo: + win_wusa.uninstall(kb) + mock_retcode.assert_called_once_with( + [ + "wusa.exe", + "/uninstall", + "/quiet", + "/kb:{}".format(kb[2:]), + "/norestart", + ], + ignore_retcode=True, + ) + assert ( + f"{kb} not installed. Additional info follows:\n\n{retcode}" + == excinfo.value.message + ) + + +def test_uninstall_path_error_other(): + """ + test uninstall function with unknown error + """ + mock_retcode = MagicMock(return_value=1234) + path = "C:\\KB123456.msu" + with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}), patch( + "os.path.exists", MagicMock(return_value=True) + ), pytest.raises(CommandExecutionError) as excinfo: + win_wusa.uninstall(path) + mock_retcode.assert_called_once_with( + ["wusa.exe", "/uninstall", "/quiet", path, "/norestart"], + ignore_retcode=True, + ) + assert "Unknown error: 1234" == excinfo.value.message diff --git a/tests/pytests/unit/states/test_win_appx.py b/tests/pytests/unit/states/test_win_appx.py new file mode 100644 index 00000000000..a49f45ac0de --- /dev/null +++ b/tests/pytests/unit/states/test_win_appx.py @@ -0,0 +1,86 @@ +import pytest + +import salt.states.win_appx as win_appx +from tests.support.mock import MagicMock, patch + +pytestmark = [ + pytest.mark.windows_whitelisted, + pytest.mark.skip_unless_on_windows, +] + + +@pytest.fixture +def configure_loader_modules(): + return {win_appx: {}} + + +def test_absent_missing(): + expected = { + "comment": "No apps found matching query: *candy*", + "changes": {}, + "name": "remove_candy", + "result": True, + } + mock_list = MagicMock(return_value=["package1", "package2"]) + with patch.dict(win_appx.__salt__, {"appx.list": mock_list}): + result = win_appx.absent("remove_candy", "*candy*") + assert result == expected + + +def test_absent_test_true(): + expected = { + "comment": "The following apps will be removed:\n- king.com.CandyCrush", + "changes": {}, + "name": "remove_candy", + "result": None, + } + mock_list = MagicMock(return_value=["package1", "king.com.CandyCrush"]) + with patch.dict(win_appx.__salt__, {"appx.list": mock_list}): + with patch.dict(win_appx.__opts__, {"test": True}): + result = win_appx.absent("remove_candy", "*candy*") + assert result == expected + + +def test_absent_missing_after_test(): + expected = { + "comment": "No apps found matching query: *candy*", + "changes": {}, + "name": "remove_candy", + "result": False, + } + mock_list = MagicMock(return_value=["package1", "king.com.CandyCrush"]) + with patch.dict( + win_appx.__salt__, + { + "appx.list": mock_list, + "appx.remove": MagicMock(return_value=None), + }, + ): + with patch.dict(win_appx.__opts__, {"test": False}): + result = win_appx.absent("remove_candy", "*candy*") + assert result == expected + + +def test_absent(): + expected = { + "comment": "Removed apps matching query: *candy*", + "changes": {"old": ["king.com.CandyCrush"]}, + "name": "remove_candy", + "result": True, + } + mock_list = MagicMock( + side_effect=( + ["package1", "king.com.CandyCrush"], + ["package1"], + ), + ) + with patch.dict( + win_appx.__salt__, + { + "appx.list": mock_list, + "appx.remove": MagicMock(return_value=True), + }, + ): + with patch.dict(win_appx.__opts__, {"test": False}): + result = win_appx.absent("remove_candy", "*candy*") + assert result == expected diff --git a/tests/pytests/unit/utils/test_win_pwsh.py b/tests/pytests/unit/utils/test_win_pwsh.py new file mode 100644 index 00000000000..add11c97f19 --- /dev/null +++ b/tests/pytests/unit/utils/test_win_pwsh.py @@ -0,0 +1,106 @@ +import pytest + +import salt.utils.win_pwsh as win_pwsh +from salt.exceptions import CommandExecutionError +from tests.support.mock import MagicMock, patch + +pytestmark = [ + pytest.mark.windows_whitelisted, + pytest.mark.skip_unless_on_windows, +] + + +def test_run_dict(): + """ + Tests the run_dict function + """ + result = win_pwsh.run_dict("Get-Item C:\\Windows") + assert result["Name"] == "Windows" + assert result["FullName"] == "C:\\Windows" + + +def test_run_dict_json_string(): + """ + Tests the run_dict function with json string + """ + ret = { + "pid": 1, + "retcode": 0, + "stderr": "", + "stdout": '[{"HotFixID": "KB123456"}, {"HotFixID": "KB123457"}]', + } + mock_all = MagicMock(return_value=ret) + with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + result = win_pwsh.run_dict("Junk-Command") + assert result == [{"HotFixID": "KB123456"}, {"HotFixID": "KB123457"}] + + +def test_run_dict_empty_return(): + """ + Tests the run_dict function with json string + """ + ret = { + "pid": 1, + "retcode": 0, + "stderr": "", + "stdout": "", + } + mock_all = MagicMock(return_value=ret) + with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + result = win_pwsh.run_dict("Junk-Command") + assert result == {} + + +def test_run_dict_stderr(): + ret = { + "pid": 1, + "retcode": 1, + "stderr": "This is an error", + "stdout": "", + } + mock_all = MagicMock(return_value=ret) + with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + with pytest.raises(CommandExecutionError) as exc_info: + win_pwsh.run_dict("Junk-Command") + assert "This is an error" in exc_info.value.message + + +def test_run_dict_missing_retcode(): + ret = { + "pid": 1, + "stderr": "", + "stdout": "", + } + mock_all = MagicMock(return_value=ret) + with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + with pytest.raises(CommandExecutionError) as exc_info: + win_pwsh.run_dict("Junk-Command") + assert "Issue executing PowerShell" in exc_info.value.message + + +def test_run_dict_retcode_not_zero(): + ret = { + "pid": 1, + "retcode": 1, + "stderr": "", + "stdout": "", + } + mock_all = MagicMock(return_value=ret) + with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + with pytest.raises(CommandExecutionError) as exc_info: + win_pwsh.run_dict("Junk-Command") + assert "Issue executing PowerShell" in exc_info.value.message + + +def test_run_dict_invalid_json(): + ret = { + "pid": 1, + "retcode": 0, + "stderr": "", + "stdout": "Invalid Json", + } + mock_all = MagicMock(return_value=ret) + with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + with pytest.raises(CommandExecutionError) as exc_info: + win_pwsh.run_dict("Junk-Command") + assert "No JSON results from PowerShell" in exc_info.value.message diff --git a/tests/unit/modules/test_win_wusa.py b/tests/unit/modules/test_win_wusa.py deleted file mode 100644 index 6577ca8b762..00000000000 --- a/tests/unit/modules/test_win_wusa.py +++ /dev/null @@ -1,242 +0,0 @@ -""" -Test the win_wusa execution module -""" - -import pytest - -import salt.modules.win_wusa as win_wusa -from salt.exceptions import CommandExecutionError -from tests.support.mixins import LoaderModuleMockMixin -from tests.support.mock import MagicMock, patch -from tests.support.unit import TestCase - - -@pytest.mark.skip_unless_on_windows -class WinWusaTestCase(TestCase, LoaderModuleMockMixin): - """ - test the functions in the win_wusa execution module - """ - - def setup_loader_modules(self): - return {win_wusa: {}} - - def test_is_installed_false(self): - """ - test is_installed function when the KB is not installed - """ - mock_retcode = MagicMock(return_value=1) - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): - self.assertFalse(win_wusa.is_installed("KB123456")) - - def test_is_installed_true(self): - """ - test is_installed function when the KB is installed - """ - mock_retcode = MagicMock(return_value=0) - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): - self.assertTrue(win_wusa.is_installed("KB123456")) - - def test_list(self): - """ - test list function - """ - ret = { - "pid": 1, - "retcode": 0, - "stderr": "", - "stdout": '[{"HotFixID": "KB123456"}, {"HotFixID": "KB123457"}]', - } - mock_all = MagicMock(return_value=ret) - with patch.dict(win_wusa.__salt__, {"cmd.run_all": mock_all}): - expected = ["KB123456", "KB123457"] - returned = win_wusa.list() - self.assertListEqual(expected, returned) - - def test_install(self): - """ - test install function - """ - mock_retcode = MagicMock(return_value=0) - path = "C:\\KB123456.msu" - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): - self.assertTrue(win_wusa.install(path)) - mock_retcode.assert_called_once_with( - ["wusa.exe", path, "/quiet", "/norestart"], ignore_retcode=True - ) - - def test_install_restart(self): - """ - test install function with restart=True - """ - mock_retcode = MagicMock(return_value=0) - path = "C:\\KB123456.msu" - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): - self.assertTrue(win_wusa.install(path, restart=True)) - mock_retcode.assert_called_once_with( - ["wusa.exe", path, "/quiet", "/forcerestart"], ignore_retcode=True - ) - - def test_install_already_installed(self): - """ - test install function when KB already installed - """ - retcode = 2359302 - mock_retcode = MagicMock(return_value=retcode) - path = "C:\\KB123456.msu" - name = "KB123456.msu" - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): - with self.assertRaises(CommandExecutionError) as excinfo: - win_wusa.install(path) - mock_retcode.assert_called_once_with( - ["wusa.exe", path, "/quiet", "/norestart"], ignore_retcode=True - ) - self.assertEqual( - "{} is already installed. Additional info follows:\n\n{}".format( - name, retcode - ), - excinfo.exception.strerror, - ) - - def test_install_reboot_needed(self): - """ - test install function when KB need a reboot - """ - retcode = 3010 - mock_retcode = MagicMock(return_value=retcode) - path = "C:\\KB123456.msu" - name = "KB123456.msu" - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): - with self.assertRaises(CommandExecutionError) as excinfo: - win_wusa.install(path) - mock_retcode.assert_called_once_with( - ["wusa.exe", path, "/quiet", "/norestart"], ignore_retcode=True - ) - self.assertEqual( - "{} correctly installed but server reboot is needed to complete" - " installation. Additional info follows:\n\n{}".format(name, retcode), - excinfo.exception.strerror, - ) - - def test_install_error_87(self): - """ - test install function when error 87 returned - """ - retcode = 87 - mock_retcode = MagicMock(return_value=retcode) - path = "C:\\KB123456.msu" - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): - with self.assertRaises(CommandExecutionError) as excinfo: - win_wusa.install(path) - mock_retcode.assert_called_once_with( - ["wusa.exe", path, "/quiet", "/norestart"], ignore_retcode=True - ) - self.assertEqual( - "Unknown error. Additional info follows:\n\n{}".format(retcode), - excinfo.exception.strerror, - ) - - def test_install_error_other(self): - """ - test install function on other unknown error - """ - mock_retcode = MagicMock(return_value=1234) - path = "C:\\KB123456.msu" - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): - with self.assertRaises(CommandExecutionError) as excinfo: - win_wusa.install(path) - mock_retcode.assert_called_once_with( - ["wusa.exe", path, "/quiet", "/norestart"], ignore_retcode=True - ) - self.assertEqual("Unknown error: 1234", excinfo.exception.strerror) - - def test_uninstall_kb(self): - """ - test uninstall function passing kb name - """ - mock_retcode = MagicMock(return_value=0) - kb = "KB123456" - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}), patch( - "os.path.exists", MagicMock(return_value=False) - ): - self.assertTrue(win_wusa.uninstall(kb)) - mock_retcode.assert_called_once_with( - [ - "wusa.exe", - "/uninstall", - "/quiet", - "/kb:{}".format(kb[2:]), - "/norestart", - ], - ignore_retcode=True, - ) - - def test_uninstall_path(self): - """ - test uninstall function passing full path to .msu file - """ - mock_retcode = MagicMock(return_value=0) - path = "C:\\KB123456.msu" - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}), patch( - "os.path.exists", MagicMock(return_value=True) - ): - self.assertTrue(win_wusa.uninstall(path)) - mock_retcode.assert_called_once_with( - ["wusa.exe", "/uninstall", "/quiet", path, "/norestart"], - ignore_retcode=True, - ) - - def test_uninstall_path_restart(self): - """ - test uninstall function with full path and restart=True - """ - mock_retcode = MagicMock(return_value=0) - path = "C:\\KB123456.msu" - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}), patch( - "os.path.exists", MagicMock(return_value=True) - ): - self.assertTrue(win_wusa.uninstall(path, restart=True)) - mock_retcode.assert_called_once_with( - ["wusa.exe", "/uninstall", "/quiet", path, "/forcerestart"], - ignore_retcode=True, - ) - - def test_uninstall_already_uninstalled(self): - """ - test uninstall function when KB already uninstalled - """ - retcode = 2359303 - mock_retcode = MagicMock(return_value=retcode) - kb = "KB123456" - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}): - with self.assertRaises(CommandExecutionError) as excinfo: - win_wusa.uninstall(kb) - mock_retcode.assert_called_once_with( - [ - "wusa.exe", - "/uninstall", - "/quiet", - "/kb:{}".format(kb[2:]), - "/norestart", - ], - ignore_retcode=True, - ) - self.assertEqual( - "{} not installed. Additional info follows:\n\n{}".format(kb, retcode), - excinfo.exception.strerror, - ) - - def test_uninstall_path_error_other(self): - """ - test uninstall function with unknown error - """ - mock_retcode = MagicMock(return_value=1234) - path = "C:\\KB123456.msu" - with patch.dict(win_wusa.__salt__, {"cmd.retcode": mock_retcode}), patch( - "os.path.exists", MagicMock(return_value=True) - ), self.assertRaises(CommandExecutionError) as excinfo: - win_wusa.uninstall(path) - mock_retcode.assert_called_once_with( - ["wusa.exe", "/uninstall", "/quiet", path, "/norestart"], - ignore_retcode=True, - ) - self.assertEqual("Unknown error: 1234", excinfo.exception.strerror) From 78c8c11787cf14920e0e583e92be24f0815962c1 Mon Sep 17 00:00:00 2001 From: Twangboy Date: Wed, 24 May 2023 18:06:30 -0600 Subject: [PATCH 11/25] Add changelog --- changelog/64978.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/64978.added.md diff --git a/changelog/64978.added.md b/changelog/64978.added.md new file mode 100644 index 00000000000..15974414a26 --- /dev/null +++ b/changelog/64978.added.md @@ -0,0 +1 @@ +Added win_appx state and execution modules for managing Microsoft Store apps and deprovisioning them from systems From 28de02a2016a5402e2e9034a453b8408f707f005 Mon Sep 17 00:00:00 2001 From: Twangboy Date: Wed, 24 May 2023 18:19:01 -0600 Subject: [PATCH 12/25] Fix pre-commit --- salt/modules/win_appx.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/salt/modules/win_appx.py b/salt/modules/win_appx.py index 8812ee1d827..40ecb0910f2 100644 --- a/salt/modules/win_appx.py +++ b/salt/modules/win_appx.py @@ -50,7 +50,7 @@ import salt.utils.win_reg log = logging.getLogger(__name__) CURRENT_VERSION_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion" -DEPROVISIONED_KEY = fr"{CURRENT_VERSION_KEY}\Appx\AppxAllUserStore\Deprovisioned" +DEPROVISIONED_KEY = rf"{CURRENT_VERSION_KEY}\Appx\AppxAllUserStore\Deprovisioned" __virtualname__ = "appx" __func_alias__ = {"list_": "list"} @@ -83,7 +83,9 @@ def _pkg_list(raw, field="Name"): return result -def list_(query=None, field="Name", include_store=False, frameworks=False, bundles=True): +def list_( + query=None, field="Name", include_store=False, frameworks=False, bundles=True +): """ Get a list of Microsoft Store packages installed on the system. @@ -168,7 +170,9 @@ def list_(query=None, field="Name", include_store=False, frameworks=False, bundl cmd.append("Where-Object -Property NonRemovable -eq $false") if not field: cmd.append("Sort-Object Name") - cmd.append("Select Name, Version, PackageFullName, PackageFamilyName, IsBundle, IsFramework") + cmd.append( + "Select Name, Version, PackageFullName, PackageFamilyName, IsBundle, IsFramework" + ) return salt.utils.win_pwsh.run_dict(" | ".join(cmd)) else: cmd.append(f"Sort-Object {field}") @@ -261,7 +265,9 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F if deprovision_only: log.debug("Deprovisioning package: %s", remove_name) - remove_cmd = f"Remove-AppxProvisionedPackage -Online -PackageName {remove_name}" + remove_cmd = ( + f"Remove-AppxProvisionedPackage -Online -PackageName {remove_name}" + ) else: log.debug("Removing package: %s", remove_name) remove_cmd = f"Remove-AppxPackage -AllUsers -Package {remove_name}" From a0ee6df8bc1154b45d06414b3a89fffef3bf8a6f Mon Sep 17 00:00:00 2001 From: Twangboy Date: Wed, 24 May 2023 22:32:55 -0600 Subject: [PATCH 13/25] Add missing doc stubs, use f-strings, add CLI examples --- doc/ref/modules/all/salt.modules.win_appx.rst | 5 ++ doc/ref/states/all/salt.states.win_appx.rst | 5 ++ salt/modules/win_appx.py | 14 +++- salt/modules/win_dism.py | 74 +++++++++---------- salt/states/win_appx.py | 6 +- salt/states/win_servermanager.py | 14 ++-- 6 files changed, 71 insertions(+), 47 deletions(-) create mode 100644 doc/ref/modules/all/salt.modules.win_appx.rst create mode 100644 doc/ref/states/all/salt.states.win_appx.rst diff --git a/doc/ref/modules/all/salt.modules.win_appx.rst b/doc/ref/modules/all/salt.modules.win_appx.rst new file mode 100644 index 00000000000..8beafe322b3 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.win_appx.rst @@ -0,0 +1,5 @@ +salt.modules.win_appx +===================== + +.. automodule:: salt.modules.win_appx + :members: diff --git a/doc/ref/states/all/salt.states.win_appx.rst b/doc/ref/states/all/salt.states.win_appx.rst new file mode 100644 index 00000000000..eb46714f5b8 --- /dev/null +++ b/doc/ref/states/all/salt.states.win_appx.rst @@ -0,0 +1,5 @@ +salt.states.win_appx +==================== + +.. automodule:: salt.states.win_appx + :members: diff --git a/salt/modules/win_appx.py b/salt/modules/win_appx.py index 40ecb0910f2..6193872a33f 100644 --- a/salt/modules/win_appx.py +++ b/salt/modules/win_appx.py @@ -236,7 +236,7 @@ def remove(query=None, include_store=False, frameworks=False, deprovision_only=F .. code-block:: bash - salt + salt "*" appx.remove *candy* """ packages = list_( query=query, @@ -309,6 +309,12 @@ def list_deprovisioned(query=None): Returns: list: A list of packages matching the query criteria + + CLI Example: + + .. code-block:: bash + + salt "*" appx.list_deprovisioned *zune* """ ret = salt.utils.win_reg.list_keys(hive="HKLM", key=f"{DEPROVISIONED_KEY}") if query is None: @@ -341,6 +347,12 @@ def install(package): Returns: bool: ``True`` if successful, otherwise ``False`` + + CLI Example: + + .. code-block:: bash + + salt "*" appx.install "C:\\Temp\\Microsoft.ZuneMusic.msixbundle" """ # I don't see a way to make the app installed for existing users on # the system. The best we can do is provision the package for new diff --git a/salt/modules/win_dism.py b/salt/modules/win_dism.py index c94e38e3dd3..d5188bd3f60 100644 --- a/salt/modules/win_dism.py +++ b/salt/modules/win_dism.py @@ -52,14 +52,14 @@ def _get_components(type_regex, plural_type, install_value, image=None): cmd = [ bin_dism, "/English", - "/Image:{}".format(image) if image else "/Online", - "/Get-{}".format(plural_type), + f"/Image:{image}" if image else "/Online", + f"/Get-{plural_type}", ] out = __salt__["cmd.run"](cmd) if install_value: - pattern = r"{} : (.*)\r\n.*State : {}\r\n".format(type_regex, install_value) + pattern = rf"{type_regex} : (.*)\r\n.*State : {install_value}\r\n" else: - pattern = r"{} : (.*)\r\n.*".format(type_regex, install_value) + pattern = rf"{type_regex} : (.*)\r\n.*" capabilities = re.findall(pattern, out, re.MULTILINE) capabilities.sort() return capabilities @@ -98,19 +98,19 @@ def add_capability( if salt.utils.versions.version_cmp(__grains__["osversion"], "10") == -1: raise NotImplementedError( "`install_capability` is not available on this version of Windows: " - "{}".format(__grains__["osversion"]) + f'{__grains__["osversion"]}' ) cmd = [ bin_dism, "/Quiet", - "/Image:{}".format(image) if image else "/Online", + f"/Image:{image}" if image else "/Online", "/Add-Capability", - "/CapabilityName:{}".format(capability), + f"/CapabilityName:{capability}", ] if source: - cmd.append("/Source:{}".format(source)) + cmd.append(f"/Source:{source}") if limit_access: cmd.append("/LimitAccess") if not restart: @@ -146,15 +146,15 @@ def remove_capability(capability, image=None, restart=False): if salt.utils.versions.version_cmp(__grains__["osversion"], "10") == -1: raise NotImplementedError( "`uninstall_capability` is not available on this version of " - "Windows: {}".format(__grains__["osversion"]) + f'Windows: {__grains__["osversion"]}' ) cmd = [ bin_dism, "/Quiet", - "/Image:{}".format(image) if image else "/Online", + f"/Image:{image}" if image else "/Online", "/Remove-Capability", - "/CapabilityName:{}".format(capability), + f"/CapabilityName:{capability}", ] if not restart: @@ -188,13 +188,13 @@ def get_capabilities(image=None): if salt.utils.versions.version_cmp(__grains__["osversion"], "10") == -1: raise NotImplementedError( "`installed_capabilities` is not available on this version of " - "Windows: {}".format(__grains__["osversion"]) + f'Windows: {__grains__["osversion"]}' ) cmd = [ bin_dism, "/English", - "/Image:{}".format(image) if image else "/Online", + f"/Image:{image}" if image else "/Online", "/Get-Capabilities", ] out = __salt__["cmd.run"](cmd) @@ -231,7 +231,7 @@ def installed_capabilities(image=None): if salt.utils.versions.version_cmp(__grains__["osversion"], "10") == -1: raise NotImplementedError( "`installed_capabilities` is not available on this version of " - "Windows: {}".format(__grains__["osversion"]) + f'Windows: {__grains__["osversion"]}' ) return _get_components("Capability Identity", "Capabilities", "Installed") @@ -261,7 +261,7 @@ def available_capabilities(image=None): if salt.utils.versions.version_cmp(__grains__["osversion"], "10") == -1: raise NotImplementedError( "`installed_capabilities` is not available on this version of " - "Windows: {}".format(__grains__["osversion"]) + f'Windows: {__grains__["osversion"]}' ) return _get_components("Capability Identity", "Capabilities", "Not Present") @@ -306,14 +306,14 @@ def add_feature( cmd = [ bin_dism, "/Quiet", - "/Image:{}".format(image) if image else "/Online", + f"/Image:{image}" if image else "/Online", "/Enable-Feature", - "/FeatureName:{}".format(feature), + f"/FeatureName:{feature}", ] if package: - cmd.append("/PackageName:{}".format(package)) + cmd.append(f"/PackageName:{package}") if source: - cmd.append("/Source:{}".format(source)) + cmd.append(f"/Source:{source}") if limit_access: cmd.append("/LimitAccess") if enable_parent: @@ -349,9 +349,9 @@ def remove_feature(feature, remove_payload=False, image=None, restart=False): cmd = [ bin_dism, "/Quiet", - "/Image:{}".format(image) if image else "/Online", + f"/Image:{image}" if image else "/Online", "/Disable-Feature", - "/FeatureName:{}".format(feature), + f"/FeatureName:{feature}", ] if remove_payload: @@ -397,15 +397,15 @@ def get_features(package=None, image=None): cmd = [ bin_dism, "/English", - "/Image:{}".format(image) if image else "/Online", + f"/Image:{image}" if image else "/Online", "/Get-Features", ] if package: if "~" in package: - cmd.append("/PackageName:{}".format(package)) + cmd.append(f"/PackageName:{package}") else: - cmd.append("/PackagePath:{}".format(package)) + cmd.append(f"/PackagePath:{package}") out = __salt__["cmd.run"](cmd) @@ -499,9 +499,9 @@ def add_package( cmd = [ bin_dism, "/Quiet", - "/Image:{}".format(image) if image else "/Online", + f"/Image:{image}" if image else "/Online", "/Add-Package", - "/PackagePath:{}".format(package), + f"/PackagePath:{package}", ] if ignore_check: @@ -554,9 +554,9 @@ def add_provisioned_package(package, image=None, restart=False): cmd = [ bin_dism, "/Quiet", - "/Image:{}".format(image) if image else "/Online", + f"/Image:{image}" if image else "/Online", "/Add-ProvisionedAppxPackage", - "/PackagePath:{}".format(package), + f"/PackagePath:{package}", "/SkipLicense", ] @@ -597,7 +597,7 @@ def remove_package(package, image=None, restart=False): cmd = [ bin_dism, "/Quiet", - "/Image:{}".format(image) if image else "/Online", + f"/Image:{image}" if image else "/Online", "/Remove-Package", ] @@ -605,9 +605,9 @@ def remove_package(package, image=None, restart=False): cmd.append("/NoRestart") if "~" in package: - cmd.append("/PackageName:{}".format(package)) + cmd.append(f"/PackageName:{package}") else: - cmd.append("/PackagePath:{}".format(package)) + cmd.append(f"/PackagePath:{package}") return __salt__["cmd.run_all"](cmd) @@ -639,9 +639,9 @@ def get_kb_package_name(kb, image=None): salt '*' dism.get_kb_package_name 1231231 """ packages = installed_packages(image=image) - search = kb.upper() if kb.lower().startswith("kb") else "KB{}".format(kb) + search = kb.upper() if kb.lower().startswith("kb") else f"KB{kb}" for package in packages: - if "_{}~".format(search) in package: + if f"_{search}~" in package: return package return None @@ -677,7 +677,7 @@ def remove_kb(kb, image=None, restart=False): """ pkg_name = get_kb_package_name(kb=kb, image=image) if pkg_name is None: - msg = "{} not installed".format(kb) + msg = f"{kb} not installed" raise CommandExecutionError(msg) log.debug("Found: %s", pkg_name) return remove_package(package=pkg_name, image=image, restart=restart) @@ -760,14 +760,14 @@ def package_info(package, image=None): cmd = [ bin_dism, "/English", - "/Image:{}".format(image) if image else "/Online", + f"/Image:{image}" if image else "/Online", "/Get-PackageInfo", ] if "~" in package: - cmd.append("/PackageName:{}".format(package)) + cmd.append(f"/PackageName:{package}") else: - cmd.append("/PackagePath:{}".format(package)) + cmd.append(f"/PackagePath:{package}") out = __salt__["cmd.run_all"](cmd) diff --git a/salt/states/win_appx.py b/salt/states/win_appx.py index 0f3afc5bdbd..dc1bc58db1f 100644 --- a/salt/states/win_appx.py +++ b/salt/states/win_appx.py @@ -81,9 +81,11 @@ def absent(name, query, include_store=False, frameworks=False, deprovision_only= CLI Example: - .. code-block:: bash + .. code-block:: yaml - salt + remove_candy_crush: + appx.absent: + query: *candy* """ ret = {"name": name, "result": True, "comment": "", "changes": {}} diff --git a/salt/states/win_servermanager.py b/salt/states/win_servermanager.py index 2e77012df18..6f91f166742 100644 --- a/salt/states/win_servermanager.py +++ b/salt/states/win_servermanager.py @@ -28,7 +28,7 @@ def installed( restart=False, source=None, exclude=None, - **kwargs + **kwargs, ): """ Install the windows feature. To install a single feature, use the ``name`` @@ -137,7 +137,7 @@ def installed( for feature in features: if feature not in old: - ret["changes"][feature] = "Will be installed recurse={}".format(recurse) + ret["changes"][feature] = f"Will be installed recurse={recurse}" elif recurse: ret["changes"][feature] = "Already installed but might install sub-features" else: @@ -168,13 +168,13 @@ def installed( for feature in status["Features"]: # Features that failed to install or be removed if not status["Features"][feature].get("Success", True): - fail_feat.append("- {}".format(feature)) + fail_feat.append(f"- {feature}") # Features that installed elif "(exclude)" not in status["Features"][feature]["Message"]: - new_feat.append("- {}".format(feature)) + new_feat.append(f"- {feature}") # Show items that were removed because they were part of `exclude` elif "(exclude)" in status["Features"][feature]["Message"]: - rem_feat.append("- {}".format(feature)) + rem_feat.append(f"- {feature}") if fail_feat: fail_feat.insert(0, "Failed to install the following:") @@ -302,10 +302,10 @@ def removed(name, features=None, remove_payload=False, restart=False): # feature is already uninstalled if not status["Features"][feature].get("Success", True): # Show items that failed to uninstall - fail_feat.append("- {}".format(feature)) + fail_feat.append(f"- {feature}") else: # Show items that uninstalled - rem_feat.append("- {}".format(feature)) + rem_feat.append(f"- {feature}") if fail_feat: fail_feat.insert(0, "Failed to remove the following:") From 636eb42a4a8003a7e91c66470d17eb50d9a7ed3c Mon Sep 17 00:00:00 2001 From: Twangboy Date: Thu, 25 May 2023 00:26:54 -0600 Subject: [PATCH 14/25] Add appx to doctree --- doc/ref/modules/all/index.rst | 1 + doc/ref/states/all/index.rst | 1 + 2 files changed, 2 insertions(+) diff --git a/doc/ref/modules/all/index.rst b/doc/ref/modules/all/index.rst index e5223cdf58b..b9a9c6fddad 100644 --- a/doc/ref/modules/all/index.rst +++ b/doc/ref/modules/all/index.rst @@ -498,6 +498,7 @@ execution modules vmctl vsphere webutil + win_appx win_auditpol win_autoruns win_certutil diff --git a/doc/ref/states/all/index.rst b/doc/ref/states/all/index.rst index 00355c022ff..f9f67257bb6 100644 --- a/doc/ref/states/all/index.rst +++ b/doc/ref/states/all/index.rst @@ -320,6 +320,7 @@ state modules virt virtualenv_mod webutil + win_appx win_certutil win_dacl win_dism From f8c54ddcfeb1a5fb7e723ed8203cd3dfc305f103 Mon Sep 17 00:00:00 2001 From: Twangboy Date: Thu, 25 May 2023 00:36:48 -0600 Subject: [PATCH 15/25] Change wording in docstring to fix literal start-string error --- salt/states/win_dism.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/states/win_dism.py b/salt/states/win_dism.py index 45bde06ad1d..0423d78fd0b 100644 --- a/salt/states/win_dism.py +++ b/salt/states/win_dism.py @@ -2,7 +2,7 @@ Installing of Windows features using DISM ========================================= -Install Windows features/capabilities/packages with DISM +Install Windows features, capabilities, and packages with DISM .. code-block:: yaml From 1bcaac0479188942e8e2335e512893d8828429e0 Mon Sep 17 00:00:00 2001 From: Twangboy Date: Fri, 26 May 2023 12:03:43 -0600 Subject: [PATCH 16/25] Replace tildes with dashes --- salt/states/win_dism.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/states/win_dism.py b/salt/states/win_dism.py index 0423d78fd0b..5d51854654b 100644 --- a/salt/states/win_dism.py +++ b/salt/states/win_dism.py @@ -6,7 +6,7 @@ Install Windows features, capabilities, and packages with DISM .. code-block:: yaml - Language.Basic~~~en-US~0.0.1.0: + Language.Basic-en-US-0.0.1.0: dism.capability_installed NetFx3: From b1ae2106c225e8fea2d46afe9d76096cfb2cd14f Mon Sep 17 00:00:00 2001 From: Twangboy Date: Fri, 26 May 2023 12:50:51 -0600 Subject: [PATCH 17/25] Try code-block bash --- salt/modules/win_dism.py | 2 +- salt/states/win_dism.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/salt/modules/win_dism.py b/salt/modules/win_dism.py index d5188bd3f60..d8bcebf5277 100644 --- a/salt/modules/win_dism.py +++ b/salt/modules/win_dism.py @@ -20,7 +20,7 @@ __virtualname__ = "dism" # host machine. On 32bit boxes that will always be System32. On 64bit boxes that # are running 64bit salt that will always be System32. On 64bit boxes that are # running 32bit salt the 64bit dism will be found in SysNative -# Sysnative is a virtual folder, a special alias, that can be used to access the +# SysNative is a virtual folder, a special alias, that can be used to access the # 64-bit System32 folder from a 32-bit application try: # This does not apply to Non-Windows platforms diff --git a/salt/states/win_dism.py b/salt/states/win_dism.py index 5d51854654b..b6c2ac83cd6 100644 --- a/salt/states/win_dism.py +++ b/salt/states/win_dism.py @@ -4,9 +4,9 @@ Installing of Windows features using DISM Install Windows features, capabilities, and packages with DISM -.. code-block:: yaml +.. code-block:: bash - Language.Basic-en-US-0.0.1.0: + Language.Basic~~~en-US~0.0.1.0: dism.capability_installed NetFx3: From 6ce69654c7b5668c9c6e4d2b0c8e55c610140751 Mon Sep 17 00:00:00 2001 From: Twangboy Date: Mon, 29 May 2023 09:49:02 -0600 Subject: [PATCH 18/25] Reformat docs to find sphinx error... I think I found it --- salt/states/win_dism.py | 189 +++++++++++++++++++++++++--------------- 1 file changed, 121 insertions(+), 68 deletions(-) diff --git a/salt/states/win_dism.py b/salt/states/win_dism.py index b6c2ac83cd6..b8b18fe21e4 100644 --- a/salt/states/win_dism.py +++ b/salt/states/win_dism.py @@ -40,18 +40,28 @@ def capability_installed( Install a DISM capability Args: - name (str): The capability to install - source (str): The optional source of the capability - limit_access (bool): Prevent DISM from contacting Windows Update for - online images - 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 + + name (str): + The capability to install + + source (str): + The optional source of the capability + + limit_access (bool): + Prevent DISM from contacting Windows Update for online images + + 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 Example: + Run ``dism.available_capabilities`` to get a list of available - capabilities. This will help you get the proper name to use. + capabilities. This will help you get the proper name to use .. code-block:: yaml @@ -95,16 +105,22 @@ def capability_removed(name, image=None, restart=False): Uninstall a DISM capability Args: - name (str): The capability to uninstall - 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 + + name (str): + The capability to uninstall + + 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: + Run ``dism.installed_capabilities`` to get a list of installed - capabilities. This will help you get the proper name to use. + capabilities. This will help you get the proper name to use .. code-block:: yaml @@ -156,23 +172,36 @@ def feature_installed( Install a DISM feature Args: - name (str): The feature in which to install - package (Optional[str]): The parent package for the feature. You do not - have to specify the package if it is the Windows Foundation Package. - Otherwise, use package to specify the parent package of the feature - source (str): The optional source of the feature - limit_access (bool): Prevent DISM from contacting Windows Update for - online images - enable_parent (Optional[bool]): True will enable all parent features of - the specified feature - 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 + + name (str): + The feature in which to install + + package (Optional[str]): + The parent package for the feature. You do not have to specify the + package if it is the Windows Foundation Package. Otherwise, use + package to specify the parent package of the feature + + source (str): + The optional source of the feature + + limit_access (bool): + Prevent DISM from contacting Windows Update for online images + + enable_parent (Optional[bool]): + ``True`` will enable all parent features of the specified feature + + 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 Example: + Run ``dism.available_features`` to get a list of available features. - This will help you get the proper name to use. + This will help you get the proper name to use .. code-block:: yaml @@ -218,18 +247,26 @@ def feature_removed(name, remove_payload=False, image=None, restart=False): Disables a feature. Args: - name (str): The feature to disable - remove_payload (Optional[bool]): Remove the feature's payload. Must - supply source when enabling in the future. - 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 + + name (str): + The feature to disable + + remove_payload (Optional[bool]): + Remove the feature's payload. Must supply source when enabling in + the future. + + 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: + Run ``dism.installed_features`` to get a list of installed features. - This will help you get the proper name to use. + This will help you get the proper name to use .. code-block:: yaml @@ -276,16 +313,24 @@ def package_installed( Install a package. Args: - name (str): The package to install. Can be a .cab file, a .msu file, - or a folder - ignore_check (Optional[bool]): Skip installation of the package if the - applicability checks fail - prevent_pending (Optional[bool]): Skip the installation of the package - if there are pending online actions - 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 + + name (str): + The package to install. Can be a .cab file, a .msu file, or a folder + + ignore_check (Optional[bool]): + Skip installation of the package if the applicability checks fail + + prevent_pending (Optional[bool]): + Skip the installation of the package if there are pending online + actions + + 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 Example: @@ -357,12 +402,12 @@ def provisioned_package_installed(name, image=None, restart=False): The name of the file before the file extension must match the name of the package after it is installed. This name can be found by - running ``dism.provisioned_packages". + running ``dism.provisioned_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``. + Default is ``None`` restart (Optional[bool]): Reboot the machine if required by the installation. Default is @@ -375,7 +420,6 @@ def provisioned_package_installed(name, image=None, restart=False): install_windows_media_player: dism.provisioned_package_installed: - name: C:\\Packages\\Microsoft.ZuneVideo_2019.22091.10036.0_neutral_~_8wekyb3d8bbwe.Msixbundle - """ ret = {"name": name, "result": True, "comment": "", "changes": {}} @@ -425,15 +469,20 @@ def package_removed(name, image=None, restart=False): Uninstall a package Args: - name (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`` - 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 + + name (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`` + + 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: @@ -504,13 +553,17 @@ def kb_removed(name, image=None, restart=False): .. versionadded:: 3006.0 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 + + 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: From 4e7e6d8b3d9680d8328c4750dc924d2a62701da7 Mon Sep 17 00:00:00 2001 From: Twangboy Date: Mon, 29 May 2023 11:56:39 -0600 Subject: [PATCH 19/25] Fix docs for appx.absent state module --- salt/modules/win_appx.py | 4 ++-- salt/states/win_appx.py | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/salt/modules/win_appx.py b/salt/modules/win_appx.py index 6193872a33f..8a6a22549f6 100644 --- a/salt/modules/win_appx.py +++ b/salt/modules/win_appx.py @@ -13,7 +13,7 @@ they be upgraded. An app removed with this module can only be re-provisioned on the machine, but it can't be re-installed for all users. Also, once a package has been deprovisioned, the only way to reinstall it is to download the package. This is -difficult. I've outlined the steps below: +difficult. The steps are outlined below: 1. Obtain the Microsoft Store URL for the app: - Open the page for the app in the Microsoft Store @@ -25,7 +25,7 @@ difficult. I've outlined the steps below: - Ensure Retail is selected in the 2nd dropdown - Click the checkmark button -This should give you a list of URLs for the package and all dependencies for all +This should return a list of URLs for the package and all dependencies for all architectures. Download the package and all dependencies for your system architecture. These will usually have one of the following file extensions: diff --git a/salt/states/win_appx.py b/salt/states/win_appx.py index dc1bc58db1f..75d90224b51 100644 --- a/salt/states/win_appx.py +++ b/salt/states/win_appx.py @@ -43,12 +43,13 @@ def absent(name, query, include_store=False, frameworks=False, deprovision_only= string matches multiple packages, they will all be removed. Here are some example strings: - - ``*teams*`` - Remove Microsoft Teams - - ``*zune*`` - Remove Windows Media Player and ZuneVideo - - ``*zuneMusic*`` - Only remove Windows Media Player - - ``*xbox*`` - Remove all xbox packages, there are 5 by default - - ``*`` - Remove everything but the Microsoft Store, unless - ``include_store=True`` + | string | description | + | --------------- | ----------- | + | ``*teams*`` | Remove Microsoft Teams | + | ``*zune*`` | Remove Windows Media Player and Zune Video | + | ``*zuneMusic*`` | Only remove Windows Media Player | + | ``*xbox*`` | Remove all xBox packages, there are 5 by default + | ``*`` | Remove everything but the Microsoft Store, unless ``include_store=True`` | .. note:: Use the ``appx.list`` function to make sure your query is @@ -85,7 +86,7 @@ def absent(name, query, include_store=False, frameworks=False, deprovision_only= remove_candy_crush: appx.absent: - query: *candy* + - query: "*candy*" """ ret = {"name": name, "result": True, "comment": "", "changes": {}} From 499c8ea103ece26f63b6245f64c738652f215e6f Mon Sep 17 00:00:00 2001 From: twangboy Date: Mon, 5 Jun 2023 15:12:41 -0600 Subject: [PATCH 20/25] Add versionadded --- salt/modules/win_appx.py | 2 ++ salt/modules/win_dism.py | 6 +++++- salt/states/win_appx.py | 2 ++ salt/states/win_dism.py | 4 +++- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/salt/modules/win_appx.py b/salt/modules/win_appx.py index 8a6a22549f6..38c1c1423e2 100644 --- a/salt/modules/win_appx.py +++ b/salt/modules/win_appx.py @@ -2,6 +2,8 @@ Manage provisioned apps ======================= +.. versionadded:: 3007.0 + Provisioned apps are part of the image and are installed for every user the first time the user logs on. Provisioned apps are also updated and sometimes reinstalled when the system is updated. diff --git a/salt/modules/win_dism.py b/salt/modules/win_dism.py index d8bcebf5277..4f4aba650c3 100644 --- a/salt/modules/win_dism.py +++ b/salt/modules/win_dism.py @@ -518,7 +518,9 @@ def add_provisioned_package(package, image=None, restart=False): """ Provision a package using DISM. A provisioned package will install for new users on the system. It will also be reinstalled on each user if the system - if updated. + is updated. + + .. versionadded:: 3007.0 Args: @@ -713,6 +715,8 @@ def provisioned_packages(image=None): """ List the packages installed on the system + .. versionadded:: 3007.0 + Args: image (Optional[str]): The path to the root directory of an offline Windows image. If `None` is passed, the running operating system is diff --git a/salt/states/win_appx.py b/salt/states/win_appx.py index 75d90224b51..e3e1e9a3fb6 100644 --- a/salt/states/win_appx.py +++ b/salt/states/win_appx.py @@ -1,6 +1,8 @@ """ Manage Microsoft Store apps on Windows. Removing an app with this modules will deprovision the app from the online Windows image. + +.. versionadded:: 3007.0 """ import fnmatch import logging diff --git a/salt/states/win_dism.py b/salt/states/win_dism.py index b8b18fe21e4..3acdbe23b2c 100644 --- a/salt/states/win_dism.py +++ b/salt/states/win_dism.py @@ -389,7 +389,9 @@ def package_installed( def provisioned_package_installed(name, image=None, restart=False): """ - Install a package. + Provision a package on a Windows image. + + .. versionadded:: 3007.0 Args: From 6aab93fcf2ca5a7401f0b3198da3db416a004d69 Mon Sep 17 00:00:00 2001 From: twangboy Date: Mon, 5 Jun 2023 15:16:26 -0600 Subject: [PATCH 21/25] Don't log powershell command --- salt/utils/win_pwsh.py | 1 - 1 file changed, 1 deletion(-) diff --git a/salt/utils/win_pwsh.py b/salt/utils/win_pwsh.py index 53d07322e47..d17d6905993 100644 --- a/salt/utils/win_pwsh.py +++ b/salt/utils/win_pwsh.py @@ -43,7 +43,6 @@ def run_dict(cmd, cwd=None): cmd = f"{cmd} | ConvertTo-Json" if "progresspreference" not in cmd.lower(): cmd = f"$ProgressPreference = 'SilentlyContinue'; {cmd}" - log.debug("PowerShell: %s", cmd) ret = __salt__["cmd.run_all"](cmd, shell="powershell", cwd=cwd) if "pid" in ret: From 998dd6aa312f9f79f49f66a3fa7491523f1ff7e9 Mon Sep 17 00:00:00 2001 From: twangboy Date: Mon, 5 Jun 2023 15:28:43 -0600 Subject: [PATCH 22/25] Don't use dunder modules in a salt util --- salt/utils/win_pwsh.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/salt/utils/win_pwsh.py b/salt/utils/win_pwsh.py index d17d6905993..b0e76d2aacf 100644 --- a/salt/utils/win_pwsh.py +++ b/salt/utils/win_pwsh.py @@ -1,13 +1,8 @@ -import logging - import salt.modules.cmdmod import salt.utils.json import salt.utils.platform from salt.exceptions import CommandExecutionError -__salt__ = {"cmd.run_all": salt.modules.cmdmod.run_all} -log = logging.getLogger(__name__) - __virtualname__ = "win_pwsh" @@ -43,7 +38,7 @@ def run_dict(cmd, cwd=None): cmd = f"{cmd} | ConvertTo-Json" if "progresspreference" not in cmd.lower(): cmd = f"$ProgressPreference = 'SilentlyContinue'; {cmd}" - ret = __salt__["cmd.run_all"](cmd, shell="powershell", cwd=cwd) + ret = salt.modules.cmdmod.run_all(cmd=cmd, shell="powershell", cwd=cwd) if "pid" in ret: del ret["pid"] From faf1f267c64ca83ac4922c4ab2e11ca2d8f8cdc2 Mon Sep 17 00:00:00 2001 From: twangboy Date: Mon, 5 Jun 2023 15:34:46 -0600 Subject: [PATCH 23/25] Fix tests --- tests/pytests/unit/utils/test_win_pwsh.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/pytests/unit/utils/test_win_pwsh.py b/tests/pytests/unit/utils/test_win_pwsh.py index add11c97f19..453954627dc 100644 --- a/tests/pytests/unit/utils/test_win_pwsh.py +++ b/tests/pytests/unit/utils/test_win_pwsh.py @@ -30,7 +30,7 @@ def test_run_dict_json_string(): "stdout": '[{"HotFixID": "KB123456"}, {"HotFixID": "KB123457"}]', } mock_all = MagicMock(return_value=ret) - with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + with patch("salt.modules.cmdmod.run_all", mock_all): result = win_pwsh.run_dict("Junk-Command") assert result == [{"HotFixID": "KB123456"}, {"HotFixID": "KB123457"}] @@ -46,7 +46,7 @@ def test_run_dict_empty_return(): "stdout": "", } mock_all = MagicMock(return_value=ret) - with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + with patch("salt.modules.cmdmod.run_all", mock_all): result = win_pwsh.run_dict("Junk-Command") assert result == {} @@ -59,7 +59,7 @@ def test_run_dict_stderr(): "stdout": "", } mock_all = MagicMock(return_value=ret) - with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + with patch("salt.modules.cmdmod.run_all", mock_all): with pytest.raises(CommandExecutionError) as exc_info: win_pwsh.run_dict("Junk-Command") assert "This is an error" in exc_info.value.message @@ -72,7 +72,7 @@ def test_run_dict_missing_retcode(): "stdout": "", } mock_all = MagicMock(return_value=ret) - with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + with patch("salt.modules.cmdmod.run_all", mock_all): with pytest.raises(CommandExecutionError) as exc_info: win_pwsh.run_dict("Junk-Command") assert "Issue executing PowerShell" in exc_info.value.message @@ -86,7 +86,7 @@ def test_run_dict_retcode_not_zero(): "stdout": "", } mock_all = MagicMock(return_value=ret) - with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + with patch("salt.modules.cmdmod.run_all", mock_all): with pytest.raises(CommandExecutionError) as exc_info: win_pwsh.run_dict("Junk-Command") assert "Issue executing PowerShell" in exc_info.value.message @@ -100,7 +100,7 @@ def test_run_dict_invalid_json(): "stdout": "Invalid Json", } mock_all = MagicMock(return_value=ret) - with patch.dict(win_pwsh.__salt__, {"cmd.run_all": mock_all}): + with patch("salt.modules.cmdmod.run_all", mock_all): with pytest.raises(CommandExecutionError) as exc_info: win_pwsh.run_dict("Junk-Command") assert "No JSON results from PowerShell" in exc_info.value.message From ef40721949b331b51bdc86ab4e6582bf9f5ee402 Mon Sep 17 00:00:00 2001 From: twangboy Date: Mon, 5 Jun 2023 17:48:48 -0600 Subject: [PATCH 24/25] Py3.8 updates --- salt/modules/win_servermanager.py | 2 +- salt/modules/win_wusa.py | 16 +++--- salt/states/win_dism.py | 63 +++++++++++---------- salt/utils/win_pwsh.py | 4 +- tests/pytests/unit/modules/test_win_wusa.py | 4 +- 5 files changed, 44 insertions(+), 45 deletions(-) diff --git a/salt/modules/win_servermanager.py b/salt/modules/win_servermanager.py index 81e8e8e5044..ab714b30e37 100644 --- a/salt/modules/win_servermanager.py +++ b/salt/modules/win_servermanager.py @@ -194,7 +194,7 @@ def install(feature, recurse=False, restart=False, source=None, exclude=None): shlex.quote(feature), management_tools, "-IncludeAllSubFeature" if recurse else "", - "" if source is None else "-Source {}".format(source), + "" if source is None else f"-Source {source}", ) out = salt.utils.win_pwsh.run_dict(cmd) diff --git a/salt/modules/win_wusa.py b/salt/modules/win_wusa.py index f0c06edddb3..1a25fed3fa6 100644 --- a/salt/modules/win_wusa.py +++ b/salt/modules/win_wusa.py @@ -57,7 +57,7 @@ def is_installed(name): """ return ( __salt__["cmd.retcode"]( - cmd="Get-HotFix -Id {}".format(name), + cmd=f"Get-HotFix -Id {name}", shell="powershell", ignore_retcode=True, ) @@ -105,17 +105,17 @@ def install(path, restart=False): # Check the ret_code file_name = os.path.basename(path) errors = { - 2359302: "{} is already installed".format(file_name), + 2359302: f"{file_name} is already installed", 3010: ( - "{} correctly installed but server reboot is needed to complete" - " installation".format(file_name) + f"{file_name} correctly installed but server reboot is needed to " + f"complete installation" ), 87: "Unknown error", } if ret_code in errors: raise CommandExecutionError(errors[ret_code], ret_code) elif ret_code: - raise CommandExecutionError("Unknown error: {}".format(ret_code)) + raise CommandExecutionError(f"Unknown error: {ret_code}") return True @@ -170,14 +170,14 @@ def uninstall(path, restart=False): # If you pass /quiet and specify /kb, you'll always get retcode 87 if there # is an error. Use the actual file to get a more descriptive error errors = { - -2145116156: "{} does not support uninstall".format(kb), - 2359303: "{} not installed".format(kb), + -2145116156: f"{kb} does not support uninstall", + 2359303: f"{kb} not installed", 87: "Unknown error. Try specifying an .msu file", } if ret_code in errors: raise CommandExecutionError(errors[ret_code], ret_code) elif ret_code: - raise CommandExecutionError("Unknown error: {}".format(ret_code)) + raise CommandExecutionError(f"Unknown error: {ret_code}") return True diff --git a/salt/states/win_dism.py b/salt/states/win_dism.py index 3acdbe23b2c..e6958e467bd 100644 --- a/salt/states/win_dism.py +++ b/salt/states/win_dism.py @@ -74,11 +74,11 @@ def capability_installed( old = __salt__["dism.installed_capabilities"]() if name in old: - ret["comment"] = "The capability {} is already installed".format(name) + ret["comment"] = f"The capability {name} is already installed" return ret if __opts__["test"]: - ret["changes"]["capability"] = "{} will be installed".format(name) + ret["changes"]["capability"] = f"{name} will be installed" ret["result"] = None return ret @@ -86,14 +86,14 @@ def capability_installed( status = __salt__["dism.add_capability"](name, source, limit_access, image, restart) if status["retcode"] not in [0, 1641, 3010]: - ret["comment"] = "Failed to install {}: {}".format(name, status["stdout"]) + ret["comment"] = f'Failed to install {name}: {status["stdout"]}' ret["result"] = False new = __salt__["dism.installed_capabilities"]() changes = salt.utils.data.compare_lists(old, new) if changes: - ret["comment"] = "Installed {}".format(name) + ret["comment"] = f"Installed {name}" ret["changes"] = status ret["changes"]["capability"] = changes @@ -133,11 +133,11 @@ def capability_removed(name, image=None, restart=False): old = __salt__["dism.installed_capabilities"]() if name not in old: - ret["comment"] = "The capability {} is already removed".format(name) + ret["comment"] = f"The capability {name} is already removed" return ret if __opts__["test"]: - ret["changes"]["capability"] = "{} will be removed".format(name) + ret["changes"]["capability"] = f"{name} will be removed" ret["result"] = None return ret @@ -145,14 +145,14 @@ def capability_removed(name, image=None, restart=False): status = __salt__["dism.remove_capability"](name, image, restart) if status["retcode"] not in [0, 1641, 3010]: - ret["comment"] = "Failed to remove {}: {}".format(name, status["stdout"]) + ret["comment"] = f'Failed to remove {name}: {status["stdout"]}' ret["result"] = False new = __salt__["dism.installed_capabilities"]() changes = salt.utils.data.compare_lists(old, new) if changes: - ret["comment"] = "Removed {}".format(name) + ret["comment"] = f"Removed {name}" ret["changes"] = status ret["changes"]["capability"] = changes @@ -214,11 +214,11 @@ def feature_installed( old = __salt__["dism.installed_features"]() if name in old: - ret["comment"] = "The feature {} is already installed".format(name) + ret["comment"] = f"The feature {name} is already installed" return ret if __opts__["test"]: - ret["changes"]["feature"] = "{} will be installed".format(name) + ret["changes"]["feature"] = f"{name} will be installed" ret["result"] = None return ret @@ -228,14 +228,14 @@ def feature_installed( ) if status["retcode"] not in [0, 1641, 3010]: - ret["comment"] = "Failed to install {}: {}".format(name, status["stdout"]) + ret["comment"] = f'Failed to install {name}: {status["stdout"]}' ret["result"] = False new = __salt__["dism.installed_features"]() changes = salt.utils.data.compare_lists(old, new) if changes: - ret["comment"] = "Installed {}".format(name) + ret["comment"] = f"Installed {name}" ret["changes"] = status ret["changes"]["feature"] = changes @@ -280,11 +280,11 @@ def feature_removed(name, remove_payload=False, image=None, restart=False): old = __salt__["dism.installed_features"]() if name not in old: - ret["comment"] = "The feature {} is already removed".format(name) + ret["comment"] = f"The feature {name} is already removed" return ret if __opts__["test"]: - ret["changes"]["feature"] = "{} will be removed".format(name) + ret["changes"]["feature"] = f"{name} will be removed" ret["result"] = None return ret @@ -292,14 +292,14 @@ def feature_removed(name, remove_payload=False, image=None, restart=False): status = __salt__["dism.remove_feature"](name, remove_payload, image, restart) if status["retcode"] not in [0, 1641, 3010]: - ret["comment"] = "Failed to remove {}: {}".format(name, status["stdout"]) + ret["comment"] = f'Failed to remove {name}: {status["stdout"]}' ret["result"] = False new = __salt__["dism.installed_features"]() changes = salt.utils.data.compare_lists(old, new) if changes: - ret["comment"] = "Removed {}".format(name) + ret["comment"] = f"Removed {name}" ret["changes"] = status ret["changes"]["feature"] = changes @@ -348,7 +348,7 @@ def package_installed( ret["result"] = None else: ret["result"] = False - ret["comment"] = "Package path {} does not exist".format(name) + ret["comment"] = f"Package path {name} does not exist" return ret old = __salt__["dism.installed_packages"]() @@ -357,13 +357,14 @@ def package_installed( package_info = __salt__["dism.package_info"](name) if package_info["Package Identity"] in old: - ret["comment"] = "The package {} is already installed: {}".format( - name, package_info["Package Identity"] + ret["comment"] = ( + f"The package {name} is already installed: " + f'{package_info["Package Identity"]}' ) return ret if __opts__["test"]: - ret["changes"]["package"] = "{} will be installed".format(name) + ret["changes"]["package"] = f"{name} will be installed" ret["result"] = None return ret @@ -373,14 +374,14 @@ def package_installed( ) if status["retcode"] not in [0, 1641, 3010]: - ret["comment"] = "Failed to install {}: {}".format(name, status["stdout"]) + ret["comment"] = f'Failed to install {name}: {status["stdout"]}' ret["result"] = False new = __salt__["dism.installed_packages"]() changes = salt.utils.data.compare_lists(old, new) if changes: - ret["comment"] = "Installed {}".format(name) + ret["comment"] = f"Installed {name}" ret["changes"] = status ret["changes"]["package"] = changes @@ -508,7 +509,7 @@ def package_removed(name, image=None, restart=False): ret["result"] = None else: ret["result"] = False - ret["comment"] = "Package path {} does not exist".format(name) + ret["comment"] = f"Package path {name} does not exist" return ret old = __salt__["dism.installed_packages"]() @@ -522,11 +523,11 @@ def package_removed(name, image=None, restart=False): "Package Identity" not in package_info or package_info["Package Identity"] not in old ): - ret["comment"] = "The package {} is already removed".format(name) + ret["comment"] = f"The package {name} is already removed" return ret if __opts__["test"]: - ret["changes"]["package"] = "{} will be removed".format(name) + ret["changes"]["package"] = f"{name} will be removed" ret["result"] = None return ret @@ -534,14 +535,14 @@ def package_removed(name, image=None, restart=False): status = __salt__["dism.remove_package"](name, image, restart) if status["retcode"] not in [0, 1641, 3010]: - ret["comment"] = "Failed to remove {}: {}".format(name, status["stdout"]) + ret["comment"] = f'Failed to remove {name}: {status["stdout"]}' ret["result"] = False new = __salt__["dism.installed_packages"]() changes = salt.utils.data.compare_lists(old, new) if changes: - ret["comment"] = "Removed {}".format(name) + ret["comment"] = f"Removed {name}" ret["changes"] = status ret["changes"]["package"] = changes @@ -587,11 +588,11 @@ def kb_removed(name, image=None, restart=False): # If pkg_name is None, the package is not installed if pkg_name is None: - ret["comment"] = "{} is not installed".format(name) + ret["comment"] = f"{name} is not installed" return ret if __opts__["test"]: - ret["changes"]["package"] = "{} will be removed".format(name) + ret["changes"]["package"] = f"{name} will be removed" ret["result"] = None return ret @@ -602,7 +603,7 @@ def kb_removed(name, image=None, restart=False): 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["comment"] = f'Failed to remove {name}: {status["stdout"]}' ret["result"] = False return ret @@ -610,7 +611,7 @@ def kb_removed(name, image=None, restart=False): changes = salt.utils.data.compare_lists(old, new) if changes: - ret["comment"] = "Removed {}".format(name) + ret["comment"] = f"Removed {name}" ret["changes"] = status ret["changes"]["package"] = changes diff --git a/salt/utils/win_pwsh.py b/salt/utils/win_pwsh.py index b0e76d2aacf..7e6e659f91f 100644 --- a/salt/utils/win_pwsh.py +++ b/salt/utils/win_pwsh.py @@ -49,9 +49,7 @@ def run_dict(cmd, cwd=None): if "retcode" not in ret or ret["retcode"] != 0: # run_all logs an error to log.error, fail hard back to the user - raise CommandExecutionError( - "Issue executing PowerShell {}".format(cmd), info=ret - ) + raise CommandExecutionError(f"Issue executing PowerShell cmd", info=ret) # Sometimes Powershell returns an empty string, which isn't valid JSON if ret["stdout"] == "": diff --git a/tests/pytests/unit/modules/test_win_wusa.py b/tests/pytests/unit/modules/test_win_wusa.py index 926a99f9fec..e68cb61cd07 100644 --- a/tests/pytests/unit/modules/test_win_wusa.py +++ b/tests/pytests/unit/modules/test_win_wusa.py @@ -163,7 +163,7 @@ def test_uninstall_kb(): "wusa.exe", "/uninstall", "/quiet", - "/kb:{}".format(kb[2:]), + f"/kb:{kb[2:]}", "/norestart", ], ignore_retcode=True, @@ -217,7 +217,7 @@ def test_uninstall_already_uninstalled(): "wusa.exe", "/uninstall", "/quiet", - "/kb:{}".format(kb[2:]), + f"/kb:{kb[2:]}", "/norestart", ], ignore_retcode=True, From 9ffbd2103d9756f67084a44cf1196fb3d3b0412a Mon Sep 17 00:00:00 2001 From: nicholasmhughes Date: Sun, 4 Jun 2023 15:23:00 -0400 Subject: [PATCH 25/25] fixes saltstack/salt#64418 configurable_test_state should be able to return False result in test mode --- changelog/64418.added.md | 1 + salt/states/test.py | 22 +++++++++++++++++++-- tests/pytests/unit/states/test_test.py | 27 +++++++++++++++++++++++--- 3 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 changelog/64418.added.md diff --git a/changelog/64418.added.md b/changelog/64418.added.md new file mode 100644 index 00000000000..b189c0d40f0 --- /dev/null +++ b/changelog/64418.added.md @@ -0,0 +1 @@ +Add ability to return False result in test mode of configurable_test_state diff --git a/salt/states/test.py b/salt/states/test.py index 18af36920bf..464f3ee33aa 100644 --- a/salt/states/test.py +++ b/salt/states/test.py @@ -173,7 +173,14 @@ def fail_with_changes(name, **kwargs): # pylint: disable=unused-argument return ret -def configurable_test_state(name, changes=True, result=True, comment="", warnings=None): +def configurable_test_state( + name, + changes=True, + result=True, + comment="", + warnings=None, + allow_test_mode_failure=False, +): """ .. versionadded:: 2014.7.0 @@ -221,6 +228,13 @@ def configurable_test_state(name, changes=True, result=True, comment="", warning Default is None .. versionadded:: 3000 + + allow_test_mode_failure + When False, running this state in test mode can only return a True + or None result. When set to True and result is set to False, the + test mode result will be False. Default is False + + .. versionadded:: 3007.0 """ ret = {"name": name, "changes": {}, "result": False, "comment": comment} change_data = { @@ -276,7 +290,11 @@ def configurable_test_state(name, changes=True, result=True, comment="", warning ) if __opts__["test"]: - ret["result"] = True if changes is False else None + if allow_test_mode_failure and result is False: + test_result = result + else: + test_result = True if changes is False else None + ret["result"] = test_result ret["comment"] = "This is a test" if not comment else comment return ret diff --git a/tests/pytests/unit/states/test_test.py b/tests/pytests/unit/states/test_test.py index ef8c3b85f76..4054c47d6b9 100644 --- a/tests/pytests/unit/states/test_test.py +++ b/tests/pytests/unit/states/test_test.py @@ -354,7 +354,6 @@ def test_configurable_test_state_test(): ret = test.configurable_test_state(mock_name) assert ret == mock_ret - with patch.dict(test.__opts__, {"test": True}): mock_ret = { "name": mock_name, "changes": mock_changes, @@ -364,7 +363,6 @@ def test_configurable_test_state_test(): ret = test.configurable_test_state(mock_name, comment=mock_comment) assert ret == mock_ret - with patch.dict(test.__opts__, {"test": True}): mock_ret = { "name": mock_name, "changes": mock_changes, @@ -376,7 +374,6 @@ def test_configurable_test_state_test(): ) assert ret == mock_ret - with patch.dict(test.__opts__, {"test": True}): mock_ret = { "name": mock_name, "changes": {}, @@ -388,6 +385,30 @@ def test_configurable_test_state_test(): ) assert ret == mock_ret + # normal test mode operation doesn't allow False result + mock_ret = { + "name": mock_name, + "changes": {}, + "result": True, + "comment": "This is a test", + } + ret = test.configurable_test_state( + mock_name, changes=False, result=False, allow_test_mode_failure=False + ) + assert ret == mock_ret + + # test allow False result in test mode + mock_ret = { + "name": mock_name, + "changes": {}, + "result": False, + "comment": "This is a test", + } + ret = test.configurable_test_state( + mock_name, changes=False, result=False, allow_test_mode_failure=True + ) + assert ret == mock_ret + def test_mod_watch(): """