Modules/businessdev.ALbuild.RuntimePackages/Public/Save-BcRuntimeDependencyPackage.ps1

function Save-BcRuntimeDependencyPackage {
    <#
    .SYNOPSIS
        Fetches the runtime package of ONE dependency for ONE platform version into a symbol folder -
        from the NuGet feed, or from blob storage when the feed does not have it.
 
    .DESCRIPTION
        Without a container a dependent app still needs its dependency chain, but only as SYMBOLS. The
        container route published the chain into a service tier; the source route needs the chain's
        packages as files the compiler can read.
 
        A runtime package is the right shape for this, and that is measured rather than assumed: a probe
        app that DECLARES a dependency on Extension License and then USES it - a codeunit type, an enum
        type and a procedure signature - compiled identically (3875 bytes both ways) whether the symbol
        came from the dependency's runtime package or from its normal .app. So the packages already in
        the feed and in blob storage can serve as chain symbols.
 
        It is also correct BY CONSTRUCTION, which the normal .app is not: a runtime package is produced
        for exactly one platform version and carries that version's runtime, while a normal .app is
        compiled once against the newest platform. Handing a BC 28 .app to BC 22's compiler produces
        'AL1153: the referenced module ... with runtime reference version 14.0 cannot be loaded by the
        compiler with version 11.1' - a lesson from the local BC 17-29 validation, where a stale
        .alpackages folder did exactly that.
 
        The version is matched EXACTLY, never approximately. Microsoft guarantees a runtime package only
        on the platform version that produced it, so 'closest build at or below the target' - which the
        feed provider offers - would be a silent correctness risk here.
 
    .PARAMETER Publisher
        Publisher of the dependency, as its app.json spells it.
 
    .PARAMETER Name
        Name of the dependency, as its app.json spells it.
 
    .PARAMETER AppId
        App id of the dependency. Only used for the blob layout, which is keyed by app id.
 
    .PARAMETER AppVersion
        The dependency's app version whose runtime package is wanted - the RELEASED version, since that
        is what the feed and the blob carry.
 
    .PARAMETER PlatformVersion
        The platform version the package must have been produced for.
 
    .PARAMETER OutputFolder
        Symbol folder to copy the package into. Created when missing.
 
    .PARAMETER Feed
        A runtime feed from Register-BcFeed (-Kind runtime). Omitted = do not look in a feed.
 
    .PARAMETER BlobContext
        An Azure storage context. Omitted = do not look in blob storage.
 
    .PARAMETER BlobContainerName
        Container holding the '<appId>/<platformVersion>/<file>' layout.
 
    .OUTPUTS
        PSCustomObject with Found (bool), Source ('Feed'|'Blob'|'None'), File, and Reason - a sentence
        naming every place that was searched when nothing was found.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Publisher,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AppId,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AppVersion,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $PlatformVersion,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $OutputFolder,
        [object] $Feed,
        [object] $BlobContext,
        [string] $BlobContainerName
    )

    if (-not (Test-Path -LiteralPath $OutputFolder)) { New-Item -ItemType Directory -Force -Path $OutputFolder | Out-Null }

    $fileName = Get-BcRuntimePackageFileName -Publisher $Publisher -Name $Name -Version $AppVersion
    $target = Join-Path $OutputFolder $fileName
    $label = "'$Name' $AppVersion for platform $PlatformVersion"

    # Already here from an earlier dependent in the same run - the common case once a chain app is
    # shared by several products.
    if (Test-Path -LiteralPath $target) {
        Write-ALbuildLog -Level Verbose "Chain symbol $label was already fetched."
        return [PSCustomObject]@{ Found = $true; Source = 'Cached'; File = $target; Reason = '' }
    }

    # Every place that was tried is remembered, so a failure can name them. A dependency that cannot be
    # found is the single most common reason a platform version gets skipped, and 'not found' without
    # the id and the path it looked for is unactionable.
    $tried = [System.Collections.Generic.List[string]]::new()

    # ---------------------------------------------------------------- 1. the NuGet feed
    if ($Feed) {
        $packageId = Get-BcRuntimeFeedPackageId -Publisher $Publisher -Name $Name -AppVersion $AppVersion
        $tried.Add("feed '$(Get-BcRuntimeProperty -InputObject $Feed -Name 'Name' -Default 'runtime')' package '$packageId' version '$PlatformVersion'")
        try {
            $versions = @(Get-BcPackageVersion -Feed $Feed -PackageId $packageId | ForEach-Object { @($_.Versions) } | Where-Object { $_ })
            if ($versions -contains $PlatformVersion) {
                $file = $Feed.DownloadAppFile($packageId, $PlatformVersion)
                Copy-Item -LiteralPath $file -Destination $target -Force
                Write-ALbuildLog "Chain symbol $label taken from the feed ('$packageId')."
                return [PSCustomObject]@{ Found = $true; Source = 'Feed'; File = $target; Reason = '' }
            }
            Write-ALbuildLog -Level Verbose ("Feed package '$packageId' has $($versions.Count) build(s) but not " +
                "$PlatformVersion$(if ($versions.Count -gt 0 -and $versions.Count -le 6) { " (has: $($versions -join ', '))" }).")
        }
        catch {
            # A feed that cannot be read must not look like a missing package: the blob is tried next,
            # but the reason is kept so the summary can tell the two apart.
            $tried.Add("feed lookup failed: $(($_.Exception.Message -replace '\s+', ' ').Trim())")
            Write-ALbuildLog -Level Warning "Feed lookup for $label failed: $($_.Exception.Message)"
        }
    }

    # ---------------------------------------------------------------- 2. blob storage (legacy store)
    if ($BlobContext -and $BlobContainerName) {
        # Two names on purpose: the blob layout is '<appId>/<pv>/<publisher>_<name>_<version>.app', but
        # the emitting side names its output '..._<version>.runtime.app'. Which one a given app landed
        # under depends on when it was uploaded, so both are tried and the one that matched is logged.
        $candidates = @(
            "$AppId/$PlatformVersion/$fileName"
            "$AppId/$PlatformVersion/$($fileName -replace '\.app$', '.runtime.app')"
        )
        foreach ($blobName in $candidates) {
            $tried.Add("blob '$BlobContainerName/$blobName'")
            try {
                $blob = Get-AzStorageBlob -Context $BlobContext -Container $BlobContainerName -Blob $blobName -ErrorAction Stop
                if (-not $blob) { continue }
                $null = Get-AzStorageBlobContent -Context $BlobContext -Container $BlobContainerName -Blob $blobName `
                    -Destination $target -Force -ErrorAction Stop
                Write-ALbuildLog "Chain symbol $label taken from blob storage ('$blobName')."
                return [PSCustomObject]@{ Found = $true; Source = 'Blob'; File = $target; Reason = '' }
            }
            catch {
                # A missing blob is the expected outcome for the first candidate name; only say something
                # when it is not a plain 'does not exist'.
                $m = "$($_.Exception.Message)"
                if ($m -notmatch '(?i)does not exist|not found|BlobNotFound') {
                    $tried.Add("blob lookup failed: $(($m -replace '\s+', ' ').Trim())")
                    Write-ALbuildLog -Level Warning "Blob lookup for $label failed: $m"
                }
            }
        }
    }

    if ($tried.Count -eq 0) { $tried.Add('nowhere - neither a feed nor a blob container was configured') }
    $reason = "The runtime package of $label could not be found. Searched: $($tried -join '; ')."
    Write-ALbuildLog -Level Warning $reason
    return [PSCustomObject]@{ Found = $false; Source = 'None'; File = $null; Reason = $reason }
}