├── README.md └── Update-VMs.ps1 /README.md: -------------------------------------------------------------------------------- 1 | # Update-VMs 2 | Snapshot, patch, health-check, and potentially roll-back Windows VMs 3 | 4 | ### Overview 5 | This script inventories all VMs in VCenter specified and rolls out patches within defined patch windows to all systems over the course of 1 week. 6 | 7 | When a machine is in its patching window, the script takes a snapshot of the VM (and removes any old snapshots created by the script), installs Windows patches, and then runs a healthcheck script (if it exists) at C:\scripts\healthcheck.ps1. If the healthcheck is not succesful or if the system is not accessible for a period of time after patching, the script rolls the VM back to the snapshot taken prior to patching and sends an email to alert administrators. 8 | 9 | ### Getting Started 10 | 11 | Tag all of your VMs that you want patched with the following attributes: 12 | 13 | - "Patching - Schedule" in format of Daily 0000 for patching at midnight, Daily 1200 for patching at noon, etc. 14 | - "BetaGroup" in format of 1,2,3,4 to define the sequence in which VMs are patched (see below for more details) 15 | 16 | Run the script with the -Install flag to inventory all VMs that can be patched based on the above attributes. 17 | 18 | ### Detailed Process 19 | 20 | - Initial Run (-Install Flag) 21 | - Get all Systems from source (default: VCenter) 22 | - Creates beta groups based on patching windows and criticality (if defined in VM properties) 23 | - Installs scheduled task to run patching jobs starting on the next Patch Tuesday and running every 30 minutes to patch any system in a patching window 24 | 25 | - Scheduled Task 26 | - Executes on Patch Tuesday at midnight for initial run patching against Beta1 Systems whenever they are in their patch windows (as defined in VCenter) 27 | - Every 30 minutes run through list to check if Beta1 systems are in patching window 28 | - If in patching window, apply latest patches 29 | - Wait 1 day 30 | - Patch all Beta 2 systems in their windows 31 | - Wait 1 day 32 | - Patch all Beta 3 systems in their windows 33 | - Wait 2 days 34 | - Patch all Beta 4 (all remaining) systems in their windows 35 | 36 | ### Switches 37 | 38 | -Install 39 | 40 | -Inventory VMs from VCenter and build patch beta groups 41 | 42 | -Installs self as a scheduled task to run every 30 minutes and patch systems according to beta schedule and patch windows 43 | 44 | -Audit 45 | 46 | -Takes -VMName as argument and queries for available patches 47 | 48 | -RegularPatch 49 | 50 | -Apply patches based on patching windows and according to beta group schedule (Should be run in scheduled task) 51 | 52 | -OnDemandPatch 53 | 54 | -Takes -VMName as argument and immediately applies available patches 55 | 56 | -VMName 57 | 58 | -Name of single VM to patch or audit 59 | 60 | -VMList 61 | 62 | -Filename containing individual VM names line by line 63 | 64 | OR 65 | 66 | -"-VMList all" will patch or audit all VMs in VCenter 67 | 68 | -KBs 69 | 70 | -List of specific patches to apply when running OnDemandPatch 71 | 72 | -Verbose 73 | 74 | -Print detailed logging of script activities 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Update-VMs.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [switch]$Install, 3 | [switch]$RegularPatch, 4 | [switch]$OnDemandPatch, 5 | [switch]$Audit, 6 | [string]$VMName, 7 | [string]$VMList, 8 | [string]$KBs, 9 | [switch]$Verbose 10 | ) 11 | #Load PowerCLI Modules 12 | Get-Module -ListAvailable VMWare* |Import-Module 13 | 14 | ###User-Controlled Variables### 15 | #BatchSize parameter determines how many systems are patched at once. This is important in a virtual environment where patching many hosts simultaneously can cause performance issues 16 | #Set this value to 0 to never batch and just patch all targeted systems at once 17 | $BatchSize = 20 18 | 19 | #Log File locations 20 | #LogFile contains data on the current beta group being patched 21 | $LogFile = $PSScriptRoot + "\logs\Windows_Update_Log.csv" 22 | #PatchLogs contains the results of patching attempts against each Windows system 23 | $PatchLogs = $PSScriptRoot + "\logs\Windows_Patching_Results.csv" 24 | #ScriptLog contains the output from the script - this file is useful for reviewing output when running as a scheduled task 25 | $ScriptLog = $PSScriptRoot + "\logs\Update-VMs_Logfile.txt" 26 | #VMFile contains the inventory of VMs, which beta groups they belong to, and their last patch status 27 | $VMFile = $PSScriptRoot + "\Windows_Patching_Systems.csv" 28 | $VCenter = @("vc01.domain.local","vc02.domain.local") 29 | $DaysToKeepSnapshots = 1 30 | ###End User-Controlled Variables### 31 | 32 | #Other globals 33 | $global:PatchStatus =@() 34 | $global:RunTime = Get-Date 35 | foreach($vc in $VCenter){ 36 | Connect-VIServer $vc | Out-Null 37 | } 38 | 39 | [int]$hour = Get-Date -format HH 40 | 41 | if($Verbose){ 42 | $oldverbose = $VerbosePreference 43 | $VerbosePreference = "continue" 44 | } 45 | Function Log_Verbose_Output($out){ 46 | Write-Verbose $out 47 | $out | Out-File $ScriptLog -Append 48 | } 49 | 50 | function Take_VCenter_Snapshot($VM){ 51 | $SnapshotSuccess = 0 52 | try{ 53 | $VM | New-Snapshot -name Backup_PriorTo_WindowsUpdates -Description "Created $(Get-Date) prior to Windows Update script running" -ErrorAction Continue |Out-Null 54 | $SnapshotSuccess = 1 55 | Log_Verbose_Output (get-date -format s) + " VERBOSE: Successfully took snapshot of $VM" 56 | 57 | } 58 | catch{ 59 | Log_Verbose_Output (get-date -format s) + " VERBOSE: Unsuccessful with Snapshot. Skipping $VM" 60 | } 61 | $SnapshotSuccess 62 | } 63 | function Clean_Old_Snapshots($VM){ 64 | #Check to see if this VM has a custom attribute overriding the "DaysToKeepSnapshots" value 65 | try{ 66 | $SnapshotDays = $VM| Get-Annotation -CustomAttribute "Snapshot - Days to keep" -ErrorAction SilentlyContinue 67 | if($SnapshotDays.Value){ 68 | [int]$DaysToKeepSnapshots = [convert]::ToInt32($SnapshotDays.Value, 10) 69 | } 70 | } 71 | catch{ 72 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: No custom snapshot days to keep value set. Continuing with default") 73 | } 74 | #Clean up old snapshots 75 | try{ 76 | Get-Snapshot -VM $VM | Foreach-Object { 77 | #Only delete snapshots created by this script (based on name) in case others are creating snapshots for other purposes 78 | if(($_.Name -eq "Backup_PriorTo_WindowsUpdates") -And ($_.Created -lt (Get-Date).AddDays(-[int]$DaysToKeepSnapshots))) { 79 | Remove-Snapshot $_ -ErrorAction Continue -Confirm:$false -RunAsync 80 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Deleted old snapshot for $VM") 81 | } 82 | } 83 | } 84 | catch{ 85 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Error: Unable to delete Snapshot(s)... $($_.Exception.Message)") 86 | } 87 | } 88 | function Get_VCenter_VMs($VCenter_Server, $VCenter_Target_Folder){ 89 | $PatchableVMs = @() 90 | $AllVMs = @() 91 | #Get all Windows servers from VCenter 92 | if($VCenter_Target_Folder){ 93 | $AllVMs = Get-Folder $VCenter_Target_Folder | Get-VM |Where-Object {$_.Guest.OSFullName -Like "*Windows*"} | Select-Object -Unique 94 | } 95 | else{ 96 | $AllVMs = Get-VM |Where-Object {$_.Guest.OSFullName -Like "*Windows*"} | Select-Object -Unique 97 | } 98 | #trim list to only include systems with patching windows defined 99 | 100 | foreach($V in $AllVMs){ 101 | $PatchSchedule = $V| Get-Annotation -CustomAttribute "Patching - Schedule" -ErrorAction SilentlyContinue 102 | if($PatchSchedule.Value){ 103 | $PatchableVMs += $V 104 | } 105 | } 106 | $PatchableVMs 107 | 108 | } 109 | function Check_Patch_Status($PatchedVMs, $VMHostname){ 110 | Log_Verbose_Output $((get-date -format s) + " DEBUG: Got to Check_Patch_Status with VM(s) to check: $PatchedVMs") 111 | $VMInventory = Import-Csv $VMFile 112 | $VMsLeftToCheck = New-Object System.Collections.ArrayList 113 | $KillJobsTime = $global:RunTime.AddHours(6) 114 | $KillJobNow = 0 115 | $StatusToLog = "" 116 | $ScriptBlock = { 117 | $RetCode = 0 118 | #$SchTask = Get-ScheduledTask -TaskName "PSWindowsUpdate" |Select Name, State 119 | #^ is easier than the next line if you're only working with 2012R2 servers. Using schtasks to support older Windows servers 120 | $SchTask = (schtasks.exe /query /tn "PSWindowsUpdate") |Out-String 121 | if($SchTask){ 122 | if($SchTask -match "Running"){ 123 | #Return 1 as scheduled task is still running and we can't batch out additional systems 124 | $RetCode = 1 125 | } 126 | elseif($SchTask -match "Ready"){ 127 | #Return 0 as scheduled task is complete and we can move onto the next system 128 | $RetCode =0 129 | } 130 | } 131 | $RetCode 132 | } 133 | if($PatchedVMs.Count -eq 1){ 134 | $VMsLeftToCheck.Add($PatchedVMs) 135 | } 136 | else{ 137 | $VMsLeftToCheck.AddRange($PatchedVMs) 138 | } 139 | while($VMsLeftToCheck.Count -ne 0){ 140 | foreach($VM in $PatchedVMs){ 141 | if($VMSLeftToCheck -contains $VM){ 142 | $startTime=(Get-Date) 143 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Checking Status of $VM") 144 | $VM = Get-VM $VM 145 | $View = get-view $VM 146 | $VMHostname = $View.Guest.Hostname 147 | 148 | if(-not $VMHostname){ #BUG FIX: Sometimes, if the system goes down for a reboot at the same time we try to get the hostname from VMWare, the View variable doesn't contain a hostname value. Using the VM inventory as an alternative to look for the hostname we need to ping 149 | foreach($v in $VMInventory){ 150 | if($v.VMName -eq $VM.Name){ 151 | $VMHostname = $v.Hostname 152 | } 153 | } 154 | } 155 | #Check for end of maintenance window 156 | $MaintWindow = $VM| Get-Annotation -CustomAttribute "End Maintenance Window" -ErrorAction SilentlyContinue 157 | if($MaintWindow.Value){ 158 | #Maint window is defined - make sure we're still in it 159 | #Assuming maint window is defined as 0600 and defining the end of the maint window 160 | try{ 161 | $WindowHour = $MaintWindow.Value[1].Substring(0,2) 162 | if((Get-Date -format HH) -le $WindowHour){ 163 | $KillJobNow = 1 164 | } 165 | } 166 | catch{ 167 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Maintenance Window not defined in VCenter.") 168 | } 169 | } 170 | 171 | if((Get-Date) -gt $KillJobsTime){ #We're past 6 hours from initial patching start, kill the job 172 | Log_Verbose_Output $((get-date -format s) + "Patching has been occurring for more than 6 hours. Killing the job.") 173 | $KillJobNow = 1 174 | } 175 | try{ 176 | if($Session = New-PSSession -ComputerName $VMHostname -ErrorAction SilentlyContinue){ 177 | if($KillJobNow -eq 1){ 178 | $null = Invoke-Command -Session $Session -ScriptBlock {$SchTask = (schtasks.exe /End /tn "PSWindowsUpdate") |out-null} 179 | $Status=0 180 | } 181 | else{ 182 | $Status = Invoke-Command -Session $Session -ScriptBlock $ScriptBlock 183 | } 184 | Remove-PSSession $Session 185 | } 186 | else{ 187 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Can't connect via PSRemoting to $VM. Moving on.") 188 | $StatusToLog = "Unable to connect via PS Remoting to check status of system" 189 | $Status =0 190 | } 191 | } 192 | catch{ 193 | Log_Verbose_Output $((get-date -format s) + " Something went wrong with PS Remoting to this server. Considering the patch job complete.") 194 | $Status = 0 195 | } 196 | 197 | if($Status -eq 0){ #Done patching this system 198 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Done Patching $VM") 199 | $StatusToLog = "Success" 200 | $VMsLeftToCheck.Remove($VM) 201 | 202 | #Reconcile VMInventory with UpdatedInventory so that we have up to date last-patch times for each system 203 | foreach($old in $VMInventory){ 204 | if($old.VMName -eq $VM.Name){ 205 | $old.Last_Patched = (Get-Date) 206 | $old.Last_Run_Status = $StatusToLog 207 | } 208 | } 209 | #HealthCheckAnalysis $VM $VMHostname 210 | } 211 | else{ 212 | Write-Verbose (get-date -format s) + " VERBOSE: Patch job still running on $VM - sleeping 5 minutes then checking again" 213 | $StatusToLog = "Success" 214 | 215 | Start-Sleep -Seconds 300 216 | } 217 | } 218 | } 219 | 220 | } 221 | $VMInventory | Export-Csv $VMFile 222 | } 223 | function Check_Outages($PatchedVMs, $VMHostname) { 224 | $VMInventory = Import-Csv $VMFile 225 | $startTime=(Get-Date) 226 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Starting to check for outages") 227 | $deadHosts = New-Object System.Collections.ArrayList 228 | $allHostsUp = 0 229 | $receivedHostname = 0 230 | if($VMHostname){ 231 | $receivedHostname = 1 232 | } 233 | 234 | while($allHostsUp -eq 0){ 235 | foreach($VM in $PatchedVMs) { 236 | if($receivedHostname -eq 0){ 237 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Checking $VM for outages") 238 | $VM = Get-VM $VM 239 | $View = get-view $VM 240 | $VMHostname = $View.Guest.Hostname 241 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Checking $VM for outages by connecting to $VMHostname") 242 | 243 | if(-not $VMHostname){ #BUG FIX: Sometimes, if the system goes down for a reboot at the same time we try to get the hostname from VMWare, the View variable doesn't contain a hostname value. Using the VM inventory as an alternative to look for the hostname we need to ping 244 | foreach($v in $VMInventory){ 245 | if($v.VMName -eq $VM.Name){ 246 | $VMHostname = $v.Hostname 247 | } 248 | } 249 | } 250 | } 251 | 252 | if(-not (Test-Connection -ComputerName $VMHostname -Count 4 -ErrorAction SilentlyContinue)){ 253 | if($deadHosts -notcontains $VM){ 254 | $deadHosts.add($VM) 255 | } 256 | $timespan = NEW-TIMESPAN –Start $startTime –End (Get-Date) 257 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Timespan from time we lost connection to VM is $($timespan.Minutes) Minutes, $($timespan.Seconds) Seconds") 258 | if($($timespan.Hours) -gt 1){ 259 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: VM has been down for one hour - rolling back to snapshot") 260 | #We've been unable to connect to the target system for an hour - roll back to previous snapshot 261 | try{ 262 | $snap = Get-Snapshot -VM $VM | Sort-Object -Property Created -Descending | Select -First 1 263 | Set-VM -VM $VM -SnapShot $snap -Confirm:$false |out-null 264 | Start-VM -VM $VM 265 | $ErrorMessage = "CRITICAL: Could not connect to $VM for one hour after patching. Rolling back to snapshot." 266 | SendEmail $ErrorMessage 267 | $StatusToLog = "FAILURE: System did not come back. Rolled back to snapshot" 268 | } 269 | catch{ 270 | $ErrorMessage = "CRITICAL: Could not connect to $VM for one hour after patching. UNABLE TO ROLL BACK TO SNAPSHOT - THIS SYSTEM IS DOWN." 271 | SendEmail $ErrorMessage 272 | $StatusToLog = "FAILURE: System did not come back. CRITICAL: Failed to roll back to snapshot" 273 | } 274 | 275 | $deadHosts.Remove($VM) 276 | } 277 | else{ 278 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Can't ping $VM. Will keep trying for 60 minutes from first notice of it being offline.") 279 | start-sleep -seconds 300 280 | } 281 | } 282 | else { #We can ping the host - make sure it wasn't down before 283 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: $VMHostname is alive") 284 | 285 | if($deadHosts -contains $VM){ 286 | $deadHosts.Remove($VM) 287 | } 288 | } 289 | } 290 | if($deadHosts.Count -eq 0){ 291 | $AllHostsUp = 1 292 | Write-Host "All hosts are up. Exiting" 293 | } 294 | } 295 | 296 | } 297 | function Patch_Windows_Systems($VMs,$KBs) { 298 | 299 | $PatchedVMs = @() 300 | $ModulePath = "C:\windows\System32\WindowsPowerShell\v1.0\Modules\PSWindowsUpdate" 301 | $numVMs = $VMs.Count 302 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Working on a total of $numVMs VMs") 303 | 304 | if($KBs){ 305 | $KBString = $KBs -join "\," 306 | write-verbose "VERBOSE: Specific patches requested. Only applying these" 307 | $ScriptCMD = "Import-Module $ModulePath\PSWindowsUpdate.psm1; get-wuinstall -KBArticleID $KBString -AcceptAll -AutoReboot" 308 | } 309 | else{ 310 | $ScriptCMD = "Import-Module $ModulePath\PSWindowsUpdate.psm1; get-wuinstall -NotCategory 'Language packs' -AcceptAll -AutoReboot" 311 | 312 | } 313 | #Process each running VM in the list 314 | foreach($VM in $VMs){ 315 | $VM = Get-VM $VM 316 | $View = get-view $VM 317 | $Hostname = $View.Guest.Hostname 318 | 319 | #Test connectivity to VM. If we can connect, install updates 320 | if($Hostname -And (Test-WSMan -ComputerName $Hostname -ErrorAction SilentlyContinue)){ 321 | #First, Snapshot VM in case updates cause issues 322 | $SnapshotSuccess = Take_VCenter_Snapshot $VM 323 | if($SnapshotSuccess -eq 1){ 324 | 325 | #Invoke-WUInstall doesn't natively support other creds so re-inventing the wheel by copying relevant portions into scriptblock 326 | $UpdateScript = { 327 | param($Computer, $ScriptCMD) 328 | $User = [Security.Principal.WindowsIdentity]::GetCurrent() 329 | [String]$TaskName = "PSWindowsUpdate" 330 | 331 | Write-Verbose "Create schedule service object" 332 | $Scheduler = New-Object -ComObject Schedule.Service 333 | 334 | $Task = $Scheduler.NewTask(0) 335 | $RegistrationInfo = $Task.RegistrationInfo 336 | $RegistrationInfo.Description = $TaskName 337 | $RegistrationInfo.Author = $User.Name 338 | 339 | $Settings = $Task.Settings 340 | $Settings.Enabled = $True 341 | $Settings.StartWhenAvailable = $True 342 | $Settings.Hidden = $False 343 | $Action = $Task.Actions.Create(0) 344 | $Action.Path = "powershell" 345 | $Action.Arguments = "-Command $ScriptCMD" 346 | $Task.Principal.RunLevel = 1 347 | 348 | $Scheduler.Connect($Computer) 349 | 350 | $RootFolder = $Scheduler.GetFolder("\") 351 | $SendFlag = 1 352 | 353 | if($Scheduler.GetRunningTasks(0) | Where-Object {$_.Name -eq $TaskName}){ 354 | write-Verbose "Updates already running on this system" 355 | } 356 | try{ 357 | $RootFolder.RegisterTaskDefinition($TaskName, $Task, 6, "SYSTEM", $Null, 1) | out-null 358 | $RootFolder.GetTask($TaskName).Run(0) | out-null 359 | } 360 | catch{ 361 | Write-Verbose "Can't create scheduled task" 362 | continue 363 | } 364 | } 365 | #Start remoting session with the target system 366 | if($Session = New-PSSession -ComputerName $Hostname -ErrorAction SilentlyContinue){ 367 | #Check for PSWindowsUpdate Module 368 | if(Invoke-Command -ScriptBlock {-not (Test-Path "C:\windows\System32\WindowsPowerShell\v1.0\Modules\PSWindowsUpdate") } -Session $Session){ 369 | try{ 370 | #If it doesn't exist, create the directory 371 | Invoke-Command -Session $Session {New-Item $ModulePath -Type directory} -ErrorAction Continue 372 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Didn't find PSWindows Update module on $Hostname - Copying it over") 373 | 374 | #Copy module files over to target system 375 | Copy-Item -Recurse -Path $ModulePath -Destination $ModulePath -ToSession $Session -ErrorAction Continue 376 | } 377 | catch{ 378 | "$(Get-Date),$VM,Failure,Couldn't copy files to target" | Out-File -FilePath $PatchLogs -Append 379 | } 380 | } 381 | 382 | #Install updates (creates scheduled task that runs immediately on target system to run get-wuinstall) 383 | try{ 384 | Invoke-Command -Session $Session -ScriptBlock $UpdateScript -ArgumentList $Hostname,$ScriptCMD -ErrorAction Continue 385 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Successfully started patching job on $VM") 386 | 387 | $PatchStatusRow = [pscustomobject]@{ Date = $(Get-Date) ; VMName = $VM.Name ; Status = "Success" ; Details = "Patching Started" } 388 | "$(Get-Date),$VM,Success,Patching Started" | Out-File -FilePath $PatchLogs -Append 389 | $PatchedVMs += $VM 390 | } 391 | Catch{ 392 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: ERROR: Couldn't create scheduled task on $VM") 393 | $PatchStatusRow = [pscustomobject]@{ Date = $(Get-Date) ; VMName = $VM.Name ; Status = "Failure" ; Details = "Couldn't create scheduled task" } 394 | "$(Get-Date),$VM,Failure,Couldn't create scheduled task" | Out-File -FilePath $PatchLogs -Append 395 | } 396 | Remove-PSSession $Session 397 | } 398 | else{ 399 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: ERROR: Couldn't connect via PS Remoting on $VM") 400 | $PatchStatusRow = [pscustomobject]@{ Date = $(Get-Date) ; VMName = $VM.Name ; Status = "Failure" ; Details = "Couldn't connect via PS Remoting" } 401 | "$(Get-Date),$VM,Failure,Couldn't connect via PS Remoting" | Out-File -FilePath $PatchLogs -Append 402 | } 403 | } 404 | else{ 405 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: ERROR: Couldn't create snapshot of $VM") 406 | $PatchStatusRow = [pscustomobject]@{ Date = $(Get-Date) ; VMName = $VM.Name ; Status = "Failure" ; Details = "Couldn't create snapshot" } 407 | "$(Get-Date),$VM,Skip,Couldn't create snapshot" | Out-File -FilePath $PatchLogs -Append 408 | } 409 | } 410 | else{ 411 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: ERROR: Couldn't connect to hostname of $VM") 412 | $PatchStatusRow = [pscustomobject]@{ Date = $(Get-Date) ; VMName = $VM.Name ; Status = "Failure" ; Details = "Couldn't connect to hostname" } 413 | "$(Get-Date),$VM,Failure,Couldn't connect to hostname of VM" | Out-File -FilePath $PatchLogs -Append 414 | } 415 | 416 | $global:PatchStatus += $PatchStatusRow 417 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Adding $PatchStatusRow to patchstatus") 418 | 419 | #Bug fix - need to write out error when system can't be patched... Otherwise this script keeps trying before moving to the next group of systems 420 | $VMInventory = Import-Csv $VMFile 421 | foreach($entry in $VMInventory){ 422 | if($entry.VMName -eq $VM.Name -and $PatchStatusRow.Status -eq "Failure"){ 423 | $entry.Last_Patched = Get-Date 424 | $entry.Last_Run_Status = $PatchStatusRow.Details 425 | } 426 | } 427 | $VMInventory | Export-Csv $VMFile 428 | } 429 | $PatchedVms 430 | } 431 | function AuditPatches($hostnames){ 432 | $AuditFile = "C:\Windows\Temp\Windows_Patch_Audit.csv" 433 | $AuditResults = @() 434 | $Patches = @() 435 | foreach($hostname in $hostnames){ 436 | if($Session = New-PSSession -ComputerName $hostname -ErrorAction SilentlyContinue){ 437 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Connected to $hostname") 438 | $ModulePath = "C:\windows\System32\WindowsPowerShell\v1.0\Modules\PSWindowsUpdate" 439 | 440 | #Check for PSWindowsUpdate Module 441 | if(Invoke-Command -ScriptBlock {-not (Test-Path "C:\windows\System32\WindowsPowerShell\v1.0\Modules\PSWindowsUpdate") } -Session $Session){ 442 | #If it doesn't exist, create the directory 443 | Invoke-Command -Session $Session {New-Item $ModulePath -Type directory} 444 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Didn't find PSWindows Update module on $hostname - Copying it over") 445 | #Copy module files over to target system 446 | copy-item -Recurse -Path $ModulePath -Destination $ModulePath -ToSession $Session 447 | } 448 | $Patches = @(Invoke-Command -Session $Session -ScriptBlock { Import-Module C:\Windows\System32\WindowsPowerShell\v1.0\Modules\PSWindowsUpdate\PSWindowsUpdate.psm1; $res = (Get-Wuinstall -ListOnly -NotCategory 'Language packs'); $res} -ErrorAction SilentlyContinue) 449 | $newRow = [pscustomobject]@{'VMName' = $hostname ; 'Status' = "Success" ; 'NumPatchesAvailable' = $Patches.Count } 450 | write-verbose "Found $($Patches.Count) available patches for $hostname" 451 | Write-Verbose "Patches `n $Patches" 452 | $AuditResults += $newRow 453 | Remove-PSSession $Session 454 | } 455 | else{ #Couldn't connect 456 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Couldn't connect to $hostname") 457 | $newRow = [pscustomobject]@{'VMName' = $hostname ; 'Status' = "Could not connect via PS Remoting" ; 'NumPatchesAvailable' = "null" } 458 | $AuditResults += $newRow 459 | } 460 | } 461 | $AuditResults | Export-Csv $AuditFile 462 | $out = (get-date -format s) + "Wrote Audit Results to $AuditFile" 463 | Write-Host $out 464 | $out |Out-File $ScriptLog -Append 465 | } 466 | 467 | function SendEmail($ErrorMessage){ 468 | $From = "fromAddress@domain.com" 469 | $To = "toAddress@domain.com" 470 | $Subject = "Failed Health Check after Patching" 471 | $SMTPServer = "server" 472 | $SMTPPort = "587" 473 | #If run non-interactive don't use get-credential 474 | Send-MailMessage -From $From -to $To -Subject $Subject -Body $ErrorMessage -SmtpServer $SMTPServer -port $SMTPPort -UseSsl -Credential (Get-Credential) 475 | } 476 | 477 | function HealthCheckAnalysis($VMs, $VMHostname){ 478 | #for each VM, check if they have a healthcheck script at C:\scripts\healthcheck.ps1 479 | #Run the healthcheck and if we receive a 1 assume success, 0 assume failure 480 | #If healthcheck fails, roll back to last snapshot. 481 | $VMInventory = Import-Csv $VMFile 482 | foreach($VM in $VMs){ 483 | $RetrySeconds = 60 484 | $RetryTimes = 5 485 | $RetrySuccess = 0 486 | $ret = "0" 487 | if(-not $VMHostname){ 488 | foreach($v in $VMInventory){ 489 | if($v.VMName -eq $VM.Name){ 490 | $Hostname = $v.Hostname 491 | } 492 | } 493 | } 494 | do{ #loop a few times in case the server is rebooting and we can't get a healthcheck 495 | if(Test-WSMan -ComputerName $Hostname -ErrorAction SilentlyContinue){ 496 | $RetrySuccess = 1 497 | if(Invoke-Command -ScriptBlock {-not (Test-Path "C:\scripts\healthcheck.ps1") } -Credential $Cred -ComputerName $Hostname){ 498 | #Health Check Script doesn't exist, assume success and don't roll back from snapshot 499 | Log_Verbose_Output $((get-date -format s) + "Couldn't find healthcheck script at C:\Scripts\Healthcheck.ps1 on $Hostname") 500 | $ret = "1" 501 | } 502 | else{ 503 | $ret = Invoke-Command -Scriptblock {C:\Scripts\healthcheck.ps1} -Credential $Cred -ComputerName $Hostname 504 | if($ret -eq "0"){ 505 | $rollbackSuccess ="1" 506 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Healthcheck failed on $Hostname. Reverting to last snapshot.") 507 | 508 | try{ 509 | $snap = Get-Snapshot -VM $VM | Sort-Object -Property Created -Descending | Select -First 1 510 | Set-VM -VM $VM -SnapShot $snap -Confirm:$false |out-null 511 | } 512 | catch{ 513 | $rollbackSuccess = "0" 514 | } 515 | if($rollbackSucess -eq "1"){ 516 | $ErrorMessage = "Health check failed after patching on $Hostname. Successfully rolled back to snapshot of system prior to patching." 517 | } 518 | else{ 519 | $ErrorMessage = "CRITICAL: Health check failed after patching on $Hostname. Not able to roll back to snapshot of system prior to patching." 520 | } 521 | SendEmail $ErrorMessage 522 | } 523 | elseif($ret -eq "1"){ 524 | Log_Verbose_Output $((get-date -format s) + "Successful patching") 525 | 526 | } 527 | } 528 | } 529 | else{ #Can't connect to server, wait 60 seconds and retry again 530 | $RetryTimes++ 531 | Start-Sleep $RetrySeconds 532 | } 533 | } 534 | while($RetrySuccess -eq 0 -and $RetryTimes -lt 5) 535 | } 536 | } 537 | 538 | # Starting script here based on switches provided by user/scheduled task 539 | if($Install){ 540 | 541 | if(-Not (Test-Path C:\Windows\System32\WindowsPowerShell\v1.0\Modules\PSWindowsUpdate)){ 542 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Installing PSWindowsUpdate Module") 543 | Save-Module -Name PSWindowsUpdate -Path C:\Windows\System32\WindowsPowerShell\v1.0\Modules 544 | Install-Module -Name PSWindowsUpdate -RequiredVersion 1.5.2.2 545 | } 546 | Log_Verbose_Output $((get-date -format s) + "Starting Initial Run. Gathering VMs") 547 | 548 | $global:VMs = Get_VCenter_VMs 549 | 550 | $VMFileContent =@() 551 | 552 | write-host "# of VMs = " $global:VMs.Count 553 | write-host "Parsing beta groups" 554 | 555 | 556 | foreach($VM in $global:VMs){ 557 | $BetaGroup = "0" 558 | $View = get-view $VM 559 | $VMName = $VM.Name 560 | $Hostname = $View.Guest.Hostname 561 | $IP = $View.Guest.IPAddress 562 | if(-not $IP){ 563 | $IP = "null" 564 | } 565 | $OS = $VM.Guest.OSFullName 566 | $PatchSchedule = $VM| Get-Annotation -CustomAttribute "Patching - Schedule" -ErrorAction SilentlyContinue 567 | $PatchSchedule = $PatchSchedule.Value 568 | if($PatchSchedule -eq ""){ 569 | $PatchSchedule = "Daily 0000" 570 | } 571 | 572 | $VCenterBetaGroup = $VM| Get-Annotation -CustomAttribute "BetaGroup" -ErrorAction SilentlyContinue 573 | 574 | try{ 575 | $a = [convert]::ToInt32($VCenterBetaGroup.Value, 10) 576 | $BetaGroup = [string]$a 577 | } 578 | catch{ 579 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: No predefined beta group found for $VMName. Skipping this VM.") 580 | } 581 | if($BetaGroup -ne ""){ #if beta group is defined in VCenter Attribute, use whatever is already defined 582 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Found predefined beta group for $VMName. This VM is in Beta Group $BetaGroup") 583 | } 584 | if($BetaGroup -ne "" -and $BetaGroup -ne "0"){ 585 | $newRow = [pscustomobject]@{ VMName = $VMName ; Hostname = $Hostname ; IP_Address = $IP ; OS = $OS ; PatchSchedule = $PatchSchedule ; Beta_Group = $BetaGroup ; Last_Patched = "null"; Last_Run_Status = "null" } 586 | $VMFileContent += $newRow 587 | } 588 | } 589 | 590 | $VMFileContent | Export-Csv $VMFile 591 | 592 | 593 | [string]$month = (get-date).month 594 | [string]$year = (get-date).year 595 | $firstdayofmonth = [datetime] ([string]$month + "/1/" + [string]$year) 596 | $patchTues = (0..30 | % {$firstdayofmonth.adddays($_) } | ? {$_.dayofweek -like "Tue*"})[1] 597 | if($(Get-Date) -gt $patchTues){ 598 | 599 | [string]$month = $(Get-Date).AddMonths(1).Month 600 | $firstdayofmonth = [datetime] ([string]$month + "/1/" + [string]$year) 601 | $patchTues = (0..30 | % {$firstdayofmonth.adddays($_) } | ? {$_.dayofweek -like "Tue*"})[1] 602 | } 603 | 604 | 605 | #Now that the inventory and beta groups are complete, install the script as a scheduled task 606 | $scriptToInstall = $PSScriptRoot + "\Update-VMs.ps1" 607 | schtasks.exe /create /TN "VCenter_Windows_Updates" /tr "powershell -file $ScriptToInstall -RegularPatch -Verbose" /sc minute /mo 30 /SD $patchTues.ToString("MM/dd/yyyy") /st $patchTues.toString("hh:mm") 608 | Write-Host "Install Finished successfully. Scheduled task is configured to start on the next patch Tuesday, $patchTues" 609 | Write-Host "IMPORTANT: Change scheduled task to run as service account with appropriate privileges to target Windows systems" 610 | } 611 | elseif($RegularPatch){ 612 | $BetaGroupVMs = @() 613 | $VMsToPatch = @() 614 | $LogFileContents = @() 615 | $PassesFromLogFile = @() 616 | $BetaCounter = 0 617 | $firstWeek = 0 618 | $BetaToPatch = 0 #Patch up to this Beta Group 619 | $Pass = "" 620 | $global:RunTime = Get-Date 621 | #Make sure we have the file of systems with priorities and patch windwos 622 | if(-not (Test-Path $VMFile)){ 623 | Write-Host "Inventory file not found. Please run this script again with -Install prior to running with -RegularPatch flag" 624 | Exit 625 | } 626 | if(-not (Test-Path $LogFile)){ 627 | #Must be first run of patching - start with beta 1 group 628 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: First run of patching, starting with beta 1 group and creating logfile at $LogFile") 629 | $LogRow = [pscustomobject]@{ Date = $global:RunTime ; Beta = "1" } 630 | $Pass = "1" 631 | $firstWeek = 1 632 | #Write entry to log file showing that we are starting on Beta1 Group 633 | $LogRow | Export-Csv $LogFile 634 | } 635 | else{ 636 | $CurrentStatus = Import-Csv $LogFile 637 | $LastRun = $CurrentStatus[-1] 638 | $Pass = $LastRun.Beta 639 | [datetime]$OneWeekAgo = $global:RunTime.AddDays(-7) 640 | #First week of running we have to be careful about what systems are patched and adhere to the beta schedule 641 | if($OneWeekAgo -lt [datetime]$CurrentStatus[0].Date){ #we're in the first week 642 | $firstWeek = 1 643 | } 644 | else{ 645 | $firstWeek = 0 646 | } 647 | } 648 | 649 | $DayOfWeek = $global:RunTime.DayOfWeek 650 | if($DayOfWeek -eq "Tuesday" -and $firstWeek -eq 1){ 651 | $BetaToPatch = 1 652 | } 653 | elseif($DayOfWeek -eq "Wednesday" -and $firstWeek -eq 1){ 654 | $BetaToPatch = 2 655 | } 656 | elseif($DayOfWeek -eq "Thursday" -and $firstWeek -eq 1){ 657 | $BetaToPatch = 2 658 | } 659 | elseif($DayOfWeek -eq "Friday" -and $firstWeek -eq 1){ 660 | $BetaToPatch = 3 661 | } 662 | elseif($DayOfWeek -eq "Saturday" -and $firstWeek -eq 1){ 663 | $BetaToPatch = 3 664 | } 665 | elseif($DayOfWeek -eq "Sunday" -and $firstWeek -eq 1){ 666 | $BetaToPatch = 3 667 | } 668 | elseif($DayOfWeek -eq "Monday" -and $firstWeek -eq 1){ 669 | $BetaToPatch = 4 670 | } 671 | else{ #we're not in the first patch cycle of the month anymore - patch everything 672 | $BetaToPatch = 4 673 | } 674 | #Import all VMs for the beta group we're patching 675 | $VMInventory = Import-Csv $VMFile 676 | $VMInventory.Count 677 | foreach($v in $VMInventory){ 678 | if(([convert]::ToInt32($v.Beta_Group, 10)) -le $BetaToPatch){ 679 | $BetaGroupVMs += $v 680 | } 681 | } 682 | #Now that we have the current Beta Group VMs, figure out which ones still need patches 683 | $PassesFromLogFile = Import-CSV $LogFile 684 | $FirstRunOfCycle = [datetime]$PassesFromLogFile[0].Date 685 | 686 | foreach($Beta in $BetaGroupVMs){ 687 | if(($Beta.Last_Patched -ne "null") -and ([datetime]$Beta.Last_Patched -gt [datetime]$FirstRunOfCycle)){ 688 | $BetaCounter++ 689 | #This beta system has already been patched during this patch cycle 690 | Log_Verbose_Output $((get-date -format s) + "Skipping $Beta.VMName as it has already been patched") 691 | Continue 692 | } 693 | elseif($Beta.PatchSchedule -like "*Daily*"){ 694 | #Figure out when window starts and see if we are in it 695 | $PatchHour = $Beta.PatchSchedule.split(" ") 696 | $PatchHour = $PatchHour[1].Substring(0,2) 697 | if([int]$PatchHour -eq $hour ){#we're in patch window 698 | 699 | $VMsToPatch += $Beta.VMName 700 | } 701 | else{ 702 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Skipping $Beta as it is not in patch window") 703 | } 704 | } 705 | else{ 706 | Log_Verbose_Output $((get-date -format s) + "Patching schedule is not daily ... Skipping for now") 707 | 708 | } 709 | } 710 | if($BetaCounter -eq $BetaGroupVMs.Count){ 711 | #All Vms in this beta group have been patched. Update the Pass # for the next run 712 | [int]$PassAsInt32 = [convert]::ToInt32($Pass, 10) 713 | $PassAsInt32++ 714 | $LogRow = [pscustomobject]@{ Date = $global:RunTime ; Beta = [string]($PassAsInt32) } 715 | Log_Verbose_Output $((get-date -format s) + "All VMs in Beta cycle are completed") 716 | } 717 | else{ #Systems still need to be patched 718 | $BeingPatchedArray = New-Object System.Collections.ArrayList 719 | $VMsLeftToPatch = New-Object System.Collections.ArrayList 720 | $PatchStatusTimer = @() 721 | $VMsLeftToPatch.AddRange($VMsToPatch) 722 | $AllPatched = 0 723 | $PatchedVMs = @() 724 | Log_Verbose_Output $((get-date -format s) + "About to patch " + $VMsToPatch.Count + " VMs") 725 | #First clean up any old snapshots that exist to avoid cluttering VCenter 726 | foreach($Vname in $VMsToPatch){ 727 | $VM = Get-VM -Name $Vname 728 | Clean_Old_Snapshots $VM 729 | } 730 | if($VMsToPatch.Count -gt $BatchSize -and $BatchSize -ne 0){ #If the number of systems to patch is greater than our max batch size, implement batching 731 | Log_Verbose_Output $((get-date -format s) + " DEBUG: patching based on batch size.") 732 | while($VMsLeftToPatch.Count -ne 0 -or $BeingPatchedArray.Count -gt 0){ #while there are still systems left to patch 733 | $BeingPatchedCounter = $BeingPatchedArray.Count 734 | while($BeingPatchedCounter -lt $BatchSize -and $VMsLeftToPatch.Count -gt 0){ #patch if we have systems left to patch and until we hit our batch limit 735 | $VM = $VMsLeftToPatch[0] 736 | $res = Patch_Windows_Systems $VM 737 | if($res){ 738 | $BeingPatchedArray.Add($VM) #Keep track of which systems are being patched 739 | $VMsLeftToPatch.Remove($VM) 740 | $BeingPatchedCounter++ 741 | $PatchedVMs += $res 742 | Log_Verbose_Output $((get-date -format s) + " DEBUG: Added $VM to BeingPatchedArray. BeingPatchedArray now looks like: $BeingPatchedArray - VMSLeftToPatch: $VMsLeftToPatch") 743 | } 744 | else{ #something went wrong and the server wasn't patched... don't check status 745 | Log_Verbose_Output $((get-date -format s) + " ERROR: Not logging status of $VM since patch function returned no result") 746 | $VMsLeftToPatch.Remove($VM) 747 | } 748 | } 749 | Start-Sleep -s 60 #Sleep for 1 minute prior to checking in on patched VMs 750 | foreach($VMBeingPatched in $BeingPatchedArray){ 751 | $VM = Get-VM -Name $VMBeingPatched 752 | #Log_Verbose_Output (get-date -format s) + " DEBUG: Checking status of $VMBeingPatched - status should be updated in inventory VM once complete" 753 | $Status = Check_Patch_Status $VM 754 | if($Status -eq 0){ 755 | $BeingPatchedArray.Remove($VMBeingPatched) 756 | #$out = (get-date -format s) + " DEBUG: Status came back clean for $VMBeingPatched - removing it from list of current patching and adding another node if it exists" 757 | } 758 | } 759 | } 760 | } 761 | elseif($VMsToPatch.Count -gt 0){ #we can fit all systems in single batch 762 | Log_Verbose_Output $((get-date -format s) + " All systems can fit in single batch - patching all at once") 763 | $PatchedVMs = Patch_Windows_Systems $VMsToPatch 764 | $Status = Check_Patch_Status $PatchedVMs 765 | } 766 | if($VMsToPatch.Count -gt 0 -and $PatchedVMs){ #Bug Fix where we were checking for outages even if nothing was patched 767 | $LogRow = [pscustomobject]@{ Date = $global:RunTime ; Beta = $Pass } 768 | #Patching has completed. It can take up to 10 minutes after the patch is completed to start a reboot. 769 | Log_Verbose_Output $((get-date -format s) + " Patching complete - sleeping 10 minutes then checking for outages") 770 | Start-Sleep -Seconds 600 771 | 772 | Check_Outages $PatchedVMs 773 | } 774 | } 775 | 776 | Export-Csv $LogFile -inputobject $LogRow -append -Force 777 | } 778 | elseif($Audit){ 779 | if($VMName){ 780 | $VM = Get-VM $VMName -ErrorAction SilentlyContinue 781 | if($VM){ 782 | $View = get-view $VM 783 | $Hostname = $View.Guest.Hostname 784 | Log_Verbose_Output (get-date -format s) + " VERBOSE: Auditing Patches for $VMName with hostname of $Hostname" 785 | 786 | } 787 | else{ 788 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Can't find VM with this name. Trying to connect directly as a hostname") 789 | $Hostname = $VMName 790 | } 791 | AuditPatches $Hostname 792 | } 793 | elseif($VMList){ 794 | $hostnames = @() 795 | $VMNames = Get-Content $VMList 796 | foreach($server in $VMNames){ 797 | $VM= Get-VM $server 798 | if($VM){ 799 | $View = get-view $VM 800 | $Hostname = $View.Guest.Hostname 801 | $hostnames += $Hostname 802 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: Auditing Patches $Hostname") 803 | } 804 | } 805 | AuditPatches $hostnames 806 | } 807 | else{ 808 | Log_Verbose_Output $((get-date -format s) + " VERBOSE: No VM defined, running against all VMs") 809 | $global:VMs = Get_VCenter_VMs 810 | AuditPatches $global:Vms 811 | } 812 | } 813 | elseif($OnDemandPatch){ 814 | if($VMName){ 815 | $AllVMs = @() 816 | $VM = Get-VM $VMName | Select-Object -Unique 817 | $View = Get-View $VM 818 | $VMHostname = $View.Guest.HostName 819 | Clean_Old_Snapshots $VM 820 | Log_Verbose_Output $((get-date -format s) + " Starting to patch $VMName with hostname of $VMHostname") 821 | 822 | if($KBs){ 823 | $null = Patch_Windows_Systems $VMName $KBs 824 | } 825 | else{ 826 | $null = Patch_Windows_Systems $VMName 827 | } 828 | Check_Patch_Status $VMName 829 | #Patching has completed. It can take up to 10 minutes after the patch is completed to start a reboot. 830 | Log_Verbose_Output $((get-date -format s) + " Patching complete on $VMName - sleeping 10 minutes then checking for outages") 831 | Start-Sleep -Seconds 600 832 | 833 | Check_Outages $VMName $VMHostname 834 | } 835 | elseif($VMList){ 836 | if($VMList -imatch "all"){#Get all VMs from VCenter and Patch them 837 | $AllVMs = Get-VM |Where-Object {$_.Guest.OSFullName -Like "*Windows*"} | Select-Object -Unique 838 | if($KBs){ 839 | $null = Patch_Windows_Systems $AllVMs $KBs 840 | } 841 | else{ 842 | $null = Patch_Windows_Systems $AllVMs 843 | } 844 | Check_Patch_Status $AllVMs 845 | #Patching has completed. It can take up to 10 minutes after the patch is completed to start a reboot. 846 | Log_Verbose_Output $((get-date -format s) + " Patching complete on $VMName - sleeping 10 minutes then checking for outages") 847 | Start-Sleep -Seconds 600 848 | Check_Outages $AllVMs 849 | } 850 | else { 851 | try{ 852 | $servernames = Get-Content $VMList 853 | 854 | } 855 | catch{ 856 | Write-Host -ForegroundColor Red "ERROR: Unable to read VMList file provided. Please correctly enter the path of the file containing the list of VMs and try again." 857 | } 858 | foreach($server in $servernames){ 859 | $v= Get-VM $server -ErrorAction SilentlyContinue 860 | $AllVMs += $v 861 | } 862 | if($KBs){ 863 | $null = Patch_Windows_Systems $AllVMs $KBs 864 | } 865 | else{ 866 | $null = Patch_Windows_Systems $AllVMs 867 | } 868 | Check_Patch_Status $AllVMs 869 | #Patching has completed. It can take up to 10 minutes after the patch is completed to start a reboot. 870 | Log_Verbose_Output $((get-date -format s) + " Patching complete on $VMName - sleeping 10 minutes then checking for outages") 871 | Start-Sleep -Seconds 600 872 | Check_Outages $AllVMs 873 | 874 | 875 | } 876 | 877 | } 878 | else{ 879 | Write-Host -ForegroundColor Red "ERROR: Need to supply -VMName or -VMList if requesting -OnDemandPatch" 880 | } 881 | 882 | } 883 | else{ 884 | write-host "Script requires one of the following arguments" 885 | write-host "-Install creates a scheduled task for automated patching after inventorying systems and building patch beta groups based on criticality" 886 | write-host "-RegularPatch should not be run manually as it kicks off regular patching cycle on systems discovered via -Install" 887 | write-host "-AuditPatches inventories systems and checks for available patches on each system" 888 | write-host "-OnDemandPatch takes a system as an argument and immediately applies available patches" 889 | } 890 | 891 | Disconnect-VIServer * -Confirm:$false 892 | 893 | if($Verbose){ 894 | $VerbosePreference = $oldverbose 895 | } 896 | 897 | 898 | 899 | --------------------------------------------------------------------------------