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) $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 on the SAME volume as the final folder so the publish is an atomic rename, not a copy. $staging = "$targetFolder.staging-" + [System.Guid]::NewGuid().ToString('N') 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 Expand-Archive -LiteralPath $tempZip -DestinationPath $staging -Force # 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) $platformFolder = $null if ($IncludePlatform) { $platformUrl = $ArtifactUrl -replace "/$([regex]::Escape($info.Country))$", '/platform' $platformFolder = & $downloadAndExtract $platformUrl (Join-Path $versionFolder 'platform') ([bool]$Force) } return [PSCustomObject]@{ ApplicationPath = $appFolder PlatformPath = $platformFolder Version = $info.Version Country = $info.Country Type = $info.Type } } } |