diff --git a/changelogs/fragments/win_package-revamp.yaml b/changelogs/fragments/win_package-revamp.yaml new file mode 100644 index 00000000000..0fc9b08f2c5 --- /dev/null +++ b/changelogs/fragments/win_package-revamp.yaml @@ -0,0 +1,10 @@ +bugfixes: +- win_package - Handle quoted and unquoted strings in the registry ``UninstallString`` value - https://github.com/ansible/ansible/issues/40973 + +minor_changes: +- win_package - Added support for ``.msp`` packages - https://github.com/ansible/ansible/issues/22789 +- win_package - Added support for specifying the HTTP method when getting files from a URL - https://github.com/ansible/ansible/issues/35377 +- win_package - Added proxy support for retrieving packages from a URL - https://github.com/ansible/ansible/issues/43818 +- win_package - Scan packages in the current user's registry hive - https://github.com/ansible/ansible/issues/45950 +- win_package - Added support for ``.appx``, ``.msix``, ``.appxbundle``, and ``.msixbundle`` package - https://github.com/ansible/ansible/issues/50765 +- win_package - Read uninstall strings from the ``QuietUninstallString`` if present to better support argumentless uninstalls of registry based packages. diff --git a/lib/ansible/module_utils/csharp/Ansible.AccessToken.cs b/lib/ansible/module_utils/csharp/Ansible.AccessToken.cs index 52fdb4bc0cd..676991e26f2 100644 --- a/lib/ansible/module_utils/csharp/Ansible.AccessToken.cs +++ b/lib/ansible/module_utils/csharp/Ansible.AccessToken.cs @@ -134,6 +134,9 @@ namespace Ansible.AccessToken public enum LogonProvider { Default, + WinNT35, + WinNT40, + WinNT50, } public enum LogonType @@ -454,4 +457,4 @@ namespace Ansible.AccessToken } } } -} \ No newline at end of file +} diff --git a/lib/ansible/modules/windows/win_package.ps1 b/lib/ansible/modules/windows/win_package.ps1 index c2fc405602b..318545466fb 100644 --- a/lib/ansible/modules/windows/win_package.ps1 +++ b/lib/ansible/modules/windows/win_package.ps1 @@ -4,491 +4,1378 @@ # Copyright: (c) 2017, Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# AccessToken should be removed once the username/password options are gone +#AnsibleRequires -CSharpUtil Ansible.AccessToken + #AnsibleRequires -CSharpUtil Ansible.Basic #Requires -Module Ansible.ModuleUtils.AddType #Requires -Module Ansible.ModuleUtils.ArgvParser #Requires -Module Ansible.ModuleUtils.CommandUtil +#Requires -Module Ansible.ModuleUtils.WebRequest -$spec = @{ - options = @{ - arguments = @{ type = "raw" } - expected_return_code = @{ type = "list"; elements = "int"; default = @(0, 3010) } - path = @{ type = "str"} - chdir = @{ type = "path" } - product_id = @{ type = "str"; aliases = @(,"productid") } - state = @{ type = "str"; default = "present"; choices = "absent", "present"; aliases = @(,"ensure") } - username = @{ type = "str"; aliases = @(,"user_name") } - password = @{ type = "str"; no_log = $true; aliases = @(,"user_password") } - validate_certs = @{ type = "bool"; default = $true } - creates_path = @{ type = "path" } - creates_version = @{ type = "str" } - creates_service = @{ type = "str" } - log_path = @{ type = "path" } +Function Import-PInvokeCode { + param ( + [Object] + $Module + ) + Add-CSharpType -AnsibleModule $Module -References @' +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Security.Principal; +using System.Text; + +//AssemblyReference -Type System.Security.Principal.IdentityReference -CLR Core + +namespace Ansible.WinPackage +{ + internal class NativeHelpers + { + [StructLayout(LayoutKind.Sequential)] + public struct PACKAGE_VERSION + { + public UInt16 Revision; + public UInt16 Build; + public UInt16 Minor; + public UInt16 Major; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct PACKAGE_ID + { + public UInt32 reserved; + public MsixArchitecture processorArchitecture; + public PACKAGE_VERSION version; + public string name; + public string publisher; + public string resourceId; + public string publisherId; + } } - required_by = @{ - creates_version = "creates_path" + + internal class NativeMethods + { + [DllImport("Ole32.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 GetClassFile( + [MarshalAs(UnmanagedType.LPWStr)] string szFilename, + ref Guid pclsid); + + [DllImport("Msi.dll")] + public static extern UInt32 MsiCloseHandle( + IntPtr hAny); + + [DllImport("Msi.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 MsiEnumPatchesExW( + [MarshalAs(UnmanagedType.LPWStr)] string szProductCode, + [MarshalAs(UnmanagedType.LPWStr)] string szUserSid, + InstallContext dwContext, + PatchState dwFilter, + UInt32 dwIndex, + StringBuilder szPatchCode, + StringBuilder szTargetProductCode, + out InstallContext pdwTargetProductContext, + StringBuilder szTargetUserSid, + ref UInt32 pcchTargetUserSid); + + [DllImport("Msi.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 MsiGetPatchInfoExW( + [MarshalAs(UnmanagedType.LPWStr)] string szPatchCode, + [MarshalAs(UnmanagedType.LPWStr)] string szProductCode, + [MarshalAs(UnmanagedType.LPWStr)] string szUserSid, + InstallContext dwContext, + [MarshalAs(UnmanagedType.LPWStr)] string szProperty, + StringBuilder lpValue, + ref UInt32 pcchValue); + + [DllImport("Msi.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 MsiGetPropertyW( + SafeMsiHandle hInstall, + [MarshalAs(UnmanagedType.LPWStr)] string szName, + StringBuilder szValueBuf, + ref UInt32 pcchValueBuf); + + [DllImport("Msi.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 MsiGetSummaryInformationW( + IntPtr hDatabase, + [MarshalAs(UnmanagedType.LPWStr)] string szDatabasePath, + UInt32 uiUpdateCount, + out SafeMsiHandle phSummaryInfo); + + [DllImport("Msi.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 MsiOpenPackageW( + [MarshalAs(UnmanagedType.LPWStr)] string szPackagePath, + out SafeMsiHandle hProduct); + + [DllImport("Msi.dll", CharSet = CharSet.Unicode)] + public static extern InstallState MsiQueryProductStateW( + [MarshalAs(UnmanagedType.LPWStr)] string szProduct); + + [DllImport("Msi.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 MsiSummaryInfoGetPropertyW( + SafeHandle hSummaryInfo, + UInt32 uiProperty, + out UInt32 puiDataType, + out Int32 piValue, + ref System.Runtime.InteropServices.ComTypes.FILETIME pftValue, + StringBuilder szValueBuf, + ref UInt32 pcchValueBuf); + + [DllImport("Kernel32.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 PackageFullNameFromId( + NativeHelpers.PACKAGE_ID packageId, + ref UInt32 packageFamilyNameLength, + StringBuilder packageFamilyName); } - required_if = @( - @("state", "present", @("path")), - @("state", "absent", @("path", "product_id"), $true) - ) - required_together = @(,@("username", "password")) - supports_check_mode = $true -} -$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + [Flags] + public enum InstallContext : uint + { + None = 0x00000000, + UserManaged = 0x00000001, + UserUnmanaged = 0x00000002, + Machine = 0x00000004, + AllUserManaged = 0x00000008, + All = UserManaged | UserUnmanaged | Machine, + } -$check_mode = $module.CheckMode + public enum InstallState : int + { + NotUsed = -7, + BadConfig = -6, + Incomplete = -5, + SourceAbsent = -4, + MoreData = -3, + InvalidArg = -2, + Unknown = -1, + Broken = 0, + Advertised = 1, + Absent = 2, + Local = 3, + Source = 4, + Default = 5, + } -$arguments = $module.Params.arguments -$expected_return_code = $module.Params.expected_return_code -$path = $module.Params.path -$chdir = $module.Params.chdir -$product_id = $module.Params.product_id -$state = $module.Params.state -$username = $module.Params.username -$password = $module.Params.password -$validate_certs = $module.Params.validate_certs -$creates_path = $module.Params.creates_path -$creates_version = $module.Params.creates_version -$creates_service = $module.Params.creates_service -$log_path = $module.Params.log_path + public enum MsixArchitecture : uint + { + X86 = 0, + Arm = 5, + X64 = 9, + Neutral = 11, + Arm64 = 12, + } -$module.Result.reboot_required = $false + [Flags] + public enum PatchState : uint + { + Invalid = 0x00000000, + Applied = 0x00000001, + Superseded = 0x00000002, + Obsoleted = 0x00000004, + Registered = 0x00000008, + All = Applied | Superseded | Obsoleted | Registered, + } -if ($null -ne $arguments) { - # convert a list to a string and escape the values - if ($arguments -is [array]) { - $arguments = Argv-ToString -arguments $arguments + public class SafeMsiHandle : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeMsiHandle() : base(true) { } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + protected override bool ReleaseHandle() + { + UInt32 res = NativeMethods.MsiCloseHandle(handle); + return res == 0; + } } -} -if (-not $validate_certs) { - [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } -} + public class PatchInfo + { + public string PatchCode; + public string ProductCode; + public InstallContext Context; + public SecurityIdentifier UserSid; + } -# Enable TLS1.1/TLS1.2 if they're available but disabled (eg. .NET 4.5) -$security_protcols = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::SystemDefault -if ([Net.SecurityProtocolType].GetMember("Tls11").Count -gt 0) { - $security_protcols = $security_protcols -bor [Net.SecurityProtocolType]::Tls11 -} -if ([Net.SecurityProtocolType].GetMember("Tls12").Count -gt 0) { - $security_protcols = $security_protcols -bor [Net.SecurityProtocolType]::Tls12 -} -[Net.ServicePointManager]::SecurityProtocol = $security_protcols + public class MsixHelper + { + public static string GetPackageFullName(string identity, string version, string publisher, + MsixArchitecture architecture, string resourceId) + { + string[] versionSplit = version.Split(new char[] {'.'}, 4); + NativeHelpers.PACKAGE_ID id = new NativeHelpers.PACKAGE_ID() + { + processorArchitecture = architecture, + version = new NativeHelpers.PACKAGE_VERSION() + { + Revision = Convert.ToUInt16(versionSplit.Length > 3 ? versionSplit[3] : "0"), + Build = Convert.ToUInt16(versionSplit.Length > 2 ? versionSplit[2] : "0"), + Minor = Convert.ToUInt16(versionSplit.Length > 1 ? versionSplit[1] : "0"), + Major = Convert.ToUInt16(versionSplit[0]), + }, + name = identity, + publisher = publisher, + resourceId = resourceId, + }; -$credential = $null -if ($null -ne $username) { - $sec_user_password = ConvertTo-SecureString -String $password -AsPlainText -Force - $credential = New-Object -TypeName PSCredential -ArgumentList $username, $sec_user_password -} + UInt32 fullNameLength = 0; + UInt32 res = NativeMethods.PackageFullNameFromId(id, ref fullNameLength, null); + if (res != 122) // ERROR_INSUFFICIENT_BUFFER + throw new Win32Exception((int)res); -$msi_tools = @" -using System; -using System.Runtime.InteropServices; -using System.Text; + StringBuilder fullName = new StringBuilder((int)fullNameLength); + res = NativeMethods.PackageFullNameFromId(id, ref fullNameLength, fullName); + if (res != 0) + throw new Win32Exception((int)res); -namespace Ansible { - public static class MsiTools { - [DllImport("msi.dll", CharSet = CharSet.Unicode, PreserveSig = true, SetLastError = true, ExactSpelling = true)] - private static extern UInt32 MsiOpenPackageW(string szPackagePath, out IntPtr hProduct); + return fullName.ToString(); + } + } - [DllImport("msi.dll", CharSet = CharSet.Unicode, PreserveSig = true, SetLastError = true, ExactSpelling = true)] - private static extern uint MsiCloseHandle(IntPtr hAny); + public class MsiHelper + { + public static UInt32 SUMMARY_PID_TEMPLATE = 7; + public static UInt32 SUMMARY_PID_REVNUMBER = 9; - [DllImport("msi.dll", CharSet = CharSet.Unicode, PreserveSig = true, SetLastError = true, ExactSpelling = true)] - private static extern uint MsiGetPropertyW(IntPtr hAny, string name, StringBuilder buffer, ref int bufferLength); + private static Guid MSI_CLSID = new Guid("000c1084-0000-0000-c000-000000000046"); + private static Guid MSP_CLSID = new Guid("000c1086-0000-0000-c000-000000000046"); - public static string GetPackageProperty(string msi, string property) { - IntPtr MsiHandle = IntPtr.Zero; - try { - uint res = MsiOpenPackageW(msi, out MsiHandle); - if (res != 0) - return null; - - int length = 256; - var buffer = new StringBuilder(length); - res = MsiGetPropertyW(MsiHandle, property, buffer, ref length); - return buffer.ToString(); - } finally { - if (MsiHandle != IntPtr.Zero) - MsiCloseHandle(MsiHandle); + public static IEnumerable EnumPatches(string productCode, string userSid, InstallContext context, + PatchState filter) + { + // PowerShell -> .NET, $null for a string parameter becomes an empty string, make sure we convert back. + productCode = String.IsNullOrEmpty(productCode) ? null : productCode; + userSid = String.IsNullOrEmpty(userSid) ? null : userSid; + + UInt32 idx = 0; + while (true) + { + StringBuilder targetPatchCode = new StringBuilder(39); + StringBuilder targetProductCode = new StringBuilder(39); + InstallContext targetContext; + StringBuilder targetUserSid = new StringBuilder(0); + UInt32 targetUserSidLength = 0; + + UInt32 res = NativeMethods.MsiEnumPatchesExW(productCode, userSid, context, filter, idx, + targetPatchCode, targetProductCode, out targetContext, targetUserSid, ref targetUserSidLength); + + SecurityIdentifier sid = null; + if (res == 0x000000EA) // ERROR_MORE_DATA + { + targetUserSidLength++; + targetUserSid.EnsureCapacity((int)targetUserSidLength); + + res = NativeMethods.MsiEnumPatchesExW(productCode, userSid, context, filter, idx, + targetPatchCode, targetProductCode, out targetContext, targetUserSid, ref targetUserSidLength); + + sid = new SecurityIdentifier(targetUserSid.ToString()); + } + + if (res == 0x00000103) // ERROR_NO_MORE_ITEMS + break; + else if (res != 0) + throw new Win32Exception((int)res); + + yield return new PatchInfo() + { + PatchCode = targetPatchCode.ToString(), + ProductCode = targetProductCode.ToString(), + Context = targetContext, + UserSid = sid, + }; + idx++; } } + + public static string GetPatchInfo(string patchCode, string productCode, string userSid, InstallContext context, + string property) + { + // PowerShell -> .NET, $null for a string parameter becomes an empty string, make sure we convert back. + userSid = String.IsNullOrEmpty(userSid) ? null : userSid; + + StringBuilder buffer = new StringBuilder(0); + UInt32 bufferLength = 0; + NativeMethods.MsiGetPatchInfoExW(patchCode, productCode, userSid, context, property, buffer, + ref bufferLength); + + bufferLength++; + buffer.EnsureCapacity((int)bufferLength); + + UInt32 res = NativeMethods.MsiGetPatchInfoExW(patchCode, productCode, userSid, context, property, buffer, + ref bufferLength); + if (res != 0) + throw new Win32Exception((int)res); + + return buffer.ToString(); + } + + public static string GetProperty(SafeMsiHandle productHandle, string property) + { + StringBuilder buffer = new StringBuilder(0); + UInt32 bufferLength = 0; + NativeMethods.MsiGetPropertyW(productHandle, property, buffer, ref bufferLength); + + // Make sure we include the null byte char at the end. + bufferLength += 1; + buffer.EnsureCapacity((int)bufferLength); + + UInt32 res = NativeMethods.MsiGetPropertyW(productHandle, property, buffer, ref bufferLength); + if (res != 0) + throw new Win32Exception((int)res); + + return buffer.ToString(); + } + + public static SafeMsiHandle GetSummaryHandle(string databasePath) + { + SafeMsiHandle summaryInfo = null; + UInt32 res = NativeMethods.MsiGetSummaryInformationW(IntPtr.Zero, databasePath, 0, out summaryInfo); + if (res != 0) + throw new Win32Exception((int)res); + + return summaryInfo; + } + + public static string GetSummaryPropertyString(SafeMsiHandle summaryHandle, UInt32 propertyId) + { + UInt32 dataType = 0; + Int32 intPropValue = 0; + System.Runtime.InteropServices.ComTypes.FILETIME propertyFiletime = + new System.Runtime.InteropServices.ComTypes.FILETIME(); + StringBuilder buffer = new StringBuilder(0); + UInt32 bufferLength = 0; + + NativeMethods.MsiSummaryInfoGetPropertyW(summaryHandle, propertyId, out dataType, out intPropValue, + ref propertyFiletime, buffer, ref bufferLength); + + // Make sure we include the null byte char at the end. + bufferLength += 1; + buffer.EnsureCapacity((int)bufferLength); + + UInt32 res = NativeMethods.MsiSummaryInfoGetPropertyW(summaryHandle, propertyId, out dataType, + out intPropValue, ref propertyFiletime, buffer, ref bufferLength); + if (res != 0) + throw new Win32Exception((int)res); + + return buffer.ToString(); + } + + public static bool IsMsi(string filename) + { + return GetClsid(filename) == MSI_CLSID; + } + + public static bool IsMsp(string filename) + { + return GetClsid(filename) == MSP_CLSID; + } + + public static SafeMsiHandle OpenPackage(string packagePath) + { + SafeMsiHandle packageHandle = null; + UInt32 res = NativeMethods.MsiOpenPackageW(packagePath, out packageHandle); + if (res != 0) + throw new Win32Exception((int)res); + + return packageHandle; + } + + public static InstallState QueryProductState(string productCode) + { + return NativeMethods.MsiQueryProductStateW(productCode); + } + + private static Guid GetClsid(string filename) + { + Guid clsid = Guid.Empty; + NativeMethods.GetClassFile(filename, ref clsid); + + return clsid; + } } } -"@ - -Add-CSharpType -AnsibleModule $module -References @" -public enum LocationType { - Empty, - Local, - Unc, - Http +'@ } -"@ -Function Download-File($url, $path) { - $web_client = New-Object -TypeName System.Net.WebClient - try { - $web_client.DownloadFile($url, $path) - } catch { - $module.FailJson("failed to download $url to $($path): $($_.Exception.Message)", $_) +Function Add-SystemReadAce { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [String] + $Path + ) + + # Don't set the System ACE if the path is a UNC path as the SID won't be valid. + if (([Uri]$Path).IsUnc) { + return } + + $acl = Get-Acl -LiteralPath $Path + $ace = New-Object -TypeName System.Security.AccessControl.FileSystemAccessRule -ArgumentList @( + (New-Object -TypeName System.Security.Principal.SecurityIdentifier -ArgumentList ('S-1-5-18')), + [System.Security.AccessControl.FileSystemRights]::Read, + [System.Security.AccessControl.AccessControlType]::Allow + ) + $acl.AddAccessRule($ace) + $acl | Set-Acl -LiteralPath $path } -Function Test-RegistryProperty($path, $name) { - # will validate if the registry key contains the property, returns true - # if the property exists and false if the property does not +Function Copy-ItemWithCredential { + [CmdletBinding(SupportsShouldProcess=$false)] + param ( + [String] + $Path, + + [String] + $Destination, + + [PSCredential] + $Credential + ) + + $filename = Split-Path -Path $Path -Leaf + $targetPath = Join-Path -Path $Destination -ChildPath $filename + + # New-PSDrive with -Credentials seems to have lots of issues, just impersonate a NewCredentials token and copy the + # file locally. NewCredentials will ensure the outbound auth to the UNC path is with the new credentials specified. + + $domain = [NullString]::Value + $username = $Credential.UserName + if ($username.Contains('\')) { + $userSplit = $username.Split('\', 2) + $domain = $userSplit[0] + $username = $userSplit[1] + } + + $impersonated = $false + $token = [Ansible.AccessToken.TokenUtil]::LogonUser( + $username, $domain, $Credential.GetNetworkCredential().Password, + [Ansible.AccessToken.LogonType]::NewCredentials, [Ansible.AccessToken.LogonProvider]::WinNT50 + ) try { - $value = (Get-Item -LiteralPath $path).GetValue($name) - # need to do it this way return ($null -eq $value) does not work - if ($null -eq $value) { - return $false - } else { - return $true + [Ansible.AccessToken.TokenUtil]::ImpersonateToken($token) + $impersonated = $true + + Copy-Item -LiteralPath $Path -Destination $targetPath + } finally { + if ($impersonated) { + [Ansible.AccessToken.TokenUtil]::RevertToSelf() } - } catch [System.Management.Automation.ItemNotFoundException] { - # key didn't exist so the property mustn't - return $false + $token.Dispose() } + + $targetPath } -Function Get-ProgramMetadata($state, $path, $product_id, [PSCredential]$credential, $creates_path, $creates_version, $creates_service) { - # will get some metadata about the program we are trying to install or remove - $metadata = @{ - installed = $false - product_id = $null - location_type = $null - msi = $false - uninstall_string = $null - path_error = $null - } - - # set the location type and validate the path - if ($null -ne $path) { - if ($path.EndsWith(".msi", [System.StringComparison]::CurrentCultureIgnoreCase)) { - $metadata.msi = $true - } else { - $metadata.msi = $false - } +Function Get-UrlFile { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [Object] + $Module, - if ($path.StartsWith("http")) { - $metadata.location_type = [LocationType]::Http - try { - Invoke-WebRequest -Uri $path -DisableKeepAlive -UseBasicParsing -Method HEAD | Out-Null - } catch { - $metadata.path_error = "the file at the URL $path cannot be reached: $($_.Exception.Message)" - } - } elseif ($path.StartsWith("/") -or $path.StartsWith("\\")) { - $metadata.location_type = [LocationType]::Unc - if ($null -ne $credential) { - # Test-Path doesn't support supplying -Credentials, need to create PSDrive before testing - $file_path = Split-Path -Path $path - $file_name = Split-Path -Path $path -Leaf - try { - New-PSDrive -Name win_package -PSProvider FileSystem -Root $file_path -Credential $credential -Scope Script - } catch { - $module.FailJson("failed to connect network drive with credentials: $($_.Exception.Message)", $_) - } - $test_path = "win_package:\$file_name" - } else { - # Someone is using an auth that supports credential delegation, at least it will fail otherwise - $test_path = $path - } + [Parameter(Mandatory=$true)] + [String] + $Url + ) - $valid_path = Test-Path -LiteralPath $test_path -PathType Leaf - if ($valid_path -ne $true) { - $metadata.path_error = "the file at the UNC path $path cannot be reached, ensure the user_name account has access to this path or use an auth transport with credential delegation" - } - } else { - $metadata.location_type = [LocationType]::Local - $valid_path = Test-Path -LiteralPath $path -PathType Leaf - if ($valid_path -ne $true) { - $metadata.path_error = "the file at the local path $path cannot be reached" - } + Invoke-WithWebRequest -Module $module -Request (Get-AnsibleWebRequest -Url $Url -Module $module) -Script { + Param ([System.Net.WebResponse]$Response, [System.IO.Stream]$Stream) + + $tempPath = Join-Path -Path $module.Tmpdir -ChildPath $Response.ResponseUri.Segments[-1] + $fs = [System.IO.File]::Create($tempPath) + try { + $Stream.CopyTo($fs) + $fs.Flush() + } finally { + $fs.Dispose() } - } else { - # should only occur when state=absent and product_id is not null, we can get the uninstall string from the reg value - $metadata.location_type = [LocationType]::Empty + + $tempPath } +} - # try and get the product id - if ($null -ne $product_id) { - $metadata.product_id = $product_id - } else { - # we can get the product_id if the path is an msi and is either a local file or unc file with credential delegation - if (($metadata.msi -eq $true) -and (($metadata.location_type -eq [LocationType]::Local) -or ($metadata.location_type -eq [LocationType]::Unc -and $null -eq $credential))) { - Add-CSharpType -AnsibleModule $module -References $msi_tools - try { - $metadata.product_id = [Ansible.MsiTools]::GetPackageProperty($path, "ProductCode") - } catch { - $module.FailJson("failed to get product_id from MSI at $($path): $($_.Exception.Message)", $_) +Function Format-PackageStatus { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [AllowEmptyString()] + [String] + $Id, + + [Parameter(Mandatory=$true)] + [String] + $Provider, + + [Switch] + $Installed, + + [Switch] + $Skip, + + [Switch] + $SkipFileForRemove, + + [Hashtable] + $ExtraInfo = @{} + ) + + @{ + Id = $Id + Installed = $Installed.IsPresent + Provider = $Provider + Skip = $Skip.IsPresent + SkipFileForRemove = $SkipFileForRemove.IsPresent + ExtraInfo = $ExtraInfo + } +} + +Function Get-InstalledStatus { + [CmdletBinding()] + param ( + [String] + $Path, + + [String] + $Id, + + [String] + $Provider, + + [String] + $CreatesPath, + + [String] + $CreatesService, + + [String] + $CreatesVersion + ) + + if ($Path) { + if ($Provider -eq 'auto') { + foreach ($info in $providerInfo.GetEnumerator()) { + if ((&$info.Value.FileSupported -Path $Path)) { + $Provider = $info.Key + break + } } - } elseif ($null -eq $creates_path -and $null -eq $creates_service) { - # we need to fail without the product id at this point - $module.FailJson("product_id is required when the path is not an MSI or the path is an MSI but not local") } - } - if ($null -ne $metadata.product_id) { - $uninstall_key = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$($metadata.product_id)" - $uninstall_key_wow64 = "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\$($metadata.product_id)" - if (Test-Path -LiteralPath $uninstall_key) { - $metadata.installed = $true - } elseif (Test-Path -LiteralPath $uninstall_key_wow64) { - $metadata.installed = $true - $uninstall_key = $uninstall_key_wow64 + $status = &$providerInfo."$Provider".Test -Path $Path -Id $Id + } else { + if ($Provider -eq 'auto') { + $providerList = [String[]]$providerInfo.Keys + } else { + $providerList = @($Provider) } - # if the reg key exists, try and get the uninstall string and check if it is an MSI - if ($metadata.installed -eq $true -and $metadata.location_type -eq [LocationType]::Empty) { - if (Test-RegistryProperty -path $uninstall_key -name "UninstallString") { - $metadata.uninstall_string = (Get-ItemProperty -LiteralPath $uninstall_key -Name "UninstallString").UninstallString - if ($metadata.uninstall_string.StartsWith("MsiExec")) { - $metadata.msi = $true - } + foreach ($name in $providerList) { + $status = &$providerInfo."$name".Test -Id $Id + + # If the package was installed for the provider (or was the last provider available). + if ($status.Installed -or $providerList[-1] -eq $name) { + break } } } - # use the creates_* to determine if the program is installed - if ($null -ne $creates_path) { - $path_exists = Test-Path -LiteralPath $creates_path - $metadata.installed = $path_exists + if ($CreatesPath) { + $exists = Test-Path -LiteralPath $CreatesPath + $status.Installed = $exists - if ($null -ne $creates_version -and $path_exists -eq $true) { - if (Test-Path -LiteralPath $creates_path -PathType Leaf) { - $existing_version = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($creates_path).FileVersion - $version_matched = $creates_version -eq $existing_version - $metadata.installed = $version_matched + if ($CreatesVersion) { + if (Test-Path -LiteralPath $CreatesPath -PathType Leaf) { + $versionRaw = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($CreatesPath) + $existingVersion = New-Object -TypeName System.Version -ArgumentList @( + $versionRaw.FileMajorPart, $versionRaw.FileMinorPart, $versionRaw.FileBuildPart, + $versionRaw.FilePrivatePart + ) + $status.Installed = $CreatesVersion -eq $existingVersion } else { - $module.FailJson("creates_path must be a file not a directory when creates_version is set") + throw "creates_path must be a file not a directory when creates_version is set" } } } - if ($null -ne $creates_service) { - $existing_service = Get-Service -Name $creates_service -ErrorAction SilentlyContinue - $service_exists = $null -ne $existing_service - $metadata.installed = $service_exists + + if ($CreatesService) { + $serviceInfo = Get-Service -Name $CreatesService -ErrorAction SilentlyContinue + $status.Installed = $null -ne $serviceInfo } + Format-PackageStatus @status +} + +Function Invoke-Executable { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [Object] + $Module, + + [Parameter(Mandatory=$true)] + [String] + $Command, + + [Int32[]] + $ReturnCodes, + + [String] + $LogPath, + + [String] + $WorkingDirectory, - # finally throw error if path is not valid unless we want to uninstall the package and it already is - if ($null -ne $metadata.path_error -and (-not ($state -eq "absent" -and $metadata.installed -eq $false))) { - $module.FailJson($metadata.path_error) + [String] + $ConsoleOutputEncoding + ) + + $commandArgs = @{ + command = $Command + } + if ($WorkingDirectory) { + $commandArgs.working_directory = $WorkingDirectory + } + if ($ConsoleOutputEncoding) { + $commandArgs.output_encoding_override = $ConsoleOutputEncoding } - return $metadata -} + $result = Run-Command @commandArgs -Function Convert-Encoding($string) { - # this will attempt to detect UTF-16 encoding and convert to UTF-8 for - # processes like msiexec - $bytes = ([System.Text.Encoding]::Default).GetBytes($string) - $is_utf16 = $true - for ($i = 0; $i -lt $bytes.Count; $i = $i + 2) { - $char = $bytes[$i + 1] - if ($char -ne [byte]0) { - $is_utf16 = $false - break + $module.Result.rc = $result.rc + if ($ReturnCodes -notcontains $result.rc) { + $module.Result.stdout = $result.stdout + $module.Result.stderr = $result.stderr + if ($LogPath -and (Test-Path -LiteralPath $LogPath)) { + $module.Result.log = (Get-Content -LiteralPath $LogPath | Out-String) } - } - if ($is_utf16 -eq $true) { - return ([System.Text.Encoding]::Unicode).GetString($bytes) + $module.FailJson("unexpected rc from '$($commandArgs.command)': see rc, stdout, and stderr for more details") } else { - return $string + $module.Result.failed = $false + } + + if ($result.rc -eq 3010) { + $module.Result.reboot_required = $true } } -$program_metadata = Get-ProgramMetadata -state $state -path $path -product_id $product_id -credential $credential -creates_path $creates_path -creates_version $creates_version -creates_service $creates_service -if ($state -eq "absent") { - if ($program_metadata.installed -eq $true) { - # artifacts we create that must be cleaned up - $cleanup_artifacts = @() - try { - # If path is on a network and we specify credentials or path is a - # URL and not an MSI we need to get a temp local copy - if ($program_metadata.location_type -eq [LocationType]::Unc -and $null -ne $credential) { - $file_name = Split-Path -Path $path -Leaf - $local_path = [System.IO.Path]::GetRandomFileName() - Copy-Item -Path "win_package:\$file_name" -Destination $local_path -WhatIf:$check_mode - $cleanup_artifacts += $local_path - } elseif ($program_metadata.location_type -eq [LocationType]::Http -and $program_metadata.msi -ne $true) { - $local_path = [System.IO.Path]::GetRandomFileName() - - if (-not $check_mode) { - Download-File -url $path -path $local_path +Function Invoke-Msiexec { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [Object] + $Module, + + [Parameter(Mandatory=$true)] + [String[]] + $Actions, + + [String] + $Arguments, + + [Int32[]] + $ReturnCodes, + + [String] + $LogPath, + + [String] + $WorkingDirectory + ) + + $tempFile = $null + try { + if (-not $LogPath) { + $tempFile = Join-Path -Path $module.Tmpdir -ChildPath "msiexec.log" + $LogPath = $tempFile + } + + $cmd = [System.Collections.Generic.List[String]]@("$env:SystemRoot\System32\msiexec.exe") + $cmd.AddRange([System.Collections.Generic.List[String]]$Actions) + $cmd.AddRange([System.Collections.Generic.List[String]]@( + '/L*V', $LogPath, '/qn', '/norestart' + )) + + $invokeParams = @{ + Module = $Module + Command = (Argv-ToString -arguments $cmd) + ReturnCodes = $ReturnCodes + LogPath = $LogPath + WorkingDirectory = $WorkingDirectory + + # Msiexec is not a console application but in the case of a fatal error it does still send messages back + # over the stdout pipe. These messages are UTF-16 encoded so we override the default UTF-8. + ConsoleOutputEncoding = 'Unicode' + } + if ($Arguments) { + $invokeParams.Command += " $Arguments" + } + Invoke-Executable @invokeParams + } finally { + if ($tempFile -and (Test-Path -LiteralPath $tempFile)) { + Remove-Item -LiteralPath $tempFile -Force + } + } +} + +$providerInfo = [Ordered]@{ + msi = @{ + FileSupported = { + param ([String]$Path) + + [Ansible.WInPackage.MsiHelper]::IsMsi($Path) + } + + Test = { + param ([String]$Path, [String]$Id) + + if ($Path) { + $msiHandle = [Ansible.WinPackage.MsiHelper]::OpenPackage($Path) + try { + $Id = [Ansible.WinPackage.MsiHelper]::GetProperty($msiHandle, 'ProductCode') + } finally { + $msiHandle.Dispose() } - $cleanup_artifacts += $local_path - } elseif ($program_metadata.location_type -eq [LocationType]::Empty -and $program_metadata.msi -ne $true) { - # TODO validate the uninstall_string to see if there are extra args in there - $local_path = $program_metadata.uninstall_string + } + + $installState = [Ansible.WinPackage.MsiHelper]::QueryProductState($Id) + + @{ + Provider = 'msi' + Id = $Id + Installed = $installState -eq [Ansible.WinPackage.InstallState]::Default + SkipFileForRemove = $true + } + } + + Set = { + param ( + [String] + $Arguments, + + [Int32[]] + $ReturnCodes, + + [String] + $Id, + + [String] + $LogPath, + + [Object] + $Module, + + [String] + $Path, + + [String] + $State, + + [String] + $WorkingDirectory + ) + + if ($state -eq 'present') { + $actions = @('/i', $Path) + + # $Module.Tmpdir only gives rights to the current user but msiexec (as SYSTEM) needs access. + Add-SystemReadAce -Path $Path } else { - $local_path = $path + $actions = @('/x', $Id) + } + + $invokeParams = @{ + Module = $Module + Actions = $actions + Arguments = $Arguments + ReturnCodes = $ReturnCodes + LogPath = $LogPath + WorkingDirectory = $WorkingDirectory } + Invoke-Msiexec @invokeParams + } + } - if ($program_metadata.msi -eq $true) { - # we are uninstalling an msi - if ( -Not $log_path ) { - $temp_path = [System.IO.Path]::GetTempPath() - $log_file = [System.IO.Path]::GetRandomFileName() - $log_path = Join-Path -Path $temp_path -ChildPath $log_file - $cleanup_artifacts += $log_path + msix = @{ + FileSupported = { + param ([String]$Path) + + $extension = [System.IO.Path]::GetExtension($Path) + + $extension -in @('.appx', '.appxbundle', '.msix', '.msixbundle') + } + + Test = { + param ([String]$Path, [String]$Id) + + $package = $null + + if ($Path) { + # Cannot find a native way to get the package info from the actual path so we need to inspect the XML + # manually. + $null = Add-Type -AssemblyName System.IO.Compression + $null = Add-Type -AssemblyName System.IO.Compression.FileSystem + + $archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Read, + [System.Text.Encoding]::UTF8) + try { + $manifestEntry = $archive.Entries | Where-Object { + $_.FullName -in @('AppxManifest.xml', 'AppxMetadata/AppxBundleManifest.xml') + } + $manifestStream = New-Object -TypeName System.IO.StreamReader -ArgumentList $manifestEntry.Open() + try { + $manifest = [xml]$manifestStream.ReadToEnd() + } finally { + $manifestStream.Dispose() + } + } finally { + $archive.Dispose() } - if ($null -ne $program_metadata.product_id) { - $id = $program_metadata.product_id + if ($manifestEntry.Name -eq 'AppxBundleManifest.xml') { + # https://docs.microsoft.com/en-us/uwp/schemas/bundlemanifestschema/element-identity + $name = $manifest.Bundle.Identity.Name + $publisher = $manifest.Bundle.Identity.Publisher + + $Ids = foreach ($p in $manifest.Bundle.Packages.Package) { + $version = $p.Version + + $architecture = 'neutral' + if ($p.HasAttribute('Architecture')) { + $architecture = $p.Architecture + } + + $resourceId = '' + if ($p.HasAttribute('ResourceId')) { + $resourceId = $p.ResourceId + } + + [Ansible.WinPackage.MsixHelper]::GetPackageFullName($name, $version, $publisher, $architecture, + $resourceId) + } } else { - $id = $local_path - } + # https://docs.microsoft.com/en-us/uwp/schemas/appxpackage/uapmanifestschema/element-identity + $name = $manifest.Package.Identity.Name + $version = $manifest.Package.Identity.Version + $publisher = $manifest.Package.Identity.Publisher + + $architecture = 'neutral' + if ($manifest.Package.Identity.HasAttribute('ProcessorArchitecture')) { + $architecture = $manifest.Package.Identity.ProcessorArchitecture + } + + $resourceId = '' + if ($manifest.Package.Identity.HasAttribute('ResourceId')) { + $resourceId = $manifest.$identityParent.Identity.ResourceId + } - $uninstall_arguments = @("$env:windir\system32\msiexec.exe", "/x", $id, "/L*V", $log_path, "/qn", "/norestart") + $Ids = @(,[Ansible.WinPackage.MsixHelper]::GetPackageFullName($name, $version, $publisher, + $architecture, $resourceId) + ) + } } else { - $log_path = $null - $uninstall_arguments = @($local_path) + $package = Get-AppxPackage -Name $Id -ErrorAction SilentlyContinue + $Ids = @($Id) } - if (-not $check_mode) { - $command_args = @{ - command = Argv-ToString -arguments $uninstall_arguments - } - if ($null -ne $arguments) { - $command_args['command'] += " $arguments" - } - if ($chdir) { - $command_args['working_directory'] = $chdir + # In the case when a file is specified or the user has set the full name and not the name, scan again for + # PackageFullName. + if ($null -eq $package) { + $package = Get-AppxPackage | Where-Object { $_.PackageFullName -in $Ids } + } + + # Make sure the Id is set to the PackageFullName so state=absent works. + if ($package) { + $Id = $package.PackageFullName + } + + @{ + Provider = 'msix' + Id = $Id + Installed = $null -ne $package + } + } + + Set = { + param ( + [String] + $Id, + + [Object] + $Module, + + [String] + $Path, + + [String] + $State + ) + $originalProgress = $ProgressPreference + try { + $ProgressPreference = 'SilentlyContinue' + if ($State -eq 'present') { + # Add-AppxPackage does not support a -LiteralPath parameter and it chokes on wildcard characters. + # We need to escape those characters when calling the cmdlet. + Add-AppxPackage -Path ([WildcardPattern]::Escape($Path)) + } else { + Remove-AppxPackage -Package $Id } + } catch { + # Replicate the same return values as the other providers. + $module.Result.rc = $_.Exception.HResult + $module.Result.stdout = "" + $module.Result.stderr = $_.Exception.Message + + $msg = "unexpected status from $($_.InvocationInfo.InvocationName): see rc and stderr for more details" + $module.FailJson($msg, $_) + } finally { + $ProgressPreference = $originalProgress + } + # Just set to 0 to align with other providers + $module.Result.rc = 0 + + # It looks like the reboot checks are an insider feature so we can't do a check for that today. + # https://docs.microsoft.com/en-us/windows/msix/packaging-tool/support-restart + } + } + + msp = @{ + FileSupported = { + param ([String]$Path) + + [Ansible.WInPackage.MsiHelper]::IsMsp($Path) + } + + Test = { + param ([String]$Path, [String]$Id) + + $productCodes = [System.Collections.Generic.List[System.String]]@() + if ($Path) { + $summaryInfo = [Ansible.WinPackage.MsiHelper]::GetSummaryHandle($Path) try { - $process_result = Run-Command @command_args - } catch { - $module.FailJson("failed to run uninstall process ($($command_args['command'])): $($_.Exception.Message)", $_) + $productCodesRaw = [Ansible.WinPackage.MsiHelper]::GetSummaryPropertyString( + $summaryInfo, [Ansible.WinPackage.MsiHelper]::SUMMARY_PID_TEMPLATE + ) + + # Filter out product codes that are not installed on the host. + foreach ($code in ($productCodesRaw -split ';')) { + $productState = [Ansible.WinPackage.MsiHelper]::QueryProductState($code) + if ($productState -eq [Ansible.WinPackage.InstallState]::Default) { + $productCodes.Add($code) + } + } + + if ($productCodes.Count -eq 0) { + throw "The specified patch does not apply to any installed MSI packages." + } + + # The first guid in the REVNUMBER is the patch code, the subsequent values are obsoleted patches + # which we don't care about. + $Id = [Ansible.WinPackage.MsiHelper]::GetSummaryPropertyString($summaryInfo, + [Ansible.WinPackage.MsiHelper]::SUMMARY_PID_REVNUMBER).Substring(0, 38) + } finally { + $summaryInfo.Dispose() } + } else { + foreach ($patch in ([Ansible.WinPackage.MsiHelper]::EnumPatches($null, $null, 'All', 'All'))) { + if ($patch.PatchCode -eq $Id) { + # We append "{guid}:{context}" so the check below checks the proper context, the context + # is then stripped out there. + $ProductCodes.Add("$($patch.ProductCode):$($patch.Context)") + } + } + } - if (($null -ne $log_path) -and (Test-Path -LiteralPath $log_path)) { - $log_content = Get-Content -Path $log_path | Out-String + # Filter the product list even further to only ones that are applied and not obsolete. + $skipCodes = [System.Collections.Generic.List[System.String]]@() + $productCodes = @(@(foreach ($product in $productCodes) { + if ($product.Length -eq 38) { # Guid length with braces is 38 + $contextList = @('UserManaged', 'UserUnmanaged', 'Machine') } else { - $log_content = $null + # We already know the context and was appended to the product guid with ';context' + $productInfo = $product.Split(':', 2) + $product = $productInfo[0] + $contextList = @($productInfo[1]) } - $module.Result.rc = $process_result.rc - if ($expected_return_code -notcontains $process_result.rc) { - $module.Result.stdout = Convert-Encoding -string $process_result.stdout - $module.Result.stderr = Convert-Encoding -string $process_result.stderr - if ($null -ne $log_content) { - $module.Result.log = $log_content + foreach ($context in $contextList) { + try { + # GetPatchInfo('State') returns a string that is a number of an enum value. + $state = [Ansible.WinPackage.PatchState][UInt32]([Ansible.WinPackage.MsiHelper]::GetPatchInfo( + $Id, $product, $null, $context, 'State' + )) + } catch [System.ComponentModel.Win32Exception] { + if ($_.Exception.NativeErrorCode -in @(0x00000645, 0x0000066F)) { + # ERROR_UNKNOWN_PRODUCT can be raised if the product is not installed in the context + # specified, just try the next one. + # ERROR_UNKNOWN_PATCH can be raised if the patch is not installed but the product is. + continue + } + throw + } + + if ($state -eq [Ansible.WinPackage.PatchState]::Applied) { + # The patch is applied to the product code, output the code for the outer list to capture. + $product + } elseif ($state.ToString() -in @('Obsoleted', 'Superseded')) { + # If the patch is obsoleted or suprseded we cannot install or remove but consider it equal to + # state=absent and present so we skip the set step. + $skipCodes.Add($product) } - $module.FailJson("unexpected rc from uninstall $uninstall_exe $($uninstall_arguments): see rc, stdout and stderr for more details") - } else { - $module.Result.failed = $false } + }) | Select-Object -Unique) - if ($process_result.rc -eq 3010) { - $module.Result.reboot_required = $true + @{ + Provider = 'msp' + Id = $Id + Installed = $productCodes.Length -gt 0 + Skip = $skipCodes.Length -eq $productCodes.Length + SkipFileForRemove = $true + ExtraInfo = @{ + ProductCodes = $productCodes } } - } finally { - # make sure we cleanup any remaining artifacts - foreach ($cleanup_artifact in $cleanup_artifacts) { - if (Test-Path -LiteralPath $cleanup_artifact) { - Remove-Item -Path $cleanup_artifact -Recurse -Force -WhatIf:$check_mode + } + + Set = { + param ( + [String] + $Arguments, + + [Int32[]] + $ReturnCodes, + + [String] + $Id, + + [String] + $LogPath, + + [Object] + $Module, + + [String] + $Path, + + [String] + $State, + + [String] + $WorkingDirectory, + + [String[]] + $ProductCodes + ) + + $tempLink = $null + try { + $actions = @(if ($state -eq 'present') { + # $Module.Tmpdir only gives rights to the current user but msiexec (as SYSTEM) needs access. + Add-SystemReadAce -Path $Path + + # MsiApplyPatchW fails if the path contains a ';', we need to use a temporary symlink instead. + # https://docs.microsoft.com/en-us/windows/win32/api/msi/nf-msi-msiapplypatchw + if ($Path.Contains(';')) { + $tempLink = Join-Path -Path $env:TEMP -ChildPath "win_package-$([System.IO.Path]::GetRandomFileName()).msp" + $res = Run-Command -command (Argv-ToString -arguments @("cmd.exe", "/c", "mklink", $tempLink, $Path)) + if ($res.rc -ne 0) { + $Module.Result.rc = $res.rc + $Module.Result.stdout = $res.stdout + $Module.Result.stderr = $res.stderr + + $Module.FailJson("Failed to create temporary symlink '$tempLink' -> '$Path' for msiexec patch install as path contains semicolon") + } + $Path = $tempLink + } + + ,@('/update', $Path) + } else { + foreach ($code in $ProductCodes) { + ,@('/uninstall', $Id, '/package', $code) + } + }) + + $invokeParams = @{ + Arguments = $Arguments + Module = $Module + ReturnCodes = $ReturnCodes + LogPath = $LogPath + WorkingDirectory = $WorkingDirectory + } + foreach ($action in $actions) { + Invoke-Msiexec -Actions $action @invokeParams + } + } finally { + if ($tempLink -and (Test-Path -LiteralPath $tempLink)) { + Remove-Item -LiteralPath $tempLink -Force } } } - - $module.Result.changed = $true } -} else { - if ($program_metadata.installed -eq $false) { - # artifacts we create that must be cleaned up - $cleanup_artifacts = @() - try { - # If path is on a network and we specify credentials or path is a - # URL and not an MSI we need to get a temp local copy - if ($program_metadata.location_type -eq [LocationType]::Unc -and $null -ne $credential) { - $file_name = Split-Path -Path $path -Leaf - $local_path = [System.IO.Path]::GetRandomFileName() - Copy-Item -Path "win_package:\$file_name" -Destination $local_path -WhatIf:$check_mode - $cleanup_artifacts += $local_path - } elseif ($program_metadata.location_type -eq [LocationType]::Http -and $program_metadata.msi -ne $true) { - $local_path = [System.IO.Path]::GetRandomFileName() - - if (-not $check_mode) { - Download-File -url $path -path $local_path + + # Should always be last as the FileSupported is a catch all. + registry = @{ + FileSupported = { $true } + + Test = { + param ([String]$Id) + + $status = @{ + Provider = 'registry' + Id = $Id + Installed = $false + ExtraInfo = @{ + RegistryPath = $null } - $cleanup_artifacts += $local_path - } else { - $local_path = $path } - if ($program_metadata.msi -eq $true) { - # we are installing an msi - if ( -Not $log_path ) { - $temp_path = [System.IO.Path]::GetTempPath() - $log_file = [System.IO.Path]::GetRandomFileName() - $log_path = Join-Path -Path $temp_path -ChildPath $log_file - $cleanup_artifacts += $log_path + if ($Id) { + :regLoop foreach ($hive in @("HKLM", "HKCU")) { # Search machine wide and user specific. + foreach ($key in @("SOFTWARE", "SOFTWARE\Wow6432Node")) { # Search the 32 and 64-bit locations. + $regPath = "$($hive):\$key\Microsoft\Windows\CurrentVersion\Uninstall\$Id" + if (Test-Path -LiteralPath $regPath) { + $status.Installed = $true + $status.ExtraInfo.RegistryPath = $regPath + break regLoop + } + } } + } - $install_arguments = @("$env:windir\system32\msiexec.exe", "/i", $local_path, "/L*V", $log_path, "/qn", "/norestart") - } else { - $log_path = $null - $install_arguments = @($local_path) + $status + } + + Set = { + param ( + [String] + $Arguments, + + [Int32[]] + $ReturnCodes, + + [Object] + $Module, + + [String] + $Path, + + [String] + $State, + + [String] + $WorkingDirectory, + + [String] + $RegistryPath + ) + + $invokeParams = @{ + Module = $Module + ReturnCodes = $ReturnCodes + WorkingDirectory = $WorkingDirectory } - if (-not $check_mode) { - $command_args = @{ - command = Argv-ToString -arguments $install_arguments - } - if ($null -ne $arguments) { - $command_args['command'] += " $arguments" - } - if ($chdir) { - $command_args['working_directory'] = $chdir - } + if ($Path) { + $invokeParams.Command = Argv-ToString -arguments @($Path) + } else { + $registryProperties = Get-ItemProperty -LiteralPath $RegistryPath - try { - $process_result = Run-Command @command_args - } catch { - $module.FailJson("failed to run install process ($($command_args['command'])): $($_.Exception.Message)", $_) + if ('QuietUninstallString' -in $registryProperties.PSObject.Properties.Name) { + $command = $registryProperties.QuietUninstallString + } elseif ('UninstallString' -in $registryProperties.PSObject.Properties.Name) { + $command = $registryProperties.UninstallString + } else { + $module.FailJson("Failed to find registry uninstall string at registry path '$RegistryPath'") } - if (($null -ne $log_path) -and (Test-Path -LiteralPath $log_path)) { - $log_content = Get-Content -Path $log_path | Out-String - } else { - $log_content = $null + # If the uninstall string starts with '%', we need to expand the env vars. + if ($command.StartsWith('%') -or $command.StartsWith('"%')) { + $command = [System.Environment]::ExpandEnvironmentVariables($command) } - $module.Result.rc = $process_result.rc - if ($expected_return_code -notcontains $process_result.rc) { - $module.Result.stdout = Convert-Encoding -string $process_result.stdout - $module.Result.stderr = Convert-Encoding -string $process_result.stderr - if ($null -ne $log_content) { - $module.Result.log = $log_content + # If the command is not quoted and contains spaces we need to see if it needs to be manually quoted for the executable. + if (-not $command.StartsWith('"') -and $command.Contains(' ')) { + $rawArguments = [System.Collections.Generic.List[String]]@() + + $executable = New-Object -TypeName System.Text.StringBuilder + foreach ($cmd in ([Ansible.Process.ProcessUtil]::ParseCommandLine($command))) { + if ($rawArguments.Count -eq 0) { + # Still haven't found the path, append the arg to the executable path and see if it exists. + $null = $executable.Append($cmd) + $exe = $executable.ToString() + if (Test-Path -LiteralPath $exe -PathType Leaf) { + $rawArguments.Add($exe) + } else { + $null = $executable.Append(" ") # The arg had a space and we need to preserve that. + } + } else { + $rawArguments.Add($cmd) + } } - $module.FailJson("unexpected rc from install $install_exe $($install_arguments): see rc, stdout and stderr for more details") - } else { - $module.Result.failed = $false - } - if ($process_result.rc -eq 3010) { - $module.Result.reboot_required = $true + # If we still couldn't find a file just use the command literally and hope WIndows can handle it, + # otherwise recombind the args which will also quote whatever is needed. + if ($rawArguments.Count -gt 0) { + $command = Argv-ToString -arguments $rawArguments + } } + + $invokeParams.Command = $command } - } finally { - # make sure we cleanup any remaining artifacts - foreach ($cleanup_artifact in $cleanup_artifacts) { - if (Test-Path -LiteralPath $cleanup_artifact) { - Remove-Item -Path $cleanup_artifact -Recurse -Force -WhatIf:$check_mode - } + + if ($Arguments) { + $invokeParams.Command += " $Arguments" } + + Invoke-Executable @invokeParams } + } +} - $module.Result.changed = $true +$spec = @{ + options = @{ + arguments = @{ type = "raw" } + expected_return_code = @{ type = "list"; elements = "int"; default = @(0, 3010) } + path = @{ type = "str"} + chdir = @{ type = "path" } + product_id = @{ + type = "str" + aliases = @("productid") + deprecated_aliases = @( + @{ name = "productid"; version = "2.14" } + ) + } + state = @{ + type = "str" + default = "present" + choices = "absent", "present" + aliases = @(,"ensure") + deprecated_aliases = @( + ,@{ name = "ensure"; version = "2.14" } + ) + } + username = @{ type = "str"; aliases = @(,"user_name"); removed_in_version = "2.14" } + password = @{ type = "str"; no_log = $true; aliases = @(,"user_password"); removed_in_version = "2.14" } + creates_path = @{ type = "path" } + creates_version = @{ type = "str" } + creates_service = @{ type = "str" } + log_path = @{ type = "path" } + provider = @{ type = "str"; default = "auto"; choices = $providerInfo.Keys + "auto" } + } + required_by = @{ + creates_version = "creates_path" } + required_if = @( + @("state", "present", @("path")), + @("state", "absent", @("path", "product_id"), $true) + ) + required_together = @(,@("username", "password")) + supports_check_mode = $true } +$spec = Merge-WebRequestSpec -ModuleSpec $spec -$module.ExitJson() +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$arguments = $module.Params.arguments +$expectedReturnCode = $module.Params.expected_return_code +$path = $module.Params.path +$chdir = $module.Params.chdir +$productId = $module.Params.product_id +$state = $module.Params.state +$username = $module.Params.username +$password = $module.Params.password +$createsPath = $module.Params.creates_path +$createsVersion = $module.Params.creates_version +$createsService = $module.Params.creates_service +$logPath = $module.Params.log_path +$provider = $module.Params.provider +$module.Result.reboot_required = $false + +if ($null -ne $arguments) { + # convert a list to a string and escape the values + if ($arguments -is [array]) { + $arguments = Argv-ToString -arguments $arguments + } +} + +$credential = $null +if ($null -ne $username) { + $secPassword = ConvertTo-SecureString -String $password -AsPlainText -Force + $credential = New-Object -TypeName PSCredential -ArgumentList $username, $secPassword +} + +# This must be set after the module spec so the validate-modules sanity-test can get the arg spec. +Import-PInvokeCode -Module $module + +$pathType = $null +if ($path -and $path.StartsWith('http', [System.StringComparison]::InvariantCultureIgnoreCase)) { + $pathType = 'url' +} elseif ($path -and ($path.StartsWith('\\') -or $path.StartsWith('//') -and $username)) { + $pathType = 'unc' +} + +$tempFile = $null +try { + $getParams = @{ + Id = $productId + Provider = $provider + CreatesPath = $createsPath + CreatesVersion = $createsVersion + CreatesService = $createsService + } + + # If the path is a URL or UNC with credentials and no ID is set then create a temp copy for idempotency checks. + if ($pathType -and -not $Id) { + $tempFile = switch ($pathType) { + url { Get-UrlFile -Module $module -Url $path } + unc { Copy-ItemWithCredential -Path $path -Destination $module.Tmpdir -Credential $credential } + } + $path = $tempFile + $getParams.Path = $path + } elseif ($path -and -not $pathType) { + if (-not (Test-Path -LiteralPath $path)) { + $module.FailJson("the file at the path '$path' cannot be reached") + } + $getParams.Path = $path + } + + $packageStatus = Get-InstalledStatus @getParams + + $changed = -not $packageStatus.Skip -and (($state -eq 'present') -ne $packageStatus.Installed) + $module.Result.rc = 0 # Make sure rc is always set + if ($changed -and -not $module.CheckMode) { + # Make sure we get a temp copy of the file if the provider requires it and we haven't already done so. + if ($pathType -and -not $tempFile -and ($state -eq 'present' -or -not $packageStatus.SkipFileForRemove)) { + $tempFile = switch ($pathType) { + url { Get-UrlFile -Module $module -Url $path } + unc { Copy-ItemWithCredential -Path $path -Destination $module.Tmpdir -Credential $credential } + } + $path = $tempFile + } + + $setParams = @{ + Arguments = $arguments + ReturnCodes = $expectedReturnCode + Id = $packageStatus.Id + LogPath = $logPath + Module = $module + Path = $path + State = $state + WorkingDirectory = $chdir + } + $setParams += $packageStatus.ExtraInfo + &$providerInfo."$($packageStatus.Provider)".Set @setParams + } + $module.Result.changed = $changed +} finally { + if ($tempFile -and (Test-Path -LiteralPath $tempFile)) { + Remove-Item -LiteralPath $tempFile -Force + } +} + +$module.ExitJson() diff --git a/lib/ansible/modules/windows/win_package.py b/lib/ansible/modules/windows/win_package.py index b7c1b184616..864fe6975d8 100644 --- a/lib/ansible/modules/windows/win_package.py +++ b/lib/ansible/modules/windows/win_package.py @@ -17,10 +17,12 @@ module: win_package version_added: "1.7" short_description: Installs/uninstalls an installable package description: -- Installs or uninstalls a package in either an MSI or EXE format. -- These packages can be sources from the local file system, network file share +- Installs or uninstalls software packages for Windows. +- Supports C(.exe), C(.msi), C(.msp), C(.appx), C(.appxbundle), C(.msix), + and C(.msixbundle). +- These packages can be sourced from the local file system, network file share or a url. -- Please read the notes section around some caveats with this module. +- See I(provider) for more info on each package type that is supported. options: arguments: description: @@ -28,6 +30,7 @@ options: package. - If the package is an MSI do not supply the C(/qn), C(/log) or C(/norestart) arguments. + - This is only used for the C(msi), C(msp), and C(registry) providers. - As of Ansible 2.5, this parameter can be a list of arguments and the module will escape the arguments as necessary, it is recommended to use a string when dealing with MSI packages due to the unique escaping issues @@ -37,6 +40,7 @@ options: description: - Set the specified path as the current working directory before installing or uninstalling a package. + - This is only used for the C(msi), C(msp), and C(registry) providers. type: path version_added: '2.8' creates_path: @@ -69,11 +73,25 @@ options: C(3010). - A return code of C(3010) usually means that a reboot is required, the C(reboot_required) return value is set if the return code is C(3010). + - This is only used for the C(msi), C(msp), and C(registry) providers. type: list + elements: int default: [0, 3010] + log_path: + description: + - Specifies the path to a log file that is persisted after a package is + installed or uninstalled. + - This is only used for the C(msi) or C(msp) provider. + - When omitted, a temporary log file is used instead for those providers. + - This is only valid for MSI files, use C(arguments) for the C(registry) + provider. + type: path + version_added: '2.8' password: description: - The password for C(user_name), must be set when C(user_name) is. + - This option is deprecated in favour of using become, see examples for + more information. type: str aliases: [ user_password ] path: @@ -81,11 +99,10 @@ options: - Location of the package to be installed or uninstalled. - This package can either be on the local file system, network share or a url. - - If the path is on a network share and the current WinRM transport doesn't - support credential delegation, then C(user_name) and C(user_password) - must be set to access the file. - - There are cases where this file will be copied locally to the server so - it can access it, see the notes for more info. + - When C(state=present), C(product_id) is not set and the path is a URL, + this file will always be downloaded to a temporary directory for + idempotency checks, otherwise the file will only be downloaded if the + package has not been installed based on the C(product_id) checks. - If C(state=present) then this value MUST be set. - If C(state=absent) then this value does not need to be set if C(product_id) is. @@ -95,21 +112,66 @@ options: - The product id of the installed packaged. - This is used for checking whether the product is already installed and getting the uninstall information if C(state=absent). - - You can find product ids for installed programs in the Windows registry - editor either at - C(HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall) or for 32 bit - programs at - C(HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall). - - This SHOULD be set when the package is not an MSI, or the path is a url + - For msi packages, this is the C(ProductCode) (GUID) of the package. This + can be found under the same registry paths as the C(registry) provider. + - For msp packages, this is the C(PatchCode) (GUID) of the package which + can found under the C(Details -> Revision number) of the file's properties. + - For msix packages, this is the C(Name) or C(PackageFullName) of the + package found under the C(Get-AppxPackage) cmdlet. + - For registry (exe) packages, this is the registry key name under the + registry paths specified in I(provider). + - This value is ignored if C(path) is set to a local accesible file path + and the package is not an C(exe). + - This SHOULD be set when the package is an C(exe), or the path is a url or a network share and credential delegation is not being used. The C(creates_*) options can be used instead but is not recommended. + - The C(productid) alias will be removed in Ansible 2.14. type: str aliases: [ productid ] + provider: + description: + - Set the package provider to use when searching for a package. + - The C(auto) provider will select the proper provider if I(path) + otherwise it scans all the other providers based on the I(product_id). + - The C(msi) provider scans for MSI packages installed on a machine wide + and current user context based on the C(ProductCode) of the MSI. Before + Ansible 2.10 only the machine wide context was searched. + - The C(msix) provider is used to install C(.appx), C(.msix), + C(.appxbundle), or C(.msixbundle) packages. These packages are only + installed or removed on the current use. The host must be set to allow + sideloaded apps or in developer mode. See the examples for how to enable + this. If a package is already installed but C(path) points to an updated + package, this will be installed over the top of the existing one. + - The C(msp) provider scans for all MSP patches installed on a machine wide + and current user context based on the C(PatchCode) of the MSP. A C(msp) + will be applied or removed on all C(msi) products that it applies to and + is installed. If the patch is obsoleted or superseded then no action will + be taken. + - The C(registry) provider is used for traditional C(exe) installers and + uses the following registry path to determine if a product was installed; + C(HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall), + C(HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall), + C(HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall), and + C(HKCU:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall). + Before Ansible 2.10 only the C(HKLM) hive was searched. + - Before Ansible 2.10 only the C(msi) and C(registry) providers were used. + choices: + - auto + - msi + - msix + - msp + - registry + default: auto + type: str + version_added: '2.10' state: description: - Whether to install or uninstall the package. - - The module uses C(product_id) and whether it exists at the registry path - to see whether it needs to install or uninstall the package. + - The module uses I(product_id) to determine whether the package is + installed or not. + - For all providers but C(auto), the I(path) can be used for idempotency + checks if it is locally accesible filesystem path. + - The C(ensure) alias will be removed in Ansible 2.14. type: str choices: [ absent, present ] default: present @@ -119,39 +181,61 @@ options: - Username of an account with access to the package if it is located on a file share. - This is only needed if the WinRM transport is over an auth method that - does not support credential delegation like Basic or NTLM. + does not support credential delegation like Basic or NTLM or become is + not used. + - This option is deprecated in favour of using become, see examples for + more information. type: str aliases: [ user_name ] - validate_certs: - description: - - If C(no), SSL certificates will not be validated. This should only be - used on personally controlled sites using self-signed certificates. - - Before Ansible 2.4 this defaulted to C(no). - type: bool - default: yes - version_added: '2.4' - log_path: + + # Overrides the options in url_windows + client_cert: + version_added: '2.10' + client_cert_password: + version_added: '2.10' + follow_redirects: + version_added: '2.10' + force_basic_auth: + version_added: '2.10' + headers: + version_added: '2.10' + http_agent: + version_added: '2.10' + maximum_redirection: + version_added: '2.10' + method: + version_added: '2.10' + proxy_password: + version_added: '2.10' + proxy_url: + version_added: '2.10' + proxy_use_default_credential: + version_added: '2.10' + proxy_username: + version_added: '2.10' + timeout: description: - - Specifies the path to a log file that is persisted after an MSI package is installed or uninstalled. - - When omitted, a temporary log file is used for MSI packages. - - This is only valid for MSI files, use C(arguments) for other package types. - type: path - version_added: '2.8' + - Specifies how long the web download request can be pending before it + times out in seconds. + - Set to C(0) to specify an infinite timeout. + version_added: '2.10' + url_password: + version_added: '2.10' + url_username: + version_added: '2.10' + use_default_credential: + version_added: '2.10' + use_proxy: + version_added: '2.10' +extends_documentation_fragment: +- url_windows notes: - When C(state=absent) and the product is an exe, the path may be different from what was used to install the package originally. If path is not set then - the path used will be what is set under C(UninstallString) in the registry - for that product_id. -- Not all product ids are in a GUID form, some programs incorrectly use a - different structure but this module should support any format. -- By default all msi installs and uninstalls will be run with the options + the path used will be what is set under C(QuietUninstallString) or + C(UninstallString) in the registry for that I(product_id). +- By default all msi installs and uninstalls will be run with the arguments C(/log, /qn, /norestart). -- It is recommended you download the package first from the URL using the - M(win_get_url) module as it opens up more flexibility with what must be set - when calling C(win_package). -- Packages will be temporarily downloaded or copied locally when path is a - network location and credential delegation is not set, or path is a URL - and the file is not an MSI. - All the installation checks under C(product_id) and C(creates_*) add together, if one fails then the program is considered to be absent. seealso: @@ -170,7 +254,7 @@ EXAMPLES = r''' product_id: '{CF2BEA3C-26EA-32F8-AA9B-331F7E34BA97}' arguments: /install /passive /norestart -- name: Install Visual C thingy with list of arguments instead of a string, and permanent log +- name: Install Visual C thingy with list of arguments instead of a string win_package: path: http://download.microsoft.com/download/1/6/B/16B06F60-3B20-4FF2-B699-5E9B7962F9AE/VSU_4/vcredist_x64.exe product_id: '{CF2BEA3C-26EA-32F8-AA9B-331F7E34BA97}' @@ -178,13 +262,13 @@ EXAMPLES = r''' - /install - /passive - /norestart - log_path: D:\logs\vcredist_x64-exe-{{lookup('pipe', 'date +%Y%m%dT%H%M%S')}}.log -- name: Install Remote Desktop Connection Manager from msi +- name: Install Remote Desktop Connection Manager from msi with a permanent log win_package: path: https://download.microsoft.com/download/A/F/0/AF0071F3-B198-4A35-AA90-C68D103BDCCF/rdcman.msi product_id: '{0240359E-6A4C-4884-9E94-B397A02D893C}' state: present + log_path: D:\logs\vcredist_x64-exe-{{lookup('pipe', 'date +%Y%m%dT%H%M%S')}}.log - name: Uninstall Remote Desktop Connection Manager win_package: @@ -202,14 +286,18 @@ EXAMPLES = r''' state: absent # 7-Zip exe doesn't use a guid for the Product ID -- name: Install 7zip from a network share specifying the credentials +- name: Install 7zip from a network share with specific credentials win_package: path: \\domain\programs\7z.exe product_id: 7-Zip arguments: /S state: present - user_name: DOMAIN\User - user_password: Password + become: yes + become_method: runas + become_flags: logon_type=new_credential logon_flags=netcredentials_only + vars: + ansible_become_user: DOMAIN\User + ansible_become_password: Password - name: Install 7zip and use a file version for the installation check win_package: @@ -238,12 +326,40 @@ EXAMPLES = r''' arguments: '/q /norestart' state: present expected_return_code: [0, 666, 3010] + +- name: Install a .msp patch + win_package: + path: C:\Patches\Product.msp + state: present + +- name: Remove a .msp patch + win_package: + product_id: '{AC76BA86-A440-FFFF-A440-0C13154E5D00}' + state: absent + +- name: Enable installation of 3rd party MSIX packages + win_regedit: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock + name: AllowAllTrustedApps + data: 1 + type: dword + state: present + +- name: Install an MSIX package for the current user + win_package: + path: C:\Installers\Calculator.msix # Can be .appx, .msixbundle, or .appxbundle + state: present + +- name: Uninstall an MSIX package using the product_id + win_package: + product_id: InputApp + state: absent ''' RETURN = r''' log: - description: The contents of the MSI log. - returned: installation/uninstallation failure for MSI packages + description: The contents of the MSI or MSP log. + returned: installation/uninstallation failure for MSI or MSP packages type: str sample: Installation completed successfully rc: diff --git a/test/integration/targets/win_package/defaults/main.yml b/test/integration/targets/win_package/defaults/main.yml index c713139e81f..87d6ec8c169 100644 --- a/test/integration/targets/win_package/defaults/main.yml +++ b/test/integration/targets/win_package/defaults/main.yml @@ -1,21 +1,35 @@ --- -# spaces are tricky, let's have one by default -test_win_package_path_safe: C:\ansible\win_package -test_win_package_path: C:\ansible\win package -test_win_package_log_path_install: C:\ansible\win package\test-install.log -test_win_package_log_path_uninstall: C:\ansible\win package\test-uninstall.log -test_win_package_good_url: https://ansible-ci-files.s3.amazonaws.com/test/integration/roles/test_win_package/good.msi -test_win_package_reboot_url: https://ansible-ci-files.s3.amazonaws.com/test/integration/roles/test_win_package/reboot.msi -test_win_package_bad_url: https://ansible-ci-files.s3.amazonaws.com/test/integration/roles/test_win_package/bad.msi -test_win_package_exe_url: https://ansible-ci-files.s3.amazonaws.com/test/integration/roles/test_win_package/7z.exe # TODO: change to it's own executable +test_path: '{{ remote_tmp_dir }}\win_package .ÅÑŚÌβŁÈ [$!@^&test(;)]' -test_win_package_good_id: '{223D9A13-653B-4231-A365-EDDC30B4F226}' -test_win_package_reboot_id: '{223D9A13-653B-4231-A365-EDDC30B4F227}' -test_win_package_exe_id: 7-Zip +# MSI packages +good_url: https://ansible-ci-files.s3.amazonaws.com/test/integration/roles/test_win_package/good.msi +reboot_url: https://ansible-ci-files.s3.amazonaws.com/test/integration/roles/test_win_package/reboot.msi +bad_url: https://ansible-ci-files.s3.amazonaws.com/test/integration/roles/test_win_package/bad.msi -# define the below to run the network tests, all 3 msi's should exist in this path -# test_win_package_network_path: \\ANSIBLE\network +# MSIX tools +makeappx_url: https://ansible-ci-files.s3.amazonaws.com/test/integration/targets/win_package/makeappx.zip -# set the below to test a network path without credential delegation like Basic or NTLM -# test_win_package_network_username: ANSIBLE\User -# test_win_package_network_password: Password \ No newline at end of file +# MSP packages - https://wixtoolset.org/documentation/manual/v3/patching/patch_building.html +patch_msi_url: https://ansible-ci-files.s3.amazonaws.com/test/integration/targets/win_package/patch.msi +patch_msp_url: https://ansible-ci-files.s3.amazonaws.com/test/integration/targets/win_package/patch.msp +patch_install_file: C:\Program Files (x86)\Patch Sample Directory\Sample.txt + +good_id: '{223D9A13-653B-4231-A365-EDDC30B4F226}' +reboot_id: '{223D9A13-653B-4231-A365-EDDC30B4F227}' +patch_product_id: '{48C49ACE-90CF-4161-9C6E-9162115A54DD}' +patch_patch_id: '{224C316C-5894-4771-BABF-21A3AC1F75FF}' +msix_id: WinPackageMsix +msixbundle_id: WinPackageBundleMsix +appx_id: WinPackageAppx +appxbundle_id: WinPackageBundleAppx +registry_id: WinPackageRegistry + +all_ids: +- '{{ good_id }}' +- '{{ reboot_id }}' +- '{{ patch_product_id }}' +- '{{ msix_id }}' +- '{{ msixbundle_id }}' +- '{{ appx_id }}' +- '{{ appxbundle_id }}' +- '{{ registry_id }}' diff --git a/test/integration/targets/win_package/handlers/main.yml b/test/integration/targets/win_package/handlers/main.yml new file mode 100644 index 00000000000..41cdb8179e1 --- /dev/null +++ b/test/integration/targets/win_package/handlers/main.yml @@ -0,0 +1,15 @@ +--- +- name: remove trusted root cert + win_certificate_store: + thumbprint: '{{ test_win_package_msix_packages.thumbprint }}' + store_location: LocalMachine + store_name: Root + state: absent + +- name: remove sideloading mode for msix + win_regedit: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock + name: AllowAllTrustedApps + data: 0 + type: dword + state: present diff --git a/test/integration/targets/win_package/library/win_make_appx.ps1 b/test/integration/targets/win_package/library/win_make_appx.ps1 new file mode 100644 index 00000000000..eaf6e8e1ee5 --- /dev/null +++ b/test/integration/targets/win_package/library/win_make_appx.ps1 @@ -0,0 +1,194 @@ +#!powershell + +# Copyright: (c) 2020, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.ArgvParser +#Requires -Module Ansible.ModuleUtils.CommandUtil + +$spec = @{ + options = @{ + packages = @{ + type = "list" + elements = "dict" + options = @{ + identity = @{ type = "str"; required = $true } + version = @{ type = "str"; required = $true } + architecture = @{ type = "str" } + resource_id = @{ type = "str" } + min_version = @{ type = "str"; default = "10.0.17763.0" } + max_version = @{ type = "str"; default = "10.0.18362.0" } + filename = @{ type = "str"; required = $true } + } + } + bundles = @{ + type = "list" + elements = "dict" + options = @{ + files = @{ type = "list"; elements = "str"; required = $true } + filename = @{ type = "str"; required = $true } + } + } + publisher = @{ type = "str"; required = $true } + path = @{ type = "str"; required = $true } + makeappx_path = @{ type = "str"; required = $true } + signtool_path = @{ type = "str"; required = $true } + } +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$packages = $module.Params.packages +$bundles = $module.Params.bundles +$publisher = $module.Params.publisher +$path = $module.Params.path +$makeappxPath = $module.Params.makeappx_path +$signtoolPath = $module.Params.signtool_path + +if (-not (Test-Path -LiteralPath $path)) { + $module.FailJson("The path at '$path' does not exist") +} + +$manifest = @' + + + + + {0}DisplayName + PublisherDisplayName + Test MSIX Package for win_package + icon.png + + + + + + + + + + + + + + + + +'@ + +# bytes of http://1x1px.me/000000-0.png +$iconBytes = [System.Convert]::FromBase64String('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNiYAAAAAkAAxkR2eQAAAAASUVORK5CYII=') + +$certParams = @{ + # Can only create in the My store, so store it there temporarily. + CertStoreLocation = 'Cert:\CurrentUser\My' + FriendlyName = 'win_package test' + KeyUsage = 'DigitalSignature' + Subject = $publisher + TextExtension = @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") + Type = 'Custom' +} +$cert = New-SelfSignedCertificate @certParams + +try { + # Need to create a temporary pfx for signtool.exe and we need to import the cert to the Trusted Root store. + $module.Result.thumbprint = $cert.Thumbprint + $certPath = Join-Path -Path $module.Tmpdir -ChildPath 'cert.pfx' + $certPassword = ([char[]]([char]33..[char]126) | Sort-Object {Get-Random})[0..16] -join '' + $certPasswordSS = ConvertTo-SecureString -String $certPassword -AsPlainText -Force + $null = $cert | Export-PfxCertificate -FilePath $certPath -Password $certPasswordSS + + $importParams = @{ + FilePath = $certPath + CertStoreLocation = 'Cert:\LocalMachine\Root' + Password = $certPasswordSS + } + $null = Import-PfxCertificate @importParams +} finally { + $cert | Remove-Item -Force +} + +$module.Result.changed = $true + +foreach ($info in $packages) { + $architectureAttribute = "" + if ($info.architecture) { + $architectureAttribute = " ProcessorArchitecture=`"$($info.architecture)`"" + } + + $resourceIdAttribute = "" + if ($info.resource_id) { + $resourceIdAttribute = " ResourceId=`"$($info.resource_id)`"" + } + + $xml = $manifest -f @( + $info.identity, $info.version, $publisher, $architectureAttribute, $resourceIdAttribute, $info.min_version, + $info.max_version + ) + + $tempDir = Join-Path -Path $module.Tmpdir -ChildPath ([System.IO.Path]::GetRandomFileName()) + New-Item -Path $tempDir -ItemType Directory > $null + Set-Content -LiteralPath (Join-Path -Path $tempDir -ChildPath 'AppxManifest.xml') -Value $xml + Set-Content -LiteralPath (Join-Path -Path $tempDir -ChildPath 'icon.png') -Value $iconBytes + Set-Content -LiteralPath (Join-Path -Path $tempDir -ChildPath 'test.exe') -Value '' + + $outPath = Join-Path -Path $path -ChildPath $info.filename + $makeArguments = @($makeappxPath, 'pack', '/d', $tempDir, '/p', $outPath, '/o') + $res = Run-Command -command (Argv-ToString -arguments $makeArguments) + + if ($res.rc -ne 0) { + $module.Result.rc = $res.rc + $module.Result.stdout = $res.stdout + $module.Result.stderr = $res.stderr + $module.FailJson("Failed to make package for $($info.filename): see stdout and stderr for more info") + } + + Remove-Item -Literalpath $tempDir -Force -Recurse + + $signArguments = @($signtoolPath, 'sign', '/a', '/v', '/fd', 'SHA256', '/f', $certPath, '/p', $certPassword, + $outPath) + $res = Run-Command -command (Argv-ToString -arguments $signArguments) + + if ($res.rc -ne 0) { + $module.Result.rc = $res.rc + $module.Result.stdout = $res.stdout + $module.Result.stderr = $res.stderr + $module.FailJson("Failed to sign package for $($info.filename): see stdout and stderr for more info") + } +} + +foreach ($info in $bundles) { + $tempDir = Join-Path -Path $module.Tmpdir -ChildPath ([System.IO.Path]::GetRandomFileName()) + New-Item -Path $tempDir -ItemType Directory > $null + foreach ($name in $info.files) { + $sourcePath = Join-Path -Path $path -ChildPath $name + $targetPath = Join-Path -Path $tempDir -ChildPath $name + Move-Item -LiteralPath $sourcePath -Destination $targetPath + } + $outPath = Join-Path -Path $path -ChildPath $info.filename + $makeArguments = @($makeappxPath, 'bundle', '/d', $tempDir, '/p', $outPath, '/o') + $res = Run-Command -command (Argv-ToString -arguments $makeArguments) + + if ($res.rc -ne 0) { + $module.Result.rc = $res.rc + $module.Result.stdout = $res.stdout + $module.Result.stderr = $res.stderr + $module.FailJson("Failed to make bundle for $($info.filename): see stdout and stderr for more info") + } + + Remove-Item -LiteralPath $tempDir -Force -Recurse + + $signArguments = @($signtoolPath, 'sign', '/a', '/v', '/fd', 'SHA256', '/f', $certPath, '/p', $certPassword, + $outPath) + $res = Run-Command -command (Argv-ToString -arguments $signArguments) + + if ($res.rc -ne 0) { + $module.Result.rc = $res.rc + $module.Result.stdout = $res.stdout + $module.Result.stderr = $res.stderr + $module.FailJson("Failed to sign bundle for $($info.filename): see stdout and stderr for more info") + } +} + +$module.ExitJson() diff --git a/test/integration/targets/win_package/meta/main.yml b/test/integration/targets/win_package/meta/main.yml new file mode 100644 index 00000000000..9f37e96cd90 --- /dev/null +++ b/test/integration/targets/win_package/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_remote_tmp_dir diff --git a/test/integration/targets/win_package/tasks/exe_tests.yml b/test/integration/targets/win_package/tasks/exe_tests.yml deleted file mode 100644 index d773ed1ecfd..00000000000 --- a/test/integration/targets/win_package/tasks/exe_tests.yml +++ /dev/null @@ -1,303 +0,0 @@ ---- -- name: install local exe (check mode) - win_package: - path: '{{test_win_package_path}}\7z.exe' - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: present - register: install_local_exe_check - check_mode: yes - -- name: get result of install local exe (check mode) - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: install_local_exe_actual_check - -- name: assert install local exe (check mode) - assert: - that: - - install_local_exe_check is changed - - install_local_exe_check.reboot_required == False - - install_local_exe_actual_check.exists == False - -- name: install local exe - win_package: - path: '{{test_win_package_path}}\7z.exe' - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: present - register: install_local_exe - -- name: get result of install local exe - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: install_local_exe_actual - -- name: assert install local exe - assert: - that: - - install_local_exe is changed - - install_local_exe.reboot_required == False - - install_local_exe.rc == 0 - - install_local_exe_actual.exists == True - -- name: install local exe (idempotent) - win_package: - path: '{{test_win_package_path}}\7z.exe' - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: present - register: install_local_exe_idempotent - -- name: assert install local exe (idempotent) - assert: - that: - - install_local_exe_idempotent is not changed - -- name: uninstall local exe with path (check mode) - win_package: - path: C:\Program Files\7-Zip\Uninstall.exe - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: absent - register: uninstall_path_local_exe_check - check_mode: yes - -- name: get result of uninstall local exe with path (check mode) - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: uninstall_path_local_exe_actual_check - -- name: assert uninstall local exe with path (check mode) - assert: - that: - - uninstall_path_local_exe_check is changed - - uninstall_path_local_exe_check.reboot_required == False - - uninstall_path_local_exe_actual_check.exists == True - -- name: uninstall local exe with path - win_package: - path: C:\Program Files\7-Zip\Uninstall.exe - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: absent - register: uninstall_path_local_exe - -- name: get result of uninstall local exe with path - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: uninstall_path_local_exe_actual - -- name: assert uninstall local exe with path - assert: - that: - - uninstall_path_local_exe is changed - - uninstall_path_local_exe.reboot_required == False - - uninstall_path_local_exe.rc == 0 - - uninstall_path_local_exe_actual.exists == False - -- name: uninstall local exe with path (idempotent) - win_package: - path: C:\Program Files\7-Zip\Uninstall.exe - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: absent - register: uninstall_path_local_exe_idempotent - -- name: assert uninstall local exe with path (idempotent) - assert: - that: - - uninstall_path_local_exe_idempotent is not changed - -- name: install url exe (check mode) - win_package: - path: '{{test_win_package_exe_url}}' - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: present - register: install_url_exe_check - check_mode: yes - -- name: get result of install url exe (check mode) - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: install_url_exe_actual_check - -- name: assert install url exe (check mode) - assert: - that: - - install_url_exe_check is changed - - install_url_exe_check.reboot_required == False - - install_url_exe_actual_check.exists == False - -- name: install url exe - win_package: - path: '{{test_win_package_exe_url}}' - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: present - register: install_url_exe - -- name: get result of install url exe - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: install_url_exe_actual - -- name: assert install url exe - assert: - that: - - install_url_exe is changed - - install_url_exe.reboot_required == False - - install_url_exe.rc == 0 - - install_url_exe_actual.exists == True - -- name: install url exe (idempotent) - win_package: - path: '{{test_win_package_exe_url}}' - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: present - register: install_url_exe_again - -- name: assert install url exe (idempotent) - assert: - that: - - install_url_exe_again is not changed - -- name: uninstall local exe with product_id (check mode) - win_package: - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: absent - register: uninstall_id_local_exe_check - check_mode: yes - -- name: get result of uninstall local exe with product_id (check mode) - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: uninstall_id_local_exe_actual_check - -- name: assert uninstall local exe with product_id (check mode) - assert: - that: - - uninstall_id_local_exe_check is changed - - uninstall_id_local_exe_check.reboot_required == False - - uninstall_id_local_exe_actual_check.exists == True - -- name: uninstall local exe with product_id - win_package: - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: absent - register: uninstall_id_local_exe - -- name: get result of uninstall local exe with product_id - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: uninstall_id_local_exe_actual - -- name: assert uninstall local exe with product_id - assert: - that: - - uninstall_id_local_exe is changed - - uninstall_id_local_exe.reboot_required == False - - uninstall_id_local_exe.rc == 0 - - uninstall_id_local_exe_actual.exists == False - -- name: uninstall local exe with product_id (idempotent) - win_package: - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: absent - register: uninstall_id_local_exe_idempotent - -- name: assert uninstall local exe with product_id (idempotent) - assert: - that: - - uninstall_id_local_exe_idempotent is not changed - -- name: install exe checking path - win_package: - path: '{{test_win_package_path}}\7z.exe' - arguments: /S - creates_path: C:\Program Files\7-Zip\7z.exe - register: install_exe_create_path - -- name: get result of install exe checking path - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: install_exe_create_path_actual - -- name: assert install exe checking path - assert: - that: - - install_exe_create_path.changed == True - - install_exe_create_path_actual.exists == True - -- name: install exe checking path (idempotent) - win_package: - path: '{{test_win_package_path}}\7z.exe' - arguments: /S - creates_path: C:\Program Files\7-Zip\7z.exe - register: install_exe_create_path_again - -- name: assert install exe checking path (idempotent) - assert: - that: - - not install_exe_create_path_again.changed == True - -- name: install exe checking path and version - win_package: - path: '{{test_win_package_path}}\7z.exe' - arguments: /S - creates_path: C:\Program Files\7-Zip\7z.exe - creates_version: '16.04' - register: install_exe_create_version_match - -- name: assert install exe checking path and version - assert: - that: - - install_exe_create_version_match is not changed - -- name: install exe checking path and version mismatch - win_package: - path: '{{test_win_package_path}}\7z.exe' - arguments: /S - creates_path: C:\Program Files\7-Zip\7z.exe - creates_version: fail-version - register: install_exe_create_version_mismatch - -- name: assert install exe checking path and version mistmatch - assert: - that: - - install_exe_create_version_mismatch is changed - -- name: install exe checking service - win_package: - path: '{{test_win_package_path}}\7z.exe' - arguments: /S - creates_service: Netlogon - register: install_exe_create_service_match - -- name: assert install exe checking service - assert: - that: - - install_exe_create_service_match is not changed - -- name: install exe checking service mismatch - win_package: - path: '{{test_win_package_path}}\7z.exe' - arguments: /S - creates_service: fake-service - register: install_exe_create_service_mismatch - -- name: assert install exe checking service mismatch - assert: - that: - - install_exe_create_service_mismatch is changed - -- name: uninstall exe post tests - win_package: - arguments: /S - product_id: '{{test_win_package_exe_id}}' - state: absent diff --git a/test/integration/targets/win_package/tasks/failure_tests.yml b/test/integration/targets/win_package/tasks/failure_tests.yml index 5ed7a054ad7..e37e757789e 100644 --- a/test/integration/targets/win_package/tasks/failure_tests.yml +++ b/test/integration/targets/win_package/tasks/failure_tests.yml @@ -2,14 +2,14 @@ --- - name: fail to install broken msi win_package: - path: '{{test_win_package_path}}\bad.msi' + path: '{{ test_path }}\bad.msi' state: present register: fail_bad_rc - failed_when: "'unexpected rc from install' not in fail_bad_rc.msg and fail_bad_rc.exit_code != 1603" + failed_when: "'unexpected rc from' not in fail_bad_rc.msg and fail_bad_rc.rc != 1603" - name: fail when not using an int for a return code win_package: - path: '{{test_win_package_path}}\good.msi' + path: '{{ test_path }}\good.msi' state: present expected_return_code: 0,abc register: fail_invalid_return_code @@ -27,19 +27,12 @@ register: fail_no_path_state_absent_no_id failed_when: 'fail_no_path_state_absent_no_id.msg != "state is absent but any of the following are missing: path, product_id"' -- name: fail when product_id is not set and path is not a local MSI - win_package: - path: '{{test_win_package_good_url}}' - state: present - register: fail_install_url_no_id - failed_when: fail_install_url_no_id.msg != 'product_id is required when the path is not an MSI or the path is an MSI but not local' - - name: fail invalid local path win_package: - path: '{{test_win_package_path}}\no file.msi' + path: '{{ test_path }}\no file.msi' state: present register: fail_invalid_local_path - failed_when: fail_invalid_local_path.msg != 'the file at the local path ' + test_win_package_path + '\\no file.msi cannot be reached' + failed_when: fail_invalid_local_path.msg != "the file at the path '" + test_path + "\\no file.msi' cannot be reached" - name: fail invalid URL win_package: @@ -47,26 +40,11 @@ product_id: 'id' state: present register: fail_invalid_url_path - failed_when: "fail_invalid_url_path.msg != 'the file at the URL http://fakeurl/file.msi cannot be reached: The remote name could not be resolved: \\'fakeurl\\''" - -- name: fail invalid UNC path - win_package: - path: \\fakenetwork\unc file.msi - product_id: 'id' - state: present - register: fail_invalid_unc_path - failed_when: fail_invalid_unc_path.msg != 'the file at the UNC path \\\\fakenetwork\\unc file.msi cannot be reached, ensure the user_name account has access to this path or use an auth transport with credential delegation' - -- name: fail when product_id is not set and path is not a local MSI - win_package: - path: '{{test_win_package_good_url}}' - state: present - register: fail_no_id_not_local_msi - failed_when: fail_no_id_not_local_msi.msg != 'product_id is required when the path is not an MSI or the path is an MSI but not local' + failed_when: "\"The remote name could not be resolved: 'fakeurl'\" not in fail_invalid_url_path.msg" - name: fail to check version without creates_path win_package: - path: '{{test_win_package_path}}\7z.exe' + path: '{{ test_path }}\good.msi' state: present creates_version: 1 register: fail_creates_version_without_path @@ -74,9 +52,9 @@ - name: fail to check version without when path is not a file win_package: - path: '{{test_win_package_path}}\7z.exe' + path: '{{ test_path }}\good.msi' state: present creates_path: C:\Windows creates_version: 1 register: fail_creates_version_not_a_file - failed_when: fail_creates_version_not_a_file.msg != 'creates_path must be a file not a directory when creates_version is set' + failed_when: "'creates_path must be a file not a directory when creates_version is set' not in fail_creates_version_not_a_file.msg" diff --git a/test/integration/targets/win_package/tasks/main.yml b/test/integration/targets/win_package/tasks/main.yml index 3733ea63014..250bc8307f9 100644 --- a/test/integration/targets/win_package/tasks/main.yml +++ b/test/integration/targets/win_package/tasks/main.yml @@ -1,63 +1,71 @@ --- -- name: ensure testing folders exists +- name: ensure testing folder exists win_file: - path: '{{item}}' + path: '{{ test_path }}' state: directory - with_items: - - '{{test_win_package_path}}' - - '{{test_win_package_path_safe}}' -- name: download msi files from S3 bucket +# Some of the registry_tests.yml create a badly formed unisntall string so remove the reg entry in case the test +# didn't get to cleaning itself up +- name: remove registry package path + win_regedit: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} + state: absent + +- name: download packages from S3 bucket win_get_url: - url: '{{item.url}}' - dest: '{{test_win_package_path}}\{{item.name}}' + url: '{{ item.url }}' + dest: '{{ test_path }}\{{ item.name }}' with_items: - - { url: '{{test_win_package_good_url}}', name: 'good.msi' } - - { url: '{{test_win_package_reboot_url}}', name: 'reboot.msi' } - - { url: '{{test_win_package_bad_url}}', name: 'bad.msi' } - # - { url: '{{test_win_package_exe_url}}', name: '7z.exe' } + - url: '{{ good_url }}' + name: good.msi + - url: '{{ reboot_url }}' + name: reboot.msi + - url: '{{ bad_url }}' + name: bad.msi + - url: '{{ patch_msi_url }}' + name: patch.msi + - url: '{{ patch_msp_url }}' + name: patch.msp -- name: make sure all test msi's are uninstalled before test +- name: make sure all test packages are uninstalled before test win_package: - product_id: '{{item.id}}' - arguments: '{{item.args|default(omit)}}' + product_id: '{{ item }}' state: absent - with_items: - - { id: '{{test_win_package_good_id}}' } - - { id: '{{test_win_package_reboot_id}}' } - # - { id: '{{test_win_package_exe_id}}', args: '/S' } + with_items: '{{ all_ids }}' - block: - name: run tests for expected failures include_tasks: failure_tests.yml - - name: run tests for local and URL msi files + - name: run tests for msi files and URL paths include_tasks: msi_tests.yml - # doesn't work 100% on AWS hosts, disabling for now until we get a better exe example - # - name: run tests for local and URL exe files - # include_tasks: exe_tests.yml + # The msix test setup will only work on Server 2019 or newer so conditionally run this + - name: check if we can run the msix tests + win_shell: | + $osVersion = [Version](Get-Item -LiteralPath "$env:SystemRoot\System32\kernel32.dll").VersionInfo.ProductVersion + $osVersion -ge [Version]"10.0.17763" + register: can_run_msix + changed_when: False + + - name: run tests for msix packages + include_tasks: msix_tests.yml + when: can_run_msix.stdout | trim | bool - # these tests can be run manually by defining test_win_package_network_path - - name: run tests for network msi files (manual) - include_tasks: network_tests.yml - when: test_win_package_network_path is defined + - name: run tests for msp packages + include_tasks: msp_tests.yml + + - name: run tests for registry packages + include_tasks: registry_tests.yml always: - - name: make sure all test msi's are uninstalled after test - win_package: - product_id: '{{item.id}}' - arguments: '{{item.args|default(omit)}}' + - name: remove registry package path + win_regedit: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} state: absent - with_items: - - { id: '{{test_win_package_good_id}}' } - - { id: '{{test_win_package_reboot_id}}' } - # - { id: '{{test_win_package_exe_id}}', args: '/S' } - - - name: cleanup test artifacts - win_file: - path: '{{item}}' + + - name: make sure all test packages are uninstalled after test + win_package: + product_id: '{{ item }}' state: absent - with_items: - - '{{test_win_package_path}}' - - '{{test_win_package_path_safe}}' + with_items: '{{ all_ids }}' diff --git a/test/integration/targets/win_package/tasks/msi_tests.yml b/test/integration/targets/win_package/tasks/msi_tests.yml index f6aeae7706f..4c76084dd1d 100644 --- a/test/integration/targets/win_package/tasks/msi_tests.yml +++ b/test/integration/targets/win_package/tasks/msi_tests.yml @@ -2,20 +2,20 @@ # this test just makes sure the task doesn't fail when we set out expected rc - name: install broken msi override expected rc win_package: - path: '{{test_win_package_path}}\bad.msi' + path: '{{ test_path }}\bad.msi' state: present expected_return_code: 0,1603 - name: install local msi (check mode) win_package: - path: '{{test_win_package_path}}\good.msi' + path: '{{ test_path }}\good.msi' state: present register: install_local_msi_check check_mode: yes - name: get result of install local msi (check mode) win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} + path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ good_id }} register: install_local_msi_actual_check - name: assert install local msi (check mode) @@ -27,14 +27,14 @@ - name: install local msi with log win_package: - path: '{{test_win_package_path}}\good.msi' + path: '{{ test_path }}\good.msi' state: present - log_path: "{{test_win_package_log_path_install}}" + log_path: '{{ test_path }}\msi.log' register: install_local_msi - name: get result of install local msi win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} + path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ good_id }} register: install_local_msi_actual - name: assert install local msi @@ -47,7 +47,7 @@ - name: get result of install local msi log_path win_stat: - path: "{{test_win_package_log_path_install}}" + path: '{{ test_path }}\msi.log' register: install_local_msi_actual_log_path - name: assert install local msi log path @@ -57,7 +57,7 @@ - name: install local msi (idempotent) win_package: - path: '{{test_win_package_path}}\good.msi' + path: '{{ test_path }}\good.msi' state: present register: install_local_msi_idempotent @@ -68,14 +68,14 @@ - name: uninstall local msi with path (check mode) win_package: - path: '{{test_win_package_path}}\good.msi' + path: '{{ test_path }}\good.msi' state: absent register: uninstall_path_local_msi_check check_mode: yes - name: get result of uninstall local msi with path (check mode) win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} + path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ good_id }} register: uninstall_path_local_msi_actual_check - name: assert uninstall local msi with path (check mode) @@ -87,14 +87,14 @@ - name: uninstall local msi with path win_package: - path: '{{test_win_package_path}}\good.msi' + path: '{{ test_path }}\good.msi' state: absent - log_path: "{{test_win_package_log_path_uninstall}}" + log_path: '{{ test_path }}\msi uninstall.log' register: uninstall_path_local_msi - name: get result of uninstall local msi with path win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} + path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ good_id }} register: uninstall_path_local_msi_actual - name: assert uninstall local msi with path @@ -107,7 +107,7 @@ - name: get result of uninstall local msi with path win_stat: - path: "{{test_win_package_log_path_uninstall}}" + path: '{{ test_path }}\msi uninstall.log' register: uninstall_path_local_msi_actual_log_path - name: assert uninstall local msi with path @@ -117,7 +117,7 @@ - name: uninstall local msi with path (idempotent) win_package: - path: '{{test_win_package_path}}\good.msi' + path: '{{ test_path }}\good.msi' state: absent register: uninstall_path_local_msi_idempotent @@ -128,15 +128,15 @@ - name: install url msi (check mode) win_package: - path: '{{test_win_package_good_url}}' - product_id: '{{test_win_package_good_id}}' + path: '{{ good_url }}' + product_id: '{{ good_id }}' state: present register: install_url_msi_check check_mode: yes - name: get result of install url msi (check mode) win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} + path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ good_id }} register: install_url_msi_actual_check - name: assert install url msi (check mode) @@ -148,14 +148,14 @@ - name: install url msi win_package: - path: '{{test_win_package_good_url}}' - product_id: '{{test_win_package_good_id}}' + path: '{{ good_url }}' + product_id: '{{ good_id }}' state: present register: install_url_msi - name: get result of install url msi win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} + path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ good_id }} register: install_url_msi_actual - name: assert install url msi @@ -168,8 +168,8 @@ - name: install url msi (idempotent) win_package: - path: '{{test_win_package_good_url}}' - product_id: '{{test_win_package_good_id}}' + path: '{{ good_url }}' + product_id: '{{ good_id }}' state: present register: install_url_msi_again @@ -180,14 +180,14 @@ - name: uninstall local msi with product_id (check mode) win_package: - product_id: '{{test_win_package_good_id}}' + product_id: '{{ good_id }}' state: absent register: uninstall_id_local_msi_check check_mode: yes - name: get result of uninstall local msi with product_id (check mode) win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} + path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ good_id }} register: uninstall_id_local_msi_actual_check - name: assert uninstall local msi with product_id (check mode) @@ -199,13 +199,13 @@ - name: uninstall local msi with product_id win_package: - product_id: '{{test_win_package_good_id}}' + product_id: '{{ good_id }}' state: absent register: uninstall_id_local_msi - name: get result of uninstall local msi with product_id win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} + path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ good_id }} register: uninstall_id_local_msi_actual - name: assert uninstall local msi with product_id @@ -218,7 +218,7 @@ - name: uninstall local msi with product_id (idempotent) win_package: - product_id: '{{test_win_package_good_id}}' + product_id: '{{ good_id }}' state: absent register: uninstall_id_local_msi_idempotent @@ -229,14 +229,14 @@ - name: install local reboot msi (check mode) win_package: - path: '{{test_win_package_path}}\reboot.msi' + path: '{{ test_path }}\reboot.msi' state: present register: install_local_reboot_msi_check check_mode: yes - name: get result of install local reboot msi (check mode) win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_reboot_id}} + path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ reboot_id }} register: install_local_reboot_msi_actual_check - name: assert install local reboot msi (check mode) @@ -248,13 +248,13 @@ - name: install local reboot msi win_package: - path: '{{test_win_package_path}}\reboot.msi' + path: '{{ test_path }}\reboot.msi' state: present register: install_local_reboot_msi - name: get result of install local reboot msi win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_reboot_id}} + path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ reboot_id }} register: install_local_reboot_msi_actual - name: assert install local reboot msi @@ -267,7 +267,7 @@ - name: install local reboot msi (idempotent) win_package: - path: '{{test_win_package_path}}\reboot.msi' + path: '{{ test_path }}\reboot.msi' state: present register: install_local_reboot_msi_idempotent @@ -278,7 +278,7 @@ - name: uninstall reboot msi after test win_package: - path: '{{test_win_package_path}}\reboot.msi' + path: '{{ test_path }}\reboot.msi' state: absent - name: ensure the install folder is cleaned in case uninstall didn't work @@ -288,7 +288,7 @@ - name: install local msi with arguments (check mode) win_package: - path: '{{test_win_package_path}}\good.MSI' + path: '{{ test_path }}\good.MSI' state: present arguments: ADDLOCAL=Cow register: install_msi_argument_check @@ -314,7 +314,7 @@ - name: install local msi with arguments win_package: - path: '{{test_win_package_path}}\good.MSI' + path: '{{ test_path}}\good.MSI' state: present arguments: ADDLOCAL=Cow register: install_msi_argument @@ -340,7 +340,7 @@ - name: install local msi with arguments (idempotent) win_package: - path: '{{test_win_package_path}}\good.MSI' + path: '{{ test_path}}\good.MSI' state: present arguments: ADDLOCAL=Cow register: install_msi_argument_again @@ -352,29 +352,101 @@ - name: uninstall good msi after test win_package: - path: '{{test_win_package_path}}\good.msi' + path: '{{ test_path }}\good.msi' state: absent +- name: install good msi for all users + win_package: + path: '{{ test_path }}\good.msi' + state: present + arguments: ALLUSERS=1 + register: install_good_all_users + +- name: get result of install good msi for all users + win_shell: | + Add-Type -Namespace Msi -Name NativeMethods -UsingNamespace System.Text -MemberDefinition @' + [DllImport("Msi.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 MsiGetProductInfoExW( + string szProductCode, + string szUserSid, + UInt32 dwContext, + string szProperty, + StringBuilder szValue, + ref UInt32 pcchValue); + '@ + + $productCode = '{{ good_id }}' + $sb = New-Object -TypeName System.Text.StringBuilder -ArgumentList 0 + $sbLength = [UInt32]0 + + $null = [Msi.NativeMethods]::MsiGetProductInfoExW($productCode, [NullString]::Value, 4, "State", $sb, [ref]$sbLength) + $sbLength += 1 + $null = $sb.EnsureCapacity($sbLength) + + $null = [Msi.NativeMethods]::MsiGetProductInfoExW($productCode, [NullString]::Value, 4, "State", $sb, [ref]$sbLength) + [int]$sb.ToString() + register: install_good_all_users_actual + +- name: assert install good msi for all users + assert: + that: + - install_good_all_users is changed + - install_good_all_users_actual.stdout | trim | int == 5 # INSTALLSTATE_DEFAULT + +- name: install good msi for all users (idempotent) + win_package: + path: '{{ test_path }}\good.msi' + state: present + arguments: ALLUSERS=1 + register: install_good_all_users_again + +- name: assert install good msi for all users (idempotent) + assert: + that: + - not install_good_all_users_again is changed + +- name: uninstall good msi for all users + win_package: + product_id: '{{ good_id }}' + state: absent + register: uninstall_good_all_users + +- name: get result of uninstall good msi for all users + win_shell: | + Add-Type -Namespace Msi -Name NativeMethods -MemberDefinition @' + [DllImport("Msi.dll", CharSet = CharSet.Unicode)] + public static extern Int32 MsiQueryProductStateW( + string szProductCode); + '@ + [Msi.NativeMethods]::MsiQueryProductStateW('{{ good_id }}') + register: uninstall_good_all_users_actual + +- name: assert uninstall good msi for all users + assert: + that: + - uninstall_good_all_users is changed + - uninstall_good_all_users_actual.stdout | trim | int == -1 # INSTALLSTATE_UNKNOWN + - name: create custom install directory for msi install win_file: - path: '{{test_win_package_path_safe}}\good' + path: '{{ test_path }}\msi install' state: directory - name: install msi to custom path using string arguments win_package: - path: '{{test_win_package_path}}\good.msi' + path: '{{ test_path }}\good.msi' state: present - arguments: ADDLOCAL=Cow INSTALLDIR={{test_win_package_path_safe}}\install + arguments: ADDLOCAL=Cow INSTALLDIR="{{ test_path }}\msi install" register: install_msi_string_arguments - name: get result of moo file after install local msi with string arguments win_stat: - path: '{{test_win_package_path_safe}}\install\moo.exe' + path: '{{ test_path }}\msi install\moo.exe' register: install_msi_string_arguments_moo - name: get result of cow file after install local msi with string arguments win_stat: - path: '{{test_win_package_path_safe}}\install\cow.exe' + path: '{{ test_path }}\msi install\cow.exe' register: install_msi_string_arguments_cow - name: assert results of install msi to custom path using string arguments @@ -388,38 +460,52 @@ - name: uninstall good msi after string argument test win_package: - path: '{{test_win_package_path}}\good.msi' + path: '{{ test_path }}\good.msi' state: absent -- name: install msi to custom path using list arguments - win_package: - path: '{{test_win_package_path}}\good.msi' - state: present - arguments: - - ADDLOCAL=Moo - - INSTALLDIR={{test_win_package_path_safe}}\install - register: install_msi_list_arguments - -- name: get result of moo file after install local msi with list arguments - win_stat: - path: '{{test_win_package_path_safe}}\install\moo.exe' - register: install_msi_list_arguments_moo - -- name: get result of cow file after install local msi with list arguments - win_stat: - path: '{{test_win_package_path_safe}}\install\cow.exe' - register: install_msi_list_arguments_cow - -- name: assert results of install msi to custom path using list arguments - assert: - that: - - install_msi_list_arguments is changed - - install_msi_list_arguments.reboot_required == False - - install_msi_list_arguments.rc == 0 - - install_msi_list_arguments_moo.stat.exists == True - - install_msi_list_arguments_cow.stat.exists == False +# MSI arguments KEY="value" are known to fail when set as a list, for this test just create a simple folder path that +# does not need to be escaped and cleanup at the end. +- name: create a simple spaceless folder for argument list test + win_file: + path: C:\ansible_win_package + state: directory -- name: uninstall good msi after list argument test - win_package: - path: '{{test_win_package_path}}\good.msi' - state: absent +- block: + - name: install msi to custom path using list arguments + win_package: + path: '{{ test_path }}\good.msi' + state: present + arguments: + - ADDLOCAL=Moo + - INSTALLDIR=C:\ansible_win_package + register: install_msi_list_arguments + + - name: get result of moo file after install local msi with list arguments + win_stat: + path: C:\ansible_win_package\moo.exe + register: install_msi_list_arguments_moo + + - name: get result of cow file after install local msi with list arguments + win_stat: + path: C:\ansible_win_package\cow.exe + register: install_msi_list_arguments_cow + + - name: assert results of install msi to custom path using list arguments + assert: + that: + - install_msi_list_arguments is changed + - install_msi_list_arguments.reboot_required == False + - install_msi_list_arguments.rc == 0 + - install_msi_list_arguments_moo.stat.exists == True + - install_msi_list_arguments_cow.stat.exists == False + + - name: uninstall good msi after list argument test + win_package: + path: '{{ test_path }}\good.msi' + state: absent + + always: + - name: remove spaceless folder for argument list test + win_file: + path: C:\ansible_win_package + state: absent diff --git a/test/integration/targets/win_package/tasks/msix_tests.yml b/test/integration/targets/win_package/tasks/msix_tests.yml new file mode 100644 index 00000000000..738c6ef0205 --- /dev/null +++ b/test/integration/targets/win_package/tasks/msix_tests.yml @@ -0,0 +1,450 @@ +--- +- name: enable sideloading of apps for test + win_regedit: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock + name: AllowAllTrustedApps + data: 1 + type: dword + state: present + notify: remove sideloading mode for msix + +- name: download makeappx binaries + win_get_url: + url: '{{ makeappx_url }}' + dest: '{{ test_path }}\makeappx.zip' + +- name: extract makeappx binaries + win_unzip: + src: '{{ test_path }}\makeappx.zip' + dest: '{{ test_path }}\makeappx' + +- name: setup MSIX packages + win_make_appx: + packages: + - identity: '{{ msix_id }}' + version: 1.0.0.0 + filename: WinPackage-1.0.0.0.msix + - identity: '{{ msix_id }}' + version: 1.0.0.1 + filename: WinPackage-1.0.0.1.msix + - identity: '{{ appx_id }}' + version: 1.0.0.0 + filename: WinPackage.appx + - identity: '{{ msixbundle_id }}' + version: 1.0.0.1 + architecture: x64 + resource_id: resid + filename: WinPackageBundle-x64.msix + - identity: '{{ msixbundle_id }}' + version: 1.0.0.1 + architecture: x86 + resource_id: resid + filename: WinPackageBundle-x86.msix + - identity: '{{ appxbundle_id }}' + version: 1.0.0.1 + architecture: x64 + resource_id: resid + filename: WinPackageBundle-x64.appx + - identity: '{{ appxbundle_id }}' + version: 1.0.0.1 + architecture: x86 + resource_id: resid + filename: WinPackageBundle-x86.appx + - identity: FailMsix + version: 1.2.3.4 + min_version: 99.0.0.0 + max_version: 99.0.0.0 + filename: Fail.msix + bundles: + - files: + - WinPackageBundle-x64.msix + - WinPackageBundle-x86.msix + filename: WinPackageBundle.msixbundle + - files: + - WinPackageBundle-x64.appx + - WinPackageBundle-x86.appx + filename: WinPackageBundle.appxbundle + publisher: CN=Ansible Core, O=Ansible, L=Durhan, S=NC, C=USA + path: '{{ test_path }}' + makeappx_path: '{{ test_path }}\makeappx\makeappx.exe' + signtool_path: '{{ test_path }}\makeappx\signtool.exe' + become: yes # New-SelfSignedCertificate requires this to store the cert with key into the store. + become_method: runas + vars: + ansible_become_user: '{{ ansible_user }}' + ansible_become_pass: '{{ ansible_password }}' + register: test_win_package_msix_packages + notify: remove trusted root cert + +- name: install msix (check mode) + win_package: + path: '{{ test_path }}\WinPackage-1.0.0.0.msix' + state: present + register: msix_install_check + check_mode: yes + +- name: get result of install msix (check mode) + win_shell: if (Get-AppxPackage -Name '{{ msix_id }}') { $true } else { $false } + register: msix_install_actual_check + changed_when: False + +- name: assert install msix (check mode) + assert: + that: + - msix_install_check is changed + - msix_install_check.rc == 0 + - not msix_install_check.reboot_required + - not msix_install_actual_check.stdout | trim | bool + +- name: install msix + win_package: + path: '{{ test_path }}\WinPackage-1.0.0.0.msix' + state: present + register: msix_install + +- name: get result of install msix + win_shell: if (Get-AppxPackage -Name '{{ msix_id }}') { $true } else { $false } + register: msix_install_actual + changed_when: False + +- name: assert install msix + assert: + that: + - msix_install is changed + - msix_install.rc == 0 + - not msix_install.reboot_required + - msix_install_actual.stdout | trim | bool + +- name: install msix (idempotence) + win_package: + path: '{{ test_path }}\WinPackage-1.0.0.0.msix' + state: present + register: msix_install_again + +- name: assert install msix (idempotence) + assert: + that: + - not msix_install_again is changed + +- name: install updated msix package + win_package: + path: '{{ test_path }}\WinPackage-1.0.0.1.msix' + state: present + register: msix_install_updated + +- name: get result of install updated msix package + win_shell: Get-AppxPackage -Name '{{ msix_id }}' | Select-Object -ExpandProperty Version + changed_when: False + register: msix_install_updated_actual + +- name: assert result of install updated msix package + assert: + that: + - msix_install_updated is changed + - msix_install_updated.rc == 0 + - not msix_install_updated.reboot_required + - msix_install_updated_actual.stdout | trim == "1.0.0.1" + +- name: fail to install older msix when new is present + win_package: + path: '{{ test_path }}\WinPackage-1.0.0.0.msix' + state: present + register: fail_msix_older + failed_when: "'unexpected status from Add-AppxPackage' not in fail_msix_older.msg" + +- name: remove msix by path (check mode) + win_package: + path: '{{ test_path }}\WinPackage-1.0.0.1.msix' + state: absent + register: msix_uninstall_check + check_mode: yes + +- name: get result of remove msix by path (check mode) + win_shell: if (Get-AppxPackage -Name '{{ msix_id }}') { $true } else { $false } + changed_when: False + register: msix_uninstall_actual_check + +- name: assert results of remove msix by path (check mode) + assert: + that: + - msix_uninstall_check is changed + - msix_uninstall_check.rc == 0 + - not msix_uninstall_check.reboot_required + - msix_uninstall_actual_check.stdout | trim | bool + +- name: remove msix by path + win_package: + path: '{{ test_path }}\WinPackage-1.0.0.1.msix' + state: absent + register: msix_uninstall + +- name: get result of remove msix by path + win_shell: if (Get-AppxPackage -Name '{{ msix_id }}') { $true } else { $false } + changed_when: False + register: msix_uninstall_actual + +- name: assert results of remove msix by path + assert: + that: + - msix_uninstall is changed + - msix_uninstall.rc == 0 + - not msix_uninstall.reboot_required + - not msix_uninstall_actual.stdout | trim | bool + +- name: remove msix by path (idempotence) + win_package: + path: '{{ test_path }}\WinPackage-1.0.0.1.msix' + state: absent + register: msix_uninstall_again + +- name: assert results of remove msix by path (idempotence) + assert: + that: + - not msix_uninstall_again is changed + +# The install steps are the same as msix so no need for check and idempotency tests +- name: install appx + win_package: + path: '{{ test_path }}\WinPackage.appx' + state: present + register: appx_install + +- name: get result of install appx + win_shell: if (Get-AppxPackage -Name '{{ appx_id }}') { $true } else { $false } + changed_when: False + register: appx_install_actual + +- name: assert results of install appx + assert: + that: + - appx_install is changed + - appx_install.rc == 0 + - not appx_install.reboot_required + - appx_install_actual.stdout | trim | bool + +- name: remove appx by id (check mode) + win_package: + product_id: '{{ appx_id }}' + state: absent + register: appx_uninstall_check + check_mode: yes + +- name: get result of remove appx (check mode) + win_shell: if (Get-AppxPackage -Name '{{ appx_id }}') { $true } else { $false } + changed_when: False + register: appx_uninstall_actual_check + +- name: assert results of remove appx by id (check mode) + assert: + that: + - appx_uninstall_check is changed + - appx_uninstall_check.rc == 0 + - not appx_uninstall_check.reboot_required + - appx_uninstall_actual_check.stdout | trim | bool + +- name: remove appx by id + win_package: + product_id: '{{ appx_id }}' + state: absent + register: appx_uninstall + +- name: get result of remove appx + win_shell: if (Get-AppxPackage -Name '{{ appx_id }}') { $true } else { $false } + changed_when: False + register: appx_uninstall_actual + +- name: assert results of remove appx by id + assert: + that: + - appx_uninstall is changed + - appx_uninstall.rc == 0 + - not appx_uninstall.reboot_required + - not appx_uninstall_actual.stdout | trim | bool + +- name: remove appx by id (idempotence) + win_package: + product_id: '{{ appx_id }}' + state: absent + register: appx_uninstall_again + +- name: assert results of remove appx by id (idempotence) + assert: + that: + - not appx_uninstall_again is changed + +- name: validate failures are detected on a bad package + win_package: + path: '{{ test_path }}\Fail.msix' + state: present + register: fail_msix + failed_when: "'unexpected status from Add-AppxPackage' not in fail_msix.msg" + +- name: install msixbundle (check mode) + win_package: + path: '{{ test_path }}\WinPackageBundle.msixbundle' + state: present + register: msixbundle_install_check + check_mode: yes + +- name: get result of install msixbundle (check mode) + win_shell: if (Get-AppxPackage -Name '{{ msixbundle_id }}') { $true } else { $false } + changed_when: False + register: msixbundle_install_actual_check + +- name: assert install msixbundle (check mode) + assert: + that: + - msixbundle_install_check is changed + - msixbundle_install_check.rc == 0 + - not msixbundle_install_check.reboot_required + - not msixbundle_install_actual_check.stdout | trim | bool + +- name: install msixbundle + win_package: + path: '{{ test_path }}\WinPackageBundle.msixbundle' + state: present + register: msixbundle_install + +- name: get result of install msixbundle + win_shell: if (Get-AppxPackage -Name '{{ msixbundle_id }}') { $true } else { $false } + changed_when: False + register: msixbundle_install_actual + +- name: assert install msixbundle + assert: + that: + - msixbundle_install is changed + - msixbundle_install.rc == 0 + - not msixbundle_install.reboot_required + - msixbundle_install_actual.stdout | trim | bool + +- name: install msixbundle (idempotence) + win_package: + path: '{{ test_path }}\WinPackageBundle.msixbundle' + state: present + register: msixbundle_install_again + +- name: assert install msixbundle (idempotence) + assert: + that: + - not msixbundle_install_again is changed + +- name: uninstall msixbundle by id (check mode) + win_package: + product_id: '{{ msixbundle_id }}' + state: absent + register: msixbundle_uninstall_check + check_mode: yes + +- name: get result of uninstall msixbundle by id (check mode) + win_shell: if (Get-AppxPackage -Name '{{ msixbundle_id }}') { $true } else { $false } + changed_when: False + register: msixbundle_uninstall_actual_check + +- name: assert uninstall msixbundle by id (check mode) + assert: + that: + - msixbundle_uninstall_check is changed + - msixbundle_uninstall_check.rc == 0 + - not msixbundle_uninstall_check.reboot_required + - msixbundle_uninstall_actual_check.stdout | trim | bool + +- name: uninstall msixbundle by id + win_package: + product_id: '{{ msixbundle_id }}' + state: absent + register: msixbundle_uninstall + +- name: get result of uninstall msixbundle by id + win_shell: if (Get-AppxPackage -Name '{{ msixbundle_id }}') { $true } else { $false } + changed_when: False + register: msixbundle_uninstall_actual + +- name: assert uninstall msixbundle by id + assert: + that: + - msixbundle_uninstall is changed + - msixbundle_uninstall.rc == 0 + - not msixbundle_uninstall.reboot_required + - not msixbundle_uninstall_actual.stdout | trim | bool + +- name: uninstall msixbundle by id (idempotence) + win_package: + product_id: '{{ msixbundle_id }}' + state: absent + register: msixbundle_uninstall_again + +- name: assert uninstall msixbundle by id (idempotence) + assert: + that: + - not msixbundle_uninstall_again is changed + +# The logic for appxbundle is the same for msixbundle no need for check and idempotence tests +- name: install appxbundle + win_package: + path: '{{ test_path }}\WinPackageBundle.appxbundle' + state: present + register: appxbundle_install + +- name: get result of install appxbundle + win_shell: if (Get-AppxPackage -Name '{{ appxbundle_id }}') { $true } else { $false } + changed_when: False + register: appxbundle_install_actual + +- name: assert install appxbundle + assert: + that: + - appxbundle_install is changed + - appxbundle_install.rc == 0 + - not appxbundle_install.reboot_required + - appxbundle_install_actual.stdout | trim | bool + +- name: uninstall appxbundle by path (check mode) + win_package: + path: '{{ test_path }}\WinPackageBundle.appxbundle' + state: absent + register: msixbundle_uninstall_check + check_mode: yes + +- name: get result of uninstall appxbundle by path (check mode) + win_shell: if (Get-AppxPackage -Name '{{ appxbundle_id }}') { $true } else { $false } + changed_when: False + register: msixbundle_uninstall_actual_check + +- name: assert uninstall appxbundle by path (check mode) + assert: + that: + - msixbundle_uninstall_check is changed + - msixbundle_uninstall_check.rc == 0 + - not msixbundle_uninstall_check.reboot_required + - msixbundle_uninstall_actual_check.stdout | trim | bool + +- name: uninstall appxbundle by path + win_package: + path: '{{ test_path }}\WinPackageBundle.appxbundle' + state: absent + register: msixbundle_uninstall + +- name: get result of uninstall appxbundle by path + win_shell: if (Get-AppxPackage -Name '{{ appxbundle_id }}') { $true } else { $false } + changed_when: False + register: msixbundle_uninstall_actual + +- name: assert uninstall appxbundle by path + assert: + that: + - msixbundle_uninstall is changed + - msixbundle_uninstall.rc == 0 + - not msixbundle_uninstall.reboot_required + - not msixbundle_uninstall_actual.stdout | trim | bool + +- name: uninstall appxbundle by path (idempotence) + win_package: + path: '{{ test_path }}\WinPackageBundle.appxbundle' + state: absent + register: msixbundle_uninstall_again + +- name: assert uninstall appxbundle by path (idempotence) + assert: + that: + - not msixbundle_uninstall_again is changed diff --git a/test/integration/targets/win_package/tasks/msp_tests.yml b/test/integration/targets/win_package/tasks/msp_tests.yml new file mode 100644 index 00000000000..cb7ffe94860 --- /dev/null +++ b/test/integration/targets/win_package/tasks/msp_tests.yml @@ -0,0 +1,167 @@ +--- +- name: fail if base product is not applied + win_package: + path: '{{ test_path }}\patch.msp' + state: present + register: fail_no_product_found + failed_when: '"The specified patch does not apply to any installed MSI packages" not in fail_no_product_found.msg' + +- name: install base package for msp patch + win_package: + path: '{{ test_path }}\patch.msi' + state: present + +- name: install msp (check mode) + win_package: + path: '{{ test_path }}\patch.msp' + state: present + register: msp_install_check + check_mode: yes + +- name: get result of install msp (check mode) + slurp: + path: '{{ patch_install_file }}' + register: msp_install_actual_check + +- name: assert install msp (check mode) + assert: + that: + - msp_install_check is changed + - msp_install_check.rc == 0 + - not msp_install_check.reboot_required + - msp_install_actual_check.content | b64decode == 'This is version 1.0' + +- name: install msp + win_package: + path: '{{ test_path }}\patch.msp' + state: present + register: msp_install + +- name: get result of install msp + slurp: + path: '{{ patch_install_file }}' + register: msp_install_actual + +- name: assert install msp + assert: + that: + - msp_install is changed + - msp_install.rc == 0 + - not msp_install.reboot_required + - msp_install_actual.content | b64decode == 'This is version 1.1' + +- name: install msp (idempotence) + win_package: + path: '{{ test_path }}\patch.msp' + state: present + register: msp_install_again + +- name: assert install msp (idempotence) + assert: + that: + - not msp_install_again is changed + +- name: remove msp by path (check mode) + win_package: + path: '{{ test_path }}\patch.msp' + state: absent + register: msp_uninstall_path_check + check_mode: yes + +- name: get result of remove msp by path (check mode) + slurp: + path: '{{ patch_install_file }}' + register: msp_uninstall_path_actual_check + +- name: assert remove msp by path (check mode) + assert: + that: + - msp_uninstall_path_check is changed + - msp_uninstall_path_check.rc == 0 + - not msp_uninstall_path_check.reboot_required + - msp_uninstall_path_actual_check.content | b64decode == 'This is version 1.1' + +- name: remove msp by path + win_package: + path: '{{ test_path }}\patch.msp' + state: absent + register: msp_uninstall_path + +- name: get result of remove msp by path + slurp: + path: '{{ patch_install_file }}' + register: msp_uninstall_path_actual + +- name: assert remove msp by path + assert: + that: + - msp_uninstall_path is changed + - msp_uninstall_path.rc == 0 + - not msp_uninstall_path.reboot_required + - msp_uninstall_path_actual.content | b64decode == 'This is version 1.0' + +- name: remove msp by path (idempotence) + win_package: + path: '{{ test_path }}\patch.msp' + state: absent + register: msp_uninstall_path_again + +- name: assert remove msp by path (idempotence) + assert: + that: + - not msp_uninstall_path_again is changed + +- name: install patch again + win_package: + path: '{{ test_path }}\patch.msp' + state: present + +- name: remove msp by id (check mode) + win_package: + product_id: '{{ patch_patch_id }}' + state: absent + register: msp_uninstall_id_check + check_mode: yes + +- name: get result of remove msp by id (check mode) + slurp: + path: '{{ patch_install_file }}' + register: msp_uninstall_id_actual_check + +- name: assert remove msp by id (check mode) + assert: + that: + - msp_uninstall_id_check is changed + - msp_uninstall_id_check.rc == 0 + - not msp_uninstall_id_check.reboot_required + - msp_uninstall_id_actual_check.content | b64decode == 'This is version 1.1' + +- name: remove msp by id + win_package: + product_id: '{{ patch_patch_id }}' + state: absent + register: msp_uninstall_id + +- name: get result of remove msp by id + slurp: + path: '{{ patch_install_file }}' + register: msp_uninstall_id_actual + +- name: assert remove msp by id + assert: + that: + - msp_uninstall_id is changed + - msp_uninstall_id.rc == 0 + - not msp_uninstall_id.reboot_required + - msp_uninstall_id_actual.content | b64decode == 'This is version 1.0' + +- name: remove msp by id (idempotence) + win_package: + product_id: '{{ patch_patch_id }}' + state: absent + register: msp_uninstall_id_again + +- name: assert remove msp by id (idempotence) + assert: + that: + - not msp_uninstall_id_again is changed diff --git a/test/integration/targets/win_package/tasks/network_tests.yml b/test/integration/targets/win_package/tasks/network_tests.yml deleted file mode 100644 index 4f183a44d5b..00000000000 --- a/test/integration/targets/win_package/tasks/network_tests.yml +++ /dev/null @@ -1,427 +0,0 @@ ---- -- name: install network msi (check mode) - win_package: - path: '{{test_win_package_network_path}}\good.msi' - product_id: '{{test_win_package_good_id}}' - state: present - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_msi_check - check_mode: yes - -- name: get result of install network msi (check mode) - win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} - register: install_network_msi_actual_check - -- name: assert install network msi (check mode) - assert: - that: - - install_network_msi_check is changed - - install_network_msi_check.reboot_required == False - - install_network_msi_actual_check.exists == False - -- name: install network msi - win_package: - path: '{{test_win_package_network_path}}\good.msi' - product_id: '{{test_win_package_good_id}}' - state: present - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_msi - -- name: get result of install network msi - win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} - register: install_network_msi_actual - -- name: assert install network msi - assert: - that: - - install_network_msi is changed - - install_network_msi.reboot_required == False - - install_network_msi.rc == 0 - - install_network_msi_actual.exists == True - -- name: install network msi (idempotent) - win_package: - path: '{{test_win_package_network_path}}\good.msi' - product_id: '{{test_win_package_good_id}}' - state: present - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_msi_idempotent - -- name: assert install network msi (idempotent) - assert: - that: - - install_network_msi_idempotent is not changed - -- name: uninstall network msi with path (check mode) - win_package: - path: '{{test_win_package_network_path}}\good.msi' - product_id: '{{test_win_package_good_id}}' - state: absent - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: uninstall_path_network_msi_check - check_mode: yes - -- name: get result of uninstall network msi with path (check mode) - win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} - register: uninstall_path_network_msi_actual_check - -- name: assert uninstall network msi with path (check mode) - assert: - that: - - uninstall_path_network_msi_check is changed - - uninstall_path_network_msi_check.reboot_required == False - - uninstall_path_network_msi_actual_check.exists == True - -- name: uninstall network msi with path - win_package: - path: '{{test_win_package_network_path}}\good.msi' - product_id: '{{test_win_package_good_id}}' - state: absent - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: uninstall_path_network_msi - -- name: get result of uninstall network msi with path - win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_good_id}} - register: uninstall_path_network_msi_actual - -- name: assert uninstall network msi with path - assert: - that: - - uninstall_path_network_msi is changed - - uninstall_path_network_msi.reboot_required == False - - uninstall_path_network_msi.rc == 0 - - uninstall_path_network_msi_actual.exists == False - -- name: uninstall network msi with path (idempotent) - win_package: - path: '{{test_win_package_network_path}}\good.msi' - product_id: '{{test_win_package_good_id}}' - state: absent - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: uninstall_path_network_msi_idempotent - -- name: assert uninstall network msi with path (idempotent) - assert: - that: - - uninstall_path_network_msi_idempotent is not changed - -- name: install network reboot msi (check mode) - win_package: - path: '{{test_win_package_network_path}}\reboot.msi' - product_id: '{{test_win_package_reboot_id}}' - state: present - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_reboot_msi_check - check_mode: yes - -- name: get result of install network reboot msi (check mode) - win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_reboot_id}} - register: install_network_reboot_msi_actual_check - -- name: assert install network reboot msi (check mode) - assert: - that: - - install_network_reboot_msi_check is changed - - install_network_reboot_msi_check.reboot_required == False - - install_network_reboot_msi_actual_check.exists == False - -- name: install network reboot msi - win_package: - path: '{{test_win_package_network_path}}\reboot.msi' - product_id: '{{test_win_package_reboot_id}}' - state: present - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_reboot_msi - -- name: get result of install network reboot msi - win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_reboot_id}} - register: install_network_reboot_msi_actual - -- name: assert install network reboot msi - assert: - that: - - install_network_reboot_msi is changed - - install_network_reboot_msi.reboot_required == True - - install_network_reboot_msi.rc == 3010 - - install_network_reboot_msi_actual.exists == True - -- name: install network reboot msi (idempotent) - win_package: - path: '{{test_win_package_network_path}}\reboot.msi' - product_id: '{{test_win_package_reboot_id}}' - state: present - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_reboot_msi_idempotent - -- name: assert install network reboot msi (idempotent) - assert: - that: - - install_network_reboot_msi_idempotent is not changed - -- name: uninstall network msi with product_id (check mode) - win_package: - product_id: '{{test_win_package_reboot_id}}' - state: absent - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: uninstall_id_network_msi_check - check_mode: yes - -- name: get result of uninstall network msi with product_id (check mode) - win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_reboot_id}} - register: uninstall_id_network_msi_actual_check - -- name: assert uninstall network msi with product_id (check mode) - assert: - that: - - uninstall_id_network_msi_check is changed - - uninstall_id_network_msi_check.reboot_required == False - - uninstall_id_network_msi_actual_check.exists == True - -- name: uninstall network msi with product_id - win_package: - product_id: '{{test_win_package_reboot_id}}' - state: absent - register: uninstall_id_network_msi - -- name: get result of uninstall network msi with product_id - win_reg_stat: - path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_reboot_id}} - register: uninstall_id_network_msi_actual - -- name: assert uninstall network msi with product_id - assert: - that: - - uninstall_id_network_msi is changed - - uninstall_id_network_msi.reboot_required == True - - uninstall_id_network_msi.rc == 3010 - - uninstall_id_network_msi_actual.exists == False - -- name: uninstall network msi with product_id (idempotent) - win_package: - product_id: '{{test_win_package_reboot_id}}' - state: absent - register: uninstall_id_network_msi_idempotent - -- name: assert uninstall network msi with product_id (idempotent) - assert: - that: - - uninstall_id_network_msi_idempotent is not changed - -- name: ensure the install folder is cleaned in case uninstall didn't work - win_file: - path: '%ProgramFiles(x86)%\Bovine University' - state: absent - -- name: install network msi with arguments (check mode) - win_package: - path: '{{test_win_package_network_path}}\good.msi' - product_id: '{{test_win_package_good_id}}' - state: present - arguments: ADDLOCAL=Cow - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_msi_argument_check - check_mode: yes - -- name: get result of moo file after install network msi with arguments (check mode) - win_stat: - path: '%ProgramFiles(x86)%\Bovine University\moo.exe' - register: install_network_msi_argument_moo_check - -- name: get result of cow file after install network msi with arguments (check mode) - win_stat: - path: '%ProgramFiles(x86)%\Bovine University\cow.exe' - register: install_network_msi_argument_cow_check - -- name: assert install network msi with arguments (check mode) - assert: - that: - - install_network_msi_argument_check is changed - - install_network_msi_argument_check.reboot_required == False - - install_network_msi_argument_moo_check.stat.exists == False - - install_network_msi_argument_cow_check.stat.exists == False - -- name: install network msi with arguments - win_package: - path: '{{test_win_package_network_path}}\good.msi' - product_id: '{{test_win_package_good_id}}' - state: present - arguments: ADDLOCAL=Cow - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_msi_argument - -- name: get result of moo file after install network msi with arguments - win_stat: - path: '%ProgramFiles(x86)%\Bovine University\moo.exe' - register: install_network_msi_argument_moo - -- name: get result of cow file after install network msi with arguments - win_stat: - path: '%ProgramFiles(x86)%\Bovine University\cow.exe' - register: install_network_msi_argument_cow - -- name: assert install network msi with arguments - assert: - that: - - install_network_msi_argument is changed - - install_network_msi_argument.reboot_required == False - - install_network_msi_argument.rc == 0 - - install_network_msi_argument_moo.stat.exists == False - - install_network_msi_argument_cow.stat.exists == True - -- name: install network msi with arguments (idempotent) - win_package: - path: '{{test_win_package_network_path}}\good.msi' - product_id: '{{test_win_package_good_id}}' - state: present - arguments: ADDLOCAL=Cow - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_msi_argument_again - -- name: assert install network msi with arguments (idempotent) - assert: - that: - - install_network_msi_argument_again is not changed - -- name: uninstall msi after test - win_package: - product_id: '{{test_win_package_good_id}}' - state: absent - -- name: install network exe (check mode) - win_package: - path: '{{test_win_package_network_path}}\7z.exe' - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: present - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_exe_check - check_mode: yes - -- name: get result of install network exe (check mode) - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: install_network_exe_actual_check - -- name: assert install network exe (check mode) - assert: - that: - - install_network_exe_check is changed - - install_network_exe_check.reboot_required == False - - install_network_exe_actual_check.exists == False - -- name: install network exe - win_package: - path: '{{test_win_package_network_path}}\7z.exe' - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: present - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_exe - -- name: get result of install network exe - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: install_network_exe_actual - -- name: assert install network exe - assert: - that: - - install_network_exe is changed - - install_network_exe.reboot_required == False - - install_network_exe.rc == 0 - - install_network_exe_actual.exists == True - -- name: install network exe (idempotent) - win_package: - path: '{{test_win_package_network_path}}\7z.exe' - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: present - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: install_network_exe_idempotent - -- name: assert install network exe (idempotent) - assert: - that: - - install_network_exe_idempotent is not changed - -- name: uninstall network exe (check mode) - win_package: - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: absent - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: uninstall_network_exe_check - check_mode: yes - -- name: get result of uninstall network exe (check mode) - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: uninstall_network_exe_actual_check - -- name: assert uninstall network exe (check mode) - assert: - that: - - uninstall_network_exe_check is changed - - uninstall_network_exe_check.reboot_required == False - - uninstall_network_exe_actual_check.exists == True - -- name: uninstall network exe - win_package: - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: absent - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: uninstall_network_exe - -- name: get result of uninstall network exe - win_reg_stat: - path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{test_win_package_exe_id}} - register: uninstall_network_exe_actual - -- name: assert uninstall network exe - assert: - that: - - uninstall_network_exe is changed - - uninstall_network_exe.reboot_required == False - - uninstall_network_exe.rc == 0 - - uninstall_network_exe_actual.exists == False - -- name: uninstall network exe (idempotent) - win_package: - product_id: '{{test_win_package_exe_id}}' - arguments: /S - state: absent - user_name: '{{test_win_package_network_username|default(omit)}}' - user_password: '{{test_win_package_network_password|default(omit)}}' - register: uninstall_network_exe_idempotent - -- name: assert uninstall network exe (idempotent) - assert: - that: - - uninstall_network_exe_idempotent is not changed \ No newline at end of file diff --git a/test/integration/targets/win_package/tasks/registry_tests.yml b/test/integration/targets/win_package/tasks/registry_tests.yml new file mode 100644 index 00000000000..b4b5a8e2bd5 --- /dev/null +++ b/test/integration/targets/win_package/tasks/registry_tests.yml @@ -0,0 +1,393 @@ +--- +# This symlink allows us to test paths with a space in the executable path +- name: create symbolic link in test folder to powershell + win_command: cmd.exe /c mklink "{{ test_path }}\powershell symlink.exe" C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + +- name: copy across test script that creates a registry install entry + win_template: + src: registry_package.ps1.j2 + dest: '{{ test_path }}\registry_package.ps1' + +- name: install registry package not quoted and no spaces in path + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: + - -File + - '{{ test_path }}\registry_package.ps1' + - HKLMx64 + - UninstallString + - C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -Command Remove-Item -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} -Force -Recurse + state: present + +- name: uninstall registry package not quoted and no spaces in path (check mode) + win_package: + product_id: '{{ registry_id }}' + state: absent + register: registry_uninstall_normal_check + check_mode: yes + +- name: get result of uninstall registry package not quoted and no spaces in path (check mode) + win_reg_stat: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} + register: registry_uninstall_normal_actual_check + +- name: assert uninstall registry package not quoted and no spaces in path (check mode) + assert: + that: + - registry_uninstall_normal_check is changed + - registry_uninstall_normal_check.rc == 0 + - not registry_uninstall_normal_check.reboot_required + - registry_uninstall_normal_actual_check.exists + +- name: uninstall registry package not quoted and no spaces in path + win_package: + product_id: '{{ registry_id }}' + state: absent + register: registry_uninstall_normal + +- name: get result of uninstall registry package not quoted and no spaces in path + win_reg_stat: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} + register: registry_uninstall_normal_actual + +- name: assert uninstall registry package not quoted and no spaces in path + assert: + that: + - registry_uninstall_normal is changed + - registry_uninstall_normal.rc == 0 + - not registry_uninstall_normal.reboot_required + - not registry_uninstall_normal_actual.exists + +- name: uninstall registry package not quoted and no spaces in path (idempotence) + win_package: + product_id: '{{ registry_id }}' + state: absent + register: registry_uninstall_normal_again + +- name: assert uninstall registry package not quoted and no spaces in path (idempotence) + assert: + that: + - not registry_uninstall_normal_again is changed + +- name: install registry package not quoted and spaces in path + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: + - -File + - '{{ test_path }}\registry_package.ps1' + - HKLMx86 + - QuietUninstallString + - '{{ test_path }}\powershell symlink.exe -Command Remove-Item -Path HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} -Force -Recurse' + state: present + +- name: uninstall registry package not quoted and spaces in path + win_package: + product_id: '{{ registry_id }}' + state: absent + register: registry_uninstall_not_quoted + +- name: get result of uninstall registry package not quoted and spaces in path + win_reg_stat: + path: HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} + register: registry_uninstall_not_quoted_actual + +- name: assert uninstall registry package not quoted and spaces in path + assert: + that: + - registry_uninstall_not_quoted is changed + - registry_uninstall_not_quoted.rc == 0 + - not registry_uninstall_not_quoted.reboot_required + - not registry_uninstall_not_quoted_actual.exists + +- name: install registry package quoted and no spaces in path + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: + - -File + - '{{ test_path }}\registry_package.ps1' + - HKCUx64 + - UninstallString + - '"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" -Command Remove-Item -Path HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} -Force -Recurse' + state: present + +- name: uninstall registry package quoted and no spaces in path + win_package: + product_id: '{{ registry_id }}' + state: absent + register: registry_uninstall_quoted_normal + +- name: get result of uninstall registry package quoted and no spaces in path + win_reg_stat: + path: HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Unisntall\{{ registry_id }} + register: registry_uninstall_quoted_normal_actual + +- name: assert uninstall registry package quoted and no spaces in path + assert: + that: + - registry_uninstall_quoted_normal is changed + - registry_uninstall_quoted_normal.rc == 0 + - not registry_uninstall_quoted_normal.reboot_required + - not registry_uninstall_quoted_normal_actual.exists + +- name: install registry package quoted and spaces in path + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: + - -File + - '{{ test_path }}\registry_package.ps1' + - HKCUx86 + - QuietUninstallString + - '"{{ test_path }}\powershell symlink.exe" -Command Remove-Item -Path HKCU:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} -Force -Recurse' + state: present + +- name: uninstall registry package quoted and spaces in path + win_package: + product_id: '{{ registry_id }}' + state: absent + register: registry_uninstall_quoted_spaces + +- name: get result of uninstall registry package quoted and spaces in path + win_reg_stat: + path: HKCU:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} + register: registry_uninstall_quoted_spaces_actual + +- name: assert uninstall registry package quoted and spaces in path + assert: + that: + - registry_uninstall_quoted_spaces is changed + - registry_uninstall_quoted_spaces.rc == 0 + - not registry_uninstall_quoted_spaces.reboot_required + - not registry_uninstall_quoted_spaces_actual.exists + +- name: install registry package with unquoted env vars + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: + - -File + - '{{ test_path }}\registry_package.ps1' + - HKLMx64 + - UninstallString + - '%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -Command Remove-Item -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} -Force -Recurse' + state: present + +- name: uninstall registry package with unquoted env vars + win_package: + product_id: '{{ registry_id }}' + state: absent + register: registry_uninstall_env + +- name: get result of ininstall registry package with unquoted env vars + win_reg_stat: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} + register: registry_uninstall_env_actual + +- name: assert uninstall registry package with unquoted env vars + assert: + that: + - registry_uninstall_env is changed + - registry_uninstall_env.rc == 0 + - not registry_uninstall_env.reboot_required + - not registry_uninstall_env_actual.exists + +- name: install registry package quoted env vars + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: + - -File + - '{{ test_path }}\registry_package.ps1' + - HKLMx64 + - UninstallString + - '"%TestVar%\powershell symlink.exe" -Command Remove-Item -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} -Force -Recurse' + state: present + +- name: uninstall registry package quoted env vars + win_package: + product_id: '{{ registry_id }}' + state: absent + register: registry_uninstall_env_quoted + environment: + TestVar: '{{ test_path }}' + +- name: get result of uninstall registry package quoted env vars + win_reg_stat: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} + register: registry_uninstall_env_quoted_actual + +- name: assert uninstall registry package quoted env vars + assert: + that: + - registry_uninstall_env_quoted is changed + - registry_uninstall_env_quoted.rc == 0 + - not registry_uninstall_env_quoted.reboot_required + - not registry_uninstall_env_quoted_actual.exists + +- name: install registry package for overriding path test + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: + - -File + - '{{ test_path }}\registry_package.ps1' + - HKLMx64 + - UninstallString + - Fail path + state: present + +- name: uninstall registry package with overridden path and explicit error code + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + product_id: '{{ registry_id }}' + arguments: '-Command Remove-Item -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }}; exit 1' + expected_return_code: 1 + state: absent + register: registry_uninstall_explicit_path + +- name: get result of uninstall registry package with overridden path and explicit error code + win_reg_stat: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} + register: registry_uninstall_explicit_path_actual + +- name: assert unisntall registry package with overridden path and explicit error code + assert: + that: + - registry_uninstall_explicit_path is changed + - registry_uninstall_explicit_path.rc == 1 + - not registry_uninstall_explicit_path.reboot_required + - not registry_uninstall_explicit_path_actual.exists + +- name: create registry package for uninstal with explicit arguments test + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: + - -File + - '{{ test_path }}\registry_package.ps1' + - HKLMx64 + - QuietUninstallString + - C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + state: present + +- name: uninstall registry package with explicit arguments and chdir + win_package: + product_id: '{{ registry_id }}' + arguments: -Command Remove-Item -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} -Force -Recurse; [System.IO.File]::WriteAllText('{{ test_path }}\reg_out.txt', $pwd.Path, [System.Text.Encoding]::Unicode); exit 3010 + state: absent + chdir: C:\Users + register: registry_uninstall_arguments + +- name: get package result of uninstall registry package with explicit arguments and chdir + win_reg_stat: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} + register: registry_uninstall_arguments_reg_actual + +- name: get package output of uninstall registry package with explicit arguments and chdir + slurp: + path: '{{ test_path }}\reg_out.txt' + register: registry_uninstall_arguments_chdir_actual + +- name: assert uninstall registry package with explicit arguments and chdir + assert: + that: + - registry_uninstall_arguments is changed + - registry_uninstall_arguments.rc == 3010 + - registry_uninstall_arguments.reboot_required + - not registry_uninstall_arguments_reg_actual.exists + # backslash escaping makes it hard to compare the value, just compare the raw base64 string expected which is 'C:\Users' as UTF-16-LE + - registry_uninstall_arguments_chdir_actual.content == '//5DADoAXABVAHMAZQByAHMA' + +- name: install package for creates_* tests + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: + - -File + - '{{ test_path }}\registry_package.ps1' + - HKLMx64 + - UninstallString + - powershell.exe -Command Remove-Item -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{{ registry_id }} -Force -Recurse + state: present + +- name: get actual PowerShell file version for tests + win_shell: | + $path = '{{ test_path }}\powershell symlink.exe' + $version = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($path) + (New-Object -TypeName System.Version -ArgumentList @( + $version.FileMajorPart, $version.FileMinorPart, $version.FileBuildPart, $version.FilePrivatePart + )).ToString() + changed_when: False + register: powershell_version + +- name: test creates_path overrides product_id + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: echo hi + product_id: '{{ registry_id }}' + creates_path: C:\missing + state: present + register: creates_path + +- name: test creates_version overrides product_id + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: echo hi + product_id: '{{ registry_id }}' + creates_path: '{{ test_path }}\powershell symlink.exe' + creates_version: 1.0.0 + state: present + register: creates_version + +- name: assert test creates_path and creates_version override product_id is changed + assert: + that: + - creates_path is changed + - creates_version is changed + +- name: test creates_path to existing but no product_id + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: echo hi + product_id: Fake + creates_path: C:\Windows + state: present + register: creates_path_present + +- name: test creates_version to existing but no product_id + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: echo hi + product_id: Fake + creates_path: '{{ test_path }}\powershell symlink.exe' + creates_version: '{{ powershell_version.stdout | trim }}' + state: present + register: creates_version_present + +- name: assert test creates_path to existing but no product_Id + assert: + that: + - not creates_path_present is changed + - not creates_version_present is changed + +- name: test creates_service overrides product_id + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: echo hi + product_id: '{{ registry_id }}' + creates_service: missing service + state: present + register: creates_service + +- name: assert test creates_service overrides product_id + assert: + that: + - creates_service is changed + +- name: test creates_service to existing but no product_id + win_package: + path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: echo hi + product_id: Fake + creates_service: winrm + state: present + register: creates_service_present + +- name: assert test creates_service to existing but no product_Id + assert: + that: + - not creates_service_present is changed diff --git a/test/integration/targets/win_package/templates/registry_package.ps1.j2 b/test/integration/targets/win_package/templates/registry_package.ps1.j2 new file mode 100644 index 00000000000..b82b21d0c0c --- /dev/null +++ b/test/integration/targets/win_package/templates/registry_package.ps1.j2 @@ -0,0 +1,26 @@ +$ErrorActionPreference = 'Stop' + +$productId = '{{ registry_id }}' + +$regPath = switch ($args[0]) { + HKLMx64 { 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' } + HKLMx86 { 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' } + HKCUx64 { 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' } + HKCUx86 { 'HKCU:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' } + default { throw "Invalid registry path specified $($args[0])" } +} +$regProperty = $args[1] +$regUninstallString = $args[2] +#$regUninstallString = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($args[2])) + +$null = New-Item -Path $regPath -Name $productId -Force + +$propParams = @{ + Path = "$regPath\$productId" + Force = $true + PropertyType = 'String' +} +New-ItemProperty -Name $regProperty -Value $regUninstallString @propParams +if ($regProperty -eq 'QuietUninstallString') { + New-ItemProperty -Name 'UninstallString' -Value 'Fail if used' @propParams +} diff --git a/test/sanity/ignore.txt b/test/sanity/ignore.txt index 2de05d27253..19910366330 100644 --- a/test/sanity/ignore.txt +++ b/test/sanity/ignore.txt @@ -7562,9 +7562,6 @@ lib/ansible/modules/windows/win_lineinfile.ps1 pslint:PSCustomUseLiteralPath lib/ansible/modules/windows/win_mapped_drive.ps1 pslint:PSCustomUseLiteralPath lib/ansible/modules/windows/win_netbios.ps1 validate-modules:parameter-list-no-elements lib/ansible/modules/windows/win_optional_feature.ps1 validate-modules:parameter-list-no-elements -lib/ansible/modules/windows/win_package.ps1 pslint:PSCustomUseLiteralPath -lib/ansible/modules/windows/win_package.ps1 pslint:PSUseApprovedVerbs -lib/ansible/modules/windows/win_package.ps1 validate-modules:doc-elements-mismatch lib/ansible/modules/windows/win_pagefile.ps1 pslint:PSCustomUseLiteralPath lib/ansible/modules/windows/win_pagefile.ps1 pslint:PSUseDeclaredVarsMoreThanAssignments # New PR - bug test_path should be testPath lib/ansible/modules/windows/win_pagefile.ps1 pslint:PSUseSupportsShouldProcess