functions/New-UnifiedEnvironment.ps1


<#
    .SYNOPSIS
        Deploy a new Unified Environment in Power Platform Admin Center (PPAC).
         
    .DESCRIPTION
        Deploys a new Unified Environment in Power Platform Admin Center (PPAC).
         
        Support D365 Finance and Operations, either Developer Edition (UDE) or Unified Sandbox Environment (USE).
         
    .PARAMETER Type
        Instructs the cmdlet to create either a Unified Sandbox Environment (USE) or a Unified Developer Environment (UDE).
         
        Valid values are:
        - "USE": Deploys a Unified Sandbox Environment (USE) which is a sandbox environment without developer tools.
        - "UDE": Deploys a Unified Developer Environment (UDE) which is a sandbox environment with developer tools.
         
    .PARAMETER Name
        Name of the new environment as it will be displayed in Power Platform Admin Center (PPAC).
         
    .PARAMETER CustomDomainName
        The custom domain name to be associated with the new environment.
         
        E.g. "demo-time" will create the environment URLs:
        - "https://demo-time.crmX.dynamics.com".
        - "https://demo-time.operations.eu.dynamics.com"
         
    .PARAMETER Location
        The deployment location for the new environment.
         
        This translates to the Power Platform location where the environment will be created.
         
        Data residency and compliance requirements should be considered when selecting the location.
         
        Get-PpacDeployLocation can be used to find available locations.
         
    .PARAMETER Region
        The Azure region for the new environment.
         
        It specifies the physical location of the data center where the environment will be hosted.
         
        Get-PpacDeployLocation | Format-List can be used to find possible regions.
         
    .PARAMETER NoDemoDb
        Instructs the cmdlet to create the environment without a demo database.
         
    .PARAMETER Version
        The version of the Finance and Operations application to be installed in the new environment.
         
    .PARAMETER SecurityGroup
        Entra Groups security group to restrict access to the new environment.
         
    .PARAMETER PostProvisionDelaySeconds
        Additional delay (in seconds) after the shell environment reports as ready.
         
        This pause helps ensure the platform application package endpoint is fully ready before install is attempted.
         
    .PARAMETER ReadyStateTimeoutMinutes
        Maximum number of minutes to wait for the environment to reach state 'Ready'.
         
        Prevents endless waiting when an environment is stuck in a non-ready state.
         
    .PARAMETER WaitForCompletion
        Instructs the cmdlet to wait until the final provisioning app installation is completed.
         
    .EXAMPLE
        PS C:\> New-UnifiedEnvironment -Type "UDE" -Name "MyUdeEnv" -Location "Europe"
         
        This will create a new Unified Developer Environment (UDE) named "MyUdeEnv" in the "Europe" location.
        It will include a demo database by default.
        It will get a default/unique domain name assigned by Power Platform.
        It will take the latest available version of Finance and Operations.
        It will not restrict access to the environment.
         
        It will deploy into the North Europe region, as it's the default region for the Europe location.
         
    .EXAMPLE
        PS C:\> New-UnifiedEnvironment -Type "USE" -Name "MyUseEnv" -Location "Europe" -Region "West Europe"
         
        This will create a new Unified Sandbox Environment (USE) named "MyUseEnv" in the "Europe" location.
        It will deploy into the "West Europe" region.
        It will include a demo database.
        It will get a default/unique domain name assigned by Power Platform.
        It will take the latest available version of Finance and Operations.
        It will not restrict access to the environment.
         
    .EXAMPLE
        PS C:\> New-UnifiedEnvironment -Type "UDE" -Name "MyUdeEnv" -Location "Europe" -CustomDomainName "myudeenv"
         
        This will create a new Unified Developer Environment (UDE) named "MyUdeEnv" in the "Europe" location.
        It will include a demo database by default.
        It will get the custom domain name "myudeenv".
        It will take the latest available version of Finance and Operations.
        It will not restrict access to the environment.
         
    .EXAMPLE
        PS C:\> New-UnifiedEnvironment -Type "USE" -Name "MyUseEnv" -Location "Europe" -NoDemoDb
         
        This will create a new Unified Sandbox Environment (USE) named "MyUseEnv" in the "Europe" location.
        It will not include a demo database.
        It will get a default/unique domain name assigned by Power Platform.
        It will take the latest available version of Finance and Operations.
        It will not restrict access to the environment.
         
    .EXAMPLE
        PS C:\> New-UnifiedEnvironment -Type "UDE" -Name "MyUdeEnv" -Location "Europe" -Version "10.0.44"
         
        This will create a new Unified Developer Environment (UDE) named "MyUdeEnv" in the "Europe" location.
        It will include a demo database by default.
        It will get a default/unique domain name assigned by Power Platform.
        It will install version 10.0.44 of Finance and Operations.
        It will not restrict access to the environment.
         
    .EXAMPLE
        PS C:\> New-UnifiedEnvironment -Type "USE" -Name "MyUseEnv" -Location "Europe" -SecurityGroup "MySecurityGroup"
         
        This will create a new Unified Sandbox Environment (USE) named "MyUseEnv" in the "Europe" location.
        It will include a demo database by default.
        It will get a default/unique domain name assigned by Power Platform.
        It will take the latest available version of Finance and Operations.
        It will restrict access to the environment to members of the specified Entra Groups security group "MySecurityGroup".
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
         
