Payload/Start-HDTResume.ps1

<#
    .SYNOPSIS
        The RunOnce payload: reconcile the boot, then resume the task sequence.
 
    .DESCRIPTION
        The engine is launched at logon by a RunOnce entry re-registered each
        leg, pointing at C:\HDT\Start-HDTResume.ps1, which loads state.json and
        continues at the next step.
 
        THIS IS NOT A MODULE FILE. The loader dot-sources Private\ and Public\
        only, so Payload\ ships as a script, is staged to C:\HDT\, and is
        launched by
 
            powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\HDT\Start-HDTResume.ps1
 
        which is the command Set-HDTAutoLogon writes into RunOnce.
 
        THE RECONCILE RUNS FIRST, BEFORE ANYTHING ELSE: "if the
        state document says the run is finished, failed, or missing, it clears
        autologon, the LSA secret, the RunOnce entry, and C:\HDT\state.json
        before doing anything else." A machine that boots with a finished or
        abandoned run must be disarmed on THAT boot, not on the next one, and
        certainly not only when AutoLogonCount finally runs out. So nothing here
        touches the sequence until the reconcile has answered.
 
        When it answers Teardown this script exits 0 and the machine is left
        disarmed. When it answers Resume, the log context is rebuilt from the
        state's runId and seq - so the JSONL numbering continues across the
        reboot - the sequence is re-imported from the workspace, and the loop is
        called with the state that was reconciled.
 
        IT IS TESTED BY PARSING AND INSPECTING IT rather than by running it.
        Running it for real means a module under C:\HDT\Modules, real service
        adapters and a machine to reboot, which belongs to phase 04's integration
        layer. tests/unit/StartHDTResumePayload.Tests.ps1 asserts what can be
        proven from a desk: that it parses under both engines, that the reconcile
        precedes the loop, and that the reconciled state is what the loop is
        given.
 
    .PARAMETER ModulePath
        The staged Hephaestus module. C:\HDT\Modules\Hephaestus on a deployed
        machine; a test points it elsewhere.
 
    .PARAMETER StatePath
        The run state document. It lives at C:\HDT\state.json in the
        full OS.
 
    .PARAMETER WorkspaceRoot
        Where the sequence lives when there is no share to reach. The deploy root
        from the bootstrap document wins over it: the sequence, the rules and the
        applications live where the deployment came from, and a leg rooted at
        C:\HDT would re-import a sequence that is not there.
 
    .PARAMETER BootstrapPath
        The staged bootstrap document - the deploy root, the provider and the
        account that opens the share. Copy-HDTResumeAgent puts the boot image's
        own copy beside this file. Absent, the leg runs against the local disk
        alone and says so.
 
    .PARAMETER SequencePath
        The sequence file, when it is not where WorkspaceRoot would put it.
 
    .PARAMETER LogPath
        The log directory for this leg. Defaults to Get-HDTLogPath for the full
        OS phase.
 
    .PARAMETER MaxAgeHour
        How stale a Running state may be before the run counts as abandoned.
        Passed through to the reconcile.
 
    .EXAMPLE
        powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\HDT\Start-HDTResume.ps1
 
        What RunOnce runs.
 
    .EXAMPLE
        .\Start-HDTResume.ps1 -ModulePath C:\src\Hephaestus -StatePath C:\HDT\state.json
#>

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string] $ModulePath = 'C:\HDT\Modules\Hephaestus',

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string] $StatePath = 'C:\HDT\state.json',

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string] $WorkspaceRoot = 'C:\HDT',

    # THE SHARE, AND THE ACCOUNT THAT OPENS IT. Copy-HDTResumeAgent stages the
    # boot image's own bootstrap.json beside this file, for the reason the
    # description gives: the full-OS leg is the one that installs software, and
    # the software is on the share.
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string] $BootstrapPath = 'C:\HDT\bootstrap.json',

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string] $SequencePath,

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string] $LogPath,

    [Parameter()]
    [ValidateRange(0, [int]::MaxValue)]
    [int] $MaxAgeHour = 12
)

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

