Public/Deploy-FinOpsHub.ps1

# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

<#
    .SYNOPSIS
    Deploys a FinOps hub instance.
 
    .DESCRIPTION
    The Deploy-FinOpsHub command either creates a new or updates an existing FinOps hub instance by deploying an Azure Resource Manager deployment template. The FinOps hub template is downloaded from GitHub.
 
    Deploy-FinOpsHub calls Initialize-FinOpsHubDeployment before deploying the template.
 
    .PARAMETER Name
    Required. Name of the hub. Used to ensure unique resource names.
 
    .PARAMETER ResourceGroupName
    Required. Name of the resource group to deploy to. Will be created if it doesn't exist.
 
    .PARAMETER Location
    Required. Azure location where all resources should be created. See https://aka.ms/azureregions.
 
    .PARAMETER Version
    Optional. Version of the FinOps hub template to use. Default = "latest".
 
    .PARAMETER Preview
    Optional. Indicates that preview releases should also be included. Default = false.
 
    .PARAMETER StorageSku
    Optional. Storage SKU to use. LRS = Lowest cost, ZRS = High availability. Note Standard SKUs are not available for Data Lake gen2 storage. Allowed: Premium_LRS, Premium_ZRS. Default: Premium_LRS.
 
    .PARAMETER EnableInfrastructureEncryption
    Optional. Enable infrastructure encryption on the storage account. Default = false.
 
    .PARAMETER EnablePurgeProtection
    Optional. Enable purge protection for the Key Vault. Default = false.
 
    .PARAMETER RemoteHubStorageUri
    Optional. Storage account to push data to for ingestion into a remote hub.
 
    .PARAMETER RemoteHubStorageKey
    Optional. Storage account key to use when pushing data to a remote hub.
 
    .PARAMETER EnableManagedExports
    Optional. Enable managed exports where your FinOps hub instance creates and runs Cost Management exports on your behalf. Not supported for Microsoft Customer Agreement (MCA) billing profiles. Default = false.
 
    .PARAMETER DataExplorerName
    Optional. Name of the Azure Data Explorer cluster to use for advanced analytics. If empty, Azure Data Explorer will not be deployed. Required to use with Power BI if you have more than $2-5M/mo in costs being monitored. Default: "" (do not use).
 
    .PARAMETER DataExplorerSku
    Optional. Name of the Azure Data Explorer SKU. Default: "Dev(No SLA)_Standard_E2a_v4".
 
    .PARAMETER DataExplorerCapacity
    Optional. Number of nodes to use in the cluster. Allowed values: 1 for the Basic SKU tier and 2-1000 for Standard. Default: 1 for dev/test SKUs, 2 for standard SKUs.
 
    .PARAMETER FabricQueryUri
    Optional. Microsoft Fabric eventhouse query URI. Default: "" (do not use).
 
    .PARAMETER FabricCapacityUnits
    Optional. Number of capacity units for the Microsoft Fabric capacity. This is the number in your Fabric SKU (e.g., Trial = 1, F2 = 2, F64 = 64). Allowed values: 1-2048. Default: 2.
 
    .PARAMETER Tags
    Optional. Tags to apply to all resources. We will also add the cm-resource-parent tag for improved cost roll-ups in Cost Management.
 
    .PARAMETER TagsByResource
    Optional. Tags to apply to resources based on their resource type. Resource type specific tags will be merged with tags for all resources.
 
    .PARAMETER ScopesToMonitor
    Optional. Array of scope IDs to monitor and ingest cost for. Used with managed exports to automatically create Cost Management exports. Scope ID formats:
    - EA billing account: /providers/Microsoft.Billing/billingAccounts/{enrollment-number}
    - MCA billing profile: /providers/Microsoft.Billing/billingAccounts/{billing-account-id}/billingProfiles/{billing-profile-id}
    - Subscription: /subscriptions/{subscription-id}
    - Resource group: /subscriptions/{subscription-id}/resourceGroups/{resource-group-name}
    Example: @('/subscriptions/00000000-0000-0000-0000-000000000000', '/subscriptions/11111111-1111-1111-1111-111111111111')
 
    .PARAMETER ExportRetentionInDays
    Optional. Number of days of data to retain in the msexports container. Default: 0.
 
    .PARAMETER IngestionRetentionInMonths
    Optional. Number of months of data to retain in the ingestion container. Default: 13.
 
    .PARAMETER DataExplorerRawRetentionInDays
    Optional. Number of days of data to retain in the Data Explorer *_raw tables. Default: 0.
 
    .PARAMETER DataExplorerFinalRetentionInMonths
    Optional. Number of months of data to retain in the Data Explorer *_final_v* tables. Default: 13.
 
    .PARAMETER EnablePublicAccess
    Optional. Enable public access to the data lake. Default: true.
 
    .PARAMETER VirtualNetworkAddressPrefix
    Optional. Address space for the workload. A /26 is required for the workload. Default: "10.20.30.0/26".
 
    .EXAMPLE
    Deploy-FinOpsHub -Name MyHub -ResourceGroupName MyNewResourceGroup -Location westus -DataExplorerName MyFinOpsHubCluster
 
    Deploys a FinOps hub instance named MyHub to the MyNewResourceGroup resource group with a new MyFinOpsHubCluster Data Explorer cluster. If the resource group does not exist, it will be created. If the hub already exists, it will be updated to the latest version.
 
    .EXAMPLE
    Deploy-FinOpsHub -Name MyHub -ResourceGroupName MyExistingResourceGroup -Location westus -Version 0.1.1
 
    Deploys a FinOps hub instance named MyHub to the MyExistingResourceGroup resource group using version 0.1.1 of the template. This version is required for Microsoft Online Services Agreement (MOSA) subscriptions since FOCUS exports aren't available from Cost Management. If the resource group does not exist, it will be created. If the hub already exists, it will be updated to version 0.1.1.
 
    .LINK
    https://aka.ms/ftk/Deploy-FinOpsHub