#>

function New-UnifiedEnvironment {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter (Mandatory = $true)]
        [ValidateSet("UDE", "USE")]
        [string] $Type,

        [Parameter (Mandatory = $true)]
        [string] $Name,

        [string] $CustomDomainName,

        [Parameter (Mandatory = $true)]
        [string] $Location,

        [string] $Region,

        [switch] $NoDemoDb,

        [version] $Version,

        [Alias('EntraGroup')]
        [string] $SecurityGroup,

        [ValidateRange(0, 300)]
        [int] $PostProvisionDelaySeconds = 60,

        [ValidateRange(1, 720)]
        [int] $ReadyStateTimeoutMinutes = 60,

        [switch] $WaitForCompletion
    )
    
    begin {
        $SecurityGroupId = $null

        $secureTokenBap = (Get-AzAccessToken -ResourceUrl "https://service.powerapps.com/" -AsSecureString).Token
        $tokenBapValue = ConvertFrom-SecureString -AsPlainText -SecureString $secureTokenBap

        $headersBapApi = @{
            "Authorization" = "Bearer $($tokenBapValue)"
        }

        $secureTokenPowerApi = (Get-AzAccessToken -ResourceUrl "https://api.powerplatform.com/" -AsSecureString).Token
        $tokenPowerApiValue = ConvertFrom-SecureString -AsPlainText -SecureString $secureTokenPowerApi
    
        if ($SecurityGroup) {
            $SecurityGroupId = Get-GraphGroup `
                -Group $SecurityGroup | `
                Select-Object -ExpandProperty id
        }
        
        if (Test-PSFFunctionInterrupt) { return }
    }
    
    process {
        if (Test-PSFFunctionInterrupt) { return }

        $shellEnvironmentParams = @{
            Name                     = $Name
            HeadersBapApi            = $headersBapApi
            Location                 = $Location
            Region                   = $Region
            CustomDomainName         = $CustomDomainName
            SecurityGroupId          = $SecurityGroupId
            PostProvisionDelaySeconds = $PostProvisionDelaySeconds
            ReadyStateTimeoutMinutes = $ReadyStateTimeoutMinutes
        }

        $shellEnvironment = New-ShellEnvironment @shellEnvironmentParams

        if ($null -eq $shellEnvironment) { return }

        $environmentExists = $shellEnvironment.EnvironmentExists
        $environmentReady = $shellEnvironment.EnvironmentReady
        $envObj = $shellEnvironment.Environment

        if ($environmentExists -and $environmentReady) {

            $platformInstallParams = @{
                Name               = $Name
                Environment        = $envObj
                TokenPowerApiValue = $tokenPowerApiValue
            }

            $appObj = Install-PlatformApplicationPackage @platformInstallParams

            if ($null -eq $appObj) { return }

            $provisioningParams = @{
                Name              = $Name
                Type              = $Type
                NoDemoDb          = $NoDemoDb.IsPresent
                Version           = $Version
                WaitForCompletion = $WaitForCompletion.IsPresent
            }

            $appObj = Start-PlatformProvisioning @provisioningParams

            if ($null -eq $appObj) { return }

            # Output the app details, for the user to see
            $appObj
        }
    }
    
    end {
        
    }
}

