#!powershell # Copyright: (c) 2015, Matt Davis # Copyright: (c) 2017, Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) #AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType #AnsibleRequires -CSharpUtil Ansible.Basic $spec = @{ options = @{ accept_list = @{ type = 'list'; elements = 'str'; aliases = 'whitelist' } category_names = @{ type = 'list' elements = 'str' default = 'CriticalUpdates', 'SecurityUpdates', 'UpdateRollups' } log_path = @{ type = 'path' } reject_list = @{ type = 'list'; elements = 'str'; aliases = 'blacklist' } server_selection = @{ type = 'str'; choices = 'default', 'managed_server', 'windows_update'; default = 'default' } state = @{ type = 'str'; choices = 'installed', 'searched', 'downloaded'; default = 'installed' } skip_optional = @{ type = 'bool'; default = $false } # options used by the action plugin - ignored here reboot = @{ type = 'bool'; default = $false } reboot_timeout = @{ type = 'int'; default = 1200 } use_scheduled_task = @{ type = 'bool'; default = $false } _wait = @{ type = 'bool'; default = $false } _output_path = @{ type = 'str' } } supports_check_mode = $true } $module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) # For backwards compatibility - allow the camel case names but internally use the full names $categoryNames = $module.Params.category_names | ForEach-Object -Process { switch -exact ($_) { CriticalUpdates { 'Critical Updates' } DefinitionUpdates { 'Definition Updates' } DeveloperKits { 'Developer Kits' } FeaturePacks { 'Feature Packs' } SecurityUpdates { 'Security Updates' } ServicePacks { 'Service Packs' } UpdateRollups { 'Update Rollups' } default { $_ } } } Function Invoke-TaskInfo { <# .SYNOPSIS Bootstrap script used as the entrypoint for our ephemeral task to invoke the code written to the pipe. .PARAMETER PipeName The named pipe to read the invocation details from. .PARAMETER LogPath Write any failures to this path for reporting an error to the parent. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [String] $Id, [Parameter(Mandatory)] [String] $LogPath ) $ErrorActionPreference = 'Stop' # Traps are icky but it does have the convenience of capturing all failures for us to log trap { $errInfo = $runInfo = [System.Management.Automation.PSSerializer]::Serialize($_) $errInfo | Out-File (Join-Path $LogPath 'error.txt') [System.Environment]::Exit(1) } # NamedPipeClientStream does not fail if the pipe does not exist and will hang indefinitely. In case there was a # problem with starting the pipe fail straight away instead of hanging. We cannot use Test-Path as that will # connect to the pipe which we want to reserve for our explicit .Connect() call later on. We also cannot use the # $Id as the filter part because old Win versions (2008/08R2) do not seem to support filtering for pipes there. # While we don't guarantee support for these versions I'm not ready to fully drop it when there's a simple # workaround. # Also need to enumerate the output manually to ignore illegal paths in .NET logic # https://github.com/ansible-collections/ansible.windows/issues/291 $pipeEnumerator = [System.IO.Directory]::EnumerateFiles('\\.\pipe\', '*').GetEnumerator() try { while ($true) { try { $remaining = $pipeEnumerator.MoveNext() } catch { continue } if (-not $remaining) { throw "Pipe $Id does not exist" } if ($pipeEnumerator.Current -eq "\\.\pipe\$Id") { break } } } finally { $pipeEnumerator.Dispose() } $clientReader = $null $client = New-Object -TypeName System.IO.Pipes.NamedPipeClientStream -ArgumentList @( '.', $Id, [System.IO.Pipes.PipeDirection]::In, [System.IO.Pipes.PipeOptions]::None, [System.Security.Principal.TokenImpersonationLevel]::Anonymous ) try { $client.Connect() $clientReader = New-Object -TypeName System.IO.StreamReader -ArgumentList $client $details = $clientReader.ReadToEnd() } finally { if ($clientReader) { $clientReader.Dispose() } $client.Dispose() } $rs = $null try { $eventHandle = [System.Threading.EventWaitHandle]::OpenExisting("Global\$Id") try { $runInfo = [System.Management.Automation.PSSerializer]::Deserialize($details) $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() foreach ($funcInfo in $runInfo.Commands.GetEnumerator()) { $cmd = New-Object -TypeName System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList @( $funcInfo.Key, $funcInfo.Value, [System.Management.Automation.ScopedItemOptions]::AllScope, $null ) $iss.Commands.Add($cmd) } $rs = [RunspaceFactory]::CreateRunspace($iss) $rs.Open() $ps = [PowerShell]::Create() $ps.Runspace = $rs [void]$ps.AddScript($runInfo.ScriptBlock).AddParameters($runInfo.Parameters) $task = $ps.BeginInvoke() # Signal parent that the data was received/decoded and is running. [void]$eventHandle.Set() } finally { $eventHandle.Dispose() } $ps.EndInvoke($task) } finally { if ($rs) { $rs.Dispose() } } } Function New-NamedPipe { <# .SYNOPSIS Creates a namedpipe accessible to the current user. .PARAMETER Name The pipe name to create. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "", Justification = "We don't care about failures on dispoable, especially ones we know will occur")] [OutputType([System.IO.StreamWriter])] [CmdletBinding()] param ( [Parameter(Mandatory)] [String] $Name ) $currentUser = ([Security.Principal.WindowsIdentity]::GetCurrent()).User $systemSid = (New-Object -TypeName Security.Principal.SecurityIdentifier -ArgumentList @( [Security.Principal.WellKnownSidType ]::LocalSystemSid, $null)) $pipeSec = New-Object -TypeName System.IO.Pipes.PipeSecurity foreach ($sid in @($currentUser, $systemSid)) { $pipeSec.AddAccessRule($pipeSec.AccessRuleFactory( $sid, [Int32]([System.IO.Pipes.PipeAccessRights]'ReadData,ReadAttributes,ReadExtendedAttributes,Synchronize'), $false, [System.Security.AccessControl.InheritanceFlags]::None, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow )) } # FUTURE: This won't work on pwsh as it doesn't take the PipeSecurity overload. Unfortunately the only way to do # that before .NET 5 (pwsh 7.2+) is to use PInvoke to call CreateNamedPipeW. $server = New-Object -TypeName System.IO.Pipes.NamedPipeServerStream -ArgumentList @( $Name, [System.IO.Pipes.PipeDirection]::Out, 1, [System.IO.Pipes.PipeTransmissionMode]::Byte, [System.IO.Pipes.PipeOptions]::Asynchronous, 0, 0, $pipeSec ) $sw = New-Object -TypeName System.IO.StreamWriter -ArgumentList $server # Calling Dispose() on the stream will throw an exception is no client has connected to the server. It still # closes the stream which is what we want so we just ignore the exception. $sw.PSObject.Members.Add( ( New-Object -TypeName System.Management.Automation.PSScriptMethod -ArgumentList @( 'Dispose', { try { $this.PSBase.Dispose() } catch [System.InvalidOperationException] {} } ))) $sw } Function Start-EphemeralTask { <# .SYNOPSIS Creates and starts the process as a scheduled task immediately. .PARAMETER Name The name of the task to create. .PARAMETER Path The executable path to invoke. .PARAMETER Arguments The arguments to run the task with. #> [OutputType([Int32])] [CmdletBinding()] param ( [Parameter(Mandatory)] [String] $Name, [Parameter(Mandatory)] [String] $Path, [Parameter(Mandatory)] [String] $Arguments ) $errMessage = $null $scheduler = New-Object -ComObject Schedule.Service try { $scheduler.Connect() $taskFolder = $scheduler.GetFolder('\') # Stop and delete the task if it is already running $task = $null $folderTasks = $taskFolder.GetTasks(1) # TASK_ENUM_HIDDEN for ($i = 1; $i -le $folderTasks.Count; $i++) { if ($folderTasks.Item($i).Name -eq $Name) { $task = $folderTasks.Item($i) break } } if ($task) { if ($task.State -eq 4) { # TASK_STATE_RUNNING $task.Stop(0) } $taskFolder.DeleteTask($Name, 0) } $taskDefinition = $scheduler.NewTask(0) $taskAction = $taskDefinition.Actions.Create(0) # TASK_ACTION_EXEC $taskAction.Path = $Path $taskAction.Arguments = $Arguments $taskDefinition.Settings.AllowDemandStart = $true $taskDefinition.Settings.AllowHardTerminate = $true $taskDefinition.Settings.DisallowStartIfOnBatteries = $false $taskDefinition.Settings.Enabled = $true $taskDefinition.Settings.StopIfGoingOnBatteries = $false # Try the current user first but fallback to SYSTEM in case the user isn't allowed to do batch logons. $userSids = @( [Security.Principal.WindowsIdentity]::GetCurrent().User (New-Object -TypeName Security.Principal.SecurityIdentifier -ArgumentList @( [Security.Principal.WellKnownSidType ]::LocalSystemSid, $null)) ) foreach ($sid in $userSids) { # While S4U is designed for normal user accounts it is accepted for well known service accounts $taskDefinition.Principal.UserId = $sid.Value $taskDefinition.Principal.LogonType = 2 # TASK_LOGON_S4U $taskDefinition.Principal.RunLevel = 1 # TASK_RUNLEVEL_HIGHEST $registerDate = Get-Date $createdTask = $taskFolder.RegisterTaskDefinition( $Name, $taskDefinition, 2, # TASK_CREATE $null, $null, $taskDefinition.Principal.LogonType ) try { $runningTask = $createdTask.RunEx( $null, 2, # TASK_RUN_IGNORE_CONSTRAINTS 0, "" ) # Gets the task logs if there is a failure and has been logged after the register datetime. $taskFilter = @" "@ # There is a chance EnginePID isn't yet defined (task hasn't fully started). We want to wait until that prop # is populated before returning the value and continuing on. $taskPid = 0 $errMessage = $null while ($true) { # The task might still be initialising, wait until it is no longer queued if ($createdTask.LastTaskResult -eq 0x00041325) { # SCHED_S_TASK_QUEUED Start-Sleep -Seconds 1 continue } $taskPid = $runningTask.EnginePID if ($taskPid) { break } if ($createdTask.State -ne 4) { # TASK_STATE_RUNNING $errEvent = Get-WinEvent -FilterXml $taskFilter -ErrorAction SilentlyContinue | Select-Object -First 1 if ($errEvent) { $errMessage = $errEvent.Message } else { # If event logs are disabled for tasks we can only use the last run result for information. $errMessage = "Unknown failure trying to start win_updates tasks '0x{0:X8}'- enable task event logs to see more info" -f ( $createdTask.LastTaskResult ) } break } Start-Sleep -Seconds 1 } if ($taskPid) { $taskPid break } } finally { # The task will continue to run even after it is deleted $taskFolder.DeleteTask($Name, 0) } } if ($errMessage) { throw "Failed to start task: $errMessage" } } finally { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($scheduler) } } Function Invoke-AsBatchLogon { <# .SYNOPSIS Invoke the scriptblock as a batch logon through the task scheduler. .PARAMETER Path The directory to store the bootstrap script and any errors it encountered. .PARAMETER ScriptBlock The scriptblock to invoke. .PARAMETER Parameters The parameters to invoke on the scriptblock. .PARAMETER Wait Wait for the scriptblock to finish instead of it running in the background. #> [OutputType([int])] [CmdletBinding()] param ( [Parameter(Mandatory)] [Ansible.Basic.AnsibleModule] $Module, [Parameter(Mandatory)] [ScriptBlock] $ScriptBlock, [Parameter(Mandatory)] [Hashtable] $Parameters, [Parameter()] [Hashtable] $Commands = @{}, [Switch] $Wait ) $errPath = Join-Path $Module.Tmpdir 'error.txt' if (Test-Path -LiteralPath $errPath) { Remove-Item -LiteralPath $errpath -Force } $eventHandle = $server = $null try { $pipeName = "ansible-$($Module.ModuleName)-$([Guid]::NewGuid().Guid)" $server = New-NamedPipe -Name $pipeName $waitConnect = $server.BaseStream.BeginWaitForConnection($null, $null) $eventHandle = New-Object -TypeName System.Threading.EventWaitHandle -ArgumentList @( $false, [System.Threading.EventResetMode]::ManualReset, "Global\$pipeName" ) [void]$eventHandle.Reset() $scriptPath = Join-Path $Module.Tmpdir 'task.ps1' Set-Content -LiteralPath $scriptPath -Value ${function:Invoke-TaskInfo} $pwsh = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" # Cannot use -File in case the host has a GPO defined execution policy. Instead this will read the script # and run it like an in memory scriptblock bypassing the execution policy. $arguments = @( '-NoProfile' '-NonInteractive' '-Command' '$cmd = Get-Content -LiteralPath ''{0}'' -Raw;' -f $scriptPath '&([ScriptBlock]::Create($cmd)) -Id ''{0}'' -LogPath ''{1}''' -f $pipeName, $module.TmpDir ) -join ' ' $taskPid = Start-EphemeralTask -Name "ansible-$($Module.ModuleName)" -Path $pwsh -Arguments $arguments # Wait for the task to connect to our pipe or for the process to end (failed and should be reported) $waitProcPS = [PowerShell]::Create() [void]$waitProcPS.AddCommand('Wait-Process').AddParameters(@{Id = $taskPid; ErrorAction = 'SilentlyContinue' }) $waitProcTask = $waitProcPS.BeginInvoke() $waitIdx = [System.Threading.WaitHandle]::WaitAny(@( $waitProcTask.AsyncWaitHandle, $waitConnect.AsyncWaitHandle )) if ($waitIdx -eq 0) { throw "Task failed to connect to pipe" } $server.BaseStream.EndWaitForConnection($waitConnect) $runInfo = [System.Management.Automation.PSSerializer]::Serialize(@{ Commands = $Commands ScriptBlock = $ScriptBlock.ToString() Parameters = $Parameters }) $server.WriteLine($runInfo) $server.BaseStream.WaitForPipeDrain() # Close the named pipe so the client knows it's reached the end $server.Dispose() # Wait for confirmation the task has received the data and has started the task or failed (proc has ended) $waitIdx = [System.Threading.WaitHandle]::WaitAny(@( $waitProcTask.AsyncWaitHandle, $eventHandle )) if ($waitIdx -eq 0) { throw "Task failed to invoke script" } if ($Wait) { [void]$waitProcPS.EndInvoke($waitProcTask) } else { $waitProcPS.Stop() } $taskPid } catch { if (Test-Path -LiteralPath $errPath) { $rawError = Get-Content -LiteralPath $errPath -Raw $errDetails = [System.Management.Automation.PSSerializer]::Deserialize($rawError) # Because the ErrorRecord is a deserialized object we need to manually build the exception msg. $catInfo = '{0}: ({1}:{2}) [{3}], {4}' -f ( [System.Management.Automation.ErrorCategory]$errDetails.ErrorCategory_Category, $errDetails.ErrorCategory_TargetName, $errDetails.ErrorCategory_TargetType, $errDetails.ErrorCategory_Activity, $errDetails.ErrorCategory_Reason ) $exceptionString = "{0}`r`n{1}" -f ($errDetails.ToString(), $errDetails.InvocationInfo.PositionMessage) $exceptionString += "`r`n + CategoryInfo : {0}" -f $catInfo $exceptionString += "`r`n + FullyQualifiedErrorId : {0}" -f $errDetails.FullyQualifiedErrorId $exceptionString += "`r`n`r`nScriptStackTrace:`r`n{0}" -f $errDetails.ErrorDetails_ScriptStackTrace $Module.Result.exception = $exceptionString $Module.FailJson("Failure in task bootstrap script ($($_.Exception.Message)): $($errDetails.ToString())") } else { $Module.FailJson("Failed to invoke batch script: $($_.Exception.Message)", $_) } } finally { if ($eventHandle) { $eventHandle.Dispose() } if ($server) { $server.Dispose() } } } Function Install-WindowsUpdate { [CmdletBinding()] Param( [Parameter(Mandatory)] [String[]] $Category, [Parameter(Mandatory)] [String] $ServerSelection, [Parameter(Mandatory)] [String] $State, [Parameter(Mandatory)] [String] $OutputPath, [Parameter(Mandatory)] [String] $CancelId, [Parameter()] [AllowEmptyCollection()] [String[]] $Accept = @(), [Parameter()] [Switch] $SkipOptional, [Parameter()] [AllowEmptyCollection()] [String[]] $Reject = @(), [Parameter()] [String] $LogPath, [Switch] $CheckMode, [Switch] $LocalDebugger ) $ErrorActionPreference = 'Stop' $exitResult = @{ changed = $false failed = $false reboot_required = $false action = $null # Current action, used for exception information if set exception = $null # Exception info in case of a failure @{message, exception, hresult} } $tmpDir = Split-Path -Path $outputPath -Parent Add-CSharpType -TempPath $tmpDir -References @' using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Runtime.InteropServices; using System.Management.Automation; using System.Management.Automation.Runspaces; using System.Threading; using System.Threading.Tasks; namespace Ansible.Windows.WinUpdates { [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] [Guid("77254866-9F5B-4C8E-B9E2-C77A8530D64B")] public interface IDownloadCompletedCallback { void Invoke(IDownloadJob job, IDownloadCompletedCallbackArgs callbackArgs); } [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] [Guid("C574DE85-7358-43F6-AAE8-8697E62D8BA7")] public interface IDownloadJob {} [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] [Guid("FA565B23-498C-47A0-979D-E7D5B1813360")] public interface IDownloadCompletedCallbackArgs {} [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] [Guid("8C3F1CDD-6173-4591-AEBD-A56A53CA77C1")] public interface IDownloadProgressChangedCallback { void Invoke(IDownloadJob job, IDownloadProgressChangedCallbackArgs callbackArgs); } [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] [Guid("324FF2C6-4981-4B04-9412-57481745AB24")] public interface IDownloadProgressChangedCallbackArgs {} [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] [Guid("DAA4FDD0-4727-4DBE-A1E7-745DCA317144")] public interface IDownloadResult {} [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] [Guid("45F4F6F3-D602-4F98-9A8A-3EFA152AD2D3")] public interface IInstallationCompletedCallback { void Invoke(IInstallationJob job, IInstallationCompletedCallbackArgs callbackArgs); } [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] [Guid("5C209F0B-BAD5-432A-9556-4699BED2638A")] public interface IInstallationJob {} [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] [Guid("250E2106-8EFB-4705-9653-EF13C581B6A1")] public interface IInstallationCompletedCallbackArgs {} [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] [Guid("E01402D5-F8DA-43BA-A012-38894BD048F1")] public interface IInstallationProgressChangedCallback { void Invoke(IInstallationJob job, IInstallationProgressChangedCallbackArgs callbackArgs); } [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] [Guid("E4F14E1E-689D-4218-A0B9-BC189C484A01")] public interface IInstallationProgressChangedCallbackArgs {} [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] [Guid("A43C56D6-7451-48D4-AF96-B6CD2D0D9B7A")] public interface IInstallationResult {} [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] [Guid("88AEE058-D4B0-4725-A2F1-814A67AE964C")] public interface ISearchCompletedCallback { void Invoke(ISearchJob job, ISearchCompletedCallbackArgs callbackArgs); } [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] [Guid("7366EA16-7A1A-4EA2-B042-973D3E9CD99B")] public interface ISearchJob {} [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] [Guid("A700A634-2850-4C47-938A-9E4B6E5AF9A6")] public interface ISearchCompletedCallbackArgs {} [ComImport()] [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] [Guid("D40CFF62-E08C-4498-941A-01E25F0FD33C")] public interface ISearchResult {} public enum OperationResultCode : int { NotStarted = 0, InProgress = 1, Succeeded = 2, SuceededWithErrors = 3, Failed = 4, Aborted = 5, } public class NativeMethods { [DllImport("Kernel32.dll")] public static extern UInt32 SetThreadExecutionState( UInt32 esFlags); } public class API { public Dictionary IndexMap = new Dictionary(); private Mutex LogMutex = new Mutex(); private Mutex OutputMutex = new Mutex(); private string OutputPath; private string LogPath; private bool CheckMode; private bool LocalDebugging; public API(string outputPath, string logPath, bool checkMode, bool localDebugging) { OutputPath = outputPath; LogPath = logPath; CheckMode = checkMode; LocalDebugging = localDebugging; } public static Task WaitHandleToTask(WaitHandle waitHandle) { TaskCompletionSource tcs = new TaskCompletionSource(); ThreadPool.RegisterWaitForSingleObject( waitHandle, (o, timeout) => { tcs.SetResult(null); }, null, Timeout.InfiniteTimeSpan, true ); return tcs.Task; } public Task DownloadAsync(object downloader, ScriptBlock progress, CancellationToken cancelToken) { BuildOnCompleted onCompleted = action => new DownloadCompletedCallback(action); return InvokeAsync(downloader, "Download", onCompleted, new DownloadProgressChangedCallback(progress, this), cancelToken); } public Task InstallAsync(object installer, ScriptBlock progress, CancellationToken cancelToken) { BuildOnCompleted onCompleted = action => new InstallationCompletedCallback(action); return InvokeAsync(installer, "Install", onCompleted, new InstallationProgressChangedCallback(progress, this), cancelToken); } public Task SearchAsync(object searcher, string criteria, CancellationToken cancelToken) { BuildOnCompleted onCompleted = action => new SearchCompletedCallback(action); return InvokeAsync(searcher, "Search", onCompleted, criteria, cancelToken); } public void InvokePowerShell(ScriptBlock scriptblock, object job, object callbackArgs, string id) { try { using (Runspace rs = RunspaceFactory.CreateRunspace()) using (PowerShell pipeline = PowerShell.Create()) { rs.Open(); pipeline.Runspace = rs; pipeline.AddScript(scriptblock.ToString()); pipeline.AddParameter("Api", this); pipeline.AddParameter("Job", job); pipeline.AddParameter("CallbackArgs", callbackArgs); pipeline.Invoke(); } } catch (Exception e) { WriteLog(String.Format("{0} failed to invoke powershell script: {1}", id, e.Message)); throw; } } public void WriteProgress(string task, IDictionary result) { Dictionary progress = new Dictionary() { { "task", task }, { "result", result }, }; using (Runspace rs = RunspaceFactory.CreateRunspace()) using (PowerShell pipeline = PowerShell.Create()) { rs.Open(); pipeline.Runspace = rs; pipeline.AddCommand("ConvertTo-Json"); pipeline.AddParameter("InputObject", progress); pipeline.AddParameter("Compress", true); pipeline.AddParameter("Depth", 5); string msg = pipeline.Invoke()[0]; AppendFile(msg, OutputPath, OutputMutex); } } public void WriteLog(string msg) { string dateStr = DateTime.Now.ToString("u"); string logMsg = String.Format("{0} {1}", dateStr, msg); if (!String.IsNullOrWhiteSpace(LogPath) && !CheckMode) AppendFile(logMsg, LogPath, LogMutex); if (LocalDebugging) { LogMutex.WaitOne(); try { Console.WriteLine(logMsg); } finally { LogMutex.ReleaseMutex(); } } } private void AppendFile(string msg, string path, Mutex mut) { mut.WaitOne(); try { using (FileStream fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read)) using (StreamWriter sw = new StreamWriter(fs)) sw.WriteLine(msg); } finally { mut.ReleaseMutex(); } } private delegate object BuildOnCompleted(Action action); private TaskInvokeAsync(object com, string method, BuildOnCompleted buildOnCompleted, object onProgress, CancellationToken cancelToken) where T : class { TaskCompletionSource task = new TaskCompletionSource(); object job = null; CancellationTokenRegistration? reg = null; object onCompleted = buildOnCompleted((_job, callbackArgs) => { try { T res = com.GetType().InvokeMember( String.Format("End{0}", method), BindingFlags.InvokeMethod, null, com, new object[] { _job } ) as T; task.TrySetResult(res); } catch (TargetInvocationException e) { Exception exp = e; if (e.InnerException is COMException) exp = e.InnerException; task.TrySetException(exp); WriteLog(String.Format("{0} on completed callback failed: {1}", method, exp.Message)); } finally { job = null; if (reg != null) ((CancellationTokenRegistration)reg).Dispose(); } }); job = com.GetType().InvokeMember( String.Format("Begin{0}", method), BindingFlags.InvokeMethod, null, com, new object[] { onProgress, onCompleted, method } ); reg = cancelToken.Register(() => { //task.TrySetCanceled(cancelToken); if (job != null) { job.GetType().InvokeMember( "RequestAbort", BindingFlags.InvokeMethod, null, job, new object[] {} ); } }); return task.Task; } } public class DownloadCompletedCallback : IDownloadCompletedCallback { private Action Action; public DownloadCompletedCallback(Action action) { this.Action = action; } public void Invoke(IDownloadJob job, IDownloadCompletedCallbackArgs callbackArgs) { Action.Invoke(job, callbackArgs); } } public class DownloadProgressChangedCallback : IDownloadProgressChangedCallback { private ScriptBlock Action; private API Api; public DownloadProgressChangedCallback(ScriptBlock action, API api) { this.Action = action; this.Api = api; } public void Invoke(IDownloadJob job, IDownloadProgressChangedCallbackArgs callbackArgs) { Api.InvokePowerShell(Action, job, callbackArgs, "Download"); } } public class InstallationCompletedCallback : IInstallationCompletedCallback { private Action Action; public InstallationCompletedCallback(Action action) { this.Action = action; } public void Invoke(IInstallationJob job, IInstallationCompletedCallbackArgs callbackArgs) { Action.Invoke(job, callbackArgs); } } public class InstallationProgressChangedCallback : IInstallationProgressChangedCallback { private ScriptBlock Action; private API Api; public InstallationProgressChangedCallback(ScriptBlock action, API api) { this.Action = action; this.Api = api; } public void Invoke(IInstallationJob job, IInstallationProgressChangedCallbackArgs callbackArgs) { Api.InvokePowerShell(Action, job, callbackArgs, "Install"); } } public class SearchCompletedCallback : ISearchCompletedCallback { private Action Action; public SearchCompletedCallback(Action action) { this.Action = action; } public void Invoke(ISearchJob job, ISearchCompletedCallbackArgs callbackArgs) { Action.Invoke(job, callbackArgs); } } } '@ Function Invoke-AsyncMethod { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSPossibleIncorrectUsageOfAssignmentOperator", "", Justification = "False positive, the syntax is valid and works")] [CmdletBinding()] param ( [Parameter(Mandatory, Position = 0)] [String] $Action, [Parameter(Mandatory, Position = 1)] [ScriptBlock] $ScriptBlock ) try { $cancelToken = New-Object -TypeName System.Threading.CancellationTokenSource $task = &$ScriptBlock $cancelToken.Token while ($true) { # Tells the host not to go to sleep every minute, this # passes in the flags ES_CONTINUOUS | ES_SYSTEM_REQUIRED. # https://github.com/ansible-collections/ansible.windows/issues/310 [void][Ansible.Windows.WinUpdates.NativeMethods]::SetThreadExecutionState([UInt32]"0x80000001") $waitIdx = [System.Threading.Tasks.Task]::WaitAny(@( $cancelTask, $task ), 60000) if ($waitIdx -ge 0) { break } } if ($waitIdx -eq 0) { if (-not $task.IsCompleted) { # Sends the COM RequestAbort signal to the job $cancelToken.Cancel() } } [void]$task.Wait() $task.Result } catch { $exitResult.action = $Action # The COMException could be deeply nested, try and throw that if it exists $exp = $_.Exception do { if ($exp -is [System.Runtime.InteropServices.COMException]) { throw $exp } } while ($exp = $exp.InnerException) throw # Otherwise throw the original } } Function Receive-CallbackProgress { [CmdletBinding()] param ($Api, $Job, $CallbackArgs) # This runs in a brand new Runspace and doesn't have access to any of vars in our normal process. try { $taskType = $Job.AsyncState.ToLower() $progress = $CallbackArgs.Progress $updateIdx = $progress.CurrentUpdateIndex $updateId = $Api.IndexMap[$updateIdx] $progressObj = @{CurrentUpdateId = $updateId } foreach ($prop in $progress.PSObject.Properties) { $progressObj[$prop.Name] = $prop.Value } $res = $CallbackArgs.Progress.GetUpdateResult($updateIdx) $resObj = @{} foreach ($prop in $res.PSObject.Properties) { $resObj[$prop.Name] = $prop.Value } $finalRes = @{ task = $taskType result = @{ progress = $progressObj result = $resObj } } $Api.WriteLog("Received $taskType progress update:`r`n$($finalRes | ConvertTo-Json)") $Api.WriteProgress($taskType, $finalRes.result) } catch { $Api.WriteLog("Progress $taskType callback failed: $($_ | Out-String)`r`n$($_.ScriptStackTrace)") throw } } Function Test-InList { [CmdletBinding()] param ( [Parameter(Mandatory)] [String[]] $InputObject, [Parameter(Mandatory)] [AllowEmptyCollection()] [String[]] $Match ) if ($Match.Count -eq 0) { return $true } $isMatch = $false :outer foreach ($entry in $InputObject) { foreach ($matchEntry in $Match) { if ($entry -imatch $matchEntry) { $isMatch = $true break :outer } } } $isMatch } Function Format-UpdateInfo { [CmdletBinding()] param ( [Parameter(Mandatory)] [object] $Update ) # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-uamg/9ed3c4f7-30fc-4b7b-97e1-308b5159822c $impact = switch ($Update.InstallationBehavior.Impact) { 0 { 'Normal' } 1 { 'Minor' } 2 { 'RequiresExclusiveHandling' } default { "Unknown $($Update.InstallationBehavior.Impact)" } } # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-uamg/eee24bbd-0be7-4a81-bed5-bff1fbb1832b $rebootBehavior = switch ($Update.InstallationBehavior.RebootBehavior) { 0 { 'NeverReboots' } 1 { 'AlwaysRequiresReboot' } 2 { 'CanRequestReboot' } default { "Unknown $($Update.InstallationBehavior.RebootBehavior)" } } # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-uamg/a4ec1231-6523-4196-8497-7b63ecc35b61 $updateType = switch ($Update.Type) { 1 { 'Software' } 2 { 'Driver' } default { "Unknown $($Update.Type)" } } # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-uamg/d4dc5648-a8a9-436d-9fdd-c90730bf64b0 $deploymentAction = switch ($Update.DeploymentAction) { 0 { 'None' } 1 { 'Installation' } 2 { 'Uninstallation' } 3 { 'Detection' } default { "Unknown $($Update.DeploymentAction)" } } # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-uamg/012a7226-c23d-4905-b630-9a6506032aa9 $autoSelection = switch ($Update.AutoSelection) { 0 { 'LetWindowsUpdateDecide' } 1 { 'AutoSelectIfDownloaded' } 2 { 'NeverAutoSelect' } 3 { 'AlwaysAutoSelect' } default { "Unknown $($Update.AutoSelection)" } } # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-uamg/02e59b57-4d4e-4060-ab69-8207a10271aa $autoDownload = switch ($Update.AutoDownload) { 0 { 'LetWindowsUpdateDecide' } 1 { 'NeverAutoDownload' } 2 { 'AlwaysAutoDownload' } default { "Unknown $($Update.AutoDownload)" } } [Ordered]@{ # User friendly info / Identifiers id = $Update.Identity.UpdateID title = $Update.Title description = $Update.Description kb = @($Update.KBArticleIDs | ForEach-Object { "KB$_" }) # Search filter critera type = $updateType deployment_action = $deploymentAction auto_select_on_websites = $Update.AutoSelectOnWebSites browse_only = $Update.BrowseOnly revision_number = $Update.Identity.RevisionNumber categories = @($Update.Categories | ForEach-Object { $_.Name }) is_installed = $Update.IsInstalled is_hidden = $Update.IsHidden is_present = $Update.IsPresent reboot_required = $Update.RebootRequired # Extra info impact = $impact reboot_behaviour = $rebootBehavior is_beta = $Update.IsBeta is_downloaded = $Update.IsDownloaded is_mandatory = $Update.IsMandatory is_uninstallable = $Update.IsUninstallable auto_selection = $autoSelection auto_download = $autoDownload } } # Make sure the output file exists before running [IO.File]::Create($OutputPath).Dispose() $cancelEvent = New-Object -TypeName System.Threading.EventWaitHandle -ArgumentList @( $false, [System.Threading.EventResetMode]::ManualReset, $CancelId ) [void]$cancelEvent.Reset() $cancelTask = [Ansible.Windows.WinUpdates.API]::WaitHandleToTask($cancelEvent) $api = New-Object -TypeName Ansible.Windows.WinUpdates.API -ArgumentList $OutputPath, $LogPath, $CheckMode, $LocalDebugger # Make sure each exception is captured and logged to the file trap { if (-not $exitResult) { $exitResult = @{} } $exitResult.failed = $true $exitResult.exception = @{ message = $_.ToString() exception = ($_ | Out-String) + "`r`n`r`n$($_.ScriptStackTrace)" } if ($exitResult.action) { $exitResult.exception.message = $exitResult.action + ": " + $exitResult.exception.message $exitResult.Remove('action') } if ($_.Exception -is [Runtime.InteropServices.COMException]) { # COMExceptions don't contain any info in the error message, we make sure we return the HResult for the # action plugin to properly decode $exitResult.exception.hresult = $_.Exception.HResult } if ($api) { $api.WriteProgress('exit', $exitResult) $api.WriteLog("Exception encountered:`r`n$(ConvertTo-Json $exitResult)`r`nExiting...") } else { # May happen if a failure occurs before $api is defined, probably due to edits during development $exit = @{ task = 'exit' result = $exitResult } Set-Content -LiteralPath $OutputPath -Value (ConvertTo-Json $exit -Compress) } } $rebootRequired = (New-Object -ComObject Microsoft.Update.SystemInfo).RebootRequired $exitResult.reboot_required = $rebootRequired $api.WriteLog("Reboot requirement check: $rebootRequired") $api.WriteLog("Creating Windows Update session...") $session = New-Object -ComObject Microsoft.Update.Session $api.WriteLog("Create Windows Update searcher...") $searcher = $session.CreateUpdateSearcher() $api.WriteLog("Setting the Windows Update Agent source catalog...") $serverSelectionValue = switch ($ServerSelection) { "default" { 0 } "managed_server" { 1 } "windows_update" { 2 } } $searcher.ServerSelection = $serverSelectionValue $api.WriteLog("Search source set to '$($ServerSelection)' (ServerSelection = $($serverSelectionValue))") $query = 'IsInstalled = 0' $api.WriteLog("Searching for updates to install with query '$query'") $searchResult = Invoke-AsyncMethod 'Searching for updates' { $api.SearchAsync($searcher, $query, $args[0]) } $resCode = [Ansible.Windows.WinUpdates.OperationResultCode]$searchResult.ResultCode if ($resCode -ne 'Succeeded') { # Probably due to a cancelation request throw "Failed to search for updates ($resCode $([int]$resCode))" } $api.WriteLog("Found $($searchResult.Updates.Count) updates") $api.WriteLog("Filtering found updated based on input search criteria") $updateCollection = New-Object -ComObject Microsoft.Update.UpdateColl $allUpdates = [System.Collections.Generic.List[Hashtable]]@() $filteredUpdates = [System.Collections.Generic.List[Hashtable]]@() foreach ($update in $searchResult.Updates) { $updateInfo = Format-UpdateInfo -Update $update $api.WriteLog("Process filtering rules for`r`n$(ConvertTo-Json $updateInfo)") $allUpdates.Add($updateInfo) $categoryMatch = $Category.Length -eq 0 foreach ($matchCat in $Category) { if ($matchCat -eq '*' -or $updateInfo.categories -ieq $matchCat) { $categoryMatch = $true break } } $matchList = ($updateInfo.title + $updateInfo.kb) $filteredReasons = [System.Collections.Generic.List[String]]@() if (-not (Test-InList -InputObject $matchList -Match $Accept)) { $filteredReasons.Add('accept_list') } if ($Reject.Count -gt 0 -and (Test-InList -InputObject $matchList -Match $Reject)) { $filteredReasons.Add('reject_list') } if ($updateInfo.is_hidden) { $filteredReasons.Add('hidden') } if (-not $categoryMatch) { $filteredReasons.Add('category_names') } if ($SkipOptional) { If ($updateInfo.browse_only) { $filteredReasons.Add('skip_optional') } } $updateId = "$($updateInfo.id) - $($updateInfo.title)" if ($filteredReasons) { $api.WriteLog("Skipping update $updateId due to $($filteredReasons -join ", ")") $filteredUpdates.Add(@{id = $updateInfo.id; reasons = $filteredReasons }) } else { if (-not $update.EulaAccepted) { $api.WriteLog("Accepting EULA for $updateId") $update.AcceptEula() } $api.WriteLog("Adding update $updateId") $updateCollection.Add($update) > $null } } # Allows the action plugin to map update ids to human readable update info $api.WriteProgress('search_result', @{ updates = $allUpdates filtered = $filteredUpdates }) $exit = $false if ($CheckMode) { $api.WriteLog("Check mode: exiting...") $exit = $true } elseif ($State -eq 'searched') { $api.WriteLog("Search mode: exiting...") $exit = $true } elseif ($updateCollection.Count -eq 0) { $api.WriteLog("No updated pending: exiting...") $exit = $true } if ($exit) { $exitResult.changed = $updateCollection.Count -gt 0 -and $State -ne 'searched' $api.WriteProgress('exit', $exitResult) return } if ($rebootRequired) { throw "A reboot is required before more updates can be installed" } $downloadCollection = New-Object -ComObject Microsoft.Update.UpdateColl $api.IndexMap.Clear() foreach ($update in $updateCollection) { if ($update.IsDownloaded) { $api.WriteLog("Update $($update.Identity.UpdateId) already downloaded, skipping...") continue } $api.WriteLog("Update $($update.Identity.UpdateId) not downloaded") $api.IndexMap[$downloadCollection.Count] = $update.Identity.UpdateId $downloadCollection.Add($update) > $null } if ($downloadCollection.Count -gt 0) { $api.WriteLog("Downloading updates...") $dl = $session.CreateUpdateDownloader() $dl.Updates = $downloadCollection $downloadResult = Invoke-AsyncMethod 'Downloading updates' { $api.DownloadAsync($dl, ${function:Receive-CallbackProgress}, $args[0]) } $exitResult.changed = $true # FUTURE: configurable download retry $failed = $false $progressResult = [System.Collections.Generic.List[Object]]@() for ($i = 0; $i -lt $downloadCollection.Count; $i++) { $update = $downloadCollection.Item($i) $res = $downloadResult.GetUpdateResult($i) $updateId = $update.Identity.UpdateId $resultCode = [Ansible.Windows.WinUpdates.OperationResultCode]$res.ResultCode $hresult = $res.HResult $api.WriteLog("Download result for $updateId - ResultCode: $resultCode, HResult: $hresult") $progressResult.Add(@{ id = $updateId result_code = [int]$resultCode hresult = $hresult }) if ($resultCode -ne 'Succeeded') { $failed = $true } } $api.WriteProgress('download_result', @{ info = $progressResult }) if ($failed) { # More details are in the downloaded list throw "Failed to download all updates - see updates for more information" } } else { $api.WriteLog("All updates selected have been downloaded...") } if ($State -eq 'downloaded') { $api.WriteLog("Download mode: exiting...") $api.WriteProgress('exit', $exitResult) return } $api.WriteLog("Installing updates...") $installer = $session.CreateUpdateInstaller() $installer.AllowSourcePrompts = $false $installer.ClientApplicationID = "ansible.windows.win_updates" $installer.Updates = $updateCollection $api.IndexMap.Clear() for ($i = 0; $i -lt $installer.Updates.Count; $i++) { $api.IndexMap[$i] = $installer.Updates.Item($i).Identity.UpdateId } $installResult = Invoke-AsyncMethod 'Installing updates' { $api.InstallAsync($installer, ${function:Receive-CallbackProgress}, $args[0]) } $exitResult.changed = $true $failed = $false $progressResult = [System.Collections.Generic.List[Object]]@() for ($i = 0; $i -lt $updateCollection.Count; $i++) { $update = $updateCollection.Item($i) $res = $installResult.GetUpdateResult($i) $updateId = $update.Identity.UpdateId $resultCode = [Ansible.Windows.WinUpdates.OperationResultCode]$res.ResultCode $hresult = $res.HResult $rebootRequired = $res.RebootRequired $api.WriteLog("Install result for $updateId - ResultCode: $resultCode, HResult: $hresult, RebootRequired: $rebootRequired") $progressResult.Add(@{ id = $updateId result_code = [int]$resultCode hresult = $hresult reboot_required = $rebootRequired }) if ($resultCode -ne 'Succeeded') { $failed = $true } if ($rebootRequired) { $exitResult.reboot_required = $true } } $api.WriteProgress('install_result', @{ info = $progressResult }) if ($failed) { # More details are in the installed list throw "Failed to install all updates - see updates for more information" } $exitResult.reboot_required = (New-Object -ComObject Microsoft.Update.SystemInfo).RebootRequired $api.WriteLog("Post-install reboot requirement $($exitResult.reboot_required)") $api.WriteProgress('exit', $exitResult) } <# Most of the Windows Update Agent API will not run under a remote token which is typically what a WinRM process is. We can use a scheduled task to change the logon to a batch/service logon allowing us to bypass that restriction. The other benefit of a scheduled task is that it is not tied to the lifetime of the WinRM process. This allows it to outlive any network drops. In the case of async we need to tie the lifetime of this process to the scheduled task, in other situations we poll the status in a separate process. #> $outputPathDir = $module.Params._output_path if (-not $outputPathDir) { # Running async means this won't be set, just use the module tmpdir. $outputPathDir = $module.Tmpdir } # The scheduled task might need to fallback to run as SYSTEM so grant that SID rights to OutputDir $systemSid = (New-Object -TypeName Security.Principal.SecurityIdentifier -ArgumentList @( [Security.Principal.WellKnownSidType ]::LocalSystemSid, $null)) $outputDirAcl = Get-Acl -LiteralPath $outputPathDir $systemAce = $outputDirAcl.AccessRuleFactory( $systemSid, [System.Security.AccessControl.FileSystemRights]'Modify,Read,ExecuteFile,Synchronize', $false, [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit', [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow ) $outputDirAcl.AddAccessRule($systemAce) Set-Acl -LiteralPath $outputPathDir -AclObject $outputDirAcl $outputPath = [IO.Path]::GetFullpath((Join-Path $outputPathDir 'output.txt')) $cancelId = "Global\Ansible.Windows.WinUpdates-$([Guid]::NewGuid().Guid)" $invokeSplat = @{ Module = $module Commands = @{ 'Add-CSharpType' = ${function:Add-CSharpType} } ScriptBlock = ${function:Install-WindowsUpdate} Parameters = @{ Category = $categoryNames Accept = @(if ($module.Params.accept_list) { $module.Params.accept_list }) Reject = @(if ($module.Params.reject_list) { $module.Params.reject_list }) ServerSelection = $module.Params.server_selection State = $module.Params.state SkipOptional = $module.Params.skip_optional CancelId = $cancelId OutputPath = $outputPath LogPath = $module.Params.log_path CheckMode = $module.CheckMode } Wait = $module.Params._wait } # In case of a reboot the tmpdir will be shared and we need to start from scratch again. Remove-Item -LiteralPath $outputPath -ErrorAction SilentlyContinue -Force $eventId = 'Ansible.Windows.WinUpdatesWatcher' $fsWatcher = [System.IO.FileSystemWatcher]@{ Path = Split-Path -Path $outputPath -Parent Filter = Split-Path -Path $outputPath -Leaf } try { Register-ObjectEvent -InputObject $fsWatcher -EventName Created -SourceIdentifier $eventId # If debugging locally change this to $true if ($false) { $invokeSplat.Wait = $true $params = $invokeSplat.Parameters $null = Install-WindowsUpdate @params -LocalDebugger } else { $taskPid = Invoke-AsBatchLogon @invokeSplat } # Make sure the output file exists before continuing (task has started) $null = Wait-Event -SourceIdentifier $eventId } finally { $fsWatcher.EnableRaisingEvents = $false $fsWatcher.Dispose() Remove-Event -SourceIdentifier $eventId -ErrorAction SilentlyContinue Unregister-Event -SourceIdentifier $eventId -ErrorAction SilentlyContinue } if ($invokeSplat.Wait) { # Format the output for legacy async behaviour $module.Result.reboot_required = $false $module.Result.changed = $false $module.Result.found_update_count = 0 $module.Result.failed_update_count = 0 $module.Result.installed_update_count = 0 $module.Result.updates = @{} $module.Result.filtered_updates = @{} $updates = @{} Get-Content -LiteralPath $outputPath | ForEach-Object -Process { $progress = ConvertFrom-Json -InputObject $_ $task = $progress.task $result = $progress.result if ($task -eq 'search_result') { $filterMap = @{} foreach ($filteredUpdate in $result.filtered) { $filterMap[$filteredUpdate.id] = $filteredUpdate.reasons } foreach ($updateInfo in $result.updates) { $resultInfo = @{ categories = @($updateInfo.categories) id = $updateInfo.id installed = $false downloaded = $false kb = @($updateInfo.kb | ForEach-Object { if ($_.StartsWith("KB")) { $_.Substring(2) } else { $_ } }) title = $updateInfo.title } if ($updateInfo.id -in $filterMap.Keys) { $reasons = @($filterMap[$updateInfo.id]) # This value is deprecated in favour of the full list and should be removed in 2023-06-01. We also # need to rename the whitelist/blacklist reasons for backwards compatibility. $depReason = $reasons[0] if ($depReason -eq 'accept_list') { $depReason = 'whitelist' } if ($depReason -eq 'reject_list') { $depReason = 'blacklist' } $resultInfo.filtered_reasons = $reasons $resultInfo.filtered_reason = $depReason } $updates[$updateInfo.id] = $resultInfo } } elseif ($task -in @('download_result', 'install_result')) { foreach ($resultInfo in $result.info) { $updateInfo = $updates[$resultInfo.id] if ($resultInfo.result_code -ne 2) { $updateInfo.failure_hresult_code = $resultInfo.hresult } else { $taskType = if ($task -eq 'download_result') { 'downloaded' } else { 'installed' } $updateInfo[$taskType] = $true } } } elseif ($task -eq 'exit') { $module.Result.changed = $result.changed $module.Result.reboot_required = $result.reboot_required $module.Result.failed = $result.failed if ($result.exception) { $module.Result.msg = $result.exception.message $module.Result.exception = $result.exception.exception if ($result.exception.hresult) { $module.Result.hresult = $result.exception.hresult } } } } foreach ($updateKvp in $updates.GetEnumerator()) { $id = $updateKvp.Key $info = $updateKvp.Value if ($info.Contains('filtered_reasons')) { $module.Result.filtered_updates[$info.id] = $info continue } $module.Result.found_update_count += 1 if ($info.Contains('failure_hresult_code')) { $module.Result.failed_update_count += 1 } elseif ($info.installed) { $module.Result.installed_update_count += 1 } $module.Result.updates[$info.id] = $info } } else { $module.Result.output_path = $outputPath $module.Result.task_pid = $taskPid $module.Result.cancel_id = $cancelId } $module.ExitJson()