Publish/Publish-BcAppToDevEndpointWithRetry.ps1

<#
.SYNOPSIS
Function to publish apps to a Microsoft Dynamics 365 Business Central environment with retry capabilities in case of failure.
 
.DESCRIPTION
The `Publish-BcAppToDevEndpointWithRetry` function automates the process of publishing apps to a Microsoft Dynamics 365 Business Central development environment. It handles various publishing scenarios, including publishing multiple apps from file paths or from a URL containing a zipped collection of apps. The function supports retries if the initial publishing attempts fail and provides detailed feedback on publishing results, handling common outcomes such as successful publishing, duplicate package detection, or apps that are already installed.
 
The function supports two authentication methods: Business Central OAuth authentication context (`bcAuthContext`) or classic credentials (`pscredential`). Based on the outcome of each publishing attempt, the function either proceeds to the next app or retries the publishing process up to a specified number of times.
 
.PARAMETER appFile
Specifies the app file path(s) or a URL pointing to a ZIP file containing apps. Multiple paths can be provided as a space-separated string.
 
.PARAMETER devServerUrl
Specifies the URL of the development endpoint where the apps will be published.
 
.PARAMETER bcAuthContext
Specifies the Business Central OAuth authentication context to be used for publishing.
 
.PARAMETER credential
Specifies the credentials to use if `bcAuthContext` is not provided.
 
.PARAMETER environment
Specifies the environment name in Business Central. This is used when authenticating with `bcAuthContext`.
 
.PARAMETER sslVerificationDisabled
A switch that disables SSL verification for connections to the development server.
 
.PARAMETER syncMode
Specifies the synchronization mode for publishing apps.
 
.PARAMETER dependencyPublishingOption
Specifies how dependencies should be handled during publishing.
 
.PARAMETER timeoutMinutes
Specifies the timeout for publishing operations, in minutes. The default is 10 minutes.
 
.PARAMETER maxRetries
Specifies the maximum number of retries if the publishing process fails. The default is 3 retries.
 
.PARAMETER WaitingTimeInSecond
Specifies the waiting time between retries, in seconds. The default is 90 seconds.
 
