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
        }
    }
}