Saas/Publish-PerTenantExtensionApps.ps1

<#
 .Synopsis
  Function for publishing PTE apps to an online tenant
 .Description
  Function for publishing PTE apps to an online tenant
  Please consult the CI/CD Workshop document at http://aka.ms/cicdhol to learn more about this function
 .Parameter clientId
  ClientID of Azure AD App for authenticating to Business Central (SecureString or String)
 .Parameter clientSecret
  ClientSecret of Azure AD App for authenticating to Business Central (SecureString or String)
 .Parameter tenantId
  TenantId of tenant in which you want to publish the Per Tenant Extension Apps
 .Parameter environment
  Name of the environment inside the tenant in which you want to publish the Per Tenant Extension Apps
 .Parameter companyName
  Company Name in which the Azure AD App is registered
 .Parameter appFiles
  Array or comma separated string of apps or .zip files containing apps, which needs to be published
  The apps will be sorted by dependencies and published+installed
 .Parameter useNewLine
  Add this switch to add a newline to progress indicating periods during wait.
  Azure DevOps doesn't update logs until a newline is added.
 .Parameter hideInstalledExtensionsOutput
  Add this parameter to hide the output that lists installed extensions on the specified environment before and after installation of new and updated PTE extensions.
#>

function Publish-PerTenantExtensionApps {
    [CmdletBinding(DefaultParameterSetName="AC")]
    Param(
        [Parameter(Mandatory=$true, ParameterSetName="CC")]
        $clientId,
        [Parameter(Mandatory=$true, ParameterSetName="CC")]
        $clientSecret,
        [Parameter(Mandatory=$true, ParameterSetName="CC")]
        [string] $tenantId,
        [Parameter(Mandatory=$true, ParameterSetName="AC")]
        [Hashtable] $bcAuthContext,
        [Parameter(Mandatory=$true)]
        [string] $environment,
        [Parameter(Mandatory=$false)]
        [string] $companyName,
        [Parameter(Mandatory=$true)]
        $appFiles,
        [ValidateSet('Add','Force')]
        [string] $schemaSyncMode = 'Add',
        [ValidateSet('','Current version','Next minor version','Next major version')]
        [string] $schedule = '',
        [switch] $useNewLine,
        [switch] $hideInstalledExtensionsOutput
    )

$telemetryScope = InitTelemetryScope -name $MyInvocation.InvocationName -parameterValues $PSBoundParameters -includeParameters @()
try {
    
    function GetAuthHeaders {
        $script:authContext = Renew-BcAuthContext -bcAuthContext $script:authContext
        return @{ "Authorization" = "Bearer $($script:authContext.AccessToken)" }
    }

    $newLine = @{}
    if (!$useNewLine) {
        $newLine = @{ "NoNewLine" = $true }
    }

    if ($PsCmdlet.ParameterSetName -eq "CC") {
        if ($clientId -is [SecureString]) { $clientID = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($clientID)) }
        if ($clientId -isnot [String]) { throw "ClientID needs to be a SecureString or a String" }
        if ($clientSecret -is [String]) { $clientSecret = ConvertTo-SecureString -String $clientSecret -AsPlainText -Force }
        if ($clientSecret -isnot [SecureString]) { throw "ClientSecret needs to be a SecureString or a String" }

        $script:authContext = New-BcAuthContext `
            -clientID $clientID `
            -clientSecret $clientSecret `
            -tenantID $tenantId `
            -scopes "https://api.businesscentral.dynamics.com/.default"

        if (-not ($script:AuthContext)) {
            throw "Authentication failed"
        }
    }
    else {
        $script:authContext = Renew-BcAuthContext -bcAuthContext $bcAuthContext
    }

    $appFolder = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString())
    try {
        $appFiles = CopyAppFilesToFolder -appFiles $appFiles -folder $appFolder
        $automationApiUrl = "$($bcContainerHelperConfig.apiBaseUrl.TrimEnd('/'))/v2.0/$environment/api/microsoft/automation/v2.0"
        
        Write-Host "$automationApiUrl/companies"
        $companies = Invoke-RestMethod -Headers (GetAuthHeaders) -Method Get -Uri "$automationApiUrl/companies" -UseBasicParsing
        $company = $companies.value | Where-Object { ($companyName -eq "") -or ($_.name -eq $companyName) } | Select-Object -First 1
        if (!($company)) {
            throw "No company $companyName"
        }
        $companyId = $company.id
        if ($companyName -eq "") {
            $companyName = $company.name
        }
        Write-Host "Company '$companyName' has id $companyId"
        
        Write-Host "$automationApiUrl/companies($companyId)/extensions"
        $getExtensions = Invoke-WebRequest -Headers (GetAuthHeaders) -Method Get -Uri "$automationApiUrl/companies($companyId)/extensions" -UseBasicParsing
        $extensions = (ConvertFrom-Json $getExtensions.Content).value | Sort-Object -Property DisplayName
        
        if(!$hideInstalledExtensionsOutput) {
            Write-Host "Extensions before:"
            $extensions | ForEach-Object { Write-Host " - $($_.DisplayName), Version $($_.versionMajor).$($_.versionMinor).$($_.versionBuild).$($_.versionRevision), Installed=$($_.isInstalled)" }
            Write-Host
        }

        $body = @{"schedule" = "Current Version"}
        $appDep = $extensions | Where-Object { $_.DisplayName -eq 'Application' }
        $appDepVer = [System.Version]"$($appDep.versionMajor).$($appDep.versionMinor).$($appDep.versionBuild).$($appDep.versionRevision)"
        if ($appDepVer -ge [System.Version]"21.2.0.0") {
            if ($schemaSyncMode -eq 'Force') {
                $body."SchemaSyncMode" = "Force Sync"
            }
            else {
                $body."SchemaSyncMode" = "Add"
            }
        }
        else {
            if ($schemaSyncMode -eq 'Force') {
                throw 'SchemaSyncMode Force is not supported before version 21.2'
            }
        }

        if($schedule) {
            $body."schedule" = $schedule
        }

        $ifMatchHeader = @{ "If-Match" = '*'}
        $jsonHeader = @{ "Content-Type" = 'application/json'}
        $streamHeader = @{ "Content-Type" = 'application/octet-stream'}
        try {
            Sort-AppFilesByDependencies -appFiles $appFiles -excludeRuntimePackages | ForEach-Object {
                Write-Host @newline "$([System.IO.Path]::GetFileName($_)) - "
                $appJson = Get-AppJsonFromAppFile -appFile $_
                
                $existingApp = $extensions | Where-Object { $_.id -eq $appJson.id -and $_.isInstalled }
                if ($existingApp) {
                    if ($existingApp.isInstalled) {
                        $existingVersion = [System.Version]"$($existingApp.versionMajor).$($existingApp.versionMinor).$($existingApp.versionBuild).$($existingApp.versionRevision)"
                        if ($existingVersion -ge $appJson.version) {
                            Write-Host "already installed"
                        }
                        else {
                            Write-Host @newLine "upgrading"
                            $existingApp = $null
                        }
                    }
                    else {
                        Write-Host @newLine "installing"
                        $existingApp = $null
                    }
                }
                else {
                    Write-Host @newLine "publishing and installing"
                }
                if (!$existingApp) {
                    $extensionUpload = (Invoke-RestMethod -Method Get -Uri "$automationApiUrl/companies($companyId)/extensionUpload" -Headers (GetAuthHeaders)).value
                    Write-Host @newLine "."
                    if ($extensionUpload -and $extensionUpload.systemId) {
                        $extensionUpload = Invoke-RestMethod `
                            -Method Patch `
                            -Uri "$automationApiUrl/companies($companyId)/extensionUpload($($extensionUpload.systemId))" `
                            -Headers ((GetAuthHeaders) + $ifMatchHeader + $jsonHeader) `
                            -Body ($body | ConvertTo-Json -Compress)
                    }
                    else {
                        $ExtensionUpload = Invoke-RestMethod `
                            -Method Post `
                            -Uri "$automationApiUrl/companies($companyId)/extensionUpload" `
                            -Headers ((GetAuthHeaders) + $jsonHeader) `
                            -Body ($body | ConvertTo-Json -Compress)
                    }
                    Write-Host @newLine "."
                    if ($null -eq $extensionUpload.systemId) {
                        throw "Unable to upload extension"
                    }
                    $fileBody = [System.IO.File]::ReadAllBytes($_)
                    Invoke-RestMethod `
                        -Method Patch `
                        -Uri $extensionUpload.'extensionContent@odata.mediaEditLink' `
                        -Headers ((GetAuthHeaders) + $ifMatchHeader + $streamHeader) `
                        -Body $fileBody | Out-Null
                    Write-Host @newLine "."    
                    Invoke-RestMethod `
                        -Method Post `
                        -Uri "$automationApiUrl/companies($companyId)/extensionUpload($($extensionUpload.systemId))/Microsoft.NAV.upload" `
                        -Headers ((GetAuthHeaders) + $ifMatchHeader) `
                        -ErrorAction SilentlyContinue | Out-Null
                    Write-Host @newLine "."    
                    $completed = $false
                    $errCount = 0
                    $sleepSeconds = 30
                    $lastStatus = ''
                    while (!$completed)
                    {
                        Start-Sleep -Seconds $sleepSeconds
                        try {
                            $extensionDeploymentStatusResponse = Invoke-WebRequest -Headers (GetAuthHeaders) -Method Get -Uri "$automationApiUrl/companies($companyId)/extensionDeploymentStatus" -UseBasicParsing
                            $extensionDeploymentStatuses = (ConvertFrom-Json $extensionDeploymentStatusResponse.Content).value

                            $thisExtension = $extensionDeploymentStatuses | Where-Object { $_.publisher -eq $appJson.publisher -and $_.name -eq $appJson.name -and $_.appVersion -eq $appJson.version }
                            if ($null -eq $thisExtension) {
                                throw "Unable to find extension deployment status"
                            } 
                            $thisExtension | ForEach-Object {
                                if ($_.status -ne $lastStatus) {
                                    if (!$useNewLine) { Write-Host }
                                    Write-Host @newLine $_.status
                                    $lastStatus = $_.status
                                }
                                if ($_.status -eq "InProgress") {
                                    $errCount = 0
                                    $sleepSeconds = 5
                                    Write-Host @newLine "."
                                }
                                elseif ($_.Status -eq "Unknown") {
                                    throw "Unknown Error"
                                }
                                elseif ($_.Status -eq "Completed") {
                                    if (!$useNewLine) { Write-Host }
                                    $completed = $true
                                }
                                else {
                                    $errCount = 5
                                    throw $_.status
                                }
                            }
                        }
                        catch {
                            if (!$useNewLine) { Write-Host }
                            if ($errCount++ -gt 4) {
                                Write-Host $_.Exception.Message
                                throw "Unable to publish app. Please open the Extension Deployment Status Details page in Business Central to see the detailed error message."
                            }
                            $sleepSeconds += $sleepSeconds
                            Write-Host "Error: $($_.Exception.Message). Retrying in $sleepSeconds seconds"
                        }
                    }
                }
            }
        }
        catch [System.Net.WebException],[System.Net.Http.HttpRequestException] {
            if (!$useNewLine) { Write-Host }
            Write-Host "ERROR $($_.Exception.Message)"
            Write-Host $_.ScriptStackTrace
            throw (GetExtendedErrorMessage $_)
        }
        catch {
            if (!$useNewLine) { Write-Host }
            Write-Host "ERROR: $($_.Exception.Message) [$($_.Exception.GetType().FullName)]"
            throw
        }
        finally {
            $getExtensions = Invoke-WebRequest -Headers (GetAuthHeaders) -Method Get -Uri "$automationApiUrl/companies($companyId)/extensions" -UseBasicParsing
            $extensions = (ConvertFrom-Json $getExtensions.Content).value | Sort-Object -Property DisplayName
            
            if (!$hideInstalledExtensionsOutput) {
                Write-Host
                Write-Host "Extensions after:"
                $extensions | ForEach-Object { Write-Host " - $($_.DisplayName), Version $($_.versionMajor).$($_.versionMinor).$($_.versionBuild).$($_.versionRevision), Installed=$($_.isInstalled)" }
            }
        }
    }
    catch [System.Net.WebException],[System.Net.Http.HttpRequestException] {
        Write-Host "ERROR $($_.Exception.Message)"
        throw (GetExtendedErrorMessage $_)
    }
    finally {
        if (Test-Path $appFolder) {
            Remove-Item $appFolder -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}
catch {
    TrackException -telemetryScope $telemetryScope -errorRecord $_
    throw
}
finally {
    $script:authContext = $null
    TrackTrace -telemetryScope $telemetryScope
}
}
Export-ModuleMember -Function Publish-PerTenantExtensionApps