Modules/businessdev.ALbuild.Containers/Public/Get-BcArtifact.ps1
|
function Get-BcArtifact { <# .SYNOPSIS Downloads and caches a Business Central artifact (application + platform packages). .DESCRIPTION Downloads the application package for the given artifact URL and, unless suppressed, the matching platform package (the same URL with the country replaced by 'platform'). Each package is a ZIP; it is extracted into the artifact cache and reused on subsequent calls. .PARAMETER ArtifactUrl The artifact URL (see Find-BcArtifactUrl). .PARAMETER CacheFolder Root cache folder. Defaults to the configured ArtifactCacheFolder. .PARAMETER IncludePlatform Also download/extract the platform package. Default: $true. .PARAMETER Force Re-download even if a cached copy exists. .EXAMPLE Get-BcArtifact -ArtifactUrl (Find-BcArtifactUrl -Country w1 -Select Latest) .OUTPUTS PSCustomObject with ApplicationPath, PlatformPath, Version, Country. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline)] [ValidateNotNullOrEmpty()] [string] $ArtifactUrl, [string] $CacheFolder, [bool] $IncludePlatform = $true, [switch] $Force ) process { if (-not $CacheFolder) { $CacheFolder = Get-ALbuildConfig -Name 'ArtifactCacheFolder' } $info = Get-BcArtifactVersion -ArtifactUrl $ArtifactUrl $downloadAndExtract = { param([string] $url, [string] $targetFolder, [bool] $force, [string] $cacheRoot) $completeMarker = Join-Path $targetFolder '.albuild-complete' # Fast path: a fully-extracted artifact (marker present) is reused as-is. if ((Test-Path -LiteralPath $completeMarker) -and -not $force) { return $targetFolder } # CONCURRENCY: self-hosted agents share this cache and parallel builds race a brand-new # artifact the first time it appears. The old code extracted IN PLACE into the shared final # folder (and deleted it when the marker was missing), so a parallel build could read a # half-extracted folder - missing apps -> "package ... could not be found" at compile. # Fix: (1) serialise same-host extraction of the SAME artifact with a global mutex (avoids # redundant multi-GB downloads), and (2) publish ATOMICALLY - extract to a private temp folder # on the same volume, then rename it into place. A partial extraction is never visible under # $targetFolder, and a complete folder is never deleted out from under a concurrent reader. $hash = [System.BitConverter]::ToString( [System.Security.Cryptography.SHA256]::Create().ComputeHash( [System.Text.Encoding]::UTF8.GetBytes($targetFolder.ToLowerInvariant()))).Replace('-', '').Substring(0, 32) $mutex = New-Object System.Threading.Mutex($false, "Global\albuild-artifact-$hash") $held = $false try { try { $held = $mutex.WaitOne([TimeSpan]::FromMinutes(30)) } catch [System.Threading.AbandonedMutexException] { $held = $true } # prior holder crashed; we own it now # Re-check under the lock: another process may have completed the extraction while we waited. if ((Test-Path -LiteralPath $completeMarker) -and -not $force) { return $targetFolder } $tempZip = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.Guid]::NewGuid().ToString() + '.zip') # Stage under a SHORT directory at the cache ROOT - deliberately NOT a long # "<final>.staging-<guid>" suffix beside the DEEP final folder. BC artifacts nest paths # close to the 260-char Windows MAX_PATH limit (e.g. the platform's WebClient assets), so a # long staging suffix at the <type>\<version>\ level pushes the deepest files past MAX_PATH # on agents without long-path support and Expand-Archive fails with a misleading # "Cannot find path ... because it does not exist". A cache-root staging path is SHORTER # than the final path, so anything that fits the final folder also fits staging - while # staying on the SAME volume (rename is atomic). The id is only 8 hex so '\.staging\<id>' # (18 chars) is <= even the SHORTEST possible final suffix ('\onprem\1.0.0.0\w1'), keeping # the guarantee for every artifact; 32 bits is ample against the rare cross-host race the # mutex does not cover (same-host extraction is already serialised above). $stagingRoot = Join-Path $cacheRoot '.staging' New-Item -Path $stagingRoot -ItemType Directory -Force | Out-Null $staging = Join-Path $stagingRoot ([System.Guid]::NewGuid().ToString('N').Substring(0, 8)) try { Write-ALbuildLog "Downloading artifact $url ..." $attempt = 0 while ($true) { $attempt++ # Save-BcRemoteFile prefers curl (much faster than Invoke-WebRequest for the large # artifact ZIPs) and falls back to Invoke-WebRequest when curl is unavailable. try { Save-BcRemoteFile -Url $url -OutFile $tempZip; break } catch { if ($attempt -ge 3) { throw "Failed to download artifact '$url': $($_.Exception.Message)" } Start-Sleep -Seconds (5 * $attempt) } } New-Item -Path $staging -ItemType Directory -Force | Out-Null try { Expand-Archive -LiteralPath $tempZip -DestinationPath $staging -Force } catch { # The usual causes on a freshly set-up agent: a path exceeding the 260-char Windows # MAX_PATH limit (BC artifacts nest deeply - enable long paths with # LongPathsEnabled=1 or use a shorter ArtifactCacheFolder), or the disk running out # of space mid-extraction. Surface that instead of the raw path error. throw "Failed to extract artifact into '$staging': $($_.Exception.Message) " + '(this is typically a Windows MAX_PATH path-length limit - enable long paths ' + "(LongPathsEnabled=1) or shorten the artifact cache folder - or low disk space on the cache drive)." } # Marker goes INTO the staging folder, so it and the content become visible together. Set-Content -LiteralPath (Join-Path $staging '.albuild-complete') -Value (Get-Date -Format 'o') -Encoding UTF8 # Publish atomically. If a complete copy already exists (race lost, and not -force), # keep it. Only a stale/partial final (no marker) is replaced - never a complete one, so # a concurrent reader is never left without files. if ((Test-Path -LiteralPath $completeMarker) -and -not $force) { return $targetFolder } $parent = Split-Path -Parent $targetFolder if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -Path $parent -ItemType Directory -Force | Out-Null } if (Test-Path -LiteralPath $targetFolder) { Remove-Item -LiteralPath $targetFolder -Recurse -Force -ErrorAction SilentlyContinue } try { [System.IO.Directory]::Move($staging, $targetFolder) } catch { # Lost a cross-host race: another agent published it. Use theirs if complete. if (Test-Path -LiteralPath $completeMarker) { return $targetFolder } throw } return $targetFolder } finally { if (Test-Path -LiteralPath $tempZip) { Remove-Item -LiteralPath $tempZip -Force -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue } } } finally { if ($held) { $mutex.ReleaseMutex() } $mutex.Dispose() } } $versionFolder = Join-Path $CacheFolder ($info.Type) | Join-Path -ChildPath $info.Version.ToString() $appFolder = & $downloadAndExtract $ArtifactUrl (Join-Path $versionFolder $info.Country) ([bool]$Force) $CacheFolder $platformFolder = $null if ($IncludePlatform) { $platformUrl = $ArtifactUrl -replace "/$([regex]::Escape($info.Country))$", '/platform' $platformFolder = & $downloadAndExtract $platformUrl (Join-Path $versionFolder 'platform') ([bool]$Force) $CacheFolder } return [PSCustomObject]@{ ApplicationPath = $appFolder PlatformPath = $platformFolder Version = $info.Version Country = $info.Country Type = $info.Type } } } |