Modules/businessdev.ALbuild.RuntimePackages/Private/Invoke-BcRuntimeVersionBuild.ps1

function Invoke-BcRuntimeVersionBuild {
    <#
    .SYNOPSIS
        Produces the runtime packages of every pending app for ONE platform version, in one container.
 
    .DESCRIPTION
        The body a factory worker runs. One container is created for the platform version and every
        pending app is processed inside it, in dependency order:
 
            create container
              for each app (dependencies first):
                install the app's dependency chain
                publish + install the app
                produce the runtime package
                tear the app AND its chain down again
            remove container
 
        WHY THE TEARDOWN IS NOT OPTIONAL
        The dependencies are themselves catalogue products that need runtime packages of their own, so
        they are built here too - the container is not a fixed stage with a fixed set of apps on it, it
        is reused by apps with different requirements. Leaving an app installed would mean each app's
        runtime package is produced against whatever happened to be installed before it, which depends
        on the order the planner emitted and on which apps failed earlier. That is not a property anyone
        wants a shipped artifact to have. Tearing down after each app makes the package a function of
        the app and the platform version only.
 
        The teardown runs in 'finally': an app that fails must not leave its partial state behind for
        the next one, or one failure quietly turns into a series of them.
 
        FAILURE ISOLATION
        Every app is isolated. If one fails, its transitive dependents in the same container are marked
        as failed too (they cannot be installed without it) and everything else continues. A failed
        platform version never fails the run - Microsoft occasionally publishes an artifact that cannot
        produce a working container, and one bad version must not stall a catalogue.
 
    .PARAMETER Item
        One work item from Get-BcRuntimeWorkSet: PlatformVersion, ArtifactUrl, Products[].
 
    .PARAMETER WorkRoot
        Private working folder for this version. Nothing outside it is written, which is what lets
        several workers run at once - the shared checkout is never mutated.
 
    .PARAMETER OutputFolder
        Root of the '<appId>/<platformVersion>/' output layout.
 
    .PARAMETER Credential
        Container admin credential.
 
    .PARAMETER MemoryLimit
        Container memory limit.
 
    .PARAMETER LicenseFile
        BC licence for the container (runtime package generation is licence-checked on the server).
 
    .PARAMETER Signing
        Splat for Invoke-BcAppSigning. Omitted = no signing.
 
    .PARAMETER TeardownMode
        Full (default) removes the app and its dependency chain after each app. AppOnly keeps the chain
        installed - faster, but the packages are then produced against a shared installation state.
 
    .PARAMETER UseImageCache
        Start from a cached version-specific image instead of installing the artifact on every start.
 
    .PARAMETER DockerExecutable
        Docker executable.
 
    .OUTPUTS
        PSCustomObject: PlatformVersion, Status, Seconds, Products[] (Name, Status, Seconds, Error, File).
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNull()] [object] $Item,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $WorkRoot,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $OutputFolder,
        [pscredential] $Credential,
        [string] $MemoryLimit = '8G',
        [string] $LicenseFile,
        [hashtable] $Signing,
        [ValidateSet('Full', 'AppOnly')] [string] $TeardownMode = 'Full',
        [switch] $UseImageCache,
        [string] $DockerExecutable = 'docker'
    )

    $started = Get-Date
    $pv = "$($Item.PlatformVersion)"
    $results = [System.Collections.Generic.List[object]]::new()
    # An app cannot be installed without its dependencies, so once a product fails every product that
    # depends on it fails too - reported honestly rather than attempted and failing with a confusing
    # "dependency not found".
    $failedProducts = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    $containerName = "bdev-rt-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
    $status = 'Succeeded'

    try {
        Write-ALbuildLog "[$pv] Creating container '$containerName' ..."
        $createArgs = @{
            Name             = $containerName
            ArtifactUrl      = $Item.ArtifactUrl
            DockerExecutable = $DockerExecutable
        }
        if ($Credential) { $createArgs['Credential'] = $Credential }
        if ($MemoryLimit) { $createArgs['MemoryLimit'] = $MemoryLimit }
        if ($LicenseFile) { $createArgs['LicenseFile'] = $LicenseFile }
        if ($UseImageCache) { $createArgs['UseImageCache'] = $true }
        New-BcContainer @createArgs | Out-Null

        # First-party symbols for THIS platform version, resolved once and reused by every app. The apps
        # are recompiled against the target platform, so without them the compile fails with AL1022
        # ('Microsoft Application ... could not be found'). Get-BcArtifact / Get-BcArtifactSymbolFolder
        # are lock-protected and published atomically, so several workers can resolve the same artifact
        # concurrently without seeing a half-extracted one.
        $symbolFolders = @()
        try {
            $artifact = Get-BcArtifact -ArtifactUrl $Item.ArtifactUrl
            $symbolFolders = @(Get-BcArtifactSymbolFolder -Artifact $artifact)
            Write-ALbuildLog "[$pv] Symbol folders: $($symbolFolders.Count)"
        }
        catch {
            # Not fatal here: report it once, then let each app fail with the compiler's own message
            # rather than aborting the whole platform version on a symbol-resolution hiccup.
            Write-ALbuildLog -Level Warning "[$pv] Could not resolve first-party symbols: $($_.Exception.Message)"
        }

        foreach ($product in @($Item.Products)) {
            $productStarted = Get-Date
            $chain = @(Get-BcRuntimeProperty -InputObject $product -Name 'DependencyChain' -Default @())

            $blockedBy = @($chain | Where-Object { $failedProducts.Contains("$($_.Name)") } | ForEach-Object { "$($_.Name)" })
            if ($blockedBy.Count -gt 0) {
                [void]$failedProducts.Add("$($product.Name)")
                $results.Add([PSCustomObject]@{
                        Name    = "$($product.Name)"
                        Status  = 'Failed'
                        Seconds = 0
                        Error   = "Skipped because its dependency failed on this platform version: $($blockedBy -join ', ')."
                        File    = $null
                    })
                Write-ALbuildLog -Level Warning "[$pv] $($product.Name): dependency failed ($($blockedBy -join ', ')); not attempted."
                continue
            }

            $installed = [System.Collections.Generic.List[object]]::new()
            try {
                Write-ALbuildLog "[$pv] $($product.Name): installing dependency chain ($(if ($chain.Count) { ($chain.Name) -join ' -> ' } else { 'none' })) ..."
                foreach ($dependency in $chain) {
                    $dependencyApp = Get-BcRuntimeAppFile -Product $dependency -WorkRoot $WorkRoot -PlatformVersion $pv -Signing $Signing -SymbolFolder $symbolFolders
                    Publish-BcContainerApp -Name $containerName -AppFile $dependencyApp -Sync -Install -SkipVerification -DockerExecutable $DockerExecutable
                    $installed.Add($dependency)
                }

                $appFile = Get-BcRuntimeAppFile -Product $product -WorkRoot $WorkRoot -PlatformVersion $pv -Signing $Signing -SymbolFolder $symbolFolders
                Publish-BcContainerApp -Name $containerName -AppFile $appFile -Sync -Install -SkipVerification -DockerExecutable $DockerExecutable
                $installed.Add($product)

                $targetFolder = Join-Path $OutputFolder ($product.TargetPath -replace '/', [System.IO.Path]::DirectorySeparatorChar)
                if (-not (Test-Path -LiteralPath $targetFolder)) { New-Item -ItemType Directory -Force -Path $targetFolder | Out-Null }

                $runtimeApp = New-BcRuntimePackage -Name $containerName -AppName "$($product.Name)" `
                    -AppPublisher "$($product.Publisher)" -AppVersion "$($product.AppVersion)" `
                    -OutputFolder $targetFolder -DockerExecutable $DockerExecutable
                if ($Signing) { Invoke-BcAppSigning -Path $runtimeApp @Signing }

                $targetFile = Join-Path $targetFolder "$($product.FileName)"
                if ("$runtimeApp" -ne "$targetFile") { Move-Item -LiteralPath $runtimeApp -Destination $targetFile -Force }

                $results.Add([PSCustomObject]@{
                        Name    = "$($product.Name)"
                        Status  = 'Succeeded'
                        Seconds = [Math]::Round(((Get-Date) - $productStarted).TotalSeconds, 1)
                        Error   = $null
                        File    = $targetFile
                    })
                Write-ALbuildLog -Level Success "[$pv] $($product.Name): runtime package created."
            }
            catch {
                [void]$failedProducts.Add("$($product.Name)")
                $status = 'SucceededWithIssues'
                $results.Add([PSCustomObject]@{
                        Name    = "$($product.Name)"
                        Status  = 'Failed'
                        Seconds = [Math]::Round(((Get-Date) - $productStarted).TotalSeconds, 1)
                        Error   = $_.Exception.Message
                        File    = $null
                    })
                Write-ALbuildLog -Level Warning "[$pv] $($product.Name) failed: $($_.Exception.Message)"
            }
            finally {
                if ($TeardownMode -eq 'Full') {
                    # Reverse order: a dependency cannot be removed while something still depends on it.
                    for ($i = $installed.Count - 1; $i -ge 0; $i--) {
                        $entry = $installed[$i]
                        try {
                            Uninstall-BcContainerApp -Name $containerName -AppName "$($entry.Name)" -AppVersion "$($entry.AppVersion)" -Force -DockerExecutable $DockerExecutable
                            Unpublish-BcContainerApp -Name $containerName -AppName "$($entry.Name)" -AppVersion "$($entry.AppVersion)" -DockerExecutable $DockerExecutable
                        }
                        catch {
                            # Best effort: the container is discarded at the end of the version anyway, and a
                            # teardown error must not mask the build error that may have caused it.
                            Write-ALbuildLog -Level Warning "[$pv] Could not remove '$($entry.Name)': $($_.Exception.Message)"
                        }
                    }
                }
            }
        }
    }
    catch {
        $status = 'Failed'
        Write-ALbuildLog -Level Warning "[$pv] Platform version failed: $($_.Exception.Message)"
        foreach ($product in @($Item.Products)) {
            if (@($results | Where-Object { $_.Name -eq "$($product.Name)" }).Count -gt 0) { continue }
            $results.Add([PSCustomObject]@{
                    Name    = "$($product.Name)"
                    Status  = 'Failed'
                    Seconds = 0
                    Error   = "Platform version failed before this app was reached: $($_.Exception.Message)"
                    File    = $null
                })
        }
    }
    finally {
        Remove-BcContainer -Name $containerName -DockerExecutable $DockerExecutable -Confirm:$false -ErrorAction SilentlyContinue
    }

    return [PSCustomObject]@{
        PlatformVersion = $pv
        Status          = $status
        Seconds         = [Math]::Round(((Get-Date) - $started).TotalSeconds, 1)
        Products        = $results.ToArray()
    }
}