functions/Invoke-PpacD365AppInstall.ps1


<#
    .SYNOPSIS
        Installs or updates D365 applications in the environment.
         
    .DESCRIPTION
        Enables the user to install or update D365 applications in the environment as it would be done in Power Platform Admin Center (PPAC).
         
        Mimics the behavior of 'pac app-management install-application-package --unique-name':
        POST https://api.powerplatform.com/appmanagement/environments/{environmentId}/applicationPackages/{uniqueName}/install?api-version=2024-10-01
        with body '{"payloadValue":"..."}' (or '{}' when no payload is needed).
         
        The same endpoint handles both fresh installs (state 'None') and updates of already installed apps.
         
        Returns one result object per requested app with the final operation status.
         
    .PARAMETER EnvironmentId
        The id of the environment that you want to work against
         
    .PARAMETER D365App
        The name, unique name or package name of the D365 application you want to install in the environment.
         
        Supports single or multiple application names.
         
    .PARAMETER PayloadValue
        Optional payload value sent as '{"payloadValue":"..."}' in the install request body.
         
        Required by some apps with a custom install experience (e.g. Finance and Operations Provisioning App).
        Omit for regular installs / updates.
         
    .PARAMETER PollIntervalSeconds
        Seconds to wait between each status poll against the operations endpoint.
         
        Defaults to 60 seconds.
         
    .PARAMETER TimeoutMinutes
        Maximum time to wait for all installations to reach a terminal state (Succeeded / Failed / Canceled) before giving up polling.
         
        Defaults to 120 minutes. Use 0 to wait indefinitely (not recommended for automation).
         
        When the timeout expires the cmdlet stops polling, warns, and returns the last known statuses so the caller can decide how to proceed.
         
    .EXAMPLE
        PS C:\> Invoke-PpacD365AppInstall -EnvironmentId "eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6" -D365App "Invoice Capture for Dynamics 365 Finance"
         
        This will install the "Invoice Capture for Dynamics 365 Finance" application in the environment.
         
    .EXAMPLE
        PS C:\> Invoke-PpacD365AppInstall -EnvironmentId "eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6" -D365App "msdyn_FnoInvoiceCaptureAnchor"
         
        This will install the "Invoice Capture for Dynamics 365 Finance" application in the environment.
        It will use the unique name of the application (msdyn_FnoInvoiceCaptureAnchor) to find the application and install it.
         
    .EXAMPLE
        PS C:\> Invoke-PpacD365AppInstall -EnvironmentId "eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6" -D365App "MicrosoftOperationsVEAnchor","msdyn_FnoInvoiceCaptureAnchor"
         
        This will install the "Microsoft Operations VE" and "Invoice Capture for Dynamics 365 Finance" applications in the environment.
        It will use the unique name of the D365 applications to find the applications and install them.
        It will start the installation of both applications, and then wait for the installation process to finish.
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
#>

