Private/Kinds/Storage.ps1

# The Storage Kind: what the machine stores things on, and whether there is room.
#
# The reliability counters behind a solid-state drive's wear need admin rights and ship
# as their own Kind in the Elevated Part. What is here is what a Technician can see
# without elevating: the device, its media, its free space, and whether it is thrashing.

$script:StorageSampleCount = 3

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

    $disks = @()
    try {
        $disks = @(Get-PhysicalDisk -ErrorAction Stop | ForEach-Object {
            [pscustomobject]@{
                DeviceId     = $_.DeviceId
                FriendlyName = $_.FriendlyName
                MediaType    = "$($_.MediaType)"
                BusType      = "$($_.BusType)"
                SizeBytes    = $_.Size
                HealthStatus = "$($_.HealthStatus)"
            }
        })
    }
    catch { }

    $systemDrive = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$env:SystemDrive'" -ErrorAction SilentlyContinue

    $filter = "Name='_Total'"
    # The first read of a formatted-performance class is primed with zeroes; discard it.
    [void](Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter $filter -ErrorAction SilentlyContinue)
    $samples = @(1..$script:StorageSampleCount | ForEach-Object {
        Write-CheckProgress -Status ((Get-Text 'Console.Progress.Sample') -f $_, $script:StorageSampleCount) `
            -Step ($_ - 1) -Of $script:StorageSampleCount
        Wait-Sample
        Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter $filter -ErrorAction SilentlyContinue
    })

    [pscustomobject]@{
        PSTypeName          = 'Gutcheck.Data.Storage'
        PhysicalDisks       = $disks
        SystemDriveLetter   = $env:SystemDrive
        SystemDriveFreeBytes = $systemDrive.FreeSpace
        SystemDriveSizeBytes = $systemDrive.Size
        # The limits this Check was given, for a later Check that asks whether the drive
        # is short of space: see Get-StorageFreeSpace.
        FreeSpaceLimit      = Get-StorageFreeSpaceLimit -Parameters $Parameters
        IdleTimeSamples     = @($samples | ForEach-Object { $_.PercentIdleTime })
    }
}

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

    New-PhysicalDiskFinding -Data $Data -Parameters $Parameters -Situation $Situation
    New-FreeSpaceFinding    -Data $Data -Parameters $Parameters
    New-DiskBusyFinding     -Data $Data -Parameters $Parameters
}

function Test-StorageVirtualDisk {
    <#
    .SYNOPSIS
        Whether a disk is a virtual one: by what it is called and how it is attached, or
        because the machine is a virtual machine. Pure.
    .DESCRIPTION
        A hypervisor presents a file on its own storage as a disk, and what that disk
        calls itself - "HDD" as a rule - says nothing about what it lies on. Whether it
        is fast, and whether it is failing, the host knows and Windows does not.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$Disk, [AllowNull()]$Situation)

    $machine = Select-SituationText -Situation $Situation -Fact Form -Text @{ Virtual = 'yes'; Unknown = '' }
    if ($machine) { return $true }
    if ("$(Get-DataProperty $Disk 'BusType')" -match '(?i)Virtual|Spaces') { return $true }
    "$(Get-DataProperty $Disk 'FriendlyName')" -match '(?i)\bVirtual\b|VMware|\bQEMU\b|\bVBOX\b|\bXen\b|Msft'
}

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

    $disks = (Get-DataCollection $Data 'PhysicalDisks')
    if (-not $disks.Count) {
        # Named so it cannot be mistaken for a device: the per-device Findings below are
        # "Disk 0", "Disk 1". The script abandoned the whole Check here and lost free
        # space and disk-busy with it; a Check that could not run belongs in the Report.
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.Storage.PhysicalDisks') `
            -Hint (Get-Text 'Hint.Storage.NoPhysicalDiskCouldBe')
    }

    # One Finding per device: a machine with a healthy SSD and a failing HDD is not
    # summarised honestly by either one of them.
    foreach ($disk in $disks) {
        $virtual  = Test-StorageVirtualDisk -Disk $disk -Situation $Situation
        $severity = 'OK'
        $meaning  = ''
        $hint     = ''

        # Two different things, and they were one sentence: a disk that is failing, and a
        # disk that is merely slow by construction. And a virtual disk is neither for
        # calling itself an HDD.
        if ("$($disk.HealthStatus)" -ne 'Healthy') {
            $severity = 'FAIL'
            $meaning  = $(if ($virtual) { Get-Text 'Meaning.Storage.VirtualUnhealthy' } else { Get-Text 'Meaning.Storage.Unhealthy' })
            $hint     = $(if ($virtual) { Get-Text 'Hint.Storage.VirtualUnhealthy' }    else { Get-Text 'Hint.Storage.Unhealthy' })
        }
        elseif ("$($disk.MediaType)" -eq 'HDD' -and -not $virtual) {
            $severity = 'FAIL'
            $meaning  = Get-Text 'Meaning.Storage.Hdd'
            $hint     = Get-Text 'Hint.Storage.Hdd'
        }

        New-Finding -Category Storage -Check ((Get-Text 'Check.Storage.Disk') -f $disk.DeviceId) -Severity $severity `
            -Value ('{0} | {1} | {2} | {3} GB | Health: {4}' -f
                $disk.FriendlyName, $disk.MediaType, $disk.BusType,
                [math]::Round(($disk.SizeBytes / 1GB)), $disk.HealthStatus) `
            -Meaning $meaning -Hint $hint -Subject "$($disk.DeviceId)"
    }
}

