Modules/businessdev.ALbuild.Apps/Public/Publish-BcContainerApp.ps1
|
function Publish-BcContainerApp { <# .SYNOPSIS Publishes an AL app (.app) to a Business Central container, optionally syncing/installing. .DESCRIPTION Copies the .app into the container and publishes it with the BC server management cmdlets, then optionally synchronises and installs it. Requires Windows + Docker. .PARAMETER Name Container name. .PARAMETER AppFile Path to the .app file on the host. .PARAMETER SkipVerification Publish without signature verification (for unsigned development apps). .PARAMETER Sync Synchronise the app after publishing. .PARAMETER Install Install the app after synchronising. .PARAMETER SyncMode Schema sync mode: Add (default), Clean, Development or ForceSync. .PARAMETER Scope Global (default) or Tenant. .PARAMETER ServerInstance BC server instance. Default 'BC'. .PARAMETER Tenant Tenant. Default 'default'. .PARAMETER DockerExecutable The Docker executable to use (default 'docker'). .PARAMETER OperationTimeoutSeconds Maximum seconds to allow the publish/sync/install before abandoning it and throwing. Prevents a hung operation (e.g. a deadlocked schema sync) from freezing the build. Default 600. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '', Justification = 'The Start-Job scriptblock declares a param() block bound positionally via -ArgumentList; $using: is intentionally not used.')] [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [Alias('ContainerName')] [string] $Name, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AppFile, [switch] $SkipVerification, [switch] $Sync, [switch] $Install, [ValidateSet('Add', 'Clean', 'Development', 'ForceSync')] [string] $SyncMode = 'Add', [ValidateSet('Global', 'Tenant')] [string] $Scope = 'Global', [string] $ServerInstance = 'BC', [string] $Tenant = 'default', [int] $OperationTimeoutSeconds = 600, [string] $DockerExecutable = 'docker', # Publish via the container's development endpoint (like VS Code / BcContainerHelper -useDevEndpoint): # the app is published as a replaceable development extension - re-publishable without a version bump, # which is what test environments need. Requires -Credential (a BC user for the dev endpoint). [switch] $Development, [pscredential] $Credential ) if (-not (Test-Path -LiteralPath $AppFile)) { throw "App file not found: '$AppFile'." } if (-not $PSCmdlet.ShouldProcess($Name, "Publish $(Split-Path $AppFile -Leaf)")) { return } $containerPath = Copy-BcFileToContainer -Name $Name -Source $AppFile -DockerExecutable $DockerExecutable if ($Development) { # Development (dev-endpoint) publish. The dev endpoint publishes + syncs + installs in one call and # marks the app as a development extension, so we do NOT run the Publish/Sync/Install-NAVApp flow. if (-not $Credential) { throw 'A development (dev-endpoint) publish requires -Credential (the BC user to authenticate to the dev endpoint).' } # The dev endpoint's schema-update modes are synchronize|recreate|forcesync; map from -SyncMode. $schemaUpdateMode = switch ($SyncMode) { 'ForceSync' { 'forcesync' } 'Clean' { 'recreate' } default { 'synchronize' } } $devScript = { # Resolve the developer-services endpoint from the server's CustomSettings.config (authoritative, # regardless of who created the container), then POST the .app to it (like V1 / VS Code publish). $cfgFile = Get-ChildItem -Path 'C:\Program Files\Microsoft Dynamics*\*\Service\CustomSettings.config' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $cfgFile) { [System.Console]::Out.WriteLine("ALBUILD-ERROR:Could not locate CustomSettings.config in the container to resolve the dev endpoint."); exit 1 } [xml]$cfg = Get-Content -LiteralPath $cfgFile.FullName $getSetting = { param($key) $node = $cfg.SelectSingleNode("//appSettings/add[@key='$key']"); if ($node) { $node.value } else { $null } } $port = & $getSetting 'DeveloperServicesPort'; if (-not $port) { $port = '7049' } $ssl = (& $getSetting 'DeveloperServicesSSLEnabled') -eq 'true' $proto = if ($ssl) { 'https' } else { 'http' } $url = "$proto`://localhost:$port/$ServerInstance/dev/apps?SchemaUpdateMode=$SchemaMode" if ($ssl) { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } } Add-Type -AssemblyName System.Net.Http $client = [System.Net.Http.HttpClient]::new() $pair = "$($DevUser):$($DevPassword)" $b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair)) $client.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Basic', $b64) $client.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan $client.DefaultRequestHeaders.ExpectContinue = $false $appName = Split-Path -Path $Path -Leaf $content = [System.Net.Http.MultipartFormDataContent]::new() $fs = [System.IO.FileStream]::new($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read) try { $disp = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new('form-data') $disp.Name = $appName; $disp.FileName = $appName; $disp.FileNameStar = $appName $sc = [System.Net.Http.StreamContent]::new($fs); $sc.Headers.ContentDisposition = $disp $content.Add($sc) [System.Console]::Out.WriteLine("Dev-publishing '$appName' to $url ..."); [System.Console]::Out.Flush() $resp = $client.PostAsync($url, $content).GetAwaiter().GetResult() if (-not $resp.IsSuccessStatusCode) { $detail = '' try { $detail = $resp.Content.ReadAsStringAsync().GetAwaiter().GetResult() } catch { $detail = '' } try { $j = $detail | ConvertFrom-Json; if ($j.Message) { $detail = $j.Message } } catch { $null = $_ } [System.Console]::Out.WriteLine("ALBUILD-ERROR:Dev publish of '$appName' failed: $([int]$resp.StatusCode) $($resp.ReasonPhrase). $detail"); [System.Console]::Out.Flush() exit 1 } [System.Console]::Out.WriteLine("Dev-published '$appName' successfully."); [System.Console]::Out.Flush() } finally { $fs.Close() } } $null = Invoke-BcContainerCommand -ContainerName $Name -ScriptBlock $devScript -DockerExecutable $DockerExecutable -StreamOutput -Variables @{ Path = $containerPath; ServerInstance = $ServerInstance; SchemaMode = $schemaUpdateMode DevUser = $Credential.UserName; DevPassword = $Credential.GetNetworkCredential().Password } Write-ALbuildLog -Level Success "Dev-published '$(Split-Path -Path $AppFile -Leaf)' to the development endpoint of '$Name'." return } $script = { $info = Get-NAVAppInfo -Path $Path # Each timeout-bounded child job is a fresh runspace that does not inherit the imported NAV cmdlets, # so it re-imports the exact assembly the session already loaded (resolved centrally, per BC version # and PowerShell edition, in ConvertTo-BcEncodedCommand). Reusing that loaded path keeps the version # logic in one place and works whether the session runs under Windows PowerShell or pwsh. $navMgmtPath = (Get-Command Publish-NAVApp -ErrorAction SilentlyContinue).Module.Path # Show a single operation's job as a growing-dots line, then 'OK' / 'FAILED <complete error>' # / 'TIMED OUT', so EACH step is shown separately and it is obvious which one hangs. The # caller builds the job with explicit scalar arguments (a robust Start-Job pattern). function Wait-BcStepJob { param([System.Management.Automation.Job] $Job, [string] $Label) $stepStart = Get-Date [System.Console]::Out.Write("$Label "); [System.Console]::Out.Flush() $elapsed = 0 while (-not (Wait-Job $Job -Timeout $PollSeconds)) { $elapsed += $PollSeconds if ($elapsed -ge $OperationTimeoutSeconds) { 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 stalled publish/sync is usually a *server-side* operation that failed (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. $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() return [PSCustomObject]@{ Ok = $false; Error = "$Label timed out after $OperationTimeoutSeconds s. NAV server log: $serverErr" } } return [PSCustomObject]@{ Ok = $false; Error = "$Label timed out after $OperationTimeoutSeconds s (operation hung)" } } [System.Console]::Out.Write('. '); [System.Console]::Out.Flush() } try { # The job emits warnings as 'ALBUILD-WARN:' data lines (a child job's warnings are # written straight to the host, so they cannot be redirected from the parent). Print # 'OK' on the step line, then each warning on its own line; the rest is real output. $results = Receive-Job $Job -ErrorAction Stop $jobWarn = @($results | Where-Object { "$_" -like 'ALBUILD-WARN:*' } | ForEach-Object { "$_" -replace '^ALBUILD-WARN:', '' }) $out = @($results | Where-Object { "$_" -notlike '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() } $txt = if ($out) { ($out | Out-String).Trim() } else { '' } if ($txt) { [System.Console]::Out.WriteLine($txt); [System.Console]::Out.Flush() } return [PSCustomObject]@{ Ok = $true; Error = '' } } catch { [System.Console]::Out.WriteLine('FAILED'); [System.Console]::Out.Flush() # Show the COMPLETE error (full compiler/server output), only ANSI-stripped. $full = ("$($_.Exception.Message)" -replace '\x1b\[[0-9;]*m', '').TrimEnd() [System.Console]::Out.WriteLine($full); [System.Console]::Out.Flush() $first = @(($full -split "`r?`n") | Where-Object { $_.Trim() }) $first = if ($first.Count -gt 0) { $first[0].Trim() } else { 'failed' } return [PSCustomObject]@{ Ok = $false; Error = "$Label failed: $first" } } finally { Remove-Job $Job -Force -ErrorAction SilentlyContinue } } $app = "'$($info.Name) $($info.Version)'" $job = Start-Job -ScriptBlock { param($module, $si, $path, $scope, $skip) if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop } $wv = @() Publish-NAVApp -ServerInstance $si -Path $path -Scope $scope -SkipVerification:$skip -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue foreach ($x in $wv) { "ALBUILD-WARN:$x" } } -ArgumentList $navMgmtPath, $ServerInstance, $Path, $Scope, ([bool]$SkipVerification) $r = Wait-BcStepJob -Job $job -Label "Publishing $app" if (-not $r.Ok) { [System.Console]::Out.WriteLine("ALBUILD-ERROR:$($r.Error)"); [System.Console]::Out.Flush(); exit 1 } if ($DoSync) { $job = Start-Job -ScriptBlock { param($module, $si, $name, $ver, $mode, $tenant) if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop } $wv = @() Sync-NAVApp -ServerInstance $si -Name $name -Version $ver -Mode $mode -Tenant $tenant -Force -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue foreach ($x in $wv) { "ALBUILD-WARN:$x" } } -ArgumentList $navMgmtPath, $ServerInstance, $info.Name, $info.Version, $Mode, $Tenant $r = Wait-BcStepJob -Job $job -Label "Syncing $app" if (-not $r.Ok) { [System.Console]::Out.WriteLine("ALBUILD-ERROR:$($r.Error)"); [System.Console]::Out.Flush(); exit 1 } } if ($DoInstall) { # Install vs upgrade: a plain Install-NAVApp over an already-installed DIFFERENT version fails # ("tenant ... already uses a different version ... with the same app ID"). So if any OTHER # version of this app is already present on the tenant, the freshly published one is an # upgrade: run the data upgrade, then unpublish the superseded version(s). Otherwise it is a # first-time install. Mirrors Publish-BcOnPremApp (the directly-installed-server path). $prior = @(Get-NAVAppInfo -ServerInstance $ServerInstance -Name $info.Name -Publisher $info.Publisher -Tenant $Tenant -TenantSpecificProperties | Where-Object { "$($_.AppId)" -eq "$($info.AppId)" -and "$($_.Version)" -ne "$($info.Version)" }) if ($prior.Count -eq 0) { [System.Console]::Out.WriteLine("$($info.Name) $($info.Version): no other version present - first-time install.") $job = Start-Job -ScriptBlock { param($module, $si, $name, $ver, $tenant) if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop } $wv = @() Install-NAVApp -ServerInstance $si -Name $name -Version $ver -Tenant $tenant -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue foreach ($x in $wv) { "ALBUILD-WARN:$x" } } -ArgumentList $navMgmtPath, $ServerInstance, $info.Name, $info.Version, $Tenant $r = Wait-BcStepJob -Job $job -Label "Installing $app" if (-not $r.Ok) { [System.Console]::Out.WriteLine("ALBUILD-ERROR:$($r.Error)"); [System.Console]::Out.Flush(); exit 1 } } else { [System.Console]::Out.WriteLine("$($info.Name) $($info.Version): upgrading from $(@($prior | ForEach-Object { $_.Version }) -join ', ').") $job = Start-Job -ScriptBlock { param($module, $si, $name, $ver, $tenant) if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop } $wv = @() Start-NAVAppDataUpgrade -ServerInstance $si -Name $name -Version $ver -Tenant $tenant -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue foreach ($x in $wv) { "ALBUILD-WARN:$x" } } -ArgumentList $navMgmtPath, $ServerInstance, $info.Name, $info.Version, $Tenant $r = Wait-BcStepJob -Job $job -Label "Upgrading to $app" if (-not $r.Ok) { [System.Console]::Out.WriteLine("ALBUILD-ERROR:$($r.Error)"); [System.Console]::Out.Flush(); exit 1 } foreach ($old in $prior) { $job = Start-Job -ScriptBlock { param($module, $si, $name, $publisher, $ver) if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop } Unpublish-NAVApp -ServerInstance $si -Name $name -Publisher $publisher -Version $ver -ErrorAction Stop } -ArgumentList $navMgmtPath, $ServerInstance, $info.Name, $info.Publisher, $old.Version $r = Wait-BcStepJob -Job $job -Label "Unpublishing superseded $($info.Name) $($old.Version)" # Best-effort cleanup: the upgrade already succeeded, so a failed unpublish of an old # version must NOT fail the release. Warn and carry on (do not exit). if (-not $r.Ok) { [System.Console]::Out.WriteLine("WARNING: Could not unpublish superseded $($info.Name) $($old.Version): $($r.Error). Continuing."); [System.Console]::Out.Flush() } } } } [System.Console]::Out.WriteLine("Published $($info.Name) $($info.Version)") } # Stream the in-container output so the progress line is visible live; the full output is still # returned. Log a concise host-side summary (the per-app line already streamed). $null = Invoke-BcContainerCommand -ContainerName $Name -ScriptBlock $script -DockerExecutable $DockerExecutable -StreamOutput -Variables @{ Path = $containerPath; ServerInstance = $ServerInstance; Scope = $Scope SkipVerification = [bool]$SkipVerification; DoSync = [bool]$Sync; DoInstall = [bool]$Install; Mode = $SyncMode; Tenant = $Tenant OperationTimeoutSeconds = [int]$OperationTimeoutSeconds; PollSeconds = 10 } Write-ALbuildLog -Level Success "Published & installed '$(Split-Path -Path $AppFile -Leaf)' into '$Name'." } |