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