Modules/businessdev.ALbuild.Feeds/Public/Resolve-BcExternalDependency.ps1
|
function Resolve-BcExternalDependency { <# .SYNOPSIS Stages dependency apps that are not resolved from a NuGet feed (Azure DevOps Universal feed packages and committed local .app files) into a folder, and returns their identities. .DESCRIPTION Complements the NuGet dependency resolver for sources it cannot query by app id: * UniversalPackages - named packages downloaded from an Azure DevOps Universal feed via Get-BcUniversalPackage (e.g. your own apps published to a 'Products' feed). * LocalPackage - repository folders of committed .app files, for ISVs without any public feed (e.g. CKL). Relative folders are resolved against -BaseFolder. * UrlPackage - .app files served from a static download URL (an ISV that publishes a direct link rather than a feed, e.g. bms GmbH's b-jira). Downloaded to -OutputFolder. All staged .app files land in -OutputFolder; the returned identities (read with Expand-BcAppFile) let the caller pin them as a satisfied baseline for NuGet resolution and install them into the container alongside the feed-resolved apps. Missing local folders are skipped with a warning; symbol-only files are kept (the caller decides installability). .PARAMETER UniversalPackages Universal feed entries, each an object/hashtable: { feed, name, version?, organization?, project? }. 'organization' falls back to -Organization; 'version' defaults to '*'. .PARAMETER LocalPackage Folders of committed .app files (relative paths resolved against -BaseFolder). .PARAMETER UrlPackage Static download URLs of .app files. Each entry is a string URL or an object { url, tokenEnv?, header? }: 'tokenEnv' names an environment variable whose value is sent as a Bearer token; 'header' is a raw 'Name: value' header. A download failure is non-fatal (a warning; other sources still stage). .PARAMETER OutputFolder Folder the packages are staged into (created if missing). .PARAMETER Organization Default Azure DevOps organization (URL or name) for Universal entries without their own. .PARAMETER Project Default project for project-scoped Universal feeds. .PARAMETER AccessToken PAT for the Universal download (exported as AZURE_DEVOPS_EXT_PAT by Get-BcUniversalPackage). .PARAMETER BaseFolder Root used to resolve relative -LocalPackage folders. Default: current location. .OUTPUTS PSCustomObject per staged app: Id, Name, Publisher, Version, File, Source ('local'|'url'| 'universal') and NameBased ($true when the identity was read from the file name because the .app is an encrypted runtime package that cannot be read on the host; Id is then empty). #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Resolves the full set of external dependency packages; the plural is intentional.')] [CmdletBinding()] [OutputType([PSCustomObject])] param( [object[]] $UniversalPackages = @(), [string[]] $LocalPackage = @(), [object[]] $UrlPackage = @(), [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $OutputFolder, [string] $Organization, [string] $Project, [string] $AccessToken, [string] $BaseFolder = (Get-Location).Path ) if (-not (Test-Path -LiteralPath $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null } $field = { param($source, [string] $name) if ($null -eq $source) { return $null } $prop = $source.PSObject.Properties | Where-Object { $_.Name -ieq $name } | Select-Object -First 1 if ($prop) { return $prop.Value } return $null } # Track which source staged each file, so the caller can apply source precedence (local/url beat # NuGet feeds; universal is feed-subordinate) and an unreadable local/url package can fall back to # a file-name identity. Universal packages nest arbitrarily -> tag them by a post-staging snapshot; # url/local land at known paths and are tagged inline. $norm = { param($p) try { [System.IO.Path]::GetFullPath("$p") } catch { "$p" } } $sourceByFile = @{} # Parse a '{Publisher}_{Name}_{Version}.app' file name: version is the last '_'-segment that looks # like a version, the first segment is the publisher, the rest is the name. $parseAppFileName = { param($fileName) $b = [System.IO.Path]::GetFileNameWithoutExtension($fileName) $parts = @($b -split '_') $ver = if ($parts.Count -ge 2 -and $parts[-1] -match '^\d+(\.\d+){0,3}$') { $parts[-1] } else { '' } $rest = if ($ver) { @($parts[0..($parts.Count - 2)]) } else { $parts } [PSCustomObject]@{ Publisher = if ($rest.Count -ge 1) { $rest[0] } else { '' } Name = if ($rest.Count -ge 2) { ($rest[1..($rest.Count - 1)] -join '_') } else { '' } Version = $ver } } foreach ($entry in @($UniversalPackages)) { $feed = & $field $entry 'feed' $name = & $field $entry 'name' if (-not $feed -or -not $name) { Write-ALbuildLog -Level Warning "Skipping a universalPackages entry without 'feed'/'name'." continue } $org = & $field $entry 'organization'; if (-not $org) { $org = $Organization } if (-not $org) { throw "Universal package '$name' has no 'organization' and no default was provided." } $proj = & $field $entry 'project'; if (-not $proj) { $proj = $Project } $version = & $field $entry 'version'; if (-not $version) { $version = '*' } $packageArgs = @{ Organization = $org; Feed = $feed; Name = $name; Version = $version; OutputFolder = $OutputFolder } if ($proj) { $packageArgs['Project'] = $proj } if ($AccessToken) { $packageArgs['AccessToken'] = $AccessToken } # Non-fatal: if a universal package is missing or access is denied, fall through to the local # package(s) instead of failing the resolve. Local packages are the availability fallback. try { Get-BcUniversalPackage @packageArgs | Out-Null } catch { Write-ALbuildLog -Level Warning "Universal package '$name' (feed '$feed') unavailable: $($_.Exception.Message). Falling back to local/other sources." } } # Everything staged so far came from a universal feed (they nest in arbitrary subfolders). foreach ($f in (Get-ChildItem -LiteralPath $OutputFolder -Filter '*.app' -File -Recurse -ErrorAction SilentlyContinue)) { $sourceByFile[(& $norm $f.FullName)] = 'universal' } foreach ($entry in @($UrlPackage)) { $url = if ($entry -is [string]) { $entry } else { & $field $entry 'url' } if ([string]::IsNullOrWhiteSpace($url)) { Write-ALbuildLog -Level Warning "Skipping a urlPackages entry without 'url'." continue } # Name the download from the URL's last (URL-decoded) segment; fall back to a generated name. $leaf = try { [System.Uri]::UnescapeDataString(([System.Uri]$url).Segments[-1]) } catch { '' } if ([string]::IsNullOrWhiteSpace($leaf) -or ($leaf -notmatch '\.app$')) { $leaf = "urlpackage-$([guid]::NewGuid().ToString('N').Substring(0, 8)).app" } $dest = Join-Path $OutputFolder $leaf $headers = @{} $tokenEnv = if ($entry -is [string]) { $null } else { & $field $entry 'tokenEnv' } if ($tokenEnv) { $tokenValue = [System.Environment]::GetEnvironmentVariable($tokenEnv) if ($tokenValue) { $headers['Authorization'] = "Bearer $tokenValue" } else { Write-ALbuildLog -Level Warning "urlPackages tokenEnv '$tokenEnv' is not set; requesting '$url' without authorization." } } $rawHeader = if ($entry -is [string]) { $null } else { & $field $entry 'header' } if ($rawHeader) { $sep = ([string]$rawHeader).IndexOf(':') if ($sep -gt 0) { $headers[([string]$rawHeader).Substring(0, $sep).Trim()] = ([string]$rawHeader).Substring($sep + 1).Trim() } } # Non-fatal (like the universal source): a broken link falls through to local/other sources. try { Write-ALbuildLog "Downloading URL dependency '$leaf' from '$url'..." Invoke-WebRequest -Uri $url -OutFile $dest -Headers $headers -UseBasicParsing -ErrorAction Stop } catch { Write-ALbuildLog -Level Warning "URL dependency '$url' unavailable: $($_.Exception.Message). Falling back to local/other sources." } if (Test-Path -LiteralPath $dest) { $sourceByFile[(& $norm $dest)] = 'url' } } foreach ($folder in @($LocalPackage)) { if ([string]::IsNullOrWhiteSpace($folder)) { continue } # [IO.Path]::Combine / [IO.Directory]::Exists (not Join-Path / Test-Path) so a drive-qualified # path on a foreign OS - e.g. a Windows 'C:\...' folder evaluated on Linux - is combined and # reported missing instead of throwing a terminating DriveNotFoundException (both Join-Path and # Test-Path resolve the drive qualifier) under $ErrorActionPreference = 'Stop'. $resolved = if ([System.IO.Path]::IsPathRooted($folder)) { $folder } else { [System.IO.Path]::Combine($BaseFolder, $folder) } if (-not [System.IO.Directory]::Exists($resolved)) { Write-ALbuildLog "Local dependency folder '$resolved' not found; skipping." continue } $apps = @(Get-ChildItem -LiteralPath $resolved -Filter '*.app' -File -Recurse -ErrorAction SilentlyContinue) foreach ($app in $apps) { $destFile = Join-Path $OutputFolder $app.Name Copy-Item -LiteralPath $app.FullName -Destination $destFile -Force $sourceByFile[(& $norm $destFile)] = 'local' } Write-ALbuildLog "Staged $($apps.Count) local dependency package(s) from '$resolved'." } # Read every staged package, then keep only the newest version per app id - several sources may # serve the same app (e.g. universal + local). Files for superseded versions are removed from the # staging folder so only the chosen one is pinned and installed. $byId = @{} $loose = [System.Collections.Generic.List[object]]::new() # packages pinned by file-name identity (no readable id) $rank = { param($s) if ("$s" -eq 'universal') { 1 } else { 0 } } # local/url outrank universal # Recurse: a Universal-feed package nests its .app(s) in subfolders (e.g. app\ and test\), so a # top-level-only scan would miss them and stage nothing. Local packages are copied flat (above). foreach ($file in (Get-ChildItem -LiteralPath $OutputFolder -Filter '*.app' -File -Recurse -ErrorAction SilentlyContinue)) { $source = $sourceByFile[(& $norm $file.FullName)]; if (-not $source) { $source = 'universal' } $info = $null try { $info = Expand-BcAppFile -Path $file.FullName } catch { # No host-readable ZIP payload (an encrypted BC runtime app). For a local/url package, fall # back to the '{Publisher}_{Name}_{Version}.app' file-name identity so it can still be pinned # by name (never queried from a feed) and installed. A universal package without a readable # id has no reliable name to fall back on, so it is skipped. if ($source -eq 'universal') { Write-ALbuildLog "Skipping '$($file.Name)' for staging: no host-readable payload and no file-name identity." continue } $fn = & $parseAppFileName $file.Name Write-ALbuildLog "Package '$($file.Name)' is not host-readable (encrypted runtime app); using its file-name identity '$($fn.Publisher) / $($fn.Name) $($fn.Version)' for name-based pinning." $loose.Add([PSCustomObject]@{ Id = ''; Name = $fn.Name; Publisher = $fn.Publisher; Version = $fn.Version; File = $file.FullName; Source = $source; NameBased = $true }) continue } finally { if ($info -and $info.ExtractedPath -and (Test-Path -LiteralPath $info.ExtractedPath)) { Remove-Item -LiteralPath $info.ExtractedPath -Recurse -Force -ErrorAction SilentlyContinue } } $entry = [PSCustomObject]@{ Id = $info.Id; Name = $info.Name; Publisher = $info.Publisher; Version = $info.Version; File = $file.FullName; Source = $source; NameBased = $false } if (-not $info.Id) { $loose.Add($entry); continue } $id = "$($info.Id)".ToLowerInvariant() if (-not $byId.ContainsKey($id)) { $byId[$id] = $entry; continue } # The same app id from several sources: prefer the higher-priority source (local/url over # universal); among equal sources keep the newest version. $kept = $byId[$id] $take = if ((& $rank $entry.Source) -ne (& $rank $kept.Source)) { (& $rank $entry.Source) -lt (& $rank $kept.Source) } else { (ConvertTo-BcVersion $entry.Version) -gt (ConvertTo-BcVersion $kept.Version) } if ($take) { Write-ALbuildLog "Using '$($entry.Name)' $($entry.Version) from $($entry.Source) (over $($kept.Version) from $($kept.Source))." Remove-Item -LiteralPath $kept.File -Force -ErrorAction SilentlyContinue $byId[$id] = $entry } else { if ($entry.File -ne $kept.File) { Remove-Item -LiteralPath $entry.File -Force -ErrorAction SilentlyContinue } } } # Flatten: a Universal package nests its .app in subfolders, but this cmdlet's contract is that every # staged .app lands directly in -OutputFolder (so consumers can install/copy from it without a # recursive scan). Move each kept file to the OutputFolder root and report its new path. $staged = @(@($byId.Values) + @($loose)) foreach ($app in $staged) { $dest = Join-Path $OutputFolder ([System.IO.Path]::GetFileName($app.File)) if ($app.File -ne $dest) { Move-Item -LiteralPath $app.File -Destination $dest -Force $app.File = $dest } } return $staged } |