function New-ShellEnvironment {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Name,

        [Parameter(Mandatory = $true)]
        [hashtable] $HeadersBapApi,

        [Parameter(Mandatory = $true)]
        [string] $Location,

        [string] $Region,

        [string] $CustomDomainName,

        [string] $SecurityGroupId,

        [Parameter(Mandatory = $true)]
        [int] $PostProvisionDelaySeconds,

        [Parameter(Mandatory = $true)]
        [int] $ReadyStateTimeoutMinutes
    )

    $localUri = 'https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments?api-version=2024-05-01'

    $config = [PsCustomObject][ordered]@{
        location   = $Location
        properties = [PsCustomObject][ordered]@{
            databaseType              = "CommonDataService"
            description               = ""
            displayName               = $Name
            environmentSku            = "Sandbox" # UDE & USE - Can only be Sandbox
            linkedEnvironmentMetadata = [PsCustomObject][ordered]@{
                baseLanguage = "" # Maybe it selects the TenantDefault
                currency     = $null # Maybe it selects the TenantDefault
                templates    = @("D365_DeveloperEdition")
            }
        }
    }

    if ($Region) {
        $config.properties | `
            Add-Member -MemberType NoteProperty `
            -Name azureRegion `
            -Value $Region
    }

    if ($CustomDomainName) {
        $config.properties.linkedEnvironmentMetadata | `
            Add-Member -MemberType NoteProperty `
            -Name domainName `
            -Value $CustomDomainName
    }

    if ($null -ne $SecurityGroupId) {
        $config.properties.linkedEnvironmentMetadata | `
            Add-Member -MemberType NoteProperty `
            -Name securityGroupId `
            -Value $SecurityGroupId
    }

    $payload = $config | ConvertTo-Json -Depth 10

    $environmentExists = $false
    $environmentReady = $false
    $statusEnv = $null
    $envObj = $null

    # Phase 1: Ensure environment exists
    $envObj = Get-BapEnvironment -EnvironmentId $Name | `
        Select-Object -First 1

    if ($null -ne $envObj) {
        $environmentExists = $true
        Write-PSFMessage -Level Verbose -Message "Environment '$Name' already exists. Skipping shell provisioning and moving to readiness checks."
    }
    else {
        $createEnvironmentParams = @{
            Method            = 'Post'
            Uri               = $localUri
            Headers           = $HeadersBapApi
            Body              = $payload
            ContentType       = 'application/json'
            SkipHttpErrorCheck = $true
            StatusCodeVariable = 'statusEnv'
        }

        Invoke-RestMethod @createEnvironmentParams > $null 4>$null

        if ($statusEnv -like "2**") {
            $environmentExists = $true
        }
    }

    # Phase 2: Ensure environment is ready
    if ($environmentExists) {
        $readyStateDeadline = (Get-Date).AddMinutes($ReadyStateTimeoutMinutes)

        do {
            $envObj = Get-BapEnvironment -EnvironmentId $Name | `
                Select-Object -First 1

            if ($null -eq $envObj) {
                Stop-PSFFunction -Message "Environment '$Name' could not be found while waiting for it to become ready."
                return
            }

            $environmentReady = $envObj.State -eq "Ready"

            if (-not $environmentReady) {
                if ((Get-Date) -ge $readyStateDeadline) {
                    $messageString = "Environment '$Name' did not reach state 'Ready' within $ReadyStateTimeoutMinutes minutes. Last known state was '$($envObj.State)'."
                    Write-PSFMessage -Level Important -Message $messageString
                    Stop-PSFFunction -Message "Stopping because environment readiness timed out." -Exception $([System.Exception]::new($messageString))
                    return
                }

                Write-PSFMessage -Level Verbose -Message "Waiting for environment '$Name' to be provisioned and reach state 'Ready'..."
                Start-Sleep -Seconds 20
            }
        } until ($environmentReady)

        if ($statusEnv -like "2**" -and $PostProvisionDelaySeconds -gt 0) {
            $progressActivity = "Waiting for Microsoft to finish provisioning the environment '$Name' and for the platform package endpoint to be ready..."
            for ($secondsElapsed = 0; $secondsElapsed -lt $PostProvisionDelaySeconds; $secondsElapsed++) {
                $secondsRemaining = $PostProvisionDelaySeconds - $secondsElapsed
                $percentComplete = [Math]::Floor(($secondsElapsed / $PostProvisionDelaySeconds) * 100)

                Write-Progress `
                    -Activity $progressActivity `
                    -Status "Give it a minute... $secondsRemaining sec remaining" `
                    -PercentComplete $percentComplete

                Start-Sleep -Seconds 1
            }

            Write-Progress `
                -Activity $progressActivity `
                -Status "Installing platform package next." `
                -PercentComplete 100 `
                -Completed
        }

        Write-PSFMessage -Level Verbose -Message "Environment '$Name' is ready for provisioning and platform package installation."
    }

    [PsCustomObject]@{
        EnvironmentExists = $environmentExists
        EnvironmentReady  = $environmentReady
        Environment       = $envObj
        StatusCode        = $statusEnv
    }
}