function Get-StorageFreeSpaceLimit {
    <#
    .SYNOPSIS
        The three limits free space on the system drive is judged by, as a Check
        Definition gives them or as Gutcheck ships them. Pure.
    .DESCRIPTION
        Named as the parameters are, so that what comes back reads as parameters again.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Parameters)

    [pscustomobject]@{
        FreeSpaceFailBelowGB      = Get-Parameter $Parameters 'FreeSpaceFailBelowGB' 15
        FreeSpaceFailBelowPercent = Get-Parameter $Parameters 'FreeSpaceFailBelowPercent' 10
        FreeSpaceWarnBelowPercent = Get-Parameter $Parameters 'FreeSpaceWarnBelowPercent' 20
    }
}

function Get-StorageFreeSpace {
    <#
    .SYNOPSIS
        How much of the system drive is free, and what Severity that earns. Nothing where
        the drive could not be read. Pure.
    .DESCRIPTION
        The one place that says whether the system drive is short of space. The Storage
        Judge asks it with the parameters of its Check Definition. A Check that is handed
        what the Storage Check gathered asks it without any, and is then answered by the
        limits the Storage Check was given, which it handed on with what it gathered - so
        that a Customer who set its own limits is not judged by two.
    .PARAMETER Parameters
        The parameters of the Storage Check. Left out: those in the data, and where the
        data holds none, what Gutcheck ships.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [AllowNull()][hashtable]$Parameters)

    if ($null -eq $Parameters) { $Parameters = ConvertTo-ParameterHashtable (Get-DataProperty $Data 'FreeSpaceLimit') }
    $limit = Get-StorageFreeSpaceLimit -Parameters $Parameters

    $free = ConvertTo-Number (Get-DataProperty $Data 'SystemDriveFreeBytes')
    $size = ConvertTo-Number (Get-DataProperty $Data 'SystemDriveSizeBytes')
    if ($null -eq $free -or $null -eq $size -or $size -le 0) { return $null }

    $freeGB      = $free / 1GB
    $freePercent = $free / $size * 100

    # Two thresholds that disagree are resolved the way the script resolved them: whichever
    # is worse wins. 12 GB free on a 2 TB disk is a comfortable-looking 0.6 % and still
    # fatal, and 9 % of a 1 TB disk is 90 comfortable-looking gigabytes and still fatal.
    $severity = Get-SeverityBelow $freePercent $limit.FreeSpaceWarnBelowPercent $limit.FreeSpaceFailBelowPercent
    if ($freeGB -lt $limit.FreeSpaceFailBelowGB) { $severity = 'FAIL' }

    [pscustomobject]@{
        Drive       = "$(Get-DataProperty $Data 'SystemDriveLetter')"
        FreeGB      = $freeGB
        FreePercent = $freePercent
        Severity    = $severity
    }
}

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

    if ($null -eq $Parameters) { $Parameters = @{} }
    $drive = Get-DataProperty $Data 'SystemDriveLetter'
    $space = Get-StorageFreeSpace -Data $Data -Parameters $Parameters

    if ($null -eq $space) {
        return New-UnavailableFinding -Category Storage -Check ((Get-Text 'Check.Storage.FreeSpace') -f $drive) `
            -Hint (Get-Text 'Hint.Storage.TheSystemDriveDidNot')
    }

    # Declared where the drive is short of space and not where it has room: what a
    # Finding about something that takes room refers to.
    $signal = @()
    if ($space.Severity -in 'WARN', 'FAIL') { $signal = @('system-drive-low') }

    New-Finding -Category Storage -Check ((Get-Text 'Check.Storage.FreeSpace') -f $drive) -Severity $space.Severity `
        -Value ('{0:N1} GB ({1:N0} %)' -f $space.FreeGB, $space.FreePercent) `
        -Hint (Get-Text 'Hint.Storage.FullSSDsGetSlowUpdates') -Signal $signal
}

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

    $warn = Get-Parameter $Parameters 'DiskBusyAtRestWarnPercent' 40
    $fail = Get-Parameter $Parameters 'DiskBusyAtRestFailPercent' 80

    $idle = Get-SampleAverage (Get-DataProperty $Data 'IdleTimeSamples')
    if ($null -eq $idle) {
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.Storage.DiskBusyAtRest') `
            -Hint (Get-Text 'Hint.Storage.TheDiskPerformanceCountersDid')
    }

    $busy = 100 - $idle
    New-Finding -Category Storage -Check (Get-Text 'Check.Storage.DiskBusyAtRest') -Severity (Get-Severity $busy $warn $fail) `
        -Value ('{0:N0} %' -f $busy) `
        -Hint (Get-Text 'Hint.Storage.DiskPermanentlyBusyWhileIdle')
}