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',
        [ValidateSet('Container', 'Source')] [string] $Engine = 'Container',
        [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)

    # Set when an app could not be removed again. The container is shared by every product of this
    # platform version, so a leftover install is not a cosmetic problem: in run 27378 on 27.10.53179.0
    # the teardown of 365 business API failed and 365 business E-Invoice died immediately afterwards on
    # a container command, with nothing tying the two lines together. A container whose state cannot be
    # guaranteed must not produce shipped packages.
    $tainted = ''

    # The countries of one platform version run as concurrent workers, so everything this worker
    # writes under $WorkRoot has to be keyed by both. Without it they share output paths.
    $country = "$(Get-BcRuntimeProperty -InputObject $Item -Name 'Country' -Default '')"

    # SOURCE ENGINE: no container at all. Every BC artifact ships the complete AL compiler, and that
    # compiler emits the runtime package itself - so the two things the container was for (publishing
    # the dependency chain so the service tier can resolve it, and asking that tier for the package)
    # both fall away. Measured on this catalogue: 11 s per package against a container round trip of
    # roughly ten minutes, of which 78-94 % was provisioning. The dependencies are still needed - as
    # SYMBOLS, which is what the chain folder already is.
    #
    # Dispatched HERE, before the try: the container route's 'finally' removes the container, and a
    # 'return' from inside a try still runs it - so a source build would tear down a container it never
    # created. Harmless, but the log would say otherwise, and a test caught it.
    if ($Engine -eq 'Source') {
        return Invoke-BcRuntimeVersionBuildFromSource -Item $Item -WorkRoot $WorkRoot -OutputFolder $OutputFolder `
            -Signing $Signing -Started $started
    }

    $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 ($country) { "-$($country.ToLowerInvariant())" })"
        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

            if ($tainted) {
                [void]$failedProducts.Add("$($product.Name)")
                $results.Add([PSCustomObject]@{
                        Name    = "$($product.Name)"
                        Status  = 'Failed'
                        Seconds = 0
                        Error   = "Not attempted: $tainted"
                        File    = $null
                    })
                Write-ALbuildLog -Level Warning "[$pv] $($product.Name): not attempted - $tainted"
                continue
            }

            $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()
            # Set the moment the publish is attempted - see the note at the Publish call.
            $mayBePublished = $false
            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) {
                    # A chain app that fails here fails for everyone. Mark it by name so the existing
                    # dependency check skips the products that need it - including its own turn later in
                    # this loop. Without that, run 27378 re-attempted the API build for every dependent and
                    # collided with the file handles its own failed compiler still held, reporting
                    # 'logo.png is being used by another process' as a failure of Sanction Screen.
                    try {
                        $dependencyApp = Get-BcRuntimeAppFile -Product $dependency -WorkRoot $WorkRoot -PlatformVersion $pv -Country $country -Signing $Signing -SymbolFolder ($symbolFolders + @($chainSymbolFolder))
                    }
                    catch {
                        [void]$failedProducts.Add("$($dependency.Name)")
                        throw
                    }
                    Publish-BcContainerApp -Name $containerName -AppFile $dependencyApp -Sync -Install -SkipVerification -DockerExecutable $DockerExecutable
                    $installed.Add($dependency)
                    # Copied, not referenced: Get-BcRuntimeAppFile caches per (platform version, country, 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 -Country $country -Signing $Signing -SymbolFolder ($symbolFolders + @($chainSymbolFolder))
                # '-Sync -Install' is NOT atomic: the app can be published and then fail to install. Run
                # 27409 lost three ERiC packages exactly there, and took Sanction Screen down with them -
                # the app was never recorded, so teardown never removed it, and its dependency could then
                # not be unpublished ('required by 365 business ERiC'). Record the ATTEMPT beforehand, so
                # teardown unwinds what may be in the container rather than what we hoped happened.
                $mayBePublished = $true
                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') {
                    # First the app whose publish was attempted but never confirmed. It has to go before
                    # its dependencies, and it has to go QUIETLY: it may be published, half-published, or
                    # not there at all, and a complaint about removing something that was never there is
                    # noise. What matters is that a leftover cannot block the chain behind it.
                    if ($mayBePublished -and -not @($installed | Where-Object { "$($_.Name)" -eq "$($product.Name)" }).Count) {
                        Write-ALbuildLog "[$pv] $($product.Name): publish did not complete - removing any leftover before unwinding the chain."
                        try { Uninstall-BcContainerApp -Name $containerName -AppName "$($product.Name)" -AppVersion "$($product.AppVersion)" -Force -DockerExecutable $DockerExecutable } catch { }
                        try { Unpublish-BcContainerApp -Name $containerName -AppName "$($product.Name)" -AppVersion "$($product.AppVersion)" -DockerExecutable $DockerExecutable } catch { }
                    }

                    # 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]
                        # BOTH steps are attempted, and EITHER failure taints. Previously one try block
                        # covered both, so an uninstall that failed skipped the unpublish entirely - and a
                        # still-published app keeps blocking its own dependency, which is what actually
                        # breaks the next product. Attempting both is the fix; still treating an uninstall
                        # failure as a taint keeps the existing safety net, because 'that combination
                        # cannot happen in practice' is not something to bet a shared container on.
                        $trouble = $null
                        try {
                            Uninstall-BcContainerApp -Name $containerName -AppName "$($entry.Name)" -AppVersion "$($entry.AppVersion)" -Force -DockerExecutable $DockerExecutable
                        }
                        catch {
                            $trouble = $_.Exception.Message
                            Write-ALbuildLog -Level Warning "[$pv] Could not uninstall '$($entry.Name)' (still attempting the unpublish): $($_.Exception.Message)"
                        }
                        try {
                            Unpublish-BcContainerApp -Name $containerName -AppName "$($entry.Name)" -AppVersion "$($entry.AppVersion)" -DockerExecutable $DockerExecutable
                        }
                        catch {
                            $trouble = $_.Exception.Message
                        }
                        if ($trouble) {
                            # It must not mask the build error that may have caused it - so it is recorded,
                            # not thrown. But it is not harmless either: the container lives on for the rest
                            # of this platform version, and the next product would inherit the leftover.
                            if (-not $tainted) {
                                $tainted = ("'$($entry.Name)' could not be removed from the container after " +
                                    "'$($product.Name)', so its state is no longer known: $trouble")
                            }
                            Write-ALbuildLog -Level Warning "[$pv] Could not remove '$($entry.Name)': $trouble"
                        }
                    }
                }
            }
        }
    }
    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
        # Reported, not just used: two work items share a platform version and differ only by country,
        # so a log line without it names the same thing twice.
        Country         = $country
        Status          = $status
        Seconds         = [Math]::Round(((Get-Date) - $started).TotalSeconds, 1)
        Products        = $results.ToArray()
    }
}