function Install-PlatformApplicationPackage {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Name,

        [Parameter(Mandatory = $true)]
        [psobject] $Environment,

        [Parameter(Mandatory = $true)]
        [string] $TokenPowerApiValue
    )

    $platformAppParams = @{
        EnvironmentId = $Name
        Name          = 'Dynamics 365 Finance and Operations Platform Tools'
    }

    $appPlatform = Get-PpacD365App @platformAppParams | Select-Object -First 1

    $headersLocal = @{
        "Authorization" = "Bearer $($TokenPowerApiValue)"
        "Content-Type"  = "application/json"
    }

    $localUri = "https://api.powerplatform.com/appmanagement/environments/{0}/applicationPackages/{1}/install?api-version=2022-03-01-preview" `
        -f $Environment.PpacEnvId `
        , $appPlatform.PpacPackageName

    $statusPlat = $null
    $platformInstallMaxAttempts = 3

    for ($platformInstallAttempt = 1; $platformInstallAttempt -le $platformInstallMaxAttempts; $platformInstallAttempt++) {
        Write-PSFMessage -Level Verbose -Message "Installing platform package '$($appPlatform.PpacPackageName)' (attempt $platformInstallAttempt of $platformInstallMaxAttempts)..."

        $platformInstallParams = @{
            Method            = 'Post'
            Uri               = $localUri
            Headers           = $headersLocal
            Body              = '{}'
            SkipHttpErrorCheck = $true
            StatusCodeVariable = 'statusPlat'
        }

        Invoke-RestMethod @platformInstallParams > $null 4>$null

        if ($statusPlat -like "2**") {
            break
        }

        if ($platformInstallAttempt -lt $platformInstallMaxAttempts) {
            Write-PSFMessage -Level Verbose -Message "Platform package install attempt failed with status '$statusPlat'. Retrying in 10 seconds..."
            Start-Sleep -Seconds 10
        }
    }

    if (-not ($statusPlat -like "2**")) {
        $messageString = "Failed to install the platform application package: <c='em'>$($appPlatform.PpacPackageName)</c>. Please check the environment and try installing the package manually."
        Write-PSFMessage -Level Important -Message $messageString
        Stop-PSFFunction -Message "Stopping because installing the platform application package failed." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -StepsUpward 1
        return
    }

    $appPlatformInstalled = $false

    do {
        Write-PSFMessage -Level Verbose -Message "Waiting for platform app to be installed ..."
        Start-Sleep -Seconds 20

        $appObj = Get-PpacD365App @platformAppParams | Select-Object -First 1

        $appPlatformInstalled = $appObj.Status -eq "Installed"
    } until ($appPlatformInstalled -eq $true)

    $appObj
}

