Public/Set-HDTLogPath.ps1

function Set-HDTLogPath {
    <#
        .SYNOPSIS
            Moves the live log to the target volume, mirroring everything already
            written.
 
        .DESCRIPTION
            A DEPLOYMENT THAT DIES IN WinPE LOSES ITS LOG AT THE REBOOT, AND
            DYING IN WinPE IS EXACTLY WHEN THE LOG IS WANTED. X: is a RAM disk;
            it is gone the moment the machine restarts. HDT says
            _HDTLogPath follows the deployment:
 
              WinPE, before a disk exists X:\HDT\Logs
              WinPE, after the volume is formatted <target>\HDT\Logs
              Full OS C:\HDT\Logs
 
            This is the second row. Phase 04 deferred it on purpose and said so;
            this is where it lands.
 
            WHAT IT DOES, IN ORDER:
 
              1. computes the destination with Get-HDTLogPath, which already owns
                 that answer - it is not rebuilt by hand here;
              2. returns immediately if the context is already there, because the
                 loop may ask more than once;
              3. creates the destination through the context's IFileSystem;
              4. MIRRORS everything already written, preserving the tree -
                 HDT.log, HDT.jsonl, status.json, Steps\, Gather\, Native\;
              5. repoints LogPath, JsonlPath, MasterLogPath and - when a step is
                 mid-flight - StepLogPath. Seq IS NOT TOUCHED: the
                 counter is monotonic across the whole run and a reset here would
                 be exactly the ambiguity it exists to prevent;
              6. sets _HDTLogPath in the variable dictionary when one is given;
              7. writes one message record naming BOTH paths, through the already
                 repointed context, so the first line in the new file explains why
                 the file exists.
 
            IT IS A MIRROR, NOT A MOVE. The RAM-disk copy stays. A relocation
            that deleted the only log and then failed would be worse than no
            relocation.
 
            AND IT NEVER THROWS. A target volume that cannot be written - full,
            not formatted after all, a letter that went away - produces a Warning
            record through the OLD context, leaves everything pointing at X:, and
            returns the old path. Losing the logs is not an acceptable price for
            moving the logs.
 
            A BARE DRIVE LETTER IS ACCEPTED, because that is what the engine
            publishes: Invoke-HDTDiskPartitionStep sets HDTOSVolume to 'W', not
            'W:'. 'W', 'W:' and 'W:\' all name the same volume here.
 
        .PARAMETER Context
            A New-HDTLogContext result. It is mutated in place - every holder of
            the same object logs to the new path from the next record onward.
 
        .PARAMETER TargetVolume
            The formatted target volume. A bare letter, 'W:' or 'W:\'.
 
        .PARAMETER Variable
            The live variable dictionary. When supplied, _HDTLogPath is set in
            it. The engine variable is read-only TO SEQUENCES AND RULES; the
            engine itself is what sets it, which is what the leading underscore
            means.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.String - the log path in force after the call. The new one on
            success, the old one when the relocation could not be made.
 
        .EXAMPLE
            Set-HDTLogPath -Context $log -TargetVolume 'W:' -Variable $Context.Variable
 
            What Invoke-HDTTaskSequence does once, after the step that formats
            the volume publishes HDTOSVolume.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Mirrors a log directory and repoints an in-memory context. It deletes nothing, and it is called from a finally-safe path that must never prompt or fail.')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNull()]
        [object] $Context,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string] $TargetVolume,

        [Parameter()]
        [AllowNull()]
        [System.Collections.IDictionary] $Variable
    )

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

    $current = ([string] $Context.LogPath).TrimEnd('\', '/')

    # 'W' -> 'W:'. The partition step publishes a bare letter, and a bare letter
    # handed to Get-HDTLogPath would yield a RELATIVE path - logs written beside
    # the current directory, on the RAM disk, which is the one place they must
    # not stay.
    $volume = $TargetVolume.Trim().TrimEnd('\', '/')
    if ($volume -notmatch ':$') {
        $volume = '{0}:' -f $volume.TrimEnd(':')
    }

    # Get-HDTLogPath already owns the answer, including the rule that a
    # -TargetVolume means nothing in the full OS. Passing the context's own phase
    # rather than a literal keeps that rule in one place: a FullOS context
    # computes C:\HDT\Logs, matches, and this becomes a no-op.
    $destination = (Get-HDTLogPath -Phase ([string] $Context.Phase) -TargetVolume $volume).TrimEnd('\', '/')

    if ($destination -eq $current) {
        return $current
    }

    $fileSystem = $Context.FileSystem

    try {
        $fileSystem.CreateDirectory($destination)

        # The whole tree, relative paths preserved: Steps\003-ApplyImage.dism.log
        # arrives as Steps\003-ApplyImage.dism.log rather than flattened into a
        # directory of clashing names.
        [void] (Copy-HDTContentTree -Source $current -Destination $destination -FileSystem $fileSystem)
    } catch {
        # THE OLD CONTEXT, DELIBERATELY. It is the one that still works, and this
        # record is the only evidence the relocation was tried.
        Write-HDTLog -Context $Context -Severity Warning -Component 'Logging' `
            -Message ("The deployment log could not be relocated from '{0}' to '{1}': {2}. Logging continues on the RAM disk, which means this log does not survive the next reboot." -f
                $current, $destination, $_.Exception.Message) `
            -Data ([ordered] @{ from = $current; to = $destination })

        return $current
    }

    # Repointed AFTER the mirror succeeded, so a half-copied tree never becomes
    # the place the run logs to.
    $Context.LogPath = $destination
    $Context.JsonlPath = '{0}\HDT.jsonl' -f $destination
    $Context.MasterLogPath = '{0}\HDT.log' -f $destination

    # A step mid-flight has its own log file under the old root. Rebased, it
    # keeps one log rather than two halves.
    $stepLogPath = [string] $Context.StepLogPath
    if (-not [string]::IsNullOrWhiteSpace($stepLogPath) -and
        $stepLogPath.StartsWith(($current + '\'), [System.StringComparison]::OrdinalIgnoreCase)) {

        $Context.StepLogPath = '{0}{1}' -f $destination, $stepLogPath.Substring($current.Length)
    }

    if ($PSBoundParameters.ContainsKey('Variable') -and $null -ne $Variable) {
        $Variable['_HDTLogPath'] = $destination
    }

    # Through the ALREADY REPOINTED context, so the first line in the new file
    # says why the file exists. Seq carries straight on from the old one.
    Write-HDTLog -Context $Context -Component 'Logging' `
        -Message ("_HDTLogPath moved from '{0}' to '{1}'. Everything written so far was mirrored, and the copy on the RAM disk is left in place." -f
            $current, $destination) `
        -Data ([ordered] @{ from = $current; to = $destination })

    return $destination
}