Modules/businessdev.ALbuild.RuntimePackages/Public/Invoke-BcRuntimeFactory.ps1
|
function Invoke-BcRuntimeFactory { <# .SYNOPSIS Builds runtime packages for a slice of platform versions, several containers at a time. .DESCRIPTION Runs the work items from Get-BcRuntimeWorkSet through a pool of worker threads, each owning one BC container for one platform version, and checkpoints every version as soon as it is done. WHY A POOL INSIDE ONE JOB Throughput here is capped by the organisation's four parallel self-hosted jobs, not by the build host - which has 36 cores and 192 GB of RAM and was measured with ~72 GB still free while four containers ran. Adding pipeline stages cannot get past that cap; adding workers inside a job does, and costs nothing. WHY CHECKPOINTING Build 27197 built 186 runtime packages and shipped 130 of them. Upload ran once, after the whole loop, so when the run was cancelled ten hours of finished work was thrown away. -OnVersionComplete runs after each platform version, so a cancelled or crashed run loses at most the version still in flight. The callback runs on the DISPATCHER thread, deliberately. Uploading to blob storage and pushing to a feed needs an Azure PowerShell context, and those context objects are not safe to share across runspaces. Workers produce files; the dispatcher ships them. PER-APP VISIBILITY Azure DevOps renders a stage as a single icon, and giving every app its own stage would mean a container per (app, platform version) - the arrangement the factory exists to remove. So the per-app result is carried in the artefacts a job can publish instead: * the log, one collapsible '##[group]' per platform version with one line per app; * -JUnitPath, one test case per app per version for PublishTestResults@2, which is what puts a green/red/skipped entry per app into the Tests tab; * -SummaryPath, the app x version matrix for '##vso[task.uploadsummary]'. Worker output is captured to a per-version file and replayed by the dispatcher when the version finishes, so the log reads as ordered blocks instead of interleaved lines from parallel workers. .PARAMETER WorkItem Work items from Get-BcRuntimeWorkSet (PlatformVersion, ArtifactUrl, Products[]). .PARAMETER WorkRoot Root for the private per-worker working copies. .PARAMETER OutputFolder Root of the '<appId>/<platformVersion>/' output layout. .PARAMETER Throughput Requested worker count. Capped by available memory and disk - see Get-BcRuntimeWorkerCount. .PARAMETER MemoryLimit Per-container memory limit. .PARAMETER ReserveHostGb Host memory to leave for the agent, the compiler and the OS. .PARAMETER Credential Container admin credential. .PARAMETER LicenseFile BC licence for the containers. .PARAMETER Signing Splat for Invoke-BcAppSigning. .PARAMETER TeardownMode Full (default) removes each app and its dependency chain after its runtime package is produced. .PARAMETER UseImageCache Start containers from the cached version-specific image. .PARAMETER OnVersionComplete Scriptblock invoked on the dispatcher thread with the finished version result - the checkpoint. .PARAMETER LogFolder Where per-version worker logs are written. Defaults to a folder under -WorkRoot. .PARAMETER JUnitPath Write JUnit results here for PublishTestResults@2. .PARAMETER SummaryPath Write the Markdown matrix here for task.uploadsummary. .PARAMETER DockerExecutable Docker executable. .PARAMETER NoAzureDevOpsLogging Suppress the '##vso[...]' logging commands. Tests set this: they run inside a real Azure DevOps job, where a logged issue would mark that job rather than the run under test. .OUTPUTS PSCustomObject: Produced, Failed, Skipped, Results[], WorkerCount. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $WorkItem, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $WorkRoot, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $OutputFolder, [ValidateRange(1, 64)] [int] $Throughput = 3, [string] $MemoryLimit = '8G', [ValidateRange(0, 512)] [int] $ReserveHostGb = 16, [pscredential] $Credential, [string] $LicenseFile, [hashtable] $Signing, [ValidateSet('Full', 'AppOnly')] [string] $TeardownMode = 'Full', [switch] $UseImageCache, [scriptblock] $OnVersionComplete, [string] $LogFolder, [string] $JUnitPath, [string] $SummaryPath, [string] $DockerExecutable = 'docker', # Suppress the '##vso[...]' logging commands. Set by tests: they run inside a real Azure # DevOps job, where those commands would apply to the job running the tests. [switch] $NoAzureDevOpsLogging ) Assert-ALbuildLicensed -Feature 'RuntimePackages' $items = @($WorkItem) if ($items.Count -eq 0) { Write-ALbuildLog 'Nothing to build - every planned runtime package already exists.' if ($JUnitPath) { Set-Content -LiteralPath $JUnitPath -Value (ConvertTo-BcRuntimeJUnit -Result @()) -Encoding UTF8 } if ($SummaryPath) { Set-Content -LiteralPath $SummaryPath -Value (ConvertTo-BcRuntimeSummaryMarkdown -Result @()) -Encoding UTF8 } return [PSCustomObject]@{ Produced = 0; Failed = 0; Skipped = 0; Results = @(); WorkerCount = 0 } } foreach ($folder in @($WorkRoot, $OutputFolder)) { if (-not (Test-Path -LiteralPath $folder)) { New-Item -ItemType Directory -Force -Path $folder | Out-Null } } if (-not $LogFolder) { $LogFolder = Join-Path $WorkRoot 'logs' } if (-not (Test-Path -LiteralPath $LogFolder)) { New-Item -ItemType Directory -Force -Path $LogFolder | Out-Null } $workers = Get-BcRuntimeWorkerCount -Requested $Throughput ` -MemoryLimitGb ([int]($MemoryLimit -replace '[^0-9]', '')) ` -FreeMemoryGb (Get-BcRuntimeFreeMemoryGb) ` -ReserveMemoryGb $ReserveHostGb ` -FreeDiskGb (Get-BcRuntimeFreeDiskGb -DockerExecutable $DockerExecutable) # Thread jobs need PowerShell 7; on Windows PowerShell 5.1 the factory still works, just serially. $canRunParallel = $workers.Count -gt 1 -and $null -ne (Get-Command -Name 'Start-ThreadJob' -ErrorAction SilentlyContinue) if (-not $canRunParallel -and $workers.Count -gt 1) { Write-ALbuildLog -Level Warning 'Start-ThreadJob is unavailable (Windows PowerShell 5.1); running one container at a time.' } Write-ALbuildLog "Worker pool: $($workers.Reason)$(if (-not $canRunParallel) { ' (serial)' })." $results = [System.Collections.Generic.List[object]]::new() $checkpoint = { param([object] $Result) $log = Join-Path $LogFolder "$($Result.PlatformVersion).log" Write-Host "##[group]BC $($Result.PlatformVersion) - $($Result.Status) ($([Math]::Round($Result.Seconds / 60, 1)) min)" if (Test-Path -LiteralPath $log) { Get-Content -LiteralPath $log | ForEach-Object { Write-Host $_ } } foreach ($p in @($Result.Products)) { $mark = switch ($p.Status) { 'Succeeded' { ' +' } 'Skipped' { ' -' } default { ' x' } } Write-Host "$mark $($p.Name) [$($p.Status)] $($p.Seconds)s$(if ($p.Error) { " - $($p.Error)" })" } Write-Host '##[endgroup]' # Issues are raised from the dispatcher only, after the replay, so they attribute to the right # version instead of landing in whatever block happened to be open. foreach ($p in @($Result.Products | Where-Object { $_.Status -eq 'Failed' })) { # Suppressible for the same reason: a unit test that drives the failure path would # otherwise raise real warnings on the job running it. if (-not $NoAzureDevOpsLogging) { Write-Host "##vso[task.logissue type=warning]BC $($Result.PlatformVersion): $($p.Name) - $($p.Error)" } } $results.Add($Result) if ($OnVersionComplete) { try { & $OnVersionComplete $Result } catch { Write-ALbuildLog -Level Warning "Checkpoint for BC $($Result.PlatformVersion) failed: $($_.Exception.Message)" } } } if ($PSCmdlet.ShouldProcess("$($items.Count) platform version(s)", 'Build runtime packages')) { $buildArgs = @{ WorkRoot = $WorkRoot OutputFolder = $OutputFolder Credential = $Credential MemoryLimit = $MemoryLimit LicenseFile = $LicenseFile Signing = $Signing TeardownMode = $TeardownMode UseImageCache = [bool]$UseImageCache DockerExecutable = $DockerExecutable } if (-not $canRunParallel) { foreach ($item in $items) { & $checkpoint (Invoke-BcRuntimeVersionBuild @buildArgs -Item $item) } } else { $manifest = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) '..' | Join-Path -ChildPath 'businessdev.ALbuild.psd1' $manifest = [System.IO.Path]::GetFullPath($manifest) $queue = [System.Collections.Generic.Queue[object]]::new() foreach ($item in $items) { $queue.Enqueue($item) } $running = [System.Collections.Generic.List[object]]::new() $start = { param([object] $Item) $job = Start-ThreadJob -ArgumentList $manifest, $Item, $buildArgs, $LogFolder -ScriptBlock { param($ManifestPath, $Item, $BuildArgs, $LogFolder) $logFile = Join-Path $LogFolder "$($Item.PlatformVersion).log" $resultFile = Join-Path $LogFolder "$($Item.PlatformVersion).result.xml" try { Import-Module $ManifestPath -Force -DisableNameChecking # RuntimePackages is NESTED inside the root manifest, so 'Get-Module -Name' never # returns it - a fresh runspace only ever sees 'businessdev.ALbuild'. The module # object is needed because Invoke-BcRuntimeVersionBuild is private and only # reachable through its own module scope. Reach it via the root's NestedModules; # the by-name lookup stays first for the case where the submodule was imported # standalone, which the tests do. $module = Get-Module -Name 'businessdev.ALbuild.RuntimePackages' if (-not $module) { $module = @((Get-Module -Name 'businessdev.ALbuild').NestedModules | Where-Object { $_.Name -eq 'businessdev.ALbuild.RuntimePackages' })[0] } # Fail with something a human can act on. Without this the next line dies as # "The expression after '&' ... produced an object that was not valid", which # says nothing about the module that failed to resolve. if (-not $module) { throw "businessdev.ALbuild.RuntimePackages could not be resolved after importing '$ManifestPath'. Loaded modules: $(@(Get-Module | ForEach-Object { $_.Name }) -join ', ')." } # Every stream is captured to the worker's own file. Nothing writes to the host # from a worker: parallel writes would interleave into an unreadable log. $result = $null $output = & { $splat = @{} foreach ($k in $BuildArgs.Keys) { if ($null -ne $BuildArgs[$k]) { $splat[$k] = $BuildArgs[$k] } } $result = & $module { param($S, $I) Invoke-BcRuntimeVersionBuild @S -Item $I } $splat $Item $result | Export-Clixml -LiteralPath $resultFile -Depth 6 } *>&1 $output | ForEach-Object { "$_" } | Set-Content -LiteralPath $logFile -Encoding UTF8 } catch { "Worker for BC $($Item.PlatformVersion) failed: $($_.Exception.Message)" | Set-Content -LiteralPath $logFile -Encoding UTF8 [PSCustomObject]@{ PlatformVersion = "$($Item.PlatformVersion)" Status = 'Failed' Seconds = 0 Products = @($Item.Products | ForEach-Object { [PSCustomObject]@{ Name = "$($_.Name)"; Status = 'Failed'; Seconds = 0; Error = $_.Exception.Message; File = $null } }) } | Export-Clixml -LiteralPath $resultFile -Depth 6 } return "$($Item.PlatformVersion)" } $running.Add([PSCustomObject]@{ Job = $job; Item = $Item }) } while ($running.Count -lt $workers.Count -and $queue.Count -gt 0) { & $start $queue.Dequeue() } while ($running.Count -gt 0) { $finished = Wait-Job -Job @($running.Job) -Any foreach ($done in @($finished)) { $entry = @($running | Where-Object { $_.Job.Id -eq $done.Id })[0] Receive-Job -Job $done -ErrorAction SilentlyContinue | Out-Null Remove-Job -Job $done -Force -ErrorAction SilentlyContinue [void]$running.Remove($entry) $resultFile = Join-Path $LogFolder "$($entry.Item.PlatformVersion).result.xml" $result = if (Test-Path -LiteralPath $resultFile) { Import-Clixml -LiteralPath $resultFile } else { [PSCustomObject]@{ PlatformVersion = "$($entry.Item.PlatformVersion)" Status = 'Failed' Seconds = 0 Products = @($entry.Item.Products | ForEach-Object { [PSCustomObject]@{ Name = "$($_.Name)"; Status = 'Failed'; Seconds = 0; Error = 'The worker produced no result.'; File = $null } }) } } & $checkpoint $result if ($queue.Count -gt 0) { & $start $queue.Dequeue() } } } } } $all = @($results | ForEach-Object { $_.Products }) $produced = @($all | Where-Object { $_.Status -eq 'Succeeded' }).Count $failed = @($all | Where-Object { $_.Status -eq 'Failed' }).Count $skipped = @($all | Where-Object { $_.Status -eq 'Skipped' }).Count if ($JUnitPath) { Set-Content -LiteralPath $JUnitPath -Value (ConvertTo-BcRuntimeJUnit -Result $results.ToArray()) -Encoding UTF8 } if ($SummaryPath) { Set-Content -LiteralPath $SummaryPath -Value (ConvertTo-BcRuntimeSummaryMarkdown -Result $results.ToArray()) -Encoding UTF8 } Write-ALbuildLog -Level Success "Runtime factory: $produced package(s) built, $failed failed, $skipped skipped across $($results.Count) platform version(s)." # NO '##vso[task.complete ...]' here. A cmdlet must not decide the result of the pipeline task that # happens to be running it - the caller owns that, and does it from the returned counts. When the # unit tests exercised the failure path inside a real Azure DevOps job, this line marked the CI job # itself SucceededWithIssues (build 27294). # # A failed platform version still never fails the run: Microsoft occasionally publishes an artifact # that cannot produce a working container, and one bad version must not stall the catalogue. return [PSCustomObject]@{ Produced = $produced Failed = $failed Skipped = $skipped Results = $results.ToArray() WorkerCount = $workers.Count } } |