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:
parent
dff662fa0f
commit
457bccf540
6 changed files with 685 additions and 325 deletions
|
@ -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
|
|
@ -7,13 +7,6 @@
|
||||||
|
|
||||||
#Requires -Module Ansible.ModuleUtils.Legacy
|
#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"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
$params = Parse-Args -arguments $args -supports_check_mode $true
|
$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"
|
$blacklist = Get-AnsibleParam -obj $params -name "blacklist" -type "list"
|
||||||
$whitelist = Get-AnsibleParam -obj $params -name "whitelist" -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) {
|
Function Get-CategoryGuid($category_name) {
|
||||||
$guid = switch -exact ($category_name) {
|
$guid = switch -exact ($category_name) {
|
||||||
"Application" {"5C9376AB-8CE6-464A-B136-22113DD69801"}
|
"Application" {"5C9376AB-8CE6-464A-B136-22113DD69801"}
|
||||||
|
@ -55,270 +32,523 @@ Function Get-CategoryGuid($category_name) {
|
||||||
"Tools" {"B4832BD8-E735-4761-8DAF-37F882276DAB"}
|
"Tools" {"B4832BD8-E735-4761-8DAF-37F882276DAB"}
|
||||||
"UpdateRollups" {"28BC880E-0592-4CBF-8F95-C79B17911D5F"}
|
"UpdateRollups" {"28BC880E-0592-4CBF-8F95-C79B17911D5F"}
|
||||||
"Updates" {"CD5FFD1E-E932-4E3A-BF74-18BF0B1BBD83"}
|
"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
|
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 $_ }
|
$category_guids = $category_names | ForEach-Object { Get-CategoryGuid -category_name $_ }
|
||||||
|
|
||||||
Write-DebugLog -msg "Creating Windows Update session..."
|
$common_functions = {
|
||||||
try {
|
Function Write-DebugLog($msg) {
|
||||||
$session = New-Object -ComObject Microsoft.Update.Session
|
$date_str = Get-Date -Format u
|
||||||
} catch {
|
$msg = "$date_str $msg"
|
||||||
Fail-Json -obj $result -message "Failed to create Microsoft.Update.Session COM object: $($_.Exception.Message)"
|
|
||||||
}
|
Write-Debug -Message $msg
|
||||||
|
if ($log_path -ne $null -and (-not $check_mode)) {
|
||||||
Write-DebugLog -msg "Create Windows Update searcher..."
|
Add-Content -Path $log_path -Value $msg
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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) {
|
$update_script_block = {
|
||||||
$kb_match = $false
|
Param(
|
||||||
foreach ($kb in $update_info.kb) {
|
[hashtable]$arguments
|
||||||
if ("KB$kb" -imatch $blacklist_entry) {
|
)
|
||||||
$kb_match = $true
|
|
||||||
}
|
$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"
|
Write-DebugLog -msg "Creating Windows Update session..."
|
||||||
$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 {
|
try {
|
||||||
$update.AcceptEula()
|
$session = New-Object -ComObject Microsoft.Update.Session
|
||||||
} catch {
|
} 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 "Native job completed with output: $($result | Out-String -Width 300)"
|
||||||
Write-DebugLog -msg "Skipping hidden update $($update_info.title)"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-DebugLog -msg "Adding update $($update_info.id) - $($update_info.title)"
|
return ,$result
|
||||||
$updates_to_install.Add($update) > $null
|
|
||||||
|
|
||||||
$result.updates[$update_info.id] = $update_info
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
if ($scheduled_job -ne $null) {
|
||||||
$result.reboot_required = Get-RebootStatus
|
Write-DebugLog -msg "Scheduled Job $name exists, ensuring it is not running..."
|
||||||
$result.found_update_count = $updates_to_install.Count
|
$scheduler = New-Object -ComObject Schedule.Service
|
||||||
$result.installed_update_count = 0
|
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
|
foreach ($task_to_stop in $running_tasks) {
|
||||||
if ($check_mode -or $state -eq "searched") {
|
Write-DebugLog -msg "Stopping running task $($task_to_stop.InstanceGuid)..."
|
||||||
Write-DebugLog -msg "Check mode: exiting..."
|
$task_to_stop.Stop()
|
||||||
Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)"
|
}
|
||||||
|
|
||||||
if ($updates_to_install.Count -gt 0 -and ($state -ne "searched")) {
|
<# FUTURE: add a global waithandle for this to release any other waiters. Wait-Job
|
||||||
$result.changed = $true
|
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) {
|
Function Start-AsScheduledTask($common_functions, $script) {
|
||||||
if ($result.reboot_required) {
|
$job_name = "ansible-win-updates"
|
||||||
Write-DebugLog -msg "FATAL: A reboot is required before more updates can be installed"
|
Remove-ScheduledJob -name $job_name
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-DebugLog -msg "Downloading updates..."
|
$job_args = @{
|
||||||
$update_index = 1
|
ScriptBlock = $script
|
||||||
foreach ($update in $updates_to_install) {
|
Name = $job_name
|
||||||
$update_number = "($update_index of $($updates_to_install.Count))"
|
ArgumentList = @(
|
||||||
if ($update.IsDownloaded) {
|
@{
|
||||||
Write-DebugLog -msg "Update $update_number $($update.Identity.UpdateId) already downloaded, skipping..."
|
category_guids = $category_guids
|
||||||
$update_index++
|
log_path = $log_path
|
||||||
continue
|
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..."
|
Write-DebugLog -msg "Registering scheduled job with args $($job_args | Out-String -Width 300)"
|
||||||
try {
|
$scheduled_job = Register-ScheduledJob @job_args
|
||||||
$dl = $session.CreateUpdateDownloader()
|
|
||||||
} catch {
|
# RunAsTask isn't available in PS3 - fall back to a 2s future trigger
|
||||||
Fail-Json -obj $result -message "Failed to create downloader object: $($_.Exception.Message)"
|
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..."
|
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
try {
|
$job = $null
|
||||||
$dl.Updates = New-Object -ComObject Microsoft.Update.UpdateColl
|
|
||||||
} catch {
|
Write-DebugLog -msg "Waiting for job completion..."
|
||||||
Fail-Json -obj $result -message "Failed to create download collection object: $($_.Exception.Message)"
|
|
||||||
|
# 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)"
|
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
$dl.Updates.Add($update) > $null
|
|
||||||
|
|
||||||
Write-DebugLog -msg "Downloading $update_number $($update.Identity.UpdateId)"
|
# NB: output from scheduled jobs is delayed after completion (including the sub-objects after the primary Output object is available)
|
||||||
try {
|
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) {
|
||||||
$download_result = $dl.Download()
|
Write-DebugLog -msg "Waiting for job output to populate..."
|
||||||
} catch {
|
Start-Sleep -Milliseconds 500
|
||||||
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)"
|
# NB: fallthru on both timeout and success
|
||||||
# FUTURE: configurable download retry
|
$ret = @{
|
||||||
if ($download_result.ResultCode -ne 2) { # OperationResultCode orcSucceeded
|
ErrorOutput = $job.Error
|
||||||
Fail-Json -obj $result -message "Failed to download update $update_number $($update.Identity.UpdateId) - $($update.Title): Download Result $($download_result.ResultCode)"
|
WarningOutput = $job.Warning
|
||||||
|
VerboseOutput = $job.Verbose
|
||||||
|
DebugOutput = $job.Debug
|
||||||
}
|
}
|
||||||
|
|
||||||
$result.changed = $true
|
if ($job.Output -eq $null -or -not $job.Output.Keys.Contains('job_output')) {
|
||||||
$update_index++
|
$ret.Output = @{failed = $true; msg = "job output was lost"}
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
} else {
|
} else {
|
||||||
$update_fail_count++
|
$ret.Output = $job.Output.job_output # sub-object returned, can only be accessed as a property for some reason
|
||||||
$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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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..."
|
# source the common code into the current scope so we can call it
|
||||||
$result.reboot_required = Get-RebootStatus
|
. $common_functions
|
||||||
$result.installed_update_count = $update_success_count
|
|
||||||
$result.failed_update_count = $update_fail_count
|
|
||||||
|
|
||||||
if ($update_fail_count -gt 0) {
|
<# Most of the Windows Update Agent API will not run under a remote token,
|
||||||
Fail-Json -obj $result -msg "Failed to install one or more updates"
|
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)"
|
if ($wua_available) {
|
||||||
|
Write-DebugLog -msg "WUA is available in current logon process, running natively"
|
||||||
Exit-Json $result
|
$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
|
||||||
|
|
|
@ -85,6 +85,18 @@ options:
|
||||||
I(category_names). It will not force the module to install an update
|
I(category_names). It will not force the module to install an update
|
||||||
if it was not in the category specified.
|
if it was not in the category specified.
|
||||||
version_added: '2.5'
|
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:
|
author:
|
||||||
- Matt Davis (@nitzmahone)
|
- Matt Davis (@nitzmahone)
|
||||||
notes:
|
notes:
|
||||||
|
@ -100,16 +112,17 @@ notes:
|
||||||
'''
|
'''
|
||||||
|
|
||||||
EXAMPLES = r'''
|
EXAMPLES = r'''
|
||||||
- name: Install all security, critical, and rollup updates
|
- name: Install all security, critical, and rollup updates without a scheduled task
|
||||||
win_updates:
|
win_updates:
|
||||||
category_names:
|
category_names:
|
||||||
- SecurityUpdates
|
- SecurityUpdates
|
||||||
- CriticalUpdates
|
- CriticalUpdates
|
||||||
- UpdateRollups
|
- UpdateRollups
|
||||||
|
|
||||||
- name: Install only security updates
|
- name: Install only security updates as a scheduled task for Server 2008
|
||||||
win_updates:
|
win_updates:
|
||||||
category_names: SecurityUpdates
|
category_names: SecurityUpdates
|
||||||
|
use_scheduled_task: yes
|
||||||
|
|
||||||
- name: Search-only, return list of found updates (if any), log to C:\ansible_wu.txt
|
- name: Search-only, return list of found updates (if any), log to C:\ansible_wu.txt
|
||||||
win_updates:
|
win_updates:
|
||||||
|
@ -139,37 +152,6 @@ EXAMPLES = r'''
|
||||||
blacklist:
|
blacklist:
|
||||||
- Windows Malicious Software Removal Tool for Windows
|
- Windows Malicious Software Removal Tool for Windows
|
||||||
- \d{4}-\d{2} Cumulative Update for Windows Server 2016
|
- \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'''
|
RETURN = r'''
|
||||||
|
|
|
@ -40,15 +40,17 @@ class ActionModule(ActionBase):
|
||||||
raise AnsibleError("Unknown category_name %s, must be one of "
|
raise AnsibleError("Unknown category_name %s, must be one of "
|
||||||
"(%s)" % (name, ','.join(valid_categories)))
|
"(%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")
|
display.vvv("win_updates: running win_updates module")
|
||||||
result = self._execute_module(module_name='win_updates',
|
wrap_async = self._task.async_val
|
||||||
module_args=module_args,
|
result = self._execute_module_with_become(module_name='win_updates',
|
||||||
task_vars=task_vars,
|
module_args=module_args,
|
||||||
wrap_async=self._task.async_val)
|
task_vars=task_vars,
|
||||||
|
wrap_async=wrap_async,
|
||||||
|
use_task=use_task)
|
||||||
return result
|
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")
|
display.vvv("win_updates: rebooting remote host after update install")
|
||||||
reboot_args = {
|
reboot_args = {
|
||||||
'reboot_timeout': reboot_timeout
|
'reboot_timeout': reboot_timeout
|
||||||
|
@ -58,42 +60,35 @@ class ActionModule(ActionBase):
|
||||||
if reboot_result.get('failed', False):
|
if reboot_result.get('failed', False):
|
||||||
raise AnsibleError(reboot_result['msg'])
|
raise AnsibleError(reboot_result['msg'])
|
||||||
|
|
||||||
display.vvv("win_updates: checking WUA is not busy with win_shell "
|
# only run this if the user has specified we can only use scheduled
|
||||||
"command")
|
# tasks, the win_shell command requires become and will be skipped if
|
||||||
# While this always returns False after a reboot it doesn't return a
|
# become isn't available to use
|
||||||
# value until Windows is actually ready and finished installing updates
|
if use_task:
|
||||||
# This needs to run with become as WUA doesn't work over WinRM
|
display.vvv("win_updates: skipping WUA is not busy check as "
|
||||||
# Ignore connection errors as another reboot can happen
|
"use_scheduled_task=True is set")
|
||||||
command = "(New-Object -ComObject Microsoft.Update.Session)." \
|
else:
|
||||||
"CreateUpdateInstaller().IsBusy"
|
display.vvv("win_updates: checking WUA is not busy with win_shell "
|
||||||
shell_module_args = {
|
"command")
|
||||||
'_raw_params': 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
|
try:
|
||||||
# a windows reboot during execution
|
shell_result = self._execute_module_with_become(
|
||||||
orig_become = self._play_context.become
|
module_name='win_shell', module_args=shell_module_args,
|
||||||
orig_become_method = self._play_context.become_method
|
task_vars=task_vars, wrap_async=False, use_task=use_task
|
||||||
orig_become_user = self._play_context.become_user
|
)
|
||||||
if orig_become is None or orig_become is False:
|
display.vvv("win_updates: shell wait results: %s"
|
||||||
self._play_context.become = True
|
% json.dumps(shell_result))
|
||||||
if orig_become_method != 'runas':
|
except Exception as exc:
|
||||||
self._play_context.become_method = 'runas'
|
display.debug("win_updates: Fatal error when running shell "
|
||||||
if orig_become_user is None or 'root':
|
"command, attempting to recover: %s" % to_text(exc))
|
||||||
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
|
|
||||||
|
|
||||||
display.vvv("win_updates: ensure the connection is up and running")
|
display.vvv("win_updates: ensure the connection is up and running")
|
||||||
# in case Windows needs to reboot again after the updates, we wait for
|
# in case Windows needs to reboot again after the updates, we wait for
|
||||||
|
@ -129,6 +124,32 @@ class ActionModule(ActionBase):
|
||||||
dict_var.update(new)
|
dict_var.update(new)
|
||||||
return dict_var
|
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):
|
def run(self, tmp=None, task_vars=None):
|
||||||
self._supports_check_mode = True
|
self._supports_check_mode = True
|
||||||
self._supports_async = True
|
self._supports_async = True
|
||||||
|
@ -148,6 +169,8 @@ class ActionModule(ActionBase):
|
||||||
reboot = self._task.args.get('reboot', False)
|
reboot = self._task.args.get('reboot', False)
|
||||||
reboot_timeout = self._task.args.get('reboot_timeout',
|
reboot_timeout = self._task.args.get('reboot_timeout',
|
||||||
self.DEFAULT_REBOOT_TIMEOUT)
|
self.DEFAULT_REBOOT_TIMEOUT)
|
||||||
|
use_task = boolean(self._task.args.get('use_scheduled_task', False),
|
||||||
|
strict=False)
|
||||||
|
|
||||||
# Validate the options
|
# Validate the options
|
||||||
try:
|
try:
|
||||||
|
@ -184,7 +207,7 @@ class ActionModule(ActionBase):
|
||||||
new_module_args = self._task.args.copy()
|
new_module_args = self._task.args.copy()
|
||||||
new_module_args.pop('reboot', None)
|
new_module_args.pop('reboot', None)
|
||||||
new_module_args.pop('reboot_timeout', 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
|
# if the module failed to run at all then changed won't be populated
|
||||||
# so we just return the result as is
|
# so we just return the result as is
|
||||||
|
@ -232,7 +255,8 @@ class ActionModule(ActionBase):
|
||||||
"update install"
|
"update install"
|
||||||
try:
|
try:
|
||||||
changed = True
|
changed = True
|
||||||
self._reboot_server(task_vars, reboot_timeout)
|
self._reboot_server(task_vars, reboot_timeout,
|
||||||
|
use_task)
|
||||||
except AnsibleError as exc:
|
except AnsibleError as exc:
|
||||||
result['failed'] = True
|
result['failed'] = True
|
||||||
result['msg'] = "Failed to reboot remote host when " \
|
result['msg'] = "Failed to reboot remote host when " \
|
||||||
|
@ -242,7 +266,8 @@ class ActionModule(ActionBase):
|
||||||
|
|
||||||
result.pop('msg', None)
|
result.pop('msg', None)
|
||||||
# rerun the win_updates module after the reboot is complete
|
# 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):
|
if result.get('failed', False):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
@ -63,12 +63,13 @@
|
||||||
- update_search_with_log_check.found_update_count is defined
|
- update_search_with_log_check.found_update_count is defined
|
||||||
- update_search_with_log_check_actual.stat.exists == False
|
- 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:
|
win_updates:
|
||||||
state: searched
|
state: searched
|
||||||
category_names:
|
category_names:
|
||||||
- CriticalUpdates
|
- CriticalUpdates
|
||||||
log_path: '{{win_updates_dir}}/update.log'
|
log_path: '{{win_updates_dir}}/update.log'
|
||||||
|
use_scheduled_task: yes
|
||||||
register: update_search_with_log
|
register: update_search_with_log
|
||||||
|
|
||||||
- name: get stat of update log file
|
- name: get stat of update log file
|
||||||
|
|
120
test/units/plugins/action/test_win_updates.py
Normal file
120
test/units/plugins/action/test_win_updates.py
Normal 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
|
Loading…
Reference in a new issue