diff --git a/Actions/.Modules/settings.schema.json b/Actions/.Modules/settings.schema.json index c5af7485b3..34cf091e5a 100644 --- a/Actions/.Modules/settings.schema.json +++ b/Actions/.Modules/settings.schema.json @@ -985,7 +985,7 @@ "unpublishOldVersions": { "type": "boolean", "default": false, - "description": "When set to true, AL-Go unpublishes old, uninstalled versions of the deployed apps from the environment after a successful deployment. Only applies to PTE deployments (Scope PTE) and is non-fatal. See https://aka.ms/ALGoSettings#deployto" + "description": "Optional boolean, default false. After successful Scope PTE deployment, unpublishes only selected apps' uninstalled versions older than the currently installed version on Business Central 25.4 through 29. On BC 30 and later, Business Central manages cleanup in the background, which may be delayed; timing and retained versions are not guaranteed. This setting does not control background cleanup: AL-Go warns and skips explicit unpublish without failing deployment. Cleanup failures are warnings; does not apply to Scope Dev or custom deployments. See https://aka.ms/ALGoSettings#deployto" } } } diff --git a/Actions/Deploy/Deploy.ps1 b/Actions/Deploy/Deploy.ps1 index c3f6d0527f..1c7ee2a0e6 100644 --- a/Actions/Deploy/Deploy.ps1 +++ b/Actions/Deploy/Deploy.ps1 @@ -193,7 +193,7 @@ else { Publish-BcContainerApp @parameters -useDevEndpoint -checkAlreadyInstalled -excludeRuntimePackages -replacePackageId } else { - # Use automation API for production environments (Publish-PerTenantExtensionApps) + # The shared AL-Go publisher selects the API based on the target environment version. $parameters = @{ "bcAuthContext" = $bcAuthContext "environment" = $deploymentSettings.EnvironmentName @@ -201,7 +201,7 @@ else { } if ($deploymentSettings.SyncMode) { if (@('Add','ForceSync') -notcontains $deploymentSettings.SyncMode) { - throw "Invalid SyncMode $($deploymentSettings.SyncMode) when deploying using the automation API. Valid values are Add and ForceSync." + throw "Invalid SyncMode $($deploymentSettings.SyncMode) when deploying Per Tenant Extensions. Valid values are Add and ForceSync." } Write-Host "Using $($deploymentSettings.SyncMode)" $syncMode = $deploymentSettings.SyncMode @@ -211,11 +211,11 @@ else { CheckInstalledApps -bcAuthContext $bcAuthContext -environment $deploymentSettings.EnvironmentName -appFiles $apps - Write-Host "Publishing apps using automation API" - Publish-PerTenantExtensionApps @parameters + Write-Host "Publishing Per Tenant Extensions" + PublishPTEApps @parameters if (($deploymentSettings['unpublishOldVersions'] -is [bool]) -and $deploymentSettings['unpublishOldVersions']) { - Write-Host "Unpublishing old app versions" + Write-Host "Checking cleanup of old app versions" UnpublishOldAppVersions -bcAuthContext $bcAuthContext -environment $deploymentSettings.EnvironmentName -appFiles $apps } } diff --git a/Actions/Deploy/Deploy.psm1 b/Actions/Deploy/Deploy.psm1 index c32a0c20df..b23057a128 100644 --- a/Actions/Deploy/Deploy.psm1 +++ b/Actions/Deploy/Deploy.psm1 @@ -205,7 +205,6 @@ function CheckIfAppNeedsInstallOrUpgrade { return $needsInstall, $needsUpgrade } -# Check if the apps are already installed and emit a warning if the installed version is higher than the version in the app file <# .SYNOPSIS Check installed apps against the provided app files and emit warnings if the installed version is higher than the version in the app file. @@ -242,6 +241,269 @@ function CheckInstalledApps { } } +<# + .SYNOPSIS + Resolve the target BC version from its installed Application extension. + .PARAMETER extensions + Extensions returned by the read-only environment API. + .PARAMETER environment + Target environment name, used in failure diagnostics. +#> +function GetPTEApplicationVersion { + Param( + [object[]] $extensions, + [string] $environment + ) + + $applications = @($extensions | Where-Object { $_.displayName -eq 'Application' -and $_.isInstalled }) + $version = $null + if ($applications.Count -eq 1) { + $application = $applications[0] | ConvertTo-HashTable -recurse + $parts = @('versionMajor', 'versionMinor', 'versionBuild', 'versionRevision' | ForEach-Object { + if ($application.ContainsKey($_)) { [string]$application[$_] } + }) + if ($parts.Count -eq 4 -and [version]::TryParse(($parts -join '.'), [ref]$version)) { + return $version + } + } + throw "Could not determine the Business Central version in environment $environment from a unique installed Application extension." +} + +<# + .SYNOPSIS + Upload a PTE for immediate installation through Admin Center API v2.29. + .DESCRIPTION + Sends multipart bytes on PowerShell 5 and 7 and disposes HTTP content and streams. + Upload errors are never retried because the service might already have accepted the package. + .PARAMETER bcAuthContext + Authentication context to renew before uploading. + .PARAMETER uri + Admin Center pteInstall URL for the target environment. + .PARAMETER appFile + App package to upload. + .PARAMETER syncMode + Admin Center schema sync mode, Add or ForceSync. +#> +function InvokePTEUpload { + Param( + [hashtable] $bcAuthContext, + [string] $uri, + [string] $appFile, + [ValidateSet('Add', 'ForceSync')] + [string] $syncMode + ) + + $authContext = Renew-BcAuthContext -bcAuthContext $bcAuthContext + if (!$authContext -or !$authContext['AccessToken']) { + throw "Authentication failed before PTE upload." + } + Write-Host "::add-mask::$($authContext.AccessToken)" + $headers = @{ Authorization = "Bearer $($authContext.AccessToken)" } + Add-Type -AssemblyName System.Net.Http + $multipart = $null + $fileStream = $null + $fileContent = $null + try { + $multipart = [System.Net.Http.MultipartFormDataContent]::new() + $fileStream = [System.IO.File]::OpenRead($appFile) + $fileContent = [System.Net.Http.StreamContent]::new($fileStream) + $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new('application/octet-stream') + $multipart.Add($fileContent, 'extensionFile', [System.IO.Path]::GetFileName($appFile)) + $fields = @{ + deploymentSchedule = 'Immediate' + syncMode = $syncMode + acceptIsvEula = 'true' + installOrUpdateNeededDependencies = 'false' + } + foreach ($field in $fields.Keys) { + $multipart.Add([System.Net.Http.StringContent]::new($fields[$field]), $field) + } + # Invoke-RestMethod supports a byte body on both Windows PowerShell and PowerShell Core. + $bytes = $multipart.ReadAsByteArrayAsync().GetAwaiter().GetResult() + Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -Body $bytes ` + -ContentType $multipart.Headers.ContentType.ToString() -UseBasicParsing -TimeoutSec 300 -ErrorAction Stop + } + catch { + $details = $_.Exception.Message + if ($_.ErrorDetails) { $details += " $($_.ErrorDetails.Message)" } + throw "PTE upload failed for $([System.IO.Path]::GetFileName($appFile)): $details. The upload will not be replayed." + } + finally { + if ($multipart) { $multipart.Dispose() } + if ($fileContent) { $fileContent.Dispose() } + if ($fileStream) { $fileStream.Dispose() } + } +} + +<# + .SYNOPSIS + Wait for the exact Admin Center PTE operation to complete successfully. + .DESCRIPTION + Polls only the returned app and operation IDs. An accepted or scheduled immediate install + is not success. Only transient status-read errors are retried, never the upload. + .PARAMETER bcAuthContext + Authentication context to renew for each status request. + .PARAMETER appsUri + Admin Center apps URL for the target environment. + .PARAMETER operation + Upload response containing the operation id, appId and status. + .PARAMETER appId + Expected app ID from the package. + .PARAMETER timeoutSeconds + Maximum elapsed polling time. Defaults to 30 minutes. + .PARAMETER maxStatusRequests + Maximum number of status requests. Defaults to 360. + .PARAMETER pollIntervalSeconds + Delay between status requests. Defaults to 30 seconds, capped by the remaining timeout. +#> +function WaitPTEOperation { + Param( + [hashtable] $bcAuthContext, + [string] $appsUri, + $operation, + [string] $appId, + [ValidateRange(1, 1800)] + [int] $timeoutSeconds = 1800, + [ValidateRange(1, 360)] + [int] $maxStatusRequests = 360, + [ValidateRange(0, 30)] + [int] $pollIntervalSeconds = 30 + ) + + $operation = $operation | ConvertTo-HashTable -recurse + if ($operation -isnot [hashtable] -or !$operation['id'] -or $operation['appId'] -ne $appId) { + throw "PTE upload returned missing or mismatched app/operation details for app $appId. The upload will not be replayed." + } + $operationId = [string]$operation['id'] + $operationUri = "$appsUri/$([Uri]::EscapeDataString($appId))/operations/$([Uri]::EscapeDataString($operationId))" + $timer = [System.Diagnostics.Stopwatch]::StartNew() + $transientErrors = 0 + $requests = 0 + while ($true) { + if ($operation -isnot [hashtable] -or $operation['id'] -ne $operationId -or + ($operation.ContainsKey('appId') -and $operation['appId'] -ne $appId)) { + throw "Missing or mismatched PTE operation $operationId for app $appId." + } + $status = [string]$operation['status'] + Write-Host "PTE operation $operationId for app ${appId}: $status" + switch ($status) { + 'succeeded' { return } + 'scheduled' {} + 'running' {} + default { + throw "PTE operation $operationId for app $appId did not succeed. Response: $($operation | ConvertTo-Json -Depth 20 -Compress)" + } + } + if ($requests -ge $maxStatusRequests -or $timer.Elapsed.TotalSeconds -ge $timeoutSeconds) { + break + } + $sleepSeconds = [Math]::Min($pollIntervalSeconds, [Math]::Max(0, $timeoutSeconds - $timer.Elapsed.TotalSeconds)) + Start-Sleep -Milliseconds ([int][Math]::Ceiling($sleepSeconds * 1000)) + if ($timer.Elapsed.TotalSeconds -ge $timeoutSeconds) { break } + # Renew outside the retry catch so authentication errors always fail immediately. + $bcAuthContext = Renew-BcAuthContext -bcAuthContext $bcAuthContext + if (!$bcAuthContext -or !$bcAuthContext['AccessToken']) { + throw "Authentication failed while reading PTE operation $operationId for app $appId." + } + Write-Host "::add-mask::$($bcAuthContext.AccessToken)" + $headers = @{ Authorization = "Bearer $($bcAuthContext.AccessToken)" } + $requests++ + try { + $requestTimeout = [int][Math]::Max(1, [Math]::Min(30, [Math]::Ceiling($timeoutSeconds - $timer.Elapsed.TotalSeconds))) + $operation = Invoke-RestMethod -Headers $headers -Method Get -Uri $operationUri ` + -UseBasicParsing -TimeoutSec $requestTimeout -ErrorAction Stop + $operation = $operation | ConvertTo-HashTable -recurse + $transientErrors = 0 + } + catch { + $statusCode = 0 + if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + $transient = $statusCode -in 408,429,500,502,503,504 -or + (!$statusCode -and ($_.Exception -is [System.Net.WebException] -or + $_.Exception -is [System.Net.Http.HttpRequestException] -or $_.Exception -is [System.TimeoutException])) + $details = $_.Exception.Message + if ($_.ErrorDetails) { $details += " $($_.ErrorDetails.Message)" } + if (!$transient -or ++$transientErrors -gt 5) { + throw "Unable to read PTE operation $operationId for app ${appId}: $details" + } + OutputWarning -message "Transient error reading PTE operation ${operationId}: $details. Retrying the status request." + } + } + throw "Timed out waiting for PTE operation $operationId for app $appId after $timeoutSeconds seconds or $maxStatusRequests status requests. The upload will not be replayed." +} + +<# + .SYNOPSIS + Publish primary apps or PTE dependencies using the target BC version. + .DESCRIPTION + Below BC 30, delegates publishing to the released BcContainerHelper Automation implementation. + On BC 30 and later, AL-Go installs packages immediately using Admin Center API v2.29 with + no legacy fallback. Existing helper utilities expand packages, exclude runtime packages and + order dependencies. Same or newer installed versions are skipped. + .PARAMETER bcAuthContext + Business Central authentication context. + .PARAMETER environment + Target Business Central environment name. + .PARAMETER appFiles + App packages or archives to publish. + .PARAMETER schemaSyncMode + Existing helper-compatible sync mode: Add or Force (mapped to ForceSync for Admin Center). + .PARAMETER applicationFamily + Admin Center application family. Defaults to BusinessCentral; not a workflow setting. +#> +function PublishPTEApps { + Param( + [hashtable] $bcAuthContext, + [string] $environment, + [string[]] $appFiles, + [ValidateSet('Add', 'Force')] + [string] $schemaSyncMode = 'Add', + [string] $applicationFamily = 'BusinessCentral' + ) + + $extensions = @(Get-BcInstalledExtensions -bcAuthContext $bcAuthContext -environment $environment) + $applicationVersion = GetPTEApplicationVersion -extensions $extensions -environment $environment + if ($applicationVersion -lt [version]'30.0.0.0') { + Write-Host "Target BC $applicationVersion - publishing PTEs using BcContainerHelper Automation API" + Publish-PerTenantExtensionApps -bcAuthContext $bcAuthContext -environment $environment -appFiles $appFiles -SchemaSyncMode $schemaSyncMode + return + } + Write-Host "Target BC $applicationVersion - publishing PTEs using AL-Go Admin Center API v2.29" + $appsUri = "$($bcContainerHelperConfig.apiBaseUrl.TrimEnd('/'))/admin/v2.29/applications/$([Uri]::EscapeDataString($applicationFamily))/environments/$([Uri]::EscapeDataString($environment))/apps" + $tempPath = NewTemporaryFolder + try { + $copiedApps = @(Copy-AppFilesToFolder -appFiles $appFiles -folder $tempPath) + $sortedApps = @(Sort-AppFilesByDependencies -appFiles $copiedApps -excludeRuntimePackages) + foreach ($appFile in $sortedApps) { + $appJson = Get-AppJsonFromAppFile -appFile $appFile + $appVersion = [version]$appJson.version + $installedVersions = @($extensions | Where-Object { $_.id -eq $appJson.id -and $_.isInstalled } | + ForEach-Object { [version]::new($_.versionMajor, $_.versionMinor, $_.versionBuild, $_.versionRevision) }) + if (@($installedVersions | Where-Object { $_ -ge $appVersion }).Count -gt 0) { + Write-Host "App $($appJson.name) v$appVersion or newer is already installed" + continue + } + $syncMode = if ($schemaSyncMode -eq 'Force') { 'ForceSync' } else { 'Add' } + $operation = InvokePTEUpload -bcAuthContext $bcAuthContext -uri "$appsUri/pteInstall" -appFile $appFile -syncMode $syncMode + WaitPTEOperation -bcAuthContext $bcAuthContext -appsUri $appsUri -operation $operation -appId $appJson.id + $extensions += @{ + id = $appJson.id + displayName = $appJson.name + isInstalled = $true + versionMajor = $appVersion.Major + versionMinor = $appVersion.Minor + versionBuild = $appVersion.Build + versionRevision = $appVersion.Revision + } + } + } + finally { + Remove-Item -LiteralPath $tempPath -Force -Recurse + } +} + <# .SYNOPSIS Unpublish old, uninstalled versions of the deployed apps from a Business Central environment. @@ -249,7 +511,11 @@ function CheckInstalledApps { After a new version of a Per Tenant Extension is installed, previous versions remain published (but uninstalled) and clutter Extension Management. This function unpublishes those old versions using the automation API v2.0 Microsoft.NAV.unpublish action. Only versions that are not installed AND older than a currently installed version - of the same app (matched by app id) are unpublished. This is only supported for PTE deployments (automation API). + of the same app (matched by app id) are unpublished. This is only supported for PTE deployments on BC 25.4 + through 29. On BC 30 and later, Business Central manages old-version cleanup in the background. + Explicit unpublish is not supported, so AL-Go skips its cleanup with a warning. Background cleanup may + be delayed; its timing and retained versions are not guaranteed. Read-only Automation API calls determine + the installed Application version. The function is non-fatal: any failure is reported as a warning and never fails the deployment. .PARAMETER bcAuthContext The Business Central authentication context. @@ -291,12 +557,11 @@ function UnpublishOldAppVersions { $companyUrl = "$automationApiUrl/companies($companyId)" $extensions = @((Invoke-RestMethod -Method Get -Uri "$companyUrl/extensions" -Headers $headers -UseBasicParsing).value) - $application = $extensions | Where-Object { $_.displayName -eq 'Application' -and $_.isInstalled } | Select-Object -First 1 - if (-not $application) { - OutputWarning -message "Could not determine the Business Central version in environment $environment - skipping unpublish of old app versions." + $applicationVersion = GetPTEApplicationVersion -extensions $extensions -environment $environment + if ($applicationVersion -ge [version]'30.0.0.0') { + OutputWarning -message "AL-Go cannot explicitly unpublish old PTE versions on Business Central 30 or later; environment $environment is running $applicationVersion. Business Central manages cleanup in the background, which may be delayed; cleanup timing and retained versions are not guaranteed. AL-Go cleanup was skipped; the deployment remains successful." return } - $applicationVersion = [version]::new($application.versionMajor, $application.versionMinor, $application.versionBuild, $application.versionRevision) if ($applicationVersion -lt [version]'25.4.0.0') { OutputWarning -message "Unpublishing old app versions requires Business Central 25.4 or later; environment $environment is running $applicationVersion." return @@ -310,7 +575,7 @@ function UnpublishOldAppVersions { continue } # Use the currently installed version as the cleanup threshold. The environment may have a newer - # version installed than the deployed artifact, which Publish-PerTenantExtensionApps treats as success. + # version installed than the deployed artifact, which PublishPTEApps treats as success. $installedVersions = @($matching | Where-Object { $_.isInstalled } | ForEach-Object { [version]::new($_.versionMajor, $_.versionMinor, $_.versionBuild, $_.versionRevision) }) if ($installedVersions.Count -eq 0) { # No installed version - nothing to clean up against @@ -398,7 +663,7 @@ function InstallOrUpgradeApps { } if ($PTEsToInstall) { # Install or upgrade PTEs - Publish-PerTenantExtensionApps -bcAuthContext $bcAuthContext -environment $environment -appFiles $PTEsToInstall -SchemaSyncMode $schemaSyncMode + PublishPTEApps -bcAuthContext $bcAuthContext -environment $environment -appFiles $PTEsToInstall -SchemaSyncMode $schemaSyncMode } } finally { @@ -472,3 +737,5 @@ function InstallUnknownDependencies { Write-Host "Unknown dependencies installed or upgraded" } } + +Export-ModuleMember -Function GetHeadRefFromPRId, GetAppsAndDependenciesFromArtifacts, CheckIfAppNeedsInstallOrUpgrade, CheckInstalledApps, PublishPTEApps, UnpublishOldAppVersions, InstallOrUpgradeApps, InstallUnknownDependencies diff --git a/Actions/Deploy/README.md b/Actions/Deploy/README.md index c0fe51d2bc..0a2ee2ad89 100644 --- a/Actions/Deploy/README.md +++ b/Actions/Deploy/README.md @@ -2,6 +2,14 @@ Deploy Apps to online environment +## PTE publishing + +PTE deployment installs apps in dependency order and skips app versions that are already installed or superseded by a newer installed version. Deployment waits for installation to complete; installation failures fail the deployment. + +BC 30+ S2S deployments require `AdminCenter.ReadWrite.All` application permission, tenant admin consent, and registration in the administration center's Authorized Microsoft Entra apps. Existing AUTHCONTEXT structures stay unchanged, and environment API permissions remain necessary. See [deployment authorization](../../Workshop/ContinuousDeployment.md#pte-deployment-authorization) for full requirements. + +`unpublishOldVersions` is an optional boolean in `DeployTo` (default `false`). It runs only after successful Scope PTE publishing. On BC 25.4 through 29, only selected apps' uninstalled versions older than the currently installed version are unpublished. On BC 30+, Business Central manages cleanup in the background, which may be delayed; timing and retained versions are not guaranteed. The setting does not control that background cleanup: AL-Go warns and skips explicit unpublish without failing deployment. Scope Dev, custom deployments, and container behavior are unchanged. + ## INPUT ### ENV variables diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 96bf65947c..2cd84df5ee 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,3 +1,11 @@ +### Breaking changes + +**PTE deployment to BC 30 and later requires additional authorization.** Earlier, environment-level deployment permissions were sufficient. Existing deployments to BC 30 or later will now fail if the required administration center access has not been granted. + +For S2S, add the `AdminCenter.ReadWrite.All` application permission, grant tenant admin consent, and register the client ID under the Business Central administration center's **Authorized Microsoft Entra apps**. Delegated users need appropriate administration center access. Keep the existing environment permissions and AUTHCONTEXT credential structure. See [deployment authorization](Workshop/ContinuousDeployment.md#pte-deployment-authorization). Deployments to BC 29 and earlier, Scope Dev, custom deployments, and container behavior are unchanged. + +**Old-version cleanup changes on BC 30 and later.** Business Central manages cleanup in the background rather than AL-Go explicitly unpublishing old versions. Cleanup may be delayed; its timing and retained versions are not guaranteed. `unpublishOldVersions` does not control background cleanup: if enabled, AL-Go warns and skips explicit unpublish without failing deployment. + ### Separate test execution from RunPipeline (PREVIEW) A new `useSeparateTestAction` complex setting (default `{ "enabled": false, "testType": "" }`) runs normal tests (`testFolders`) in a dedicated `RunTests` action when `enabled` is `true`. The separate action is used when tests are built and enabled and the build uses one local container. Normal tests remain in `RunPipeline` for builds with `additionalCountries`, no published apps, or test-data import callbacks that would otherwise have no remaining RunPipeline test category. BCPT and page scripting tests remain in `RunPipeline`. @@ -9,7 +17,7 @@ The action uses AlTool by default, running each app's enabled normal tests in on ### New `unpublishOldVersions` setting for deployment -The `DeployTo` setting now supports an opt-in `unpublishOldVersions` boolean (default `false`). When enabled, AL-Go unpublishes old, uninstalled versions of the deployed apps from the environment after a successful deployment, keeping Extension Management clean. This only applies to PTE deployments (Scope PTE / automation API), uses the Automation API v2.0 `Microsoft.NAV.unpublish` action, and is non-fatal (failures are reported as warnings and never fail the deployment). +The `DeployTo` setting now supports an opt-in `unpublishOldVersions` boolean (default `false`). After successful Scope PTE deployment on BC 25.4 through 29, it removes only selected apps' uninstalled versions older than the currently installed version. Cleanup failures remain warnings. For BC 30 and later, see the cleanup behavior under **Breaking changes** above. ### Settings sources in workflow logs @@ -56,7 +64,7 @@ AL-Go now supports including pre-release versions of Business Central app packag ### New `unpublishOldVersions` setting for deployment -The `DeployTo` setting now supports an opt-in `unpublishOldVersions` boolean (default `false`). When enabled, AL-Go unpublishes old, uninstalled versions of the deployed apps from the environment after a successful deployment, keeping Extension Management clean. This only applies to PTE deployments (Scope PTE / automation API), uses the Automation API v2.0 `Microsoft.NAV.unpublish` action, and is non-fatal (failures are reported as warnings and never fail the deployment). +The `DeployTo` setting now supports an opt-in `unpublishOldVersions` boolean (default `false`). After successful Scope PTE deployment on BC 25.4 through 29, it removes only selected apps' uninstalled versions older than the currently installed version. Cleanup failures remain warnings. For BC 30 and later, see the cleanup behavior under **Breaking changes** above. ### Expanded AL-Go telemetry dashboard diff --git a/Scenarios/RegisterProductionEnvironment.md b/Scenarios/RegisterProductionEnvironment.md index 29b65afff8..059e52823b 100644 --- a/Scenarios/RegisterProductionEnvironment.md +++ b/Scenarios/RegisterProductionEnvironment.md @@ -2,6 +2,14 @@ *Prerequisites: A completed ["Create a release of your application"](CreateRelease.md) scenario, an online production environment setup for S2S as specified in task 2 here: [Using Service to Service Authentication - Business Central | Microsoft Docs](https://go.microsoft.com/fwlink/?linkid=2217415&clcid=0x409), using the same Microsoft Entra application registration as scenario 3* +## PTE publishing authorization + +Before **Scope PTE** deployment to BC 30 or later using S2S, add the **AdminCenter.ReadWrite.All** application permission under **Dynamics 365 Business Central**, grant tenant admin consent, and register the client ID on the Business Central administration center's **Authorized Microsoft Entra apps** page. Existing app registrations may need this additional authorization. Keep the existing environment API permissions and the Microsoft Entra application registration and required permission sets inside the Business Central environment. These additional permissions are not required for deployment to earlier BC versions. + +The **AUTHCONTEXT** credential structure is unchanged, including `TenantID`, `ClientID`, and `ClientSecret` for S2S. See [deployment authorization](../Workshop/ContinuousDeployment.md#pte-deployment-authorization) and the official [Business Central administration center API authentication documentation](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/administration-center-api). + +## Register the environment + > [!NOTE] > For access to environments, environment secrets, and deployment branches in private or internal repositories, you must use GitHub Pro, GitHub Team, or GitHub Enterprise. (see [this](https://go.microsoft.com/fwlink/?linkid=2216857&clcid=0x409)). We are considering adding a secondary option for listing environments. diff --git a/Scenarios/RegisterSandboxEnvironment.md b/Scenarios/RegisterSandboxEnvironment.md index 0a40c6bbc5..0bcd0b797c 100644 --- a/Scenarios/RegisterSandboxEnvironment.md +++ b/Scenarios/RegisterSandboxEnvironment.md @@ -2,6 +2,14 @@ *Prerequisites: A completed ["Add a test app to an existing project"](Scenarios/AddATestApp.md) scenario, a Microsoft Entra application registration, and an online sandbox environment called QA with the setup for S2S as specified in task 1 and 2 [here](https://go.microsoft.com/fwlink/?linkid=2217415&clcid=0x409) completed.* +## PTE publishing authorization + +For **Scope PTE** S2S deployment to BC 30 or later, add the **AdminCenter.ReadWrite.All** application permission under **Dynamics 365 Business Central**, grant tenant admin consent, and register the application's client ID on the Business Central administration center's **Authorized Microsoft Entra apps** page. Existing app registrations may need this additional authorization. Retain the existing environment API permissions and Microsoft Entra application registration inside the environment, including the required permission sets. + +The **AUTHCONTEXT** credential structure below is unchanged. See [deployment authorization](../Workshop/ContinuousDeployment.md#pte-deployment-authorization) and the official [Business Central administration center API authentication documentation](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/administration-center-api). These additional permissions are not required for deployment to earlier BC versions or **Scope Dev**. + +## Register the environment + > [!NOTE] > For access to environments, environment secrets, and deployment branches in private or internal repositories, you must use GitHub Pro, GitHub Team, or GitHub Enterprise. (see [this](https://go.microsoft.com/fwlink/?linkid=2216857&clcid=0x409)). > If you are running a free GitHub SKU, you can use the [environments](https://aka.ms/algosettings#environments) setting and a secret with a name of the environment followed by` _AuthContext` instead of GitHub environments and environment secrets. diff --git a/Scenarios/settings.md b/Scenarios/settings.md index 279e7d28aa..f59e13c4ef 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -75,7 +75,7 @@ The repository settings are only read from the repository settings file (.github | githubRunnerShell | Specifies which shell is used for build jobs in workflows including a build job. The default is to use the same as defined in **shell**. If the shell setting isn't defined, **powershell** is the default, which results in using _PowerShell 5.1_. Use **pwsh** for _PowerShell 7_. | | environments | Array of logical environment names. You can specify environments in GitHub environments or in the repo settings file. If you specify environments in the settings file, you can create your AUTHCONTEXT secret using **\\_AUTHCONTEXT**. You can specify additional information about environments in a setting called **DeployTo\** | | DeliverTo\ | Structure with additional properties for the deliveryTarget specified. Some properties are deliveryTarget specific. The structure can contain the following properties:
**Branches** = an array of branch patterns, which are allowed to deliver to this deliveryTarget. (Default main)
**CreateContainerIfNotExist** = *[Only for DeliverToStorage]* Create Blob Storage Container if it doesn't already exist. (Default false)
| -| DeployTo\ | Structure with additional properties for the environment specified. `` refers to the GitHub environment name. The structure can contain the following properties:
**EnvironmentType** = specifies the type of environment. The environment type can be used to invoke a custom deployment. (Default SaaS)
**EnvironmentName** = specifies the "real" name of the environment if it differs from the GitHub environment.
**Branches** = an array of branch patterns, which are allowed to deploy to this environment. These branches can also be defined under the environment in GitHub settings and both settings are honored. If neither setting is defined, the default is the **main** branch only.
**Projects** = In multi-project repositories, this property can be a comma separated list of project patterns to deploy to this environment. (Default \*)
**DependencyInstallMode** = Determines how dependencies are deployed if `GenerateDependencyArtifact` is true. Default value is `install` to install dependencies if not already installed. Other values are `ignore` for ignoring dependencies and `upgrade` or `forceUpgrade` for upgrading dependencies.
**includeTestAppsInSandboxEnvironment** = deploys test apps and their dependencies if the environment type is sandbox (Default is `false`)
**excludeAppIds** = array of app ids to exclude from deployment. (Default is `[]`)
**Scope** = Determines the mechanism for deployment to the environment (Dev or PTE). If not specified, AL-Go for GitHub will always use the Dev Scope for AppSource Apps, but also for PTEs when deploying to sandbox environments when impersonation (refreshtoken) is used for authentication.
**SyncMode** = ForceSync if deployment to this environment should happen with ForceSync, else Add. If deploying to the development endpoint you can also specify Development or Clean. (Default Add)
**unpublishOldVersions** = When set to `true`, AL-Go will unpublish old, uninstalled versions of the deployed apps from the environment after a successful deployment, to keep Extension Management clean. Only applies to PTE deployments (Scope PTE / automation API) and is non-fatal (failures are reported as warnings). (Default false)
**BuildMode** = specifies which buildMode to use for the deployment. Default is to use the Default buildMode
**ContinuousDeployment** = true if this environment should be used for continuous deployment, else false. (Default: AL-Go will continuously deploy to sandbox environments or environments, which doesn't end in (PROD) or (FAT)
**runs-on** = specifies which runner to use when deploying to this environment. (Default is settings.runs-on)
**shell** = specifies which shell to use when deploying to this environment, pwsh or powershell. (Default is settings.shell)
**companyId** = Company Id from Business Central (for PowerPlatform connection)
**ppEnvironmentUrl** = Url of the PowerPlatform environment to deploy to
| +| DeployTo\ | Structure with additional properties for the environment specified. `` refers to the GitHub environment name. The structure can contain the following properties:
**EnvironmentType** = specifies the type of environment. The environment type can be used to invoke a custom deployment. (Default SaaS)
**EnvironmentName** = specifies the "real" name of the environment if it differs from the GitHub environment.
**Branches** = an array of branch patterns, which are allowed to deploy to this environment. These branches can also be defined under the environment in GitHub settings and both settings are honored. If neither setting is defined, the default is the **main** branch only.
**Projects** = In multi-project repositories, this property can be a comma separated list of project patterns to deploy to this environment. (Default \*)
**DependencyInstallMode** = Determines how dependencies are deployed if `GenerateDependencyArtifact` is true. Default value is `install` to install dependencies if not already installed. Other values are `ignore` for ignoring dependencies and `upgrade` or `forceUpgrade` for upgrading dependencies.
**includeTestAppsInSandboxEnvironment** = deploys test apps and their dependencies if the environment type is sandbox (Default is `false`)
**excludeAppIds** = array of app ids to exclude from deployment. (Default is `[]`)
**Scope** = Determines the mechanism for deployment to the environment (Dev or PTE). If not specified, AL-Go for GitHub will always use the Dev Scope for AppSource Apps, but also for PTEs when deploying to sandbox environments when impersonation (refreshtoken) is used for authentication. Scope PTE deployment to BC 30 or later requires additional [deployment authorization](../Workshop/ContinuousDeployment.md#pte-deployment-authorization).
**SyncMode** = ForceSync if deployment to this environment should happen with ForceSync, else Add. If deploying to the development endpoint you can also specify Development or Clean. (Default Add)
**unpublishOldVersions** = Optional boolean (`true` or `false`, default `false`), honored by the standard Deploy action in CI/CD and Publish To Environment for both templates after successful Scope PTE publishing. On BC 25.4 through 29, unpublishes only selected apps' uninstalled versions older than the currently installed version. On BC 30 and later, Business Central manages cleanup in the background, which may be delayed; timing and retained versions are not guaranteed. This setting does not control background cleanup: AL-Go warns and skips explicit unpublish without failing deployment. Cleanup failures are warnings; does not apply to Scope Dev or custom deployments.
**BuildMode** = specifies which buildMode to use for the deployment. Default is to use the Default buildMode
**ContinuousDeployment** = true if this environment should be used for continuous deployment, else false. (Default: AL-Go will continuously deploy to sandbox environments or environments, which doesn't end in (PROD) or (FAT)
**runs-on** = specifies which runner to use when deploying to this environment. (Default is settings.runs-on)
**shell** = specifies which shell to use when deploying to this environment, pwsh or powershell. (Default is settings.shell)
**companyId** = Company Id from Business Central (for PowerPlatform connection)
**ppEnvironmentUrl** = Url of the PowerPlatform environment to deploy to
| | alDoc | Structure with properties for the aldoc reference document generation. The structure can contain the following properties:
**continuousDeployment** = Determines if reference documentation will be deployed continuously as part of CI/CD. You can run the **Deploy Reference Documentation** workflow to deploy manually or on a schedule. (Default false)
**deployToGitHubPages** = Determines whether or not the reference documentation site should be deployed to GitHub Pages for the repository. In order to deploy to GitHub Pages, GitHub Pages must be enabled and set to GitHub Actuibs. (Default true)
**maxReleases** = Maximum number of releases to include in the reference documentation. (Default 3)
**groupByProject** = Determines whether projects in multi-project repositories are used as folders in reference documentation
**includeProjects** = An array of projects to include in the reference documentation. (Default all)
**excludeProjects** = An array of projects to exclude in the reference documentation. (Default none)
**header** = Header for the documentation site. (Default: Documentation for...)
**footer** = Footer for the documentation site. (Default: Made with...)
**defaultIndexMD** = Markdown for the landing page of the documentation site. (Default: Reference documentation...)
**defaultReleaseMD** = Markdown for the landing page of the release sites. (Default: Release reference documentation...)
*Note that in header, footer, defaultIndexMD and defaultReleaseMD you can use the following placeholders: {REPOSITORY}, {VERSION}, {INDEXTEMPLATERELATIVEPATH}, {RELEASENOTES}* | | useProjectDependencies | Determines whether your projects are built using a multi-stage built workflow or single stage. After setting useProjectDependencies to true, you need to run Update AL-Go System Files and your workflows including a build job will change to have multiple build jobs, depending on each other. The number of build jobs will be determined by the dependency depth in your projects.
You can change dependencies between your projects, but if the dependency **depth** changes, AL-Go will warn you that updates for your AL-Go System Files are available and you will need to run the workflow. | | CICDPushBranches | CICDPushBranches can be specified as an array of branches, which triggers a CI/CD workflow on commit. You need to run the Update AL-Go System Files workflow for the change to take effect.
Default is [ "main", "release/\*", "feature/\*" ]

**Supported release branch naming formats:**
When using release branches, AL-Go supports various naming conventions for matching previous releases during upgrade testing. The following formats are recognized:
• `releases/26` - matches releases with major version 26
• `releases/26.x` - matches releases with major version 26
• `releases/26x` - matches releases with major version 26
• `releases/v26` - matches releases with major version 26
• `releases/v26.x` - matches releases with major version 26
• `releases/v26x` - matches releases with major version 26
• `releases/26.3` - matches releases with major.minor version 26.3
The same patterns work with the singular `release/` prefix. | diff --git a/Tests/Deploy.Action.Test.ps1 b/Tests/Deploy.Action.Test.ps1 index f1517cd820..62a990042d 100644 --- a/Tests/Deploy.Action.Test.ps1 +++ b/Tests/Deploy.Action.Test.ps1 @@ -55,9 +55,11 @@ Describe "Deploy Action Tests" { Mock GetAppsAndDependenciesFromArtifacts { return @('/tmp/app1.app'), @() } Mock Sort-AppFilesByDependencies { } Mock CheckInstalledApps { } - Mock Publish-PerTenantExtensionApps { $script:publishPTECalled = $true } + Mock PublishPTEApps { $script:publishPTECalled = $true } + Mock Publish-PerTenantExtensionApps { throw "The action must use the shared AL-Go publisher" } Mock Publish-BcContainerApp { } Mock UnpublishOldAppVersions { $script:unpublishAfterPublish = $script:publishPTECalled } + Mock OutputWarning { } } AfterEach { @@ -69,7 +71,7 @@ Describe "Deploy Action Tests" { It 'Invokes cleanup after PTE publish when unpublishOldVersions is true' { InvokeDeploy -deploymentSettings @{ "EnvironmentType" = "SaaS"; "EnvironmentName" = "test"; "Branches" = @(); "Projects" = "*"; "DependencyInstallMode" = "ignore"; "SyncMode" = $null; "Scope" = "PTE"; "continuousDeployment" = $true; "includeTestAppsInSandboxEnvironment" = $false; "excludeAppIds" = @(); "unpublishOldVersions" = $true } - Assert-MockCalled Publish-PerTenantExtensionApps -Exactly 1 + Assert-MockCalled PublishPTEApps -Exactly 1 Assert-MockCalled UnpublishOldAppVersions -Exactly 1 $script:unpublishAfterPublish | Should -BeTrue } @@ -77,7 +79,48 @@ Describe "Deploy Action Tests" { It 'Does not invoke cleanup when unpublishOldVersions is not set (default)' { InvokeDeploy -deploymentSettings @{ "EnvironmentType" = "SaaS"; "EnvironmentName" = "test"; "Branches" = @(); "Projects" = "*"; "DependencyInstallMode" = "ignore"; "SyncMode" = $null; "Scope" = "PTE"; "continuousDeployment" = $true; "includeTestAppsInSandboxEnvironment" = $false; "excludeAppIds" = @() } - Assert-MockCalled Publish-PerTenantExtensionApps -Exactly 1 + Assert-MockCalled PublishPTEApps -Exactly 1 + Assert-MockCalled UnpublishOldAppVersions -Times 0 + Assert-MockCalled OutputWarning -Times 0 + } + + It 'Does not invoke cleanup or warn when unpublishOldVersions is false' { + InvokeDeploy -deploymentSettings @{ "EnvironmentType" = "SaaS"; "EnvironmentName" = "test"; "Branches" = @(); "Projects" = "*"; "DependencyInstallMode" = "ignore"; "SyncMode" = $null; "Scope" = "PTE"; "continuousDeployment" = $true; "includeTestAppsInSandboxEnvironment" = $false; "excludeAppIds" = @(); "unpublishOldVersions" = $false } + + Assert-MockCalled PublishPTEApps -Exactly 1 + Assert-MockCalled UnpublishOldAppVersions -Times 0 + Assert-MockCalled OutputWarning -Times 0 + } + + It 'Passes primary apps to the shared publisher using ' -TestCases @( + @{ syncMode = 'Add'; helperSyncMode = 'Add' }, + @{ syncMode = 'ForceSync'; helperSyncMode = 'Force' } + ) { + param($syncMode, $helperSyncMode) + $expectedSyncMode = $helperSyncMode + Mock PublishPTEApps { + $PesterBoundParameters.Keys | Should -Not -Contain 'schedule' + } + + InvokeDeploy -deploymentSettings @{ "EnvironmentType" = "SaaS"; "EnvironmentName" = "test"; "Branches" = @(); "Projects" = "*"; "DependencyInstallMode" = "ignore"; "SyncMode" = $syncMode; "Scope" = "PTE"; "continuousDeployment" = $true; "includeTestAppsInSandboxEnvironment" = $false; "excludeAppIds" = @() } + + Assert-MockCalled PublishPTEApps -Exactly 1 -ParameterFilter { + $environment -eq 'test' -and $bcAuthContext.tenantId -eq 'test-tenant' -and + $appFiles.Count -eq 1 -and $appFiles[0] -eq '/tmp/app1.app' -and $SchemaSyncMode -eq $expectedSyncMode + } + Assert-MockCalled Publish-BcContainerApp -Times 0 + Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + } + + It 'Does not retry publishing or invoke cleanup after shared publishing fails' { + Mock PublishPTEApps { throw "Publishing authorization failed" } + Mock OutputError { param($message) throw $message } + + { InvokeDeploy -deploymentSettings @{ "EnvironmentType" = "SaaS"; "EnvironmentName" = "test"; "Branches" = @(); "Projects" = "*"; "DependencyInstallMode" = "ignore"; "SyncMode" = $null; "Scope" = "PTE"; "continuousDeployment" = $true; "includeTestAppsInSandboxEnvironment" = $false; "excludeAppIds" = @(); "unpublishOldVersions" = $true } } | Should -Throw "*Publishing authorization failed*" + + Assert-MockCalled PublishPTEApps -Exactly 1 + Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + Assert-MockCalled Publish-BcContainerApp -Times 0 Assert-MockCalled UnpublishOldAppVersions -Times 0 } @@ -85,6 +128,7 @@ Describe "Deploy Action Tests" { InvokeDeploy -deploymentSettings @{ "EnvironmentType" = "SaaS"; "EnvironmentName" = "test"; "Branches" = @(); "Projects" = "*"; "DependencyInstallMode" = "ignore"; "SyncMode" = $null; "Scope" = "Dev"; "continuousDeployment" = $true; "includeTestAppsInSandboxEnvironment" = $false; "excludeAppIds" = @(); "unpublishOldVersions" = $true } Assert-MockCalled Publish-BcContainerApp -Exactly 1 + Assert-MockCalled PublishPTEApps -Times 0 Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 Assert-MockCalled UnpublishOldAppVersions -Times 0 } diff --git a/Tests/Deploy.Module.Test.ps1 b/Tests/Deploy.Module.Test.ps1 index c1be130086..fd09d2707f 100644 --- a/Tests/Deploy.Module.Test.ps1 +++ b/Tests/Deploy.Module.Test.ps1 @@ -161,7 +161,8 @@ InModuleScope Deploy { # Allows testing of private functions } Mock Sort-AppFilesByDependencies { } Mock Install-BcAppFromAppSource { return $true } - Mock Publish-PerTenantExtensionApps { return $true } + Mock PublishPTEApps { return $true } + Mock Publish-PerTenantExtensionApps { throw "Dependencies must use the shared AL-Go publisher" } Mock Write-Host { } } @@ -785,7 +786,7 @@ InModuleScope Deploy { # Allows testing of private functions Assert-MockCalled Install-BcAppFromAppSource -Exactly 1 -ParameterFilter { $appId -eq $script:appSourceApp1Id -and $acceptIsvEula -eq $true -and $installOrUpdateNeededDependencies -eq $true } - Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + Assert-MockCalled PublishPTEApps -Times 0 } It 'Publishes PTE apps when upgrade needed' { @@ -804,9 +805,27 @@ InModuleScope Deploy { # Allows testing of private functions InstallOrUpgradeApps -bcAuthContext $bcAuthContext -environment $environment -apps $apps -installMode $installMode Assert-MockCalled Install-BcAppFromAppSource -Times 0 - Assert-MockCalled Publish-PerTenantExtensionApps -Exactly 1 -ParameterFilter { - $appFiles.Count -eq 1 -and $SchemaSyncMode -eq "Add" + Assert-MockCalled PublishPTEApps -Exactly 1 -ParameterFilter { + $appFiles.Count -eq 1 -and $SchemaSyncMode -eq "Add" -and + $bcAuthContext.tenantId -eq "test-tenant" -and $environment -eq "test-env" + } + } + + It 'Delegates PTE dependency publishing without requesting deferred installation' { + Mock Get-ChildItem { + param($Path) + return @(@{ FullName = Join-Path $Path "PTEApp.app" }) + } + Mock PublishPTEApps { + $PesterBoundParameters.Keys | Should -Not -Contain 'schedule' + } + + InstallOrUpgradeApps -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -apps @("PTEApp.app") -installMode "upgrade" + + Assert-MockCalled PublishPTEApps -Exactly 1 -ParameterFilter { + $appFiles.Count -eq 1 -and $appFiles[0] -like "*PTEApp.app" -and $SchemaSyncMode -eq "Add" } + Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 } It 'Uses Force schema sync mode when installMode is ForceUpgrade' { @@ -824,11 +843,26 @@ InModuleScope Deploy { # Allows testing of private functions InstallOrUpgradeApps -bcAuthContext $bcAuthContext -environment $environment -apps $apps -installMode $installMode - Assert-MockCalled Publish-PerTenantExtensionApps -Exactly 1 -ParameterFilter { + Assert-MockCalled PublishPTEApps -Exactly 1 -ParameterFilter { $SchemaSyncMode -eq "Force" } } + It 'Propagates shared publishing errors without retrying and cleans up temporary files' { + Mock Get-ChildItem { + param($Path) + return @(@{ FullName = Join-Path $Path "PTEApp.app" }) + } + Mock PublishPTEApps { throw "Publishing authorization failed" } + + { InstallOrUpgradeApps -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -apps @("PTEApp.app") -installMode "upgrade" } | Should -Throw "*Publishing authorization failed*" + + Assert-MockCalled PublishPTEApps -Exactly 1 + Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + Assert-MockCalled Install-BcAppFromAppSource -Times 0 + Assert-MockCalled Remove-Item -Exactly 1 -ParameterFilter { $Force -and $Recurse } + } + It 'Skips AppSource apps published in Dev scope when upgrade needed' { Mock Get-ChildItem { param($Path) @@ -864,7 +898,7 @@ InModuleScope Deploy { # Allows testing of private functions $appId -eq $script:appSourceApp1Id } # Should publish PTE apps - Assert-MockCalled Publish-PerTenantExtensionApps -Exactly 1 -ParameterFilter { + Assert-MockCalled PublishPTEApps -Exactly 1 -ParameterFilter { $appFiles.Count -eq 1 } } @@ -878,7 +912,7 @@ InModuleScope Deploy { # Allows testing of private functions InstallOrUpgradeApps -bcAuthContext $bcAuthContext -environment $environment -apps $apps -installMode $installMode Assert-MockCalled Install-BcAppFromAppSource -Times 0 - Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + Assert-MockCalled PublishPTEApps -Times 0 } It 'Handles empty apps array' { @@ -897,7 +931,7 @@ InModuleScope Deploy { # Allows testing of private functions $appFiles.Count -eq 0 } Assert-MockCalled Install-BcAppFromAppSource -Times 0 - Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + Assert-MockCalled PublishPTEApps -Times 0 } It 'Refreshes installed apps list after AppSource installation' { diff --git a/Tests/PublishPTEApps.Test.ps1 b/Tests/PublishPTEApps.Test.ps1 new file mode 100644 index 0000000000..803af99d3b --- /dev/null +++ b/Tests/PublishPTEApps.Test.ps1 @@ -0,0 +1,500 @@ +Import-Module (Join-Path $PSScriptRoot '../Actions/Deploy/Deploy.psm1') -Force + +InModuleScope Deploy { + Describe "AL-Go PTE publishing" { + BeforeAll { + . (Join-Path $PSScriptRoot '../Actions/AL-Go-Helper.ps1') + DownloadAndImportBcContainerHelper -baseFolder ([System.IO.Path]::GetTempPath()) + Add-Type -AssemblyName System.Net.Http + $script:originalConfig = $bcContainerHelperConfig + } + + AfterAll { + $script:bcContainerHelperConfig = $script:originalConfig + } + + BeforeEach { + $script:bcContainerHelperConfig = @{ apiBaseUrl = 'https://api.example.test/' } + $script:application = @{ + id = 'application'; displayName = 'Application'; isInstalled = $true + versionMajor = 30; versionMinor = 0; versionBuild = 0; versionRevision = 0 + } + $script:extensions = @($script:application) + Mock Write-Host { } + Mock OutputWarning { } + Mock Renew-BcAuthContext { return @{ AccessToken = 'renewed-test-token' } } + Mock Invoke-RestMethod { throw "Unexpected HTTP request" } + Mock Get-BcInstalledExtensions { return $script:extensions } + Mock Publish-PerTenantExtensionApps { } + } + + Context "Shared router" { + BeforeEach { + Mock NewTemporaryFolder { return 'test-app-folder' } + Mock Copy-AppFilesToFolder { return @('App.app') } + Mock Sort-AppFilesByDependencies { return @('App.app') } + Mock Remove-Item { } + Mock Get-AppJsonFromAppFile { return @{ id = 'app-1'; name = 'App'; version = '2.0.0.0' } } + Mock InvokePTEUpload { return @{ id = 'operation-1'; appId = 'app-1'; status = 'running' } } + Mock WaitPTEOperation { } + } + + It 'Uses only the released helper for BC and preserves Force and immediate defaults' -TestCases @( + @{ major = 28 }, @{ major = 29 } + ) { + param($major) + $script:application.versionMajor = $major + $script:application.versionMinor = 9 + $script:application.versionBuild = 99999 + $script:application.versionRevision = 99999 + Mock Publish-PerTenantExtensionApps { + $PesterBoundParameters.Keys | Should -Not -Contain 'schedule' + $PesterBoundParameters.Keys | Should -Not -Contain 'applicationFamily' + } + + PublishPTEApps -bcAuthContext @{ tenantId = 'tenant' } -environment 'Sandbox' -appFiles @('Apps.zip') -schemaSyncMode Force + + Assert-MockCalled Publish-PerTenantExtensionApps -Exactly 1 -ParameterFilter { + $environment -eq 'Sandbox' -and $bcAuthContext.tenantId -eq 'tenant' -and + $appFiles[0] -eq 'Apps.zip' -and $schemaSyncMode -eq 'Force' + } + Assert-MockCalled InvokePTEUpload -Times 0 + Assert-MockCalled NewTemporaryFolder -Times 0 + Assert-MockCalled Invoke-RestMethod -Times 0 + } + + It 'Keeps API implementation helpers private and exposes the shared publisher' { + $exports = (Get-Module Deploy).ExportedFunctions.Keys + $exports | Should -Contain 'PublishPTEApps' + $exports | Should -Not -Contain 'InvokePTEUpload' + $exports | Should -Not -Contain 'WaitPTEOperation' + $exports | Should -Not -Contain 'GetPTEApplicationVersion' + } + + It 'Uses AL-Go Admin Center publishing for BC ' -TestCases @( + @{ major = 30 }, @{ major = 31 } + ) { + param($major) + $script:application.versionMajor = $major + + PublishPTEApps -bcAuthContext @{} -environment 'Sandbox' -appFiles @('Apps.zip') + + Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + Assert-MockCalled Copy-AppFilesToFolder -Exactly 1 -ParameterFilter { $appFiles[0] -eq 'Apps.zip' } + Assert-MockCalled Sort-AppFilesByDependencies -Exactly 1 -ParameterFilter { $excludeRuntimePackages } + Assert-MockCalled InvokePTEUpload -Exactly 1 -ParameterFilter { $syncMode -eq 'Add' } + Assert-MockCalled WaitPTEOperation -Exactly 1 -ParameterFilter { $appId -eq 'app-1' } + Assert-MockCalled Remove-Item -Exactly 1 -ParameterFilter { $LiteralPath -eq 'test-app-folder' -and $Recurse -and $Force } + } + + It 'Uses the configured base URL and escapes environment and application family' { + PublishPTEApps -bcAuthContext @{} -environment 'QA / East' -applicationFamily 'Family / US' -appFiles @('App.app') -schemaSyncMode Force + + Assert-MockCalled InvokePTEUpload -Exactly 1 -ParameterFilter { + $uri -eq 'https://api.example.test/admin/v2.29/applications/Family%20%2F%20US/environments/QA%20%2F%20East/apps/pteInstall' -and + $syncMode -eq 'ForceSync' + } + } + + It 'Does not mistake the package version for the target BC version' { + Mock Get-AppJsonFromAppFile { return @{ id = 'app-1'; name = 'App'; version = '28.0.0.0' } } + + PublishPTEApps -bcAuthContext @{} -environment 'Sandbox' -appFiles @('App.app') + + Assert-MockCalled InvokePTEUpload -Exactly 1 + Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + } + + It 'Fails explicitly before any publishing when the target version is ' -TestCases @( + @{ problem = 'missing' }, @{ problem = 'incomplete' }, @{ problem = 'invalid' }, @{ problem = 'ambiguous' } + ) { + param($problem) + switch ($problem) { + missing { $script:extensions = @() } + incomplete { $script:application.Remove('versionMinor') } + invalid { $script:application.versionMajor = 'unknown' } + ambiguous { $script:extensions += $script:application.Clone() } + } + + { PublishPTEApps -bcAuthContext @{} -environment 'Sandbox' -appFiles @('App.app') } | Should -Throw '*Could not determine the Business Central version*' + + Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + Assert-MockCalled InvokePTEUpload -Times 0 + } + + It 'Ignores uninstalled Application records when detecting the target version' { + $oldApplication = $script:application.Clone() + $oldApplication.isInstalled = $false + $oldApplication.versionMajor = 28 + $script:extensions = @($oldApplication, [PSCustomObject]$script:application) + + PublishPTEApps -bcAuthContext @{} -environment 'Sandbox' -appFiles @('App.app') + + Assert-MockCalled InvokePTEUpload -Exactly 1 + Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + } + + It 'Skips an installed version but not a newer uninstalled package' -TestCases @( + @{ version = 2 }, @{ version = 3 } + ) { + param($version) + $script:extensions += @{ + id = 'app-1'; displayName = 'App'; isInstalled = $true + versionMajor = $version; versionMinor = 0; versionBuild = 0; versionRevision = 0 + } + + PublishPTEApps -bcAuthContext @{} -environment 'Sandbox' -appFiles @('App.app') + + Assert-MockCalled InvokePTEUpload -Times 0 + Assert-MockCalled WaitPTEOperation -Times 0 + } + + It 'Upgrades an older installed app even if an uninstalled newer package is present' { + $script:extensions += @( + @{ id = 'app-1'; displayName = 'App'; isInstalled = $true; versionMajor = 1; versionMinor = 0; versionBuild = 0; versionRevision = 0 }, + @{ id = 'app-1'; displayName = 'App'; isInstalled = $false; versionMajor = 3; versionMinor = 0; versionBuild = 0; versionRevision = 0 } + ) + + PublishPTEApps -bcAuthContext @{} -environment 'Sandbox' -appFiles @('App.app') + + Assert-MockCalled InvokePTEUpload -Exactly 1 + Assert-MockCalled WaitPTEOperation -Exactly 1 + } + + It 'Completes each dependency before uploading its dependent app and skips runtime packages' { + Mock Copy-AppFilesToFolder { return @('Dependent.app', 'Runtime.app', 'Dependency.app') } + Mock Sort-AppFilesByDependencies { return @('Dependency.app', 'Dependent.app') } + Mock Get-AppJsonFromAppFile { + param($appFile) + return @{ id = $appFile; name = $appFile; version = '2.0.0.0' } + } + $script:events = @() + Mock InvokePTEUpload { + param($appFile) + $script:events += "upload:$appFile" + return @{ id = "operation-$appFile"; appId = $appFile; status = 'running' } + } + Mock WaitPTEOperation { + param($appId) + $script:events += "complete:$appId" + } + + PublishPTEApps -bcAuthContext @{} -environment 'Sandbox' -appFiles @('Apps.zip') + + ($script:events -join ',') | Should -Be 'upload:Dependency.app,complete:Dependency.app,upload:Dependent.app,complete:Dependent.app' + Assert-MockCalled Sort-AppFilesByDependencies -Exactly 1 -ParameterFilter { $excludeRuntimePackages -and $appFiles.Count -eq 3 } + Assert-MockCalled InvokePTEUpload -Times 0 -ParameterFilter { $appFile -eq 'Runtime.app' } + } + + It 'Remembers completed installs when another package for the same app is encountered' { + Mock Sort-AppFilesByDependencies { return @('App.app', 'Duplicate.app') } + + PublishPTEApps -bcAuthContext @{} -environment 'Sandbox' -appFiles @('App.app', 'Duplicate.app') + + Assert-MockCalled InvokePTEUpload -Exactly 1 + Assert-MockCalled WaitPTEOperation -Exactly 1 + } + + It 'Does not fall back or continue after failure' -TestCases @( + @{ step = 'upload' }, @{ step = 'polling' }, @{ step = 'authorization' } + ) { + param($step) + switch ($step) { + upload { Mock InvokePTEUpload { throw "Package upload failed" } } + polling { Mock WaitPTEOperation { throw "Installation failed" } } + authorization { Mock InvokePTEUpload { throw "403 Forbidden" } } + } + + { PublishPTEApps -bcAuthContext @{} -environment 'Sandbox' -appFiles @('App.app') } | Should -Throw + + Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + Assert-MockCalled InvokePTEUpload -Exactly 1 + Assert-MockCalled Remove-Item -Exactly 1 + } + } + + Context "Multipart upload" { + BeforeEach { + $script:packagePath = Join-Path $TestDrive 'Binary Package.app' + $script:packageBytes = [byte[]]@(0, 1, 127, 128, 255, 13, 10, 65, 66) + [System.IO.File]::WriteAllBytes($script:packagePath, $script:packageBytes) + } + + It 'Sends actual multipart fields and unchanged binary bytes using ' -TestCases @( + @{ syncMode = 'Add' }, @{ syncMode = 'ForceSync' } + ) { + param($syncMode) + $expectedSyncMode = $syncMode + Mock Invoke-RestMethod { + param($Body, $ContentType) + ,$Body | Should -BeOfType [byte[]] + $ContentType | Should -BeLike 'multipart/form-data; boundary=*' + $wireText = [System.Text.Encoding]::GetEncoding(28591).GetString($Body) + $wireText | Should -Match 'name="?deploymentSchedule"?\r\n\r\nImmediate\r\n' + $wireText | Should -Match ('name="?syncMode"?\r\n\r\n' + $expectedSyncMode + '\r\n') + $wireText | Should -Match 'name="?acceptIsvEula"?\r\n\r\ntrue\r\n' + $wireText | Should -Match 'name="?installOrUpdateNeededDependencies"?\r\n\r\nfalse\r\n' + $wireText | Should -Match 'name="?extensionFile"?; filename="Binary Package.app"' + $wireText | Should -Match 'Content-Type: application/octet-stream' + $wireText.Contains([System.Text.Encoding]::GetEncoding(28591).GetString($script:packageBytes)) | Should -BeTrue + return [PSCustomObject]@{ id = 'operation-1'; appId = 'app-1'; status = 'running' } + } + + $result = InvokePTEUpload -bcAuthContext @{} -uri 'https://api.example.test/apps/pteInstall' -appFile $script:packagePath -syncMode $syncMode + + $result.id | Should -Be 'operation-1' + Assert-MockCalled Renew-BcAuthContext -Exactly 1 + Assert-MockCalled Invoke-RestMethod -Exactly 1 -ParameterFilter { + $Method -eq 'Post' -and $TimeoutSec -eq 300 -and $ErrorAction -eq 'Stop' -and + $Headers.Authorization -eq 'Bearer renewed-test-token' + } + $stream = [System.IO.File]::Open($script:packagePath, 'Open', 'ReadWrite', 'None') + $stream.Dispose() + } + + It 'Disposes streams and never replays an ambiguous upload failure' { + Mock Invoke-RestMethod { throw [System.TimeoutException]::new('Upload timed out') } + + { InvokePTEUpload -bcAuthContext @{} -uri 'https://api.example.test/apps/pteInstall' -appFile $script:packagePath -syncMode Add } | Should -Throw '*Upload timed out*' + + Assert-MockCalled Invoke-RestMethod -Exactly 1 + $stream = [System.IO.File]::Open($script:packagePath, 'Open', 'ReadWrite', 'None') + $stream.Dispose() + } + + It 'Fails before upload if renewing authentication fails' { + Mock Renew-BcAuthContext { throw 'Authentication failed' } + + { InvokePTEUpload -bcAuthContext @{} -uri 'https://api.example.test/apps/pteInstall' -appFile $script:packagePath -syncMode Add } | Should -Throw '*Authentication failed*' + + Assert-MockCalled Invoke-RestMethod -Times 0 + } + + It 'Rejects a renewal response without an access token' { + Mock Renew-BcAuthContext { return @{} } + + { InvokePTEUpload -bcAuthContext @{} -uri 'https://api.example.test/apps/pteInstall' -appFile $script:packagePath -syncMode Add } | Should -Throw '*Authentication failed*' + + Assert-MockCalled Invoke-RestMethod -Times 0 + } + + It 'Preserves service upload diagnostics without replaying the upload' { + Mock Invoke-RestMethod { + $record = [System.Management.Automation.ErrorRecord]::new([System.Exception]::new('400 Bad Request'), 'InvalidPackage', 'InvalidData', $null) + $record.ErrorDetails = [System.Management.Automation.ErrorDetails]::new('Service rejected the package signature') + throw $record + } + + { InvokePTEUpload -bcAuthContext @{} -uri 'https://api.example.test/apps/pteInstall' -appFile $script:packagePath -syncMode Add } | Should -Throw '*400 Bad Request*Service rejected the package signature*not be replayed*' + + Assert-MockCalled Invoke-RestMethod -Exactly 1 + } + } + + Context "Exact operation polling" { + BeforeEach { + $script:operation = [PSCustomObject]@{ id = 'operation / 1'; appId = 'app-1'; status = 'running'; scheduleKind = 'immediate' } + Mock Start-Sleep { } + Mock Invoke-RestMethod { return [PSCustomObject]@{ id = 'operation / 1'; appId = 'app-1'; status = 'succeeded' } } + } + + It 'Accepts immediate success without polling' { + $script:operation.status = 'succeeded' + + WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' + + Assert-MockCalled Invoke-RestMethod -Times 0 + } + + It 'Polls only the exact returned operation and renews authentication' { + WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' + + Assert-MockCalled Invoke-RestMethod -Exactly 1 -ParameterFilter { + $Method -eq 'Get' -and $Uri -eq 'https://api.example.test/apps/app-1/operations/operation%20%2F%201' -and + $Headers.Authorization -eq 'Bearer renewed-test-token' -and $TimeoutSec -le 30 + } + Assert-MockCalled Renew-BcAuthContext -Exactly 1 + Assert-MockCalled Start-Sleep -Exactly 1 -ParameterFilter { $Milliseconds -eq 30000 } + } + + It 'Uses the requested shorter polling interval without changing the operation endpoint' { + WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' -pollIntervalSeconds 5 + + Assert-MockCalled Start-Sleep -Exactly 1 -ParameterFilter { $Milliseconds -eq 5000 } + Assert-MockCalled Invoke-RestMethod -Exactly 1 -ParameterFilter { + $Method -eq 'Get' -and $Uri -eq 'https://api.example.test/apps/app-1/operations/operation%20%2F%201' + } + } + + It 'Does not count scheduled immediate installation as success' { + $script:operation.status = 'scheduled' + + WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' + + Assert-MockCalled Invoke-RestMethod -Exactly 1 + } + + It 'Fails explicitly for terminal or unknown status ' -TestCases @( + @{ status = 'failed' }, @{ status = 'canceled' }, @{ status = 'skipped' }, @{ status = 'unknown' }, @{ status = '' } + ) { + param($status) + $script:operation.status = $status + $script:operation | Add-Member -NotePropertyName errorMessage -NotePropertyValue 'Service diagnostic' + + { WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' } | Should -Throw '*operation / 1*Service diagnostic*' + + Assert-MockCalled Invoke-RestMethod -Times 0 + } + + It 'Rejects upload responses with missing or mismatched ' -TestCases @( + @{ detail = 'operationId' }, @{ detail = 'appId' }, @{ detail = 'wrongApp' }, @{ detail = 'empty' } + ) { + param($detail) + switch ($detail) { + operationId { $script:operation.id = '' } + appId { $script:operation.PSObject.Properties.Remove('appId') } + wrongApp { $script:operation.appId = 'another-app' } + empty { $script:operation = $null } + } + + { WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' } | Should -Throw '*missing or mismatched*' + + Assert-MockCalled Invoke-RestMethod -Times 0 + } + + It 'Rejects missing or unrelated status response ' -TestCases @( + @{ response = 'empty' }, @{ response = 'anotherOperation' }, @{ response = 'anotherApp' }, @{ response = 'collection' } + ) { + param($response) + $script:responseKind = $response + Mock Invoke-RestMethod { + switch ($script:responseKind) { + empty { return $null } + anotherOperation { return [PSCustomObject]@{ id = 'other'; status = 'succeeded' } } + anotherApp { return [PSCustomObject]@{ id = 'operation / 1'; appId = 'other'; status = 'succeeded' } } + collection { return [PSCustomObject]@{ value = @(@{ id = 'operation / 1'; status = 'succeeded' }) } } + } + } + + { WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' } | Should -Throw '*Missing or mismatched PTE operation*' + } + + It 'Retries transient status failures and then succeeds without any write' { + $script:reads = 0 + Mock Invoke-RestMethod { + $script:reads++ + if ($script:reads -lt 3) { throw [System.TimeoutException]::new('Temporary failure') } + return [PSCustomObject]@{ id = 'operation / 1'; appId = 'app-1'; status = 'succeeded' } + } + + WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' + + Assert-MockCalled Invoke-RestMethod -Exactly 3 -ParameterFilter { $Method -eq 'Get' } + Assert-MockCalled Invoke-RestMethod -Times 0 -ParameterFilter { $Method -ne 'Get' } + Assert-MockCalled OutputWarning -Exactly 2 + } + + It 'Stops after five consecutive transient retries' { + Mock Invoke-RestMethod { throw [System.TimeoutException]::new('Temporary failure') } + + { WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' } | Should -Throw '*Unable to read PTE operation*Temporary failure*' + + Assert-MockCalled Invoke-RestMethod -Exactly 6 + Assert-MockCalled OutputWarning -Exactly 5 + } + + It 'Retries HTTP but never HTTP authorization failures' -TestCases @( + @{ statusCode = 408; retry = $true }, + @{ statusCode = 429; retry = $true }, + @{ statusCode = 500; retry = $true }, + @{ statusCode = 502; retry = $true }, + @{ statusCode = 503; retry = $true }, + @{ statusCode = 504; retry = $true }, + @{ statusCode = 401; retry = $false }, + @{ statusCode = 403; retry = $false } + ) { + param($statusCode, $retry) + $script:httpStatus = $statusCode + $script:reads = 0 + Mock Invoke-RestMethod { + $script:reads++ + if ($script:reads -eq 1) { + $exception = [System.Exception]::new("HTTP $script:httpStatus") + $exception | Add-Member -NotePropertyName Response -NotePropertyValue ([PSCustomObject]@{ StatusCode = $script:httpStatus }) + throw $exception + } + return [PSCustomObject]@{ id = 'operation / 1'; appId = 'app-1'; status = 'succeeded' } + } + + if ($retry) { + WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' + Assert-MockCalled Invoke-RestMethod -Exactly 2 + Assert-MockCalled OutputWarning -Exactly 1 + } + else { + { WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' } | Should -Throw '*Unable to read PTE operation*' + Assert-MockCalled Invoke-RestMethod -Exactly 1 + Assert-MockCalled OutputWarning -Times 0 + } + } + + It 'Resets the consecutive retry budget after a successful running status read' { + $script:reads = 0 + Mock Invoke-RestMethod { + $script:reads++ + if ($script:reads -eq 6) { return [PSCustomObject]@{ id = 'operation / 1'; status = 'running' } } + if ($script:reads -eq 12) { return [PSCustomObject]@{ id = 'operation / 1'; status = 'succeeded' } } + throw [System.TimeoutException]::new('Transient timeout') + } + + WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' + + Assert-MockCalled Invoke-RestMethod -Exactly 12 + Assert-MockCalled OutputWarning -Exactly 10 + } + + It 'Does not retry non-transient errors or renew failures' { + Mock Renew-BcAuthContext { throw '403 Forbidden' } + + { WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' } | Should -Throw '*403 Forbidden*' + + Assert-MockCalled Invoke-RestMethod -Times 0 + Assert-MockCalled OutputWarning -Times 0 + } + + It 'Does not retry an authorization status-read failure' { + Mock Invoke-RestMethod { throw '401 Unauthorized' } + + { WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' } | Should -Throw '*Unable to read PTE operation*401 Unauthorized*' + + Assert-MockCalled Invoke-RestMethod -Exactly 1 + Assert-MockCalled OutputWarning -Times 0 + } + + It 'Bounds status attempts even when the operation remains scheduled' { + Mock Invoke-RestMethod { return [PSCustomObject]@{ id = 'operation / 1'; status = 'scheduled' } } + + { WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' -maxStatusRequests 2 } | Should -Throw '*Timed out*2 status requests*' + + Assert-MockCalled Invoke-RestMethod -Exactly 2 + } + + It 'Limits the default immediate polling to 360 status requests' { + Mock Invoke-RestMethod { return [PSCustomObject]@{ id = 'operation / 1'; status = 'running' } } + + { WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' } | Should -Throw '*Timed out*1800 seconds or 360 status requests*' + + Assert-MockCalled Invoke-RestMethod -Exactly 360 + } + + It 'Honors the elapsed timeout before another status request' { + Mock Start-Sleep { [System.Threading.Thread]::Sleep(1100) } + + { WaitPTEOperation -bcAuthContext @{} -appsUri 'https://api.example.test/apps' -operation $script:operation -appId 'app-1' -timeoutSeconds 1 } | Should -Throw '*Timed out*1 seconds*' + + Assert-MockCalled Invoke-RestMethod -Times 0 + Assert-MockCalled Start-Sleep -Exactly 1 -ParameterFilter { $Milliseconds -gt 0 -and $Milliseconds -le 1000 } + } + } + } +} diff --git a/Tests/RunTests.Test.ps1 b/Tests/RunTests.Test.ps1 index 8a95c02cd7..834629b878 100644 --- a/Tests/RunTests.Test.ps1 +++ b/Tests/RunTests.Test.ps1 @@ -4,16 +4,31 @@ param() $errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 -. (Join-Path -Path $PSScriptRoot -ChildPath "../Actions/AL-Go-Helper.ps1" -Resolve) - -# Stub for the BcContainerHelper function so it can be mocked within the module scope -function Get-AppJsonFromAppFile { param($appFile) } -function Get-BcContainerEventLog { param($containerName, [switch] $doNotOpen) } - -Import-Module (Join-Path $PSScriptRoot '../Actions/RunTests/RunTests.psm1' -Resolve) -DisableNameChecking -Force - Describe 'RunTests.psm1 Tests' { BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath "../Actions/AL-Go-Helper.ps1" -Resolve) + # Module-scoped mocks need globally visible targets without helper autoload. + $script:stubbedGlobalCommands = @() + $stubDefinitions = @{ + 'ConvertTo-HashTable' = ${function:ConvertTo-HashTable} + 'Get-AppJsonFromAppFile' = { + param($appFile) + throw 'Get-AppJsonFromAppFile must be mocked by the test.' + } + 'Get-BcContainerEventLog' = { + param($containerName, [switch] $doNotOpen) + throw 'Get-BcContainerEventLog must be mocked by the test.' + } + } + foreach ($commandName in $stubDefinitions.Keys) { + $functionPath = "Function:\global:$commandName" + if (-not (Test-Path -LiteralPath $functionPath)) { + Set-Item -LiteralPath $functionPath -Value $stubDefinitions[$commandName] + $script:stubbedGlobalCommands += $commandName + } + } + Import-Module (Join-Path $PSScriptRoot '../Actions/RunTests/RunTests.psm1' -Resolve) -DisableNameChecking -Force + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testCredential', Justification = 'Used in tests')] $testCredential = New-Object System.Management.Automation.PSCredential("admin", (ConvertTo-SecureString "password" -AsPlainText -Force)) $script:compiledAppMetadataByPath = @{} @@ -60,6 +75,12 @@ Describe 'RunTests.psm1 Tests' { } } + AfterAll { + foreach ($commandName in $script:stubbedGlobalCommands) { + Remove-Item "Function:\global:$commandName" -Force -ErrorAction SilentlyContinue + } + } + BeforeEach { $script:eventLogSource = Join-Path $TestDrive "$([Guid]::NewGuid()).evtx" Set-Content -Path $script:eventLogSource -Value 'post-test-events' -Encoding UTF8 diff --git a/Tests/UnpublishOldAppVersions.Test.ps1 b/Tests/UnpublishOldAppVersions.Test.ps1 index 9220ef074b..553b7da0f1 100644 --- a/Tests/UnpublishOldAppVersions.Test.ps1 +++ b/Tests/UnpublishOldAppVersions.Test.ps1 @@ -63,6 +63,50 @@ InModuleScope Deploy { # Allows testing of private functions } } + It 'Explains delayed service-managed cleanup without any writes on BC .' -TestCases @( + @{ major = 30; minor = 0 }, + @{ major = 30; minor = 1 }, + @{ major = 31; minor = 0 } + ) { + param($major, $minor) + $script:applicationExtension.versionMajor = $major + $script:applicationExtension.versionMinor = $minor + + { UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") } | Should -Not -Throw + + Assert-MockCalled OutputWarning -Exactly 1 -ParameterFilter { + $message -like "*cannot explicitly unpublish*Business Central manages cleanup in the background*may be delayed*timing and retained versions are not guaranteed*AL-Go cleanup was skipped*deployment remains successful*" + } + Assert-MockCalled Invoke-RestMethod -Times 0 -ParameterFilter { $Method -ne "Get" } + } + + It 'Preserves legacy cleanup on BC .' -TestCases @( + @{ major = 25; minor = 4 }, + @{ major = 28; minor = 9 }, + @{ major = 29; minor = 0 }, + @{ major = 29; minor = 9 } + ) { + param($major, $minor) + $script:applicationExtension.versionMajor = $major + $script:applicationExtension.versionMinor = $minor + + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled OutputWarning -Times 0 + Assert-MockCalled Invoke-RestMethod -Exactly 1 -ParameterFilter { + $Method -eq "Post" -and $Uri -like "*extensions(pkg-app1-v1)/Microsoft.NAV.unpublish" + } + } + + It 'Warns without writes when the installed Application version is unknown' { + $script:applicationExtension.isInstalled = $false + + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Could not determine the Business Central version*" } + Assert-MockCalled Invoke-RestMethod -Times 0 -ParameterFilter { $Method -ne "Get" } + } + It 'Uses the automation API v2.0 endpoint with a bearer token' { UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") diff --git a/Workshop/ContinuousDeployment.md b/Workshop/ContinuousDeployment.md index 5591a5d49f..7abc4414d1 100644 --- a/Workshop/ContinuousDeployment.md +++ b/Workshop/ContinuousDeployment.md @@ -88,6 +88,22 @@ New-BcAuthContext -clientID $clientID -clientSecret $clientSecret -tenantID $ten Paste the value from the clipboard into the "Value" field of the **AuthContext** secret for the environment and you should be good to go. +## PTE deployment authorization + +For **Scope PTE** S2S deployment to **BC 30 or later**, add the **AdminCenter.ReadWrite.All** application permission under **Dynamics 365 Business Central**, grant **tenant admin consent**, and add the client ID to **Authorized Microsoft Entra apps** in the Business Central administration center. Existing app registrations that previously deployed successfully may need this additional authorization. Keep the environment API permissions, such as **Automation.ReadWrite.All** and **API.ReadWrite.All** where required, and the Microsoft Entra application registration and permission sets inside each Business Central environment. + +With delegated authentication (impersonation), **Scope PTE** deployment to **BC 30 or later** requires appropriate **Business Central administration center access** as well as the environment permissions. The existing **AUTHCONTEXT** credential structure and the commands above remain unchanged. See the official [Business Central administration center API documentation](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/administration-center-api) for authentication and authorization details. + +These additional permissions are not required for deployment to earlier BC versions or the sandbox development endpoint (**Scope Dev**). + +## Old published versions + +On **BC 30 and later**, Business Central manages old-version cleanup in the background. Older published versions may remain after deployment until background cleanup runs. Cleanup has been observed after an overnight interval, leaving the installed version and the latest uninstalled version; this is not a guaranteed nightly schedule or retention policy. + +The optional `DeployTo.unpublishOldVersions` boolean (default `false`) does not control background cleanup. When enabled on **BC 30 and later**, AL-Go warns and skips explicit unpublish without failing deployment. No additional cleanup setting is needed. + +On **BC 25.4 through 29**, enabling the setting removes only selected apps' uninstalled versions older than their currently installed version after successful **Scope PTE** publishing. Cleanup errors remain warnings, and the setting does not apply to Scope Dev or custom deployments. + ## Custom Deployments AL-Go can also be setup for custom deployment when you want to deploy to non-SaaS environments. More about this in the advanced section.