Scripts/SolutionDeploy.ps1

#
# SolutionDeploy.ps1
#


function Start-DeploySolution {
    Param(
        [string] [Parameter(Mandatory = $true)] $DeployServerUrl,
        [string] [Parameter(Mandatory = $true)] $UserName,
        [string] [Parameter(Mandatory = $false)] $Password = "",
        [string] [Parameter(Mandatory = $true)] $PipelinePath,
        [bool] [Parameter(Mandatory = $false)] $UseClientSecret = $false,
        [bool] [Parameter(Mandatory = $false)] $RunLocally = $false,
        [string] [Parameter(Mandatory = $false)] $EnvironmentName = $env:ENVIRONMENT_NAME
    )

    ######################## SETUP
    . "$PSScriptRoot\..\Private\_SetupTools.ps1"

    Write-Host "Using Microsoft.PowerPlatform.DevOps version :" (Get-Module -Name Microsoft.PowerPlatform.DevOps -ListAvailable).Version
    Write-Host "##[group] Installing Dependencies"
    Install-PAC

    if (!$RunLocally) {
        Install-ConfigMigrationModule
        Install-XrmModule
        Install-PowerAppsCheckerModule
        Install-PowerAppsAdmin
    }
    else {
        Write-Host "Preparing local run"
    }

    Write-Host "##[endgroup]"

    function Import-Package {
        if ($UseClientSecret) {
            [string]$CrmConnectionString = "AuthType=ClientSecret;Url=$DeployServerUrl;ClientId=$UserName;ClientSecret=$Password"
        }
        else {
            [string]$CrmConnectionString = "AuthType=OAuth;Username=$UserName;Password=$Password;Url=$DeployServerUrl;AppId=51f81489-12ee-4a9e-aaae-a2591f45987d;RedirectUri=app://58145B91-0C36-4500-8554-080854F2AC97;LoginPrompt=never"
        }
        $Packages = Get-Content "$PipelinePath\deployPackages.json" | ConvertFrom-Json

        #handle Environments file should it be missing. Same for DeployPackages and SolutionChecker files!

        $Environments = Get-Content "$PipelinePath\Environments.json" | ConvertFrom-Json
        $EnvConfig = $Environments | Where-Object { $_.EnvironmentName -eq $EnvironmentName }

        Write-Host "##[section] Creating CRM connection"
        $CRMConn = Get-CrmConnection -ConnectionString $CrmConnectionString -Verbose #-MaxCrmConnectionTimeOutMinutes 10
        #Set-CrmConnectionTimeout -conn $CRMConn -TimeoutInSeconds 600

        if ($false -eq $CRMConn.IsReady) {
            Write-Error "An error occurred: " $CRMConn.LastCrmError
            Write-Error $CRMConn.LastCrmException.Message
            Write-Error $CRMConn.LastCrmException.Source
            Write-Error $CRMConn.LastCrmException.StackTrace
            throw "Could not establish connection with server"
        }

        #ENVIRONMENT PRE-ACTION
        if ($null -ne $EnvConfig -and $EnvConfig.PreAction -eq $true) {
    
            Write-Host "##[section] Execute Environment Pre Action"
            . "$PipelinePath\Common\Environments\Scripts\PreAction.ps1" -Conn $CRMConn -PipelinePath $PipelinePath -EnvironmentName $EnvConfig.EnvironmentName -EnvironmentUrl $DeployServerUrl
            $EnvConfig.PreFunctions | ForEach-Object {
                & $_ -Conn $CRMConn
            }
            Write-Host "##[command] Environment Pre Action Complete" 
        }   

        foreach ($package in $Packages) {
            $skipDeploy = $false 
            $anyFailuresInImport = $false; 

            $Deploy = $package.DeployTo | Where-Object { $_.EnvironmentName -eq $EnvironmentName }
            if ($null -ne $Deploy) {
                $PSolution = $package.SolutionName
                Write-Host "##[group] Preparing Deployment for $PSolution"
                Write-Host "Deployment step manifest - $Deploy"
                Write-Host "##[section] Preparing $PSolution Solution as $($Deploy.DeploymentType)"

                $fileToPack = "$($Deploy.EnvironmentName)_$($PSolution)_$($Deploy.DeploymentType).zip"
                $packageFolder = "dataverse_$($PSolution)"

                Write-Host "##[command] Packing Solution $PSolution" 
                #Checking for Canvas App
                $canvasApps = Get-ChildItem -Path $PipelinePath\$PSolution\$packageFolder\CanvasApps\ -Directory -ErrorAction SilentlyContinue 
                # pack canvas apps
                $canvasApps | ForEach-Object {
                    Write-Host "Packing Canvas App $($_.name)";
                    & $env:APPDATA\Microsoft.PowerPlatform.DevOps\PACTools\tools\pac.exe canvas pack --sources $_.FullName --msapp "$($_.FullName).msapp"
                    Remove-Item $_.FullName -Recurse -ErrorAction SilentlyContinue
                }
                if ($Deploy.DeploymentType.ToLower() -eq "unmanaged") {
                    & $env:APPDATA\Microsoft.PowerPlatform.DevOps\PACTools\tools\pac.exe solution pack -f $PipelinePath\$PSolution\$packageFolder -z $PipelinePath\$PSolution\$fileToPack -p Unmanaged
                }
                else {
                    & $env:APPDATA\Microsoft.PowerPlatform.DevOps\PACTools\tools\pac.exe solution pack -f $PipelinePath\$PSolution\$packageFolder -z $PipelinePath\$PSolution\$fileToPack -p Managed -same
                }         
               
                Write-Host "##[section] Importing package"
                
                try {
                    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
                    $error.Clear()
                    Write-Host "##[section] Deploying $($package.SolutionName) as $($Deploy.DeploymentType) to - $EnvironmentName" 
                    #Get Currently Deployed Solution Version
                    Write-Host "Getting Current Solution Version from Target"
                    $SolutionQuery = Get-CrmRecords -conn $CRMConn -EntityLogicalName solution -Fields 'solutionid', 'friendlyname', 'version', 'uniquename' -FilterAttribute uniquename -FilterOperator eq -FilterValue $($package.SolutionName)
                    $Solution = $SolutionQuery.CrmRecords[0]

                    if (!$Solution) { 
                        $deployAsHolding = $false;
                        Write-Host "Solution not found in Target, Importing as New" 
                    }
                    else {
                        $SolutionVersion = $Solution.version
                        Write-Host "Found: $SolutionVersion in $EnvironmentName"

                        if ($null -ne $Deploy.DeployAsHolding) {
                            [bool]$deployAsHolding = [System.Convert]::ToBoolean($Deploy.DeployAsHolding)
                        }
                        else {
                            $deployAsHolding = $false
                        }
                    }
                        
                    Write-Host "Getting Version to be Deployed..."
                    $deployingVersion = Get-Content -Path $PipelinePath\$PSolution\$PSolution.version
                    Write-Host "##[command] Version to be deployed : $deployingVersion"
                    if ($deployingVersion -le $SolutionVersion) { $skipDeploy = $true; Write-Host "##[warning] Skipping Deployment as Target has same or newer" }
                        
                    ########################## IMPORT
                    if (!$skipDeploy) {

                        # Powerapps Solution Checker
                        if ($Deploy.PowerAppsChecker -eq $true -and $UseClientSecret -eq $true) {
                            Write-Host "##[command] Running PowerApps Solution Checker for $PSolution"
                            Start-SolutionChecker -PipelinePath $PipelinePath -SolutionPath $PipelinePath\$PSolution\$fileToPack -SolutionName $PSolution -ClientId $UserName -ClientSecret $Password -TenantId "$($CRMConn.TenantId)"
                        }
                        else {
                            Write-Host "##[warning] Powerapps Checker not configured. Add PowerAppsChecker: True as a property in the DeployTo section for your Solution"
                        }
            
                        # PRE ACTION
                        if ($Deploy.PreAction -eq $true) {
                            if (Test-Path -Path $PipelinePath\$PSolution\Scripts\PreAction.ps1) {
                                Write-Host "##[section] Execute Pre Action from $PipelinePath\$PSolution\Scripts"
                                . $PipelinePath\$PSolution\Scripts\PreAction.ps1 -Conn $CRMConn -EnvironmentName $Deploy.EnvironmentName -Path "$PipelinePath\$PSolution\"
                            }
                        }

                        $activatePlugIns = $true;
                        $overwriteUnManagedCustomizations = $true;
                        $skipDependancyOnProductUpdateCheckOnInstall = $true;
                        $isInternalUpgrade = $false;                   

                        Write-Host "##[section] Initiating Import and deployment to $($DeployServerUrl)"
                        Write-Host "##[command] Import as Holding solution : $($deployAsHolding)"
                        
                        $importId = [guid]::Empty 
                        $result = $CRMConn.ImportSolutionToCrmAsync("$PipelinePath\$PSolution\$fileToPack", [ref]$importId,
                            $activatePlugIns,
                            $overwriteUnManagedCustomizations, 
                            $skipDependancyOnProductUpdateCheckOnInstall,
                            $deployAsHolding,
                            $isInternalUpgrade)

                        Write-Host Async Operation ID: $result 
                        Write-Host Import Job ID: $importId 
                        
                        $Retrycount = 0;

                        # IMPORT
                        do {

                            try {

                                Start-Sleep -Seconds 5

                                $operation = Get-CrmRecord -conn $CRMConn -EntityLogicalName asyncoperation -Id ($result) -Fields name, statuscode, friendlymessage, completedon, errorcode
                                [int]$statuscode = $operation.statuscode_Property.value.Value;

                                if ($statuscode -le 30) {
                                    $job = Get-CrmRecord -conn $CRMConn -EntityLogicalName importjob -Id ($importId) -Fields progress 
                                    Write-Host "Polling Import for Solution: $($PSolution) : $($operation.statuscode) - $($job.progress)%"
                                    $anyFailuresInImport = $false;
                                }
                                elseif ($statuscode -eq 31 -or $statuscode -eq 32) {
                                    Write-Error "##[error]: Unable to import solution - please check Solution import history in https://make.powerapps.com/environments"
                                    Write-Warning "##[warning] Import Failed: $($operation.statuscode)"
                                    Write-Warning "##[warning] Error Code: $($operation.errorcode)"
                                    Write-Warning "##[warning] $($operation.friendlymessage)"
                                   
                                    $anyFailuresInImport = $true;
                                }
                            }
                            catch {
                                Write-Host "Retrying Polling import status"
                                $Retrycount = $Retrycount + 1
                                if ($Retrycount -gt 3) {
                                    $statuscode = 32;
                                    $anyFailuresInImport = $true;
                                    Write-Error "##[error]: Unable to poll status - please check Solution import history in https://make.powerapps.com/environments"
                                    Write-Error "##[error]:$($_.Exception.Message)"
                                    break;
                                }
                            }
                        } until ($statuscode -eq 30 -or $statuscode -eq 31 -or $statuscode -eq 32)

                        $Retrycount = 0;
                        # UPGRADE
                        if ($deployAsHolding -eq $true -and $anyFailuresInImport -eq $false) {
                         
                            # PRE UPGRADE
                            if ($Deploy.PreUpgrade -eq $true) {
                                if (Test-Path -Path $PipelinePath\$PSolution\Scripts\PreUpgrade.ps1) {
                                    Write-Host "##[section] Execute Pre Upgrade from $PipelinePath\$PSolution\Scripts"
                                    . $PipelinePath\$PSolution\Scripts\PreUpgrade.ps1 -Conn $CRMConn -EnvironmentName $Deploy.EnvironmentName -Path "$PipelinePath\$PSolution\"
                                }
                            }


                            Write-Host "Applying Upgrade to Solution"
                            $promoteRequestId = $CRMConn.DeleteAndPromoteSolutionAsync($PSolution);                          

                            if (($null -eq $promoteRequestId) -or ($promoteRequestId -eq [Guid]::Empty)) {
                                Write-Error "##[error]: Unable to promote or delete solution - please check Solution import history in https://make.powerapps.com/environments"
                                $anyFailuresInImport = $true;
                            }
                            else { 
                                Write-Host Async Operation ID: $promoteRequestId 
                                do {
                                    try {
                                        Start-Sleep -Seconds 5
                                        $operation = Get-CrmRecord -conn $CRMConn -EntityLogicalName asyncoperation -Id ($promoteRequestId) -Fields name, statuscode, friendlymessage, completedon, errorcode
                                        [int]$statuscode = $operation.statuscode_Property.value.Value;
                                                                    
                                        if ($statuscode -le 30) {
                                            Write-Host "Polling Promotion status for Solution: $($PSolution) : $($operation.statuscode)"
                                            $anyFailuresInImport = $false;
                                        }
                                        elseif ($statuscode -eq 31 -or $statuscode -eq 32) {
                                            Write-Error "##[error]: Unable to promote or delete solution - please check Solution import history in https://make.powerapps.com/environments"
                                            Write-Warning "##[warning] Delete and Promote Failed: #($operation.statuscode)"
                                            Write-Warning "##[warning] Error Code: $($operation.errorcode)"
                                            Write-Warning "##[warning] $($operation.friendlymessage)"
                                       
                                            $anyFailuresInImport = $true;
                                        }
                                    }
                                    catch {
                                        Write-Host "Retrying Polling Upgrade status"
                                        $Retrycount = $Retrycount + 1
                                        if ($Retrycount -gt 3) {
                                            $statuscode = 32;
                                            $anyFailuresInImport = $true;
                                            Write-Error "##[error]: Unable to poll status - please check Solution import history in https://make.powerapps.com/environments"
                                            Write-Error "##[error]:$($_.Exception.Message)"
                                            break;
                                        }
                                    }
                                }until($statuscode -eq 30 -or $statuscode -eq 31 -or $statuscode -eq 32)
                            }

                            # Post UPGRADE
                            if ($Deploy.PostUpgrade -eq $true) {
                                if (Test-Path -Path $PipelinePath\$PSolution\Scripts\PostUpgrade.ps1) {
                                    Write-Host "##[section] Execute Post Upgrade from $PipelinePath\$PSolution\Scripts"
                                    . $PipelinePath\$PSolution\Scripts\PostUpgrade.ps1 -Conn $CRMConn -EnvironmentName $Deploy.EnvironmentName -Path "$PipelinePath\$PSolution\"
                                }
                            }
                        }

                        # DATA CONFIGURATION
                        if ($Deploy.DeployData -eq $true -and $anyFailuresInImport -eq $false) {
                            Write-Host "##[group] Importing reference Data ..."
                            try {
                                if (Test-Path -Path $PipelinePath\$PSolution\ReferenceData\data.zip) {
                                    Import-CrmDataFile -CrmConnection $CRMConn -DataFile $PipelinePath\$PSolution\ReferenceData\data.zip -EnabledBatchMode -Verbose                    
                                }
                                else {
                                    Write-Host "Config Data file does not Exist"
                                }
                            }
                            catch {
                                Write-Error "##[error]: Unable to import configuration data - please review Pipeline error logs"
                                Write-Error "##[error]:$($_.Exception.Message)"              
                            }
                            Write-Host "##[endgroup]"
                        }
                        else {
                            Write-Host "##[section] No Data to Import for $PSolution"
                        }

                        # POST ACTION
                        if ($Deploy.PostAction -eq $true -and $anyFailuresInImport -eq $false) {
                            if (Test-Path -Path $PipelinePath\$PSolution\Scripts\PostAction.ps1) {
                                Write-Host "##[section] Execute Post Action from $PipelinePath\$PSolution\Scripts"
                                . $PipelinePath\$PSolution\Scripts\PostAction.ps1 -Conn $CRMConn -EnvironmentName $Deploy.EnvironmentName -Path "$PipelinePath\$PSolution\"
                            }
                        }

                        # Activate Flows and Establish Connection References
                        Write-Host "Getting Environment Id"
                        $orgs = Get-CrmRecords -conn $CRMConn -EntityLogicalName organization
                        if ($orgs.Count -gt 0) {
                            $orgId = $orgs.CrmRecords[0].organizationid
                            if ($UseClientSecret) {
                                Add-PowerAppsAccount -ApplicationId $UserName -ClientSecret $Password -TenantID $CRMConn.TenantId
                            }
                            else {
                                Add-PowerAppsAccount -Username $UserName -Password $Password
                            }
                            $Environment = Get-AdminPowerAppEnvironment | Where-Object OrganizationId -eq $orgId.Guid
                            $EnvId = $Environment.EnvironmentName
                            Write-Host "Environment Id - $EnvId"
                            
                            Write-Host "Checking if there are Connections References in the Solution that Need to be Wired Up"
                            $solutions = Get-CrmRecords -conn $CRMConn -EntityLogicalName solution -FilterAttribute "uniquename" -FilterOperator "eq" -FilterValue "$PSolution"
                            $solutionId = $solutions.CrmRecords[0].solutionid
                            $connRefs = (Get-CrmRecords -conn $CRMConn -EntityLogicalName connectionreference -FilterAttribute "solutionid" -FilterOperator eq -FilterValue $solutionid -Fields connectionreferencelogicalname, connectionid, connectorid, connectionreferenceid).CrmRecords
                            $connRefs | Where-Object { $null -eq $_.connectionid } | ForEach-Object {
                                $connectionType = $_.connectorid.Replace("/providers/Microsoft.PowerApps/apis/", "")
                                Write-Host "Found Connection Reference $($_.connectionreferencelogicalname) without a Connection to $connectionType"
                            
                                Write-Host "Getting Connections in Environment"
                                $connection = Get-AdminPowerAppConnection -EnvironmentName $EnvId | Where-Object ConnectorName -eq $connectionType
                                if ($connection) {
                                    # Get Dataverse systemuserid for the system user that maps to the aad user guid that created the connection
                                    $systemusers = Get-CrmRecords -conn $conn -EntityLogicalName systemuser -FilterAttribute "azureactivedirectoryobjectid" -FilterOperator "eq" -FilterValue $connection[0].CreatedBy.id -Fields domainname
                                    if ($systemusers.Count -gt 0) {
                                        Write-Host "Impersonating the Owner of the Connection - $($systemusers.CrmRecords[0].domainname)"
                                        # Impersonate the Dataverse systemuser that created the connection when updating the connection reference
                                        $impersonationCallerId = $systemusers.CrmRecords[0].systemuserid
                                        $impersonationConn = $CRMConn
                                        $impersonationConn.OrganizationWebProxyClient.CallerId = $impersonationCallerId 
                                        Write-Host "##[command] Setting Connection Reference to use $($connection[0].DisplayName)"
                                        Set-CrmRecord -conn $impersonationConn -EntityLogicalName $_.logicalname -Id $_.connectionreferenceid -Fields @{"connectionid" = $connection[0].ConnectionName }                                    
                                    }
                                }
                                else {
                                    Write-Warning "##[warning] No Connection has been set up of type $connectionType, some of your Flows may not Activate succesfully"
                                }
                            
                            }

                            Write-Host "Checking if there are Flows that need to be Activated"
                            if ($Deploy.FlowsToActivate.Count -gt 0) {
                                Write-Host "There are $($Deploy.FlowsToActivate.Count) Flows that need activating"
                                $Deploy.FlowsToActivate | ForEach-Object {
                                    $workflow = Get-CrmRecord -conn $CRMConn -EntityLogicalName workflow -Id $_.FlowId -Fields clientdata, category, statecode, name
                                    if ($_.ActivateAsUser) {
                                        Write-Host "ActivateAsUser defined and set to : $($_.ActivateAsUser), attempting to Active Flow as this user"
                                        $systemuserResult = Get-CrmRecords -conn $CRMConn -EntityLogicalName systemuser -FilterAttribute "domainname" -FilterOperator "eq" -FilterValue $_.ActivateAsUser
                                        if ($systemuserResult.Count -gt 0) {
                                            $systemUserId = $systemuserResult.CrmRecords[0].systemuserid
                                            #Activate the workflow using the owner.
                                            if ($workflow.statecode -ne "Activated") {
                                                $impersonationConn = $CRMConn
                                                $impersonationCallerId = $systemUserId
                                                $impersonationConn.OrganizationWebProxyClient.CallerId = $impersonationCallerId 
                                                Write-Host "##[command] Enabling Flow $($workflow.name)"  
                                                try {
                                                    Set-CrmRecordState -conn $impersonationConn -EntityLogicalName workflow -Id $_.FlowId -StateCode Activated -StatusCode Activated    
                                                }
                                                catch {
                                                    Write-Warning "##[warning] There was an error activating the Flow, please confirm that the user exists and that the appropriate connections have been created as this user in the Environment"
                                                    Write-Host $_
                                                }
                                            
                                            }    
                                        }
                                        Write-Warning "##[warning] User $($_.ActivateAsUser) was not found in $($Deploy.EnvironmentName)"
                                    }
                                    else {
                                        Write-Host "Attempting to Activate Flow as Owner of Connection Reference"
                                        
                                        $solutions = Get-CrmRecords -conn $CRMConn -EntityLogicalName solution -FilterAttribute "uniquename" -FilterOperator "eq" -FilterValue "$PSolution"
                                        $solutionId = $solutions.CrmRecords[0].solutionid
                                        $connRefs = (Get-CrmRecords -conn $CRMConn -EntityLogicalName connectionreference -FilterAttribute "solutionid" -FilterOperator eq -FilterValue $solutionid -Fields connectionreferencelogicalname, connectionid, connectorid, connectionreferenceid).CrmRecords
                                        $connRefToUse = $connRefs | Where-Object { $null -ne $_.connectionid } | Select-Object -First 1
                                        $connection = Get-AdminPowerAppConnection -EnvironmentName $EnvId -Filter $connRefToUse.ConnectionId
                                        # Get Dataverse systemuserid for the system user that maps to the aad user guid that created the connection
                                        $systemusers = Get-CrmRecords -conn $conn -EntityLogicalName systemuser -FilterAttribute "azureactivedirectoryobjectid" -FilterOperator "eq" -FilterValue $connection[0].CreatedBy.id
                                        if ($systemusers.Count -gt 0) {
                                            # Impersonate the Dataverse systemuser that created the connection when updating the connection reference
                                            $impersonationCallerId = $systemusers.CrmRecords[0].systemuserid
                                            if ($workflow.statecode -ne "Activated") {
                                                Write-Host "##[command] Enabling Flow $($workflow.name)"
                                                $impersonationConn = $CRMConn
                                                $impersonationConn.OrganizationWebProxyClient.CallerId = $impersonationCallerId 
                                                Set-CrmRecordState -conn $impersonationConn -EntityLogicalName workflow -Id $_.FlowId -StateCode Activated -StatusCode Activated
                                            }

                                        }
                                        
                                    }
                                }
                            }
                                    
                            else {
                                Write-Host @"
No Flows were specified for activation. If you wish to include flows for activation, please add the following in deployPackages.json
 
"FlowsToActivate": [
    {
        "FlowId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
        "FlowName" : "A Flow Name",
        "ActivateAsUser" : "Users login name e.g user@tenant.onmicrosoft.com"
    }
]
                             
"@

                            }
                        }
                                      

                        [int]$elapsedTime = $stopwatch.Elapsed.TotalMinutes      
                        $stopwatch.Stop()
                        Write-Host "##[section] Import Complete in $($elapsedTime) minutes"
                    }
                }
                catch {
                    Write-Host "##[section] Skipping $PSolution due to Solution import error"
                    Write-Error "##[error]:$($_.Exception.Message)"
                }
            }
            else {
                Write-Host "##[warning] $($package.SolutionName) is not configured for deployment to $env:ENVIRONMENT_NAME in deployPackages.json" 
            }
            Write-Host "##[endgroup]"
        }

        #EXECUTE ENVIRONMENT POST ACTION
        if ($null -ne $EnvConfig -and $EnvConfig.PostAction -eq $true) {
            Write-Host "##[section] Execute Environment Post Action"
            . "$PipelinePath\Common\Environments\Scripts\PostAction.ps1" -Conn $CRMConn -PipelinePath $PipelinePath -EnvironmentName $EnvConfig.EnvironmentName -EnvironmentUrl $DeployServerUrl
            $EnvConfig.PostFunctions | ForEach-Object {
                & $_ -Conn $CRMConn
            }
            Write-Host "##[command] Environment Post Action Complete" 
        }
    }
    Write-Host Environment $EnvironmentName
    Import-Package
}