internal/sdk/provisioning.ps1

<#
    Internal runtime for Power Platform provisioning discovery data.
 
    New-UnifiedEnvironmentV2 validates -Location and -Template against the
    Power Platform provisioning discovery endpoints instead of sending raw
    user input to the API:
 
      GET /environmentmanagement/provisioning/locations
      GET /environmentmanagement/provisioning/locations/{location}/templates
      GET /environmentmanagement/provisioning/locations/{location}/languages
      GET /environmentmanagement/provisioning/locations/{location}/currencies
 
    A trimmed snapshot ships with the module
    (internal/sdk/provisioning-cache.json) and is loaded into memory on
    import. A throttled background job refreshes the snapshot on disk for
    the next import, so tab completion and validation keep working without
    network access while staying current.
#>


$script:ProvisioningCache = $null

# Templates New-UnifiedEnvironmentV2 supports. The discovery cache keeps
# every template the API returns; this list filters what the V2 cmdlet
# validates and completes.
$script:ProvisioningV2TemplateFilter = @('D365_FinOps*', 'D365_DeveloperEdition')

function Get-ProvisioningUserCachePath {
    [CmdletBinding()]
    param ()

    $base = if ($env:APPDATA) { $env:APPDATA } else { Join-Path $HOME '.config' }
    Join-Path (Join-Path $base 'd365bap.tools') 'provisioning-cache.json'
}

function Import-ProvisioningCache {
    [CmdletBinding()]
    param ()

    $script:ProvisioningCache = @{ locations = @(); templates = @{}; languages = @{}; currencies = @{} }

    $shipped = Join-Path $script:ModuleRoot 'internal/sdk/provisioning-cache.json'
    if (Test-Path -LiteralPath $shipped) {
        $script:ProvisioningCache = Get-Content -LiteralPath $shipped -Raw -Encoding utf8 |
            ConvertFrom-Json -AsHashtable -ErrorAction Stop
    }

    $userPath = Get-ProvisioningUserCachePath
    if (Test-Path -LiteralPath $userPath) {
        try {
            $overlay = Get-Content -LiteralPath $userPath -Raw -Encoding utf8 |
                ConvertFrom-Json -AsHashtable -ErrorAction Stop
            foreach ($key in @('locations', 'templates', 'languages', 'currencies', 'refreshedUtc')) {
                if ($overlay.ContainsKey($key)) { $script:ProvisioningCache[$key] = $overlay[$key] }
            }
        }
        catch {
            Write-PSFMessage -Level Verbose -Message "Ignoring unreadable provisioning cache at $userPath : $($_.Exception.Message)"
        }
    }

    Publish-ProvisioningCompletionData
}

function Publish-ProvisioningCompletionData {
    [CmdletBinding()]
    param ()

    $locations = @($script:ProvisioningCache.locations)
    Set-PSFConfig -FullName 'd365bap.tools.provisioning.locations' -Value $locations -Description 'Power Platform provisioning locations for tab completion.'
    Set-PSFConfig -FullName 'd365bap.tools.provisioning.v2TemplateFilter' -Value $script:ProvisioningV2TemplateFilter -Description 'Template allow-list for New-UnifiedEnvironmentV2 tab completion.'
    $templateIndex = @{}
    foreach ($locationName in @($script:ProvisioningCache.templates.Keys)) {
        $templateIndex[$locationName] = @(
            $script:ProvisioningCache.templates[$locationName] | ForEach-Object {
                @{
                    name        = $_.name
                    displayName = $_.displayName
                    skus        = @($_.availability | Where-Object { -not $_.isDisabled } | ForEach-Object { $_.environmentSku })
                }
            }
        )
    }
    Set-PSFConfig -FullName 'd365bap.tools.provisioning.templates' -Value $templateIndex -Description 'Provisioning templates per location for tab completion.'
}

