Public/Invoke-ServerUpdate.ps1

function Invoke-ServerUpdate {
<#
.SYNOPSIS
    Starts the installation of available Windows updates on one or more
    servers.
 
.DESCRIPTION
    For each computer, Invoke-ServerUpdate searches for updates that are
    neither installed nor hidden, downloads them, and installs them through the
    Windows Update Agent.
 
    The Windows Update Agent refuses to install updates from a remote session,
    so the work is performed by a scheduled task that runs as SYSTEM on the
    target computer. The task writes its result to a JSON file under
    %ProgramData%\gcpstools and is removed once it completes.
 
    By default the command returns as soon as the installation has been started
    on every computer. Use -Wait to wait for the installation to finish and
    return what was installed, and -AllowReboot to let a computer restart
    itself when the installation requires it.
 
    Installing updates is a state-changing operation, so the command supports
    -WhatIf and prompts for confirmation unless -Confirm:$false is used.
 
.PARAMETER ComputerName
    One or more computer names to update. Accepts pipeline input, including
    objects with a Name or ComputerName property.
 
.PARAMETER AllowReboot
    Lets the computer restart itself after the installation when a reboot is
    required. Without this switch the computer is left running and
    RebootRequired reports whether a restart is still pending.
 
.PARAMETER Wait
    Waits for the installation to finish and reports the updates that were
    installed instead of returning as soon as it has been started.
 
.PARAMETER TimeoutMinutes
    How long -Wait waits for a computer before giving up on it and reporting
    TimedOut. The installation keeps running on the computer. Defaults to 60.
 
.INPUTS
    System.String[]. Computer names can be piped to this command.
 
.OUTPUTS
    PSCustomObject, one per computer, with these properties:
 
        ComputerName Name of the computer.
        Started Whether the installation was started.
        TimedOut Whether -Wait gave up before the install finished.
        UpdatesFound Updates found, or null when -Wait was not used.
        UpdatesInstalled Updates installed, or null when -Wait was not used.
        RebootRequired Whether a restart is needed to finish the install.
        Updates Titles of the updates that were installed.
        Error Error reported by the installation, if any.
 
.EXAMPLE
    Invoke-ServerUpdate -ComputerName 'APP01'
 
    Starts the installation on one server after confirming the action.
 
.EXAMPLE
    Invoke-ServerUpdate -ComputerName 'APP01', 'SQL01' -WhatIf
 
    Shows which servers would be updated without changing anything.
 
.EXAMPLE
    Invoke-ServerUpdate -ComputerName 'APP01' -Wait -AllowReboot -Confirm:$false
 
    Installs the updates, waits for them to finish, and lets the server reboot
    itself if that is required.
 
.EXAMPLE
    Get-ServerUpdateStatus -ComputerName 'APP01', 'SQL01' -AsObject |
        Where-Object AvailableUpdateCount -gt 0 |
        Invoke-ServerUpdate -Wait
 
    Updates only the servers that report available updates.
 
.LINK
    Get-ServerUpdateStatus
#>

[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
    [ValidateNotNullOrEmpty()]
    [Alias('Name')]
    [string[]]$ComputerName,

    [switch]$AllowReboot,

    [switch]$Wait,

    [ValidateRange(1, 1440)]
    [int]$TimeoutMinutes = 60
)

begin {
    # Runs on the target computer as SYSTEM; results are written as JSON.
    $installScript = @'
param(
    [Parameter(Mandatory = $true)]
    [string]$ResultPath,
 
    [switch]$AllowReboot
)
 
$result = [ordered]@{
    Searched = 0
    Installed = 0
    RebootRequired = $false
    ResultCode = $null
    Updates = @()
    Error = $null
}
 
try {
    $session = New-Object -ComObject 'Microsoft.Update.Session'
    $found = $session.CreateUpdateSearcher().Search('IsInstalled=0 and IsHidden=0')
    $result.Searched = $found.Updates.Count
 
    if ($found.Updates.Count -gt 0) {
        $wanted = New-Object -ComObject 'Microsoft.Update.UpdateColl'
 
        foreach ($update in $found.Updates) {
            if (-not $update.EulaAccepted) {
                $update.AcceptEula()
            }
 
            $null = $wanted.Add($update)
        }
 
        $downloader = $session.CreateUpdateDownloader()
        $downloader.Updates = $wanted
        $null = $downloader.Download()
 
        $ready = New-Object -ComObject 'Microsoft.Update.UpdateColl'
 
        foreach ($update in $wanted) {
            if ($update.IsDownloaded) {
                $null = $ready.Add($update)
                $result.Updates += $update.Title
            }
        }
 
        if ($ready.Count -gt 0) {
            $installer = $session.CreateUpdateInstaller()
            $installer.Updates = $ready
            $installed = $installer.Install()
 
            $result.Installed = $ready.Count
            $result.ResultCode = $installed.ResultCode
            $result.RebootRequired = $installed.RebootRequired
        }
    }
} catch {
    $result.Error = $_.Exception.Message
}
 
[PSCustomObject]$result | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $ResultPath -Encoding UTF8
 
if ($AllowReboot -and $result.RebootRequired) {
    Restart-Computer -Force
}
'@


    $remoteScript = {
        param($ScriptText, $AllowReboot, $WaitForCompletion, $TimeoutMinutes)

        $workDir = Join-Path $env:ProgramData 'gcpstools'
        $null = New-Item -ItemType Directory -Path $workDir -Force
        $scriptPath = Join-Path $workDir 'Install-WindowsUpdate.ps1'
        $resultPath = Join-Path $workDir 'Install-WindowsUpdate.json'

        Set-Content -LiteralPath $scriptPath -Value $ScriptText -Encoding UTF8
        Remove-Item -LiteralPath $resultPath -Force -ErrorAction SilentlyContinue

        $taskName = 'gcpstools-InstallWindowsUpdate'
        $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -ResultPath "{1}"' -f $scriptPath, $resultPath

        if ($AllowReboot) {
            $arguments += ' -AllowReboot'
        }

        $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments
        $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
        $null = Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force
        Start-ScheduledTask -TaskName $taskName

        if (-not $WaitForCompletion) {
            return [PSCustomObject]@{ TimedOut = $false; Result = $null }
        }

        $deadline = (Get-Date).AddMinutes($TimeoutMinutes)

        while ((Get-Date) -lt $deadline -and (Get-ScheduledTask -TaskName $taskName).State -eq 'Running') {
            Start-Sleep -Seconds 10
        }

        if ((Get-ScheduledTask -TaskName $taskName).State -eq 'Running') {
            return [PSCustomObject]@{ TimedOut = $true; Result = $null }
        }

        $null = Unregister-ScheduledTask -TaskName $taskName -Confirm:$false

        $installResult = $null

        if (Test-Path -LiteralPath $resultPath) {
            $installResult = Get-Content -Raw -LiteralPath $resultPath | ConvertFrom-Json
        }

        [PSCustomObject]@{ TimedOut = $false; Result = $installResult }
    }
}

process {
    $serverNumber = 0

    foreach ($server in $ComputerName) {
        $serverNumber++

        if (-not $PSCmdlet.ShouldProcess($server, 'Install available Windows updates')) {
            continue
        }

        Write-Progress -Id 1 -Activity 'Installing Windows updates' -Status "$server ($serverNumber of $($ComputerName.Count))" -PercentComplete (100 * ($serverNumber - 1) / $ComputerName.Count)

        try {
            $outcome = Invoke-Command -ComputerName $server -ErrorAction Stop -ScriptBlock $remoteScript -ArgumentList $installScript, $AllowReboot.IsPresent, $Wait.IsPresent, $TimeoutMinutes
        } catch {
            Write-Error "Failed to start the Windows update installation on '$server': $($_.Exception.Message)"

            [PSCustomObject]@{
                ComputerName     = $server
                Started          = $false
                TimedOut         = $false
                UpdatesFound     = $null
                UpdatesInstalled = $null
                RebootRequired   = $null
                Updates          = @()
                Error            = $_.Exception.Message
            }

            continue
        }

        $installResult = $outcome.Result

        if ($outcome.TimedOut) {
            Write-Warning "The Windows update installation on '$server' did not finish within $TimeoutMinutes minute(s); it is still running."
        }

        [PSCustomObject]@{
            ComputerName     = $server
            Started          = $true
            TimedOut         = [bool]$outcome.TimedOut
            UpdatesFound     = $installResult.Searched
            UpdatesInstalled = $installResult.Installed
            RebootRequired   = $installResult.RebootRequired
            Updates          = @($installResult.Updates)
            Error            = $installResult.Error
        }
    }
}

end {
    Write-Progress -Id 1 -Activity 'Installing Windows updates' -Completed
}
}