1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00

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
This commit is contained in:
Jordan Borean 2018-05-24 06:21:01 +10:00 committed by Matt Davis
parent dff662fa0f
commit 457bccf540
6 changed files with 685 additions and 325 deletions

View file

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

View file

@ -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)"
}
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
}
$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
}
}
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
}
$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 = @{}
}
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)"
Write-DebugLog -msg "Creating Windows Update session..."
try {
$update.AcceptEula()
$session = New-Object -ComObject Microsoft.Update.Session
} catch {
Fail-Json -obj $result -message "Failed to accept EULA for update $($update_info.id) - $($update_info.title)"
$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
}
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
}
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 ($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"
}
}
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)"
}
$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()
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()
}
$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
}
}
if ($update.IsHidden) {
Write-DebugLog -msg "Skipping hidden update $($update_info.title)"
continue
}
Write-DebugLog -msg "Native job completed with output: $($result | Out-String -Width 300)"
Write-DebugLog -msg "Adding update $($update_info.id) - $($update_info.title)"
$updates_to_install.Add($update) > $null
$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"
}
Write-DebugLog -msg "No reboot is pending..."
} else {
# no updates to install exit here
Exit-Json -obj $result
}
Function Start-AsScheduledTask($common_functions, $script) {
$job_name = "ansible-win-updates"
Remove-ScheduledJob -name $job_name
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
$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 "Creating downloader object..."
try {
$dl = $session.CreateUpdateDownloader()
} catch {
Fail-Json -obj $result -message "Failed to create downloader object: $($_.Exception.Message)"
Write-DebugLog -msg "Registering scheduled job with args $($job_args | Out-String -Width 300)"
$scheduled_job = Register-ScheduledJob @job_args
# 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++
}
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 {
Fail-Json -obj $result -message "Failed to create Update Installer object: $($_.Exception.Message)"
}
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)"
}
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"
if ($job.Output -eq $null -or -not $job.Output.Keys.Contains('job_output')) {
$ret.Output = @{failed = $true; msg = "job output was lost"}
} 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"
$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)"
}
Write-DebugLog -msg "Scheduled job completed with output: $($re.Output | Out-String -Width 300)"
return $ret.Output
}
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
# source the common code into the current scope so we can call it
. $common_functions
if ($update_fail_count -gt 0) {
Fail-Json -obj $result -msg "Failed to install one or more updates"
<# 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;
* 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 {
(New-Object -ComObject Microsoft.Update.Session).CreateUpdateInstaller().IsBusy > $null
$wua_available = $true
} catch {
$wua_available = $false
}
Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)"
Exit-Json $result
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
}
Exit-Json -obj $result

View file

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

View file

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

View file

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

View file

@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
# (c) 2018, Jordan Borean <jborean@redhat.com>
# 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