Merge branch 'master' into add-keyvalue-create_if_missing

This commit is contained in:
Megan Wilhite 2023-06-06 19:32:01 +00:00 committed by GitHub
commit a5bd72653f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1652 additions and 479 deletions

View file

@ -0,0 +1 @@
Dropped Python 3.7 support since it's EOL in 27 Jun 2023

1
changelog/64418.added.md Normal file
View file

@ -0,0 +1 @@
Add ability to return False result in test mode of configurable_test_state

1
changelog/64978.added.md Normal file
View file

@ -0,0 +1 @@
Added win_appx state and execution modules for managing Microsoft Store apps and deprovisioning them from systems

View file

@ -498,6 +498,7 @@ execution modules
vmctl
vsphere
webutil
win_appx
win_auditpol
win_autoruns
win_certutil

View file

@ -0,0 +1,5 @@
salt.modules.win_appx
=====================
.. automodule:: salt.modules.win_appx
:members:

View file

@ -320,6 +320,7 @@ state modules
virt
virtualenv_mod
webutil
win_appx
win_certutil
win_dacl
win_dism

View file

@ -0,0 +1,5 @@
salt.states.win_appx
====================
.. automodule:: salt.states.win_appx
:members:

View file

@ -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).

View file

@ -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

363
salt/modules/win_appx.py Normal file
View file

@ -0,0 +1,363 @@
"""
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.
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. The steps are outlined 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 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:
- ``.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
import salt.utils.platform
import salt.utils.win_pwsh
import salt.utils.win_reg
log = logging.getLogger(__name__)
CURRENT_VERSION_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion"
DEPROVISIONED_KEY = rf"{CURRENT_VERSION_KEY}\Appx\AppxAllUserStore\Deprovisioned"
__virtualname__ = "appx"
__func_alias__ = {"list_": "list"}
def __virtual__():
"""
Load only on Windows
"""
if not salt.utils.platform.is_windows():
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__
def _pkg_list(raw, field="Name"):
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 list_(
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 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``
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
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 = []
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")
if not field:
cmd.append("Sort-Object Name")
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}")
return _pkg_list(salt.utils.win_pwsh.run_dict(" | ".join(cmd)), field)
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.
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 "*" appx.remove *candy*
"""
packages = list_(
query=query,
field=None,
include_store=include_store,
frameworks=frameworks,
bundles=False,
)
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 = list_(
query=f'{package["Name"]}*',
field=None,
include_store=include_store,
frameworks=frameworks,
bundles=True,
)
if bundle and bundle["IsBundle"]:
log.debug(f'Found bundle: {bundle["PackageFullName"]}')
remove_name = bundle["PackageFullName"]
if deprovision_only:
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}"
salt.utils.win_pwsh.run_dict(remove_cmd)
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
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
list of keys for apps that have been deprovisioned.
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
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:
return ret
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.
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):
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``
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
# users
ret = __salt__["dism.add_provisioned_package"](package)
return ret["retcode"] == 0

View file

@ -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
@ -52,11 +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)
pattern = r"{} : (.*)\r\n.*State : {}\r\n".format(type_regex, install_value)
if install_value:
pattern = rf"{type_regex} : (.*)\r\n.*State : {install_value}\r\n"
else:
pattern = rf"{type_regex} : (.*)\r\n.*"
capabilities = re.findall(pattern, out, re.MULTILINE)
capabilities.sort()
return capabilities
@ -95,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:
@ -143,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:
@ -185,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)
@ -228,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")
@ -258,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")
@ -303,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:
@ -346,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:
@ -394,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)
@ -496,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:
@ -511,6 +514,60 @@ 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
is updated.
.. versionadded:: 3007.0
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",
f"/Image:{image}" if image else "/Online",
"/Add-ProvisionedAppxPackage",
f"/PackagePath:{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
@ -542,7 +599,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",
]
@ -550,9 +607,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)
@ -584,9 +641,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
@ -622,7 +679,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)
@ -654,6 +711,34 @@ def installed_packages(image=None):
)
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
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,19 +759,19 @@ 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,
"/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)

View file

@ -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:
@ -228,9 +194,9 @@ 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 = _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)

View file

@ -13,12 +13,14 @@ import logging
import os
import salt.utils.platform
import salt.utils.win_pwsh
from salt.exceptions import CommandExecutionError
log = logging.getLogger(__name__)
# Define the module's virtual name
__virtualname__ = "wusa"
__func_alias__ = {"list_": "list"}
def __virtual__():
@ -35,41 +37,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.
@ -90,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,
)
@ -138,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
@ -203,19 +170,19 @@ 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
def list():
def list_():
"""
Get a list of updates installed on the machine
@ -229,7 +196,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

View file

@ -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

127
salt/states/win_appx.py Normal file
View file

@ -0,0 +1,127 @@
"""
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
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:
| 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
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:: yaml
remove_candy_crush:
appx.absent:
- query: "*candy*"
"""
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"]:
comment = ["The following apps will be removed:"]
comment.extend(matches)
ret["comment"] = "\n- ".join(comment)
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

View file

@ -2,9 +2,9 @@
Installing of Windows features using DISM
=========================================
Install windows features/capabilties with DISM
Install Windows features, capabilities, and packages with DISM
.. code-block:: yaml
.. code-block:: bash
Language.Basic~~~en-US~0.0.1.0:
dism.capability_installed
@ -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
@ -64,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
@ -76,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
@ -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
@ -117,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
@ -129,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
@ -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
@ -185,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
@ -199,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
@ -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
@ -243,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
@ -255,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
@ -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:
@ -303,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"]()
@ -312,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
@ -328,14 +374,93 @@ 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
return ret
def provisioned_package_installed(name, image=None, restart=False):
"""
Provision a package on a Windows image.
.. versionadded:: 3007.0
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
@ -347,15 +472,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:
@ -379,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"]()
@ -393,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
@ -405,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
@ -426,13 +556,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:
@ -454,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
@ -469,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
@ -477,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

View file

@ -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:")

63
salt/utils/win_pwsh.py Normal file
View file

@ -0,0 +1,63 @@
import salt.modules.cmdmod
import salt.utils.json
import salt.utils.platform
from salt.exceptions import CommandExecutionError
__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 = f"{cmd} | ConvertTo-Json"
if "progresspreference" not in cmd.lower():
cmd = f"$ProgressPreference = 'SilentlyContinue'; {cmd}"
ret = salt.modules.cmdmod.run_all(cmd=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(f"Issue executing PowerShell 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

View file

@ -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")

View file

@ -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

View file

@ -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",
f"/kb:{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",
f"/kb:{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

View file

@ -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():
"""

View file

@ -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

View file

@ -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("salt.modules.cmdmod.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("salt.modules.cmdmod.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("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
def test_run_dict_missing_retcode():
ret = {
"pid": 1,
"stderr": "",
"stdout": "",
}
mock_all = MagicMock(return_value=ret)
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
def test_run_dict_retcode_not_zero():
ret = {
"pid": 1,
"retcode": 1,
"stderr": "",
"stdout": "",
}
mock_all = MagicMock(return_value=ret)
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
def test_run_dict_invalid_json():
ret = {
"pid": 1,
"retcode": 0,
"stderr": "",
"stdout": "Invalid Json",
}
mock_all = MagicMock(return_value=ret)
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

View file

@ -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)