Modules/businessdev.ALbuild.Apps/Private/ConvertTo-BcAppDiagnosticBlocker.ps1
|
function ConvertTo-BcAppDiagnosticBlocker { <# .SYNOPSIS Turns raw container app state into named blockers with runnable remedies. .DESCRIPTION Separated from Get-BcContainerAppDiagnostic so the judgement calls below can be tested without a Business Central container. They are worth testing: both of the defects found when this was first run against a live container were here, not in the data collection. * "Published but not installed" and "not synced" are NORMAL for most of a container's inventory - a BC image ships dozens of optional apps (language packs, e-document connectors) published and deliberately never installed. They are blockers only relative to an app you are about to publish, so they are raised only when the caller named one. Unscoped, a healthy container produced 82 blockers: a census, not a diagnosis. * Only Error/Critical events count as sync failures. A healthy container logs routine Warning-level chatter that a broad keyword match swallows ('CacheSynchronization' cache clears, and the benign 'AppAlreadyChangedWarning' that re-publishing an already-synced version always produces). A false blocker is worse than no blocker: it invents work and devalues the real ones. DATA_UPGRADE_REQUIRED is reported either way - an installed app whose tenant data lags a newer published version is a genuinely pending state regardless of what the caller asked about. .PARAMETER Raw The parsed in-container payload: an object with 'apps' and 'eventLog'. .PARAMETER ContainerName Container name, so each remedy is a command that can be run verbatim. .PARAMETER AppName The app the caller asked about; empty means "the whole container" (unscoped). .OUTPUTS PSCustomObject with Blockers and SyncErrors. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [AllowNull()] $Raw, [Parameter(Mandatory)] [string] $ContainerName, [string] $AppName ) $apps = @($Raw.apps) $blockers = @() $syncErrors = @() $scoped = -not [string]::IsNullOrWhiteSpace($AppName) foreach ($app in $apps) { $versions = @($app.published) if ($versions.Count -eq 0) { continue } $appArg = '"' + $app.name + '"' # Sort defensively: a version string that will not parse must not take the whole diagnosis down. # This runs precisely when something is already wrong, so it has to survive malformed input and # still report what it can - an unparseable version sorts last rather than throwing. $newest = @($versions | Sort-Object { $parsed = $null if ([version]::TryParse("$($_.version)", [ref]$parsed)) { $parsed } else { [version]'0.0.0.0' } } -Descending)[0] # A published version the tenant has NOT installed is exactly the leftover that makes the next # publish of the same version fail with "same App ID and Version as a previously published # Extension" - the failure a re-run inherits from an earlier aborted run. if ($scoped) { foreach ($v in $versions) { if (-not $v.installed) { $blockers += [PSCustomObject]@{ code = 'VERSION_ALREADY_PUBLISHED' app = $app.name detail = "Version $($v.version) of '$($app.name)' is published but not installed; publishing that version again will be refused." remedy = "albuild app unpublish --container $ContainerName --name $appArg --purge --yes" } } } } # The tenant holds data for an older version: an INSTALL cannot proceed, a data UPGRADE must. if ($app.tenantDataVersion) { $dataBehind = $false try { $dataBehind = ([version]$app.tenantDataVersion) -lt ([version]$newest.version) } catch { $dataBehind = $false } if ($dataBehind) { $blockers += [PSCustomObject]@{ code = 'DATA_UPGRADE_REQUIRED' app = $app.name detail = "Tenant holds data for $($app.tenantDataVersion); $($newest.version) cannot be installed directly." remedy = "albuild app publish --container $ContainerName --file <app.app> --if-exists upgrade --mode upgrade" } } } foreach ($v in $versions) { if ($v.needsUpgrade) { $blockers += [PSCustomObject]@{ code = 'DATA_UPGRADE_REQUIRED' app = $app.name detail = "Version $($v.version) of '$($app.name)' is flagged NeedsUpgrade by the tenant." remedy = "albuild app publish --container $ContainerName --file <app.app> --if-exists upgrade --mode upgrade" } } } # 'Synced' is the only state an install can proceed from. This is the case where sync reported OK # and install then said the extension was not synchronized: the state, not the message. Only the # NEWEST published version matters - that is the one being installed; an old NotSynced version is # history, not an obstacle. if ($scoped -and $newest.syncState -and $newest.syncState -notin @('Synced', 'Unknown')) { $blockers += [PSCustomObject]@{ code = 'NOT_SYNCHRONIZED' app = $app.name detail = "Version $($newest.version) of '$($app.name)' is in sync state '$($newest.syncState)'; it cannot be installed until it is Synced." remedy = "albuild app publish --container $ContainerName --file <app.app> --if-exists force-sync" } } } # Schema-sync FAILURES recorded in the server log - the per-object detail no cmdlet exposes. foreach ($e in @($Raw.eventLog)) { if (-not $e.message) { continue } if ($e.level -notin @('Error', 'Critical')) { continue } if ($e.message -match 'AppAlreadyChangedWarning|CacheSynchronization') { continue } if ($e.message -notmatch 'DatabaseSync|NavApp|schema|synchroniz') { continue } $syncErrors += [PSCustomObject]@{ timeAtUtc = $e.timeAtUtc level = $e.level message = $e.message } } if (@($syncErrors).Count -gt 0) { $blockers += [PSCustomObject]@{ code = 'SCHEMA_CONFLICT' app = $AppName detail = "The server log records $(@($syncErrors).Count) sync/schema error(s); see syncErrors for the offending object." remedy = "albuild app publish --container $ContainerName --file <app.app> --if-exists force-sync" } } return [PSCustomObject]@{ Blockers = @($blockers) SyncErrors = @($syncErrors) } } |