Public/Resolve-HDTDeployRoot.ps1

function Resolve-HDTDeployRoot {
    <#
        .SYNOPSIS
            Answers "which drive is the content on" without naming a letter.
 
        .DESCRIPTION
            THE DIFFERENCE BETWEEN A BOOT IMAGE THAT DEPLOYS AND ONE THAT SITS
            THERE. A lab test recorded WinPE assigning the CONTENT DISK C:
            and the RAM disk X:, so a deployRoot baked into bootstrap.json at
            build time cannot know what a machine that has not booted yet will
            hand out. This turns what the image says into a path that exists on
            this machine.
 
            THE ENUMERATION IS DELIBERATELY NOT ITS JOB. The caller hands it
            candidate volume roots; this decides between them. That is the same
            split Select-HDTTargetDisk already uses - the service enumerates, the
            function decides - and it is what keeps the whole decision provable
            under Pester on a machine with one disk.
 
            SIX RULES:
 
              1. Smb -> the deployRoot unchanged, Source 'Configured'. A UNC
                 needs no volume, and the share is not reachable until the
                 provider maps it, so nothing is probed.
              2. Local, ROOTED, and the marker is under it -> unchanged,
                 Source 'Configured'.
              3. Local, VOLUME-RELATIVE (one leading separator, not two) -> the
                 marker is looked for under each candidate IN THE ORDER GIVEN,
                 first hit wins, Source 'Discovered'.
              4. Local, rooted, but the marker is NOT there -> the same probe,
                 using the path's volume-relative form, and a warning naming
                 both. A boot image that was right yesterday should not be
                 unbootable because a disk was added.
              5. Nothing matched -> HDTConfigurationError naming the deployRoot,
                 the marker AND every candidate it looked at, in order. That
                 sentence is the last thing a machine with no operator will ever
                 say, so it says everything a human needs to fix it.
              6. More than one candidate matched -> the first wins and a warning
                 names all of them. Refusing would strand a machine over a stale
                 second copy; silence would hide the ambiguity.
 
            The marker defaults to rules.yaml, the rules file, which sits at
            the root of every workspace.
 
            Candidate on the returned row is what it considered, so RESULT.json
            can record what the machine SAW as well as what it chose - which is
            what a support call needs when the answer was wrong.
 
            NO DRIVE LETTER IS WRITTEN IN THIS FILE, and
            tests/unit/Resolve-HDTDeployRoot.Tests.ps1 asserts it over the
            comment-free token stream.
 
        .PARAMETER DeployRoot
            What bootstrap.json says. A UNC share, a rooted local path, or a
            volume-relative path beginning with one separator.
 
        .PARAMETER Provider
            Smb or Local.
 
        .PARAMETER CandidateRoot
            The volume roots to look under, in priority order. The caller
            enumerates them; Start-HDTDeployment.ps1 uses
            [System.IO.DriveInfo]::GetDrives().
 
        .PARAMETER Marker
            The file that identifies a workspace root. Defaults to rules.yaml.
 
        .PARAMETER FileSystem
            An IFileSystem. Defaults to the real adapter.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with Path, Source
            (Configured or Discovered), Marker and Candidate.
 
        .EXAMPLE
            Resolve-HDTDeployRoot -DeployRoot '\\server\HdtShare' -Provider Smb
 
            The share, unchanged, Source Configured.
 
        .EXAMPLE
            $volume = @([System.IO.DriveInfo]::GetDrives() |
                Where-Object { $_.IsReady } | ForEach-Object { $_.RootDirectory.FullName })
            Resolve-HDTDeployRoot -DeployRoot '\Share' -Provider Local -CandidateRoot $volume
 
            What the boot image carries, turned into the volume this machine
            actually has.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string] $DeployRoot,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateSet('Smb', 'Local')]
        [string] $Provider,

        [Parameter()]
        [AllowNull()]
        [string[]] $CandidateRoot,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $Marker = 'rules.yaml',

        [Parameter()]
        [AllowNull()]
        [object] $FileSystem
    )

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

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

    $row = {
        param([string] $Path, [string] $Source, [string[]] $Candidate)

        return [pscustomobject] ([ordered] @{
                Path      = $Path
                Source    = $Source
                Marker    = $Marker
                Candidate = [string[]] $Candidate
            })
    }

    # RULE 1. A UNC needs no volume, and probing a share nothing has mapped yet
    # would fail on every correct configuration.
    if ($Provider -eq 'Smb') {
        return (& $row $DeployRoot 'Configured' @())
    }

    $candidate = [string[]] @($CandidateRoot | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })

    # GetPathRoot rather than a regular expression, so no drive letter is written
    # in this file at all: 'D:\Share' gives 'D:\', '\Share' gives one separator,
    # '\\server\share\x' gives the share and a relative path gives nothing.
    $pathRoot = [string] [System.IO.Path]::GetPathRoot($DeployRoot)
    $isRooted = $pathRoot.Length -gt 1

    # RULE 2. Configured, and there.
    if ($isRooted -and $FileSystem.TestPath([System.IO.Path]::Combine($DeployRoot, $Marker))) {
        return (& $row $DeployRoot 'Configured' $candidate)
    }

    # RULES 3 and 4 share the probe. The volume-relative form of a rooted path is
    # what is left after its own root is taken off.
    $relative = $DeployRoot
    if ($pathRoot.Length -gt 0) {
        $relative = $DeployRoot.Substring($pathRoot.Length)
    }
    $relative = $relative.Trim('\', '/')

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

    foreach ($volume in $candidate) {
        $probe = $volume
        if (-not [string]::IsNullOrEmpty($relative)) {
            $probe = [System.IO.Path]::Combine($volume, $relative)
        }

        if ($FileSystem.TestPath([System.IO.Path]::Combine($probe, $Marker))) {
            [void] $matched.Add($probe)
        }
    }

    # RULE 5. Everything it looked at, in the order it looked.
    if ($matched.Count -eq 0) {
        $looked = 'no candidate volume was offered at all'
        if ($candidate.Count -gt 0) {
            $looked = "the candidate volumes it looked under, in order, were: {0}" -f ($candidate -join ', ')
        }

        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord `
                    -Message ("the deployment content could not be found. bootstrap.json names deployRoot '{0}', the workspace marker is '{1}', and {2}. Nothing under any of them carries the marker, so this machine has no content to deploy from." -f
                        $DeployRoot, $Marker, $looked) `
                    -TargetObject $DeployRoot `
                    -Category ObjectNotFound))
    }

    $chosen = [string] $matched[0]

    # RULE 6. First wins, and say what else was there.
    if ($matched.Count -gt 1) {
        Write-Warning ("More than one candidate volume carries '{0}': {1}. HDT is using '{2}', the first in the order it was given - refusing would strand this machine over a stale second copy, but a deployment reading the wrong one is worth saying out loud." -f
            $Marker, (@($matched) -join ', '), $chosen)
    }

    # RULE 4's warning. A boot image that was right yesterday should not be
    # unbootable because a disk was added, but the drift is worth a line.
    if ($isRooted) {
        Write-Warning ("bootstrap.json names deployRoot '{0}', and '{1}' is not there. The content was found at '{2}' instead - the volume letters this machine was given are not the ones the boot image was built with." -f
            $DeployRoot, [System.IO.Path]::Combine($DeployRoot, $Marker), $chosen)
    }

    return (& $row $chosen 'Discovered' $candidate)
}