function Start-ProvisioningCacheRefresh {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "", Justification = "Start-Job binds values through ArgumentList, the using scope modifier is not supported there.")]
    [CmdletBinding()]
    param (
        [int] $MaxAgeHours = 24
    )

    try {
        $userPath = Get-ProvisioningUserCachePath
        if (Test-Path -LiteralPath $userPath) {
            $age = [datetime]::UtcNow - (Get-Item -LiteralPath $userPath).LastWriteTimeUtc
            if ($age.TotalHours -lt $MaxAgeHours) { return }
        }

        if (Get-Job -Name 'd365bap.tools.provisioning.refresh' -State Running -ErrorAction SilentlyContinue) { return }
        Get-Job -Name 'd365bap.tools.provisioning.refresh' -ErrorAction SilentlyContinue | Remove-Job -Force -ErrorAction SilentlyContinue

        $null = Start-Job -Name 'd365bap.tools.provisioning.refresh' -ScriptBlock {
            param ([string] $CachePath)

            try {
                Import-Module Az.Accounts -ErrorAction Stop
                if (-not (Get-AzContext -ErrorAction SilentlyContinue)) { return }

                $secure = (Get-AzAccessToken -ResourceUrl 'https://api.powerplatform.com' -AsSecureString -ErrorAction Stop).Token
                $token = [Net.NetworkCredential]::new('', $secure).Password
                $headers = @{ Authorization = "Bearer $token" }
                $base = 'https://api.powerplatform.com'
                $api = '2024-10-01'

                $locations = (Invoke-RestMethod -Uri "$base/environmentmanagement/provisioning/locations?api-version=$api" -Headers $headers -ErrorAction Stop).collection
                $snapshot = [ordered]@{
                    refreshedUtc = [datetime]::UtcNow.ToString('o')
                    apiVersion   = $api
                    locations    = @($locations | ForEach-Object {
                            [ordered]@{
                                name                                          = $_.name
                                displayName                                   = $_.displayName
                                code                                          = $_.code
                                isDefault                                     = $_.isDefault
                                isDisabled                                    = $_.isDisabled
                                canProvisionDatabase                          = $_.canProvisionDatabase
                                hasFirstReleaseIslandAvailableForProvisioning = $_.hasFirstReleaseIslandAvailableForProvisioning
                            }
                        })
                    templates    = [ordered]@{}
                    languages    = [ordered]@{}
                    currencies   = [ordered]@{}
                }

                $languageLocations = @('europe', 'unitedstates', 'unitedkingdom', 'germany')
                if (Test-Path -LiteralPath $CachePath) {
                    try {
                        $previous = Get-Content -LiteralPath $CachePath -Raw -Encoding utf8 | ConvertFrom-Json -AsHashtable -ErrorAction Stop
                        foreach ($key in @($previous.languages.Keys)) {
                            if ($key -notin $languageLocations) { $languageLocations += $key }
                        }
                    }
                    catch { Write-PSFMessage -Level Verbose -Message "Provisioning refresh: keeping previous language list, could not read '$CachePath'." }
                }

                foreach ($location in @($locations.name)) {
                    try {
                        $templates = (Invoke-RestMethod -Uri "$base/environmentmanagement/provisioning/locations/$location/templates?api-version=$api" -Headers $headers -ErrorAction Stop).collection
                        $snapshot.templates[$location] = @($templates | ForEach-Object {
                                [ordered]@{
                                    name                           = $_.name
                                    displayName                    = $_.displayName
                                    isCustomerEngagement           = $_.isCustomerEngagement
                                    isSupportedForResetOperation   = $_.isSupportedForResetOperation
                                    availability                   = @($_.availability | ForEach-Object {
                                            $slot = [ordered]@{ environmentSku = $_.environmentSku; isDisabled = $_.isDisabled }
                                            if ($_.disabledReason) { $slot.disabledReason = [ordered]@{ code = $_.disabledReason.code } }
                                            $slot
                                        })
                                }
                            })
                    }
                    catch { $snapshot.templates[$location] = @() }
                }

                foreach ($location in $languageLocations) {
                    try {
                        $snapshot.languages[$location] = @((Invoke-RestMethod -Uri "$base/environmentmanagement/provisioning/locations/$location/languages?api-version=$api" -Headers $headers -ErrorAction Stop).collection |
                            ForEach-Object { [ordered]@{ localeId = $_.localeId; localizedName = $_.localizedName; isTenantDefault = $_.isTenantDefault } })
                        $snapshot.currencies[$location] = @((Invoke-RestMethod -Uri "$base/environmentmanagement/provisioning/locations/$location/currencies?api-version=$api" -Headers $headers -ErrorAction Stop).collection |
                            ForEach-Object { [ordered]@{ code = $_.code; symbol = $_.symbol; isTenantDefault = $_.isTenantDefault } })
                    }
                    catch { Write-PSFMessage -Level Verbose -Message "Provisioning refresh: skipping languages/currencies for location '$location'." }
                }

                $null = New-Item -ItemType Directory -Path (Split-Path -Parent $CachePath) -Force -ErrorAction Stop
                $snapshot | ConvertTo-Json -Depth 8 -ErrorAction Stop | Set-Content -LiteralPath $CachePath -Encoding utf8 -ErrorAction Stop
            }
            catch { Write-PSFMessage -Level Verbose -Message 'Provisioning refresh: background probe failed, keeping the previous cache.' }
        } -ArgumentList (Get-ProvisioningUserCachePath)
    }
    catch { Write-PSFMessage -Level Verbose -Message "Provisioning refresh was skipped: $($_.Exception.Message)" }
}