# THE STAGED MODULE ROOT GOES ON PSModulePath FIRST, and this is not a
# convenience. ConvertFrom-HDTYaml imports powershell-yaml lazily BY NAME, so the
# copy Copy-HDTResumeAgent staged at <root>\powershell-yaml is invisible to it
# unless the folder holding it is a module path - and without that parser the
# engine cannot read one document: not a sequence, not a rule, not an image
# catalog. The WinPE entry point has always done this; this file never did, and
# nothing noticed because no full-OS leg had ever run.
#
# THE PARENT OF -ModulePath, so the two stay in step. A caller pointing
# -ModulePath at a source tree gets that tree's parent, which is where anything
# beside it would be.
$moduleRoot = [System.IO.Path]::GetDirectoryName($ModulePath.TrimEnd('\', '/'))

if (-not [string]::IsNullOrWhiteSpace($moduleRoot)) {
    $env:PSModulePath = '{0};{1}' -f $moduleRoot, $env:PSModulePath
}

Import-Module -Name $ModulePath -Force -ErrorAction Stop

# The real adapters. A fake never appears in this file: it runs on a machine
# mid-deployment, and the whole point of the injection is that THIS is the one
# place the real ones are built.
$fileSystem = New-HDTFileSystem
$clock = New-HDTClock
$registry = New-HDTRegistryService
$lsa = New-HDTLsaService
# FullOS, and it is not a guess either: this payload runs from RunOnce on a
# deployed Windows install, which has shutdown.exe and does not have wpeutil.
$power = New-HDTPowerService -Environment FullOS
$process = New-HDTProcessService
$scriptInvoker = New-HDTScriptInvoker -Root $WorkspaceRoot
$environment = New-HDTEnvironmentProvider
$cim = New-HDTCimProvider

$logRoot = $LogPath
if ([string]::IsNullOrWhiteSpace($logRoot)) {
    $logRoot = Get-HDTLogPath -Phase FullOS
}

$fileSystem.CreateDirectory($logRoot)

# The boot log's counter is seeded from the state document BEFORE the reconcile
# runs, because DESIGN 4.4.2's monotonic seq has to survive the reboot - and the
# reconcile's own reboot.resume record is written through this context, in the
# middle of the stream. A context left at zero restarts the numbering at 1 there,
# which is exactly the ambiguity the counter exists to prevent.
#
# READ-ONLY AND BEST-EFFORT, so it is not the reconcile happening early: nothing
# is decided or acted on here. A missing or corrupt document is the case the
# reconcile is about to disarm, and a restarted counter is the least of it.
$bootSeq = [long] 0
try {
    if ($fileSystem.TestPath($StatePath)) {
        $bootSeq = [long] (ConvertFrom-Json -InputObject $fileSystem.ReadAllText($StatePath)).seq
    }
} catch {
    $bootSeq = [long] 0
}

# A bootstrap log context, so the reconcile's own decision is recorded even when
# it turns out there is no run to resume. Its run id is replaced below by the one
# from the state document when there is one.
$bootLog = New-HDTLogContext -RunId 'boot' -Phase FullOS -LogPath $logRoot `
    -FileSystem $fileSystem -Clock $clock -Seq $bootSeq

# BEFORE ANYTHING ELSE (DESIGN 4.5.2).
$decision = Invoke-HDTBootReconciliation -StatePath $StatePath -FileSystem $fileSystem `
    -Registry $registry -Lsa $lsa -Clock $clock -LogContext $bootLog -MaxAgeHour $MaxAgeHour

if ($decision.Action -eq 'Teardown') {
    Write-HDTLog -Context $bootLog -Event 'reboot.teardown' `
        -Message ("Nothing to resume ({0}); the machine has been disarmed." -f $decision.Reason)

    exit 0
}

$state = $decision.State

# -- the share ------------------------------------------------------------
#
# THE FULL-OS LEG IS THE ONE THAT INSTALLS SOFTWARE, AND THE SOFTWARE IS ON THE
# SHARE. This payload used to root itself at C:\HDT and build no content
# provider, so InstallApplications - the step whose whole documented home is a
# FullOS group - looked for Applications\ on the local disk and found nothing.
# The sequence, the rules and the applications all live where the deployment
# came from.
#
# THE ANSWER IS THE ONE THE WinPE ENTRY POINT ALREADY GAVE, through the same
# commands: bootstrap.json names the deploy root, the provider and the account,
# and Copy-HDTResumeAgent staged it beside this file.
#
# A SHARE THAT CANNOT BE REACHED IS NOT FATAL HERE. The machine is deployed and
# somebody is logged into it; the steps that need content will fail and say
# which, and the reconcile has already disarmed the autologon if it needed to.
# Refusing to run at all would turn "one application did not install" into "the
# machine never finished and nobody can tell why".
$workspaceRoot = $WorkspaceRoot
$content = $null

if ($fileSystem.TestPath($BootstrapPath)) {
    try {
        $bootstrap = Get-HDTBootstrapConfiguration -Path $BootstrapPath -FileSystem $fileSystem

        $providerArgument = @{
            Provider   = [string] $bootstrap.Provider
            Root       = [string] $bootstrap.DeployRoot
            FileSystem = $fileSystem
        }

        # THE DEPLOY ROOT THE RUN ACTUALLY USED, not the one the image was built
        # with. A bootstrap rule may have chosen another share, and the Welcome
        # screen may have corrected it by hand - both land in the state
        # document's variables, and both are what the WinPE leg connected to.
        if ($null -ne $state.variable -and $state.variable.Contains('HDTDeployRoot') -and
            -not [string]::IsNullOrWhiteSpace([string] $state.variable['HDTDeployRoot'])) {

            $providerArgument['Root'] = [string] $state.variable['HDTDeployRoot']
        }

        if ([string] $bootstrap.Provider -eq 'Smb' -and -not [bool] $bootstrap.PromptForCredential) {
            $providerArgument['Credential'] = $bootstrap.GetCredential()
        }

        $content = New-HDTContentProvider @providerArgument
        [void] $content.Connect()

        $workspaceRoot = [string] $providerArgument['Root']

        # A setFrom rule and a PowerShell step both name paths relative to the
        # workspace, so the invoker follows the root rather than the parameter.
        $scriptInvoker = New-HDTScriptInvoker -Root $workspaceRoot

        Write-HDTLog -Context $bootLog -Component 'Resume' `
            -Message ("connected to '{0}' over {1}" -f $workspaceRoot, $bootstrap.Provider)
    } catch {
        $content = $null

        Write-HDTLog -Context $bootLog -Severity Warning -Component 'Resume' `
            -Message ("the deployment share could not be reached, so any step needing content will fail: {0}" -f
                $_.Exception.Message)
    }
} else {
    Write-HDTLog -Context $bootLog -Severity Warning -Component 'Resume' `
        -Message ("no bootstrap document at '{0}', so this leg runs against the local disk alone." -f $BootstrapPath)
}

$sequenceFile = $SequencePath
if ([string]::IsNullOrWhiteSpace($sequenceFile)) {
    $sequenceFile = Get-HDTWorkspacePath -Root $workspaceRoot -Kind TaskSequences `
        -ChildPath ([string] $state.sequenceId), 'sequence.yaml'
}

$sequence = Import-HDTSequenceDocument -Path $sequenceFile -FileSystem $fileSystem

# The state's runId and the BOOT LOG'S seq are what make this leg continuous with
# the last one. Not $state.seq: the boot context above has already consumed a
# number for the reboot.resume record, and seeding from the state here would
# reissue it.
$log = New-HDTLogContext -RunId ([string] $state.runId) -Phase FullOS -LogPath $logRoot `
    -FileSystem $fileSystem -Clock $clock -Seq ([long] $bootLog.Seq)

$catalog = New-HDTServiceCatalog -FileSystem $fileSystem -Clock $clock -Registry $registry `
    -Lsa $lsa -Process $process -Power $power -ScriptInvoker $scriptInvoker -Cim $cim `
    -Environment $environment -Content $content

$variable = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($name in @($state.variable.Keys)) {
    $variable[[string] $name] = $state.variable[$name]
}

$context = New-HDTExecutionContext -RunId ([string] $state.runId) -Phase FullOS `
    -WorkspaceRoot $workspaceRoot -Variable $variable -Service $catalog -Log $log -State $state

# WHERE THIS LEG'S LOGS GO WHEN IT ENDS. The loop copies the tree only when it
# is told where, and this payload never told it - so everything the full-OS leg
# did stayed on a machine that had already been handed over, while the
# technician looked at the share.
#
# THROUGH Get-HDTLogDestination, which is what the WinPE entry point uses, so
# HDTSLShare sends these to a log server exactly as it sends the others. A
# second answer to "where do logs go" is a second place for them to be missing
# from.
#
# NOTHING TO COPY TO IS NOT AN ERROR. A leg that could not reach the share still
# runs, and its log stays on the machine where somebody can read it.
$logDestination = ''

if (-not [string]::IsNullOrWhiteSpace($workspaceRoot) -and $null -ne $content) {
    try {
        $logDestination = [string] (Get-HDTLogDestination -WorkspaceRoot $workspaceRoot -Variable $variable).Path
    } catch {
        $logDestination = ''
    }
}

$loopArgument = @{
    Sequence  = $sequence
    Context   = $context
    State     = $state
    StatePath = $StatePath
}
if (-not [string]::IsNullOrWhiteSpace($logDestination)) {
    $loopArgument['LogDestination'] = $logDestination
}

$run = Invoke-HDTTaskSequence @loopArgument

if ($run.Status -eq 'Failed') {
    exit 1
}

exit 0