Modules/businessdev.ALbuild.OnPrem/Public/Publish-BcPerTenantExtension.ps1
|
function Publish-BcPerTenantExtension { <# .SYNOPSIS Deploys a per-tenant extension to a Business Central environment via the automation API (licensed). .DESCRIPTION Uploads and schedules one or more per-tenant extensions using the Business Central automation API extensionUpload entity: create an upload record, PATCH the .app binary into it, then trigger the upload action. A bearer access token for the environment is required (acquire one with New-BcApiAuthContext, which supports S2S client-secret, certificate and refresh-token authentication). The target environment can be given either as a ready automation API base URL (-AutomationBaseUrl) or, more conveniently, as -TenantId + -Environment (the URL is then built for you). When -CompanyId is omitted the first company in the environment is used. One or more .app files (or a folder of them) can be published in a single call. .PARAMETER TenantId Azure AD tenant id of the environment. Used to build the automation API base URL. .PARAMETER Environment Business Central environment name (sandbox or production). Used to build the base URL. .PARAMETER AutomationBaseUrl The automation API base URL, e.g. https://api.businesscentral.dynamics.com/v2.0/{tenant}/{environment}/api/microsoft/automation/v2.0 Supply this instead of -TenantId/-Environment to target a non-default service URL. .PARAMETER CompanyId The company id (GUID) in the environment. When omitted, the first company is resolved and used. .PARAMETER AppFile One or more .app files (or folders/wildcards) to publish. Runtime packages (*.runtime.app) are skipped. Defaults to the $(bcAppFile) build variable when called from the task. .PARAMETER AccessToken OAuth2 bearer token for the environment (e.g. from New-BcApiAuthContext). .PARAMETER SchemaSyncMode Schema sync mode: Add (default) or ForceSync. .PARAMETER Schedule WHEN the environment deploys the extension. The automation API never installs synchronously: the upload action queues the deployment, and this decides what it is queued for. Current (default) deploys against the environment's current version; NextMinor / NextMajor hold the app back until that upgrade runs. Maps to the API's "Current version" / "Next minor version" / "Next major version". .PARAMETER IncludeTestApp Publish test apps too. By default they are skipped: a test app belongs in a build container, not in a customer environment, and it drags in the Microsoft test framework. .PARAMETER NoWait Return as soon as every upload is queued, without waiting for the environment to finish deploying. The task then reports what was QUEUED, not what succeeded. .PARAMETER TimeoutMinutes How long to wait for the deployment to finish when -NoWait is not used. Default 15. .EXAMPLE $ctx = New-BcApiAuthContext -TenantId $t -ClientId $c -ClientSecret $s Publish-BcPerTenantExtension -TenantId $t -Environment 'Production' -AccessToken $ctx.AccessToken -AppFile .\out .EXAMPLE Publish-BcPerTenantExtension -AutomationBaseUrl $url -CompanyId $id -AppFile .\out\My.app -AccessToken $token #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Environment')] param( [Parameter(ParameterSetName = 'Environment', Mandatory)] [string] $TenantId, [Parameter(ParameterSetName = 'Environment', Mandatory)] [string] $Environment, [Parameter(ParameterSetName = 'Url', Mandatory)] [string] $AutomationBaseUrl, [string] $CompanyId, [Parameter(Mandatory)] [string[]] $AppFile, [Parameter(Mandatory)] [string] $AccessToken, [ValidateSet('Add', 'ForceSync')] [string] $SchemaSyncMode = 'Add', [ValidateSet('Current', 'NextMinor', 'NextMajor')] [string] $Schedule = 'Current', [switch] $IncludeTestApp, [switch] $NoWait, [int] $TimeoutMinutes = 15 ) Assert-ALbuildLicensed -Feature 'OnPrem' if ($PSCmdlet.ParameterSetName -eq 'Environment') { $AutomationBaseUrl = "https://api.businesscentral.dynamics.com/v2.0/$TenantId/$Environment/api/microsoft/automation/v2.0" } $base = $AutomationBaseUrl.TrimEnd('/') $headers = @{ Authorization = "Bearer $AccessToken" } # Resolve the .app files to publish (skip runtime packages). The wildcard/last-chance branch must # not use Get-ChildItem -File: -File is a FileSystem-provider *dynamic* parameter, so on a path # that does not resolve to the filesystem provider (e.g. a Windows 'C:\...' path on Linux, where # there is no C: drive) it fails to bind with "A parameter cannot be found that matches parameter # name 'File'" instead of simply matching nothing. Filter to files via PSIsContainer instead. $files = foreach ($p in $AppFile) { if (Test-Path -LiteralPath $p -PathType Container) { Get-ChildItem -LiteralPath $p -Filter '*.app' -Recurse -File } elseif (Test-Path -LiteralPath $p) { Get-Item -LiteralPath $p } else { Get-ChildItem -Path $p -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } } } $files = @($files | Where-Object { $_.Name -notlike '*.runtime.app' }) if ($files.Count -eq 0) { throw "No .app file(s) found to publish (looked in: $($AppFile -join ', '))." } # WHAT gets published has to be decided from each app's manifest, not from the file name. The task # input defaults to the whole artifact staging folder and this search is recursive, so a build that # also produces a test app would otherwise ship it straight into the customer's environment. Write-ALbuildLog "Found $($files.Count) .app file(s) under: $($AppFile -join ', ')." $candidates = @($files | ForEach-Object { Resolve-BcAppFileIdentity -Path $_.FullName }) # An unreadable manifest is reported but the file is still published (its name decides the test # check): silently dropping it would turn a release into a no-op, which is worse than the over- # publishing this change fixes. $unreadable = @($candidates | Where-Object { -not $_.Readable }) foreach ($bad in $unreadable) { Write-ALbuildLog -Level Warning "Could not read the manifest of '$(Split-Path $bad.File -Leaf)' ($($bad.Error)); publishing it anyway, test detection falls back to the file name." } $testApps = @($candidates | Where-Object { $_.IsTest }) if ($testApps.Count -gt 0 -and -not $IncludeTestApp) { foreach ($testApp in $testApps) { Write-ALbuildLog "Skipping test app '$($testApp.Name)' $($testApp.Version) - test apps are not published to an environment (use -IncludeTestApp to override)." } $candidates = @($candidates | Where-Object { -not $_.IsTest }) } elseif ($testApps.Count -gt 0) { Write-ALbuildLog -Level Warning "-IncludeTestApp: publishing $($testApps.Count) TEST app(s) to '$Environment'." } if ($candidates.Count -eq 0) { throw ("No publishable .app file(s) left after filtering (looked in: $($AppFile -join ', ')): " + "all $($testApps.Count) app(s) found are test apps. Point the task at the app artifact, " + 'or set -IncludeTestApp if this really is a test tenant.') } Write-ALbuildLog "Publishing $($candidates.Count) app(s): $(($candidates | ForEach-Object { "$($_.Name) $($_.Version)" }) -join ', ')." Write-ALbuildLog "Automation API: $base" # Resolve the company when not supplied (first company in the environment, as the V1 task did). if (-not $CompanyId) { $companies = Invoke-BcAutomationRequest -Uri "$base/companies" -Headers $headers -Method Get -Operation 'list the environment companies' $company = @($companies.value)[0] $CompanyId = if ($company) { $company.id } else { $null } if (-not $CompanyId) { throw "No company found at '$base'." } Write-ALbuildLog "Company: $(if ($company.PSObject.Properties['name']) { "$($company.name) " })($CompanyId) - resolved as the first company; pass -CompanyId to target another." } else { Write-ALbuildLog "Company: $CompanyId (explicit)." } $companySegment = "companies($CompanyId)/extensionUpload" $ifMatch = @{ 'If-Match' = '*' } $jsonHeader = @{ 'Content-Type' = 'application/json' } $streamHeader = @{ 'Content-Type' = 'application/octet-stream' } # Property names and values follow the documented automation API payload EXACTLY # ({"schedule":"Current version","schemaSyncMode":"Add"} - see the BC automation API docs). The # previous body sent 'Current Version' and 'SchemaSyncMode', which differ from the documented # spelling; BC option values are matched against their captions, so an undocumented casing is not # guaranteed to keep working across versions. $apiSyncMode = if ($SchemaSyncMode -eq 'ForceSync') { 'Force Sync' } else { 'Add' } $apiSchedule = switch ($Schedule) { 'NextMinor' { 'Next minor version' } 'NextMajor' { 'Next major version' } default { 'Current version' } } $uploadBody = @{ schedule = $apiSchedule; schemaSyncMode = $apiSyncMode } | ConvertTo-Json -Compress Write-ALbuildLog "Deployment request: schedule '$apiSchedule', schema sync mode '$apiSyncMode'." # Read a field defensively so a partial API response yields a clear error under Set-StrictMode. function Get-Field([object] $Object, [string] $Name) { if ($null -eq $Object) { return $null } $prop = $Object.PSObject.Properties[$Name] if ($prop) { $prop.Value } else { $null } } $queued = [System.Collections.Generic.List[object]]::new() foreach ($app in $candidates) { $leaf = Split-Path -Path $app.File -Leaf $label = "$($app.Name) $($app.Version)" if (-not $PSCmdlet.ShouldProcess($AutomationBaseUrl, "Publish PTE $label")) { continue } Write-ALbuildLog "[$label] uploading '$leaf'..." # Reuse an existing extensionUpload record if one is present (a leftover from a prior attempt), # otherwise create one - POSTing a second record returns 409 Conflict. (Mirrors the BC # automation API flow used by BcContainerHelper's Publish-PerTenantExtensionApps.) $existingResponse = Invoke-BcAutomationRequest -Uri "$base/$companySegment" -Headers $headers -Method Get -Operation "read the extension upload record for '$label'" $existing = @(Get-Field $existingResponse 'value') | Select-Object -First 1 $existingId = Get-Field $existing 'systemId' if ($existingId) { Write-ALbuildLog "[$label] reusing the existing extension upload record ($existingId)." $upload = Invoke-BcAutomationRequest -Uri "$base/$companySegment($existingId)" -Method Patch -Headers ($headers + $ifMatch + $jsonHeader) -Body $uploadBody -Operation "update the extension upload record for '$label'" } else { $upload = Invoke-BcAutomationRequest -Uri "$base/$companySegment" -Method Post -Headers ($headers + $jsonHeader) -Body $uploadBody -Operation "create the extension upload record for '$label'" } $uploadId = Get-Field $upload 'systemId' if (-not $uploadId) { throw "The automation API did not return an extensionUpload id for '$label'." } # Upload the .app bytes into the upload's media edit link. $mediaLink = Get-Field $upload 'extensionContent@odata.mediaEditLink' if (-not $mediaLink) { $mediaLink = "$base/$companySegment($uploadId)/extensionContent" } $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $app.File).ProviderPath) Write-ALbuildLog "[$label] sending $([Math]::Round($bytes.Length / 1KB)) KB to the environment..." Invoke-BcAutomationRequest -Uri $mediaLink -Method Patch -Headers ($headers + $ifMatch + $streamHeader) -Body $bytes -Operation "upload the .app content for '$label'" | Out-Null # Trigger the deployment. This only QUEUES the work - the environment installs it in the # background, which is why the outcome has to be read from extensionDeploymentStatus below. Invoke-BcAutomationRequest -Uri "$base/$companySegment($uploadId)/Microsoft.NAV.upload" -Method Post -Headers ($headers + $ifMatch) -Operation "start the deployment of '$label'" | Out-Null Write-ALbuildLog "[$label] queued for deployment ($apiSchedule, $apiSyncMode)." $queued.Add($app) } if ($queued.Count -eq 0) { return } if ($NoWait) { Write-ALbuildLog -Level Warning ("-NoWait: $($queued.Count) app(s) were QUEUED but not verified. " + "Check extensionDeploymentStatus in the environment for the outcome.") return } # Only apps whose identity we actually read can be matched against the status feed; an app published # from an unreadable manifest is reported as unverified rather than polled for forever. $verifiable = @($queued | Where-Object { $_.Readable -and $_.Name -and $_.Version }) foreach ($app in @($queued | Where-Object { $_ -notin $verifiable })) { Write-ALbuildLog -Level Warning "Cannot verify the deployment of '$(Split-Path $app.File -Leaf)' - its manifest was unreadable, so it cannot be matched in extensionDeploymentStatus." } if ($verifiable.Count -eq 0) { return } # Wait for the environment to actually deploy. Without this the task reports success for work that # may still fail minutes later inside BC - the deployment is asynchronous. Write-ALbuildLog "Waiting for the environment to finish deploying (timeout $TimeoutMinutes min)..." $deadline = (Get-Date).AddMinutes($TimeoutMinutes) $pending = @($verifiable) $failures = [System.Collections.Generic.List[string]]::new() $reported = @{} while ($pending.Count -gt 0 -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 10 $statusResponse = Invoke-BcAutomationRequest -Uri "$base/companies($CompanyId)/extensionDeploymentStatus" -Headers $headers -Method Get -Operation 'read the extension deployment status' $entries = @(Get-Field $statusResponse 'value') $stillPending = @() foreach ($app in $pending) { # Match on name + version: one environment can hold several operations at once. $entry = @($entries | Where-Object { "$(Get-Field $_ 'name')" -eq $app.Name -and "$(Get-Field $_ 'appVersion')" -eq $app.Version }) | Select-Object -First 1 if (-not $entry) { $stillPending += $app; continue } $status = "$(Get-Field $entry 'status')" $key = "$($app.Name)|$($app.Version)" if ($status -match 'InProgress|Scheduled|Pending') { if (-not $reported.ContainsKey($key)) { Write-ALbuildLog "[$($app.Name) $($app.Version)] $status..." $reported[$key] = $true } $stillPending += $app continue } if ($status -match 'Completed|Success') { Write-ALbuildLog -Level Success "[$($app.Name) $($app.Version)] deployed ($status)." } else { # Anything not explicitly in-progress or successful is a failure; surface every field the # API gives so the reason is in the release log. $detail = @('operationType', 'schedule', 'startedOn', 'operationID' | ForEach-Object { $v = Get-Field $entry $_; if ($v) { "$_=$v" } }) -join ', ' Write-ALbuildLog -Level Error "[$($app.Name) $($app.Version)] deployment status '$status'. $detail" $failures.Add("$($app.Name) $($app.Version): $status") } } $pending = @($stillPending) } # Left over at the deadline. An app that was still reported InProgress really did not finish - that # is a failure. An app that never appeared in the feed at all is NOT failed: BC does not keep a # status row forever, and turning "no evidence" into a red release would be a false alarm. foreach ($app in $pending) { $key = "$($app.Name)|$($app.Version)" if ($reported.ContainsKey($key)) { $failures.Add("$($app.Name) $($app.Version): still deploying after $TimeoutMinutes minute(s)") } else { Write-ALbuildLog -Level Warning ("Could not verify '$($app.Name) $($app.Version)': it never appeared in extensionDeploymentStatus within $TimeoutMinutes minute(s). " + 'The upload was accepted - check the environment to confirm the installation.') } } if ($failures.Count -gt 0) { throw ("Per-tenant extension deployment failed for $($failures.Count) app(s):`n " + ($failures -join "`n ") + "`nCheck extensionDeploymentStatus in environment '$Environment' for details.") } Write-ALbuildLog -Level Success "Per-tenant extension deployment finished for '$Environment'." } |