Modules/businessdev.ALbuild.Apps/Public/Get-BcContainerAppDiagnostic.ps1
|
function Get-BcContainerAppDiagnostic { <# .SYNOPSIS Explains why publishing, installing or synchronising an app in a container fails. .DESCRIPTION A failed publish reports a symptom ("an earlier version was already installed", "the extension is not synchronized") but never the state that produced it. From outside the container that state is invisible, so the caller is left guessing which of several very different situations it is in. This collects the evidence in one pass and turns it into named blockers: * every PUBLISHED version of the app (Get-NAVAppInfo on the server instance), and * per version, the TENANT's view (Get-NAVAppInfo -TenantSpecificProperties): IsInstalled, SyncState, NeedsUpgrade and ExtensionDataVersion - the version the tenant's DATA is at, which is what actually decides whether an install or a data upgrade is required, and * the tail of the Business Central server event log, which is where schema-sync failures record the offending table/field. Every blocker carries a stable code (DATA_UPGRADE_REQUIRED, VERSION_ALREADY_PUBLISHED, NOT_SYNCHRONIZED, SCHEMA_CONFLICT) and the command that clears it, so a caller can branch on the cause instead of pattern-matching a PowerShell message that changes between BC versions. Read-only: it publishes, installs, syncs and removes nothing. .PARAMETER Name Container name. .PARAMETER AppName Limit the diagnosis to one app. Omit to diagnose every non-Microsoft app in the container. .PARAMETER ServerInstance BC server instance inside the container. Default 'BC'. .PARAMETER Tenant Tenant to read tenant-specific state from. Default 'default'. .PARAMETER EventLogEntries How many recent server event-log entries to return. Default 20; 0 skips the event log. .PARAMETER DockerExecutable The Docker executable to use (default 'docker'). .OUTPUTS PSCustomObject with Container, Apps, SyncErrors, EventLog and Blockers. .EXAMPLE Get-BcContainerAppDiagnostic -Name albmcp01480a -AppName '365 business Banking' Reports the published versions, the tenant data version and the blockers preventing a publish. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [Alias('ContainerName')] [string] $Name, [string] $AppName, [string] $ServerInstance = 'BC', [string] $Tenant = 'default', [ValidateRange(0, 200)] [int] $EventLogEntries = 20, [string] $DockerExecutable = 'docker' ) $script = { # Runs INSIDE the container under Windows PowerShell. The NAV app cmdlets are imported by # Invoke-BcContainerCommand. Everything here is best-effort and wrapped: a diagnostic that throws # is worse than one that reports a gap, because the caller is already dealing with a failure. $result = [ordered]@{ serverInstance = $ServerInstance tenant = $Tenant apps = @() eventLog = @() errors = @() } # 1. Every PUBLISHED version on the server instance (independent of any tenant). $published = @() try { $p = Get-NAVAppInfo -ServerInstance $ServerInstance -ErrorAction Stop if ($AppName) { $p = $p | Where-Object { $_.Name -eq $AppName } } $published = @($p) } catch { $result.errors += "Get-NAVAppInfo (published): $($_.Exception.Message)" } # 2. The TENANT's view of those apps: installed?, synced?, and which version its DATA is at. # This is the fact that decides install-vs-upgrade, and the one no error message ever states. $tenantInfo = @() try { $t = Get-NAVAppInfo -ServerInstance $ServerInstance -Tenant $Tenant -TenantSpecificProperties -ErrorAction Stop if ($AppName) { $t = $t | Where-Object { $_.Name -eq $AppName } } $tenantInfo = @($t) } catch { $result.errors += "Get-NAVAppInfo (tenant): $($_.Exception.Message)" } # 3. Merge per app id, then per version, so one app is one entry with all its versions. $byApp = @{} foreach ($row in $published) { $id = "$($row.AppId)" if (-not $id) { $id = "$($row.Id)" } if (-not $byApp.ContainsKey($id)) { $byApp[$id] = [ordered]@{ id = $id name = "$($row.Name)" publisher = "$($row.Publisher)" published = @() tenantDataVersion = $null installedVersion = $null } } # The tenant row for this exact version, when there is one. $tv = $tenantInfo | Where-Object { ("$($_.AppId)" -eq $id -or "$($_.Id)" -eq $id) -and "$($_.Version)" -eq "$($row.Version)" } | Select-Object -First 1 $isInstalled = $false $syncState = 'Unknown' $needsUpgrade = $false $dataVersion = $null if ($tv) { $isInstalled = [bool]$tv.IsInstalled if ($null -ne $tv.SyncState) { $syncState = "$($tv.SyncState)" } if ($null -ne $tv.NeedsUpgrade) { $needsUpgrade = [bool]$tv.NeedsUpgrade } if ($tv.ExtensionDataVersion) { $dataVersion = "$($tv.ExtensionDataVersion)" } } $byApp[$id].published += [ordered]@{ version = "$($row.Version)" installed = $isInstalled syncState = $syncState needsUpgrade = $needsUpgrade extensionDataVersion = $dataVersion scope = "$($row.Scope)" } if ($isInstalled) { $byApp[$id].installedVersion = "$($row.Version)" } # The tenant's data version is a property of the APP, not of one published version. if ($dataVersion -and -not $byApp[$id].tenantDataVersion) { $byApp[$id].tenantDataVersion = $dataVersion } } $result.apps = @($byApp.Values | ForEach-Object { [PSCustomObject]$_ }) # 4. Server event log: where a schema-sync failure records the object it choked on. # # BC Server does NOT create a dedicated 'Microsoft-DynamicsNAV-Server' channel on a container - # looking for one finds nothing, which is exactly how an earlier version of these diagnostics # reported "no matching event log" instead of the reason. It writes into the Windows # *Application* log under a provider named after the service instance # ('MicrosoftDynamicsNavServer$<instance>'), so discover the provider from the running service. if ($EventLogEntries -gt 0) { $providers = @() try { $providers = @(Get-Service -Name 'MicrosoftDynamicsNavServer$*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) } catch { $null = $_ } if (-not $providers) { $providers = @("MicrosoftDynamicsNavServer`$$ServerInstance") } $events = @() foreach ($provider in $providers) { # Per-provider loop: an unregistered or empty provider must not blank out the rest. try { $events += @(Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = $provider } ` -MaxEvents ([Math]::Max(25, $EventLogEntries)) -ErrorAction Stop) } catch { $null = $_ } } # A BC Server event message opens with a block of "Key: <guid>" header lines (Server instance, # ClientSessionId, ServerActivityId, ...); the actionable text sits below it. Drop the header so # the real reason surfaces instead of a wall of "Server instance: BC". $headerKeys = '^(Server instance|ClientSessionId|ClientActivityId|ServerSessionUniqueId|ServerActivityId|EventTime|ClientComputerName|ClientAddress|UserName|CounterInformation|ProcessId|Tenant|AadTenantId)\s*:' try { $result.eventLog = @($events | Sort-Object TimeCreated -Descending | Select-Object -First $EventLogEntries | ForEach-Object { $body = @(($_.Message -split "`r?`n") | Where-Object { $_.Trim() -and $_ -notmatch $headerKeys } | Select-Object -First 6) $msg = ($body -join ' | ').Trim() if ($msg.Length -gt 1200) { $msg = $msg.Substring(0, 1200) + '...' } [PSCustomObject]@{ log = 'Application' provider = "$($_.ProviderName)" eventId = [int]$_.Id timeAtUtc = $(if ($_.TimeCreated) { $_.TimeCreated.ToUniversalTime().ToString('o') } else { $null }) level = "$($_.LevelDisplayName)" message = $msg } }) } catch { $result.errors += "Event log: $($_.Exception.Message)" } } [PSCustomObject]$result | ConvertTo-Json -Depth 8 } $json = Invoke-BcContainerCommand -ContainerName $Name -ScriptBlock $script -DockerExecutable $DockerExecutable -Variables @{ ServerInstance = $ServerInstance Tenant = $Tenant AppName = $AppName EventLogEntries = $EventLogEntries } if ([string]::IsNullOrWhiteSpace($json)) { throw "Could not read app state from container '$Name' (no output from Get-NAVAppInfo)." } $raw = $json | ConvertFrom-Json # Derive the blockers OUTSIDE the container, where the container name is known and a remedy can # therefore be a command the caller runs verbatim. The judgement calls live in a private helper so # they can be tested without a container (see ConvertTo-BcAppDiagnosticBlocker). $apps = @($raw.apps) $derived = ConvertTo-BcAppDiagnosticBlocker -Raw $raw -ContainerName $Name -AppName $AppName $blockers = @($derived.Blockers) $syncErrors = @($derived.SyncErrors) return [PSCustomObject]@{ Container = $Name ServerInstance = $ServerInstance Tenant = $Tenant Apps = $apps SyncErrors = @($syncErrors) EventLog = @($raw.eventLog) Blockers = @($blockers) Errors = @($raw.errors) } } |