Private/Kinds/Service.ps1

# The Service Kind: the Windows services an application cannot work without.
#
# The business software a German office runs on - accounting, payroll, document management,
# the DATEV workplace - is rarely one process. It is a client plus a database engine, a
# licence service or an update agent running as a Windows service, and "the program will
# not start" is very often "its service is stopped". The App Kind cannot see that: it looks
# at processes the Technician's session owns, and a service belongs to nobody's session.
#
# Which services matter is per application, so it is a Check Definition parameter. Names
# are matched as patterns against both the service name and its display name, because
# vendors rename one or the other between versions and a Definition should survive that.
#
# Crashes come from the Stability Check, handed over as -Observed, the way the App Kind
# receives application crashes. The Service Control Manager logs a crash under the
# service's display name, in the machine's language; that is the same display name this
# Gatherer reads off the same machine, so they are compared as they are.

function Get-ServiceData {
    <#
    .PARAMETER Observed
        What earlier Checks gathered. Only the Stability Check's service failures are read.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [hashtable]$Parameters = @{},
        [AllowNull()][hashtable]$Observed = @{}
    )

    $patterns = @(Get-Parameter $Parameters 'Services' @() | Where-Object { "$_".Trim() } | ForEach-Object { "$_".Trim() })
    $optional = @(Get-Parameter $Parameters 'OptionalServices' @() | Where-Object { "$_".Trim() } | ForEach-Object { "$_".Trim() })

    $installed = @()
    if ($patterns.Count -or $optional.Count) {
        $installed = @(Get-CimInstance Win32_Service -ErrorAction SilentlyContinue)
    }

    $matched = @(Select-ServiceMatch -Pattern (@($patterns) + @($optional)) -Service $installed)

    $stability = $null
    if ($Observed -and $Observed.ContainsKey('Stability')) { $stability = $Observed['Stability'] }

    [pscustomobject]@{
        PSTypeName        = 'Gutcheck.Data.Service'
        Patterns          = $patterns
        OptionalPatterns  = $optional
        Services          = $matched
        # Absent when the Stability Check did not run, which is a different fact from a
        # service that never crashed.
        StabilityObserved = $null -ne $stability
        StabilityDays     = Get-DataProperty $stability 'Days'
        ObservedFailures  = Get-DataCollection $stability 'ServiceFailures'
    }
}

function Select-ServiceMatch {
    <#
    .SYNOPSIS
        The services each pattern names, one row per pattern and service. Pure.
    .DESCRIPTION
        One row per pair, so the Judge can tell a pattern that matched nothing from one
        that matched several without matching anything itself. Separate from the Gatherer
        so what Gutcheck understood from a Check Definition can be tested without a
        machine, the way the Server Kind parses its entries. A pattern that is not a valid
        regular expression matches nothing rather than ending the Check, and so surfaces
        as the Finding for a pattern that matched nothing.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowEmptyCollection()][string[]]$Pattern = @(),
        [AllowNull()][AllowEmptyCollection()][object[]]$Service = @()
    )

    foreach ($p in $Pattern) {
        foreach ($s in @($Service | Where-Object { $_ })) {
            $hit = $false
            try { $hit = ("$($s.Name)" -match $p) -or ("$($s.DisplayName)" -match $p) }
            catch { $hit = $false }
            if (-not $hit) { continue }
            [pscustomobject]@{
                Pattern          = $p
                Name             = "$($s.Name)"
                DisplayName      = "$($s.DisplayName)"
                State            = "$($s.State)"
                StartMode        = "$($s.StartMode)"
                DelayedAutoStart = $s.DelayedAutoStart
                StartName        = "$($s.StartName)"
                PathName         = "$($s.PathName)"
                ExitCode         = $s.ExitCode
            }
        }
    }
}

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

    $required = Get-DataCollection $Data 'Patterns'
    $optional = Get-DataCollection $Data 'OptionalPatterns'
    if (-not $required.Count -and -not $optional.Count) {
        return New-Finding -Category Apps -Check (Get-Text 'Check.Service.Services') -Severity INFO `
            -Value (Get-Text 'Value.Service.NoneConfigured') `
            -Hint (Get-Text 'Hint.Service.ThisCheckDefinitionNamesNo')
    }

    $services = Get-DataCollection $Data 'Services'

    foreach ($pattern in @(@($required) + @($optional))) {
        if (@($services | Where-Object { $_.Pattern -eq $pattern }).Count) { continue }
        New-Finding -Category Apps -Check ((Get-Text 'Check.Service.Service') -f $pattern) -Severity INFO `
            -Value (Get-Text 'Value.Service.NotInstalled') `
            -Hint (Get-Text 'Hint.Service.NoServiceMatches')
    }

    # A service matched by two patterns is still one service, and judging it twice would
    # count its crashes twice. It is optional only when nothing required named it.
    $unique = @($services | Sort-Object Name -Unique)
    foreach ($service in $unique) {
        $name = $service.Name
        $isOptional = -not @($services | Where-Object { $_.Name -eq $name -and $required -contains $_.Pattern }).Count
        New-ServiceStateFinding -Service $service -Optional:$isOptional
    }

    New-ServiceCrashFinding -Data $Data -Service $unique -Parameters $Parameters
}

