From 0962a0d8168613547dc46958b8cd6539a46e8133 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Wed, 22 Nov 2017 07:49:38 +1000 Subject: [PATCH] win_updates: removed scheduled task to use become instead (#33118) * win_updates: removed scheduled task to use become instead * updated docs to remove scheduled task info * fix issue with only installing last update in group --- lib/ansible/modules/windows/win_updates.ps1 | 609 +++++++----------- lib/ansible/modules/windows/win_updates.py | 15 +- test/integration/targets/win_updates/aliases | 1 + .../targets/win_updates/defaults/main.yml | 1 + .../targets/win_updates/tasks/main.yml | 26 + .../targets/win_updates/tasks/tests.yml | 104 +++ 6 files changed, 372 insertions(+), 384 deletions(-) create mode 100644 test/integration/targets/win_updates/aliases create mode 100644 test/integration/targets/win_updates/defaults/main.yml create mode 100644 test/integration/targets/win_updates/tasks/main.yml create mode 100644 test/integration/targets/win_updates/tasks/tests.yml diff --git a/lib/ansible/modules/windows/win_updates.ps1 b/lib/ansible/modules/windows/win_updates.ps1 index 8be45314b3..725fb23583 100644 --- a/lib/ansible/modules/windows/win_updates.ps1 +++ b/lib/ansible/modules/windows/win_updates.ps1 @@ -1,422 +1,281 @@ #!powershell # This file is part of Ansible -# -# Copyright 2015, Matt Davis -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see . -# WANT_JSON -# POWERSHELL_COMMON +# Copyright 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) + +#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" -$FormatEnumerationLimit = -1 # prevent out-string et al from truncating collection dumps -<# Most of the Windows Update Agent API will not run under a remote token, -which a remote WinRM session always has. win_updates uses the Task Scheduler -to run the bulk of the update functionality under a local token. Powershell's -Scheduled-Job capability provides a decent abstraction over the Task Scheduler -and handles marshaling Powershell args in and output/errors/etc back. The -module schedules a single job that executes all interactions with the Update -Agent API, then waits for completion. A significant amount of hassle is -involved to ensure that only one of these jobs is running at a time, and to -clean up the various error conditions that can occur. #> +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false -# define the ScriptBlock that will be passed to Register-ScheduledJob -$job_body = { - Param( - [hashtable]$boundparms=@{}, - [Object[]]$unboundargs=$() - ) +$category_names = Get-AnsibleParam -obj $params -name "category_names" -type "list" -default @("CriticalUpdates", "SecurityUpdates", "UpdateRollups") +$log_path = Get-AnsibleParam -obj $params -name "log_path" -type "path" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "installed" -validateset "installed", "searched" +# TODO: blacklist and whitelist - Set-StrictMode -Version 2 +$result = @{ + changed = $false + updates = @{} +} - $ErrorActionPreference = "Stop" - $DebugPreference = "Continue" - $FormatEnumerationLimit = -1 # prevent out-string et al from truncating collection dumps +Function Write-DebugLog($msg) { + $date_str = Get-Date -Format u + $msg = "$date_str $msg" - # set this as a global for the Write-DebugLog function - $log_path = $boundparms['log_path'] + Write-Debug -Message $msg + if ($log_path -ne $null -and (-not $check_mode)) { + Add-Content -Path $log_path -Value $msg + } +} - Write-DebugLog "Scheduled job started with boundparms $($boundparms | out-string) and unboundargs $($unboundargs | out-string)" +Function Get-CategoryGuid($category_name) { + $guid = switch -exact ($category_name) { + "Application" {"5C9376AB-8CE6-464A-B136-22113DD69801"} + "Connectors" {"434DE588-ED14-48F5-8EED-A15E09A991F6"} + "CriticalUpdates" {"E6CF1350-C01B-414D-A61F-263D14D133B4"} + "DefinitionUpdates" {"E0789628-CE08-4437-BE74-2495B842F43B"} + "DeveloperKits" {"E140075D-8433-45C3-AD87-E72345B36078"} + "FeaturePacks" {"B54E7D24-7ADD-428F-8B75-90A396FA584F"} + "Guidance" {"9511D615-35B2-47BB-927F-F73D8E9260BB"} + "SecurityUpdates" {"0FA1201D-4330-4FA8-8AE9-B877473B6441"} + "ServicePacks" {"68C5B0A3-D1A6-4553-AE49-01D3A7827828"} + "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)" } + } + return $guid +} - # FUTURE: elevate this to module arg validation once we have it - Function MapCategoryNameToGuid { - Param([string] $category_name) - - $category_guid = switch -exact ($category_name) { - # as documented by TechNet @ https://technet.microsoft.com/en-us/library/ff730937.aspx - "Application" {"5C9376AB-8CE6-464A-B136-22113DD69801"} - "Connectors" {"434DE588-ED14-48F5-8EED-A15E09A991F6"} - "CriticalUpdates" {"E6CF1350-C01B-414D-A61F-263D14D133B4"} - "DefinitionUpdates" {"E0789628-CE08-4437-BE74-2495B842F43B"} - "DeveloperKits" {"E140075D-8433-45C3-AD87-E72345B36078"} - "FeaturePacks" {"B54E7D24-7ADD-428F-8B75-90A396FA584F"} - "Guidance" {"9511D615-35B2-47BB-927F-F73D8E9260BB"} - "SecurityUpdates" {"0FA1201D-4330-4FA8-8AE9-B877473B6441"} - "ServicePacks" {"68C5B0A3-D1A6-4553-AE49-01D3A7827828"} - "Tools" {"B4832BD8-E735-4761-8DAF-37F882276DAB"} - "UpdateRollups" {"28BC880E-0592-4CBF-8F95-C79B17911D5F"} - "Updates" {"CD5FFD1E-E932-4E3A-BF74-18BF0B1BBD83"} - default { throw "Unknown category_name $category_name, must be one of (Application,Connectors,CriticalUpdates,DefinitionUpdates,DeveloperKits,FeaturePacks,Guidance,SecurityUpdates,ServicePacks,Tools,UpdateRollups,Updates)" } - } - - return $category_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)" } - Function DoWindowsUpdate { - Param( - [string[]]$category_names=@("CriticalUpdates","SecurityUpdates","UpdateRollups"), - [ValidateSet("installed", "searched")] - [string]$state="installed", - [bool]$_ansible_check_mode=$false - ) + return $system_info.RebootRequired +} - $is_check_mode = $($state -eq "searched") -or $_ansible_check_mode +$category_guids = $category_names | ForEach-Object { Get-CategoryGuid -category_name $_ } - $category_guids = $category_names | % { MapCategoryNameToGUID $_ } +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)" +} - $update_status = @{ changed = $false } +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)" +} - Write-DebugLog "Creating Windows Update session..." - $session = New-Object -ComObject Microsoft.Update.Session +# 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 "Create Windows Update searcher..." - $searcher = $session.CreateUpdateSearcher() +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" - # OR is only allowed at the top-level, so we have to repeat base criteria inside - # FUTURE: change this to client-side filtered? - $criteriabase = "IsInstalled = 0" - $criteria_list = $category_guids | % { "($criteriabase AND CategoryIDs contains '$_')" } +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)" +} - $criteria = [string]::Join(" OR ", $criteria_list) - - Write-DebugLog "Search criteria: $criteria" - - Write-DebugLog "Searching for updates to install in category IDs $category_guids..." - $searchresult = $searcher.Search($criteria) - - Write-DebugLog "Creating update collection..." - - $updates_to_install = New-Object -ComObject Microsoft.Update.UpdateColl - - Write-DebugLog "Found $($searchresult.Updates.Count) updates" - - $update_status.updates = @{ } - - # FUTURE: add further filtering options - foreach($update in $searchresult.Updates) { - if(-Not $update.EulaAccepted) { - Write-DebugLog "Accepting EULA for $($update.Identity.UpdateID)" +# FUTURE: add further filtering options (whitelist/blacklist) +foreach ($update in $search_result.Updates) { + if (-not $update.EulaAccepted) { + Write-DebugLog -msg "Accepting EULA for $($update.Identity.UpdateID)" + try { $update.AcceptEula() - } - - if($update.IsHidden) { - Write-DebugLog "Skipping hidden update $($update.Title)" - continue - } - - Write-DebugLog "Adding update $($update.Identity.UpdateID) - $($update.Title)" - $res = $updates_to_install.Add($update) - - $update_status.updates[$update.Identity.UpdateID] = @{ - title = $update.Title - # TODO: pluck the first KB out (since most have just one)? - kb = $update.KBArticleIDs - id = $update.Identity.UpdateID - installed = $false - } + } catch { + Fail-Json -obj $result -message "Failed to accept EULA for update $($update.Identity.UpdateID) - $($update.Title)" } - - Write-DebugLog "Calculating pre-install reboot requirement..." - - # calculate this early for check mode, and to see if we should allow updates to continue - $sysinfo = New-Object -ComObject Microsoft.Update.SystemInfo - $update_status.reboot_required = $sysinfo.RebootRequired - $update_status.found_update_count = $updates_to_install.Count - $update_status.installed_update_count = 0 - - # bail out here for check mode - if($is_check_mode -eq $true) { - Write-DebugLog "Check mode; exiting..." - Write-DebugLog "Return value: $($update_status | out-string)" - - if($updates_to_install.Count -gt 0) { $update_status.changed = $true } - return $update_status - } - - if($updates_to_install.Count -gt 0) { - if($update_status.reboot_required) { - throw "A reboot is required before more updates can be installed." - } - else { - Write-DebugLog "No reboot is pending..." - } - Write-DebugLog "Downloading updates..." - } - - foreach($update in $updates_to_install) { - if($update.IsDownloaded) { - Write-DebugLog "Update $($update.Identity.UpdateID) already downloaded, skipping..." - continue - } - Write-DebugLog "Creating downloader object..." - $dl = $session.CreateUpdateDownloader() - Write-DebugLog "Creating download collection..." - $dl.Updates = New-Object -ComObject Microsoft.Update.UpdateColl - Write-DebugLog "Adding update $($update.Identity.UpdateID)" - $res = $dl.Updates.Add($update) - Write-DebugLog "Downloading update $($update.Identity.UpdateID)..." - $download_result = $dl.Download() - # FUTURE: configurable download retry - if($download_result.ResultCode -ne 2) { # OperationResultCode orcSucceeded - throw "Failed to download update $($update.Identity.UpdateID)" - } - } - - if($updates_to_install.Count -lt 1 ) { return $update_status } - - Write-DebugLog "Installing updates..." - - # install as a batch so the reboot manager will suppress intermediate reboots - Write-DebugLog "Creating installer object..." - $inst = $session.CreateUpdateInstaller() - Write-DebugLog "Creating install collection..." - $inst.Updates = New-Object -ComObject Microsoft.Update.UpdateColl - - foreach($update in $updates_to_install) { - Write-DebugLog "Adding update $($update.Identity.UpdateID)" - $res = $inst.Updates.Add($update) - } - - # FUTURE: use BeginInstall w/ progress reporting so we can at least log intermediate install results - Write-DebugLog "Installing updates..." - $install_result = $inst.Install() - - $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_result = $install_result.GetUpdateResult($update_index) - $update_resultcode = $update_result.ResultCode - $update_hresult = $update_result.HResult - - $update_index++ - - $update_dict = $update_status.updates[$update.Identity.UpdateID] - - if($update_resultcode -eq 2) { # OperationResultCode orcSucceeded - $update_success_count++ - $update_dict.installed = $true - Write-DebugLog "Update $($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 "Update $($update.Identity.UpdateID) failed resultcode $update_hresult hresult $update_hresult" - } - - } - - if($update_fail_count -gt 0) { - $update_status.failed = $true - $update_status.msg="Failed to install one or more updates" - } - else { $update_status.changed = $true } - - Write-DebugLog "Performing post-install reboot requirement check..." - - # recalculate reboot status after installs - $sysinfo = New-Object -ComObject Microsoft.Update.SystemInfo - $update_status.reboot_required = $sysinfo.RebootRequired - $update_status.installed_update_count = $update_success_count - $update_status.failed_update_count = $update_fail_count - - Write-DebugLog "Return value: $($update_status | out-string)" - - return $update_status } - Try { - # job system adds a bunch of cruft to top-level dict, so we have to send a sub-dict - return @{ job_output = DoWindowsUpdate @boundparms } + if ($update.IsHidden) { + Write-DebugLog -msg "Skipping hidden update $($update.Title)" + continue } - Catch { - $excep = $_ - Write-DebugLog "Fatal exception: $($excep.Exception.Message) at $($excep.ScriptStackTrace)" - return @{ job_output = @{ failed=$true;error=$excep.Exception.Message;location=$excep.ScriptStackTrace } } + + Write-DebugLog -msg "Adding update $($update.Identity.UpdateID) - $($update.Title)" + $updates_to_install.Add($update) > $null + + $result.updates[$update.Identity.UpdateId] = @{ + title = $update.Title + # TODO: pluck the first KB out (since most have just one)? + kb = $update.KBArticleIDs + id = $update.Identity.UpdateId + installed = $false } } -Function DestroyScheduledJob { - Param([string] $job_name) +Write-DebugLog -msg "Calculating pre-install reboot requirement..." - # find a scheduled job with the same name (should normally fail) - $schedjob = Get-ScheduledJob -Name $job_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 - # nuke it if it's there - If($schedjob -ne $null) { - Write-DebugLog "ScheduledJob $job_name exists, ensuring it's not running..." - # can't manage jobs across sessions, so we have to resort to the Task Scheduler script object to kill running jobs - $schedserv = New-Object -ComObject Schedule.Service - Write-DebugLog "Connecting to scheduler service..." - $schedserv.Connect() - Write-DebugLog "Getting running tasks named $job_name" - $running_tasks = @($schedserv.GetRunningTasks(0) | Where-Object { $_.Name -eq $job_name }) - - Foreach($task_to_stop in $running_tasks) { - Write-DebugLog "Stopping running task $($task_to_stop.InstanceGuid)..." - $task_to_stop.Stop() - } - - <# 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 $job_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)" + if ($updates_to_install.Count -gt 0 -and ($state -ne "searched")) { + $result.changed = $true + } + Exit-Json -obj $result } -Function RunAsScheduledJob { - Param([scriptblock] $job_body, [string] $job_name, [scriptblock] $job_init, [Object[]] $job_arg_list=@()) - - DestroyScheduledJob -job_name $job_name - - $rsj_args = @{ - ScriptBlock = $job_body - Name = $job_name - ArgumentList = $job_arg_list - ErrorAction = "Stop" - ScheduledJobOption = @{ RunElevated=$True; StartIfOnBatteries=$True; StopIfGoingOnBatteries=$False } - } - - if($job_init) { $rsj_args.InitializationScript = $job_init } - - Write-DebugLog "Registering scheduled job with args $($rsj_args | Out-String -Width 300)" - $schedjob = Register-ScheduledJob @rsj_args - - # RunAsTask isn't available in PS3- fall back to a 2s future trigger - if($schedjob | Get-Member -Name RunAsTask) { - Write-DebugLog "Starting scheduled job (PS4 method)" - $schedjob.RunAsTask() - } - else { - Write-DebugLog "Starting scheduled job (PS3 method)" - Add-JobTrigger -inputobject $schedjob -trigger $(New-JobTrigger -once -at $(Get-Date).AddSeconds(2)) - } - - $sw = [System.Diagnostics.Stopwatch]::StartNew() - - $job = $null - - Write-DebugLog "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... - Throw "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 $schedjob.Name -ErrorAction SilentlyContinue - } - - $sw = [System.Diagnostics.Stopwatch]::StartNew() - - # 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 Keys -ErrorAction Ignore) -or -not $job.Output.Keys.Contains('job_output')) -and $sw.ElapsedMilliseconds -lt 15000) { - Write-DebugLog "Waiting for job output to populate..." - Start-Sleep -Milliseconds 500 - } - - # NB: fallthru on both timeout and success - - $ret = @{ - ErrorOutput = $job.Error - WarningOutput = $job.Warning - VerboseOutput = $job.Verbose - DebugOutput = $job.Debug - } - - 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 - } - - 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)" - } - - return $ret +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" + } + Write-DebugLog -msg "No reboot is pending..." +} else { + # no updates to install exit here + Exit-Json -obj $result } -Function Log-Forensics { - Write-DebugLog "Arguments: $job_args | out-string" - Write-DebugLog "OS Version: $([environment]::OSVersion.Version | out-string)" - Write-DebugLog "Running as user: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)" - Write-DebugLog "Powershell version: $($PSVersionTable | out-string)" - # FUTURE: log auth method (kerb, password, etc) +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 { + Fail-Json -obj $result -message "Failed to create downloader object: $($_.Exception.Message)" + } + + 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)" + } + + 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 { + Fail-Json -obj $result -message "Failed to download update $update_number $($update.Identity.UpdateId) - $($update.Title): $($_.Exception.Message)" + } + + 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.ResuleCode)" + } + + $result.changed = $true + $update_index++ } -# code shared between the scheduled job and the host script -$common_inject = { - # FUTURE: capture all to a list, dump on error - Function Write-DebugLog { - Param( - [string]$msg - ) +Write-DebugLog -msg "Installing updates..." - $DebugPreference = "Continue" - $ErrorActionPreference = "Continue" - $date_str = Get-Date -Format u - $msg = "$date_str $msg" +# 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)" +} - Write-Debug $msg +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)" +} - if($log_path -ne $null) { - Add-Content $log_path $msg - } +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 { + 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" } } -# source the common code into the current scope so we can call it -. $common_inject +if ($update_fail_count -gt 0) { + Fail-Json -obj $result -msg "Failed to install one or more updates" +} -$job_args = Parse-Args $args $true +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 -# set the log_path for the global log function we injected earlier -$log_path = $job_args['log_path'] +Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)" -Log-Forensics - -Write-DebugLog "Starting scheduled job with args: $($job_args | Out-String -Width 300)" - -# pass the common code as job_init so it'll be injected into the scheduled job script -$sjo = RunAsScheduledJob -job_init $common_inject -job_body $job_body -job_name ansible-win-updates -job_arg_list $job_args - -Write-DebugLog "Scheduled job completed with output: $($sjo.Output | Out-String -Width 300)" - -Exit-Json $sjo.Output \ No newline at end of file +Exit-Json $result diff --git a/lib/ansible/modules/windows/win_updates.py b/lib/ansible/modules/windows/win_updates.py index 6f668ecb74..370fd66dc6 100644 --- a/lib/ansible/modules/windows/win_updates.py +++ b/lib/ansible/modules/windows/win_updates.py @@ -72,25 +72,22 @@ notes: - C(win_updates) does not manage reboots, but will signal when a reboot is required with the reboot_required return value. - C(win_updates) can take a significant amount of time to complete (hours, in some cases). Performance depends on many factors, including OS version, number of updates, system load, and update server load. -- C(win_updates) runs the module as a scheduled task, this task is set to start and continue to run even if the Windows host - swaps to battery power. This behaviour was changed from Ansible 2.4, before this the scheduled task would fail to start on - battery power. ''' EXAMPLES = r''' -# Install all security, critical, and rollup updates -- win_updates: +- name: Install all security, critical, and rollup updates + win_updates: category_names: - SecurityUpdates - CriticalUpdates - UpdateRollups -# Install only security updates -- win_updates: +- name: Install only security updates + win_updates: category_names: SecurityUpdates -# Search-only, return list of found updates (if any), log to c:\ansible_wu.txt -- win_updates: +- name: Search-only, return list of found updates (if any), log to c:\ansible_wu.txt + win_updates: category_names: SecurityUpdates state: searched log_path: c:\ansible_wu.txt diff --git a/test/integration/targets/win_updates/aliases b/test/integration/targets/win_updates/aliases new file mode 100644 index 0000000000..c6d6198167 --- /dev/null +++ b/test/integration/targets/win_updates/aliases @@ -0,0 +1 @@ +windows/ci/group3 diff --git a/test/integration/targets/win_updates/defaults/main.yml b/test/integration/targets/win_updates/defaults/main.yml new file mode 100644 index 0000000000..4946ccb037 --- /dev/null +++ b/test/integration/targets/win_updates/defaults/main.yml @@ -0,0 +1 @@ +win_updates_dir: '{{win_output_dir}}\win_updates' diff --git a/test/integration/targets/win_updates/tasks/main.yml b/test/integration/targets/win_updates/tasks/main.yml new file mode 100644 index 0000000000..182090e98a --- /dev/null +++ b/test/integration/targets/win_updates/tasks/main.yml @@ -0,0 +1,26 @@ +--- +- name: ensure test folder exists + win_file: + path: '{{win_updates_dir}}' + state: directory + +- name: ensure WUA service is running + win_service: + name: wuauserv + state: started + start_mode: manual + +- block: + - include_tasks: tests.yml + + always: + - name: ensure test folder is deleted + win_file: + path: '{{win_updates_dir}}' + state: absent + + - name: ensure WUA service is running + win_service: + name: wuauserv + state: started + start_mode: manual diff --git a/test/integration/targets/win_updates/tasks/tests.yml b/test/integration/targets/win_updates/tasks/tests.yml new file mode 100644 index 0000000000..106f6261c2 --- /dev/null +++ b/test/integration/targets/win_updates/tasks/tests.yml @@ -0,0 +1,104 @@ +--- +- name: expect failure when state is not a valid option + win_updates: + state: invalid + register: invalid_state + failed_when: "invalid_state.msg != 'Get-AnsibleParam: Argument state needs to be one of installed,searched but was invalid.'" + +- name: expect failure with invalid category name + win_updates: + state: searched + category_names: + - Invalid + register: invalid_category_name + failed_when: invalid_category_name.msg != 'Unknown category_name Invalid, must be one of (Application,Connectors,CriticalUpdates,DefinitionUpdates,DeveloperKits,FeaturePacks,Guidance,SecurityUpdates,ServicePacks,Tools,UpdateRollups,Updates)' + +- name: ensure log file not present before tests + win_file: + path: '{{win_updates_dir}}/update.log' + state: absent + +- name: search for updates without log output + win_updates: + state: searched + category_names: + - CriticalUpdates + register: update_search_without_log + +- name: get stat of update without log file + win_stat: + path: '{{win_updates_dir}}/update.log' + register: update_search_without_log_actual + +- name: assert search for updates without log output + assert: + that: + - not update_search_without_log is changed + - update_search_without_log.reboot_required == False + - update_search_without_log.updates is defined + - update_search_without_log.installed_update_count is defined + - update_search_without_log.found_update_count is defined + - update_search_without_log_actual.stat.exists == False + +- name: search for updates with log output (check) + win_updates: + state: searched + category_names: + - CriticalUpdates + log_path: '{{win_updates_dir}}/update.log' + register: update_search_with_log_check + check_mode: yes + +- name: get stat of update log file (check) + win_stat: + path: '{{win_updates_dir}}/update.log' + register: update_search_with_log_check_actual + +- name: assert search for updates with log output + assert: + that: + - not update_search_with_log_check is changed + - update_search_with_log_check.reboot_required == False + - update_search_with_log_check.updates is defined + - update_search_with_log_check.installed_update_count is defined + - 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 + win_updates: + state: searched + category_names: + - CriticalUpdates + log_path: '{{win_updates_dir}}/update.log' + register: update_search_with_log + +- name: get stat of update log file + win_stat: + path: '{{win_updates_dir}}/update.log' + register: update_search_with_log_actual + +- name: assert search for updates with log output + assert: + that: + - not update_search_with_log is changed + - update_search_with_log.reboot_required == False + - update_search_with_log.updates is defined + - update_search_with_log.installed_update_count is defined + - update_search_with_log.found_update_count is defined + - update_search_with_log_actual.stat.exists + +- name: ensure WUA service is stopped for tests + win_service: + name: wuauserv + state: stopped + start_mode: disabled + +- name: expect failed when running with stopped WUA service + win_updates: + state: searched + category_names: + - CriticalUpdates + register: update_service_stopped_failed + failed_when: + - "'Failed to search for updates with criteria' not in update_service_stopped_failed.msg" + - "'The service cannot be started' not in update_service_stopped_failed.msg"