Private/Kinds/Server.ps1

# The Server Kind: whether the machines an application depends on can be reached.
#
# The first Kind whose parameters are genuinely per-Customer. Every other Kind asks the
# same questions of every machine; this one cannot be performed at all without knowing
# which Servers matter, and those differ per Customer by definition. It is therefore also
# the first real exercise of a Check Definition configuring rather than merely tuning.
#
# The same Kind serves an application's Servers and Servers a Technician supplied directly.
# There is no difference between them worth a second implementation: a Server is a host,
# optionally with ports, and the questions are the same either way.

# How many times each port is tried and how many pings are sent. Not Check Definition
# parameters, for the reason Private/Sampling.ps1 gives: a Customer may disagree about a
# threshold, not about how many attempts make a measurement. One connect is not evidence -
# a firewall that drops one connection in five is exactly what makes an application feel
# unreliable, and a single successful probe would report it healthy.
$script:ServerTcpAttempts = 5
$script:ServerPingCount   = 20

function ConvertTo-ServerTarget {
    <#
    .SYNOPSIS
        Parses one Server entry into a host and its ports. Pure, and reaches nothing.
    .DESCRIPTION
        A Server is written as "host", "host:port" or "host:port,port". Parsing is separate
        from probing so that what Gutcheck understood from a Check Definition can be tested
        without a network, which matters because a mistyped entry in the Checks Repo would
        otherwise surface as a Customer's server being unreachable.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Entry)

    if (-not $Entry -or -not "$Entry".Trim()) { return }

    $parts    = "$Entry".Trim() -split ':', 2
    $hostName = $parts[0].Trim()
    if (-not $hostName) { return }

    $ports = @()
    if ($parts.Count -gt 1) {
        $ports = @($parts[1] -split ',' |
            ForEach-Object { $_.Trim() } |
            Where-Object { $_ -match '^\d+$' } |
            ForEach-Object { [int]$_ } |
            Where-Object { $_ -ge 1 -and $_ -le 65535 })
    }

    [pscustomobject]@{
        PSTypeName  = 'Gutcheck.ServerTarget'
        Entry       = "$Entry".Trim()
        HostName    = $hostName
        Ports       = $ports
        # An address literal has nothing to resolve, and reporting DNS for one would be
        # reporting on a lookup that never happened.
        IsIpLiteral = $hostName -match '^\d{1,3}(\.\d{1,3}){3}$'
    }
}

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

    $entries = @(Get-Parameter $Parameters 'Servers' @())

    $servers = @(foreach ($entry in $entries) {
        $target = ConvertTo-ServerTarget -Entry $entry
        if (-not $target) { continue }

        $resolution = $null
        if (-not $target.IsIpLiteral) { $resolution = Resolve-HostAddress -HostName $target.HostName }

        # A host that does not resolve cannot be connected to or pinged by name, so the
        # remaining probes would only produce noise about a name that does not exist.
        $resolved = $target.IsIpLiteral -or $resolution.Resolved

        $portResults = @()
        $latency     = $null
        if ($resolved) {
            $portResults = @(foreach ($port in $target.Ports) {
                $times = @(1..$script:ServerTcpAttempts | ForEach-Object {
                    $ms = Measure-TcpConnect -HostName $target.HostName -Port $port
                    Start-Sleep -Milliseconds 200
                    $ms
                })
                $ok = @($times | Where-Object { $null -ne $_ })
                [pscustomobject]@{
                    Port      = $port
                    Attempts  = $script:ServerTcpAttempts
                    Successes = $ok.Count
                    AverageMs = $(if ($ok.Count) { [math]::Round(($ok | Measure-Object -Average).Average, 1) } else { $null })
                    MaximumMs = $(if ($ok.Count) { ($ok | Measure-Object -Maximum).Maximum } else { $null })
                }
            })
            $latency = Measure-Latency -Target $target.HostName -Count $script:ServerPingCount
        }

        [pscustomobject]@{
            Entry       = $target.Entry
            HostName    = $target.HostName
            Ports       = $target.Ports
            IsIpLiteral = $target.IsIpLiteral
            Resolution  = $resolution
            PortResults = $portResults
            Latency     = $latency
        }
    })

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.Data.Server'
        Servers    = $servers
    }
}

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

    $servers = Get-DataCollection $Data 'Servers'
    if (-not $servers.Count) {
        # A Check Definition that names a Kind but supplies it nothing has not been
        # written yet, and a Report that simply omits it lets that go unnoticed.
        return New-Finding -Category Network -Check (Get-Text 'Check.Server.Servers') -Severity INFO -Value (Get-Text 'Value.Server.NoneConfigured') `
            -Hint (Get-Text 'Hint.Server.ThisCheckDefinitionNamesNo')
    }

    foreach ($server in $servers) {
        New-ServerDnsFinding -Server $server -Parameters $Parameters

        # Everything below needs a name that resolved. Asking anything else of a host that
        # does not exist produces Findings about a typo, dressed as Findings about a
        # network - which is how a Technician ends up looking at a firewall for an hour.
        if (-not (Test-ServerResolved -Server $server)) { continue }

        New-ServerPortFinding -Server $server -Parameters $Parameters
        New-ServerPingFinding -Server $server -Parameters $Parameters
    }
}

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

    $latency = @((Get-DataCollection $Data 'Servers') |
        ForEach-Object { $_.Latency } | Where-Object { $_ })
    if (-not $latency.Count) { return }

    New-Section -Title (Get-Text 'Title.Server.LatencyTestsServers') -Row @(
        $latency | ForEach-Object { $_ | Select-Object Target, Sent, Lost, LossPercent, AverageMs, MaximumMs }
    )
}

function Test-ServerResolved {
    <#
    .SYNOPSIS
        Whether this Server has a name that can be reached at all.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)]$Server)

    if (Get-DataProperty $Server 'IsIpLiteral') { return $true }
    [bool](Get-DataProperty (Get-DataProperty $Server 'Resolution') 'Resolved')
}

function New-ServerDnsFinding {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Server, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'ServerDnsWarnMs' 500
    $fail = Get-Parameter $Parameters 'ServerDnsFailMs' ([double]::MaxValue)

    # Nothing to resolve, so nothing to report. An address literal is not a DNS success.
    if (Get-DataProperty $Server 'IsIpLiteral') { return }

    $hostName   = Get-DataProperty $Server 'HostName'
    $resolution = Get-DataProperty $Server 'Resolution'

    if (-not (Get-DataProperty $resolution 'Resolved')) {
        return New-Finding -Category Network -Check ((Get-Text 'Check.Server.Dns') -f $hostName) -Severity FAIL `
            -Value (Get-Text 'Value.Server.CannotBeResolved') `
            -Hint (Get-Text 'Hint.Server.WrongNameDNSProblemOr')
    }

    $ms = ConvertTo-Number (Get-DataProperty $resolution 'Milliseconds')
    if ($null -eq $ms) {
        return New-UnavailableFinding -Category Network -Check ((Get-Text 'Check.Server.Dns') -f $hostName) `
            -Hint (Get-Text 'Hint.Shared.NameResolvedNotTimed')
    }

    New-Finding -Category Network -Check ((Get-Text 'Check.Server.Dns') -f $hostName) `
        -Severity (Get-Severity $ms $warn $fail) -Value ('{0:N0} ms' -f $ms) `
        -Hint (Get-Text 'Hint.Server.SlowNameResolutionDelaysEvery')
}

