Public/Steps/Invoke-HDTValidateStep.ps1

function Invoke-HDTValidateStep {
    <#
        .SYNOPSIS
            Pre-flights a machine before anything destructive runs on it.

        .DESCRIPTION
            MDT's Validate, rebuilt as a step that checks the machine against
            what the sequence is about to do to it:

              - name: Validate
                type: Validate
                minRamMB: 2048
                minDiskGB: 60
                requireUefi: true # optional
                diskNumber: 0 # optional
                requireVariable: [HDTComputerName] # optional

            IT RUNS Select-HDTTargetDisk WITH THE ARGUMENTS DiskPartition WILL
            USE, and that is the reason the step exists. A deployment that is
            going to refuse to choose a disk refuses HERE, in the first step,
            while the machine is still intact - rather than after the technician
            has watched two minutes of progress bar. The protected drive letters
            are built the same way too, so the pre-flight cannot pass a machine
            the real step would then refuse.

            IT REPORTS EVERY FAILED CHECK, NOT THE FIRST. 'not enough RAM',
            followed twenty minutes later by 'no disk large enough', is two trips
            to the bench.

            EVERY CHECK IS OPT-IN except the target disk. A Validate step that
            declares no bound imposes none; what it always does is prove that
            exactly one disk of a usable size is present.

            IT DOES NOT ASK WHETHER THE DISK IS EMPTY, and it used to. Mirroring
            DiskPartition's `wipe` guard meant a machine carrying C: and D: -
            every machine that has ever been deployed - failed step 1 with "the
            step did not declare that it may be replaced", and the fix was to
            repeat `wipe: true` on a step that wipes nothing. A rebuild is the
            normal case. Whether the target may be erased is DiskPartition's
            question, because DiskPartition is what erases it, and that step
            still refuses without `wipe: true`.

            IT READS FACTS OUT OF THE CONTEXT VARIABLES, NEVER CIM. HDTMemory and
            HDTIsUEFI were gathered by phase 02 before the sequence began; a step
            that re-gathered would be a second answer to the same question, and
            PROJECT constraint 4 forbids it reaching CIM at all.

            A REFUSAL IS RETURNED, NOT THROWN. The step contract requires a
            result whose Status is in the closed set, so the refusal's errorId
            travels in the result's Data, where Get-HDTFailureClass reads it and
            classes it Configuration - which is what stops a refusal being
            retried.

        .PARAMETER Step
            A flattened step from Import-HDTSequenceDocument.

        .PARAMETER Context
            A New-HDTExecutionContext context. Its Service catalog must carry a
            Disk service.

        .OUTPUTS
            A New-HDTStepResult. Data carries diskNumber on success, and errorId
            on a refusal.

        .EXAMPLE
            Invoke-HDTValidateStep -Step $step -Context $context
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object] $Step,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object] $Context
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $failure = New-Object -TypeName System.Collections.ArrayList
    $data = [ordered] @{}

    $lookup = {
        param([string] $Name)

        foreach ($key in @($Context.Variable.Keys)) {
            if ([string] $key -eq $Name) { return $Context.Variable[$key] }
        }

        return $null
    }

    try {
        $minimumRamMb = Get-HDTStepProperty -Step $Step -Name 'minRamMB' -Context $Context -Expand -As Long
        $minimumDiskGb = Get-HDTStepProperty -Step $Step -Name 'minDiskGB' -Context $Context -Expand -As Long
        $requireUefi = Get-HDTStepProperty -Step $Step -Name 'requireUefi' -Default $false -Context $Context -Expand -As Bool
        $requireVariable = Get-HDTStepProperty -Step $Step -Name 'requireVariable'
        $minimumTpmVersion = Get-HDTStepProperty -Step $Step -Name 'minTpmVersion' -Context $Context -Expand
    } catch {
        $message = [string] $_.Exception.Message

        Write-HDTLog -Context $Context.Log -Message $message -Severity Error -Event step.fail -Component 'Validate'

        return (New-HDTStepResult -Status Failed -Message $message `
                -Data ([ordered] @{ errorId = 'HDTConfigurationError' }))
    }

    # -- memory -----------------------------------------------------------

    if ($null -ne $minimumRamMb -and $minimumRamMb -gt 0) {
        $memory = & $lookup 'HDTMemory'

        if ($null -eq $memory -or [string]::IsNullOrWhiteSpace([string] $memory)) {
            [void] $failure.Add(('the machine reports no HDTMemory, so the minimum of {0} MB cannot be checked. Gather the machine facts before this step.' -f $minimumRamMb))
        } else {
            $actual = [long] 0
            if (-not [long]::TryParse(([string] $memory).Trim(), [ref] $actual)) {
                [void] $failure.Add(("HDTMemory is '{0}', which is not a number of megabytes." -f $memory))
            } elseif ($actual -lt $minimumRamMb) {
                [void] $failure.Add(('this machine has {0} MB of memory and the sequence requires {1} MB.' -f $actual, $minimumRamMb))
            }
        }
    }

    # -- firmware ---------------------------------------------------------

    if ($requireUefi) {
        $firmware = & $lookup 'HDTIsUEFI'

        $isUefi = $false
        if ($firmware -is [bool]) {
            $isUefi = [bool] $firmware
        } elseif ($null -ne $firmware) {
            # A rules.yaml value arrives as text, so 'True' counts and '0' does not.
            $isUefi = ([string] $firmware).Trim() -eq 'True'
        }

        if (-not $isUefi) {
            [void] $failure.Add('this sequence requires UEFI firmware and HDTIsUEFI is not true for this machine.')
        }
    }

    # -- TPM ---------------------------------------------------------------
    #
    # WINDOWS 11 REQUIRES TPM 2.0, and a machine without one gets all the way
    # through partitioning and imaging before Setup says so - on a disk that has
    # already been wiped. Checking it here costs nothing and is the difference
    # between a refusal and a rebuild.
    #
    # HDTTPMVersion IS Win32_Tpm.SpecVersion'S FIRST COMPONENT, gathered before
    # the sequence began. SpecVersion is '2.0, 0, 1.38'; only the first part is
    # the spec, and Get-HDTMachineFact has already taken it.

    if (-not [string]::IsNullOrWhiteSpace([string] $minimumTpmVersion)) {
        $wanted = $null

        if (-not [version]::TryParse(([string] $minimumTpmVersion).Trim(), [ref] $wanted)) {
            [void] $failure.Add(("minTpmVersion is '{0}', which is not a version number." -f $minimumTpmVersion))
        } else {
            $reported = & $lookup 'HDTTPMVersion'

            # NO TPM IS ITS OWN MESSAGE. 'this machine reports TPM version ' with
            # nothing after it is the kind of sentence that sends a technician to
            # look for a setting that is not the problem.
            if ($null -eq $reported -or [string]::IsNullOrWhiteSpace([string] $reported)) {
                [void] $failure.Add(('this machine reports no TPM, and the sequence requires version {0} or later. On a virtual machine, a TPM has to be added to the VM.' -f $wanted))
            } else {
                $actualVersion = $null

                if (-not [version]::TryParse(([string] $reported).Trim(), [ref] $actualVersion)) {
                    [void] $failure.Add(("HDTTPMVersion is '{0}', which is not a version number." -f $reported))
                } elseif ($actualVersion -lt $wanted) {
                    [void] $failure.Add(('this machine has TPM {0} and the sequence requires {1} or later.' -f
                            $actualVersion, $wanted))
                }
            }
        }
    }

    # -- authored variables -----------------------------------------------

    foreach ($name in @($requireVariable)) {
        $text = ([string] $name).Trim()
        if ($text.Length -eq 0) { continue }

        $value = & $lookup $text
        if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string] $value)) {
            [void] $failure.Add(("the variable {0} is required by this sequence and was never resolved." -f $text))
        }
    }

    # -- the disks --------------------------------------------------------

    $minimumSizeByte = [long] 64424509440
    if ($null -ne $minimumDiskGb -and $minimumDiskGb -gt 0) {
        $minimumSizeByte = [long] $minimumDiskGb * 1073741824
    }

    try {
        $diskService = $Context.Service.GetRequired('Disk', 'Validate')

        $diskRow = @($diskService.GetDisk())
        $partitionRow = @($diskService.GetPartition())
        $volumeRow = @($diskService.GetVolume())
    } catch {
        $message = [string] $_.Exception.Message

        Write-HDTLog -Context $Context.Log -Message $message -Severity Error -Event step.fail -Component 'Validate'

        return (New-HDTStepResult -Status Failed -Message $message)
    }

    if ($null -ne $minimumDiskGb -and $minimumDiskGb -gt 0) {
        $bigEnough = @($diskRow | Where-Object { [long] $_.SizeBytes -ge $minimumSizeByte })

        if ($bigEnough.Count -eq 0) {
            $present = 'none'
            if ($diskRow.Count -gt 0) {
                $present = (@($diskRow | ForEach-Object { 'disk {0} = {1} bytes' -f [int] $_.Number, [long] $_.SizeBytes }) -join ', ')
            }

            [void] $failure.Add(('no disk on this machine holds at least {0} GB ({1}).' -f $minimumDiskGb, $present))
        }
    }

    # The same call DiskPartition will make, with the same guards, so a machine
    # that passes here cannot be refused by the step that follows.
    $selectArgument = @{
        Disk               = $diskRow
        Partition          = $partitionRow
        Volume             = $volumeRow
        MinimumSizeByte    = $minimumSizeByte
        ProtectDriveLetter = [string[]] @(
            [string] $Context.WorkspaceRoot
            [string] $Context.Log.LogPath
        )
    }

    # THE PRE-FLIGHT NEVER REFUSES A DISK FOR HAVING DATA ON IT, and a real VM
    # is why. This step used to mirror DiskPartition's `wipe` guard, so a
    # machine carrying C: and D: - which is every machine that has ever been
    # deployed - failed step 1 with "the step did not declare that it may be
    # replaced". A rebuild is the normal case, and the answer was to repeat
    # `wipe: true` on a step that wipes nothing.
    #
    # WHETHER THE TARGET MAY BE ERASED IS DiskPartition'S QUESTION, because
    # DiskPartition is what erases it, and that step still refuses without
    # `wipe: true`. What the pre-flight asks is whether a usable disk of the
    # right size is present at all - and it asks it BEFORE anything is touched,
    # which is the whole point of asking early.
    $selectArgument['AllowExistingData'] = $true

    $diskNumber = Get-HDTStepProperty -Step $Step -Name 'diskNumber' -Context $Context -Expand -As Int
    if ($null -ne $diskNumber) {
        $selectArgument['DiskNumber'] = [int] $diskNumber
    }

    try {
        $target = Select-HDTTargetDisk @selectArgument -WarningAction SilentlyContinue
        $data['diskNumber'] = [int] $target.Number
    } catch {
        $errorId = ([string] $_.FullyQualifiedErrorId).Split(',')[0]
        $data['errorId'] = $errorId

        [void] $failure.Add([string] $_.Exception.Message)
    }

    # -- the verdict ------------------------------------------------------

    if ($failure.Count -gt 0) {
        $message = "this machine did not pass the pre-flight:{0}{1}" -f [System.Environment]::NewLine,
        (@($failure | ForEach-Object { ' - {0}' -f $_ }) -join [System.Environment]::NewLine)

        $data['failedCheck'] = [string[]] @($failure)

        Write-HDTLog -Context $Context.Log -Message $message -Severity Error -Event step.fail -Component 'Validate' -Data $data

        return (New-HDTStepResult -Status Failed -Message $message -Data $data)
    }

    $message = 'this machine passed the pre-flight; disk {0} is the deployment target.' -f $data['diskNumber']

    Write-HDTLog -Context $Context.Log -Message $message -Component 'Validate' -Data $data

    return (New-HDTStepResult -Status Completed -Message $message -Data $data)
}