Public/Steps/Invoke-HDTDiskPartitionStep.ps1

function Invoke-HDTDiskPartitionStep {
    <#
        .SYNOPSIS
            Clears, initialises and partitions the one disk this deployment may
            wipe.
 
        .DESCRIPTION
            The destructive step, and the one the refusal rules were written
            about:
            "wiping the wrong disk is the single most destructive failure mode in
            this class of tool."
 
              - name: Format and Partition
                type: DiskPartition
                layout: "%HDTDiskLayout%" # optional; firmware decides otherwise
                wipe: true # required for a disk that is not RAW
                diskNumber: 0 # optional; the ambiguity override
                minDiskGB: 60 # optional; default 60
 
            THREE GUARDS, AND EACH ONE HAS A TEST THAT PROVES NOTHING WAS
            WRITTEN WHEN IT FIRED:
 
              1. SupportsShouldProcess. Under -WhatIf the step plans, logs the
                 plan and returns Completed having called no write method.
              2. The ambiguity refusal. It never selects a disk itself -
                 Select-HDTTargetDisk does, and returns one row or refuses.
              3. The protected drive letters. The letters of the workspace root
                 and of the log path are ALWAYS passed, so HDT cannot wipe the
                 disk it is reading its own instructions from or writing its log
                 to. That guard is not conditional on anything an author typed.
 
            THE ORDER IS A LAB-VERIFIED RECIPE, and it is asserted from the
            fake's journal rather than described here and hoped for:
 
              GetDisk / GetPartition / GetVolume
              Select-HDTTargetDisk
              ClearDisk Clear-Disk -RemoveData -RemoveOEM
              InitializeDisk and NO MSR is created by hand
              per partition: NewPartition -> SetPartitionDriveLetter ->
                             FormatVolume -> SetPartitionType where it differs
 
            NO MICROSOFT RESERVED PARTITION IS EVER CREATED HERE.
            Initialize-Disk -PartitionStyle GPT creates the MSR itself.
            PSD's PSDPartition.ps1 (line 116) initialises GPT and then creates an
            MSR by hand a few lines later, which is how the spike ended up with a
            duplicate 16 MB partition. HDT carries the 16 MB as an allowance on
            the layout, subtracts it from the space Windows can have, and creates
            nothing for it.
 
            THE ESP IS CREATED AS BASIC DATA AND RETYPED AFTER FORMATTING. A
            partition created directly as an EFI System partition cannot readily
            be given a drive letter to format through, so the layout carries both
            CreateGptType and GptType and this step creates, letters, formats,
            then retypes. That is the field recipe; 04-04 is where it first runs
            against a real disk.
 
            A REFUSAL IS RETURNED, NOT THROWN, and carries its errorId in the
            result's Data, where Get-HDTFailureClass reads it and classes it
            Configuration - so a refusal to wipe ends the run instead of being
            retried three times.
 
            THIS STEP PUBLISHES HDTOSVolume; THE LOOP MOVES THE LOG.
            _HDTLogPath moves to <target>\HDT\Logs once the target
            volume is formatted, and from 05-03 it does: Invoke-HDTTaskSequence
            calls Set-HDTLogPath after any step completes in the WinPE phase
            having left HDTOSVolume non-empty, mirroring the whole log tree onto
            the volume this step just made, and pointing the state document's
            mirror at <target>\HDT\state.json at the same moment.
 
            IT IS STILL NOT THIS STEP'S JOB, and that is the point of saying so
            here. A step does not own the log context; one that reached into it
            would be the wrong shape, and every other step that formats a volume
            in future would have to remember to do the same thing. The step
            publishes a fact and the loop - the one place that sees every step
            finish - acts on it.
 
        .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 and plan, or errorId on
            a refusal.
 
        .EXAMPLE
            Invoke-HDTDiskPartitionStep -Step $step -Context $context
 
        .EXAMPLE
            Invoke-HDTDiskPartitionStep -Step $step -Context $context -WhatIf
 
            The plan, logged, with nothing written to any disk.
    #>

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

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

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

    $fail = {
        param([string] $Message, [string] $ErrorId)

        $data = [ordered] @{}
        if (-not [string]::IsNullOrWhiteSpace($ErrorId)) { $data['errorId'] = $ErrorId }

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

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

    # -- what the step asked for ------------------------------------------

    try {
        $layoutName = Get-HDTStepProperty -Step $Step -Name 'layout'

        # AN AUTHORED TABLE, IF THERE IS ONE. A step may name a layout or write
        # its own partitions; naming both is a document that says two different
        # things about the same disk, and guessing which was meant is exactly
        # the kind of guess this step refuses everywhere else.
        $authored = Get-HDTStepProperty -Step $Step -Name 'partition'
        $style = Get-HDTStepProperty -Step $Step -Name 'style' -Context $Context -Expand

        # @($null).Count IS 1, NOT 0. A step with no partition property reads
        # back $null, and the obvious test for "did it declare a table" sends
        # every named-layout step down the authored path - which is what it did,
        # and every existing test in this suite failed at once.
        $hasAuthored = $false
        if ($null -ne $authored) {
            $hasAuthored = @(@($authored) | Where-Object { $null -ne $_ }).Count -gt 0
        }

        if ($hasAuthored -and -not [string]::IsNullOrWhiteSpace([string] $layoutName)) {
            throw ("this step declares both a layout ('{0}') and its own partition table. They are two different answers to the same question, and guessing which was meant is how a disk gets laid out by accident - keep one." -f $layoutName)
        }
        $allowExistingData = Get-HDTStepProperty -Step $Step -Name 'wipe' -Default $false -Context $Context -Expand -As Bool
        $minimumDiskGb = Get-HDTStepProperty -Step $Step -Name 'minDiskGB' -Default 60 -Context $Context -Expand -As Long
        $diskNumber = Get-HDTStepProperty -Step $Step -Name 'diskNumber' -Context $Context -Expand -As Int
    } catch {
        return (& $fail ([string] $_.Exception.Message) 'HDTConfigurationError')
    }

    # -- the target -------------------------------------------------------

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

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

    $selectArgument = @{
        Disk               = $diskRow
        Partition          = $partitionRow
        Volume             = $volumeRow
        MinimumSizeByte    = [long] $minimumDiskGb * 1073741824
        # NEVER CONDITIONAL: the disk carrying the workspace and the disk
        # carrying the log are out of range whatever the sequence says.
        ProtectDriveLetter = [string[]] @(
            [string] $Context.WorkspaceRoot
            [string] $Context.Log.LogPath
        )
    }

    if ($allowExistingData) { $selectArgument['AllowExistingData'] = $true }
    if ($null -ne $diskNumber) { $selectArgument['DiskNumber'] = [int] $diskNumber }

    try {
        $target = Select-HDTTargetDisk @selectArgument
    } catch {
        return (& $fail ([string] $_.Exception.Message) (([string] $_.FullyQualifiedErrorId).Split(',')[0]))
    }

    $number = [int] $target.Number

    # -- the plan ---------------------------------------------------------

    try {
        if ($hasAuthored) {

            # THE STYLE FOLLOWS THE FIRMWARE UNLESS THE STEP PINS IT, which is
            # what Resolve-HDTDiskLayoutName already does for a named layout:
            # a UEFI machine gets GPT and a BIOS machine MBR, because a disk
            # laid out for the other firmware does not boot.
            $resolvedStyle = [string] $style

            if ([string]::IsNullOrWhiteSpace($resolvedStyle)) {
                $resolvedStyle = 'MBR'
                if ((Resolve-HDTDiskLayoutName -Variable $Context.Variable -Layout '') -eq 'uefi-standard') {
                    $resolvedStyle = 'GPT'
                }
            }

            $resolvedName = 'authored'
            $layout = ConvertTo-HDTDiskLayout -Partition ([object[]] @($authored)) -Style $resolvedStyle
        } else {
            $resolvedName = Resolve-HDTDiskLayoutName -Variable $Context.Variable -Layout ([string] $layoutName)
            $layout = Get-HDTDiskLayout -Name $resolvedName
        }

        $plan = @(New-HDTDiskLayoutPlan -Layout $layout -DiskSizeByte ([long] $target.SizeBytes))
    } catch {
        return (& $fail ([string] $_.Exception.Message) (([string] $_.FullyQualifiedErrorId).Split(',')[0]))
    }

    $sizeGb = [math]::Round([long] $target.SizeBytes / 1073741824, 1)

    $planLine = @($plan | ForEach-Object {
            ' {0}. {1} {2} bytes {3} {4}: as {5}' -f $_.Order, $_.Role, $_.SizeByte, $_.FileSystem, $_.DriveLetter, $_.Label
        })

    Write-HDTLog -Context $Context.Log -Component 'DiskPartition' `
        -Message ("disk {0} ({1}, {2} GB) will be cleared and repartitioned as {3}:{4}{5}" -f
            $number, [string] $target.FriendlyName, $sizeGb, $resolvedName,
            [System.Environment]::NewLine, ($planLine -join [System.Environment]::NewLine)) `
        -Data ([ordered] @{
            diskNumber     = $number
            friendlyName   = [string] $target.FriendlyName
            sizeBytes      = [long] $target.SizeBytes
            layout         = $resolvedName
            partitionStyle = [string] $layout.PartitionStyle
        })

    # -- the volumes this step publishes ----------------------------------
    #
    # Written whether or not the writes happen, so a -WhatIf run of the whole
    # sequence stays coherent: the steps after this one plan against the volumes
    # this one would have created.

    $Context.Variable['HDTTargetDisk'] = $number
    $Context.Variable['HDTSystemVolume'] = ''
    $Context.Variable['HDTOSVolume'] = ''
    $Context.Variable['HDTRecoveryVolume'] = ''

    foreach ($row in $plan) {
        $letter = [string] $row.DriveLetter

        if ($row.Role -eq 'System') { $Context.Variable['HDTSystemVolume'] = $letter }
        if ($row.Role -eq 'Windows') { $Context.Variable['HDTOSVolume'] = $letter }
        if ($row.Role -eq 'Recovery') { $Context.Variable['HDTRecoveryVolume'] = $letter }
    }

    # THERE IS NOTHING TO CLEAR ON A RAW DISK, AND Clear-Disk SAYS SO. Found by
    # running the integration suite against a real VHDX (04-04):
    #
    # Clear-Disk -Number N -RemoveData -RemoveOEM
    # -> The disk has not been initialized.
    #
    # A brand-new VHDX is RAW, and so is the disk in a machine that has never
    # been deployed - which is every machine this step exists for. Calling
    # ClearDisk unconditionally passed against a fake that shrugged at it and
    # would have failed on the first bare-metal disk it met.
    #
    # This is a decision about WHAT TO DO to a disk, so it lives here rather
    # than in the adapter, which stays branch-free by design.
    $mustClear = ([string] $target.PartitionStyle -ne 'RAW')

    $data = [ordered] @{
        diskNumber     = $number
        layout         = $resolvedName
        partitionStyle = [string] $layout.PartitionStyle
        cleared        = $mustClear
        plan           = [object[]] $plan
    }

    if (-not $PSCmdlet.ShouldProcess(
            ('disk {0} ({1}, {2} GB)' -f $number, [string] $target.FriendlyName, $sizeGb),
            'Clear and repartition')) {

        return (New-HDTStepResult -Status Completed -Data $data `
                -Message ('disk {0} would have been cleared and repartitioned as {1}.' -f $number, $resolvedName))
    }

    # -- the writes, in SPIKES S6's verified order ------------------------

    $stage = 'clearing'

    try {
        if ($mustClear) {
            # Clear-Disk -RemoveData -RemoveOEM, which is what leaves the disk RAW.
            $diskService.ClearDisk($number)
        } else {
            Write-HDTLog -Context $Context.Log -Component 'DiskPartition' `
                -Message ('disk {0} is RAW; there is nothing to clear.' -f $number) `
                -Data ([ordered] @{ diskNumber = $number; cleared = $false })
        }

        # THIS is what creates the Microsoft Reserved partition on GPT. Nothing
        # below creates one - SPIKES S6, PSDPartition.ps1 line 116.
        $stage = 'initialising'
        $diskService.InitializeDisk($number, [string] $layout.PartitionStyle)

        foreach ($row in $plan) {
            $stage = ('creating the {0} partition' -f $row.Role)

            # Created as CreateGptType where the layout has one - basic data for
            # the ESP, so it can take a drive letter to format through - and
            # retyped after the format.
            $createType = [string] $row.CreateGptType
            if ([string]::IsNullOrWhiteSpace($createType)) { $createType = '' }

            $created = $diskService.NewPartition($number, [long] $row.SizeByte,
                [bool] $row.UseMaximumSize, $createType, [bool] $row.IsActive)

            $partitionNumber = [int] $created.PartitionNumber

            $stage = ('lettering the {0} partition' -f $row.Role)
            $diskService.SetPartitionDriveLetter($number, $partitionNumber, [string] $row.DriveLetter)

            $stage = ('formatting the {0} partition' -f $row.Role)
            $diskService.FormatVolume([string] $row.DriveLetter, [string] $row.FileSystem, [string] $row.Label)

            $finalType = [string] $row.GptType
            if (-not [string]::IsNullOrWhiteSpace($finalType) -and $finalType -ne $createType) {
                $stage = ('setting the type of the {0} partition' -f $row.Role)
                $diskService.SetPartitionType($number, $partitionNumber, $finalType)
            }
        }
    } catch {
        return (& $fail ("disk {0} failed while {1}: {2}" -f $number, $stage, [string] $_.Exception.Message) '')
    }

    $message = 'disk {0} was cleared and repartitioned as {1}: {2}.' -f $number, $resolvedName,
    (@($plan | ForEach-Object { '{0} on {1}:' -f $_.Role, $_.DriveLetter }) -join ', ')

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

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