Public/Steps/Invoke-HDTInstallApplicationsStep.ps1

function Invoke-HDTInstallApplicationsStep {
    <#
        .SYNOPSIS
            Installs the selected applications, in dependency order, surviving the
            reboots they ask for.
 
        .DESCRIPTION
            MDT's Install Applications, rebuilt on the catalog, the ordering and
            the detection of 07-01.
 
              - name: Install Applications
                type: InstallApplications
                selection: "%HDTApplications%"
 
            or a fixed list:
 
                selection: [7Zip-24.09, Contoso-Suite]
 
            and with neither, the HDTApplications variable is read directly -
            which is what a wizard answer or a rule sets. DESIGN 8: both forms
            "resolve to the same ordered install plan", because both are handed
            to the same Resolve-HDTApplicationOrder.
 
            THE PLAN IS LOGGED BEFORE ANYTHING RUNS. A technician reading the log
            of a build that went wrong needs to know what the step INTENDED, not
            only what it got through - and the plan is what shows the dependency
            closure pulled in an application the selection never named.
 
            SELECTING AN APPLICATION SELECTS WHAT IT NEEDS. The plan is the
            selection plus its transitive dependencies, ordered dependencies
            first, deterministically (Resolve-HDTApplicationOrder).
 
            DETECTION SKIPS, IT DOES NOT FAIL. An application whose rule reports
            it already installed is skipped and recorded as skipped. An
            application that declares no rule installs every time - DESIGN 8's
            documented behaviour, and the author's stated choice rather than an
            engine limitation.
 
            A 3010 REBOOTS AND COMES BACK. The step returns
            RebootRequested -Reenter and checkpoints what it has installed into
            _HDTApplicationInstalled, which the state document persists across
            legs. The next leg runs the step again and resumes at the NEXT
            application. THE TWO WAYS THIS FEATURE IS USUALLY GOT WRONG:
 
              no Reenter the loop advances past the step and every application
                            after the one that rebooted is silently skipped,
                            while the run reports success
              no progress the step restarts the list on every leg, reinstalling
                            everything ahead of the reboot each time
 
            The application that asked for the restart is checkpointed as
            INSTALLED, because 3010 means "installed, reboot required" - re-running
            it on the next leg would install it twice.
 
            AN INSTALL COMMAND IS A SHELL LINE, run through %ComSpec% /c exactly
            as the CommandLine step's command: is. Quoting, chaining and
            redirection are routine in what a vendor documents as their silent
            install. The comspec comes from the injected IEnvironmentProvider,
            never from $env:, and the working directory is the application's
            source folder so a relative installer path resolves.
 
            A FAILURE STOPS THE LIST. An application that returns a code in
            neither its successCodes nor its rebootCodes fails the step naming the
            application and the code, and the applications after it do not run -
            installing software on top of a failed dependency is how a machine
            ends up subtly broken. continueOnError on the step is how a sequence
            says otherwise, and that belongs to the loop rather than here.
 
        .PARAMETER Step
            A flattened step from Import-HDTSequenceDocument.
 
        .PARAMETER Context
            A New-HDTExecutionContext context. Its Service catalog must carry
            FileSystem and Process services; Registry and ScriptInvoker are needed
            only by the detection rules that use them.
 
        .OUTPUTS
            A New-HDTStepResult. Data carries planned, installed, skipped and, on
            a failure, the application and its exit code.
 
        .EXAMPLE
            Invoke-HDTInstallApplicationsStep -Step $step -Context $context
    #>

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

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

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

    $progressVariable = '_HDTApplicationInstalled'

    $fail = {
        param([string] $Message, [System.Collections.IDictionary] $Data)

        $payload = $Data
        if ($null -eq $payload) { $payload = [ordered] @{} }

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

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

    # -- the selection --------------------------------------------------------

    # A list in YAML stays a list; a token is expanded and then split, because
    # HDTApplications holds what a wizard or a rule wrote into one string.
    $split = {
        param([string] $Text)

        return @(@($Text -split '[,;\r\n]') |
                ForEach-Object { $_.Trim() } |
                Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    }

    $property = $Step.Property
    $selection = @()

    if ($null -ne $property -and $property.Contains('selection')) {
        $raw = $property['selection']

        if ($raw -is [System.Collections.IList] -and -not ($raw -is [string])) {
            $selection = @(@($raw) | ForEach-Object { ([string] $_).Trim() } |
                    Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
        } else {
            try {
                $expanded = Get-HDTStepProperty -Step $Step -Name 'selection' -Context $Context -Expand -As String
            } catch {
                return (& $fail ([string] $_.Exception.Message) ([ordered] @{ errorId = 'HDTConfigurationError' }))
            }

            $selection = & $split ([string] $expanded)
        }
    } elseif ($Context.Variable.Contains('HDTApplications')) {
        $selection = & $split ([string] $Context.Variable['HDTApplications'])
    }

    if (@($selection).Count -eq 0) {
        # A sequence that offers applications and a technician who picked none is
        # an ordinary deployment.
        $message = 'no applications were selected.'

        Write-HDTLog -Context $Context.Log -Message $message -Component 'InstallApplications' `
            -Data ([ordered] @{ planned = 0 })

        return (New-HDTStepResult -Status Completed -Message $message -Data ([ordered] @{
                    planned   = [string[]] @()
                    installed = [string[]] @()
                    skipped   = [string[]] @()
                }))
    }

    # -- the services ---------------------------------------------------------

    try {
        $fileSystem = $Context.Service.GetRequired('FileSystem', 'InstallApplications')
        $process = $Context.Service.GetRequired('Process', 'InstallApplications')
    } catch {
        return (& $fail ([string] $_.Exception.Message) $null)
    }

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

    try {
        $catalog = @(Get-HDTApplication -WorkspaceRoot ([string] $Context.WorkspaceRoot) `
                -FileSystem $fileSystem -Content $Context.Service.Content)

        $plan = @(Resolve-HDTApplicationOrder -Application $catalog -Id $selection)
    } catch {
        return (& $fail ([string] $_.Exception.Message) ([ordered] @{ errorId = 'HDTConfigurationError' }))
    }

    $plannedId = [string[]] @($plan | ForEach-Object { [string] $_.Id })

    # No -Event: DESIGN 4.4's event vocabulary is closed, and the plan is a
    # message rather than a lifecycle event. step.start already fired for this
    # step; a second one would make the stream lie about how many steps ran.
    Write-HDTLog -Context $Context.Log -Component 'InstallApplications' `
        -Message ('install plan, in order: {0}' -f ($plannedId -join ', ')) `
        -Data ([ordered] @{ planned = $plannedId; selected = [string[]] @($selection) })

    # -- the progress this run already made -----------------------------------

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

    if ($Context.Variable.Contains($progressVariable)) {
        foreach ($current in @($Context.Variable[$progressVariable])) {
            if (-not [string]::IsNullOrWhiteSpace([string] $current)) {
                [void] $installed.Add([string] $current)
            }
        }
    }

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

    $comSpec = 'cmd.exe'
    if ($null -ne $Context.Service.Environment) {
        $fromEnvironment = [string] $Context.Service.Environment.GetVariable('ComSpec')
        if (-not [string]::IsNullOrWhiteSpace($fromEnvironment)) { $comSpec = $fromEnvironment }
    }

    $timeoutMillisecond = 0
    if ([int] $Step.TimeoutMinutes -gt 0) {
        $timeoutMillisecond = [int] $Step.TimeoutMinutes * 60000
    }

    $checkpoint = {
        # The live variable dictionary is copied into the state document on every
        # save, so writing the progress here is what makes it survive the reboot.
        $Context.Variable[$progressVariable] = [string[]] @($installed)
    }

    # -- the list -------------------------------------------------------------

    foreach ($application in $plan) {
        $id = [string] $application.Id

        # Already done on an earlier leg of THIS run. Resuming at the next
        # application rather than restarting the list is the whole point of
        # checkpointing.
        if ($installed -contains $id) {
            Write-HDTLog -Context $Context.Log -Severity Debug -Component 'InstallApplications' `
                -Message ("'{0}' was installed on an earlier leg of this run." -f $id) `
                -Data ([ordered] @{ application = $id })

            continue
        }

        try {
            $alreadyInstalled = Test-HDTApplicationDetection -Detect $application.Detect `
                -FileSystem $fileSystem -Registry $Context.Service.Registry `
                -ScriptInvoker $Context.Service.ScriptInvoker -Variable $Context.Variable
        } catch {
            return (& $fail ("'{0}': {1}" -f $id, [string] $_.Exception.Message) ([ordered] @{
                        application = $id
                        planned     = $plannedId
                        installed   = [string[]] @($installed)
                        skipped     = [string[]] @($skipped)
                    }))
        }

        if ($alreadyInstalled) {
            [void] $skipped.Add($id)

            Write-HDTLog -Context $Context.Log -Component 'InstallApplications' `
                -Message ("'{0}' is already installed; skipping it." -f $application.Name) `
                -Data ([ordered] @{ application = $id })

            continue
        }

        $argument = '/c {0}' -f [string] $application.Install

        # DESIGN 4.4.5: the full command line is a Debug-only detail, because an
        # install command routinely carries a licence key or a service account.
        Write-HDTLog -Context $Context.Log -Severity Debug -Event 'native.exec' -Component 'InstallApplications' `
            -Message ('installing {0}: {1} {2}' -f $id, $comSpec, $argument) `
            -Data ([ordered] @{ application = $id; workingDirectory = [string] $application.SourcePath })

        $result = $process.Start($comSpec, $argument, [string] $application.SourcePath, $timeoutMillisecond)

        $exitCode = [int] $result.ExitCode

        $data = [ordered] @{
            application = $id
            exitCode    = $exitCode
            planned     = $plannedId
            installed   = [string[]] @($installed)
            skipped     = [string[]] @($skipped)
        }

        if ([bool] $result.TimedOut) {
            return (& $fail ("'{0}' timed out after {1} minute(s) and was stopped." -f $id, $Step.TimeoutMinutes) $data)
        }

        # rebootCodes is checked FIRST, the same precedence the CommandLine step
        # states: an installer reporting 3010 that also lists it as successful is
        # a real configuration, and treating it as plain success would drop the
        # restart it asked for.
        if (@($application.RebootCodes) -contains $exitCode) {
            # 3010 is "installed, reboot required" - so it IS installed, and the
            # next leg must not run it again.
            [void] $installed.Add($id)
            & $checkpoint

            $message = "'{0}' returned {1} and asked for a restart. {2} of {3} application(s) done." -f
            $id, $exitCode, @($installed).Count, @($plannedId).Count

            Write-HDTLog -Context $Context.Log -Message $message -Event 'native.exec' `
                -Component 'InstallApplications' -Data ([ordered] @{
                    application = $id
                    exitCode    = $exitCode
                    installed   = [string[]] @($installed)
                    skipped     = [string[]] @($skipped)
                })

            return (New-HDTStepResult -Status RebootRequested -ExitCode $exitCode -Reenter `
                    -Message $message -Data ([ordered] @{
                        application = $id
                        exitCode    = $exitCode
                        planned     = $plannedId
                        installed   = [string[]] @($installed)
                        skipped     = [string[]] @($skipped)
                    }))
        }

        if (-not (@($application.SuccessCodes) -contains $exitCode)) {
            return (& $fail ("'{0}' returned {1}, which is not in its successCodes ({2}) or rebootCodes ({3}). The applications after it were not installed." -f
                    $id, $exitCode, (@($application.SuccessCodes) -join ', '), (@($application.RebootCodes) -join ', ')) $data)
        }

        [void] $installed.Add($id)
        & $checkpoint

        Write-HDTLog -Context $Context.Log -Message ("installed '{0}' ({1})." -f $application.Name, $exitCode) `
            -Event 'native.exec' -Component 'InstallApplications' `
            -Data ([ordered] @{ application = $id; exitCode = $exitCode })
    }

    $message = 'installed {0} application(s), skipped {1} already present.' -f @($installed).Count, @($skipped).Count

    Write-HDTLog -Context $Context.Log -Message $message -Component 'InstallApplications' `
        -Data ([ordered] @{ installed = [string[]] @($installed); skipped = [string[]] @($skipped) })

    return (New-HDTStepResult -Status Completed -Message $message -Data ([ordered] @{
                planned   = $plannedId
                installed = [string[]] @($installed)
                skipped   = [string[]] @($skipped)
            }))
}