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)
    }
}