#>

function Deploy-FinOpsHub
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "")]
    [CmdletBinding(SupportsShouldProcess)]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

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

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

        [Parameter()]
        [string]
        $Version = 'latest',

        [Parameter()]
        [switch]
        $Preview,

        [Parameter()]
        [ValidateSet('Premium_LRS', 'Premium_ZRS')]
        [string]
        $StorageSku = 'Premium_LRS',

        [Parameter()]
        [switch]
        $EnableInfrastructureEncryption,

        [Parameter()]
        [switch]
        $EnablePurgeProtection,

        [Parameter()]
        [string]
        $RemoteHubStorageUri,

        [Parameter()]
        [string]
        $RemoteHubStorageKey,

        [Parameter()]
        [switch]
        $EnableManagedExports,

        [Parameter()]
        [string]
        $DataExplorerName,

        [Parameter()]
        [ValidateSet('Dev(No SLA)_Standard_E2a_v4', 'Dev(No SLA)_Standard_D11_v2', 'Standard_D11_v2', 'Standard_D12_v2', 'Standard_D13_v2', 'Standard_D14_v2', 'Standard_D16d_v5', 'Standard_D32d_v4', 'Standard_D32d_v5', 'Standard_DS13_v2+1TB_PS', 'Standard_DS13_v2+2TB_PS', 'Standard_DS14_v2+3TB_PS', 'Standard_DS14_v2+4TB_PS', 'Standard_E2a_v4', 'Standard_E2ads_v5', 'Standard_E2d_v4', 'Standard_E2d_v5', 'Standard_E4a_v4', 'Standard_E4ads_v5', 'Standard_E4d_v4', 'Standard_E4d_v5', 'Standard_E8a_v4', 'Standard_E8ads_v5', 'Standard_E8as_v4+1TB_PS', 'Standard_E8as_v4+2TB_PS', 'Standard_E8as_v5+1TB_PS', 'Standard_E8as_v5+2TB_PS', 'Standard_E8d_v4', 'Standard_E8d_v5', 'Standard_E8s_v4+1TB_PS', 'Standard_E8s_v4+2TB_PS', 'Standard_E8s_v5+1TB_PS', 'Standard_E8s_v5+2TB_PS', 'Standard_E16a_v4', 'Standard_E16ads_v5', 'Standard_E16as_v4+3TB_PS', 'Standard_E16as_v4+4TB_PS', 'Standard_E16as_v5+3TB_PS', 'Standard_E16as_v5+4TB_PS', 'Standard_E16d_v4', 'Standard_E16d_v5', 'Standard_E16s_v4+3TB_PS', 'Standard_E16s_v4+4TB_PS', 'Standard_E16s_v5+3TB_PS', 'Standard_E16s_v5+4TB_PS', 'Standard_E64i_v3', 'Standard_E80ids_v4', 'Standard_EC8ads_v5', 'Standard_EC8as_v5+1TB_PS', 'Standard_EC8as_v5+2TB_PS', 'Standard_EC16ads_v5', 'Standard_EC16as_v5+3TB_PS', 'Standard_EC16as_v5+4TB_PS', 'Standard_L4s', 'Standard_L8as_v3', 'Standard_L8s', 'Standard_L8s_v2', 'Standard_L8s_v3', 'Standard_L16as_v3', 'Standard_L16s', 'Standard_L16s_v2', 'Standard_L16s_v3', 'Standard_L32as_v3', 'Standard_L32s_v3')]
        [string]
        $DataExplorerSku = 'Dev(No SLA)_Standard_D11_v2',

        [Parameter()]
        [ValidateRange(1, 1000)]
        [int]
        $DataExplorerCapacity = 1,

        [Parameter()]
        [string]
        $FabricQueryUri,

        [Parameter()]
        [ValidateRange(1, 2048)]
        [int]
        $FabricCapacityUnits = 2,

        [Parameter()]
        [ValidateRange(0, 9999)]
        [int]
        $DataExplorerRawRetentionInDays = 0,

        [Parameter()]
        [ValidateRange(0, 999)]
        [int]
        $DataExplorerFinalRetentionInMonths = 13,

        [Parameter()]
        [switch]
        $DisablePublicAccess,

        [Parameter()]
        [string]
        $VirtualNetworkAddressPrefix = '10.20.30.0/26',

        [Parameter()]
        [hashtable]
        $Tags,

        [Parameter()]
        [hashtable]
        $TagsByResource,

        [Parameter()]
        [string[]]
        $ScopesToMonitor = @(),

        [Parameter()]
        [ValidateRange(0, 9999)]
        [int]
        $ExportRetentionInDays = 0,

        [Parameter()]
        [ValidateRange(0, 999)]
        [int]
        $IngestionRetentionInMonths = 13
    )

    # Initialize toolkitPath before try block to ensure cleanup works even if early failure occurs
    # Fixes issue #665: If an error occurred before $toolkitPath was set, the finally block failed
    # with 'Cannot bind argument to parameter Path because it is null', masking the real error
    $toolkitPath = $null

    try
    {
        # Ensure TEMP environment variable is set for Linux/Mac/Cloud Shell compatibility
        # Bicep CLI requires TEMP to be set for intermediate file operations
        if (-not $env:TEMP)
        {
            $env:TEMP = [System.IO.Path]::GetTempPath().TrimEnd([System.IO.Path]::DirectorySeparatorChar)
        }

        # Create resource group if it doesn't exist
        $resourceGroupObject = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction 'SilentlyContinue'
        if (-not $resourceGroupObject -and (Test-ShouldProcess $PSCmdlet $ResourceGroupName 'CreateResourceGroup'))
        {
            $resourceGroupObject = New-AzResourceGroup -Name $ResourceGroupName -Location $Location
        }

        # Create folder for download
        $toolkitPath = Join-Path ([System.IO.Path]::GetTempPath()) -ChildPath 'FinOpsToolkit'
        if (Test-ShouldProcess $PSCmdlet $toolkitPath 'CreateTempDirectory')
        {
            New-Directory -Path $toolkitPath
        }

        # Init deployment (register providers)
        Initialize-FinOpsHubDeployment -WhatIf:$WhatIfPreference

        # Download template
        if (Test-ShouldProcess $PSCmdlet $Version 'DownloadTemplate')
        {
            Save-FinOpsHubTemplate -Version $Version -Preview:$Preview -Destination $toolkitPath
            $bicepFile = Get-ChildItem -Path $toolkitPath -Include 'main.bicep' -Recurse | Where-Object -FilterScript { $_.FullName -like '*finops-hub-v*' }
            if (-not $bicepFile)
            {
                throw ($LocalizedData.Hub_Deploy_TemplateNotFound -f $toolkitPath)
            }

            $parameterSplat = @{
                TemplateFile            = $bicepFile.FullName
                TemplateParameterObject = @{
                    hubName    = $Name
                    storageSku = $StorageSku
                }
            }

            if ($Version -eq 'latest' -or [version]$Version -ge '0.4')
            {
                $parameterSplat.TemplateParameterObject.Add('remoteHubStorageUri', $RemoteHubStorageUri)
                $parameterSplat.TemplateParameterObject.Add('remoteHubStorageKey', $RemoteHubStorageKey)
            }

            if ($Version -eq 'latest' -or [version]$Version -ge '0.7')
            {
                $parameterSplat.TemplateParameterObject.Add('enableInfrastructureEncryption', $EnableInfrastructureEncryption.IsPresent)
                $parameterSplat.TemplateParameterObject.Add('dataExplorerName', $DataExplorerName)
                $parameterSplat.TemplateParameterObject.Add('dataExplorerSku', $DataExplorerSku)
                $parameterSplat.TemplateParameterObject.Add('dataExplorerCapacity', $DataExplorerCapacity)
                $parameterSplat.TemplateParameterObject.Add('dataExplorerRawRetentionInDays', $DataExplorerRawRetentionInDays)
                $parameterSplat.TemplateParameterObject.Add('dataExplorerFinalRetentionInMonths', $DataExplorerFinalRetentionInMonths)
                $parameterSplat.TemplateParameterObject.Add('enablePublicAccess', -not $DisablePublicAccess)
                $parameterSplat.TemplateParameterObject.Add('virtualNetworkAddressPrefix', $VirtualNetworkAddressPrefix)
                $parameterSplat.TemplateParameterObject.Add('exportRetentionInDays', $ExportRetentionInDays)
                $parameterSplat.TemplateParameterObject.Add('ingestionRetentionInMonths', $IngestionRetentionInMonths)
                $parameterSplat.TemplateParameterObject.Add('scopesToMonitor', $ScopesToMonitor)
            }

            if ($Version -eq 'latest' -or [version]$Version -ge '0.10')
            {
                $parameterSplat.TemplateParameterObject.Add('fabricQueryUri', $FabricQueryUri)
                $parameterSplat.TemplateParameterObject.Add('fabricCapacityUnits', $FabricCapacityUnits)
            }

            if ($Version -eq 'latest' -or [version]$Version -ge '12.0')
            {
                $parameterSplat.TemplateParameterObject.Add('enableManagedExports', $EnableManagedExports.IsPresent)
            }

            if ($Version -eq 'latest' -or [version]$Version -ge '13.0')
            {
                $parameterSplat.TemplateParameterObject.Add('enablePurgeProtection', $EnablePurgeProtection.IsPresent)
            }

            if ($Tags -and $Tags.Keys.Count -gt 0)
            {
                $parameterSplat.TemplateParameterObject.Add('tags', $Tags)
            }

            if ($TagsByResource -and $TagsByResource.Keys.Count -gt 0)
            {
                $parameterSplat.TemplateParameterObject.Add('tagsByResource', $TagsByResource)
            }
        }

        # Run the deployment
        if (Test-ShouldProcess $PSCmdlet $ResourceGroupName 'DeployFinOpsHub')
        {
            Write-Verbose -Message ($LocalizedData.Hub_Deploy_Deploy -f $bicepFile.FullName, $resourceGroupObject.ResourceGroupName)
            return New-AzResourceGroupDeployment @parameterSplat -ResourceGroupName $resourceGroupObject.ResourceGroupName
        }
    }
    catch
    {
        throw $_.Exception.Message
    }
    finally
    {
        # Clean up downloaded files
        if ($toolkitPath)
        {
            Remove-Item -Path $toolkitPath -Recurse -Force -ErrorAction 'SilentlyContinue'
        }
    }
}