function New-ServerPortFinding {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Server, [hashtable]$Parameters)

    $latencyWarn = Get-Parameter $Parameters 'ServerTcpWarnMs' 150
    $latencyFail = Get-Parameter $Parameters 'ServerTcpFailMs' ([double]::MaxValue)

    $hostName = Get-DataProperty $Server 'HostName'

    foreach ($port in (Get-DataCollection $Server 'PortResults')) {
        $check     = 'TCP {0}:{1}' -f $hostName, $port.Port
        $successes = ConvertTo-Number $port.Successes
        $attempts  = ConvertTo-Number $port.Attempts
        if ($null -eq $attempts -or $attempts -le 0) { $attempts = $script:ServerTcpAttempts }

        if (-not $successes) {
            # Refused outright: a distinct Finding from a port that answers slowly or
            # intermittently, because the thing to go and look at is different.
            New-Finding -Category Network -Check $check -Severity FAIL `
                -Value ((Get-Text 'Value.Server.NoConnection') -f $attempts) `
                -Hint (Get-Text 'Hint.Server.NothingIsListeningOrA')
            continue
        }

        $average = ConvertTo-Number $port.AverageMs

        # Two independent ways for a port to be bad: some attempts failed, or the ones that
        # succeeded were slow. Whichever reads worse decides.
        $missed = 'OK'
        if ($successes -lt $attempts) { $missed = 'WARN' }
        $slow = 'OK'
        if ($null -ne $average) { $slow = Get-Severity $average $latencyWarn $latencyFail }

        New-Finding -Category Network -Check $check -Severity (Get-WorstSeverity $missed $slow) `
            -Value ((Get-Text 'Value.Server.ConnectResult') -f $average, $port.MaximumMs, $successes, $attempts) `
            -Hint (Get-Text 'Hint.Server.FailedOrSlowConnectsFirewall')
    }
}

function New-ServerPingFinding {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Server, [hashtable]$Parameters)

    $lossWarn = Get-Parameter $Parameters 'ServerLossWarnPercent'  0
    $lossFail = Get-Parameter $Parameters 'ServerLossFailPercent'  2
    $msWarn   = Get-Parameter $Parameters 'ServerLatencyWarnMs'   50
    $msFail   = Get-Parameter $Parameters 'ServerLatencyFailMs'   ([double]::MaxValue)

    $hostName = Get-DataProperty $Server 'HostName'
    $check    = 'Ping {0}' -f $hostName
    $latency  = Get-DataProperty $Server 'Latency'
    if (-not $latency) { return }

    $loss     = ConvertTo-Number (Get-DataProperty $latency 'LossPercent')
    $average  = ConvertTo-Number (Get-DataProperty $latency 'AverageMs')
    $maximum  = ConvertTo-Number (Get-DataProperty $latency 'MaximumMs')

    if ($null -eq $average) {
        # No reply at all. Whether that matters depends on whether anything else got
        # through: plenty of Customer networks block ICMP to servers that work perfectly.
        $ports     = Get-DataCollection $Server 'PortResults'
        $anyPortOk = @($ports | Where-Object { (ConvertTo-Number $_.Successes) -gt 0 }).Count -gt 0

        if ($ports.Count -and $anyPortOk) {
            return New-Finding -Category Network -Check $check -Severity INFO `
                -Value (Get-Text 'Value.Server.NoIcmpReply')
        }
        return New-Finding -Category Network -Check $check -Severity FAIL -Value (Get-Text 'Value.Server.NoReply') `
            -Hint (Get-Text 'Hint.Server.HostUnreachableOrPingBlocked')
    }

    $severity = Get-WorstSeverity (Get-Severity $loss $lossWarn $lossFail) (Get-Severity $average $msWarn $msFail)

    New-Finding -Category Network -Check $check -Severity $severity `
        -Value ((Get-Text 'Value.Server.PingResult') -f $average, $maximum, $loss) `
        -Hint (Get-Text 'Hint.Server.PacketLossMakesClientServer')
}