Public/Select-HDTTargetDisk.ps1

function Select-HDTTargetDisk {
    <#
        .SYNOPSIS
            Chooses the one disk a deployment may wipe, or refuses to choose.
 
        .DESCRIPTION
            The engine refuses to guess when the disk is unexpected
            (multiple disks, existing data volumes, USB source disk in range).
            DiskPartition requires either an unambiguous target or an explicit
            diskNumber. Wiping the wrong disk is the single most destructive
            failure mode in this class of tool."
 
            This is that refusal, as pure logic over the three flat listings
            IDiskService returns. It touches no hardware, opens no handle and
            takes no service: it is handed rows and returns one of them, so
            every rule below is provable under Pester with no disk attached.
 
            SEVEN EXCLUSION RULES, IN TWO CLASSES. Every rule is evaluated for
            every disk and its reason is recorded, so a refusal can print the
            whole table rather than a verdict a technician cannot act on.
 
              # Rule Excludes -DiskNumber
              1 IsSystem or IsBoot the disk this machine runs from NEVER
              2 holds a protected letter the disk carrying the workspace NEVER
              3 IsReadOnly a disk that cannot be written NEVER
              4 IsOffline a disk HDT cannot online NEVER
              5 existing data a disk with a file system on it no
              6 BusType USB the stick the technician booted yes, warned
              7 under the minimum size too small to hold Windows yes, warned
 
            RULE 1 IS ABSOLUTE ON PURPOSE. The alternative - trusting the
            diskNumber an author typed - means one wrong number in a YAML file
            destroys the machine running the sequence, which on a developer's
            workstation is the developer's workstation. This host's own captured
            row is a fixture in the suite for exactly that reason.
 
            RULE 2 IS RULE 1 FOR THE SHARE. DiskPartition passes the drive
            letters of the workspace root and the log path, so HDT cannot wipe
            the disk it is reading its own instructions from.
 
            RULE 5 IS OVERRIDDEN BY THE SEQUENCE, NOT BY THE NUMBER. A disk with
            data on it is used when the step declares it, which reaches here as
            -AllowExistingData. Naming the disk explicitly is not the same
            statement as declaring that its contents are expendable.
 
            NO RULE FILTERS ON BUS TYPE EXPECTING A VIRTUAL VALUE. In the lab, a
            Generation 2 VM's own system disk reports BusType = SAS, not SCSI
            and not Virtual. USB is the only bus type that excludes anything.
 
            The three refusals carry their own error ids -
            HDTAmbiguousTargetError, HDTUnsafeTargetError and
            HDTNoTargetDiskError - and Get-HDTFailureClass classifies all three
            as Configuration, so a refusal to wipe ends the run instead of being
            retried three times.
 
        .PARAMETER Disk
            Every disk on the machine, as IDiskService.GetDisk() returns them.
            An empty collection is a refusal, not a crash.
 
        .PARAMETER Partition
            Every partition on every disk, as IDiskService.GetPartition()
            returns them. Used to learn which drive letters a disk carries;
            omitted, no disk carries any.
 
        .PARAMETER Volume
            Every lettered volume, as IDiskService.GetVolume() returns them.
            Used with -Partition to learn whether a disk carries a file system.
 
        .PARAMETER DiskNumber
            The disk the sequence named. Overrides rules 6 and 7 with a warning
            and never overrides rules 1 to 5.
 
        .PARAMETER MinimumSizeByte
            The smallest disk that can hold Windows. Defaults to 60 GB.
 
        .PARAMETER ProtectDriveLetter
            Drive letters the deployment is reading from or writing to. A disk
            carrying one of them is never the target. Matched
            case-insensitively, with or without a colon.
 
        .PARAMETER AllowExistingData
            The sequence declared that the target's contents are expendable.
            Without it, a disk carrying a formatted volume is excluded.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject - the single disk row.
 
        .EXAMPLE
            Select-HDTTargetDisk -Disk $disk -Partition $partition -Volume $volume
 
            The unattended case: exactly one disk qualifies, or the run stops.
 
        .EXAMPLE
            Select-HDTTargetDisk -Disk $disk -DiskNumber 1 -AllowExistingData
 
            The authored case: the sequence names the disk and declares that
            wiping it is intended.
 
        .EXAMPLE
            Select-HDTTargetDisk -Disk $disk -ProtectDriveLetter 'X', 'Z'
 
            WinPE's scratch space and the mapped deployment share, kept out of
            range of the thing that formats disks.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowEmptyCollection()]
        [object[]] $Disk,

        [Parameter()]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]] $Partition,

        [Parameter()]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]] $Volume,

        [Parameter()]
        [int] $DiskNumber,

        [Parameter()]
        [long] $MinimumSizeByte = 64424509440,

        [Parameter()]
        [AllowNull()]
        [AllowEmptyCollection()]
        [string[]] $ProtectDriveLetter,

        [Parameter()]
        [switch] $AllowExistingData
    )

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

    $partitionRow = @()
    if ($null -ne $Partition) { $partitionRow = @($Partition) }

    $volumeRow = @()
    if ($null -ne $Volume) { $volumeRow = @($Volume) }

    # 'z:' and 'Z' are the same letter. Everything is compared as one uppercase
    # character so a test is not a test of which form the author happened to type.
    $protected = @()
    if ($null -ne $ProtectDriveLetter) {
        foreach ($entry in $ProtectDriveLetter) {
            $text = ([string] $entry).Trim().TrimEnd(':')
            if ($text.Length -gt 0) { $protected += $text.Substring(0, 1).ToUpperInvariant() }
        }
    }

    # -- one pass, a reason per disk ------------------------------------------

    $assessment = New-Object -TypeName System.Collections.ArrayList

    foreach ($current in @($Disk)) {
        $number = [int] $current.Number

        $letter = @()
        foreach ($row in $partitionRow) {
            if ([int] $row.DiskNumber -ne $number) { continue }

            $text = ([string] $row.DriveLetter).Trim().TrimEnd(':')
            if ($text.Length -gt 0) { $letter += $text.Substring(0, 1).ToUpperInvariant() }
        }

        $data = @()
        foreach ($row in $volumeRow) {
            $text = ([string] $row.DriveLetter).Trim().TrimEnd(':')
            if ($text.Length -eq 0) { continue }

            $normalised = $text.Substring(0, 1).ToUpperInvariant()
            if ($letter -notcontains $normalised) { continue }
            if ([string]::IsNullOrWhiteSpace([string] $row.FileSystem)) { continue }

            $data += ('{0} ({1})' -f $normalised, [string] $row.FileSystem)
        }

        $reason = New-Object -TypeName System.Collections.ArrayList

        # 1 - the disk this machine is running from. Never overridable.
        if ($current.IsSystem -or $current.IsBoot) {
            [void] $reason.Add([pscustomobject] @{
                    Absolute = $true
                    Text     = ('disk {0} is the disk this machine booted from' -f $number)
                })
        }

        # 2 - the disk the deployment is reading from. Never overridable.
        $held = @($letter | Where-Object { $protected -contains $_ })
        if ($held.Count -gt 0) {
            [void] $reason.Add([pscustomobject] @{
                    Absolute = $true
                    Text     = ('disk {0} holds drive letter {1}, which this deployment is reading from or writing to' -f $number, ($held -join ', '))
                })
        }

        # 3 - a disk that cannot be written.
        if ($current.IsReadOnly) {
            [void] $reason.Add([pscustomobject] @{
                    Absolute = $true
                    Text     = ('disk {0} is read-only' -f $number)
                })
        }

        # 4 - IDiskService has no way to bring a disk online, and pretending
        # otherwise fails later and less clearly.
        if ($current.IsOffline) {
            [void] $reason.Add([pscustomobject] @{
                    Absolute = $true
                    Text     = ('disk {0} is offline, and HDT cannot bring a disk online' -f $number)
                })
        }

        # 5 - existing data. Declared away by the sequence, not by the number.
        if ((([string] $current.PartitionStyle) -ne 'RAW') -and ($data.Count -gt 0) -and -not $AllowExistingData) {
            [void] $reason.Add([pscustomobject] @{
                    Absolute = $true
                    Text     = ('disk {0} carries existing data on volume {1}, and the step did not declare that it may be replaced' -f $number, ($data -join ', '))
                })
        }

        # 6 - the stick the technician booted from, "in range" (DESIGN 9.1).
        if ((([string] $current.BusType).Trim()) -eq 'USB') {
            [void] $reason.Add([pscustomobject] @{
                    Absolute = $false
                    Text     = ('disk {0} is a USB disk' -f $number)
                })
        }

        # 7 - too small to hold Windows.
        if ([long] $current.SizeBytes -lt $MinimumSizeByte) {
            [void] $reason.Add([pscustomobject] @{
                    Absolute = $false
                    Text     = ('disk {0} is {1} bytes, under the minimum of {2} bytes' -f $number, [long] $current.SizeBytes, $MinimumSizeByte)
                })
        }

        [void] $assessment.Add([pscustomobject] @{
                Number = $number
                Row    = $current
                Reason = [object[]] @($reason)
            })
    }

    $presentNumber = [int[]] @($assessment | ForEach-Object { $_.Number })

    # -- the sequence named a disk --------------------------------------------

    if ($PSBoundParameters.ContainsKey('DiskNumber')) {

        $named = @($assessment | Where-Object { $_.Number -eq $DiskNumber })

        if ($named.Count -eq 0) {
            $present = 'none'
            if ($presentNumber.Count -gt 0) { $present = ($presentNumber -join ', ') }

            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -ErrorId 'HDTConfigurationError' `
                        -TargetObject $DiskNumber -Category InvalidArgument `
                        -Message ('diskNumber {0} does not exist on this machine. The disk numbers present are {1}.' -f $DiskNumber, $present)))
        }

        if ($named.Count -gt 1) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -ErrorId 'HDTAmbiguousTargetError' `
                        -TargetObject $DiskNumber -Category InvalidResult `
                        -Message ('{0} disks report the number {1}, so naming it does not identify one. HDT will not guess which to wipe.' -f $named.Count, $DiskNumber)))
        }

        $absolute = @($named[0].Reason | Where-Object { $_.Absolute })
        if ($absolute.Count -gt 0) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -ErrorId 'HDTUnsafeTargetError' `
                        -TargetObject $DiskNumber -Category InvalidOperation `
                        -Message ('diskNumber {0} cannot be the deployment target: {1}. This rule is not overridable by naming the disk.' -f $DiskNumber, $absolute[0].Text)))
        }

        foreach ($warned in @($named[0].Reason)) {
            Write-Warning ('{0}, and was used anyway because the sequence named it.' -f $warned.Text)
        }

        return $named[0].Row
    }

    # -- nobody named a disk --------------------------------------------------

    if ($assessment.Count -eq 0) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -ErrorId 'HDTNoTargetDiskError' `
                    -TargetObject ([int[]] @()) -Category ObjectNotFound `
                    -Message 'this machine reports no disk at all, so there is nothing to deploy to. Check that the boot image carries a storage driver for this controller.'))
    }

    $candidate = @($assessment | Where-Object { @($_.Reason).Count -eq 0 })

    if ($candidate.Count -eq 1) {
        return $candidate[0].Row
    }

    if ($candidate.Count -eq 0) {
        $line = foreach ($entry in $assessment) {
            ' - {0}' -f (@($entry.Reason | ForEach-Object { $_.Text }) -join '; ')
        }

        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -ErrorId 'HDTNoTargetDiskError' `
                    -TargetObject $presentNumber -Category ObjectNotFound `
                    -Message ("no disk on this machine can be used as the deployment target:{0}{1}" -f [System.Environment]::NewLine, (@($line) -join [System.Environment]::NewLine))))
    }

    # This is the sentence that stops a toolkit from wiping a technician's
    # second drive.
    $candidateNumber = [int[]] @($candidate | ForEach-Object { $_.Number })
    $named = (@($candidateNumber | ForEach-Object { 'disk {0}' -f $_ }) -join ', ')

    $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -ErrorId 'HDTAmbiguousTargetError' `
                -TargetObject $candidateNumber -Category InvalidResult `
                -Message ('{0} disks qualify as the deployment target ({1}), and HDT will not guess which one to wipe. Set diskNumber: on the step to name the one to use.' -f $candidate.Count, $named)))
}