From 457bccf540bb8a1ed29eee769b85f825904c9527 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Thu, 24 May 2018 06:21:01 +1000 Subject: [PATCH] win_updates: add scheduled tasks back in for older hosts (#38708) * win_updates: add scheduled tasks back in for older hosts * Fixed up typo in category name error message * Fixed up some minor issues after merge * added changelog fragment * Default to become but add override to use scheduled tasks * Added basic unit tests for win_updates * fix minor typos --- .../win_updates-add-scheduled-tasks-back.yaml | 2 + lib/ansible/modules/windows/win_updates.ps1 | 700 ++++++++++++------ lib/ansible/modules/windows/win_updates.py | 48 +- lib/ansible/plugins/action/win_updates.py | 115 +-- .../targets/win_updates/tasks/tests.yml | 3 +- test/units/plugins/action/test_win_updates.py | 120 +++ 6 files changed, 674 insertions(+), 314 deletions(-) create mode 100644 changelogs/fragments/win_updates-add-scheduled-tasks-back.yaml create mode 100644 test/units/plugins/action/test_win_updates.py diff --git a/changelogs/fragments/win_updates-add-scheduled-tasks-back.yaml b/changelogs/fragments/win_updates-add-scheduled-tasks-back.yaml new file mode 100644 index 00000000000..878486619e7 --- /dev/null +++ b/changelogs/fragments/win_updates-add-scheduled-tasks-back.yaml @@ -0,0 +1,2 @@ +bugfixes: +- win_updates - Added the ability to run on a scheduled task for older hosts so async starts working again - https://github.com/ansible/ansible/issues/38364 diff --git a/lib/ansible/modules/windows/win_updates.ps1 b/lib/ansible/modules/windows/win_updates.ps1 index d9f310253e2..374b82ef8c8 100644 --- a/lib/ansible/modules/windows/win_updates.ps1 +++ b/lib/ansible/modules/windows/win_updates.ps1 @@ -7,13 +7,6 @@ #Requires -Module Ansible.ModuleUtils.Legacy -<# Most of the Windows Update API will not run under a remote token, which a -remote WinRM session always has. We set the below AnsibleRequires flag to -require become being used when executing the module to bypass this restriction. -This means we don't have to mess around with scheduled tasks. #> - -#AnsibleRequires -Become - $ErrorActionPreference = "Stop" $params = Parse-Args -arguments $args -supports_check_mode $true @@ -25,22 +18,6 @@ $state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "insta $blacklist = Get-AnsibleParam -obj $params -name "blacklist" -type "list" $whitelist = Get-AnsibleParam -obj $params -name "whitelist" -type "list" -$result = @{ - changed = $false - updates = @{} - filtered_updates = @{} -} - -Function Write-DebugLog($msg) { - $date_str = Get-Date -Format u - $msg = "$date_str $msg" - - Write-Debug -Message $msg - if ($log_path -ne $null -and (-not $check_mode)) { - Add-Content -Path $log_path -Value $msg - } -} - Function Get-CategoryGuid($category_name) { $guid = switch -exact ($category_name) { "Application" {"5C9376AB-8CE6-464A-B136-22113DD69801"} @@ -55,270 +32,523 @@ Function Get-CategoryGuid($category_name) { "Tools" {"B4832BD8-E735-4761-8DAF-37F882276DAB"} "UpdateRollups" {"28BC880E-0592-4CBF-8F95-C79B17911D5F"} "Updates" {"CD5FFD1E-E932-4E3A-BF74-18BF0B1BBD83"} - default { Fail-Json -obj $result -message "Unknown category_name $category_name, must be one of (Application,Connectors,CriticalUpdates,DefinitionUpdates,DeveloperKits,FeaturePacks,Guidance,SecurityUpdates,ServicePacks,Tools,UpdateRollups,Updates)" } + default { Fail-Json -message "Unknown category_name $category_name, must be one of (Application,Connectors,CriticalUpdates,DefinitionUpdates,DeveloperKits,FeaturePacks,Guidance,SecurityUpdates,ServicePacks,Tools,UpdateRollups,Updates)" } } return $guid } - -Function Get-RebootStatus() { - try { - $system_info = New-Object -ComObject Microsoft.Update.SystemInfo - } catch { - Fail-Json -obj $result -message "Failed to create Microsoft.Update.SystemInfo COM object for reboot status: $($_.Exception.Message)" - } - - return $system_info.RebootRequired -} - $category_guids = $category_names | ForEach-Object { Get-CategoryGuid -category_name $_ } -Write-DebugLog -msg "Creating Windows Update session..." -try { - $session = New-Object -ComObject Microsoft.Update.Session -} catch { - Fail-Json -obj $result -message "Failed to create Microsoft.Update.Session COM object: $($_.Exception.Message)" -} - -Write-DebugLog -msg "Create Windows Update searcher..." -try { - $searcher = $session.CreateUpdateSearcher() -} catch { - Fail-Json -obj $result -message "Failed to create Windows Update search from session: $($_.Exception.Message)" -} - -# OR is only allowed at the top-level, so we have to repeat base criteria inside -# FUTURE: change this to client-side filtered? -$criteria_base = "IsInstalled = 0" -$criteria_list = $category_guids | ForEach-Object { "($criteria_base AND CategoryIds contains '$_') " } -$criteria = [string]::Join(" OR", $criteria_list) -Write-DebugLog -msg "Search criteria: $criteria" - -Write-DebugLog -msg "Searching for updates to install in category Ids $category_guids..." -try { - $search_result = $searcher.Search($criteria) -} catch { - Fail-Json -obj $result -message "Failed to search for updates with criteria '$criteria': $($_.Exception.Message)" -} -Write-DebugLog -msg "Found $($search_result.Updates.Count) updates" - -Write-DebugLog -msg "Creating update collection..." -try { - $updates_to_install = New-Object -ComObject Microsoft.Update.UpdateColl -} catch { - Fail-Json -obj $result -message "Failed to create update collection object: $($_.Exception.Message)" +$common_functions = { + Function Write-DebugLog($msg) { + $date_str = Get-Date -Format u + $msg = "$date_str $msg" + + Write-Debug -Message $msg + if ($log_path -ne $null -and (-not $check_mode)) { + Add-Content -Path $log_path -Value $msg + } + } } -foreach ($update in $search_result.Updates) { - $update_info = @{ - title = $update.Title - # TODO: pluck the first KB out (since most have just one)? - kb = $update.KBArticleIDs - id = $update.Identity.UpdateId - installed = $false - } +$update_script_block = { + Param( + [hashtable]$arguments + ) + + $ErrorActionPreference = "Stop" + $DebugPreference = "Continue" + + Function Start-Updates { + Param( + $category_guids, + $log_path, + $state, + $blacklist, + $whitelist + ) + + $result = @{ + changed = $false + updates = @{} + filtered_updates = @{} + } + + Write-DebugLog -msg "Creating Windows Update session..." + try { + $session = New-Object -ComObject Microsoft.Update.Session + } catch { + $result.failed = $true + $result.msg = "Failed to create Microsoft.Update.Session COM object: $($_.Exception.Message)" + return $result + } + + Write-DebugLog -msg "Create Windows Update searcher..." + try { + $searcher = $session.CreateUpdateSearcher() + } catch { + $result.failed = $true + $result.msg = "Failed to create Windows Update search from session: $($_.Exception.Message)" + return $result + } + + # OR is only allowed at the top-level, so we have to repeat base criteria inside + # FUTURE: change this to client-side filtered? + $criteria_base = "IsInstalled = 0" + $criteria_list = $category_guids | ForEach-Object { "($criteria_base AND CategoryIds contains '$_') " } + $criteria = [string]::Join(" OR", $criteria_list) + Write-DebugLog -msg "Search criteria: $criteria" + + Write-DebugLog -msg "Searching for updates to install in category Ids $category_guids..." + try { + $search_result = $searcher.Search($criteria) + } catch { + $result.failed = $true + $result.msg = "Failed to search for updates with criteria '$criteria': $($_.Exception.Message)" + return $result + } + Write-DebugLog -msg "Found $($search_result.Updates.Count) updates" + + Write-DebugLog -msg "Creating update collection..." + try { + $updates_to_install = New-Object -ComObject Microsoft.Update.UpdateColl + } catch { + $result.failed = $true + $result.msg = "Failed to create update collection object: $($_.Exception.Message)" + return $result + } + + foreach ($update in $search_result.Updates) { + $update_info = @{ + title = $update.Title + # TODO: pluck the first KB out (since most have just one)? + kb = $update.KBArticleIDs + id = $update.Identity.UpdateId + installed = $false + } + + # validate update again blacklist/whitelist + $skipped = $false + $whitelist_match = $false + foreach ($whitelist_entry in $whitelist) { + if ($update_info.title -imatch $whitelist_entry) { + $whitelist_match = $true + break + } + foreach ($kb in $update_info.kb) { + if ("KB$kb" -imatch $whitelist_entry) { + $whitelist_match = $true + break + } + } + } + if ($whitelist.Length -gt 0 -and -not $whitelist_match) { + Write-DebugLog -msg "Skipping update $($update_info.id) - $($update_info.title) as it was not found in the whitelist" + $skipped = $true + } - # validate update again blacklist/whitelist - $skipped = $false - $whitelist_match = $false - foreach ($whitelist_entry in $whitelist) { - if ($update_info.title -imatch $whitelist_entry) { - $whitelist_match = $true - break + foreach ($kb in $update_info.kb) { + if ("KB$kb" -imatch $blacklist_entry) { + $kb_match = $true + } + foreach ($blacklist_entry in $blacklist) { + $kb_match = $false + foreach ($kb in $update_info.kb) { + if ("KB$kb" -imatch $blacklist_entry) { + $kb_match = $true + } + } + if ($kb_match -or $update_info.title -imatch $blacklist_entry) { + Write-DebugLog -msg "Skipping update $($update_info.id) - $($update_info.title) as it was found in the blacklist" + $skipped = $true + break + } + } + } + if ($skipped) { + $result.filtered_updates[$update_info.id] = $update_info + continue + } + + + if (-not $update.EulaAccepted) { + Write-DebugLog -msg "Accepting EULA for $($update_info.id)" + try { + $update.AcceptEula() + } catch { + $result.failed = $true + $result.msg = "Failed to accept EULA for update $($update_info.id) - $($update_info.title)" + return $result + } + } + + if ($update.IsHidden) { + Write-DebugLog -msg "Skipping hidden update $($update_info.title)" + continue + } + + Write-DebugLog -msg "Adding update $($update_info.id) - $($update_info.title)" + $updates_to_install.Add($update) > $null + + $result.updates[$update_info.id] = $update_info } - foreach ($kb in $update_info.kb) { - if ("KB$kb" -imatch $whitelist_entry) { - $whitelist_match = $true - break + + Write-DebugLog -msg "Calculating pre-install reboot requirement..." + + # calculate this early for check mode, and to see if we should allow updates to continue + $result.reboot_required = (New-Object -ComObject Microsoft.Update.SystemInfo).RebootRequired + $result.found_update_count = $updates_to_install.Count + $result.installed_update_count = 0 + + # Early exit of check mode/state=searched as it cannot do more after this + if ($check_mode -or $state -eq "searched") { + Write-DebugLog -msg "Check mode: exiting..." + Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)" + + if ($updates_to_install.Count -gt 0 -and ($state -ne "searched")) { + $result.changed = $true } + return $result } - } - if ($whitelist.Length -gt 0 -and -not $whitelist_match) { - Write-DebugLog -msg "Skipping update $($update_info.id) - $($update_info.title) as it was not found in the whitelist" - $skipped = $true - } - - foreach ($blacklist_entry in $blacklist) { - $kb_match = $false - foreach ($kb in $update_info.kb) { - if ("KB$kb" -imatch $blacklist_entry) { - $kb_match = $true + + if ($updates_to_install.Count -gt 0) { + if ($result.reboot_required) { + Write-DebugLog -msg "FATAL: A reboot is required before more updates can be installed" + $result.failed = $true + $result.msg = "A reboot is required before more updates can be installed" + return $result + } + Write-DebugLog -msg "No reboot is pending..." + } else { + # no updates to install exit here + return $result + } + + Write-DebugLog -msg "Downloading updates..." + $update_index = 1 + foreach ($update in $updates_to_install) { + $update_number = "($update_index of $($updates_to_install.Count))" + if ($update.IsDownloaded) { + Write-DebugLog -msg "Update $update_number $($update.Identity.UpdateId) already downloaded, skipping..." + $update_index++ + continue + } + + Write-DebugLog -msg "Creating downloader object..." + try { + $dl = $session.CreateUpdateDownloader() + } catch { + $result.failed = $true + $result.msg = "Failed to create downloader object: $($_.Exception.Message)" + return $result + } + + Write-DebugLog -msg "Creating download collection..." + try { + $dl.Updates = New-Object -ComObject Microsoft.Update.UpdateColl + } catch { + $result.failed = $true + $result.msg = "Failed to create download collection object: $($_.Exception.Message)" + return $result + } + + Write-DebugLog -msg "Adding update $update_number $($update.Identity.UpdateId)" + $dl.Updates.Add($update) > $null + + Write-DebugLog -msg "Downloading $update_number $($update.Identity.UpdateId)" + try { + $download_result = $dl.Download() + } catch { + $result.failed = $true + $result.msg = "Failed to download update $update_number $($update.Identity.UpdateId) - $($update.Title): $($_.Exception.Message)" + return $result + } + + Write-DebugLog -msg "Download result code for $update_number $($update.Identity.UpdateId) = $($download_result.ResultCode)" + # FUTURE: configurable download retry + if ($download_result.ResultCode -ne 2) { # OperationResultCode orcSucceeded + $result.failed = $true + $result.msg = "Failed to download update $update_number $($update.Identity.UpdateId) - $($update.Title): Download Result $($download_result.ResultCode)" + return $result + } + + $result.changed = $true + $update_index++ + } + + Write-DebugLog -msg "Installing updates..." + + # install as a batch so the reboot manager will suppress intermediate reboots + Write-DebugLog -msg "Creating installer object..." + try { + $installer = $session.CreateUpdateInstaller() + } catch { + $result.failed = $true + $result.msg = "Failed to create Update Installer object: $($_.Exception.Message)" + return $result + } + + Write-DebugLog -msg "Creating install collection..." + try { + $installer.Updates = New-Object -ComObject Microsoft.Update.UpdateColl + } catch { + $result.failed = $true + $result.msg = "Failed to create Update Collection object: $($_.Exception.Message)" + return $result + } + + foreach ($update in $updates_to_install) { + Write-DebugLog -msg "Adding update $($update.Identity.UpdateID)" + $installer.Updates.Add($update) > $null + } + + # FUTURE: use BeginInstall w/ progress reporting so we can at least log intermediate install results + try { + $install_result = $installer.Install() + } catch { + $result.failed = $true + $result.msg = "Failed to install update from Update Collection: $($_.Exception.Message)" + return $result + } + + $update_success_count = 0 + $update_fail_count = 0 + + # WU result API requires us to index in to get the install results + $update_index = 0 + foreach ($update in $updates_to_install) { + $update_number = "($($update_index + 1) of $($updates_to_install.Count))" + try { + $update_result = $install_result.GetUpdateResult($update_index) + } catch { + $result.failed = $true + $result.msg = "Failed to get update result for update $update_number $($update.Identity.UpdateID) - $($update.Title): $($_.Exception.Message)" + return $result + } + $update_resultcode = $update_result.ResultCode + $update_hresult = $update_result.HResult + + $update_index++ + + $update_dict = $result.updates[$update.Identity.UpdateID] + if ($update_resultcode -eq 2) { # OperationResultCode orcSucceeded + $update_success_count++ + $update_dict.installed = $true + Write-DebugLog -msg "Update $update_number $($update.Identity.UpdateID) succeeded" + } else { + $update_fail_count++ + $update_dict.installed = $false + $update_dict.failed = $true + $update_dict.failure_hresult_code = $update_hresult + Write-DebugLog -msg "Update $update_number $($update.Identity.UpdateID) failed, resultcode: $update_resultcode, hresult: $update_hresult" } } - if ($kb_match -or $update_info.title -imatch $blacklist_entry) { - Write-DebugLog -msg "Skipping update $($update_info.id) - $($update_info.title) as it was found in the blacklist" - $skipped = $true - break + + Write-DebugLog -msg "Performing post-install reboot requirement check..." + $result.reboot_required = (New-Object -ComObject Microsoft.Update.SystemInfo).RebootRequired + $result.installed_update_count = $update_success_count + $result.failed_update_count = $update_fail_count + + if ($update_fail_count -gt 0) { + $result.failed = $true + $result.msg = "Failed to install one or more updates" + return $result } + + Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)" } - if ($skipped) { - $result.filtered_updates[$update_info.id] = $update_info - continue + + $check_mode = $arguments.check_mode + try { + return @{ + job_output = Start-Updates @arguments + } + } catch { + Write-DebugLog -msg "Fatal exception: $($_.Exception.Message) at $($_.ScriptStackTrace)" + return @{ + job_output = @{ + failed = $true + msg = $_.Exception.Message + location = $_.ScriptStackTrace + } + } } +} +Function Start-Natively($common_functions, $script) { + $runspace_pool = [RunspaceFactory]::CreateRunspacePool() + $runspace_pool.Open() - if (-not $update.EulaAccepted) { - Write-DebugLog -msg "Accepting EULA for $($update_info.id)" - try { - $update.AcceptEula() - } catch { - Fail-Json -obj $result -message "Failed to accept EULA for update $($update_info.id) - $($update_info.title)" - } + try { + $ps_pipeline = [PowerShell]::Create() + $ps_pipeline.RunspacePool = $runspace_pool + + # add the common script functions + $ps_pipeline.AddScript($common_functions) > $null + + # add the update script block and required parameters + $ps_pipeline.AddStatement().AddScript($script) > $null + $ps_pipeline.AddParameter("arguments", @{ + category_guids = $category_guids + log_path = $log_path + state = $state + blacklist = $blacklist + whitelist = $whitelist + check_mode = $check_mode + }) > $null + + $output = $ps_pipeline.Invoke() + } finally { + $runspace_pool.Close() } - if ($update.IsHidden) { - Write-DebugLog -msg "Skipping hidden update $($update_info.title)" - continue + $result = $output[0].job_output + if ($ps_pipeline.HadErrors) { + $result.failed = $true + + # if the msg wasn't set, then add a generic error to at least tell the user something + if (-not ($result.ContainsKey("msg"))) { + $result.msg = "Unknown failure when executing native update script block" + $result.errors = $ps_pipeline.Streams.Error + } } - Write-DebugLog -msg "Adding update $($update_info.id) - $($update_info.title)" - $updates_to_install.Add($update) > $null + Write-DebugLog -msg "Native job completed with output: $($result | Out-String -Width 300)" - $result.updates[$update_info.id] = $update_info + return ,$result } -Write-DebugLog -msg "Calculating pre-install reboot requirement..." +Function Remove-ScheduledJob($name) { + $scheduled_job = Get-ScheduledJob -Name $name -ErrorAction SilentlyContinue -# calculate this early for check mode, and to see if we should allow updates to continue -$result.reboot_required = Get-RebootStatus -$result.found_update_count = $updates_to_install.Count -$result.installed_update_count = 0 + if ($scheduled_job -ne $null) { + Write-DebugLog -msg "Scheduled Job $name exists, ensuring it is not running..." + $scheduler = New-Object -ComObject Schedule.Service + Write-DebugLog -msg "Connecting to scheduler service..." + $scheduler.Connect() + Write-DebugLog -msg "Getting running tasks named $name" + $running_tasks = @($scheduler.GetRunningTasks(0) | Where-Object { $_.Name -eq $name }) -# Early exit of check mode/state=searched as it cannot do more after this -if ($check_mode -or $state -eq "searched") { - Write-DebugLog -msg "Check mode: exiting..." - Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)" + foreach ($task_to_stop in $running_tasks) { + Write-DebugLog -msg "Stopping running task $($task_to_stop.InstanceGuid)..." + $task_to_stop.Stop() + } - if ($updates_to_install.Count -gt 0 -and ($state -ne "searched")) { - $result.changed = $true + <# FUTURE: add a global waithandle for this to release any other waiters. Wait-Job + and/or polling will block forever, since the killed job object in the parent + session doesn't know it's been killed :( #> + Unregister-ScheduledJob -Name $name } - Exit-Json -obj $result } -if ($updates_to_install.Count -gt 0) { - if ($result.reboot_required) { - Write-DebugLog -msg "FATAL: A reboot is required before more updates can be installed" - Fail-Json -obj $result -message "A reboot is required before more updates can be installed" +Function Start-AsScheduledTask($common_functions, $script) { + $job_name = "ansible-win-updates" + Remove-ScheduledJob -name $job_name + + $job_args = @{ + ScriptBlock = $script + Name = $job_name + ArgumentList = @( + @{ + category_guids = $category_guids + log_path = $log_path + state = $state + blacklist = $blacklist + whitelist = $whitelist + check_mode = $check_mode + } + ) + ErrorAction = "Stop" + ScheduledJobOption = @{ RunElevated=$True; StartIfOnBatteries=$True; StopIfGoingOnBatteries=$False } + InitializationScript = $common_functions } - Write-DebugLog -msg "No reboot is pending..." -} else { - # no updates to install exit here - Exit-Json -obj $result -} -Write-DebugLog -msg "Downloading updates..." -$update_index = 1 -foreach ($update in $updates_to_install) { - $update_number = "($update_index of $($updates_to_install.Count))" - if ($update.IsDownloaded) { - Write-DebugLog -msg "Update $update_number $($update.Identity.UpdateId) already downloaded, skipping..." - $update_index++ - continue - } + Write-DebugLog -msg "Registering scheduled job with args $($job_args | Out-String -Width 300)" + $scheduled_job = Register-ScheduledJob @job_args - Write-DebugLog -msg "Creating downloader object..." - try { - $dl = $session.CreateUpdateDownloader() - } catch { - Fail-Json -obj $result -message "Failed to create downloader object: $($_.Exception.Message)" + # RunAsTask isn't available in PS3 - fall back to a 2s future trigger + if ($scheduled_job | Get-Member -Name RunAsTask) { + Write-DebugLog -msg "Starting scheduled job (PS4+ method)" + $scheduled_job.RunAsTask() + } else { + Write-DebugLog -msg "Starting scheduled job (PS3 method)" + Add-JobTrigger -InputObject $scheduled_job -trigger $(New-JobTrigger -Once -At $(Get-Date).AddSeconds(2)) } - Write-DebugLog -msg "Creating download collection..." - try { - $dl.Updates = New-Object -ComObject Microsoft.Update.UpdateColl - } catch { - Fail-Json -obj $result -message "Failed to create download collection object: $($_.Exception.Message)" + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $job = $null + + Write-DebugLog -msg "Waiting for job completion..." + + # Wait-Job can fail for a few seconds until the scheduled task starts - poll for it... + while ($job -eq $null) { + Start-Sleep -Milliseconds 100 + if ($sw.ElapsedMilliseconds -ge 30000) { # tasks scheduled right after boot on 2008R2 can take awhile to start... + Fail-Json -msg "Timed out waiting for scheduled task to start" + } + + # FUTURE: configurable timeout so we don't block forever? + # FUTURE: add a global WaitHandle in case another instance kills our job, so we don't block forever + $job = Wait-Job -Name $scheduled_job.Name -ErrorAction SilentlyContinue } - Write-DebugLog -msg "Adding update $update_number $($update.Identity.UpdateId)" - $dl.Updates.Add($update) > $null + $sw = [System.Diagnostics.Stopwatch]::StartNew() - Write-DebugLog -msg "Downloading $update_number $($update.Identity.UpdateId)" - try { - $download_result = $dl.Download() - } catch { - Fail-Json -obj $result -message "Failed to download update $update_number $($update.Identity.UpdateId) - $($update.Title): $($_.Exception.Message)" + # NB: output from scheduled jobs is delayed after completion (including the sub-objects after the primary Output object is available) + while (($job.Output -eq $null -or -not ($job.Output | Get-Member -Name Key -ErrorAction Ignore) -or -not $job.Output.Key.Contains("job_output")) -and $sw.ElapsedMilliseconds -lt 15000) { + Write-DebugLog -msg "Waiting for job output to populate..." + Start-Sleep -Milliseconds 500 } - Write-DebugLog -msg "Download result code for $update_number $($update.Identity.UpdateId) = $($download_result.ResultCode)" - # FUTURE: configurable download retry - if ($download_result.ResultCode -ne 2) { # OperationResultCode orcSucceeded - Fail-Json -obj $result -message "Failed to download update $update_number $($update.Identity.UpdateId) - $($update.Title): Download Result $($download_result.ResultCode)" + # NB: fallthru on both timeout and success + $ret = @{ + ErrorOutput = $job.Error + WarningOutput = $job.Warning + VerboseOutput = $job.Verbose + DebugOutput = $job.Debug } - $result.changed = $true - $update_index++ -} + if ($job.Output -eq $null -or -not $job.Output.Keys.Contains('job_output')) { + $ret.Output = @{failed = $true; msg = "job output was lost"} + } else { + $ret.Output = $job.Output.job_output # sub-object returned, can only be accessed as a property for some reason + } -Write-DebugLog -msg "Installing updates..." + try { # this shouldn't be fatal, but can fail with both Powershell errors and COM Exceptions, hence the dual error-handling... + Unregister-ScheduledJob -Name $job_name -Force -ErrorAction Continue + } catch { + Write-DebugLog "Error unregistering job after execution: $($_.Exception.ToString()) $($_.ScriptStackTrace)" + } + Write-DebugLog -msg "Scheduled job completed with output: $($re.Output | Out-String -Width 300)" -# install as a batch so the reboot manager will suppress intermediate reboots -Write-DebugLog -msg "Creating installer object..." -try { - $installer = $session.CreateUpdateInstaller() -} catch { - Fail-Json -obj $result -message "Failed to create Update Installer object: $($_.Exception.Message)" + return $ret.Output } -Write-DebugLog -msg "Creating install collection..." -try { - $installer.Updates = New-Object -ComObject Microsoft.Update.UpdateColl -} catch { - Fail-Json -obj $result -message "Failed to create Update Collection object: $($_.Exception.Message)" -} +# source the common code into the current scope so we can call it +. $common_functions -foreach ($update in $updates_to_install) { - Write-DebugLog -msg "Adding update $($update.Identity.UpdateID)" - $installer.Updates.Add($update) > $null -} +<# Most of the Windows Update Agent API will not run under a remote token, +which a remote WinRM session always has. Using become can bypass this +limitation but it is not always an option with older hosts. win_updates checks +if WUA is available in the current logon process and does either of the below; -# FUTURE: use BeginInstall w/ progress reporting so we can at least log intermediate install results + * If become is used then it will run the windows update process natively + without any of the scheduled task hackery + * If become is not used then it will run the windows update process under + a scheduled job. +#> try { - $install_result = $installer.Install() + (New-Object -ComObject Microsoft.Update.Session).CreateUpdateInstaller().IsBusy > $null + $wua_available = $true } catch { - Fail-Json -obj $result -message "Failed to install update from Update Collection: $($_.Exception.Message)" -} - -$update_success_count = 0 -$update_fail_count = 0 - -# WU result API requires us to index in to get the install results -$update_index = 0 -foreach ($update in $updates_to_install) { - $update_number = "($($update_index + 1) of $($updates_to_install.Count))" - try { - $update_result = $install_result.GetUpdateResult($update_index) - } catch { - Fail-Json -obj $result -message "Failed to get update result for update $update_number $($update.Identity.UpdateID) - $($update.Title): $($_.Exception.Message)" - } - $update_resultcode = $update_result.ResultCode - $update_hresult = $update_result.HResult - - $update_index++ - - $update_dict = $result.updates[$update.Identity.UpdateID] - if ($update_resultcode -eq 2) { # OperationResultCode orcSucceeded - $update_success_count++ - $update_dict.installed = $true - Write-DebugLog -msg "Update $update_number $($update.Identity.UpdateID) succeeded" - } else { - $update_fail_count++ - $update_dict.installed = $false - $update_dict.failed = $true - $update_dict.failure_hresult_code = $update_hresult - Write-DebugLog -msg "Update $update_number $($update.Identity.UpdateID) failed, resultcode: $update_resultcode, hresult: $update_hresult" - } + $wua_available = $false } -Write-DebugLog -msg "Performing post-install reboot requirement check..." -$result.reboot_required = Get-RebootStatus -$result.installed_update_count = $update_success_count -$result.failed_update_count = $update_fail_count - -if ($update_fail_count -gt 0) { - Fail-Json -obj $result -msg "Failed to install one or more updates" +if ($wua_available) { + Write-DebugLog -msg "WUA is available in current logon process, running natively" + $result = Start-Natively -common_functions $common_functions -script $update_script_block +} else { + Write-DebugLog -msg "WUA is not avialable in current logon process, running with scheduled task" + $result = Start-AsScheduledTask -common_functions $common_functions -script $update_script_block } -Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)" - -Exit-Json $result - +Exit-Json -obj $result diff --git a/lib/ansible/modules/windows/win_updates.py b/lib/ansible/modules/windows/win_updates.py index d0ef3c1d0b5..9f6d4337adb 100644 --- a/lib/ansible/modules/windows/win_updates.py +++ b/lib/ansible/modules/windows/win_updates.py @@ -85,6 +85,18 @@ options: I(category_names). It will not force the module to install an update if it was not in the category specified. version_added: '2.5' + use_scheduled_task: + description: + - Will not auto elevate the remote process with I(become) and use a + scheduled task instead. + - Set this to C(yes) when using this module with async on Server 2008, + 2008 R2, or Windows 7, or on Server 2008 that is not authenticated + with basic or credssp. + - Can also be set to C(yes) on newer hosts where become does not work + due to further privilege restrictions from the OS defaults. + type: bool + default: 'no' + version_added: '2.6' author: - Matt Davis (@nitzmahone) notes: @@ -100,16 +112,17 @@ notes: ''' EXAMPLES = r''' -- name: Install all security, critical, and rollup updates +- name: Install all security, critical, and rollup updates without a scheduled task win_updates: category_names: - SecurityUpdates - CriticalUpdates - UpdateRollups -- name: Install only security updates +- name: Install only security updates as a scheduled task for Server 2008 win_updates: category_names: SecurityUpdates + use_scheduled_task: yes - name: Search-only, return list of found updates (if any), log to C:\ansible_wu.txt win_updates: @@ -139,37 +152,6 @@ EXAMPLES = r''' blacklist: - Windows Malicious Software Removal Tool for Windows - \d{4}-\d{2} Cumulative Update for Windows Server 2016 - -# Note async works on Windows Server 2012 or newer - become must be explicitly set on the task for this to work -- name: Search for Windows updates asynchronously - win_updates: - category_names: - - SecurityUpdates - state: searched - async: 180 - poll: 10 - register: updates_to_install - become: yes - become_method: runas - become_user: SYSTEM - -# Async can also be run in the background in a fire and forget fashion -- name: Search for Windows updates asynchronously (poll and forget) - win_updates: - category_names: - - SecurityUpdates - state: searched - async: 180 - poll: 0 - register: updates_to_install_async - -- name: get status of Windows Update async job - async_status: - jid: '{{ updates_to_install_async.ansible_job_id }}' - register: updates_to_install_result - become: yes - become_method: runas - become_user: SYSTEM ''' RETURN = r''' diff --git a/lib/ansible/plugins/action/win_updates.py b/lib/ansible/plugins/action/win_updates.py index 766ff496993..5c12a51e23d 100644 --- a/lib/ansible/plugins/action/win_updates.py +++ b/lib/ansible/plugins/action/win_updates.py @@ -40,15 +40,17 @@ class ActionModule(ActionBase): raise AnsibleError("Unknown category_name %s, must be one of " "(%s)" % (name, ','.join(valid_categories))) - def _run_win_updates(self, module_args, task_vars): + def _run_win_updates(self, module_args, task_vars, use_task): display.vvv("win_updates: running win_updates module") - result = self._execute_module(module_name='win_updates', - module_args=module_args, - task_vars=task_vars, - wrap_async=self._task.async_val) + wrap_async = self._task.async_val + result = self._execute_module_with_become(module_name='win_updates', + module_args=module_args, + task_vars=task_vars, + wrap_async=wrap_async, + use_task=use_task) return result - def _reboot_server(self, task_vars, reboot_timeout): + def _reboot_server(self, task_vars, reboot_timeout, use_task): display.vvv("win_updates: rebooting remote host after update install") reboot_args = { 'reboot_timeout': reboot_timeout @@ -58,42 +60,35 @@ class ActionModule(ActionBase): if reboot_result.get('failed', False): raise AnsibleError(reboot_result['msg']) - display.vvv("win_updates: checking WUA is not busy with win_shell " - "command") - # While this always returns False after a reboot it doesn't return a - # value until Windows is actually ready and finished installing updates - # This needs to run with become as WUA doesn't work over WinRM - # Ignore connection errors as another reboot can happen - command = "(New-Object -ComObject Microsoft.Update.Session)." \ - "CreateUpdateInstaller().IsBusy" - shell_module_args = { - '_raw_params': command - } - - # run win_shell module with become and ignore any errors in case of - # a windows reboot during execution - orig_become = self._play_context.become - orig_become_method = self._play_context.become_method - orig_become_user = self._play_context.become_user - if orig_become is None or orig_become is False: - self._play_context.become = True - if orig_become_method != 'runas': - self._play_context.become_method = 'runas' - if orig_become_user is None or 'root': - self._play_context.become_user = 'SYSTEM' - try: - shell_result = self._execute_module(module_name='win_shell', - module_args=shell_module_args, - task_vars=task_vars) - display.vvv("win_updates: shell wait results: %s" - % json.dumps(shell_result)) - except Exception as exc: - display.debug("win_updates: Fatal error when running shell " - "command, attempting to recover: %s" % to_text(exc)) - finally: - self._play_context.become = orig_become - self._play_context.become_method = orig_become_method - self._play_context.become_user = orig_become_user + # only run this if the user has specified we can only use scheduled + # tasks, the win_shell command requires become and will be skipped if + # become isn't available to use + if use_task: + display.vvv("win_updates: skipping WUA is not busy check as " + "use_scheduled_task=True is set") + else: + display.vvv("win_updates: checking WUA is not busy with win_shell " + "command") + # While this always returns False after a reboot it doesn't return + # a value until Windows is actually ready and finished installing + # updates. This needs to run with become as WUA doesn't work over + # WinRM, ignore connection errors as another reboot can happen + command = "(New-Object -ComObject Microsoft.Update.Session)." \ + "CreateUpdateInstaller().IsBusy" + shell_module_args = { + '_raw_params': command + } + + try: + shell_result = self._execute_module_with_become( + module_name='win_shell', module_args=shell_module_args, + task_vars=task_vars, wrap_async=False, use_task=use_task + ) + display.vvv("win_updates: shell wait results: %s" + % json.dumps(shell_result)) + except Exception as exc: + display.debug("win_updates: Fatal error when running shell " + "command, attempting to recover: %s" % to_text(exc)) display.vvv("win_updates: ensure the connection is up and running") # in case Windows needs to reboot again after the updates, we wait for @@ -129,6 +124,32 @@ class ActionModule(ActionBase): dict_var.update(new) return dict_var + def _execute_module_with_become(self, module_name, module_args, task_vars, + wrap_async, use_task): + orig_become = self._play_context.become + orig_become_method = self._play_context.become_method + orig_become_user = self._play_context.become_user\ + + if not use_task: + if orig_become is None or orig_become is False: + self._play_context.become = True + if orig_become_method != 'runas': + self._play_context.become_method = 'runas' + if orig_become_user is None or orig_become_user == 'root': + self._play_context.become_user = 'SYSTEM' + + try: + module_res = self._execute_module(module_name=module_name, + module_args=module_args, + task_vars=task_vars, + wrap_async=wrap_async) + finally: + self._play_context.become = orig_become + self._play_context.become_method = orig_become_method + self._play_context.become_user = orig_become_user + + return module_res + def run(self, tmp=None, task_vars=None): self._supports_check_mode = True self._supports_async = True @@ -148,6 +169,8 @@ class ActionModule(ActionBase): reboot = self._task.args.get('reboot', False) reboot_timeout = self._task.args.get('reboot_timeout', self.DEFAULT_REBOOT_TIMEOUT) + use_task = boolean(self._task.args.get('use_scheduled_task', False), + strict=False) # Validate the options try: @@ -184,7 +207,7 @@ class ActionModule(ActionBase): new_module_args = self._task.args.copy() new_module_args.pop('reboot', None) new_module_args.pop('reboot_timeout', None) - result = self._run_win_updates(new_module_args, task_vars) + result = self._run_win_updates(new_module_args, task_vars, use_task) # if the module failed to run at all then changed won't be populated # so we just return the result as is @@ -232,7 +255,8 @@ class ActionModule(ActionBase): "update install" try: changed = True - self._reboot_server(task_vars, reboot_timeout) + self._reboot_server(task_vars, reboot_timeout, + use_task) except AnsibleError as exc: result['failed'] = True result['msg'] = "Failed to reboot remote host when " \ @@ -242,7 +266,8 @@ class ActionModule(ActionBase): result.pop('msg', None) # rerun the win_updates module after the reboot is complete - result = self._run_win_updates(new_module_args, task_vars) + result = self._run_win_updates(new_module_args, task_vars, + use_task) if result.get('failed', False): return result diff --git a/test/integration/targets/win_updates/tasks/tests.yml b/test/integration/targets/win_updates/tasks/tests.yml index da03c3f0c08..add3dc36c62 100644 --- a/test/integration/targets/win_updates/tasks/tests.yml +++ b/test/integration/targets/win_updates/tasks/tests.yml @@ -63,12 +63,13 @@ - update_search_with_log_check.found_update_count is defined - update_search_with_log_check_actual.stat.exists == False -- name: search for updates with log output +- name: search for updates with log output and use scheduled task win_updates: state: searched category_names: - CriticalUpdates log_path: '{{win_updates_dir}}/update.log' + use_scheduled_task: yes register: update_search_with_log - name: get stat of update log file diff --git a/test/units/plugins/action/test_win_updates.py b/test/units/plugins/action/test_win_updates.py new file mode 100644 index 00000000000..15016e752ea --- /dev/null +++ b/test/units/plugins/action/test_win_updates.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# (c) 2018, Jordan Borean +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from ansible.compat.tests.mock import patch, MagicMock, mock_open +from ansible.plugins.action.win_updates import ActionModule +from ansible.playbook.task import Task + + +class TestWinUpdatesActionPlugin(object): + + INVALID_OPTIONS = ( + ( + {"category_names": ["fake category"]}, + False, + "Unknown category_name fake category, must be one of (Application," + "Connectors,CriticalUpdates,DefinitionUpdates,DeveloperKits," + "FeaturePacks,Guidance,SecurityUpdates,ServicePacks,Tools," + "UpdateRollups,Updates)" + ), + ( + {"state": "invalid"}, + False, + "state must be either installed or searched" + ), + ( + {"reboot": "nonsense"}, + False, + "cannot parse reboot as a boolean: The value 'nonsense' is not a " + "valid boolean." + ), + ( + {"reboot_timeout": "string"}, + False, + "reboot_timeout must be an integer" + ), + ( + {"reboot": True}, + True, + "async is not supported for this task when reboot=yes" + ) + ) + + # pylint bug: https://github.com/PyCQA/pylint/issues/511 + # pylint: disable=undefined-variable + @pytest.mark.parametrize('task_args, async_val, expected', + ((t, a, e) for t, a, e in INVALID_OPTIONS)) + def test_invalid_options(self, task_args, async_val, expected): + task = MagicMock(Task) + task.args = task_args + task.async_val = async_val + + connection = MagicMock() + play_context = MagicMock() + play_context.check_mode = False + + plugin = ActionModule(task, connection, play_context, loader=None, + templar=None, shared_loader_obj=None) + res = plugin.run() + assert res['failed'] + assert expected in res['msg'] + + BECOME_OPTIONS = ( + (False, False, "sudo", "root", True, "runas", "SYSTEM"), + (False, True, "sudo", "root", True, "runas", "SYSTEM"), + (False, False, "runas", "root", True, "runas", "SYSTEM"), + (False, False, "sudo", "user", True, "runas", "user"), + (False, None, "sudo", None, True, "runas", "SYSTEM"), + + # use scheduled task, we shouldn't change anything + (True, False, "sudo", None, False, "sudo", None), + (True, True, "runas", "SYSTEM", True, "runas", "SYSTEM"), + ) + + # pylint bug: https://github.com/PyCQA/pylint/issues/511 + # pylint: disable=undefined-variable + @pytest.mark.parametrize('use_task, o_b, o_bmethod, o_buser, e_b, e_bmethod, e_buser', + ((u, ob, obm, obu, eb, ebm, ebu) + for u, ob, obm, obu, eb, ebm, ebu in BECOME_OPTIONS)) + def test_module_exec_with_become(self, use_task, o_b, o_bmethod, o_buser, + e_b, e_bmethod, e_buser): + def mock_execute_module(self, **kwargs): + pc = self._play_context + return {"become": pc.become, "become_method": pc.become_method, + "become_user": pc.become_user} + + task = MagicMock(Task) + task.args = {} + + connection = MagicMock() + connection.module_implementation_preferences = ('.ps1', '.exe', '') + + play_context = MagicMock() + play_context.check_mode = False + play_context.become = o_b + play_context.become_method = o_bmethod + play_context.become_user = o_buser + + plugin = ActionModule(task, connection, play_context, loader=None, + templar=None, shared_loader_obj=None) + with patch('ansible.plugins.action.ActionBase._execute_module', + new=mock_execute_module): + actual = plugin._execute_module_with_become('win_updates', {}, {}, + True, use_task) + + # always make sure we reset back to the defaults + assert play_context.become == o_b + assert play_context.become_method == o_bmethod + assert play_context.become_user == o_buser + + # verify what was set when _execute_module was called + assert actual['become'] == e_b + assert actual['become_method'] == e_bmethod + assert actual['become_user'] == e_buser