Modules/businessdev.ALbuild.Apps/Public/Install-BcContainerDependency.ps1
|
function Install-BcContainerDependency { <# .SYNOPSIS Publishes and installs an AL project's resolved dependency apps into a container, in dependency order. .DESCRIPTION The runtime counterpart to compile-time symbol resolution: an app can only be installed and tested once the apps it depends on are installed. This stages the .app packages found in -PackageFolder into the container's shared folder, then - inside the container - reads each package with Get-NAVAppInfo (which handles both regular and encrypted *runtime* packages, unlike a host-side ZIP reader), orders them so each app is published after the apps it depends on, and publishes/synchronises/installs each. Apps already installed (e.g. Microsoft first-party / the toolkit) are skipped; dependencies outside the set are assumed already present. Requires Windows + Docker. .PARAMETER Name Container name. .PARAMETER PackageFolder One or more folders of dependency .app files to install (searched recursively). Use this only when there is no resolver lock file: a developer's local .alpackages accumulates many historical versions/variants - prefer -PackageFile with the resolver-chosen set. .PARAMETER PackageFile Explicit dependency .app file paths to install (e.g. the files named in the resolver lock file). Preferred over -PackageFolder so only the resolved versions are installed. At least one of -PackageFolder/-PackageFile is required. .PARAMETER ServerInstance BC server instance inside the container. Default 'BC'. .PARAMETER SkipVerification Publish without signature verification. Default $true. .PARAMETER OperationTimeoutSeconds Maximum seconds to allow a single app's publish/sync/install before abandoning it and recording a failure. Prevents a hung operation (e.g. a deadlocked schema sync) from freezing the build. Default 600. .PARAMETER DockerExecutable The Docker executable to use (default 'docker'). .OUTPUTS System.String lines describing what was installed/skipped. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '', Justification = 'The Start-Job scriptblock declares a param() block bound positionally via -ArgumentList; $using: is intentionally not used.')] [CmdletBinding(SupportsShouldProcess)] [OutputType([string])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [Alias('ContainerName')] [string] $Name, [string[]] $PackageFolder, [string[]] $PackageFile, [string] $ServerInstance = 'BC', [bool] $SkipVerification = $true, [int] $OperationTimeoutSeconds = 600, [string] $DockerExecutable = 'docker' ) if (-not $PackageFolder -and -not $PackageFile) { throw 'Specify -PackageFolder and/or -PackageFile.' } $found = New-Object System.Collections.Generic.List[object] foreach ($folder in $PackageFolder) { if ($folder -and (Test-Path -LiteralPath $folder)) { foreach ($f in (Get-ChildItem -LiteralPath $folder -Filter '*.app' -File -Recurse -ErrorAction SilentlyContinue)) { [void]$found.Add($f) } } } foreach ($file in $PackageFile) { if ($file -and (Test-Path -LiteralPath $file)) { [void]$found.Add((Get-Item -LiteralPath $file)) } elseif ($file) { Write-ALbuildLog -Level Warning "Dependency package file not found: $file" } } $files = @($found | Sort-Object -Property FullName -Unique) if ($files.Count -eq 0) { Write-ALbuildLog "No dependency packages found (folders: $($PackageFolder -join ', '); files: $((@($PackageFile) | Measure-Object).Count))." return } if (-not $PSCmdlet.ShouldProcess($Name, "Install $($files.Count) dependency app(s)")) { return } # Stage the packages into the container's shared folder (C:\run\my\deps) so the server can read # them - 'docker cp' is unsupported against a running hyperv container, and host-side parsing # cannot read encrypted runtime packages. $hostShare = Get-BcContainerHostShare -Name $Name $hostDeps = Join-Path $hostShare 'deps' if (Test-Path -LiteralPath $hostDeps) { Remove-Item -LiteralPath $hostDeps -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Force -Path $hostDeps | Out-Null foreach ($file in $files) { Copy-Item -LiteralPath $file.FullName -Destination $hostDeps -Force } # Inside the container: read every package (Get-NAVAppInfo handles encrypted runtime apps), order # by dependency, then publish/sync/install the ones that are not already installed. $null = Invoke-BcContainerCommand -ContainerName $Name -DockerExecutable $DockerExecutable -StreamOutput -Variables @{ DepsFolder = 'C:\run\my\deps' ServerInstance = $ServerInstance SkipVerification = [bool]$SkipVerification OperationTimeoutSeconds = [int]$OperationTimeoutSeconds PollSeconds = 10 } -ScriptBlock { # Extract an app id from an app-info or dependency object (the property is 'AppId' on some # objects, 'Id' on others) and lowercase it. The 'if' must live in a scriptblock body (where # it is a valid expression in an assignment); written inline as '(if ...)' it would be parsed # as a command grouping and fail with "The term 'if' is not recognized". $appId = { param($o) $raw = if ($o.PSObject.Properties.Name -contains 'AppId') { $o.AppId } else { $o.Id } if ($raw) { "$raw".ToLowerInvariant() } else { '' } } # Does a .app contain an installable payload, or is it a symbols-only / compile-only package # (only the manifest + SymbolReference, which compiles but cannot be published)? A BC .app is a # NAVX header followed by a ZIP; a symbols package contains just those metadata files. $hasPayload = { param($path) try { Add-Type -AssemblyName System.IO.Compression -ErrorAction SilentlyContinue $bytes = [System.IO.File]::ReadAllBytes($path) $zs = -1 for ($i = 0; ($i -lt 64) -and ($i -lt $bytes.Length - 4); $i++) { if ($bytes[$i] -eq 0x50 -and $bytes[$i + 1] -eq 0x4B -and $bytes[$i + 2] -eq 0x03 -and $bytes[$i + 3] -eq 0x04) { $zs = $i; break } } if ($zs -lt 0) { return $true } # unexpected format - do not block $ms = New-Object System.IO.MemoryStream (, ([byte[]]$bytes[$zs..($bytes.Length - 1)])) $zip = New-Object System.IO.Compression.ZipArchive($ms, [System.IO.Compression.ZipArchiveMode]::Read) $meta = @('[Content_Types].xml', 'DocComments.xml', 'NavxManifest.xml', 'SymbolReference.json') $payload = $false foreach ($e in $zip.Entries) { if ($meta -notcontains $e.FullName) { $payload = $true; break } } $zip.Dispose(); $ms.Dispose() return $payload } catch { return $true } # on any error, do not block - let publish decide } $apps = foreach ($file in (Get-ChildItem -LiteralPath $DepsFolder -Filter '*.app' -File)) { try { $info = Get-NAVAppInfo -Path $file.FullName -ErrorAction Stop } catch { Write-Output "Skipped unreadable package '$($file.Name)': $($_.Exception.Message)"; continue } $depIds = @($info.Dependencies | ForEach-Object { & $appId $_ } | Where-Object { $_ }) [PSCustomObject]@{ File = $file.FullName Id = & $appId $info Name = $info.Name Version = $info.Version Publisher = "$($info.Publisher)" DepIds = $depIds HasPayload = (& $hasPayload $file.FullName) } } # Microsoft first-party apps (System/Base/Application, localization, test toolkit, ...) are # delivered by the container itself, so never publish them from .alpackages - those are the # compile-time symbol packages and may target a different platform build. ISV dependencies on # them are satisfied by the container, so dropping them here also keeps the dependency graph # correct (their ids simply aren't in-set). $platform = @($apps | Where-Object { $_.Publisher -eq 'Microsoft' }) if ($platform.Count -gt 0) { Write-Output "Skipped $($platform.Count) Microsoft first-party package(s) provided by the container." } $apps = @($apps | Where-Object { $_.Id -and $_.Publisher -ne 'Microsoft' }) # The same app can be present more than once - e.g. several versions committed under # .dependencies, or a local package that also resolved from a feed. Keep only the newest # version of each (by app id) so a stale copy is never installed alongside the current one. $byId = @{} foreach ($a in $apps) { $existing = $byId[$a.Id] if (-not $existing) { $byId[$a.Id] = $a; continue } $cur = try { [version]"$($a.Version)" } catch { [version]'0.0.0.0' } $prev = try { [version]"$($existing.Version)" } catch { [version]'0.0.0.0' } if ($cur -gt $prev) { Write-Output "Using newest '$($a.Name)' $($a.Version) (ignoring older $($existing.Version))." $byId[$a.Id] = $a } else { Write-Output "Using newest '$($existing.Name)' $($existing.Version) (ignoring older $($a.Version))." } } $apps = @($byId.Values) # Apps already installed in the tenant (skip those, and treat them as satisfied deps). $installed = @{} foreach ($a in @(Get-NAVAppInfo -ServerInstance $ServerInstance -Tenant 'default' -TenantSpecificProperties -ErrorAction SilentlyContinue)) { if ($a.IsInstalled) { $id = & $appId $a if ($id) { $installed[$id] = $true } } } # Topologically order: emit an app once its in-set dependencies have been emitted. Iterate the # fixed $apps array and track emitted apps in a hashtable (avoids List.Remove / @(list), which # bind unreliably in the container's Windows PowerShell). $inSet = @{}; foreach ($a in $apps) { $inSet[$a.Id] = $true } $done = @{} $ordered = New-Object System.Collections.Generic.List[object] while ($ordered.Count -lt $apps.Count) { $progressed = $false foreach ($a in $apps) { if ($done.ContainsKey($a.Id)) { continue } $unmet = @($a.DepIds | Where-Object { $inSet.ContainsKey($_) -and -not $done.ContainsKey($_) }) if ($unmet.Count -eq 0) { [void]$ordered.Add($a); $done[$a.Id] = $true; $progressed = $true } } # A dependency cycle leaves apps unemitted; append them as-is so the publish still proceeds. if (-not $progressed) { foreach ($a in $apps) { if (-not $done.ContainsKey($a.Id)) { [void]$ordered.Add($a); $done[$a.Id] = $true } } break } } # Resolve the NAV management module so each timeout-bounded child job can import it (the same # module the container session already uses). $navMgmt = @(Get-ChildItem 'C:\Program Files\Microsoft Dynamics NAV\*\Service\Management\Microsoft.Dynamics.Nav.Management.dll' -ErrorAction SilentlyContinue) if (-not $navMgmt) { $navMgmt = @(Get-ChildItem 'C:\Program Files\Microsoft Dynamics NAV' -Recurse -Filter 'Microsoft.Dynamics.Nav.Management.dll' -ErrorAction SilentlyContinue) } $navMgmtPath = if ($navMgmt) { $navMgmt[0].FullName } else { '' } # Publish/sync/install each app, bounding every app in a timeout-limited child job so a hung # operation (e.g. a deadlocked schema sync) is abandoned and reported instead of freezing the # build. Do not abort on the first failure: record each, skip any app whose in-set dependency # failed (it cannot be satisfied), and report every failing app at the end. $failed = @{} # id -> "Name Version - reason" $names = @{} # id -> "Name Version" (for naming dependents) foreach ($a in $ordered) { $names[$a.Id] = "$($a.Name) $($a.Version)" if ($installed.ContainsKey($a.Id)) { Write-Output "Skipped (already installed) $($a.Name) $($a.Version)"; continue } # A symbols-only package compiles but has no installable payload - fail it clearly instead # of letting the server's recompile stall on the missing app. if (-not $a.HasPayload) { [System.Console]::Out.WriteLine("Installing '$($a.Name) $($a.Version)' SYMBOLS-ONLY"); [System.Console]::Out.Flush() $failed[$a.Id] = "$($a.Name) $($a.Version) - symbols-only package (compile-only): the .app has no application payload, so it cannot be installed. Provide the real, installable .app (the build/runtime artifact), not the symbol package." continue } $blockedBy = @($a.DepIds | Where-Object { $failed.ContainsKey($_) }) if ($blockedBy.Count -gt 0) { $depNames = @($blockedBy | ForEach-Object { if ($names[$_]) { $names[$_] } else { $_ } }) -join ', ' $failed[$a.Id] = "$($a.Name) $($a.Version) - dependency not installed: $depNames" Write-Output "FAILED $($a.Name) $($a.Version): dependency not installed ($depNames)" continue } # Default (additive) sync mode - fresh installs, and -ForceSync can hang on destructive # schema changes. # Suppress and CAPTURE warnings inside the job (a child job's warnings are written straight # to the host in Windows PowerShell, so the parent cannot redirect them); re-emit them as # 'ALBUILD-WARN:' data lines the parent prints after 'OK'. $job = Start-Job -ScriptBlock { param($module, $si, $path, $name, $ver, $skip) if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop } $wv = @() Publish-NAVApp -ServerInstance $si -Path $path -SkipVerification:$skip -Scope Global -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue Sync-NAVApp -ServerInstance $si -Name $name -Version $ver -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue Install-NAVApp -ServerInstance $si -Name $name -Version $ver -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue foreach ($x in $wv) { "ALBUILD-WARN:$x" } } -ArgumentList $navMgmtPath, $ServerInstance, $a.File, $a.Name, $a.Version, ([bool]$SkipVerification) # Live progress on one growing line: "Installing 'X 1.0' . . . OK" - a dot per poll, # flushed so it streams through the container/pipeline. Bounded by the timeout so a hung # operation cannot freeze the build. $stepStart = Get-Date [System.Console]::Out.Write("Installing '$($a.Name) $($a.Version)' "); [System.Console]::Out.Flush() $elapsed = 0 $timedOut = $false while (-not (Wait-Job $job -Timeout $PollSeconds)) { $elapsed += $PollSeconds if ($elapsed -ge $OperationTimeoutSeconds) { $timedOut = $true; break } [System.Console]::Out.Write('. '); [System.Console]::Out.Flush() } if ($timedOut) { Stop-Job $job -ErrorAction SilentlyContinue Remove-Job $job -Force -ErrorAction SilentlyContinue [System.Console]::Out.WriteLine("TIMED OUT after $OperationTimeoutSeconds s"); [System.Console]::Out.Flush() # A stall is usually a failed server-side operation (e.g. an extension recompile) whose # real error only lands in the NAV server event log - the cmdlet just never returns. # Surface that log entry so the reason is visible instead of a bare "timed out". $serverErr = '' try { $evt = Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = "MicrosoftDynamicsNavServer`$$ServerInstance"; Level = 2; StartTime = $stepStart } -MaxEvents 1 -ErrorAction SilentlyContinue if ($evt) { $serverErr = if ($evt.Message -match '(?s)\bMessage\b\s*(.+)$') { $matches[1] } else { $evt.Message } $serverErr = ($serverErr -replace '\x1b\[[0-9;]*m', '' -replace '\s+', ' ').Trim() } } catch { $serverErr = '' } # best-effort: never let log-reading mask the timeout if ($serverErr) { [System.Console]::Out.WriteLine("NAV server log: $serverErr"); [System.Console]::Out.Flush() } $reason = if ($serverErr) { "timed out after $OperationTimeoutSeconds s. NAV server log: $serverErr" } else { "timed out after $OperationTimeoutSeconds s (operation hung; likely an incompatible schema/sync)" } $failed[$a.Id] = "$($a.Name) $($a.Version) - $reason" continue } try { # The job emits warnings as 'ALBUILD-WARN:' data lines; print 'OK' on the install line, # then each warning on its own line after it. $results = Receive-Job $job -ErrorAction Stop $jobWarn = @($results | Where-Object { "$_" -like 'ALBUILD-WARN:*' } | ForEach-Object { "$_" -replace '^ALBUILD-WARN:', '' }) [System.Console]::Out.WriteLine('OK'); [System.Console]::Out.Flush() foreach ($w in $jobWarn) { [System.Console]::Out.WriteLine(" WARNING: $(($w -replace '\s+', ' ').Trim())"); [System.Console]::Out.Flush() } } catch { # Keep the COMPLETE error (only ANSI-stripped) for the live log so nothing is lost, and # derive a short categorised reason for the final summary. Keep the first AL error code # and the platform-version mismatch when present. $full = ("$($_.Exception.Message)" -replace '\x1b\[[0-9;]*m', '').TrimEnd() $raw = ($full -replace '\s+', ' ').Trim() $alErr = if ($raw -match '(error AL\d{3,}:[^|+~]{0,160})') { $matches[1].Trim() } else { '' } $plat = if ($raw -match 'Package:\s*([\d.]+),\s*Server:\s*([\d.]+)') { "built for BC platform $($matches[1]), container is $($matches[2])" } else { '' } $reason = '' if ($raw -match 'compilation failed') { $reason = "extension compilation failed on publish$(if ($plat) { " ($plat)" })$(if ($alErr) { "; first error: $alErr" })" } elseif ($raw -match 'different version than the current server') { $reason = "the package was built for a different BC platform build than this container$(if ($plat) { " ($plat)" })" } elseif ($raw -match 'synchroniz') { $reason = 'schema synchronization failed' } else { $reason = ($raw -replace '^(Publish|Sync|Install)-NAVApp\s*:\s*', '').Trim() if ($reason.Length -gt 200) { $reason = $reason.Substring(0, 200) + ' ...' } } if (-not $reason) { $reason = 'publish/install failed' } [System.Console]::Out.WriteLine("FAILED - $reason"); [System.Console]::Out.Flush() # The complete error, so support can see everything (not just the categorised reason). [System.Console]::Out.WriteLine($full); [System.Console]::Out.Flush() $failed[$a.Id] = "$($a.Name) $($a.Version) - $reason" } finally { Remove-Job $job -Force -ErrorAction SilentlyContinue } } if ($failed.Count -gt 0) { # Emit a clean failure summary (no PowerShell error-record scaffolding) and exit non-zero. # The complete per-app errors were already shown live above. [System.Console]::Out.WriteLine("ALBUILD-ERROR:$($failed.Count) dependency app(s) could not be installed (the ISV may not yet ship a build compatible with this BC version):") foreach ($v in @($failed.Values | Sort-Object)) { [System.Console]::Out.WriteLine("ALBUILD-ERROR: - $v") } [System.Console]::Out.Flush() exit 1 } } # Per-app progress already streamed live; log a concise host-side completion line. Write-ALbuildLog -Level Success "Dependencies installed in '$Name'." } |