function Resolve-ProvisioningLocation {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Location
    )

    $locations = @($script:ProvisioningCache.locations)
    $match = @($locations | Where-Object { $_.name -eq $Location })
    if (-not $match) { $match = @($locations | Where-Object { $_.name -like $Location }) }
    if ($match.Count -eq 1) { return $match[0].name }
    if (-not $match) { $match = @($locations | Where-Object { $_.displayName -like $Location }) }
    if ($match.Count -eq 1) { return $match[0].name }

    $valid = @($locations | ForEach-Object { $_.name } | Sort-Object -Unique)
    throw "Unknown location '$Location'. Valid values: $($valid -join ', ')."
}

function Resolve-ProvisioningTemplate {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Location,

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

        [string] $EnvironmentSku = 'Sandbox',

        [string[]] $AllowedPattern
    )

    $templates = @($script:ProvisioningCache.templates[$Location])
    if ($AllowedPattern) {
        $templates = @($templates | Where-Object {
                $templateName = $_.name
                @($AllowedPattern | Where-Object { $templateName -like $_ }).Count -gt 0
            })
    }
    $match = @($templates | Where-Object { $_.name -eq $Template })
    if (-not $match) { $match = @($templates | Where-Object { $_.name -like $Template }) }

    if ($match.Count -ne 1) {
        $valid = @($templates | ForEach-Object { $_.name } | Sort-Object -Unique)
        throw "Unknown template '$Template' for location '$Location'. Supported values: $($valid -join ', ')."
    }

    $slot = @($match[0].availability | Where-Object { $_.environmentSku -eq $EnvironmentSku })
    if (-not $slot) {
        throw "Template '$Template' has no $EnvironmentSku slot in location '$Location'."
    }
    if ($slot[0].isDisabled) {
        # Advisory only: availability flags have been observed to disagree
        # across calls, so a disabled flag warns instead of blocking.
        $reason = $slot[0].disabledReason.code
        Write-PSFMessage -Level Warning -Message "Template '$Template' is currently reported as disabled for $EnvironmentSku in location '$Location' (reason: $reason). Provisioning will be attempted anyway."
    }
    return $match[0].name
}

function Get-ProvisioningTenantDefault {
    [CmdletBinding()]
    param (
        [string] $Location = 'europe'
    )

    $languages = @($script:ProvisioningCache.languages[$Location])
    if (-not $languages) {
        foreach ($key in @($script:ProvisioningCache.languages.Keys)) {
            $languages = @($script:ProvisioningCache.languages[$key])
            if ($languages) { break }
        }
    }
    $language = @($languages | Where-Object { $_.isTenantDefault } | Select-Object -First 1)
    $baseLanguage = if ($language) { [int]$language[0].localeId } else { 1033 }

    $currencies = @($script:ProvisioningCache.currencies[$Location])
    if (-not $currencies) {
        foreach ($key in @($script:ProvisioningCache.currencies.Keys)) {
            $currencies = @($script:ProvisioningCache.currencies[$key])
            if ($currencies) { break }
        }
    }
    $currency = @($currencies | Where-Object { $_.isTenantDefault } | Select-Object -First 1)

    [pscustomobject]@{
        BaseLanguage   = $baseLanguage
        CurrencyCode   = if ($currency) { $currency[0].code } else { 'USD' }
        CurrencySymbol = if ($currency) { $currency[0].symbol } else { '$' }
    }
}

Import-ProvisioningCache
Start-ProvisioningCacheRefresh