Modules/businessdev.ALbuild.OnPrem/Private/Import-BcManagementShell.ps1
|
function Import-BcManagementShell { <# .SYNOPSIS Loads the Business Central administration shell (Publish-NAVApp / Sync-NAVApp / Install-NAVApp / Get-NAVAppInfo / ...) into the current session. .DESCRIPTION The on-prem cmdlets are provided by the BC server's management assemblies, which are NOT on PSModulePath and are therefore not auto-loaded. Without this, an on-prem publish fails with "The term 'Publish-NAVApp' is not recognized". Mirrors the AL.build V1 ReleaseOnPrem behaviour: if the shell is already present it is reused; otherwise the management assemblies are located under the BC install tree and imported (modern 'Microsoft.BusinessCentral.*' first, then the legacy 'Microsoft.Dynamics.Nav.*' names for older versions). BC29 removed the Windows PowerShell 5 compatibility assembly (...\Service\Management\Microsoft.Dynamics.Nav.Management.dll), leaving only the .NET 8 modules under ...\Service\Admin, which Windows PowerShell 5.1 cannot load - and Azure DevOps runs task.ps1 under 5.1. When that is the situation, this function falls back to installing proxy functions that forward each cmdlet to a PowerShell 7 child process (see Invoke-BcManagementProxyCommand), so the on-prem publish/sync/install/upgrade flow keeps working unchanged. That fallback needs PowerShell 7 on the agent; if it is missing we say so explicitly rather than failing later with an unrecognised-cmdlet error. Progress is logged in detail (every search root, every assembly imported and from where) so a failure on a customer agent is diagnosable from the pipeline log alone. .PARAMETER SentinelCommand The command whose presence proves the shell is loaded. Default 'Publish-NAVApp' (the cmdlet the on-prem publish actually needs). .PARAMETER SearchRoot Glob root(s) under which to search for the management assemblies. .OUTPUTS None. Throws a clear, actionable error listing the searched locations if the shell cannot be loaded. #> [CmdletBinding()] param( [string] $SentinelCommand = 'Publish-NAVApp', [string[]] $SearchRoot = @( 'C:\Program Files\Microsoft Dynamics*\*\Service\', 'C:\Program Files (x86)\Microsoft Dynamics*\*\Service\' ) ) # A resolved mode is sticky for the session: re-running the assembly hunt (and the pwsh smoke test) # on every one of the ~9 management calls a publish makes would be pure waste. if ((Test-Path 'variable:script:BcManagementMode') -and $script:BcManagementMode -eq 'Pwsh') { Write-ALbuildLog -Level Verbose "BC management shell resolved earlier to the PowerShell 7 delegation mode; reusing it." return } if (Get-Command -Name $SentinelCommand -ErrorAction SilentlyContinue) { Write-ALbuildLog "BC management shell already loaded ('$SentinelCommand' available); reusing it." $script:BcManagementMode = 'InProcess' return } Write-ALbuildLog "BC management shell not loaded ('$SentinelCommand' missing); locating the management assemblies..." # Modern (BC15+) assemblies first, then the legacy NAV names for older on-prem versions. Each set # provides the server (…Management) + app (…Apps.Management) cmdlets we call. $moduleSets = @( @('Microsoft.BusinessCentral.Management.dll', 'Microsoft.BusinessCentral.Apps.Management.dll'), @('Microsoft.Dynamics.Nav.Management.dll', 'Microsoft.Dynamics.Nav.Apps.Management.dll') ) # A per-assembly import failure is EXPECTED and harmless: on a modern BC server under Windows # PowerShell 5.1 the modern Microsoft.BusinessCentral.* assemblies target .NET 8 and fail to load # ("System.Runtime, Version=8.0.0.0 ... not found"), then the legacy Microsoft.Dynamics.Nav.* set # loads fine. So the search is quiet (Verbose): attempts and failures are recorded, not warned, and # surfaced only in the throw if NOTHING yields the sentinel cmdlet. Only that final state is an error. $searched = [System.Collections.Generic.List[string]]::new() $loadErrors = [System.Collections.Generic.List[string]]::new() # Every modern (.NET 8) module file we located, whether or not it loaded here. On BC29 under Windows # PowerShell none of them load, and these are exactly the files the pwsh fallback must import. $modernFiles = [System.Collections.Generic.List[string]]::new() $importedAny = $false foreach ($set in $moduleSets) { $isModernSet = $set -contains 'Microsoft.BusinessCentral.Management.dll' foreach ($dll in $set) { foreach ($root in $SearchRoot) { # String-concatenate (roots end in a separator) rather than Join-Path: Join-Path # resolves the 'C:' qualifier and throws "drive 'C' does not exist" on non-Windows # agents (the module is loaded/tested on the Linux CI leg, though it only runs for real # on a Windows BC server). $searched.Add($root + $dll) $found = @() # NOTE: do NOT add -File here. With a wildcard '...\*\Service\' path + -Recurse, -File makes # Get-ChildItem return nothing (PowerShell quirk), so the assemblies are never found - which # is exactly why V1/the reference installer omit it. Filter out directories in the loop instead. try { $found = @(Get-ChildItem -Path $root -Filter $dll -Recurse -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer }) } catch { $found = @() } # a Windows-only root throws 'drive not found' off Windows; treat as no match foreach ($f in $found) { Write-ALbuildLog -Level Verbose " trying '$($f.Name)' from '$($f.Directory.FullName)'." if ($isModernSet) { # Prefer the .psd1 manifest for the pwsh fallback: it declares RequiredAssemblies # and the format files, which importing the bare .dll skips. # Swap the extension by string, NOT Join-Path: as noted above, Join-Path resolves # the 'C:' qualifier of these Windows paths and throws "drive 'C' does not exist" # on a non-Windows agent (the module is loaded and unit-tested on the Linux CI leg, # though it only runs for real on a Windows BC server). Test-Path is guarded for # the same reason. $manifest = $f.FullName -replace '\.dll$', '.psd1' $hasManifest = $false try { $hasManifest = Test-Path -LiteralPath $manifest -ErrorAction SilentlyContinue } catch { $hasManifest = $false } $modernFiles.Add($(if ($hasManifest) { $manifest } else { $f.FullName })) } try { Import-Module -Name $f.FullName -Force -Global -DisableNameChecking -ErrorAction Stop $importedAny = $true } catch { # Expected for the modern set on PS 5.1; recorded (not warned) in case the whole load fails. $loadErrors.Add("$($f.FullName): $($_.Exception.Message)") Write-ALbuildLog -Level Verbose " '$($f.Name)' did not load: $($_.Exception.Message)" } } } } # Stop at the first assembly set that yields the cmdlet we need. if (Get-Command -Name $SentinelCommand -ErrorAction SilentlyContinue) { break } } if (Get-Command -Name $SentinelCommand -ErrorAction SilentlyContinue) { $script:BcManagementMode = 'InProcess' Write-ALbuildLog -Level Success "BC management shell loaded ('$SentinelCommand' now available)." return } # Nothing loaded in-process, but the server ships the modern .NET 8 modules: this is the BC29 # situation (they need PowerShell 7 and we are almost certainly on Windows PowerShell 5.1, which is # what Azure DevOps runs task.ps1 under). Delegate every call to pwsh instead of failing. We do not # gate this on the edition: if we somehow already run on Core and the import still failed, a fresh # pwsh process is worth trying, and its real import error is a far better diagnostic than the # generic "could not load the management shell" below. if ($modernFiles.Count -gt 0) { $modules = @($modernFiles | Select-Object -Unique) Write-ALbuildLog -Level Information ("Only the .NET 8 management modules are present ({0}); they did not load in this session (PowerShell {1}, {2} edition) - this is a BC29+ server. Falling back to delegating management calls to PowerShell 7." -f (($modules | Split-Path -Leaf) -join ', '), $PSVersionTable.PSVersion, $PSVersionTable.PSEdition) $pwshPath = (Get-Command -Name 'pwsh' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source) if (-not $pwshPath) { foreach ($candidate in @("$env:ProgramFiles\PowerShell\7\pwsh.exe", "${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe")) { if (Test-Path -LiteralPath $candidate) { $pwshPath = $candidate; break } } } if (-not $pwshPath) { throw @" This Business Central server ships only the .NET 8 management modules (BC29 removed the Windows PowerShell 5 compatibility module Microsoft.Dynamics.Nav.Management.dll), and PowerShell 7 was not found on this agent - so '$SentinelCommand' cannot be reached at all. Install PowerShell 7 on the agent (winget install --id Microsoft.PowerShell, or https://aka.ms/powershell), then re-run. Modules found: $($modules -join '; ') "@ } # Smoke-test the delegation before declaring success, so a broken fallback fails here - with the # real import error - instead of on the first publish. SilentlyContinue so a *missing cmdlet* # falls through to the explicit message below; an import failure still throws (proxy exit 3). $probe = Invoke-BcManagementProxyCommand -Command 'Get-Command' -PwshPath $pwshPath -ModuleFile $modules ` -Parameters @{ Name = $SentinelCommand; ErrorAction = 'SilentlyContinue' } if (-not $probe) { throw "PowerShell 7 ('$pwshPath') loaded the .NET 8 management modules but still does not expose '$SentinelCommand'. Modules: $($modules -join '; ')." } $script:BcManagementMode = 'Pwsh' $script:BcManagementModuleFile = $modules $script:BcManagementPwsh = $pwshPath Write-ALbuildLog -Level Success "BC management shell reachable via PowerShell 7 ('$pwshPath'); '$SentinelCommand' resolved there." return } # Neither an in-process import nor the pwsh fallback got us there. $hint = if ($importedAny) { "Assemblies were found but none exposed '$SentinelCommand' - the installed BC version may use different module names, or every candidate failed to load." } else { 'No BC management assemblies were found under the search roots - the BC server (or its management tools) does not appear to be installed on this agent.' } $detail = "Could not load the Business Central management shell. $hint" $detail += [Environment]::NewLine + "Searched: $(($searched | Select-Object -Unique) -join '; ')" if ($loadErrors.Count -gt 0) { $detail += [Environment]::NewLine + "Load errors: " + ($loadErrors -join ' | ') } throw $detail } |