Public/New-HDTPxePayload.ps1

function New-HDTPxePayload {
    <#
        .SYNOPSIS
            Stages everything a non-WDS TFTP or HTTP stack needs to serve the HDT
            boot image, and verifies every copy by hash.
 
        .DESCRIPTION
            For sites with an existing TFTP/HTTP stack instead of
            WDS, New-HDTPxePayload stages bootmgr, bootmgfw.efi, boot.sdi, the
            BCD, and the boot WIM into a directory to point that server at."
 
            THE REQUIRED SET IS A DECLARED TABLE, AND -ListRequired HANDS IT
            BACK. The tests read that same table rather than a second copy of it,
            so completeness is checked against ONE list. A row added without a
            copy landing turns the suite red; a row deleted stops being asserted,
            which is why the row count is pinned as well.
 
              Boot\<arch>\bootmgr.exe ADK Media bootmgr
              Boot\<arch>\wdsmgfw.efi (optional) ADK Media EFI\Boot\bootx64.efi
              Boot\<arch>\bootmgfw.efi ADK Media bootmgr.efi
              Boot\<arch>\boot.sdi ADK Media Boot\boot.sdi
              Boot\<arch>\BCD ADK Media Boot\BCD
              Boot\<arch>\Fonts\* ADK Media Boot\Fonts\*
              Boot\<arch>\Images\<name>.wim workspace Boot\<name>.wim
              Boot\<arch>\<name>.manifest.json workspace Boot\<name>.manifest.json
 
            wdsmgfw.efi IS OPTIONAL AND DOES NOT AFFECT Complete, because a
            site's TFTP stack may want either name for the same bootloader and
            this command does not know which. It is reported in Skipped rather
            than dropped silently.
 
            WHAT 'Complete' MEANS: every declared file is staged and its bytes
            verify. IT DOES NOT MEAN A MACHINE WILL PXE BOOT FROM THIS, and this
            file will not make the larger claim. The BCD staged here is the ADK
            media template, which describes booting sources\boot.wim from
            removable media; a TFTP/HTTP stack generally needs its own BCD store
            and its own device element.
 
            THIS PAYLOAD HAS NEVER BEEN NETWORK-BOOTED by anything in this
            repository - there is no WDS on this host and PROJECT.md forbids
            standing one up beside CM01's PXE responder - so claiming staging
            completeness is honest and claiming bootability would not be.
            tests/integration/PxePayload.Integration.Tests.ps1 asserts that this
            sentence is still here.
 
            EVERY COPY IS VERIFIED BY HASH AND A MISMATCH IS A FAILURE, NOT A
            WARNING. A truncated boot.sdi on a TFTP server is a machine that
            hangs at boot with no message on the screen and no line in any log;
            an operator who was warned about it in a scrollback they closed is an
            operator with a fleet that does not boot.
 
            Everything goes through IFileSystem and every ADK path through
            Get-HDTAdkPath - PROJECT.md's rule, because the kit layout has moved
            between ADK releases and a literal would work here forever and fail
            in the field.
 
        .PARAMETER WorkspaceRoot
            The deployment share. Boot\<name>.wim and its manifest are read from
            it, and it is the one place this command refuses to write.
 
        .PARAMETER Path
            Where to stage the payload - the directory a TFTP or HTTP server is
            pointed at. Must be outside the workspace: the payload is for a
            different server, and a copy of the share inside the share is a share
            that grows a copy of itself every time somebody runs this.
 
        .PARAMETER Architecture
            amd64 (default) or arm64, in the ADK's vocabulary. The destination
            folder uses the PXE tree's: amd64 stages under Boot\x64.
 
        .PARAMETER BootImageName
            The boot image base name, HDTPE_x64 by default - which is
            workspace.yaml's own default for bootImage.name. Name another if the
            workspace declares one.
 
        .PARAMETER ListRequired
            Return the declared table instead of staging anything: Destination,
            Source, Origin, Required and Kind, in table order.
 
        .PARAMETER AdkRoot
            An explicit ADK root, which wins over the registry.
 
        .PARAMETER FileSystem
            An IFileSystem. Defaults to the real adapter.
 
        .PARAMETER Registry
            An IRegistryService, for resolving the ADK. Defaults to the real
            adapter.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with Path, Architecture,
            BootImageName, Complete, File (Destination, Source, SizeBytes,
            Sha256, Required), Missing and Skipped.
 
            With -ListRequired: one PSCustomObject per declared row.
 
        .EXAMPLE
            New-HDTPxePayload -WorkspaceRoot 'C:\HDTLab\Share' -Path 'D:\tftproot'
 
            Stages the payload and reports Complete when every declared file
            landed and verified.
 
        .EXAMPLE
            New-HDTPxePayload -ListRequired | Format-Table Destination, Source, Required
 
            What the payload is declared to contain, without staging it.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Stage', SupportsShouldProcess = $true)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'Stage')]
        [ValidateNotNullOrEmpty()]
        [string] $WorkspaceRoot,

        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Stage')]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [Parameter(Mandatory = $true, ParameterSetName = 'List')]
        [switch] $ListRequired,

        [Parameter()]
        [ValidateSet('amd64', 'arm64')]
        [string] $Architecture = 'amd64',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $BootImageName = 'HDTPE_x64',

        [Parameter(ParameterSetName = 'Stage')]
        [AllowEmptyString()]
        [string] $AdkRoot = '',

        [Parameter(ParameterSetName = 'Stage')]
        [AllowNull()]
        [object] $FileSystem,

        [Parameter(ParameterSetName = 'Stage')]
        [AllowNull()]
        [object] $Registry
    )

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

    $row = @(Get-HDTPxePayloadRow -Architecture $Architecture -BootImageName $BootImageName)

    if ($ListRequired) {
        return [pscustomobject[]] $row
    }

    if ($null -eq $FileSystem) { $FileSystem = New-HDTFileSystem }
    if ($null -eq $Registry) { $Registry = New-HDTRegistryService }

    # =====================================================================
    # 1. THE DESTINATION - THE PAYLOAD IS FOR A DIFFERENT SERVER
    # =====================================================================

    $workspaceFull = [System.IO.Path]::GetFullPath($WorkspaceRoot).TrimEnd('\', '/')
    $payloadFull = [System.IO.Path]::GetFullPath($Path).TrimEnd('\', '/')
    $separator = [System.IO.Path]::DirectorySeparatorChar

    if ($payloadFull -eq $workspaceFull -or
        $payloadFull.StartsWith(($workspaceFull + $separator), [System.StringComparison]::OrdinalIgnoreCase)) {

        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $Path `
                    -Message ("the payload path '{0}' is inside the workspace '{1}'. This payload is a copy of the boot bits for a DIFFERENT server - a TFTP or HTTP root - and staging it inside the deployment share gives the share a copy of itself that grows every time this command runs. Choose a path outside the workspace." -f $Path, $WorkspaceRoot)))
    }

    # =====================================================================
    # 2. THE ADK, RESOLVED - NEVER A LITERAL
    # =====================================================================

    $adkSplat = @{
        Asset        = 'WinPeMedia'
        Architecture = $Architecture
        Registry     = $Registry
        FileSystem   = $FileSystem
    }
    if (-not [string]::IsNullOrWhiteSpace($AdkRoot)) { $adkSplat['Root'] = $AdkRoot }

    $mediaRoot = Get-HDTAdkPath @adkSplat

    # =====================================================================
    # 3. THE BOOT IMAGE MUST HAVE BEEN BUILT
    # =====================================================================

    $wimSource = Get-HDTWorkspacePath -Root $WorkspaceRoot -Kind Boot -ChildPath ('{0}.wim' -f $BootImageName)

    if (-not $FileSystem.TestPath($wimSource)) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $wimSource -Category ObjectNotFound `
                    -Message ("there is no boot image to stage. Run Update-HDTBootImage against this workspace first - it writes Boot\{0}.wim, Boot\{0}.manifest.json and the ISO beside them; a PXE payload without the WIM is a TFTP root that answers a machine and then has nothing to give it." -f $BootImageName)))
    }

    # =====================================================================
    # 4. STAGE
    # =====================================================================

    $description = 'Stage the {0} PXE payload ({1} declared row(s))' -f $BootImageName, $row.Count

    $staged = New-Object -TypeName System.Collections.ArrayList
    $missing = New-Object -TypeName System.Collections.ArrayList
    $skipped = New-Object -TypeName System.Collections.ArrayList

    if (-not $PSCmdlet.ShouldProcess($Path, $description)) {
        return [pscustomobject] @{
            Path          = $Path
            Architecture  = $Architecture
            BootImageName = $BootImageName
            Complete      = $false
            File          = [pscustomobject[]] @()
            Missing       = [string[]] @()
            Skipped       = [string[]] @()
        }
    }

    $FileSystem.CreateDirectory($Path)

    # COPY AND VERIFY, ONE FILE. The hash of the source is read before the copy
    # and the hash of the destination after it, and they are compared - so a copy
    # that silently truncated is a failure here rather than a machine that hangs
    # at boot on somebody else's site.
    $copyOne = {
        param([string] $Source, [string] $RelativeDestination, [bool] $Required)

        $destination = [System.IO.Path]::Combine($Path, $RelativeDestination)

        $FileSystem.CreateDirectory([System.IO.Path]::GetDirectoryName($destination))
        $FileSystem.CopyItem($Source, $destination)

        $sourceHash = [string] $FileSystem.GetHash($Source)
        $destinationHash = [string] $FileSystem.GetHash($destination)

        if (-not $sourceHash.Equals($destinationHash, [System.StringComparison]::OrdinalIgnoreCase)) {
            throw ("HDTIntegrityError: the staged copy of '{0}' does not match its source. Source '{1}' hashes {2}; the copy at '{3}' hashes {4}. This is reported as a failure and not a warning on purpose: a truncated boot file on a TFTP server is a machine that hangs at boot with no message on the screen and no line in any log." -f
                $RelativeDestination, $Source, $sourceHash, $destination, $destinationHash)
        }

        return [pscustomobject] @{
            Destination = $RelativeDestination
            Source      = $Source
            SizeBytes   = [long] $FileSystem.GetLength($destination)
            Sha256      = $destinationHash
            Required    = $Required
        }
    }

    foreach ($entry in $row) {
        $origin = $mediaRoot
        if ([string] $entry.Origin -eq 'Workspace') { $origin = $WorkspaceRoot }

        $source = [System.IO.Path]::Combine($origin, ([string] $entry.Source))

        if (-not $FileSystem.TestPath($source)) {
            if ([bool] $entry.Required) {
                [void] $missing.Add([string] $entry.Destination)
                Write-Warning ("The PXE payload is incomplete: '{0}' is declared required and its source '{1}' is not there. The payload was staged without it." -f
                    $entry.Destination, $source)
            } else {
                [void] $skipped.Add([string] $entry.Destination)
            }

            continue
        }

        if ([string] $entry.Kind -eq 'Directory') {
            foreach ($child in @($FileSystem.GetChildItem($source))) {
                $leaf = [System.IO.Path]::GetFileName(([string] $child).TrimEnd('\', '/'))

                [void] $staged.Add((& $copyOne ([string] $child) `
                            ([System.IO.Path]::Combine([string] $entry.Destination, $leaf)) `
                            ([bool] $entry.Required)))
            }

            continue
        }

        [void] $staged.Add((& $copyOne $source ([string] $entry.Destination) ([bool] $entry.Required)))
    }

    return [pscustomobject] @{
        Path          = $Path
        Architecture  = $Architecture
        BootImageName = $BootImageName

        # Complete is "every declared REQUIRED row landed and verified". The
        # optional row cannot make it false, and nothing here says the payload
        # will boot a machine.
        Complete      = ($missing.Count -eq 0)
        File          = [pscustomobject[]] @($staged)
        Missing       = [string[]] @($missing)
        Skipped       = [string[]] @($skipped)
    }
}