Private/Kinds/Updates.ps1

# The Updates Kind: whether Windows is still maintaining itself.
#
# A machine that stopped updating months ago does not announce it. The symptom a Customer
# reports is that it got slow, and the cause is a servicing stack that has been failing
# quietly since a fixed point that this Check is what finds.

# Windows Update result codes, as the agent reports them. Numeric because the strings
# beside them are localised and the estate is German.
$script:UpdateResultSucceeded = 2
$script:UpdateResultFailed    = 4

# How much history to ask for. The agent keeps far more than a Technician needs and
# fetching all of it is slow on a machine that has been running for years.
$script:UpdateHistoryLimit = 100

# Where Windows records that it wants a restart. Read as three independent facts, because
# a pending file rename is ordinary and a pending servicing operation is not.
$script:UpdateRebootKeys = @(
    @{ Name = 'CBS';           Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' }
    @{ Name = 'WindowsUpdate'; Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' }
)

function Get-UpdatesData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $days  = [int](Get-Parameter $Parameters 'Days' 30)
    $since = (Get-Date).AddDays(-$days)

    $history   = @()
    $reachable = $true
    try {
        $session  = New-Object -ComObject Microsoft.Update.Session
        $searcher = $session.CreateUpdateSearcher()
        $total    = $searcher.GetTotalHistoryCount()
        if ($total -gt 0) {
            $history = @($searcher.QueryHistory(0, [Math]::Min($script:UpdateHistoryLimit, $total)) |
                ForEach-Object {
                    [pscustomobject]@{
                        Title      = "$($_.Title)"
                        Date       = $_.Date
                        ResultCode = $_.ResultCode
                    }
                })
        }
    }
    catch {
        # The update service is stoppable, and is stopped on plenty of managed machines.
        # That is a different fact from a machine that has simply never updated.
        $reachable = $false
    }

    $pending = @()
    foreach ($key in $script:UpdateRebootKeys) {
        if (Test-Path $key.Path) { $pending += $key.Name }
    }
    $renames = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' `
        -ErrorAction SilentlyContinue).PendingFileRenameOperations
    if ($renames) { $pending += 'FileRename' }

    [pscustomobject]@{
        PSTypeName       = 'Gutcheck.Data.Updates'
        # When the Gatherer looked. The age of the last update is a Severity input, so a
        # Judge computing it from the clock would not be a pure function of what it was
        # handed - the same data would grade differently tomorrow, and no fixture could
        # pin a boundary. The System Kind records the same thing for the same reason.
        GatheredAt       = Get-Date
        Days             = $days
        Since            = $since
        ServiceReachable = $reachable
        History          = $history
        PendingReboot    = $pending
    }
}

function ConvertTo-UpdatesFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    New-LastUpdateFinding    -Data $Data -Parameters $Parameters
    New-FailedUpdateFinding  -Data $Data -Parameters $Parameters
    New-PendingRebootFinding -Data $Data -Parameters $Parameters
}

function New-LastUpdateFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warnDays = Get-Parameter $Parameters 'LastUpdateWarnDays' 35
    $failDays = Get-Parameter $Parameters 'LastUpdateFailDays' 60

    if (-not (Get-DataProperty $Data 'ServiceReachable')) {
        return New-UnavailableFinding -Category Updates -Check (Get-Text 'Check.Updates.LastSuccessfulUpdate') `
            -Hint (Get-Text 'Hint.Updates.TheWindowsUpdateAgentDid')
    }

    $succeeded = @((Get-DataCollection $Data 'History') |
        Where-Object { (ConvertTo-Number $_.ResultCode) -eq $script:UpdateResultSucceeded -and $_.Date })

    if (-not $succeeded.Count) {
        # An empty history is not an old update; it is no evidence either way, and saying
        # so is the difference between a Technician checking and a Technician assuming.
        return New-UnavailableFinding -Category Updates -Check (Get-Text 'Check.Updates.LastSuccessfulUpdate') `
            -Hint (Get-Text 'Hint.Updates.ThisMachineHasNoSuccessful')
    }

    $gatheredAt = Get-DataProperty $Data 'GatheredAt'
    if (-not $gatheredAt) {
        return New-UnavailableFinding -Category Updates -Check (Get-Text 'Check.Updates.LastSuccessfulUpdate') `
            -Hint (Get-Text 'Hint.Updates.TheCheckDidNotRecord')
    }

    $last = @($succeeded | Sort-Object { [datetime]$_.Date } -Descending)[0]
    $age  = ([datetime]$gatheredAt - [datetime]$last.Date).TotalDays

    New-Finding -Category Updates -Check (Get-Text 'Check.Updates.LastSuccessfulUpdate') `
        -Severity (Get-Severity $age $warnDays $failDays) `
        -Value ((Get-Text 'Value.Updates.LastWithAge') -f [datetime]$last.Date, $age) `
        -Hint (Get-Text 'Hint.Updates.WindowsNotUpdating')
}

function New-FailedUpdateFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warnAbove = Get-Parameter $Parameters 'FailedUpdateWarnAbove' 0

    if (-not (Get-DataProperty $Data 'ServiceReachable')) { return }

    $since  = Get-DataProperty $Data 'Since'
    $failed = @((Get-DataCollection $Data 'History') | Where-Object {
        (ConvertTo-Number $_.ResultCode) -eq $script:UpdateResultFailed -and
        $_.Date -and (-not $since -or [datetime]$_.Date -ge [datetime]$since)
    })

    New-Finding -Category Updates -Check (Get-Text 'Check.Updates.FailedUpdatesPeriod') `
        -Severity (Get-Severity $failed.Count $warnAbove ([double]::MaxValue)) -Value $failed.Count `
        -Hint (Get-Text 'Hint.Updates.RepeatedFailedUpdatesServicingProblem')
}

function New-PendingRebootFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $pending = Get-DataCollection $Data 'PendingReboot'

    # A pending file rename on its own is ordinary - an installer tidying up at next boot.
    # A pending servicing or update operation means the machine is mid-change and some of
    # what Gutcheck measured is in an in-between state.
    $serious = @($pending | Where-Object { $_ -in 'CBS', 'WindowsUpdate' })

    $severity = 'OK'
    if ($serious.Count) { $severity = 'WARN' }

    New-Finding -Category Updates -Check (Get-Text 'Check.Updates.PendingReboot') -Severity $severity `
        -Value $(if ($pending.Count) { $pending -join ', ' } else { 'no' }) `
        -Hint (Get-Text 'Hint.Updates.RestartTheLaptop')
}