From 29a80d35554edb004e24cd629a45baa2efde15f4 Mon Sep 17 00:00:00 2001 From: nitzmahone Date: Fri, 21 Aug 2015 09:49:36 -0700 Subject: [PATCH] win_updates rewrite for 2.0 uses scheduled job to run under a local token (required for WU client) supports check mode no external PS module deps --- windows/win_updates.ps1 | 438 +++++++++++++++++++++++++++++++++++----- windows/win_updates.py | 132 +++++++++--- 2 files changed, 491 insertions(+), 79 deletions(-) diff --git a/windows/win_updates.ps1 b/windows/win_updates.ps1 index 92c1b93e1f8..d790aec6a29 100644 --- a/windows/win_updates.ps1 +++ b/windows/win_updates.ps1 @@ -1,7 +1,7 @@ #!powershell # This file is part of Ansible # -# Copyright 2014, Trond Hindenes +# Copyright 2015, Matt Davis # # Ansible is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -19,68 +19,400 @@ # WANT_JSON # POWERSHELL_COMMON -function Write-Log -{ - param - ( - [parameter(mandatory=$false)] - [System.String] - $message - ) +$ErrorActionPreference = "Stop" +$FormatEnumerationLimit = -1 # prevent out-string et al from truncating collection dumps - $date = get-date -format 'yyyy-MM-dd hh:mm:ss.zz' +<# Most of the Windows Update Agent API will not run under a remote token, +which a remote WinRM session always has. win_updates uses the Task Scheduler +to run the bulk of the update functionality under a local token. Powershell's +Scheduled-Job capability provides a decent abstraction over the Task Scheduler +and handles marshaling Powershell args in and output/errors/etc back. The +module schedules a single job that executes all interactions with the Update +Agent API, then waits for completion. A significant amount of hassle is +involved to ensure that only one of these jobs is running at a time, and to +clean up the various error conditions that can occur. #> - Write-Host "$date $message" +# define the ScriptBlock that will be passed to Register-ScheduledJob +$job_body = { + Param( + [hashtable]$boundparms=@{}, + [Object[]]$unboundargs=$() + ) - Out-File -InputObject "$date $message" -FilePath $global:LoggingFile -Append + Set-StrictMode -Version 2 + + $ErrorActionPreference = "Stop" + $DebugPreference = "Continue" + $FormatEnumerationLimit = -1 # prevent out-string et al from truncating collection dumps + + # set this as a global for the Write-DebugLog function + $log_path = $boundparms['log_path'] + + Write-DebugLog "Scheduled job started with boundparms $($boundparms | out-string) and unboundargs $($unboundargs | out-string)" + + # FUTURE: elevate this to module arg validation once we have it + Function MapCategoryNameToGuid { + Param([string] $category_name) + + $category_guid = switch -exact ($category_name) { + # as documented by TechNet @ https://technet.microsoft.com/en-us/library/ff730937.aspx + "Application" {"5C9376AB-8CE6-464A-B136-22113DD69801"} + "Connectors" {"434DE588-ED14-48F5-8EED-A15E09A991F6"} + "CriticalUpdates" {"E6CF1350-C01B-414D-A61F-263D14D133B4"} + "DefinitionUpdates" {"E0789628-CE08-4437-BE74-2495B842F43B"} + "DeveloperKits" {"E140075D-8433-45C3-AD87-E72345B36078"} + "FeaturePacks" {"B54E7D24-7ADD-428F-8B75-90A396FA584F"} + "Guidance" {"9511D615-35B2-47BB-927F-F73D8E9260BB"} + "SecurityUpdates" {"0FA1201D-4330-4FA8-8AE9-B877473B6441"} + "ServicePacks" {"68C5B0A3-D1A6-4553-AE49-01D3A7827828"} + "Tools" {"B4832BD8-E735-4761-8DAF-37F882276DAB"} + "UpdateRollups" {"28BC880E-0592-4CBF-8F95-C79B17911D5F"} + "Updates" {"CD5FFD1E-E932-4E3A-BF74-18BF0B1BBD83"} + default { throw "Unknown category_name $category_name, must be one of (Application,Connectors,CriticalUpdates,DefinitionUpdates,DeveloperKits,FeaturePacks,Guidance,SecurityUpdates,ServicePacks,Tools,UpdateRollups,Updates)" } + } + + return $category_guid + } + + Function DoWindowsUpdate { + 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 | % { MapCategoryNameToGUID $_ } + + $update_status = @{ changed = $false } + + Write-DebugLog "Creating Windows Update session..." + $session = New-Object -ComObject Microsoft.Update.Session + + Write-DebugLog "Create Windows Update searcher..." + $searcher = $session.CreateUpdateSearcher() + + # OR is only allowed at the top-level, so we have to repeat base criteria inside + # FUTURE: change this to client-side filtered? + $criteriabase = "IsInstalled = 0" + $criteria_list = $category_guids | % { "($criteriabase AND CategoryIDs contains '$_')" } + + $criteria = [string]::Join(" OR ", $criteria_list) + + Write-DebugLog "Search criteria: $criteria" + + Write-DebugLog "Searching for updates to install in category IDs $category_guids..." + $searchresult = $searcher.Search($criteria) + + Write-DebugLog "Creating update collection..." + + $updates_to_install = New-Object -ComObject Microsoft.Update.UpdateColl + + Write-DebugLog "Found $($searchresult.Updates.Count) updates" + + $update_status.updates = @{ } + + # FUTURE: add further filtering options + foreach($update in $searchresult.Updates) { + if(-Not $update.EulaAccepted) { + Write-DebugLog "Accepting EULA for $($update.Identity.UpdateID)" + $update.AcceptEula() + } + + 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 { + # job system adds a bunch of cruft to top-level dict, so we have to send a sub-dict + return @{ job_output = DoWindowsUpdate @boundparms } + } + Catch { + $excep = $_ + Write-DebugLog "Fatal exception: $($excep.Exception.Message) at $($excep.ScriptStackTrace)" + return @{ job_output = @{ failed=$true;error=$excep.Exception.Message;location=$excep.ScriptStackTrace } } + } } -$params = Parse-Args $args; -$result = New-Object PSObject; -Set-Attr $result "changed" $false; +Function DestroyScheduledJob { + Param([string] $job_name) + + # find a scheduled job with the same name (should normally fail) + $schedjob = Get-ScheduledJob -Name $job_name -ErrorAction SilentlyContinue + + # nuke it if it's there + If($schedjob -ne $null) { + Write-DebugLog "ScheduledJob $job_name exists, ensuring it's not running..." + # can't manage jobs across sessions, so we have to resort to the Task Scheduler script object to kill running jobs + $schedserv = New-Object -ComObject Schedule.Service + Write-DebugLog "Connecting to scheduler service..." + $schedserv.Connect() + Write-DebugLog "Getting running tasks named $job_name" + $running_tasks = @($schedserv.GetRunningTasks(0) | Where-Object { $_.Name -eq $job_name }) + + Foreach($task_to_stop in $running_tasks) { + Write-DebugLog "Stopping running task $($task_to_stop.InstanceId)..." + $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(($params.logPath).Length -gt 0) { - $global:LoggingFile = $params.logPath -} else { - $global:LoggingFile = "c:\ansible-playbook.log" -} -if ($params.category) { - $category = $params.category -} else { - $category = "critical" } -$installed_prior = get-wulist -isinstalled | foreach { $_.KBArticleIDs } -set-attr $result "updates_already_present" $installed_prior +Function RunAsScheduledJob { + Param([scriptblock] $job_body, [string] $job_name, [scriptblock] $job_init, [Object[]] $job_arg_list=@()) -write-log "Looking for updates in '$category'" -set-attr $result "updates_category" $category -$to_install = get-wulist -category $category -$installed = @() -foreach ($u in $to_install) { - $kb = $u.KBArticleIDs - write-log "Installing $kb - $($u.Title)" - $install_result = get-wuinstall -KBArticleID $u.KBArticleIDs -acceptall -ignorereboot - Set-Attr $result "updates_installed_KB$kb" $u.Title - $installed += $kb -} -write-log "Installed: $($installed.count)" -set-attr $result "updates_installed" $installed -set-attr $result "updates_installed_count" $installed.count -$result.changed = $installed.count -gt 0 + DestroyScheduledJob -job_name $job_name -$installed_afterwards = get-wulist -isinstalled | foreach { $_.KBArticleIDs } -set-attr $result "updates_installed_afterwards" $installed_afterwards + $rsj_args = @{ + ScriptBlock = $job_body + Name = $job_name + ArgumentList = $job_arg_list + ErrorAction = "Stop" + ScheduledJobOption = @{ RunElevated=$True } + } -$reboot_needed = Get-WURebootStatus -write-log $reboot_needed -if ($reboot_needed -match "not") { - write-log "Reboot not required" -} else { - write-log "Reboot required" - Set-Attr $result "updates_reboot_needed" $true - $result.changed = $true + 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.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 $job.Output.job_output -eq $null) -and $sw.ElapsedMilliseconds -lt 15000) { + Write-DebugLog "Waiting for job output to be non-null..." + 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 $job.Output.job_output -eq $null) { + $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 } -Set-Attr $result "updates_success" "true" -Exit-Json $result; +Function Log-Forensics { + Write-DebugLog "Arguments: $job_args | out-string" + Write-DebugLog "OS Version: $([environment]::OSVersion.Version | out-string)" + Write-DebugLog "Running as user: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)" + # FUTURE: log auth method (kerb, password, etc) +} + +# code shared between the scheduled job and the host script +$common_inject = { + # FUTURE: capture all to a list, dump on error + Function Write-DebugLog { + Param( + [string]$msg + ) + + $DebugPreference = "Continue" + $ErrorActionPreference = "Continue" + $date_str = Get-Date -Format u + $msg = "$date_str $msg" + + Write-Debug $msg + + if($log_path -ne $null) { + Add-Content $log_path $msg + } + } +} + +# source the common code into the current scope so we can call it +. $common_inject + +$parsed_args = Parse-Args $args $true +# grr, why use PSCustomObject for args instead of just native hashtable? +$parsed_args.psobject.properties | foreach -begin {$job_args=@{}} -process {$job_args."$($_.Name)" = $_.Value} -end {$job_args} + +# set the log_path for the global log function we injected earlier +$log_path = $job_args.log_path + +Log-Forensics + +Write-DebugLog "Starting scheduled job with args: $($job_args | Out-String -Width 300)" + +# pass the common code as job_init so it'll be injected into the scheduled job script +$sjo = RunAsScheduledJob -job_init $common_inject -job_body $job_body -job_name ansible-win-updates -job_arg_list $job_args + +Write-DebugLog "Scheduled job completed with output: $($sjo.Output | Out-String -Width 300)" + +Exit-Json $sjo.Output \ No newline at end of file diff --git a/windows/win_updates.py b/windows/win_updates.py index 13c57f2b6d1..84d7d54c311 100644 --- a/windows/win_updates.py +++ b/windows/win_updates.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# (c) 2014, Peter Mounce +# (c) 2015, Matt Davis # # This file is part of Ansible # @@ -24,34 +24,114 @@ DOCUMENTATION = ''' --- module: win_updates -version_added: "1.9" -short_description: Lists / Installs windows updates +version_added: "2.0" +short_description: Download and install Windows updates description: - - Installs windows updates using PSWindowsUpdate (http://gallery.technet.microsoft.com/scriptcenter/2d191bcd-3308-4edd-9de2-88dff796b0bc). - - PSWindowsUpdate needs to be installed first - use win_chocolatey. + - Searches, downloads, and installs Windows updates synchronously by automating the Windows Update client options: - category: - description: - - Which category to install updates from - required: false - default: critical - choices: - - critical - - security - - (anything that is a valid update category) - default: critical - aliases: [] - logPath: - description: - - Where to log command output to - required: false - default: c:\\ansible-playbook.log - aliases: [] -author: "Peter Mounce (@petemounce)" + category_names: + description: + - A scalar or list of categories to install updates from + required: false + default: ["CriticalUpdates","SecurityUpdates","UpdateRollups"] + choices: + - Application + - Connectors + - CriticalUpdates + - DefinitionUpdates + - DeveloperKits + - FeaturePacks + - Guidance + - SecurityUpdates + - ServicePacks + - Tools + - UpdateRollups + - Updates + state: + description: + - Controls whether found updates are returned as a list or actually installed. + - This module also supports Ansible check mode, which has the same effect as setting state=searched + required: false + default: installed + choices: + - installed + - searched + log_path: + description: + - If set, win_updates will append update progress to the specified file. The directory must already exist. + required: false +author: "Matt Davis (@mattdavispdx)" +notes: +- win_updates must be run by a user with membership in the local Administrators group +- win_updates will use the default update service configured for the machine (Windows Update, Microsoft Update, WSUS, etc) +- win_updates does not manage reboots, but will signal when a reboot is required with the reboot_required return value. +- 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. ''' EXAMPLES = ''' - # Install updates from security category - win_updates: - category: security + # Install all security, critical, and rollup updates + win_updates: + category_names: ['SecurityUpdates','CriticalUpdates','UpdateRollups'] + + # Install only security updates + win_updates: category_names=SecurityUpdates + + # Search-only, return list of found updates (if any), log to c:\ansible_wu.txt + win_updates: category_names=SecurityUpdates status=searched log_path=c:/ansible_wu.txt +''' + +RETURN = ''' +reboot_required: + description: True when the target server requires a reboot to complete updates (no further updates can be installed until after a reboot) + returned: success + type: boolean + sample: True + +updates: + description: List of updates that were found/installed + returned: success + type: dictionary + sample: + contains: + title: + description: Display name + returned: always + type: string + sample: "Security Update for Windows Server 2012 R2 (KB3004365)" + kb: + description: A list of KB article IDs that apply to the update + returned: always + type: list of strings + sample: [ '3004365' ] + id: + description: Internal Windows Update GUID + returned: always + type: string (guid) + sample: "fb95c1c8-de23-4089-ae29-fd3351d55421" + installed: + description: Was the update successfully installed + returned: always + type: boolean + sample: True + failure_hresult_code: + description: The HRESULT code from a failed update + returned: on install failure + type: boolean + sample: 2147942402 + +found_update_count: + description: The number of updates found needing to be applied + returned: success + type: int + sample: 3 +installed_update_count: + description: The number of updates successfully installed + returned: success + type: int + sample: 2 +failed_update_count: + description: The number of updates that failed to install + returned: always + type: int + sample: 0 '''