summaryrefslogtreecommitdiff
path: root/windows/win_updates.ps1
blob: a74e68f36633cc6dee481474ea1661ba4b346016 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
#!powershell
# 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
# POWERSHELL_COMMON

$ErrorActionPreference = "Stop"
$FormatEnumerationLimit = -1 # prevent out-string et al from truncating collection dumps

<# Most of the Windows Update Agent API will not run under a remote token,
which a remote WinRM session always has. win_updates uses the Task Scheduler
to run the bulk of the update functionality under a local token. Powershell's
Scheduled-Job capability provides a decent abstraction over the Task Scheduler
and handles marshaling Powershell args in and output/errors/etc back. The
module schedules a single job that executes all interactions with the Update
Agent API, then waits for completion. A significant amount of hassle is
involved to ensure that only one of these jobs is running at a time, and to
clean up the various error conditions that can occur. #>

# define the ScriptBlock that will be passed to Register-ScheduledJob
$job_body = {
    Param(
    [hashtable]$boundparms=@{},
    [Object[]]$unboundargs=$()
    )

    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()
          }

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

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.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
  }

}

Function RunAsScheduledJob {
  Param([scriptblock] $job_body, [string] $job_name, [scriptblock] $job_init, [Object[]] $job_arg_list=@())

  DestroyScheduledJob -job_name $job_name

  $rsj_args = @{
    ScriptBlock = $job_body
    Name = $job_name
    ArgumentList = $job_arg_list
    ErrorAction = "Stop"
    ScheduledJobOption = @{ RunElevated=$True }
  }

  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 "Arguments: $job_args | out-string"
    Write-DebugLog "OS Version: $([environment]::OSVersion.Version | out-string)"
    Write-DebugLog "Running as user: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
    Write-DebugLog "Powershell version: $($PSVersionTable | out-string)"
    # FUTURE: log auth method (kerb, password, etc)
}

# 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