.EXAMPLE
Publish-BcAppToDevEndpointWithRetry `
    -appFile "C:\Apps\MyApp1.app C:\Apps\MyApp2.app" `
    -devServerUrl "https://navdev.smartcloud.com.ua:7149/localizationMoldova" `
    -bcAuthContext $bcAuthContext `
    -syncMode 'ForceSync' `
    -dependencyPublishingOption 'Ignore' `
    -maxRetries 3 `
    -WaitingTimeInSecond 90
#>


        function Publish-BcAppToDevEndpointWithRetry1 {
            param (
                [Parameter(Mandatory = $true)]
                $appFile,
                [Parameter(Mandatory = $true)]
                [string] $devServerUrl,
                [Hashtable] $bcAuthContext,
                [pscredential] $credential,
                [string] $environment,
                [switch] $sslVerificationDisabled,
                [ValidateSet('Add', 'Clean', 'Development', 'ForceSync')]
                [string] $syncMode = 'ForceSync',
                [ValidateSet('Default', 'Ignore', 'Strict')]
                [string] $dependencyPublishingOption = 'Ignore',
                [int] $timeoutMinutes = 10,
                [int] $maxRetries = 3,
                [int] $WaitingTimeInSecond = 90,
                [int] $retryCount = 0
            )

            $params = @{}
            $appHashTable = @{}
            $appPathsList = @()
            $appNamesArray = @()

            if ($bcAuthContext) {
                $params = @{
                    "bcAuthContext" = $bcAuthContext
                    "environment"   = $environment
                }
            } elseif ($credential) {
                $params = @{
                    "credential" = $credential
                }
            }

            function Validate-AndCreateAppHashTable {
                param (
                    [array]$appNamesArray,
                    [array]$appPathsList
                )
                $appHashTable = @{}

                if ($appNamesArray.Count -eq $appPathsList.Count) {
                    for ($i = 0; $i -lt $appNamesArray.Count; $i++) {
                        $appHashTable[$appNamesArray[$i]] = $appPathsList[$i]
                    }
                } else {
                    Write-Warning "The appNamesArray and appPathsList arrays have different lengths. Please check the data."
                }

                return $appHashTable
            }

            function Get-AppNamesFromPaths {
                param (
                    [string[]]$appPaths
                )
                $appNamesArray = @()
                foreach ($appPath in $appPaths) {
                    $appName = (Get-AppJsonFromAppFile -appFile $appPath).name
                    $appNamesArray += $appName
                }
                return $appNamesArray
            }

            if ($appFile -like "https://*") {
                $tempFolder = [System.IO.Path]::Combine($env:TEMP, [System.Guid]::NewGuid().ToString())
                $tempZipPath = [System.IO.Path]::Combine($tempFolder, "latest.zip")
                
                New-Item -ItemType Directory -Path $tempFolder | Out-Null

                try {
                    $destinationFolder = [System.IO.Path]::Combine($tempFolder, "extracted")
                    New-Item -ItemType Directory -Path $destinationFolder | Out-Null
                    CopyAppFilesToFolder -appFiles $appFile -folder $destinationFolder
                    $appPathsList = @(Get-ChildItem -Path $destinationFolder -Filter "*.app" -Recurse | Select-Object -ExpandProperty FullName)
                    $appNamesArray = @(Get-AppNamesFromPaths -appPaths $appPathsList)

                    $appHashTable = Validate-AndCreateAppHashTable -appNamesArray $appNamesArray -appPathsList $appPathsList
                } catch {
                    Write-Host "Error: $($_.Exception.Message)"
                }
            } else {
                $appPathsList = @($appFile -split "(?<=\.app) ")
                $appNamesArray = @(Get-AppNamesFromPaths -appPaths $appPathsList)

                $appHashTable = Validate-AndCreateAppHashTable -appNamesArray $appNamesArray -appPathsList $appPathsList
            }

            while ($retryCount -le $maxRetries -and $appHashTable.Count -gt 0) {
                Write-Host "Publishing to tenant"

                & {
                    Publish-BcAppToDevEndpoint @params `
                        -devServerUrl $devServerUrl `
                        -appFile $appHashTable.Values `
                        -syncMode $syncMode `
                        -dependencyPublishingOption $dependencyPublishingOption `
                        -timeoutMinutes $timeoutMinutes
                } *>&1 | Tee-Object -Variable publishResult

                if ($publishResult -match 'successfully published' -or $publishResult -match 'A duplicate package ID is detected.' -or $publishResult -match 'was already installed' -or $publishResult -match "\u041E\u0431\u043D\u0430\u0440\u0443\u0436\u0435\u043D \u043F\u043E\u0432\u0442\u043E\u0440\u044F\u044E\u0449\u0438\u0439\u0441\u044F \u0418\u0414 \u043F\u0430\u043A\u0435\u0442\u0430") {
                    $successCount = ($publishResult | Select-String -Pattern 'successfully published').Matches.Count
                    $alreadyInstalledCount = ($publishResult | Select-String -Pattern 'was already installed').Matches.Count
                    $totalAppsCount = $appHashTable.Count

                    if ($publishResult -match "\u041E\u0431\u043D\u0430\u0440\u0443\u0436\u0435\u043D") {
                        $duplicateCount = ($publishResult | Select-String -Pattern "\u041E\u0431\u043D\u0430\u0440\u0443\u0436\u0435\u043D \u043F\u043E\u0432\u0442\u043E\u0440\u044F\u044E\u0449\u0438\u0439\u0441\u044F \u0418\u0414 \u043F\u0430\u043A\u0435\u0442\u0430").Matches.Count
                    } else {
                        $duplicateCount = ($publishResult | Select-String -Pattern 'A duplicate package ID is detected').Matches.Count
                    }

                    if (($successCount + $duplicateCount + $alreadyInstalledCount) -eq $totalAppsCount) {
                        Write-Host "All apps were installed"
                        break
                    } else {
                        $appKeys = $appHashTable.Keys.Clone()
                        foreach ($app in $appKeys) {
                            $patternSuccessfully = "App .*$app"
                            $patternDuplicate = "A duplicate package ID is detected"
                            $patternDuplicateRU = "\u041E\u0431\u043D\u0430\u0440\u0443\u0436\u0435\u043D \u043F\u043E\u0432\u0442\u043E\u0440\u044F\u044E\u0449\u0438\u0439\u0441\u044F \u0418\u0414 \u043F\u0430\u043A\u0435\u0442\u0430"

                            if ($publishResult -match "$patternSuccessfully.*successfully published") {
                                Write-Output "App $app was successfully published"
                                $appHashTable.Remove($app)
                            } elseif ($publishResult -match "$patternDuplicate.*name: '$app'" -or $publishResult -match "$patternDuplicateRU.*именем `"$app`"") {
                                Write-Output "App $app duplicate package"
                                $appHashTable.Remove($app)
                            } else {
                                Write-Output "App $app was NOT successfully published"
                            }
                        }

                        Write-Host "The remaining apps have not been published:"
                        foreach ($value in $appHashTable.Values) {
                            Write-Host $value
                        }
                    }
                }

                $retryCount++
                Write-Host "Start redeploy: $retryCount"
                Write-Host "Time waiting: $WaitingTimeInSecond"
                Start-Sleep -Seconds $WaitingTimeInSecond
            }

            if ($appFile -like "https://*") {
                Remove-Item -Path $tempFolder -Recurse -Force
            }
        }