function Invoke-PpacD365AppInstall {
    [CmdletBinding()]
    param (
        [parameter (Mandatory = $true)]
        [string] $EnvironmentId,

        [parameter (Mandatory = $true)]
        [string[]] $D365App,

        [parameter (Mandatory = $false)]
        [string] $PayloadValue,

        [parameter (Mandatory = $false)]
        [ValidateRange(5, 3600)]
        [int] $PollIntervalSeconds = 60,

        [parameter (Mandatory = $false)]
        [ValidateRange(0, 4320)]
        [int] $TimeoutMinutes = 120
    )
    
    begin {
        # Make sure all *BapEnvironment* cmdlets will validate that the environment exists prior running anything.
        $envObj = Get-BapEnvironment `
            -EnvironmentId $EnvironmentId | `
            Select-Object -First 1

        if ($null -eq $envObj) {
            $messageString = "The supplied EnvironmentId: <c='em'>$EnvironmentId</c> didn't return any matching environment details. Please verify that the EnvironmentId is correct - try running the <c='em'>Get-BapEnvironment</c> cmdlet."
            Write-PSFMessage -Level Important -Message $messageString
            Stop-PSFFunction -Message "Stopping because environment found based on the id." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        }
        
        if (Test-PSFFunctionInterrupt) { return }

        # First we will fetch ALL available apps for the environment
        $secureTokenPowerApi = Get-BapAccessToken -ResourceUrl "https://api.powerplatform.com/"
        $tokenPowerApiValue = ConvertFrom-SecureString -AsPlainText -SecureString $secureTokenPowerApi
        
        $headersPowerApi = @{
            "Authorization" = "Bearer $($tokenPowerApiValue)"
        }

        $appsAvailable = Get-PpacD365App `
            -EnvironmentId $EnvironmentId
    }
    
    process {
        if (Test-PSFFunctionInterrupt) { return }
        
        [System.Collections.Generic.List[System.Object]] $arrInstallStarted = @()
        [System.Collections.Generic.List[System.Object]] $arrStatus = @()
        [System.Collections.Generic.List[System.Object]] $arrFailedStarts = @()

        $headersPowerApi."Content-Type" = "application/json;charset=utf-8"

        foreach ($appName in $D365App) {
            $appToBeInstalled = $appsAvailable | `
                Where-Object { $_.PpacD365AppId -eq $appName -or
                $_.PpacD365AppName -eq $appName -or
                $_.PpacPackageName -eq $appName } | `
                Select-Object -First 1

            if ($null -eq $appToBeInstalled) {
                $messageString = "The combination of the supplied EnvironmentId: <c='em'>$EnvironmentId</c> and PpacD365AppId: <c='em'>$appName</c> didn't return any matching D365App. Please verify that the EnvironmentId & PpacD365AppId is correct - try running the <c='em'>Get-BapEnvironmentD365App</c> cmdlet."
                Write-PSFMessage -Level Important -Message $messageString
                Stop-PSFFunction -Message "Stopping because environment and d365app combination was NOT found based on the supplied parameters." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
            }

            if ($PSBoundParameters.ContainsKey('PayloadValue')) {
                $body = @{ payloadValue = $PayloadValue } | ConvertTo-Json -Compress
            }
            else {
                $body = '{}'
            }

            try {
                $localUri = "https://api.powerplatform.com/appmanagement/environments/{0}/applicationPackages/{1}/install?api-version=2024-10-01" `
                    -f $envObj.PpacEnvId `
                    , $appToBeInstalled.PpacPackageName

                $resIntall = Invoke-RestMethod `
                    -Method Post `
                    -Uri $localUri `
                    -Headers $headersPowerApi `
                    -Body $body

                $arrInstallStarted.Add($resIntall)
            }
            catch {
                $startError = $_.Exception.Message
                if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $startError = $_.ErrorDetails.Message }
                $appToBeInstalled | Add-Member -MemberType NoteProperty -Name '_InstallStartError' -Value $startError -Force
                $arrFailedStarts.Add($appToBeInstalled)
            }
        }

        if ($arrFailedStarts.Count -gt 0) {
            $messageString = "The following packages <c='em'>failed to start</c>:"
            Write-PSFMessage -Level Important -Message $messageString

            $arrFailedStarts.ToArray() | Format-Table

            if ($arrFailedStarts.Count -eq $D365App.Count) {
                Write-PSFMessage -Level Warning -Message "All package installations failed to start. Returning the failure details."
            }
        }
        
        $pollDeadline = if ($TimeoutMinutes -gt 0) { (Get-Date).AddMinutes($TimeoutMinutes) } else { [datetime]::MaxValue }
        $timedOut = $false
        $pendingCount = 0
        $arrStatus = @()

        if ($arrInstallStarted.Count -gt 0) {
        do {
            Start-Sleep -Seconds $PollIntervalSeconds

            $secureTokenPowerApi = Get-BapAccessToken -ResourceUrl "https://api.powerplatform.com/"
            $tokenPowerApiValue = ConvertFrom-SecureString -AsPlainText -SecureString $secureTokenPowerApi

            $headersPowerApi = @{
                "Authorization" = "Bearer $($tokenPowerApiValue)"
            }

            $arrStatus = @()

            foreach ($operation in $arrInstallStarted) {
                $localUri = "https://api.powerplatform.com/appmanagement/environments/{0}/operations/{1}?api-version=2024-10-01" `
                    -f $envObj.PpacEnvId `
                    , $operation.lastOperation.operationId

                $resInstallOperation = Invoke-RestMethod `
                    -Method Get `
                    -Uri $localUri `
                    -Headers $headersPowerApi 4> $null

                $arrStatus.Add($resInstallOperation)
            }

            $pendingCount = @($arrStatus | Where-Object { $_.status -in @('NotStarted', 'Running') }).Count

            Write-Progress -Id 1 `
                -Activity "Installing D365 app(s) in '$($envObj.PpacEnvName)'" `
                -Status "$pendingCount of $($arrStatus.Count) operation(s) still active (NotStarted / Running)" `
                -PercentComplete $(if ($arrStatus.Count -gt 0) { (($arrStatus.Count - $pendingCount) / $arrStatus.Count) * 100 } else { 100 })

            $arrStatus | Format-Table

            if ($pendingCount -gt 0 -and (Get-Date) -ge $pollDeadline) {
                $timedOut = $true
                break
            }
        } while ($pendingCount -gt 0)

        Write-Progress -Id 1 -Completed -Activity "Installing D365 app(s) in '$($envObj.PpacEnvName)'"
        }

        if ($timedOut) {
            $messageString = "Polling timed out after <c='em'>$TimeoutMinutes minute(s)</c> with <c='em'>$pendingCount operation(s)</c> still active. Returning the last known statuses."
            Write-PSFMessage -Level Warning -Message $messageString
        }

        foreach ($install in $arrInstallStarted) {
            $opId = $install.lastOperation.operationId
            $final = $arrStatus | Where-Object operationId -eq $opId | Select-Object -First 1

            [PSCustomObject]@{
                PpacD365AppName = $install.localizedName
                PpacPackageName = $install.packageUniqueName
                PackageVersion  = $install.packageVersion
                Status          = $final.status
                StatusMessage   = $final.statusMessage
                ErrorName       = $final.error.errorName
                ErrorMessage    = $final.error.message
                OperationId     = $opId
                TimedOut        = $timedOut
            }
        }

        foreach ($failed in $arrFailedStarts) {
            [PSCustomObject]@{
                PpacD365AppName = $failed.PpacD365AppName
                PpacPackageName = $failed.PpacPackageName
                PackageVersion  = $failed.AvailableVersion
                Status          = 'FailedToStart'
                StatusMessage   = $failed._InstallStartError
                ErrorName       = $null
                ErrorMessage    = $failed._InstallStartError
                OperationId     = $null
                TimedOut        = $false
            }
        }
    }
    
    end {
        
    }
}