Modules/businessdev.ALbuild.OnPrem/Private/Invoke-BcManagementProxyCommand.ps1
|
function Invoke-BcManagementProxyCommand { <# .SYNOPSIS Runs one Business Central management cmdlet in a PowerShell 7 child process and returns its output to the calling (Windows PowerShell 5.1) session. .DESCRIPTION From BC29 the management cmdlets exist only as .NET 8 assemblies (Microsoft.BusinessCentral.Management / .Apps.Management under ...\Service\Admin). Windows PowerShell 5.1 cannot load a .NET 8 assembly, and BC29 removed the Windows PowerShell 5 compatibility module Microsoft.Dynamics.Nav.Management.dll that used to bridge that gap. Microsoft ships no replacement bridge: their own NavAdminTool.ps1 just prints "Couldn't load <module>" under 5.1. Azure DevOps runs task.ps1 under Windows PowerShell 5.1, so on-prem releases against a BC29 server must delegate. This helper is that delegation: it serialises the bound parameters, launches 'pwsh' with a generated script that imports the modern modules and splats the parameters into the real cmdlet, and deserialises the result back. Import-BcManagementShell installs thin proxy functions (Publish-NAVApp, Sync-NAVApp, ...) that call in here, so the public on-prem functions are unchanged and keep working in-process wherever the cmdlets load natively (BC <= 28 under 5.1, any version under pwsh). Objects come back as deserialised PSObjects. Property access is unaffected, and the callers already coerce identity values (e.g. [guid]"$($info.AppId)") because the legacy compatibility module produced the same deserialised shapes. .PARAMETER Command The management cmdlet to invoke, e.g. 'Publish-NAVApp'. .PARAMETER Parameters The parameters to splat into it. Common parameters other than ErrorAction are dropped; ErrorAction is forwarded so the cmdlet keeps its usual behaviour in the child. .PARAMETER ModuleFile Full paths of the management modules (.psd1/.dll) the child must import before invoking. .PARAMETER PwshPath Full path to pwsh.exe. .OUTPUTS Whatever the cmdlet returned (deserialised), or nothing. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Command, [hashtable] $Parameters = @{}, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $ModuleFile, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $PwshPath ) # Common parameters would either be meaningless in the child (-ErrorVariable writes into a variable # that dies with the process) or break splatting (-Verbose as a [switch] is fine, but the *Variable / # *Buffer ones are not). ErrorAction is the one we must keep: the on-prem publish relies on # '-ErrorAction SilentlyContinue' returning nothing instead of throwing when the version already exists. $dropped = @( 'Verbose', 'Debug', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'ProgressAction', 'WhatIf', 'Confirm' ) $effective = @{} foreach ($key in $Parameters.Keys) { if ($dropped -contains $key) { continue } $effective[$key] = $Parameters[$key] } $callerErrorAction = "$($Parameters['ErrorAction'])" $tolerated = @('SilentlyContinue', 'Ignore') -contains $callerErrorAction $stem = Join-Path ([System.IO.Path]::GetTempPath()) "albuild-mgmt-$([guid]::NewGuid().ToString('N'))" $paramFile = "$stem.params.xml" $outFile = "$stem.out.xml" $scriptFile = "$stem.ps1" $stdOutFile = "$stem.stdout.txt" $stdErrFile = "$stem.stderr.txt" try { # CLIXML rather than JSON: it round-trips the parameter types the cmdlets expect (Guid, Version, # bool, string[]) without us hand-rolling conversions. Export-Clixml -InputObject $effective -LiteralPath $paramFile -Depth 5 | Out-Null # Everything interpolated into the child script lands inside a single-quoted PowerShell literal, # so double any apostrophe (a temp path under a profile like 'O'Brien' would otherwise break it). $q = { param([string] $Text) $Text -replace "'", "''" } $imports = ($ModuleFile | ForEach-Object { "Import-Module -Name '$(& $q $_)' -DisableNameChecking -ErrorAction Stop" }) -join [Environment]::NewLine $childScript = @" `$ErrorActionPreference = 'Stop' try { $imports } catch { [Console]::Error.WriteLine('ALBUILD-PROXY-IMPORT: ' + `$_.Exception.Message) exit 3 } `$__p = Import-Clixml -LiteralPath '$(& $q $paramFile)' try { `$__r = & '$(& $q $Command)' @__p if (`$null -ne `$__r) { `$__r | Export-Clixml -LiteralPath '$(& $q $outFile)' -Depth 5 } exit 0 } catch { [Console]::Error.WriteLine('ALBUILD-PROXY-ERROR: ' + `$_.Exception.Message) exit 2 } "@ # UTF-8 with BOM so pwsh reads non-ASCII (app names, publisher) correctly. [System.IO.File]::WriteAllText($scriptFile, $childScript, [System.Text.UTF8Encoding]::new($true)) Write-ALbuildLog -Level Verbose "Delegating '$Command' to PowerShell 7 ('$PwshPath')." $process = Start-Process -FilePath $PwshPath -ArgumentList @( '-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptFile ) -Wait -PassThru -NoNewWindow -RedirectStandardOutput $stdOutFile -RedirectStandardError $stdErrFile $stdOut = if (Test-Path -LiteralPath $stdOutFile) { (Get-Content -LiteralPath $stdOutFile -Raw) } else { '' } $stdErr = if (Test-Path -LiteralPath $stdErrFile) { (Get-Content -LiteralPath $stdErrFile -Raw) } else { '' } # The cmdlets log progress on stdout (Sync-NAVApp and friends are chatty). Surface it so an # on-prem release stays diagnosable from the pipeline log alone. if (-not [string]::IsNullOrWhiteSpace($stdOut)) { foreach ($line in ($stdOut -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) { Write-ALbuildLog -Level Information " [$Command] $($line.TrimEnd())" } } if ($process.ExitCode -eq 0) { if (Test-Path -LiteralPath $outFile) { return Import-Clixml -LiteralPath $outFile } return } $detail = Format-BcErrorMessage -Text (($stdErr -replace 'ALBUILD-PROXY-(IMPORT|ERROR): ', '').Trim()) if ([string]::IsNullOrWhiteSpace($detail)) { $detail = "pwsh exited with code $($process.ExitCode)." } # Exit 3 = the modules themselves would not load: infrastructure, never silenced. Exit 2 = the # cmdlet failed, so honour the caller's -ErrorAction (SilentlyContinue must return nothing). if ($process.ExitCode -eq 2 -and $tolerated) { Write-ALbuildLog -Level Verbose "'$Command' failed but -ErrorAction $callerErrorAction was requested: $detail" return } throw "$Command failed in the PowerShell 7 management session. $detail" } finally { Remove-Item -LiteralPath $paramFile, $outFile, $scriptFile, $stdOutFile, $stdErrFile -Force -ErrorAction SilentlyContinue } } |