· 6 years ago · Sep 18, 2019, 10:02 PM
1#!powershell
2
3# Copyright: (c) 2015, Matt Davis <mdavis@rolpdog.com>
4# Copyright: (c) 2017, Ansible Project
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7$category_names = @("CriticalUpdates", "SecurityUpdates", "UpdateRollups")
8$state = "installed"
9$server_selection = "default"
10
11# For backwards compatibility
12Function Get-CategoryMapping ($category_name) {
13 switch -exact ($category_name) {
14 "CriticalUpdates" {return "Critical Updates"}
15 "DefinitionUpdates" {return "Definition Updates"}
16 "DeveloperKits" {return "Developer Kits"}
17 "FeaturePacks" {return "Feature Packs"}
18 "SecurityUpdates" {return "Security Updates"}
19 "ServicePacks" {return "Service Packs"}
20 "UpdateRollups" {return "Update Rollups"}
21 default {return $category_name}
22 }
23}
24
25$category_names = $category_names | ForEach-Object { Get-CategoryMapping -category_name $_ }
26
27$common_functions = {
28 Function Write-DebugLog($msg) {
29 $date_str = Get-Date -Format u
30 $msg = "$date_str $msg"
31
32 Write-Debug -Message $msg
33 if ($null -ne $log_path -and (-not $check_mode)) {
34 Add-Content -Path $log_path -Value $msg
35 }
36 }
37}
38
39$update_script_block = {
40 Param(
41 [hashtable]$arguments
42 )
43
44 $ErrorActionPreference = "Stop"
45 $DebugPreference = "Continue"
46
47 Function Start-Updates {
48 Param(
49 $category_names,
50 $log_path,
51 $state,
52 $blacklist,
53 $whitelist,
54 $server_selection
55 )
56
57 $result = @{
58 changed = $false
59 updates = @{}
60 filtered_updates = @{}
61 }
62
63 Write-DebugLog -msg "Creating Windows Update session..."
64 try {
65 $session = New-Object -ComObject Microsoft.Update.Session
66 } catch {
67 $result.failed = $true
68 $result.msg = "Failed to create Microsoft.Update.Session COM object: $($_.Exception.Message)"
69 return $result
70 }
71
72 Write-DebugLog -msg "Create Windows Update searcher..."
73 try {
74 $searcher = $session.CreateUpdateSearcher()
75 } catch {
76 $result.failed = $true
77 $result.msg = "Failed to create Windows Update search from session: $($_.Exception.Message)"
78 return $result
79 }
80
81 Write-DebugLog -msg "Setting the Windows Update Agent source catalog..."
82 Write-DebugLog -msg "Requested search source is '$($server_selection)'"
83 try {
84 $server_selection_value = switch ($server_selection) {
85 "default" { 0 ; break }
86 "managed_server" { 1 ; break }
87 "windows_update" { 2 ; break }
88 }
89 $searcher.serverselection = $server_selection_value
90 Write-DebugLog -msg "Search source set to '$($server_selection)' (ServerSelection = $($server_selection_value))"
91 }
92 catch {
93 $result.failed = $true
94 $result.msg = "Failed to set Windows Update Agent search source: $($_.Exception.Message)"
95 return $result
96 }
97
98 Write-DebugLog -msg "Searching for updates to install"
99 try {
100 $search_result = $searcher.Search("IsInstalled = 0")
101 } catch {
102 $result.failed = $true
103 $result.msg = "Failed to search for updates: $($_.Exception.Message)"
104 return $result
105 }
106 Write-DebugLog -msg "Found $($search_result.Updates.Count) updates"
107
108 Write-DebugLog -msg "Creating update collection..."
109 try {
110 $updates_to_install = New-Object -ComObject Microsoft.Update.UpdateColl
111 } catch {
112 $result.failed = $true
113 $result.msg = "Failed to create update collection object: $($_.Exception.Message)"
114 return $result
115 }
116
117 foreach ($update in $search_result.Updates) {
118 $update_info = @{
119 title = $update.Title
120 # TODO: pluck the first KB out (since most have just one)?
121 kb = $update.KBArticleIDs
122 id = $update.Identity.UpdateId
123 installed = $false
124 categories = @($update.Categories | ForEach-Object { $_.Name })
125 }
126
127 # validate update again blacklist/whitelist/post_category_names/hidden
128 $whitelist_match = $false
129 foreach ($whitelist_entry in $whitelist) {
130 if ($update_info.title -imatch $whitelist_entry) {
131 $whitelist_match = $true
132 break
133 }
134 foreach ($kb in $update_info.kb) {
135 if ("KB$kb" -imatch $whitelist_entry) {
136 $whitelist_match = $true
137 break
138 }
139 }
140 }
141 if ($whitelist.Length -gt 0 -and -not $whitelist_match) {
142 Write-DebugLog -msg "Skipping update $($update_info.id) - $($update_info.title) as it was not found in the whitelist"
143 $update_info.filtered_reason = "whitelist"
144 $result.filtered_updates[$update_info.id] = $update_info
145 continue
146 }
147
148 $blacklist_match = $false
149 foreach ($blacklist_entry in $blacklist) {
150 if ($update_info.title -imatch $blacklist_entry) {
151 $blacklist_match = $true
152 break
153 }
154 foreach ($kb in $update_info.kb) {
155 if ("KB$kb" -imatch $blacklist_entry) {
156 $blacklist_match = $true
157 break
158 }
159 }
160 }
161 if ($blacklist_match) {
162 Write-DebugLog -msg "Skipping update $($update_info.id) - $($update_info.title) as it was found in the blacklist"
163 $update_info.filtered_reason = "blacklist"
164 $result.filtered_updates[$update_info.id] = $update_info
165 continue
166 }
167
168 if ($update.IsHidden) {
169 Write-DebugLog -msg "Skipping update $($update_info.title) as it was hidden"
170 $update_info.filtered_reason = "skip_hidden"
171 $result.filtered_updates[$update_info.id] = $update_info
172 continue
173 }
174
175 $category_match = $false
176 foreach ($match_cat in $category_names) {
177 if ($update_info.categories -ieq $match_cat) {
178 $category_match = $true
179 break
180 }
181 }
182 if ($category_names.Length -gt 0 -and -not $category_match) {
183 Write-DebugLog -msg "Skipping update $($update_info.id) - $($update_info.title) as it was not found in the category names filter"
184 $update_info.filtered_reason = "category_names"
185 $result.filtered_updates[$update_info.id] = $update_info
186 continue
187 }
188
189 if (-not $update.EulaAccepted) {
190 Write-DebugLog -msg "Accepting EULA for $($update_info.id)"
191 try {
192 $update.AcceptEula()
193 } catch {
194 $result.failed = $true
195 $result.msg = "Failed to accept EULA for update $($update_info.id) - $($update_info.title)"
196 return $result
197 }
198 }
199
200 Write-DebugLog -msg "Adding update $($update_info.id) - $($update_info.title)"
201 $updates_to_install.Add($update) > $null
202
203 $result.updates[$update_info.id] = $update_info
204 }
205
206 Write-DebugLog -msg "Calculating pre-install reboot requirement..."
207
208 # calculate this early for check mode, and to see if we should allow updates to continue
209 $result.reboot_required = (New-Object -ComObject Microsoft.Update.SystemInfo).RebootRequired
210 $result.found_update_count = $updates_to_install.Count
211 $result.installed_update_count = 0
212
213 # Early exit of check mode/state=searched as it cannot do more after this
214 if ($check_mode -or $state -eq "searched") {
215 Write-DebugLog -msg "Check mode: exiting..."
216 Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)"
217
218 if ($updates_to_install.Count -gt 0 -and ($state -ne "searched")) {
219 $result.changed = $true
220 }
221 return $result
222 }
223
224 if ($updates_to_install.Count -gt 0) {
225 if ($result.reboot_required) {
226 Write-DebugLog -msg "FATAL: A reboot is required before more updates can be installed"
227 $result.failed = $true
228 $result.msg = "A reboot is required before more updates can be installed"
229 return $result
230 }
231 Write-DebugLog -msg "No reboot is pending..."
232 } else {
233 # no updates to install exit here
234 return $result
235 }
236
237 Write-DebugLog -msg "Downloading updates..."
238 $update_index = 1
239 foreach ($update in $updates_to_install) {
240 $update_number = "($update_index of $($updates_to_install.Count))"
241 if ($update.IsDownloaded) {
242 Write-DebugLog -msg "Update $update_number $($update.Identity.UpdateId) already downloaded, skipping..."
243 $update_index++
244 continue
245 }
246
247 Write-DebugLog -msg "Creating downloader object..."
248 try {
249 $dl = $session.CreateUpdateDownloader()
250 } catch {
251 $result.failed = $true
252 $result.msg = "Failed to create downloader object: $($_.Exception.Message)"
253 return $result
254 }
255
256 Write-DebugLog -msg "Creating download collection..."
257 try {
258 $dl.Updates = New-Object -ComObject Microsoft.Update.UpdateColl
259 } catch {
260 $result.failed = $true
261 $result.msg = "Failed to create download collection object: $($_.Exception.Message)"
262 return $result
263 }
264
265 Write-DebugLog -msg "Adding update $update_number $($update.Identity.UpdateId)"
266 $dl.Updates.Add($update) > $null
267
268 Write-DebugLog -msg "Downloading $update_number $($update.Identity.UpdateId)"
269 try {
270 $download_result = $dl.Download()
271 } catch {
272 $result.failed = $true
273 $result.msg = "Failed to download update $update_number $($update.Identity.UpdateId) - $($update.Title): $($_.Exception.Message)"
274 return $result
275 }
276
277 Write-DebugLog -msg "Download result code for $update_number $($update.Identity.UpdateId) = $($download_result.ResultCode)"
278 # FUTURE: configurable download retry
279 if ($download_result.ResultCode -ne 2) { # OperationResultCode orcSucceeded
280 $result.failed = $true
281 $result.msg = "Failed to download update $update_number $($update.Identity.UpdateId) - $($update.Title): Download Result $($download_result.ResultCode)"
282 return $result
283 }
284
285 $result.changed = $true
286 $update_index++
287 }
288
289 # Early exit for download-only
290 if ($state -eq "downloaded") {
291 Write-DebugLog -msg "Downloaded $($updates_to_install.Count) updates..."
292 $result.failed = $false
293 $result.msg = "Downloaded $($updates_to_install.Count) updates"
294 return $result
295 }
296
297 Write-DebugLog -msg "Installing updates..."
298 # install as a batch so the reboot manager will suppress intermediate reboots
299
300 Write-DebugLog -msg "Creating installer object..."
301 try {
302 $installer = $session.CreateUpdateInstaller()
303 } catch {
304 $result.failed = $true
305 $result.msg = "Failed to create Update Installer object: $($_.Exception.Message)"
306 return $result
307 }
308
309 Write-DebugLog -msg "Creating install collection..."
310 try {
311 $installer.Updates = New-Object -ComObject Microsoft.Update.UpdateColl
312 } catch {
313 $result.failed = $true
314 $result.msg = "Failed to create Update Collection object: $($_.Exception.Message)"
315 return $result
316 }
317
318 foreach ($update in $updates_to_install) {
319 Write-DebugLog -msg "Adding update $($update.Identity.UpdateID)"
320 $installer.Updates.Add($update) > $null
321 }
322
323 # FUTURE: use BeginInstall w/ progress reporting so we can at least log intermediate install results
324 try {
325 $install_result = $installer.Install()
326 } catch {
327 $result.failed = $true
328 $result.msg = "Failed to install update from Update Collection: $($_.Exception.Message)"
329 return $result
330 }
331
332 $update_success_count = 0
333 $update_fail_count = 0
334
335 # WU result API requires us to index in to get the install results
336 $update_index = 0
337 foreach ($update in $updates_to_install) {
338 $update_number = "($($update_index + 1) of $($updates_to_install.Count))"
339 try {
340 $update_result = $install_result.GetUpdateResult($update_index)
341 } catch {
342 $result.failed = $true
343 $result.msg = "Failed to get update result for update $update_number $($update.Identity.UpdateID) - $($update.Title): $($_.Exception.Message)"
344 return $result
345 }
346 $update_resultcode = $update_result.ResultCode
347 $update_hresult = $update_result.HResult
348
349 $update_index++
350
351 $update_dict = $result.updates[$update.Identity.UpdateID]
352 if ($update_resultcode -eq 2) { # OperationResultCode orcSucceeded
353 $update_success_count++
354 $update_dict.installed = $true
355 Write-DebugLog -msg "Update $update_number $($update.Identity.UpdateID) succeeded"
356 } else {
357 $update_fail_count++
358 $update_dict.installed = $false
359 $update_dict.failed = $true
360 $update_dict.failure_hresult_code = $update_hresult
361 Write-DebugLog -msg "Update $update_number $($update.Identity.UpdateID) failed, resultcode: $update_resultcode, hresult: $update_hresult"
362 }
363 }
364
365 Write-DebugLog -msg "Performing post-install reboot requirement check..."
366 $result.reboot_required = (New-Object -ComObject Microsoft.Update.SystemInfo).RebootRequired
367 $result.installed_update_count = $update_success_count
368 $result.failed_update_count = $update_fail_count
369
370 if ($updates_success_count -gt 0) {
371 $result.changed = $true
372 }
373
374 if ($update_fail_count -gt 0) {
375 $result.failed = $true
376 $result.msg = "Failed to install one or more updates"
377 return $result
378 }
379
380 Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)"
381
382 return $result
383 }
384
385 $check_mode = $arguments.check_mode
386 try {
387 return @{
388 job_output = Start-Updates @arguments
389 }
390 } catch {
391 Write-DebugLog -msg "Fatal exception: $($_.Exception.Message) at $($_.ScriptStackTrace)"
392 return @{
393 job_output = @{
394 failed = $true
395 msg = $_.Exception.Message
396 location = $_.ScriptStackTrace
397 }
398 }
399 }
400}
401
402Function Start-Natively($common_functions, $script) {
403 $runspace_pool = [RunspaceFactory]::CreateRunspacePool()
404 $runspace_pool.Open()
405
406 try {
407 $ps_pipeline = [PowerShell]::Create()
408 $ps_pipeline.RunspacePool = $runspace_pool
409
410 # add the common script functions
411 $ps_pipeline.AddScript($common_functions) > $null
412
413 # add the update script block and required parameters
414 $ps_pipeline.AddStatement().AddScript($script) > $null
415 $ps_pipeline.AddParameter("arguments", @{
416 category_names = $category_names
417 log_path = $log_path
418 state = $state
419 blacklist = $blacklist
420 whitelist = $whitelist
421 check_mode = $check_mode
422 server_selection = $server_selection
423 }) > $null
424
425 $output = $ps_pipeline.Invoke()
426 } finally {
427 $runspace_pool.Close()
428 }
429
430 $result = $output[0].job_output
431 if ($ps_pipeline.HadErrors) {
432 $result.failed = $true
433
434 # if the msg wasn't set, then add a generic error to at least tell the user something
435 if (-not ($result.ContainsKey("msg"))) {
436 $result.msg = "Unknown failure when executing native update script block"
437 $result.errors = $ps_pipeline.Streams.Error
438 }
439 }
440
441 Write-DebugLog -msg "Native job completed with output: $($result | Out-String -Width 300)"
442
443 return ,$result
444}
445
446Function Remove-ScheduledJob($name) {
447 $scheduled_job = Get-ScheduledJob -Name $name -ErrorAction SilentlyContinue
448
449 if ($null -ne $scheduled_job) {
450 Write-DebugLog -msg "Scheduled Job $name exists, ensuring it is not running..."
451 $scheduler = New-Object -ComObject Schedule.Service
452 Write-DebugLog -msg "Connecting to scheduler service..."
453 $scheduler.Connect()
454 Write-DebugLog -msg "Getting running tasks named $name"
455 $running_tasks = @($scheduler.GetRunningTasks(0) | Where-Object { $_.Name -eq $name })
456
457 foreach ($task_to_stop in $running_tasks) {
458 Write-DebugLog -msg "Stopping running task $($task_to_stop.InstanceGuid)..."
459 $task_to_stop.Stop()
460 }
461
462 <# FUTURE: add a global waithandle for this to release any other waiters. Wait-Job
463 and/or polling will block forever, since the killed job object in the parent
464 session doesn't know it's been killed :( #>
465 Unregister-ScheduledJob -Name $name
466 }
467}
468
469Function Start-AsScheduledTask($common_functions, $script) {
470 $job_name = "ansible-win-updates"
471 Remove-ScheduledJob -name $job_name
472
473 $job_args = @{
474 ScriptBlock = $script
475 Name = $job_name
476 ArgumentList = @(
477 @{
478 category_names = $category_names
479 log_path = $log_path
480 state = $state
481 blacklist = $blacklist
482 whitelist = $whitelist
483 check_mode = $check_mode
484 server_selection = $server_selection
485 }
486 )
487 ErrorAction = "Stop"
488 ScheduledJobOption = @{ RunElevated=$True; StartIfOnBatteries=$True; StopIfGoingOnBatteries=$False }
489 InitializationScript = $common_functions
490 }
491
492 Write-DebugLog -msg "Registering scheduled job with args $($job_args | Out-String -Width 300)"
493 $scheduled_job = Register-ScheduledJob @job_args
494
495 # RunAsTask isn't available in PS3 - fall back to a 2s future trigger
496 if ($scheduled_job | Get-Member -Name RunAsTask) {
497 Write-DebugLog -msg "Starting scheduled job (PS4+ method)"
498 $scheduled_job.RunAsTask()
499 } else {
500 Write-DebugLog -msg "Starting scheduled job (PS3 method)"
501 Add-JobTrigger -InputObject $scheduled_job -trigger $(New-JobTrigger -Once -At $(Get-Date).AddSeconds(2))
502 }
503
504 $sw = [System.Diagnostics.Stopwatch]::StartNew()
505 $job = $null
506
507 Write-DebugLog -msg "Waiting for job completion..."
508
509 # Wait-Job can fail for a few seconds until the scheduled task starts - poll for it...
510 while ($null -eq $job) {
511 Start-Sleep -Milliseconds 100
512 if ($sw.ElapsedMilliseconds -ge 30000) { # tasks scheduled right after boot on 2008R2 can take awhile to start...
513 Fail-Json -msg "Timed out waiting for scheduled task to start"
514 }
515
516 # FUTURE: configurable timeout so we don't block forever?
517 # FUTURE: add a global WaitHandle in case another instance kills our job, so we don't block forever
518 $job = Wait-Job -Name $scheduled_job.Name -ErrorAction SilentlyContinue
519 }
520
521 $sw = [System.Diagnostics.Stopwatch]::StartNew()
522
523 # NB: output from scheduled jobs is delayed after completion (including the sub-objects after the primary Output object is available)
524 while (($null -eq $job.Output -or -not ($job.Output | Get-Member -Name Key -ErrorAction Ignore) -or -not $job.Output.Key.Contains("job_output")) -and $sw.ElapsedMilliseconds -lt 15000) {
525 Write-DebugLog -msg "Waiting for job output to populate..."
526 Start-Sleep -Milliseconds 500
527 }
528
529 # NB: fallthru on both timeout and success
530 $ret = @{
531 ErrorOutput = $job.Error
532 WarningOutput = $job.Warning
533 VerboseOutput = $job.Verbose
534 DebugOutput = $job.Debug
535 }
536
537 if ($null -eq $job.Output -or -not $job.Output.Keys.Contains('job_output')) {
538 $ret.Output = @{failed = $true; msg = "job output was lost"}
539 } else {
540 $ret.Output = $job.Output.job_output # sub-object returned, can only be accessed as a property for some reason
541 }
542
543 try { # this shouldn't be fatal, but can fail with both Powershell errors and COM Exceptions, hence the dual error-handling...
544 Unregister-ScheduledJob -Name $job_name -Force -ErrorAction Continue
545 } catch {
546 Write-DebugLog "Error unregistering job after execution: $($_.Exception.ToString()) $($_.ScriptStackTrace)"
547 }
548 Write-DebugLog -msg "Scheduled job completed with output: $($re.Output | Out-String -Width 300)"
549
550 return $ret.Output
551}
552
553# source the common code into the current scope so we can call it
554. $common_functions
555
556<# Most of the Windows Update Agent API will not run under a remote token,
557which a remote WinRM session always has. Using become can bypass this
558limitation but it is not always an option with older hosts. win_updates checks
559if WUA is available in the current logon process and does either of the below;
560
561 * If become is used then it will run the windows update process natively
562 without any of the scheduled task hackery
563 * If become is not used then it will run the windows update process under
564 a scheduled job.
565#>
566try {
567 (New-Object -ComObject Microsoft.Update.Session).CreateUpdateInstaller().IsBusy > $null
568 $wua_available = $true
569} catch {
570 $wua_available = $false
571}
572
573if ($wua_available) {
574 Write-DebugLog -msg "WUA is available in current logon process, running natively"
575 $result = Start-Natively -common_functions $common_functions -script $update_script_block
576} else {
577 Write-DebugLog -msg "WUA is not avialable in current logon process, running with scheduled task"
578 $result = Start-AsScheduledTask -common_functions $common_functions -script $update_script_block
579}
580
581$result