function New-ServiceStateFinding {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Service,

        # Named by OptionalServices: a service with legitimate reasons to be off on some
        # machines, such as a component that runs on one machine per site. Reported with
        # its state, so a second-level Technician can see it, but not judged for being off.
        [switch]$Optional
    )

    $check = (Get-Text 'Check.Service.Service') -f $Service.DisplayName
    $mode  = ConvertTo-ServiceStartModeText -StartMode $Service.StartMode

    switch ("$($Service.State)") {
        'Running' {
            return New-Finding -Category Apps -Check $check -Severity OK `
                -Value ((Get-Text 'Value.Service.Running') -f $mode)
        }
        'Stopped' {
            if ($Optional) {
                return New-Finding -Category Apps -Check $check -Severity INFO `
                    -Value ((Get-Text 'Value.Service.Stopped') -f $mode)
            }
            if ($Service.StartMode -eq 'Auto') {
                # Meant to be running and is not: whatever depends on it is failing now.
                return New-Finding -Category Apps -Check $check -Severity FAIL `
                    -Value ((Get-Text 'Value.Service.StoppedExit') -f $mode, $Service.ExitCode) `
                    -Hint (Get-Text 'Hint.Service.AutomaticButStopped')
            }
            if ($Service.StartMode -eq 'Disabled') {
                return New-Finding -Category Apps -Check $check -Severity WARN `
                    -Value ((Get-Text 'Value.Service.Stopped') -f $mode) `
                    -Hint (Get-Text 'Hint.Service.Disabled')
            }
            # Started on demand, by a trigger or by the application itself. Stopped is its
            # resting state, and a Report that called it a problem would be crying wolf.
            return New-Finding -Category Apps -Check $check -Severity INFO `
                -Value ((Get-Text 'Value.Service.Stopped') -f $mode)
        }
        default {
            # Start Pending, Stop Pending, Paused: a service that stays in one of these for
            # the length of a Run is hung, and the application waiting on it is too.
            return New-Finding -Category Apps -Check $check -Severity WARN `
                -Value ((Get-Text 'Value.Service.OtherState') -f $Service.State, $mode) `
                -Hint (Get-Text 'Hint.Service.Stuck')
        }
    }
}

function ConvertTo-ServiceStartModeText {
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$StartMode)

    switch ($StartMode) {
        'Auto'     { return (Get-Text 'Value.Service.StartMode.Auto') }
        'Manual'   { return (Get-Text 'Value.Service.StartMode.Manual') }
        'Disabled' { return (Get-Text 'Value.Service.StartMode.Disabled') }
    }
    "$StartMode"
}

function New-ServiceCrashFinding {
    [CmdletBinding()]
    param(
        [AllowNull()]$Data,
        [AllowNull()][AllowEmptyCollection()][object[]]$Service = @(),
        [hashtable]$Parameters
    )

    $warn = Get-Parameter $Parameters 'ServiceCrashWarnAbove' 0
    $fail = Get-Parameter $Parameters 'ServiceCrashFailAbove' 3

    if (-not @($Service).Count) { return }

    $check = Get-Text 'Check.Service.Crashes'
    if (-not (Get-DataProperty $Data 'StabilityObserved')) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value (Get-Text 'Value.Service.CrashesNotRead') `
            -Hint (Get-Text 'Hint.Service.StabilityDidNotRun')
    }

    $failures = Get-DataCollection $Data 'ObservedFailures'
    $days     = Get-DataProperty $Data 'StabilityDays'

    $counted = @(foreach ($s in $Service) {
        $count = @($failures | Where-Object { "$($_.Service)" -eq $s.DisplayName }).Count
        if ($count) { [pscustomobject]@{ Name = $s.DisplayName; Count = $count } }
    })

    if (-not $counted.Count) {
        return New-Finding -Category Apps -Check $check -Severity OK `
            -Value ((Get-Text 'Value.Service.NoCrashes') -f $days)
    }

    $total = ($counted | Measure-Object Count -Sum).Sum
    $described = ($counted | ForEach-Object { '{0} {1}x' -f $_.Name, $_.Count }) -join ', '

    New-Finding -Category Apps -Check $check -Severity (Get-Severity $total $warn $fail) `
        -Value ((Get-Text 'Value.Service.Crashes') -f $described, $days) `
        -Hint (Get-Text 'Hint.Service.Crashed')
}

function ConvertTo-ServiceSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    New-Section -Title (Get-Text 'Title.Service.Services') -Row @(
        (Get-DataCollection $Data 'Services') | Sort-Object Name -Unique |
            Select-Object Name, DisplayName, State, StartMode, DelayedAutoStart, StartName, PathName
    )
}