function Start-PlatformProvisioning {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Name,

        [Parameter(Mandatory = $true)]
        [ValidateSet("UDE", "USE")]
        [string] $Type,

        [Parameter(Mandatory = $true)]
        [switch] $NoDemoDb,

        [version] $Version,

        [switch] $WaitForCompletion
    )

    <#
        Platform version is 10.0.X for humans, but the application package version is 10.0.X.Y,
        so we need to get the latest available version and find the matching one.
    #>

    if (-not [System.String]::IsNullOrWhiteSpace($Version)) {
        $tmpVersion = $Version.ToString().Substring(0, 7)
        $colVersions = Get-PpacD365PlatformUpdate `
            -EnvironmentId $Name

        $deployVersion = $colVersions | `
            Where-Object Platform -eq $tmpVersion | `
            Select-Object -First 1
    }
    else {
        $deployVersion = Get-PpacD365PlatformUpdate `
            -EnvironmentId $Name `
            -Latest | `
            Select-Object -First 1
    }
    
    if ($null -eq $deployVersion) {
        $messageString = "The specified version <c='em'>$Version</c> was not valid for the environment. Please verify the available versions using the <c='em'>Get-PpacD365PlatformUpdate</c> cmdlet."
        Write-PSFMessage -Level Important -Message $messageString
        Stop-PSFFunction -Message "The specified version was not valid for the environment." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        return
    }

    $envObj = Get-BapEnvironment -EnvironmentId $Name | Select-Object -First 1
    $baseUri = $envObj.PpacEnvUri

    $secureToken = (Get-AzAccessToken -ResourceUrl $baseUri -AsSecureString).Token
    $tokenWebApiValue = ConvertFrom-SecureString -AsPlainText -SecureString $secureToken

    $headersWebApi = @{
        "Authorization" = "Bearer $($tokenWebApiValue)"
        "Content-Type"  = "application/json"
    }

    $localUri = $baseUri + '/api/data/v9.2/msprov_queuefnoinstallorupdate'

    $payload = [PsCustomObject][ordered]@{
        "payload" = "ApplicationVersion=$($deployVersion.Version)|DevToolsEnabled=$($Type -eq 'UDE')|DemoDataEnabled=$(-not $NoDemoDb)"
    } | ConvertTo-Json -Depth 3

    $provisioningRequestParams = @{
        Method             = 'Post'
        Uri                = $localUri
        Headers            = $headersWebApi
        Body               = $payload
        ContentType        = $headersWebApi.'Content-Type'
        SkipHttpErrorCheck = $true
        StatusCodeVariable = 'statusProvision'
    }

    Invoke-RestMethod @provisioningRequestParams > $null 4>$null

    if (-not ($statusProvision -like "2**")) {
        $messageString = "Failed to provision the environment with the specified version: <c='em'>$($deployVersion.Version)</c>. Please check the environment and try provisioning manually."
        Write-PSFMessage -Level Important -Message $messageString
        Stop-PSFFunction -Message "Stopping because provisioning the environment with the specified version failed." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        return
    }

    $provisioningAppParams = @{
        EnvironmentId = $Name
        Name          = 'Dynamics 365 Finance and Operations Provisioning App'
    }

    do {
        Write-PSFMessage -Level Verbose -Message "Waiting for provisioning installation to be queued ..."
        Start-Sleep -Seconds 20

        $appObj = Get-PpacD365App @provisioningAppParams
    } while (-not $appObj.StateIsInstalled)

    while ($WaitForCompletion -and $appObj.Status -ne "Installed") {
        Write-PSFMessage -Level Verbose -Message "Waiting for provisioning installation to be completed ..."
        Start-Sleep -Seconds 20

        $appObj = Get-PpacD365App @provisioningAppParams
    }

    $appObj
}