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. .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' ) 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 ', '))." } # Resolve the company when not supplied (first company in the environment, as the V1 task did). if (-not $CompanyId) { $companies = Invoke-RestMethod -Uri "$base/companies" -Headers $headers -Method Get -ErrorAction Stop $CompanyId = @($companies.value)[0].id if (-not $CompanyId) { throw "No company found at '$base'." } } $companySegment = "companies($CompanyId)/extensionUpload" $ifMatch = @{ 'If-Match' = '*' } $jsonHeader = @{ 'Content-Type' = 'application/json' } $streamHeader = @{ 'Content-Type' = 'application/octet-stream' } # The automation API's value differs from the cmdlet token: 'ForceSync' -> 'Force Sync'. $apiSyncMode = if ($SchemaSyncMode -eq 'ForceSync') { 'Force Sync' } else { 'Add' } $uploadBody = @{ schedule = 'Current Version'; SchemaSyncMode = $apiSyncMode } | ConvertTo-Json -Compress # 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 } } foreach ($file in $files) { if (-not $PSCmdlet.ShouldProcess($AutomationBaseUrl, "Publish PTE $($file.Name)")) { continue } # 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.) $existing = @(Get-Field (Invoke-RestMethod -Uri "$base/$companySegment" -Headers $headers -Method Get -ErrorAction Stop) 'value') | Select-Object -First 1 $existingId = Get-Field $existing 'systemId' if ($existingId) { $upload = Invoke-RestMethod -Uri "$base/$companySegment($existingId)" -Method Patch -Headers ($headers + $ifMatch + $jsonHeader) -Body $uploadBody -ErrorAction Stop } else { $upload = Invoke-RestMethod -Uri "$base/$companySegment" -Method Post -Headers ($headers + $jsonHeader) -Body $uploadBody -ErrorAction Stop } $uploadId = Get-Field $upload 'systemId' if (-not $uploadId) { throw "The automation API did not return an extensionUpload id for '$($file.Name)'." } # 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 $file.FullName).ProviderPath) Invoke-RestMethod -Uri $mediaLink -Method Patch -Headers ($headers + $ifMatch + $streamHeader) -Body $bytes -ErrorAction Stop | Out-Null # Trigger the upload / installation. Invoke-RestMethod -Uri "$base/$companySegment($uploadId)/Microsoft.NAV.upload" -Method Post -Headers ($headers + $ifMatch) -ErrorAction Stop | Out-Null Write-ALbuildLog -Level Success "Scheduled per-tenant extension '$($file.Name)' ($SchemaSyncMode)." } } |