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 LegacyLicenseFile
        The .flf license, used for platform majors up to 19. Business Central switched licence
        formats at BC20: a .bclicense is rejected by an older service tier, and vice versa. The
        factory spans majors inside one run, so it needs both and picks per platform version.
 
    .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,
        [string] $LegacyLicenseFile,
        [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 {
        $pvMajor = try { ([version](ConvertTo-BcVersion $pv)).Major } catch { 0 }
        Write-ALbuildLog "[$pv] Creating container '$containerName' ..."
        $createArgs = @{
            Name             = $containerName
            ArtifactUrl      = $Item.ArtifactUrl
            DockerExecutable = $DockerExecutable
        }
        if ($Credential) { $createArgs['Credential'] = $Credential }
        if ($MemoryLimit) { $createArgs['MemoryLimit'] = $MemoryLimit }
        # Up to BC19 the .flf applies, from BC20 the .bclicense. Without a licence the container
        # falls back to the demo licence bundled with the artifact, which for old majors is no
        # longer valid - BC17 in run 27319 died with 'The license file is corrupt. Error Code: -200'
        # before the service tier ever came up.
        $licenseForVersion = if ($pvMajor -le 19 -and $LegacyLicenseFile) { $LegacyLicenseFile } else { $LicenseFile }
        if ($licenseForVersion) { $createArgs['LicenseFile'] = $licenseForVersion }
        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, keyed by country and published atomically, so several workers can resolve
        # the same artifact concurrently without seeing a half-staged or foreign-country one.
        #
        # DELIBERATELY NOT CAUGHT. Without these symbols nothing in this container can compile, so a
        # failure here is a failure of the platform version - which the outer catch already reports, on
        # every product, with the real message. It used to be caught and downgraded to a warning: in run
        # 27372 the staging for 27.5.46862.0 lost a race, and what reached the log was AL1022 for
        # 'Microsoft Base Application' followed by four products 'blocked by a failed dependency' - every
        # line of it pointing away from the actual cause.
        $artifact = Get-BcArtifact -ArtifactUrl $Item.ArtifactUrl
        $symbolFolders = @(Get-BcArtifactSymbolFolder -Artifact $artifact)
        Write-ALbuildLog "[$pv] Symbol folders: $($symbolFolders.Count)"

        # Symbols for the catalogue's OWN apps. A dependent is recompiled against this platform
        # version, so the compiler needs its dependencies as packages - and the only build of them
        # that matches this platform is the one made moments ago in this container. Without it the
        # compile dies with AL1022 naming the dependency (run 27319: Print Agent and Proxy
        # Application failed on every platform version, looking for Extension License 2.1.0.0).
        #
        # A folder of its own, not the shared app cache, so a product never finds ITSELF in its own
        # package cache - only the chain is written here.
        $chainSymbolFolder = Join-Path (Join-Path $WorkRoot 'chainsym') $pv
        if (Test-Path -LiteralPath $chainSymbolFolder) { Remove-Item -LiteralPath $chainSymbolFolder -Recurse -Force }
        New-Item -ItemType Directory -Force -Path $chainSymbolFolder | Out-Null

        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' })) ..."
                # The chain arrives deepest-first, so each entry compiles against the ones before it.
                foreach ($dependency in $chain) {
                    $dependencyApp = Get-BcRuntimeAppFile -Product $dependency -WorkRoot $WorkRoot -PlatformVersion $pv -Signing $Signing -SymbolFolder ($symbolFolders + @($chainSymbolFolder))
                    Publish-BcContainerApp -Name $containerName -AppFile $dependencyApp -Sync -Install -SkipVerification -DockerExecutable $DockerExecutable
                    $installed.Add($dependency)
                    # Copied, not referenced: Get-BcRuntimeAppFile caches per (platform version, app id)
                    # in a folder shared with every other product, and pointing the compiler at that
                    # would hand a product its own package.
                    Copy-Item -LiteralPath $dependencyApp -Destination (Join-Path $chainSymbolFolder (Split-Path -Leaf $dependencyApp)) -Force
                }

                $appFile = Get-BcRuntimeAppFile -Product $product -WorkRoot $WorkRoot -PlatformVersion $pv -Signing $Signing -SymbolFolder ($symbolFolders + @($chainSymbolFolder))
                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()
    }
}