Repository: helderpinto/AzureOptimizationEngine Branch: master Commit: 143b27eac1ea Files: 107 Total size: 1.9 MB Directory structure: gitextract_09u_p9cv/ ├── .github/ │ └── workflows/ │ ├── continuous-deployment-dev-new.yml │ ├── continuous-deployment-dev.yml │ └── continuous-deployment.yml ├── .gitignore ├── Deploy-AzureOptimizationEngine.ps1 ├── LICENSE ├── README.md ├── Reset-AutomationSchedules.ps1 ├── Setup-BenefitsUsageDependencies.ps1 ├── Setup-DataCollectionRules.ps1 ├── Setup-LogAnalyticsWorkspaces.ps1 ├── Suppress-Recommendation.ps1 ├── azuredeploy-nested.bicep ├── azuredeploy.bicep ├── custom-recommendations-types.json ├── docs/ │ ├── configuring-workspaces.md │ ├── customizing-aoe.md │ └── suppressing-recommendations.md ├── model/ │ ├── filters-table.sql │ ├── loganalyticsingestcontrol-initialize.sql │ ├── loganalyticsingestcontrol-table.sql │ ├── loganalyticsingestcontrol-upgrade.sql │ ├── recommendations-sp.sql │ ├── recommendations-table.sql │ ├── sqlserveringestcontrol-initialize.sql │ └── sqlserveringestcontrol-table.sql ├── perfcounters.json ├── queries/ │ ├── rbac-spns-keys-expiring.kql │ ├── rbac-spns-roles-aad-all.kql │ ├── rbac-spns-roles-arm-all.kql │ ├── rbac-spns-roles-arm-privileged.kql │ ├── rbac-users-roles-aad-all.kql │ ├── rbac-users-roles-arm-privileged.kql │ ├── rbac-users-roles-guests-privileged.kql │ └── rbac-users-roles-guests.kql ├── runbooks/ │ ├── data-collection/ │ │ ├── Export-AADObjectsToBlobStorage.ps1 │ │ ├── Export-ARGAppGatewayPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGAppServicePlanPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGAvailabilitySetPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGLoadBalancerPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGManagedDisksPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGNICPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGNSGPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGPublicIpPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGResourceContainersPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGSqlDatabasePropertiesToBlobStorage.ps1 │ │ ├── Export-ARGUnmanagedDisksPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGVMSSPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGVNetPropertiesToBlobStorage.ps1 │ │ ├── Export-ARGVirtualMachinesPropertiesToBlobStorage.ps1 │ │ ├── Export-AdvisorRecommendationsToBlobStorage.ps1 │ │ ├── Export-AzMonitorMetricsToBlobStorage.ps1 │ │ ├── Export-ConsumptionToBlobStorage.ps1 │ │ ├── Export-PolicyComplianceToBlobStorage.ps1 │ │ ├── Export-PriceSheetToBlobStorage.ps1 │ │ ├── Export-RBACAssignmentsToBlobStorage.ps1 │ │ ├── Export-ReservationsPriceToBlobStorage.ps1 │ │ ├── Export-ReservationsUsageToBlobStorage.ps1 │ │ ├── Export-SavingsPlansUsageToBlobStorage.ps1 │ │ └── Ingest-OptimizationCSVExportsToLogAnalytics.ps1 │ ├── maintenance/ │ │ └── CleanUp-OlderRecommendationsFromSqlServer.ps1 │ ├── recommendations/ │ │ ├── Ingest-RecommendationsToLogAnalytics.ps1 │ │ ├── Ingest-RecommendationsToSQLServer.ps1 │ │ ├── Ingest-SuppressionsToLogAnalytics.ps1 │ │ ├── Recommend-AADExpiringCredentialsToBlobStorage.ps1 │ │ ├── Recommend-ARMOptimizationsToBlobStorage.ps1 │ │ ├── Recommend-AdvisorAsIsToBlobStorage.ps1 │ │ ├── Recommend-AdvisorCostAugmentedToBlobStorage.ps1 │ │ ├── Recommend-AppServiceOptimizationsToBlobStorage.ps1 │ │ ├── Recommend-DiskOptimizationsToBlobStorage.ps1 │ │ ├── Recommend-SqlDbOptimizationsToBlobStorage.ps1 │ │ ├── Recommend-StorageAccountOptimizationsToBlobStorage.ps1 │ │ ├── Recommend-UnattachedDisksToBlobStorage.ps1 │ │ ├── Recommend-UnusedAppGWsToBlobStorage.ps1 │ │ ├── Recommend-UnusedLoadBalancersToBlobStorage.ps1 │ │ ├── Recommend-VMOptimizationsToBlobStorage.ps1 │ │ ├── Recommend-VMSSOptimizationsToBlobStorage.ps1 │ │ ├── Recommend-VMsHighAvailabilityToBlobStorage.ps1 │ │ └── Recommend-VNetOptimizationsToBlobStorage.ps1 │ └── remediations/ │ ├── Remediate-AdvisorRightSizeFiltered.ps1 │ ├── Remediate-LongDeallocatedVMsFiltered.ps1 │ └── Remediate-UnattachedDisksFiltered.ps1 ├── upgrade-manifest.json └── views/ ├── AzureOptimizationEngine.pbix ├── powerbi-query.m └── workbooks/ ├── benefits-simulation.bicep ├── benefits-simulation.json ├── benefits-usage.bicep ├── benefits-usage.json ├── blockblobstorage-usage.bicep ├── blockblobstorage-usage.json ├── costs-growing.bicep ├── costs-growing.json ├── identities-roles.bicep ├── identities-roles.json ├── policy-compliance.bicep ├── policy-compliance.json ├── recommendations.bicep ├── recommendations.json ├── reservations-potential.bicep ├── reservations-potential.json ├── reservations-usage.bicep ├── reservations-usage.json ├── resources-inventory.bicep ├── resources-inventory.json ├── savingsplans-usage.bicep └── savingsplans-usage.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/continuous-deployment-dev-new.yml ================================================ name: AOE Continuous Deployment (DEV NEW) on: workflow_dispatch: push: branches: - dev permissions: id-token: write contents: read jobs: AOE-CD: environment: devnew runs-on: ubuntu-latest env: AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} AOE_SQL_ADMIN: ${{ secrets.AOE_SQL_ADMIN }} AOE_SQL_PASSWD: ${{ secrets.AOE_SQL_PASSWD }} AOE_LOCATION: ${{ secrets.AOE_LOCATION }} AOE_NAMEPREFIX: ${{ secrets.AOE_NAMEPREFIX }} steps: - run: echo "This job is now running on a ${{ runner.os }} server hosted by GitHub for the ${{ github.ref }} branch of the ${{ github.repository }} repository!" - name: Installing modules shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted Install-Module -Name Az.Accounts,Az.Resources,Az.Storage,Az.OperationalInsights,Az.Sql,Az.Automation,Microsoft.Graph.Authentication,Microsoft.Graph.Identity.DirectoryManagement -Force - name: Check out repository code uses: actions/checkout@v3 - name: Login via Az module uses: azure/login@hf_447_release with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} enable-AzPSSession: true - name: Create Deployment Settings JSON file run: | echo '{ "SubscriptionId": "'"$AZURE_SUBSCRIPTION_ID"'", "NamePrefix": "'"$AOE_NAMEPREFIX$(date '+%Y%m%d%H')"'", "WorkspaceReuse": "n", "DeployWorkbooks": "y", "SqlAdmin": "'"$AOE_SQL_ADMIN"'", "SqlPass": "'"$AOE_SQL_PASSWD"'", "TargetLocation": "'"$AOE_LOCATION"'", "DeployBenefitsUsageDependencies": "n" }' > ./deploymentSettings.json - name: Testing PowerShell script call shell: pwsh run: | ./Deploy-AzureOptimizationEngine.ps1 -SilentDeploymentSettingsPath ./deploymentSettings.json -TemplateUri "https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/dev/azuredeploy.bicep" - run: echo "🍏 This job's status is ${{ job.status }}." ================================================ FILE: .github/workflows/continuous-deployment-dev.yml ================================================ name: AOE Continuous Deployment (DEV) on: workflow_dispatch: push: branches: - dev permissions: id-token: write contents: read jobs: AOE-CD: environment: dev runs-on: ubuntu-latest env: AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} AOE_SQL_ADMIN: ${{ secrets.AOE_SQL_ADMIN }} AOE_SQL_PASSWD: ${{ secrets.AOE_SQL_PASSWD }} AOE_LOCATION: ${{ secrets.AOE_LOCATION }} AOE_NAMEPREFIX: ${{ secrets.AOE_NAMEPREFIX }} AOE_WORKSPACENAME: ${{ secrets.AOE_WORKSPACENAME }} AOE_WORKSPACERG: ${{ secrets.AOE_WORKSPACERG }} steps: - run: echo "This job is now running on a ${{ runner.os }} server hosted by GitHub for the ${{ github.ref }} branch of the ${{ github.repository }} repository!" - name: Installing modules shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted Install-Module -Name Az.Accounts,Az.Resources,Az.Storage,Az.OperationalInsights,Az.Sql,Az.Automation,Microsoft.Graph.Authentication,Microsoft.Graph.Identity.DirectoryManagement -Force - name: Check out repository code uses: actions/checkout@v3 - name: Login via Az module uses: azure/login@hf_447_release with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} enable-AzPSSession: true - name: Create Deployment Settings JSON file run: | echo '{ "SubscriptionId": "'"$AZURE_SUBSCRIPTION_ID"'", "NamePrefix": "'"$AOE_NAMEPREFIX"'", "WorkspaceReuse": "y", "WorkspaceName": "'"$AOE_WORKSPACENAME"'", "WorkspaceResourceGroupName": "'"$AOE_WORKSPACERG"'", "DeployWorkbooks": "y", "SqlAdmin": "'"$AOE_SQL_ADMIN"'", "SqlPass": "'"$AOE_SQL_PASSWD"'", "TargetLocation": "'"$AOE_LOCATION"'", "DeployBenefitsUsageDependencies": "n" }' > ./deploymentSettings.json - name: Testing PowerShell script call shell: pwsh run: | ./Deploy-AzureOptimizationEngine.ps1 -SilentDeploymentSettingsPath ./deploymentSettings.json -TemplateUri "https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/dev/azuredeploy.bicep" -DoPartialUpgrade - run: echo "🍏 This job's status is ${{ job.status }}." ================================================ FILE: .github/workflows/continuous-deployment.yml ================================================ name: AOE Continuous Deployment (PROD) on: workflow_dispatch: push: branches: - master permissions: id-token: write contents: read jobs: AOE-CD: environment: prod runs-on: ubuntu-latest env: AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} AOE_SQL_ADMIN: ${{ secrets.AOE_SQL_ADMIN }} AOE_SQL_PASSWD: ${{ secrets.AOE_SQL_PASSWD }} AOE_LOCATION: ${{ secrets.AOE_LOCATION }} AOE_NAMEPREFIX: ${{ secrets.AOE_NAMEPREFIX }} steps: - run: echo "This job is now running on a ${{ runner.os }} server hosted by GitHub for the ${{ github.ref }} branch of the ${{ github.repository }} repository!" - name: Installing modules shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted Install-Module -Name Az.Accounts,Az.Resources,Az.Storage,Az.OperationalInsights,Az.Sql,Az.Automation,Microsoft.Graph.Authentication,Microsoft.Graph.Identity.DirectoryManagement -Force - name: Check out repository code uses: actions/checkout@v3 - name: Login via Az module uses: azure/login@hf_447_release with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} enable-AzPSSession: true - name: Create Deployment Settings JSON file run: | echo '{ "SubscriptionId": "'"$AZURE_SUBSCRIPTION_ID"'", "NamePrefix": "'"$AOE_NAMEPREFIX"'", "WorkspaceReuse": "n", "DeployWorkbooks": "y", "SqlAdmin": "'"$AOE_SQL_ADMIN"'", "SqlPass": "'"$AOE_SQL_PASSWD"'", "TargetLocation": "'"$AOE_LOCATION"'", "DeployBenefitsUsageDependencies": "n" }' > ./deploymentSettings.json - name: Testing PowerShell script call shell: pwsh run: | ./Deploy-AzureOptimizationEngine.ps1 -SilentDeploymentSettingsPath ./deploymentSettings.json - run: echo "🍏 This job's status is ${{ job.status }}." ================================================ FILE: .gitignore ================================================ # Deployment state file last-deployment-state.json # Database connection settings (for the Suppress-Recommendation.ps1 helper script) database-connection-settings.json # Silent deployment settings file deployment-settings-*.json ================================================ FILE: Deploy-AzureOptimizationEngine.ps1 ================================================ param ( [Parameter(Mandatory = $false)] [string] $TemplateUri, [Parameter(Mandatory = $false)] [string] $AzureEnvironment = "AzureCloud", [Parameter(Mandatory = $false)] [switch] $DoPartialUpgrade, [Parameter(Mandatory = $false)] [switch] $IgnoreNamingAvailabilityErrors, [Parameter(Mandatory = $false)] [string] $SilentDeploymentSettingsPath, [Parameter(Mandatory = $false)] [hashtable] $ResourceTags = @{} ) function ConvertTo-Hashtable { [CmdletBinding()] [OutputType('hashtable')] param ( [Parameter(ValueFromPipeline)] $InputObject ) process { if ($null -eq $InputObject) { return $null } if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { $collection = @( foreach ($object in $InputObject) { ConvertTo-Hashtable -InputObject $object } ) Write-Output -NoEnumerate $collection } elseif ($InputObject -is [psobject]) { $hash = @{} foreach ($property in $InputObject.PSObject.Properties) { $hash[$property.Name] = ConvertTo-Hashtable -InputObject $property.Value } $hash } else { $InputObject } } } function Test-SqlPasswordComplexity { param ( [string]$Username, [string]$Password ) # Check if the username is present in the password if ($Password -match $Username) { throw "SQL password cannot contain the SQL username." return $false } # Password must be minimum 8 characters, contains at least one uppercase, lowercase letter, contains at least one digit, contains at least one special character $regex = '^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\da-zA-Z]).{8,}$' if ($Password -match $regex) { Write-Host "SQL password is valid." -ForegroundColor Green return $true } else { throw "Password does not meet the complexity requirements." return $false } } $ErrorActionPreference = "Stop" #region Deployment environment settings $lastDeploymentStatePath = ".\last-deployment-state.json" $deploymentOptions = @{} $silentDeploy = $false # Check if silent deployment settings file exists if(-not([string]::IsNullOrEmpty($SilentDeploymentSettingsPath)) -and (Test-Path -Path $SilentDeploymentSettingsPath)) { $silentDeploy = $true # Get the deployment details from the silent deployment settings file $silentDepOptions = Get-Content -Path $SilentDeploymentSettingsPath | ConvertFrom-Json Write-Host "Silent deployment options found." -ForegroundColor Green $silentDepOptions = ConvertTo-Hashtable -InputObject $silentDepOptions $silentDepOptions.Keys | ForEach-Object { $deploymentOptions[$_] = $silentDepOptions[$_] } # Validate the silent deployment settings if (-not($deploymentOptions["SubscriptionId"])) { throw "SubscriptionId is required for silent deployment." } if (-not($deploymentOptions["NamePrefix"])) { throw "NamePrefix is required for silent deployment. Set to 'EmptyNamePrefix' to use own naming convention and specify the needed resource names." } if ($deploymentOptions["NamePrefix"].Length -gt 21) { throw "Name prefix length is larger than the 21 characters limit ($($deploymentOptions["NamePrefix"]))" } if ($deploymentOptions["NamePrefix"] -eq "EmptyNamePrefix") { if (-not($deploymentOptions["ResourceGroupName"])) { throw "ResourceGroupName is required for silent deployment when NamePrefix is set to 'EmptyNamePrefix'." } if (-not($deploymentOptions["StorageAccountName"])) { throw "StorageAccountName is required for silent deployment when NamePrefix is set to 'EmptyNamePrefix'." } if (-not($deploymentOptions["AutomationAccountName"])) { throw "AutomationAccountName is required for silent deployment when NamePrefix is set to 'EmptyNamePrefix'." } if (-not($deploymentOptions["SqlServerName"])) { throw "SqlServerName is required for silent deployment when NamePrefix is set to 'EmptyNamePrefix'." } if (-not($deploymentOptions["SqlDatabaseName"])) { throw "SqlDatabaseName is required for silent deployment when NamePrefix is set to 'EmptyNamePrefix'." } } if (-not($deploymentOptions["WorkspaceReuse"]) -or ($deploymentOptions["WorkspaceReuse"] -ne "y" -and $deploymentOptions["WorkspaceReuse"] -ne "n")) { throw "WorkspaceReuse set to 'y' or 'n' is required for silent deployment." } if ($deploymentOptions["WorkspaceReuse"] -eq "y") { if (-not($deploymentOptions["WorkspaceName"])) { throw "WorkspaceName is required for silent deployment when WorkspaceReuse is set to 'y'." } if (-not($deploymentOptions["WorkspaceResourceGroupName"])) { throw "WorkspaceResourceGroupName is required for silent deployment when WorkspaceReuse is set to 'y'." } } if (-not($deploymentOptions["DeployWorkbooks"]) -or ($deploymentOptions["DeployWorkbooks"] -ne "y" -and $deploymentOptions["DeployWorkbooks"] -ne "n")) { throw "DeployWorkbooks set to 'y' or 'n' is required for silent deployment." } if (-not($deploymentOptions["SqlAdmin"])) { throw "SqlAdmin is required for silent deployment." } if (-not($deploymentOptions["SqlPass"])) { throw "SqlPass is required for silent deployment." } if (-not($deploymentOptions["TargetLocation"])) { throw "TargetLocation is required for silent deployment." } if (-not($deploymentOptions["DeployBenefitsUsageDependencies"])) { throw "DeployBenefitsUsageDependencies is required for silent deployment." } if ($deploymentOptions["DeployBenefitsUsageDependencies"] -eq "y") { if (-not($deploymentOptions["CustomerType"])) { throw "CustomerType is required for silent deployment when DeployBenefitsUsageDependencies is set to 'y'." } if (-not($deploymentOptions["BillingAccountId"])) { throw "BillingAccountId is required for silent deployment when DeployBenefitsUsageDependencies is set to 'y'." } if (-not($deploymentOptions["CurrencyCode"])) { throw "CurrencyCode is required for silent deployment when DeployBenefitsUsageDependencies is set to 'y'." } if ($deploymentOptions["CustomerType"] -eq "MCA") { if (-not($deploymentOptions["BillingProfileId"])) { throw "BillingProfileId is required for silent deployment when CustomerType is set to 'MCA'." } } } } if ((Test-Path -Path $lastDeploymentStatePath) -and !$silentDeploy) { $depOptions = Get-Content -Path $lastDeploymentStatePath | ConvertFrom-Json Write-Host $depOptions -ForegroundColor Green $depOptionsReuse = Read-Host "Found last deployment options above. Do you want to repeat/upgrade last deployment (Y/N)?" if ("Y", "y" -contains $depOptionsReuse) { foreach ($property in $depOptions.PSObject.Properties) { $deploymentOptions[$property.Name] = $property.Value } } } $GitHubOriginalUri = "https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/master/azuredeploy.bicep" if ([string]::IsNullOrEmpty($TemplateUri)) { $TemplateUri = $GitHubOriginalUri } $isTemplateAvailable = $false try { Invoke-WebRequest -Uri $TemplateUri | Out-Null $isTemplateAvailable = $true } catch { Write-Host "The template URL ($TemplateUri) is not available. Please, put it in a publicly accessible HTTPS location." -ForegroundColor Red } if (!$isTemplateAvailable) { throw "Terminating due to template unavailability." } if (-not((Test-Path -Path "./azuredeploy.bicep") -and (Test-Path -Path "./azuredeploy-nested.bicep"))) { throw "Terminating due to template unavailability. Please, change directory to where azuredeploy.bicep and azuredeploy-nested.bicep are located." } $cloudDetails = Get-AzEnvironment -Name $AzureEnvironment $ctx = Get-AzContext if (-not($ctx)) { Connect-AzAccount -Environment $AzureEnvironment $ctx = Get-AzContext } else { if ($ctx.Environment.Name -ne $AzureEnvironment) { Disconnect-AzAccount -ContextName $ctx.Name Connect-AzAccount -Environment $AzureEnvironment $ctx = Get-AzContext } } #endregion #region Azure subscription choice Write-Host "Getting Azure subscriptions..." -ForegroundColor Yellow $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" -and $_.SubscriptionPolicies.QuotaId -notlike "AAD*" } if ($subscriptions.Count -gt 1) { $selectedSubscription = -1 for ($i = 0; $i -lt $subscriptions.Count; $i++) { if (-not($deploymentOptions["SubscriptionId"])) { Write-Output "[$i] $($subscriptions[$i].Name)" } else { if ($subscriptions[$i].Id -eq $deploymentOptions["SubscriptionId"]) { $selectedSubscription = $i break } } } if (-not($deploymentOptions["SubscriptionId"])) { $lastSubscriptionIndex = $subscriptions.Count - 1 while ($selectedSubscription -lt 0 -or $selectedSubscription -gt $lastSubscriptionIndex) { Write-Output "---" $selectedSubscription = [int] (Read-Host "Please, select the target subscription for this deployment [0..$lastSubscriptionIndex]") } } if ($selectedSubscription -eq -1) { throw "The selected subscription does not exist. Check if you are logged in with the right Microsoft Entra ID user." } } else { if ($subscriptions.Count -ne 0) { $selectedSubscription = 0 } else { throw "No valid subscriptions found. Only EA, MCA, PAYG or MSDN subscriptions are supported currently." } } if ($subscriptions.Count -eq 0) { throw "No subscriptions found. Check if you are logged in with the right Microsoft Entra ID account." } $subscriptionId = $subscriptions[$selectedSubscription].Id if (-not($deploymentOptions["SubscriptionId"])) { $deploymentOptions["SubscriptionId"] = $subscriptionId } if ($ctx.Subscription.Id -ne $subscriptionId) { $ctx = Select-AzSubscription -SubscriptionId $subscriptionId } #endregion #region Resource naming options if($silentDeploy) { $workspaceReuse = $deploymentOptions["WorkspaceReuse"] } else { $workspaceReuse = $null } $deploymentNameTemplate = "{0}" + (Get-Date).ToString("yyMMddHHmmss") $resourceGroupNameTemplate = "{0}-rg" $storageAccountNameTemplate = "{0}sa" $laWorkspaceNameTemplate = "{0}-la" $automationAccountNameTemplate = "{0}-auto" $sqlServerNameTemplate = "{0}-sql" $nameAvailable = $true if (-not($deploymentOptions["NamePrefix"])) { do { $namePrefix = Read-Host "Please, enter a unique name prefix for the deployment (max. 21 chars) or existing prefix if updating deployment. If you want instead to individually name all resources, just press ENTER" if (-not($namePrefix)) { $namePrefix = "EmptyNamePrefix" } } while ($namePrefix.Length -gt 21) $deploymentOptions["NamePrefix"] = $namePrefix } else { if ($deploymentOptions["NamePrefix"] -eq "EmptyNamePrefix") { $namePrefix = $null } else { $namePrefix = $deploymentOptions["NamePrefix"] } } if (-not($deploymentOptions["WorkspaceReuse"])) { if ($null -eq $workspaceReuse) { $workspaceReuse = Read-Host "Are you going to reuse an existing Log Analytics workspace (Y/N)?" } $deploymentOptions["WorkspaceReuse"] = $workspaceReuse } else { $workspaceReuse = $deploymentOptions["WorkspaceReuse"] } if (-not($deploymentOptions["ResourceGroupName"])) { if ([string]::IsNullOrEmpty($namePrefix) -or $namePrefix -eq "EmptyNamePrefix") { $resourceGroupName = Read-Host "Please, enter the new or existing Resource Group for this deployment" $deploymentName = $deploymentNameTemplate -f $resourceGroupName $storageAccountName = Read-Host "Enter the Storage Account name" $automationAccountName = Read-Host "Automation Account name" $sqlServerName = Read-Host "Azure SQL Server name" $sqlDatabaseName = Read-Host "Azure SQL Database name" if ("N", "n" -contains $workspaceReuse) { $laWorkspaceName = Read-Host "Log Analytics Workspace" } } else { $deploymentName = $deploymentNameTemplate -f $namePrefix $resourceGroupName = $resourceGroupNameTemplate -f $namePrefix $storageAccountName = $storageAccountNameTemplate -f $namePrefix $automationAccountName = $automationAccountNameTemplate -f $namePrefix $sqlServerName = $sqlServerNameTemplate -f $namePrefix if ("Y", "y" -contains $workspaceReuse -and $silentDeploy) { $laWorkspaceName = $deploymentOptions["WorkspaceName"] } else { $laWorkspaceName = $laWorkspaceNameTemplate -f $namePrefix } $sqlDatabaseName = "azureoptimization" } $deploymentOptions["ResourceGroupName"] = $resourceGroupName $deploymentOptions["StorageAccountName"] = $storageAccountName $deploymentOptions["AutomationAccountName"] = $automationAccountName $deploymentOptions["SqlServerName"] = $sqlServerName $deploymentOptions["SqlDatabaseName"] = $sqlDatabaseName $deploymentOptions["WorkspaceName"] = $laWorkspaceName } else { # With a silent deploy, overrule any custom resource naming if a NamePrefix is provided if($silentDeploy -and ![string]::IsNullOrEmpty($namePrefix) -and $namePrefix -ne "EmptyNamePrefix") { $deploymentName = $deploymentNameTemplate -f $namePrefix $resourceGroupName = $resourceGroupNameTemplate -f $namePrefix $storageAccountName = $storageAccountNameTemplate -f $namePrefix $automationAccountName = $automationAccountNameTemplate -f $namePrefix $sqlServerName = $sqlServerNameTemplate -f $namePrefix if ("Y", "y" -contains $workspaceReuse) { $laWorkspaceName = $deploymentOptions["WorkspaceName"] } else { $laWorkspaceName = $laWorkspaceNameTemplate -f $namePrefix } $sqlDatabaseName = "azureoptimization" } else { $resourceGroupName = $deploymentOptions["ResourceGroupName"] $storageAccountName = $deploymentOptions["StorageAccountName"] $automationAccountName = $deploymentOptions["AutomationAccountName"] $sqlServerName = $deploymentOptions["SqlServerName"] $sqlDatabaseName = $deploymentOptions["SqlDatabaseName"] $laWorkspaceName = $deploymentOptions["WorkspaceName"] $deploymentName = $deploymentNameTemplate -f $resourceGroupName } } #endregion #region Resource naming availability checks Write-Host "Checking name prefix availability..." -ForegroundColor Green Write-Host "...for the Storage Account..." -ForegroundColor Green $sa = Get-AzStorageAccount -ResourceGroupName $resourceGroupName -Name $storageAccountName -ErrorAction SilentlyContinue if ($null -eq $sa) { $saNameResult = Get-AzStorageAccountNameAvailability -Name $storageAccountName if (-not($saNameResult.NameAvailable)) { $nameAvailable = $false Write-Host "$($saNameResult.Message)" -ForegroundColor Red } } else { Write-Host "(The Storage Account was already deployed)" -ForegroundColor Green } if ("N", "n" -contains $workspaceReuse) { Write-Host "...for the Log Analytics workspace..." -ForegroundColor Green $logAnalyticsReuse = $false $laWorkspaceResourceGroup = $resourceGroupName $la = Get-AzOperationalInsightsWorkspace -ResourceGroupName $resourceGroupName -Name $laWorkspaceName -ErrorAction SilentlyContinue if ($null -eq $la) { $laNameResult = Invoke-WebRequest -Uri "https://portal.loganalytics.io/api/workspaces/IsWorkspaceExists?name=$laWorkspaceName" if ($laNameResult.Content -eq "true") { $nameAvailable = $false Write-Host "The Log Analytics workspace $laWorkspaceName is already taken." -ForegroundColor Red } } else { Write-Host "(The Log Analytics Workspace was already deployed)" -ForegroundColor Green } } else { $logAnalyticsReuse = $true } Write-Host "...for the Azure SQL Server..." -ForegroundColor Green $sql = Get-AzSqlServer -ResourceGroupName $resourceGroupName -Name $sqlServerName -ErrorAction SilentlyContinue if ($null -eq $sql -and -not($sqlServerName -like "*.database.*") -and -not($IgnoreNamingAvailabilityErrors)) { $SqlServerNameAvailabilityUriPath = "/subscriptions/$subscriptionId/providers/Microsoft.Sql/checkNameAvailability?api-version=2014-04-01" $body = "{`"name`": `"$sqlServerName`", `"type`": `"Microsoft.Sql/servers`"}" $sqlNameResult = (Invoke-AzRestMethod -Path $SqlServerNameAvailabilityUriPath -Method POST -Payload $body).Content | ConvertFrom-Json if (-not($sqlNameResult.available)) { $nameAvailable = $false Write-Host "$($sqlNameResult.message) ($sqlServerName)" -ForegroundColor Red } } else { Write-Host "(The SQL Server was already deployed)" -ForegroundColor Green } if (-not($nameAvailable) -and -not($IgnoreNamingAvailabilityErrors)) { throw "Please, fix naming issues. Terminating execution." } Write-Host "Chosen resource names are available for all services" -ForegroundColor Green #endregion #region Additional resource options (LA reused, region, SQL user) if (-not($deploymentOptions["WorkspaceResourceGroupName"])) { if ("Y", "y" -contains $workspaceReuse) { $laWorkspaceName = Read-Host "Please, enter the name of the Log Analytics workspace to be reused" $laWorkspaceResourceGroup = Read-Host "Please, enter the name of the resource group containing Log Analytics $laWorkspaceName" $la = Get-AzOperationalInsightsWorkspace -ResourceGroupName $laWorkspaceResourceGroup -Name $laWorkspaceName -ErrorAction SilentlyContinue if (-not($la)) { throw "Could not find $laWorkspaceName in resource group $laWorkspaceResourceGroup for the chosen subscription. Aborting." } $deploymentOptions["WorkspaceName"] = $laWorkspaceName $deploymentOptions["WorkspaceResourceGroupName"] = $laWorkspaceResourceGroup } } else { $laWorkspaceResourceGroup = $deploymentOptions["WorkspaceResourceGroupName"] } $rg = Get-AzResourceGroup -Name $resourceGroupName -ErrorAction SilentlyContinue if (-not($deploymentOptions["TargetLocation"])) { if (-not($rg.Location)) { Write-Host "Getting Azure locations..." -ForegroundColor Green $locations = Get-AzLocation | Where-Object { $_.Providers -contains "Microsoft.Automation" -and $_.Providers -contains "Microsoft.Sql" ` -and $_.Providers -contains "Microsoft.OperationalInsights" ` -and $_.Providers -contains "Microsoft.Storage"} | Sort-Object -Property Location for ($i = 0; $i -lt $locations.Count; $i++) { Write-Output "[$i] $($locations[$i].location)" } $selectedLocation = -1 $lastLocationIndex = $locations.Count - 1 while ($selectedLocation -lt 0 -or $selectedLocation -gt $lastLocationIndex) { Write-Output "---" $selectedLocation = [int] (Read-Host "Please, select the target location for this deployment [0..$lastLocationIndex]") } $targetLocation = $locations[$selectedLocation].location } else { $targetLocation = $rg.Location } $deploymentOptions["TargetLocation"] = $targetLocation } else { $targetLocation = $deploymentOptions["TargetLocation"] } if (-not($deploymentOptions["SqlAdmin"])) { $sqlAdmin = Read-Host "Please, input the SQL Admin username" $deploymentOptions["SqlAdmin"] = $sqlAdmin } else { $sqlAdmin = $deploymentOptions["SqlAdmin"] } if (-not($deploymentOptions["SqlPass"])) { $sqlPass = Read-Host "Please, input the SQL Admin ($sqlAdmin) password" -AsSecureString } else { $sqlPass = $deploymentOptions["SqlPass"] if(Test-SqlPasswordComplexity -Username $sqlAdmin -Password $sqlPass -ErrorAction SilentlyContinue) { Write-Host "Password complexity check passed" -ForegroundColor Green $sqlPass = ConvertTo-SecureString -AsPlainText $sqlPass -Force } else { throw "SQL password complexity check failed. Please, fix the password and try again." } } #endregion #region Partial upgrade dependent resource checks if (-not($DoPartialUpgrade)) { $upgrading = $false } else { $upgrading = $true if ($null -ne $rg) { if ($upgrading -and $null -ne $sa) { $containers = Get-AzStorageContainer -Context $sa.Context } else { $upgrading = $false Write-Host "Did not find the $storageAccountName Storage Account." -ForegroundColor Yellow } if ($upgrading -and $null -ne $sql) { $databases = Get-AzSqlDatabase -ServerName $sql.ServerName -ResourceGroupName $resourceGroupName if (-not($databases | Where-Object { $_.DatabaseName -eq $sqlDatabaseName})) { $upgrading = $false Write-Host "Did not find the $sqlDatabaseName database." -ForegroundColor Yellow } } else { if (-not($IgnoreNamingAvailabilityErrors)) { $upgrading = $false Write-Host "Did not find the $sqlServerName SQL Server." -ForegroundColor Yellow } } $auto = Get-AzAutomationAccount -ResourceGroupName $resourceGroupName -Name $automationAccountName -ErrorAction SilentlyContinue if ($null -ne $auto) { $runbooks = Get-AzAutomationRunbook -ResourceGroupName $resourceGroupName ` -AutomationAccountName $auto.AutomationAccountName | Where-Object { $_.Name.StartsWith('Export') } if ($runbooks.Count -lt 3) { $upgrading = $false Write-Host "Did not find existing runbooks in the $automationAccountName Automation Account." -ForegroundColor Yellow } } else { $upgrading = $false Write-Host "Did not find the $automationAccountName Automation Account." -ForegroundColor Yellow } } else { $upgrading = $false } } #endregion $deploymentMessage = "Deploying Azure Optimization Engine to subscription" if ($upgrading) { Write-Host "Looks like this deployment was already done in the past. We will only upgrade runbooks, modules, schedules, variables, storage and the database." -ForegroundColor Yellow $deploymentMessage = "Upgrading Azure Optimization Engine in subscription" } if ($silentDeploy) { $continueInput = "Y" } else { $continueInput = Read-Host "$deploymentMessage $($subscriptions[$selectedSubscription].Name). Continue (Y/N)?" } if ("Y", "y" -contains $continueInput) { # If we deploy silently, be sure to strip the SQL password from the output if ($silentDeploy) { $deploymentOptions.Remove("SqlPass") } $deploymentOptions | ConvertTo-Json | Out-File -FilePath $lastDeploymentStatePath -Force #region Computing schedules base time $baseTime = (Get-Date).ToUniversalTime().ToString("u") $upgradingSchedules = $false $schedules = Get-AzAutomationSchedule -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName -ErrorAction SilentlyContinue if ($schedules.Count -gt 0) { $upgradingSchedules = $true $originalBaseTime = ($schedules | Where-Object { $_.Name.EndsWith("Weekly") } | Sort-Object -Property StartTime | Select-Object -First 1).StartTime.AddHours(-1.25).DateTime $now = (Get-Date).ToUniversalTime() $diff = $now.AddHours(-1.25) - $originalBaseTime $nextWeekDays = [Math]::Ceiling($diff.TotalDays / 7) * 7 $baseTime = $now.AddHours(-1.25).AddDays($nextWeekDays - $diff.TotalDays).ToString("u") Write-Host "Existing schedules found. Keeping original base time: $baseTime." -ForegroundColor Green } else { Write-Host "Automation schedules base time automatically set to $baseTime." -ForegroundColor Green } #endregion if (-not($upgrading)) { #region Template-based deployment $jobSchedules = Get-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName -ErrorAction SilentlyContinue if ($jobSchedules.Count -gt 0) { Write-Host "Unregistering previous runbook schedules associations from $automationAccountName..." -ForegroundColor Green foreach ($jobSchedule in $jobSchedules) { if ($jobSchedule.ScheduleName.StartsWith("AzureOptimization")) { Unregister-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -JobScheduleId $jobSchedule.JobScheduleId -Force } } } Write-Host "Deploying Azure Optimization Engine resources..." -ForegroundColor Green $deploymentTries = 0 $maxDeploymentTries = 2 $deploymentSucceeded = $false do { $deploymentTries++ try { $deployment = New-AzDeployment -TemplateFile ".\azuredeploy.bicep" -templateLocation $TemplateUri.Replace("azuredeploy.bicep", "") -Location $targetLocation -rgName $resourceGroupName -Name $deploymentName ` -projectLocation $targetlocation -logAnalyticsReuse $logAnalyticsReuse -baseTime $baseTime ` -logAnalyticsWorkspaceName $laWorkspaceName -logAnalyticsWorkspaceRG $laWorkspaceResourceGroup ` -storageAccountName $storageAccountName -automationAccountName $automationAccountName ` -sqlServerName $sqlServerName -sqlDatabaseName $sqlDatabaseName -cloudEnvironment $AzureEnvironment ` -sqlAdminLogin $sqlAdmin -sqlAdminPassword $sqlPass -resourceTags $ResourceTags -WarningAction SilentlyContinue $deploymentSucceeded = $true } catch { if ($deploymentTries -ge $maxDeploymentTries) { Write-Host "Failed deployment. Stop trying." -ForegroundColor Yellow throw $_ } Write-Host "Failed deployment. Trying once more..." -ForegroundColor Yellow } } while (-not($deploymentSucceeded) -and $deploymentTries -lt $maxDeploymentTries) $spnId = $deployment.Outputs['automationPrincipalId'].Value #endregion } else { #region Partial upgrade deployment $upgradeManifest = Get-Content -Path "./upgrade-manifest.json" | ConvertFrom-Json Write-Host "Creating missing storage account containers..." -ForegroundColor Green $upgradeContainers = $upgradeManifest.dataCollection.container foreach ($container in $upgradeContainers) { if (-not($container -in $containers.Name)) { New-AzStorageContainer -Name $container -Context $sa.Context -Permission Off | Out-Null Write-Host "$container container created." } } Write-Host "Importing runbooks..." -ForegroundColor Green $allRunbooks = $upgradeManifest.baseIngest.runbook + $upgradeManifest.dataCollection.runbook + $upgradeManifest.recommendations.runbook + $upgradeManifest.remediations.runbook $runbookBaseUri = $TemplateUri.Replace("azuredeploy.bicep", "") $topTemplateJson = "{ `"`$schema`": `"https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#`", " + ` "`"contentVersion`": `"1.0.0.0`", `"resources`": [" $bottomTemplateJson = "] }" $runbookDeploymentTemplateJson = $topTemplateJson for ($i = 0; $i -lt $allRunbooks.Count; $i++) { try { Invoke-WebRequest -Uri ($runbookBaseUri + $allRunbooks[$i].name) | Out-Null $runbookName = [System.IO.Path]::GetFilenameWithoutExtension($allRunbooks[$i].name) $runbookJson = "{ `"name`": `"$automationAccountName/$runbookName`", `"type`": `"Microsoft.Automation/automationAccounts/runbooks`", " + ` "`"apiVersion`": `"2018-06-30`", `"location`": `"$targetLocation`", `"tags`": $($ResourceTags | ConvertTo-Json), `"properties`": { " + ` "`"runbookType`": `"PowerShell`", `"logProgress`": false, `"logVerbose`": false, " + ` "`"publishContentLink`": { `"uri`": `"$runbookBaseUri$($allRunbooks[$i].name)`", `"version`": `"$($allRunbooks[$i].version)`" } } }" $runbookDeploymentTemplateJson += $runbookJson if ($i -lt $allRunbooks.Count - 1) { $runbookDeploymentTemplateJson += ", " } Write-Host "$($allRunbooks[$i].name) imported." } catch { Write-Host "$($allRunbooks[$i].name) not imported (not found)." -ForegroundColor Yellow } } $runbookDeploymentTemplateJson += $bottomTemplateJson $runbooksTemplatePath = "./aoe-runbooks-deployment.json" $runbookDeploymentTemplateJson | Out-File -FilePath $runbooksTemplatePath -Force Write-Host "Executing runbooks deployment..." -ForegroundColor Green New-AzResourceGroupDeployment -TemplateFile $runbooksTemplatePath -ResourceGroupName $resourceGroupName -Name ($deploymentNameTemplate -f "runbooks") | Out-Null Remove-Item -Path $runbooksTemplatePath -Force Write-Host "Runbooks update deployed." Write-Host "Importing modules..." -ForegroundColor Green $allModules = $upgradeManifest.modules $modulesDeploymentTemplateJson = $topTemplateJson for ($i = 0; $i -lt $allModules.Count; $i++) { $moduleJson = "{ `"name`": `"$automationAccountName/$($allModules[$i].name)`", `"type`": `"Microsoft.Automation/automationAccounts/modules`", " + ` "`"apiVersion`": `"2018-06-30`", `"location`": `"$targetLocation`", `"tags`": $($ResourceTags | ConvertTo-Json), `"properties`": { " + ` "`"contentLink`": { `"uri`": `"$($allModules[$i].url)`" } } " if ($allModules[$i].name -ne "Az.Accounts" -and $allModules[$i].name -ne "Microsoft.Graph.Authentication") { $moduleJson += ", `"dependsOn`": [ `"Az.Accounts`", `"Microsoft.Graph.Authentication`" ]" } $moduleJson += "}" $modulesDeploymentTemplateJson += $moduleJson if ($i -lt $allModules.Count - 1) { $modulesDeploymentTemplateJson += ", " } Write-Host "$($allModules[$i].name) imported." } $modulesDeploymentTemplateJson += $bottomTemplateJson $modulesTemplatePath = "./aoe-modules-deployment.json" $modulesDeploymentTemplateJson | Out-File -FilePath $modulesTemplatePath -Force Write-Host "Executing modules deployment..." -ForegroundColor Green New-AzResourceGroupDeployment -TemplateFile $modulesTemplatePath -ResourceGroupName $resourceGroupName -Name ($deploymentNameTemplate -f "modules") | Out-Null Remove-Item -Path $modulesTemplatePath -Force Write-Host "Modules update deployed." Write-Host "Updating schedules..." -ForegroundColor Green $allSchedules = $upgradeManifest.schedules $allScheduledRunbooks = Get-AzAutomationScheduledRunbook -AutomationAccountName $AutomationAccountName -ResourceGroupName $ResourceGroupName $exportHybridWorkerOption = ($allScheduledRunbooks | Where-Object { $_.RunbookName.StartsWith("Export") })[0].HybridWorker $ingestHybridWorkerOption = ($allScheduledRunbooks | Where-Object { $_.RunbookName.StartsWith("Ingest") })[0].HybridWorker $recommendHybridWorkerOption = ($allScheduledRunbooks | Where-Object { $_.RunbookName.StartsWith("Recommend") })[0].HybridWorker if ($allScheduledRunbooks | Where-Object { $_.RunbookName.StartsWith("Remediate") }) { $remediateHybridWorkerOption = ($allScheduledRunbooks | Where-Object { $_.RunbookName.StartsWith("Remediate") })[0].HybridWorker } $hybridWorkerOption = "None" if ($exportHybridWorkerOption -or $ingestHybridWorkerOption -or $recommendHybridWorkerOption -or $remediateHybridWorkerOption) { $hybridWorkerOption = "Export: $exportHybridWorkerOption; Ingest: $ingestHybridWorkerOption; Recommend: $recommendHybridWorkerOption; Remediate: $remediateHybridWorkerOption" } Write-Host "Current Hybrid Worker option: $hybridWorkerOption" -ForegroundColor Green $dataIngestRunbookName = [System.IO.Path]::GetFileNameWithoutExtension(($upgradeManifest.baseIngest | Where-Object { $_.source -eq "dataCollection"}).runbook.name) $dataExportsToMultiSchedule = $upgradeManifest.dataCollection | Where-Object { $_.exportSchedules.Count -gt 0 } $recommendationsProcessingRunbooks = $upgradeManifest.baseIngest | Where-Object { $_.source -eq "recommendations" -or $_.source -eq "maintenance"} foreach ($schedule in $allSchedules) { if (-not($schedules | Where-Object { $_.Name -eq $schedule.name })) { $scheduleStartTime = (Get-Date $baseTime).Add([System.Xml.XmlConvert]::ToTimeSpan($schedule.offset)) $scheduleNow = (Get-Date).ToUniversalTime() if ($schedule.frequency -eq "Hour") { if ($scheduleNow.AddMinutes(5) -gt $scheduleStartTime) { $hoursDiff = ($scheduleNow - $scheduleStartTime).Hours + 1 $scheduleStartTime = $scheduleStartTime.AddHours($hoursDiff) } New-AzAutomationSchedule -Name $schedule.name -AutomationAccountName $automationAccountName -ResourceGroupName $resourceGroupName ` -StartTime $scheduleStartTime -HourInterval 1 | Out-Null } if ($schedule.frequency -eq "Day") { if ($scheduleNow.AddMinutes(5) -gt $scheduleStartTime) { $scheduleStartTime = $scheduleStartTime.AddDays(1) } New-AzAutomationSchedule -Name $schedule.name -AutomationAccountName $automationAccountName -ResourceGroupName $resourceGroupName ` -StartTime $scheduleStartTime -DayInterval 1 | Out-Null } if ($schedule.frequency -eq "Week") { if ($scheduleNow.AddMinutes(5) -gt $scheduleStartTime) { $scheduleStartTime = $scheduleStartTime.AddDays(7) } New-AzAutomationSchedule -Name $schedule.name -AutomationAccountName $automationAccountName -ResourceGroupName $resourceGroupName ` -StartTime $scheduleStartTime -WeekInterval 1 | Out-Null } Write-Host "$($schedule.name) schedule created." } $scheduledRunbooks = Get-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -ScheduleName $schedule.name $dataExportsToSchedule = ($upgradeManifest.dataCollection + $upgradeManifest.recommendations) | Where-Object { $_.exportSchedule -eq $schedule.name } foreach ($dataExport in $dataExportsToSchedule) { $runbookName = [System.IO.Path]::GetFileNameWithoutExtension($dataExport.runbook.name) $runbookType = $runbookName.Split("-")[0] switch ($runbookType) { "Export" { $hybridWorkerName = $exportHybridWorkerOption } "Recommend" { $hybridWorkerName = $recommendHybridWorkerOption } "Ingest" { $hybridWorkerName = $ingestHybridWorkerOption } "Remediate" { $hybridWorkerName = $remediateHybridWorkerOption } Default { $hybridWorkerName = $null } } if (-not($scheduledRunbooks | Where-Object { $_.RunbookName -eq $runbookName})) { if ($hybridWorkerName) { Register-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -RunbookName $runbookName -ScheduleName $schedule.name -RunOn $hybridWorkerName | Out-Null } else { Register-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -RunbookName $runbookName -ScheduleName $schedule.name | Out-Null } Write-Host "Added $($schedule.name) schedule to $hybridWorkerName $runbookName runbook" } } foreach ($dataExport in $dataExportsToMultiSchedule) { $exportSchedule = $dataExport.exportSchedules | Where-Object { $_.schedule -eq $schedule.name } if ($exportSchedule) { $runbookName = [System.IO.Path]::GetFileNameWithoutExtension($dataExport.runbook.name) $runbookType = $runbookName.Split("-")[0] switch ($runbookType) { "Export" { $hybridWorkerName = $exportHybridWorkerOption } "Recommend" { $hybridWorkerName = $recommendHybridWorkerOption } "Ingest" { $hybridWorkerName = $ingestHybridWorkerOption } "Remediate" { $hybridWorkerName = $remediateHybridWorkerOption } Default { $hybridWorkerName = $null } } if (-not($scheduledRunbooks | Where-Object { $_.RunbookName -eq $runbookName -and $_.ScheduleName -eq $schedule.name})) { $params = @{} $exportSchedule.parameters.PSObject.Properties | ForEach-Object { $params[$_.Name] = $_.Value } if ($hybridWorkerName) { Register-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -RunbookName $runbookName -ScheduleName $schedule.name -RunOn $hybridWorkerName -Parameters $params | Out-Null } else { Register-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -RunbookName $runbookName -ScheduleName $schedule.name -Parameters $params | Out-Null } Write-Host "Added $($schedule.name) schedule to $hybridWorkerName $runbookName runbook." } } } $dataIngestToSchedule = $upgradeManifest.dataCollection | Where-Object { $_.ingestSchedule -eq $schedule.name } foreach ($dataIngest in $dataIngestToSchedule) { $hybridWorkerName = $ingestHybridWorkerOption if (-not($scheduledRunbooks | Where-Object { $_.RunbookName -eq $dataIngestRunbookName})) { $params = @{"StorageSinkContainer"=$dataIngest.container} if ($hybridWorkerName) { Register-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -RunbookName $dataIngestRunbookName -ScheduleName $schedule.name -RunOn $hybridWorkerName -Parameters $params | Out-Null } else { Register-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -RunbookName $dataIngestRunbookName -ScheduleName $schedule.name -Parameters $params | Out-Null } Write-Host "Added $($schedule.name) schedule to $hybridWorkerName $dataIngestRunbookName runbook." } } foreach ($recommendationsProcessingRunbook in $recommendationsProcessingRunbooks) { $runbookName = [System.IO.Path]::GetFileNameWithoutExtension($recommendationsProcessingRunbook.runbook.name) $hybridWorkerName = $ingestHybridWorkerOption if ($recommendationsProcessingRunbook.schedule -eq $schedule.name -and -not($scheduledRunbooks | Where-Object { $_.RunbookName -eq $runbookName})) { if ($hybridWorkerName) { Register-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -RunbookName $runbookName -ScheduleName $schedule.name -RunOn $hybridWorkerName | Out-Null } else { Register-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -RunbookName $runbookName -ScheduleName $schedule.name | Out-Null } Write-Host "Added $($schedule.name) schedule to $hybridWorkerName $runbookName runbook." } } } Write-Host "Updating variables..." -ForegroundColor Green $allVariables = $upgradeManifest.dataCollection.requiredVariables + $upgradeManifest.recommendations.requiredVariables + $upgradeManifest.remediations.requiredVariables foreach ($variable in $allVariables) { $existingVariables = Get-AzAutomationVariable -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName if (-not($existingVariables | Where-Object { $_.Name -eq $variable.name })) { New-AzAutomationVariable -Name $variable.name -AutomationAccountName $automationAccountName -ResourceGroupName $resourceGroupName ` -Value $variable.defaultValue -Encrypted $false | Out-Null Write-Host "$($variable.name) variable created." } } Write-Host "Force-updating variables..." -ForegroundColor Green $forceUpdateVariables = $upgradeManifest.overwriteVariables foreach ($variable in $forceUpdateVariables) { Set-AzAutomationVariable -Name $variable.name -AutomationAccountName $automationAccountName -ResourceGroupName $resourceGroupName ` -Value $variable.value -Encrypted $false | Out-Null Write-Host "$($variable.name) variable updated." } Write-Host "Removing deprecated runbooks..." -ForegroundColor Green $deprecatedRunbooks = $upgradeManifest.deprecatedRunbooks foreach ($deprecatedRunbook in $deprecatedRunbooks) { Remove-AzAutomationRunbook -AutomationAccountName $automationAccountName -Name $deprecatedRunbook -ResourceGroupName $resourceGroupName -Force -ErrorAction SilentlyContinue } #endregion } #region Schedules reset if ($upgradingSchedules) { $schedules = Get-AzAutomationSchedule -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName $dailySchedules = $schedules | Where-Object { $_.Frequency -eq "Day" -or $_.Frequency -eq "Hour" } Write-Host "Fixing daily schedules after upgrade..." -ForegroundColor Green foreach ($schedule in $dailySchedules) { $now = (Get-Date).ToUniversalTime() $newStartTime = [System.DateTimeOffset]::Parse($now.ToString("yyyy-MM-ddT00:00:00Z")) $newStartTime = $newStartTime.AddHours($schedule.StartTime.Hour).AddMinutes($schedule.StartTime.Minute) if ($newStartTime.AddMinutes(-5) -lt $now) { $newStartTime = $newStartTime.AddDays(1) } $expiryTime = $schedule.ExpiryTime.ToString("yyyy-MM-ddTHH:mm:ssZ") $startTime = $newStartTime.ToString("yyyy-MM-ddTHH:mm:ssZ") $automationPath = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Automation/automationAccounts/$automationAccountName/schedules/$($schedule.Name)?api-version=2015-10-31" $body = "{ `"name`": `"$($schedule.Name)`", `"properties`": { `"description`": `"$($schedule.Description)`", `"startTime`": `"$startTime`", `"expiryTime`": `"$expiryTime`", `"interval`": 1, `"frequency`": `"$($schedule.Frequency.ToString())`", `"advancedSchedule`": {} } }" Invoke-AzRestMethod -Path $automationPath -Method PUT -Payload $body | Out-Null } } #endregion #region Deployment date Automation variable Write-Host "Checking Azure Automation variable referring to the initial Azure Optimization Engine deployment date..." -ForegroundColor Green $deploymentDateVariableName = "AzureOptimization_DeploymentDate" $deploymentDateVariable = Get-AzAutomationVariable -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName -Name $deploymentDateVariableName -ErrorAction SilentlyContinue if ($null -eq $deploymentDateVariable) { $deploymentDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd") Write-Host "Setting initial deployment date ($deploymentDate)..." -ForegroundColor Green New-AzAutomationVariable -Name $deploymentDateVariableName -Description "The date of the initial engine deployment" ` -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName -Value $deploymentDate -Encrypted $false } #endregion #region Open SQL Server firewall rule if (-not($sqlServerName -like "*.database.*")) { $myPublicIp = (Invoke-WebRequest -uri "https://ifconfig.me/ip").Content.Trim() if (-not($myPublicIp -like "*.*.*.*")) { $myPublicIp = (Invoke-WebRequest -uri "https://ipv4.icanhazip.com").Content.Trim() if (-not($myPublicIp -like "*.*.*.*")) { $myPublicIp = (Invoke-WebRequest -uri "https://ipinfo.io/ip").Content.Trim() } } Write-Host "Opening SQL Server firewall temporarily to your public IP ($myPublicIp)..." -ForegroundColor Green $tempFirewallRuleName = "InitialDeployment" New-AzSqlServerFirewallRule -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -FirewallRuleName $tempFirewallRuleName -StartIpAddress $myPublicIp -EndIpAddress $myPublicIp -ErrorAction Continue | Out-Null } #endregion #region SQL Database model deployment Write-Host "Deploying SQL Database model..." -ForegroundColor Green $sqlPassPlain = (New-Object PSCredential "user", $sqlPass).GetNetworkCredential().Password if (-not($sqlServerName -like "*.database.*")) { $sqlServerEndpoint = "$sqlServerName$($cloudDetails.SqlDatabaseDnsSuffix)" } else { $sqlServerEndpoint = $sqlServerName } $databaseName = $sqlDatabaseName $SqlTimeout = 60 $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlServerEndpoint,1433;Database=$databaseName;User ID=$sqlAdmin;Password=$sqlPassPlain;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $createTableQuery = Get-Content -Path "./model/loganalyticsingestcontrol-table.sql" $Cmd = new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = $createTableQuery $Cmd.ExecuteReader() $Conn.Close() $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlServerEndpoint,1433;Database=$databaseName;User ID=$sqlAdmin;Password=$sqlPassPlain;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $initTableQuery = Get-Content -Path "./model/loganalyticsingestcontrol-initialize.sql" $Cmd = new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = $initTableQuery $Cmd.ExecuteReader() $Conn.Close() $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlServerEndpoint,1433;Database=$databaseName;User ID=$sqlAdmin;Password=$sqlPassPlain;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $upgradeTableQuery = Get-Content -Path "./model/loganalyticsingestcontrol-upgrade.sql" $Cmd = new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = $upgradeTableQuery $Cmd.ExecuteReader() $Conn.Close() $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlServerEndpoint,1433;Database=$databaseName;User ID=$sqlAdmin;Password=$sqlPassPlain;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $createTableQuery = Get-Content -Path "./model/sqlserveringestcontrol-table.sql" $Cmd = new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = $createTableQuery $Cmd.ExecuteReader() $Conn.Close() $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlServerEndpoint,1433;Database=$databaseName;User ID=$sqlAdmin;Password=$sqlPassPlain;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $initTableQuery = Get-Content -Path "./model/sqlserveringestcontrol-initialize.sql" $Cmd = new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = $initTableQuery $Cmd.ExecuteReader() $Conn.Close() $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlServerEndpoint,1433;Database=$databaseName;User ID=$sqlAdmin;Password=$sqlPassPlain;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $createTableQuery = Get-Content -Path "./model/recommendations-table.sql" $Cmd = new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = $createTableQuery $Cmd.ExecuteReader() $Conn.Close() $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlServerEndpoint,1433;Database=$databaseName;User ID=$sqlAdmin;Password=$sqlPassPlain;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $createTableQuery = Get-Content -Path "./model/recommendations-sp.sql" $Cmd = new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = $createTableQuery $Cmd.ExecuteReader() $Conn.Close() $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlServerEndpoint,1433;Database=$databaseName;User ID=$sqlAdmin;Password=$sqlPassPlain;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $createTableQuery = Get-Content -Path "./model/filters-table.sql" $Cmd = new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = $createTableQuery $Cmd.ExecuteReader() $Conn.Close() $connectionSuccess = $true } catch { Write-Host "Failed to contact SQL at try $tries." -ForegroundColor Yellow Write-Host $Error[0] -ForegroundColor Yellow Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { if (-not($sqlServerName -like "*.database.*")) { Write-Host "Deleting temporary SQL Server firewall rule..." -ForegroundColor Green Remove-AzSqlServerFirewallRule -FirewallRuleName $tempFirewallRuleName -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -ErrorAction Continue | Out-Null } throw "Could not establish connection to SQL." } #endregion #region Close SQL Server firewall rule if (-not($sqlServerName -like "*.database.*")) { Write-Host "Deleting temporary SQL Server firewall rule..." -ForegroundColor Green Remove-AzSqlServerFirewallRule -FirewallRuleName $tempFirewallRuleName -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -ErrorAction Continue | Out-Null } #endregion #region Workbooks deployment if (-not($deploymentOptions["DeployWorkbooks"])) { $deployWorkbooks = Read-Host "Do you want to deploy the workbooks with additional insights (recommended)? (Y/N)" } else { $deployWorkbooks = $deploymentOptions["DeployWorkbooks"] } if ("Y", "y" -contains $deployWorkbooks) { $deploymentOptions["DeployWorkbooks"] = "Y" $deploymentOptions | ConvertTo-Json | Out-File -FilePath $lastDeploymentStatePath -Force Write-Host "Publishing workbooks..." -ForegroundColor Green $workbooks = Get-ChildItem -Path "./views/workbooks/" | Where-Object { $_.Name.EndsWith(".bicep") } $la = Get-AzOperationalInsightsWorkspace -ResourceGroupName $laWorkspaceResourceGroup -Name $laWorkspaceName foreach ($workbook in $workbooks) { $workbookFileName = [System.IO.Path]::GetFileNameWithoutExtension($workbook.Name) Write-Host "Deploying $workbookFileName workbook..." try { New-AzResourceGroupDeployment -TemplateFile $workbook.FullName -ResourceGroupName $resourceGroupName -Name ($deploymentNameTemplate -f $workbookFileName) ` -workbookSourceId $la.ResourceId -resourceTags $ResourceTags -WarningAction SilentlyContinue | Out-Null } catch { Write-Host "Failed to deploy the workbook. If you are upgrading AOE, please remove first the $workbookFileName workbook from the $laWorkspaceName Log Analytics workspace and then re-deploy." -ForegroundColor Yellow } } } #endregion if (!$silentDeploy) { #region Grant Microsoft Entra ID role to AOE principal if ($null -eq $spnId) { $auto = Get-AzAutomationAccount -Name $automationAccountName -ResourceGroupName $resourceGroupName $spnId = $auto.Identity.PrincipalId if ($null -eq $spnId) { $runAsConnection = Get-AzAutomationConnection -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName -Name AzureRunAsConnection -ErrorAction SilentlyContinue $runAsAppId = $runAsConnection.FieldDefinitionValues.ApplicationId if ($runAsAppId) { $runAsServicePrincipal = Get-AzADServicePrincipal -ApplicationId $runAsAppId $spnId = $runAsServicePrincipal.Id } } } try { Import-Module Microsoft.Graph.Authentication Import-Module Microsoft.Graph.Identity.DirectoryManagement Write-Host "Granting Microsoft Entra ID Global Reader role to the Automation Account (requires administrative permissions in Microsoft Entra and MS Graph PowerShell SDK >= 2.4.0)..." -ForegroundColor Green #workaround for https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/888 $localPath = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile) if (-not(get-item "$localPath\.graph\" -ErrorAction SilentlyContinue)) { New-Item -Type Directory "$localPath\.graph" } switch ($cloudEnvironment) { "AzureUSGovernment" { $graphEnvironment = "USGov" break } "AzureChinaCloud" { $graphEnvironment = "China" break } "AzureGermanCloud" { $graphEnvironment = "Germany" break } Default { $graphEnvironment = "Global" } } Connect-MgGraph -Scopes "RoleManagement.ReadWrite.Directory","Directory.Read.All" -UseDeviceAuthentication -Environment $graphEnvironment -NoWelcome $globalReaderRole = Get-MgDirectoryRole -ExpandProperty Members -Property Id,Members,DisplayName,RoleTemplateId ` | Where-Object { $_.RoleTemplateId -eq "f2ef992c-3afb-46b9-b7cf-a126ee74c451" } $globalReaders = $globalReaderRole.Members.Id if (-not($globalReaders -contains $spnId)) { New-MgDirectoryRoleMemberByRef -DirectoryRoleId $globalReaderRole.Id -BodyParameter @{"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$spnId"} Start-Sleep -Seconds 5 $globalReaderRole = Get-MgDirectoryRole -ExpandProperty Members -Property Id,Members,DisplayName,RoleTemplateId ` | Where-Object { $_.RoleTemplateId -eq "f2ef992c-3afb-46b9-b7cf-a126ee74c451" } $globalReaders = $globalReaderRole.Members.Id if ($globalReaders -contains $spnId) { Write-Host "Role granted." -ForegroundColor Green } else { throw "Error when trying to grant Global Reader role" } } else { Write-Host "Role was already granted before." -ForegroundColor Green } } catch { Write-Host $Error[0] -ForegroundColor Yellow Write-Host "Could not grant role. If you want Microsoft Entra-based recommendations, please grant the Global Reader role manually to the $automationAccountName managed identity or, for previous versions of AOE, to the Run As Account principal." -ForegroundColor Red } #endregion } else { Write-Host "Could not grant role. If you want Microsoft Entra-based recommendations, please grant the Global Reader role manually to the $automationAccountName managed identity or, for previous versions of AOE, to the Run As Account principal." -ForegroundColor Red } Write-Host "Azure Optimization Engine deployment completed! We're almost there..." -ForegroundColor Green #region Benefits Usage dependencies if (-not($deploymentOptions["DeployBenefitsUsageDependencies"])) { $benefitsUsageDependenciesOption = Read-Host "Do you also want to deploy the dependencies for the Azure Benefits usage workbooks (EA/MCA customers only + agreement administrator role required)? (Y/N)" } else { $benefitsUsageDependenciesOption = $deploymentOptions["DeployBenefitsUsageDependencies"] } if ("Y", "y" -contains $benefitsUsageDependenciesOption) { $deploymentOptions["DeployBenefitsUsageDependencies"] = $benefitsUsageDependenciesOption $automationAccount = Get-AzAutomationAccount -ResourceGroupName $ResourceGroupName -Name $AutomationAccountName $principalId = $automationAccount.Identity.PrincipalId $tenantId = $automationAccount.Identity.TenantId $mcaBillingAccountIdRegex = "([A-Za-z0-9]+(-[A-Za-z0-9]+)+):([A-Za-z0-9]+(-[A-Za-z0-9]+)+)_[0-9]{4}-[0-9]{2}-[0-9]{2}" $mcaBillingProfileIdRegex = "([A-Za-z0-9]+(-[A-Za-z0-9]+)+)" if (-not($deploymentOptions["CustomerType"])) { $customerType = Read-Host "Are you an Enterprise Agreement (EA) or Microsoft Customer Agreement (MCA) customer? Please, type EA or MCA" $deploymentOptions["CustomerType"] = $customerType } else { $customerType = $deploymentOptions["CustomerType"] } switch ($customerType) { "EA" { if (-not($deploymentOptions["BillingAccountId"])) { $billingAccountId = Read-Host "Please, enter your Enterprise Agreement Billing Account ID (e.g. 12345678)" $deploymentOptions["BillingAccountId"] = $billingAccountId } else { $billingAccountId = $deploymentOptions["BillingAccountId"] } try { [int32]::Parse($billingAccountId) | Out-Null } catch { throw "The Enterprise Agreement Billing Account ID must be a number (e.g. 12345678)." } Write-Host "Granting the Enterprise Enrollment Reader role to the AOE Managed Identity..." -ForegroundColor Green $uri = "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingRoleAssignments?api-version=2019-10-01-preview" $roleAssignmentResponse = Invoke-AzRestMethod -Method GET -Uri $uri if (-not($roleAssignmentResponse.StatusCode -eq 200)) { throw "The Enterprise Enrollment Reader role could not be verified. Status Code: $($roleAssignmentResponse.StatusCode); Response: $($roleAssignmentResponse.Content)" } $roleAssignments = ($roleAssignmentResponse.Content | ConvertFrom-Json).value if (-not($roleAssignments | Where-Object { $_.properties.principalId -eq $principalId -and $_.properties.roleDefinitionId -eq "/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingRoleDefinitions/24f8edb6-1668-4659-b5e2-40bb5f3a7d7e" })) { $billingRoleAssignmentName = ([System.Guid]::NewGuid()).Guid $uri = "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingRoleAssignments/$($billingRoleAssignmentName)?api-version=2019-10-01-preview" $body = "{`"properties`": {`"principalId`":`"$principalId`",`"principalTenantId`":`"$tenantId`",`"roleDefinitionId`":`"/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingRoleDefinitions/24f8edb6-1668-4659-b5e2-40bb5f3a7d7e`"}}" $roleAssignmentResponse = Invoke-AzRestMethod -Method PUT -Uri $uri -Payload $body if (-not($roleAssignmentResponse.StatusCode -in (200,201,202))) { throw "The Enterprise Enrollment Reader role could not be granted. Status Code: $($roleAssignmentResponse.StatusCode); Response: $($roleAssignmentResponse.Content)" } } else { Write-Host "Role was already granted before." -ForegroundColor Green } break } "MCA" { if (-not($deploymentOptions["BillingAccountId"])) { $billingAccountId = Read-Host "Please, enter your Microsoft Customer Agreement Billing Account ID (e.g. :_YYYY-MM-DD)" $deploymentOptions["BillingAccountId"] = $billingAccountId } else { $billingAccountId = $deploymentOptions["BillingAccountId"] } if (-not($billingAccountId -match $mcaBillingAccountIdRegex)) { throw "The Microsoft Customer Agreement Billing Account ID must be in the format :_YYYY-MM-DD." } if (-not($deploymentOptions["BillingProfileId"])) { $billingProfileId = Read-Host "Please, enter your Billing Profile ID (e.g. ABCD-DEF-GHI-JKL)" $deploymentOptions["BillingProfileId"] = $billingProfileId } else { $billingProfileId = $deploymentOptions["BillingProfileId"] } if (-not($billingProfileId -match $mcaBillingProfileIdRegex)) { throw "The Microsoft Customer Agreement Billing Profile ID must be in the format ABCD-DEF-GHI-JKL." } Write-Host "Granting the Billing Profile Reader role to the AOE Managed Identity..." -ForegroundColor Green $uri = "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingProfiles/$billingProfileId/billingRoleAssignments?api-version=2019-10-01-preview" $roleAssignmentResponse = Invoke-AzRestMethod -Method GET -Uri $uri if (-not($roleAssignmentResponse.StatusCode -eq 200)) { throw "The Billing Profile Reader role could not be verified. Status Code: $($roleAssignmentResponse.StatusCode); Response: $($roleAssignmentResponse.Content)" } $roleAssignments = ($roleAssignmentResponse.Content | ConvertFrom-Json).value if (-not($roleAssignments | Where-Object { $_.properties.principalId -eq $principalId -and $_.properties.roleDefinitionId -eq "/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingProfiles/$billingProfileId/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000002" })) { $uri = "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingProfiles/$billingProfileId/createBillingRoleAssignment?api-version=2020-12-15-privatepreview" $body = "{`"principalId`":`"$principalId`",`"principalTenantId`":`"$tenantId`",`"roleDefinitionId`":`"/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingProfiles/$billingProfileId/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000002`"}" $roleAssignmentResponse = Invoke-AzRestMethod -Method POST -Uri $uri -Payload $body if (-not($roleAssignmentResponse.StatusCode -in (200,201,202))) { throw "The Billing Profile Reader role could not be granted. Status Code: $($roleAssignmentResponse.StatusCode); Response: $($roleAssignmentResponse.Content)" } } else { Write-Host "Role was already granted before." -ForegroundColor Green } break } Default { throw "Only EA and MCA customers are supported at this time." } } Write-Output "Setting up the Billing Account ID variable..." $billingAccountIdVarName = "AzureOptimization_BillingAccountID" $billingAccountIdVar = Get-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingAccountIdVarName -ErrorAction SilentlyContinue if (-not($billingAccountIdVar)) { New-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingAccountIdVarName -Value $billingAccountId -Encrypted $false | Out-Null } else { Set-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingAccountIdVarName -Value $billingAccountId -Encrypted $false | Out-Null } if ($billingProfileId) { Write-Output "Setting up the Billing Profile ID variable..." $billingProfileIdVarName = "AzureOptimization_BillingProfileID" $billingProfileIdVar = Get-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingProfileIdVarName -ErrorAction SilentlyContinue if (-not($billingProfileIdVar)) { New-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingProfileIdVarName -Value $billingProfileId -Encrypted $false | Out-Null } else { Set-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingProfileIdVarName -Value $billingProfileId -Encrypted $false | Out-Null } } if (-not $deploymentOptions["CurrencyCode"]) { $currencyCode = Read-Host "Please, enter your consumption currency code (e.g. EUR, USD, etc.)" $deploymentOptions["CurrencyCode"] = $currencyCode } else { $currencyCode = $deploymentOptions["CurrencyCode"] } $deploymentOptions | ConvertTo-Json | Out-File -FilePath $lastDeploymentStatePath -Force Write-Output "Setting up the consumption currency code variable..." $currencyCodeVarName = "AzureOptimization_RetailPricesCurrencyCode" $currencyCodeVar = Get-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $currencyCodeVarName -ErrorAction SilentlyContinue if (-not($currencyCodeVar)) { New-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $currencyCodeVarName -Value $currencyCode -Encrypted $false | Out-Null } else { Set-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $currencyCodeVarName -Value $currencyCode -Encrypted $false | Out-Null } } #endregion Write-Host "Deployment fully completed!" -ForegroundColor Green } else { Write-Host "Deployment cancelled." -ForegroundColor Red } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Hélder Pinto Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Welcome to the Azure Optimization Engine - now a FinOps Toolkit tool! 🔍 👋 Thank you for your interest in the Azure Optimization Engine! We've migrated the Azure Optimization Engine repository to a new home - the Microsoft [FinOps Toolkit](https://aka.ms/AzureOptimizationEngine). For historical reasons, the older code will remain here, but the most recent version and new releases will be made in the FinOps Toolkit repository going forward. Here's what you need to know: 1. **We are collecting feedback in preparation for AOE v2**: Answer this [anonymous feedback form](https://forms.office.com/r/fLeJS8Rd2E) and contribute to the [discussion about the evolution of AOE](https://github.com/microsoft/finops-toolkit/discussions/753). 1. **Start Using the FinOps Toolkit**: If you were using the Azure Optimization Engine, it's time to switch! The [FinOps Toolkit](https://aka.ms/AzureOptimizationEngine) provides advanced solutions, automation scripts, and learning resources to accelerate your FinOps journey. 2. **Open Issues Here**: Have questions, feedback, or need assistance? Open an issue in the [FinOps Toolkit repository](https://github.com/microsoft/finops-toolkit/issues), and our community and maintainers will be happy to help. 🙌 3. **Explore Our Tools**: Besides the Azure Optimization Engine, check out all the other available tools, including FinOps hubs, Power BI reports, cost optimization workbooks, and more. [Explore the FinOps Toolkit](https://aka.ms/finops/toolkit) 4. **Stay Updated**: Follow our [FinOps blog](https://techcommunity.microsoft.com/t5/finops-blog/bg-p/FinOpsBlog) for the latest news and announcements. Let's make cloud cost management easier together! 🌟 ================================================ FILE: Reset-AutomationSchedules.ps1 ================================================ param( [Parameter(Mandatory = $false)] [String] $AzureEnvironment = "AzureCloud", [Parameter(Mandatory = $true)] [String] $AutomationAccountName, [Parameter(Mandatory = $true)] [String] $ResourceGroupName ) $ErrorActionPreference = "Stop" $ctx = Get-AzContext if (-not($ctx)) { Connect-AzAccount -Environment $AzureEnvironment $ctx = Get-AzContext } else { if ($ctx.Environment.Name -ne $AzureEnvironment) { Disconnect-AzAccount -ContextName $ctx.Name Connect-AzAccount -Environment $AzureEnvironment $ctx = Get-AzContext } } try { $scheduledRunbooks = Get-AzAutomationScheduledRunbook -AutomationAccountName $AutomationAccountName -ResourceGroupName $ResourceGroupName } catch { throw "$AutomationAccountName Automation Account not found in Resource Group $ResourceGroupName in Subscription $($ctx.Subscription.Name). If we are not in the right subscription, use Set-AzContext to switch to the correct one." } if (-not($scheduledRunbooks)) { throw "The $AutomationAccountName Automation Account does not contain any scheduled runbook. It might not be associated to the Azure Optimization Engine." } $subscriptionId = (Get-AzContext).Subscription.Id $schedules = Get-AzAutomationSchedule -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName $weeklySchedules = $schedules | Where-Object { $_.Name.StartsWith("AzureOptimization") -and $_.Name.EndsWith("Weekly") } if ($weeklySchedules.Count -gt 0) { $originalBaseTime = ($weeklySchedules | Sort-Object -Property StartTime | Select-Object -First 1).StartTime.AddHours(-1.25).DateTime $now = (Get-Date).ToUniversalTime() $diff = $now.AddHours(-1.25) - $originalBaseTime $nextWeekDays = [Math]::Ceiling($diff.TotalDays / 7) * 7 $baseDateTime = $now.AddHours(-1.25).AddDays($nextWeekDays - $diff.TotalDays) $baseTimeStr = $baseDateTime.ToString("u") Write-Host "Existing schedules found. Weekly base time is $($baseDateTime.DayOfWeek) at $($baseDateTime.ToString('T')) (UTC)." -ForegroundColor Green } else { throw "The $AutomationAccountName Automation Account does not contain Azure Optimization Engine schedules." } $newBaseTimeStr = Read-Host "Please, enter a new base time for the *weekly* schedules in UTC (YYYY-MM-dd HH:mm:ss). If you want to keep the current one, just press ENTER" if (-not($newBaseTimeStr)) { $newBaseTimeStr = $baseTimeStr } else { try { $newBaseTimeStr += "Z" $newBaseTime = [DateTime]::Parse($newBaseTimeStr) } catch { throw "$newBaseTimeStr is an invalid base time. Use the following format: YYYY-MM-dd HH:mm:ss. For example: 1977-09-08 06:14:15" } if ($newBaseTime -lt (Get-Date).ToUniversalTime().AddHours(-1)) { throw "$newBaseTimeStr is an invalid base time. It can't be sooner than $((Get-Date).ToUniversalTime().AddHours(-1).ToString('u'))" } } $baseTimeUtc = [DateTime]::Parse($newBaseTimeStr).ToUniversalTime() if ($newBaseTimeStr -ne $baseTimeStr) { Write-Host "Updating current base schedule to every $($baseTimeUtc.DayOfWeek) at $($baseTimeUtc.TimeOfDay.ToString()) UTC..." -ForegroundColor Green $continueInput = Read-Host "Continue (Y/N)?" if ("Y", "y" -contains $continueInput) { $upgradeManifest = Get-Content -Path "./upgrade-manifest.json" | ConvertFrom-Json $manifestSchedules = $upgradeManifest.schedules foreach ($schedule in $schedules) { $manifestSchedule = $manifestSchedules | Where-Object { $_.name -eq $schedule.Name } if ($manifestSchedule) { if ($schedule.Frequency -eq "Week") { $newStartTime = $baseTimeUtc.Add([System.Xml.XmlConvert]::ToTimeSpan($manifestSchedule.offset)) } else { $now = (Get-Date).ToUniversalTime() $newStartTime = [System.DateTimeOffset]::Parse($now.ToString("yyyy-MM-ddT00:00:00Z")) $newStartTime = $newStartTime.AddHours($baseTimeUtc.Hour).AddMinutes($baseTimeUtc.Minute).AddSeconds($baseTimeUtc.Second) if ($newStartTime -lt $now.AddMinutes(15)) { $newStartTime = $newStartTime.AddDays(1) } $newStartTime = $newStartTime.Add([System.Xml.XmlConvert]::ToTimeSpan($manifestSchedule.offset)) } $expiryTime = $schedule.ExpiryTime.ToString("yyyy-MM-ddTHH:mm:ssZ") $startTime = $newStartTime.ToString("yyyy-MM-ddTHH:mm:ssZ") $automationPath = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Automation/automationAccounts/$automationAccountName/schedules/$($schedule.Name)?api-version=2015-10-31" $body = "{ `"name`": `"$($schedule.Name)`", `"properties`": { `"description`": `"$($schedule.Description)`", `"startTime`": `"$startTime`", `"expiryTime`": `"$expiryTime`", `"interval`": $($schedule.Interval), `"frequency`": `"$($schedule.Frequency)`", `"advancedSchedule`": {} } }" Invoke-AzRestMethod -Path $automationPath -Method PUT -Payload $body | Out-Null Write-Host "Re-scheduled $($schedule.Name)." -ForegroundColor Blue } else { Write-Host "$($schedule.Name) not found in schedules manifest." -ForegroundColor Yellow } } } else { throw "Interrupting schedules reset due to user input." } } else { Write-Host "Kept current base schedule (every $($baseTimeUtc.DayOfWeek) at $($baseTimeUtc.TimeOfDay.ToString()) UTC)." -ForegroundColor Green } $exportHybridWorkerOption = ($scheduledRunbooks | Where-Object { $_.RunbookName.StartsWith("Export") })[0].HybridWorker $ingestHybridWorkerOption = ($scheduledRunbooks | Where-Object { $_.RunbookName.StartsWith("Ingest") })[0].HybridWorker $recommendHybridWorkerOption = ($scheduledRunbooks | Where-Object { $_.RunbookName.StartsWith("Recommend") })[0].HybridWorker if ($scheduledRunbooks | Where-Object { $_.RunbookName.StartsWith("Remediate") }) { $remediateHybridWorkerOption = ($scheduledRunbooks | Where-Object { $_.RunbookName.StartsWith("Remediate") })[0].HybridWorker } $hybridWorkerOption = "None" if ($exportHybridWorkerOption -or $ingestHybridWorkerOption -or $recommendHybridWorkerOption -or $remediateHybridWorkerOption) { $hybridWorkerOption = "Export: $exportHybridWorkerOption; Ingest: $ingestHybridWorkerOption; Recommend: $recommendHybridWorkerOption; Remediate: $remediateHybridWorkerOption" } Write-Host "Current Hybrid Worker option: $hybridWorkerOption" -ForegroundColor Green $newHybridWorker = Read-Host "If you want all schedules to use the same Hybrid Worker, please enter the Hybrid Worker Group name (if you want to keep the current option, just press ENTER)" if ($newHybridWorker) { $hybridWorker = Get-AzAutomationHybridWorkerGroup -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName -Name $newHybridWorker -ErrorAction SilentlyContinue if (-not($hybridWorker)) { throw "Hybrid Worker $newHybridWorker was not found in Automation Account $automationAccountName." } Write-Host "Updating Hybrid Worker Group in every runbook schedule to $newHybridWorker..." $continueInput = Read-Host "Continue (Y/N)?" if ("Y", "y" -contains $continueInput) { Write-Host "Re-registering previous runbook schedules associations from $automationAccountName..." -ForegroundColor Green foreach ($jobSchedule in $scheduledRunbooks) { if ($jobSchedule.ScheduleName.StartsWith("AzureOptimization")) { $automationPath = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Automation/automationAccounts/$automationAccountName/jobSchedules/$($jobSchedule.JobScheduleId)?api-version=2015-10-31" $jobSchedule = (Invoke-AzRestMethod -Path $automationPath -Method GET).Content | ConvertFrom-Json Unregister-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -JobScheduleId $jobSchedule.id.Split('/')[10] -Force $params = @{} $jobSchedule.properties.parameters.PSObject.Properties | ForEach-Object { $params[$_.Name] = $_.Value } Register-AzAutomationScheduledRunbook -ResourceGroupName $resourceGroupName -AutomationAccountName $automationAccountName ` -RunbookName $jobSchedule.properties.runbook.name -ScheduleName $jobSchedule.properties.schedule.name -RunOn $newHybridWorker -Parameters $params | Out-Null Write-Host "Re-registered $($jobSchedule.properties.runbook.name) for schedule $($jobSchedule.properties.schedule.name)." -ForegroundColor Blue } } } else { throw "Interrupting schedules reset due to user input." } } else { Write-Host "Kept current Hybrid Worker option: $hybridWorkerOption" -ForegroundColor Green } Write-Host "DONE" -ForegroundColor Green ================================================ FILE: Setup-BenefitsUsageDependencies.ps1 ================================================ param( [Parameter(Mandatory = $false)] [String] $AzureEnvironment = "AzureCloud", [Parameter(Mandatory = $true)] [String] $AutomationAccountName, [Parameter(Mandatory = $true)] [String] $ResourceGroupName ) $ErrorActionPreference = "Stop" $ctx = Get-AzContext if (-not($ctx)) { Connect-AzAccount -Environment $AzureEnvironment $ctx = Get-AzContext } else { if ($ctx.Environment.Name -ne $AzureEnvironment) { Disconnect-AzAccount -ContextName $ctx.Name Connect-AzAccount -Environment $AzureEnvironment $ctx = Get-AzContext } } try { $scheduledRunbooks = Get-AzAutomationScheduledRunbook -AutomationAccountName $AutomationAccountName -ResourceGroupName $ResourceGroupName } catch { throw "$AutomationAccountName Automation Account not found in Resource Group $ResourceGroupName in Subscription $($ctx.Subscription.Name). If we are not in the right subscription, use Set-AzContext to switch to the correct one." } if (-not($scheduledRunbooks)) { throw "The $AutomationAccountName Automation Account does not contain any scheduled runbook. It might not be associated to the Azure Optimization Engine." } $automationAccount = Get-AzAutomationAccount -ResourceGroupName $ResourceGroupName -Name $AutomationAccountName $principalId = $automationAccount.Identity.PrincipalId $tenantId = $automationAccount.Identity.TenantId if (-not($principalId)) { throw "The $AutomationAccountName Automation Account does not have a managed identity and probably is not associated to the latest version of Azure Optimization Engine. Please, upgrade it before setting up benefits usage dependencies (more details: https://github.com/helderpinto/AzureOptimizationEngine#upgrade)." } $pricesheetSchedule = Get-AzAutomationSchedule -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name "AzureOptimization_ExportPricesWeekly" -ErrorAction SilentlyContinue if (-not($pricesheetSchedule)) { throw "The Azure Optimization Engine is in an older version. Please, upgrade it before setting up benefits usage dependencies (more details: https://github.com/helderpinto/AzureOptimizationEngine#upgrade)." } $mcaBillingAccountIdRegex = "([A-Za-z0-9]+(-[A-Za-z0-9]+)+):([A-Za-z0-9]+(-[A-Za-z0-9]+)+)_[0-9]{4}-[0-9]{2}-[0-9]{2}" $mcaBillingProfileIdRegex = "([A-Za-z0-9]+(-[A-Za-z0-9]+)+)" $customerType = Read-Host "Are you an Enterprise Agreement (EA), Microsoft Customer Agreement (MCA), or other type (Other) of customer? Please, type EA, MCA, or Other" switch ($customerType) { "EA" { $billingAccountId = Read-Host "Please, enter your Enterprise Agreement Billing Account ID (e.g. 12345678)" try { [int32]::Parse($billingAccountId) | Out-Null } catch { throw "The Enterprise Agreement Billing Account ID must be a number (e.g. 12345678)." } Write-Host "Granting the Enterprise Enrollment Reader role to the AOE Managed Identity..." -ForegroundColor Green $uri = "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingRoleAssignments?api-version=2019-10-01-preview" $roleAssignmentResponse = Invoke-AzRestMethod -Method GET -Uri $uri if (-not($roleAssignmentResponse.StatusCode -eq 200)) { throw "The Enterprise Enrollment Reader role could not be verified. Status Code: $($roleAssignmentResponse.StatusCode); Response: $($roleAssignmentResponse.Content)" } $roleAssignments = ($roleAssignmentResponse.Content | ConvertFrom-Json).value if (-not($roleAssignments | Where-Object { $_.properties.principalId -eq $principalId -and $_.properties.roleDefinitionId -eq "/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingRoleDefinitions/24f8edb6-1668-4659-b5e2-40bb5f3a7d7e" })) { $billingRoleAssignmentName = ([System.Guid]::NewGuid()).Guid $uri = "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingRoleAssignments/$($billingRoleAssignmentName)?api-version=2019-10-01-preview" $body = "{`"properties`": {`"principalId`":`"$principalId`",`"principalTenantId`":`"$tenantId`",`"roleDefinitionId`":`"/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingRoleDefinitions/24f8edb6-1668-4659-b5e2-40bb5f3a7d7e`"}}" $roleAssignmentResponse = Invoke-AzRestMethod -Method PUT -Uri $uri -Payload $body if (-not($roleAssignmentResponse.StatusCode -in (200,201,202))) { throw "The Enterprise Enrollment Reader role could not be granted. Status Code: $($roleAssignmentResponse.StatusCode); Response: $($roleAssignmentResponse.Content)" } } else { Write-Host "Role was already granted before." -ForegroundColor Green } break } "MCA" { $billingAccountId = Read-Host "Please, enter your Microsoft Customer Agreement Billing Account ID (e.g. :_YYYY-MM-DD)" if (-not($billingAccountId -match $mcaBillingAccountIdRegex)) { throw "The Microsoft Customer Agreement Billing Account ID must be in the format :_YYYY-MM-DD." } $billingProfileId = Read-Host "Please, enter your Billing Profile ID (e.g. ABCD-DEF-GHI-JKL)" if (-not($billingProfileId -match $mcaBillingProfileIdRegex)) { throw "The Microsoft Customer Agreement Billing Profile ID must be in the format ABCD-DEF-GHI-JKL." } Write-Host "Granting the Billing Profile Reader role to the AOE Managed Identity..." -ForegroundColor Green $uri = "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingProfiles/$billingProfileId/billingRoleAssignments?api-version=2019-10-01-preview" $roleAssignmentResponse = Invoke-AzRestMethod -Method GET -Uri $uri if (-not($roleAssignmentResponse.StatusCode -eq 200)) { throw "The Billing Profile Reader role could not be verified. Status Code: $($roleAssignmentResponse.StatusCode); Response: $($roleAssignmentResponse.Content)" } $roleAssignments = ($roleAssignmentResponse.Content | ConvertFrom-Json).value if (-not($roleAssignments | Where-Object { $_.properties.principalId -eq $principalId -and $_.properties.roleDefinitionId -eq "/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingProfiles/$billingProfileId/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000002" })) { $uri = "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingProfiles/$billingProfileId/createBillingRoleAssignment?api-version=2020-12-15-privatepreview" $body = "{`"principalId`":`"$principalId`",`"principalTenantId`":`"$tenantId`",`"roleDefinitionId`":`"/providers/Microsoft.Billing/billingAccounts/$billingAccountId/billingProfiles/$billingProfileId/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000002`"}" $roleAssignmentResponse = Invoke-AzRestMethod -Method POST -Uri $uri -Payload $body if (-not($roleAssignmentResponse.StatusCode -in (200,201,202))) { throw "The Billing Profile Reader role could not be granted. Status Code: $($roleAssignmentResponse.StatusCode); Response: $($roleAssignmentResponse.Content)" } } else { Write-Host "Role was already granted before." -ForegroundColor Green } break } Default { throw "Only EA and MCA customers are supported at this time." } } Write-Output "Setting up the Billing Account ID variable..." $billingAccountIdVarName = "AzureOptimization_BillingAccountID" $billingAccountIdVar = Get-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingAccountIdVarName -ErrorAction SilentlyContinue if (-not($billingAccountIdVar)) { New-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingAccountIdVarName -Value $billingAccountId -Encrypted $false | Out-Null } else { Set-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingAccountIdVarName -Value $billingAccountId -Encrypted $false | Out-Null } if ($billingProfileId) { Write-Output "Setting up the Billing Profile ID variable..." $billingProfileIdVarName = "AzureOptimization_BillingProfileID" $billingProfileIdVar = Get-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingProfileIdVarName -ErrorAction SilentlyContinue if (-not($billingProfileIdVar)) { New-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingProfileIdVarName -Value $billingProfileId -Encrypted $false | Out-Null } else { Set-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $billingProfileIdVarName -Value $billingProfileId -Encrypted $false | Out-Null } } $currencyCode = Read-Host "Please, enter your consumption currency code (e.g. EUR, USD, etc.)" Write-Output "Setting up the consumption currency code variable..." $currencyCodeVarName = "AzureOptimization_RetailPricesCurrencyCode" $currencyCodeVar = Get-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $currencyCodeVarName -ErrorAction SilentlyContinue if (-not($currencyCodeVar)) { New-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $currencyCodeVarName -Value $currencyCode -Encrypted $false | Out-Null } else { Set-AzAutomationVariable -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $currencyCodeVarName -Value $currencyCode -Encrypted $false | Out-Null } Write-Host "DONE" -ForegroundColor Green ================================================ FILE: Setup-DataCollectionRules.ps1 ================================================ param( [Parameter(Mandatory = $false)] [String] $AzureEnvironment = "AzureCloud", [Parameter(Mandatory = $true)] [String] $DestinationWorkspaceResourceId, [Parameter(Mandatory = $false)] [int] $IntervalSeconds = 60, [Parameter(Mandatory = $false)] [hashtable] $ResourceTags = @{} ) $ErrorActionPreference = "Stop" $ctx = Get-AzContext if (-not($ctx)) { Connect-AzAccount -Environment $AzureEnvironment $ctx = Get-AzContext } else { if ($ctx.Environment.Name -ne $AzureEnvironment) { Disconnect-AzAccount -ContextName $ctx.Name Connect-AzAccount -Environment $AzureEnvironment $ctx = Get-AzContext } } $lastDeploymentStatePath = ".\last-deployment-state.json" $deploymentOptions = @{} $perfCounters = Get-Content -Path ".\perfcounters.json" | ConvertFrom-Json if ((Test-Path -Path $lastDeploymentStatePath)) { $depOptions = Get-Content -Path $lastDeploymentStatePath | ConvertFrom-Json Write-Host $depOptions -ForegroundColor Green $depOptionsReuse = Read-Host "Found last deployment options above. Do you want to create Data Collection Rules (DCRs) reusing the last deployment options (Y/N)?" if ("Y", "y" -contains $depOptionsReuse) { foreach ($property in $depOptions.PSObject.Properties) { $deploymentOptions[$property.Name] = $property.Value } } } Write-Host "Getting Azure subscriptions..." -ForegroundColor Yellow $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" -and $_.SubscriptionPolicies.QuotaId -notlike "AAD*" } if ($subscriptions.Count -gt 1) { $selectedSubscription = -1 for ($i = 0; $i -lt $subscriptions.Count; $i++) { if (-not($deploymentOptions["SubscriptionId"])) { Write-Output "[$i] $($subscriptions[$i].Name)" } else { if ($subscriptions[$i].Id -eq $deploymentOptions["SubscriptionId"]) { $selectedSubscription = $i break } } } if (-not($deploymentOptions["SubscriptionId"])) { $lastSubscriptionIndex = $subscriptions.Count - 1 while ($selectedSubscription -lt 0 -or $selectedSubscription -gt $lastSubscriptionIndex) { Write-Output "---" $selectedSubscription = [int] (Read-Host "Please, select the target subscription for this deployment [0..$lastSubscriptionIndex]") } } if ($selectedSubscription -eq -1) { throw "The selected subscription does not exist. Check if you are logged in with the right Microsoft Entra ID user." } } else { if ($subscriptions.Count -ne 0) { $selectedSubscription = 0 } else { throw "No valid subscriptions found. Only EA, MCA, PAYG or MSDN subscriptions are supported currently." } } if ($subscriptions.Count -eq 0) { throw "No subscriptions found. Check if you are logged in with the right Microsoft Entra ID account." } $subscriptionId = $subscriptions[$selectedSubscription].Id if ($ctx.Subscription.SubscriptionId -ne $DestinationWorkspaceResourceId.Split('/')[2]) { $ctx = Set-AzContext -SubscriptionId $DestinationWorkspaceResourceId.Split('/')[2] } $la = Get-AzOperationalInsightsWorkspace -ResourceGroupName $DestinationWorkspaceResourceId.Split('/')[4] -Name $DestinationWorkspaceResourceId.Split('/')[8] -ErrorAction SilentlyContinue if (-not($la)) { throw "The destination workspace ($DestinationWorkspaceResourceId) does not exist. Check if you are logged in with the right Microsoft Entra ID user." } if (-not($deploymentOptions["NamePrefix"])) { do { $namePrefix = Read-Host "Please, enter a unique name prefix for the DCRs or existing prefix if updating deployment. If you want instead to individually name all DCRs, just press ENTER" if (-not($namePrefix)) { $namePrefix = "EmptyNamePrefix" } } while ($namePrefix.Length -gt 21) } else { if ($deploymentOptions["NamePrefix"] -eq "EmptyNamePrefix") { $namePrefix = $null } else { $namePrefix = $deploymentOptions["NamePrefix"] } } $windowsDcrNameTemplate = "{0}-windows-dcr" $linuxDcrNameTemplate = "{0}-linux-dcr" if (-not($deploymentOptions["ResourceGroupName"])) { $resourceGroupName = Read-Host "Please, enter the new or existing Resource Group for this deployment" } else { $resourceGroupName = $deploymentOptions["ResourceGroupName"] } if ($ctx.Subscription.SubscriptionId -ne $subscriptionId) { $ctx = Set-AzContext -SubscriptionId $subscriptionId } $rg = Get-AzResourceGroup -Name $resourceGroupName if ([string]::IsNullOrEmpty($namePrefix) -or $namePrefix -eq "EmptyNamePrefix") { $windowsDcrName = Read-Host "Enter the Windows DCR name" $linuxDcrName = Read-Host "Enter the Linux DCR name" } else { $windowsDcrName = $windowsDcrNameTemplate -f $namePrefix $linuxDcrName = $linuxDcrNameTemplate -f $namePrefix } if (-not($deploymentOptions["TargetLocation"])) { if (-not($rg.Location)) { Write-Host "Getting Azure locations..." -ForegroundColor Green $locations = Get-AzLocation | Where-Object { $_.Providers -contains "Microsoft.Insights" } | Sort-Object -Property Location for ($i = 0; $i -lt $locations.Count; $i++) { Write-Output "[$i] $($locations[$i].location)" } $selectedLocation = -1 $lastLocationIndex = $locations.Count - 1 while ($selectedLocation -lt 0 -or $selectedLocation -gt $lastLocationIndex) { Write-Output "---" $selectedLocation = [int] (Read-Host "Please, select the target location for this deployment [0..$lastLocationIndex]") } $targetLocation = $locations[$selectedLocation].location } else { $targetLocation = $rg.Location } } else { $targetLocation = $deploymentOptions["TargetLocation"] } $windowsPerfCounters = @() foreach ($perfCounter in ($perfCounters | Where-Object {$_.osType -eq "Windows"})) { $windowsPerfCounters += $ExecutionContext.InvokeCommand.ExpandString('"\\$($perfCounter.objectName)($($perfCounter.instance))\\$($perfCounter.counterName)"') } $linuxPerfCounters = @() foreach ($perfCounter in ($perfCounters | Where-Object {$_.osType -eq "Linux"})) { $linuxPerfCounters += $ExecutionContext.InvokeCommand.ExpandString('"\\$($perfCounter.objectName)($($perfCounter.instance))\\$($perfCounter.counterName)"') } $windowsDcrBody = @' { "dataSources": { "performanceCounters": [ { "streams": [ "Microsoft-Perf" ], "samplingFrequencyInSeconds": $IntervalSeconds, "counterSpecifiers": [ $($windowsPerfCounters -join ",") ], "name": "perfCounterDataSource$IntervalSeconds" } ] }, "destinations": { "logAnalytics": [ { "workspaceResourceId": "$destinationWorkspaceResourceId", "workspaceId": "$($la.Properties.CustomerId)", "name": "la--1138206996" } ] }, "dataFlows": [ { "streams": [ "Microsoft-Perf" ], "destinations": [ "la--1138206996" ] } ] } '@ Write-Output "Creating Windows DCR..." $windowsDcrBody = $ExecutionContext.InvokeCommand.ExpandString($windowsDcrBody) | ConvertFrom-Json New-AzResource -ResourceType "Microsoft.Insights/dataCollectionRules" -ResourceGroupName $resourceGroupName -Location $targetLocation -Name $windowsDcrName -PropertyObject $windowsDcrBody -ApiVersion "2021-04-01" -Tag $ResourceTags -Kind "Windows" -Force | Out-Null $linuxDcrBody = @' { "dataSources": { "performanceCounters": [ { "streams": [ "Microsoft-Perf" ], "samplingFrequencyInSeconds": $IntervalSeconds, "counterSpecifiers": [ $($linuxPerfCounters -join ",") ], "name": "perfCounterDataSource$IntervalSeconds" } ] }, "destinations": { "logAnalytics": [ { "workspaceResourceId": "$destinationWorkspaceResourceId", "workspaceId": "$($la.Properties.CustomerId)", "name": "la--1138206996" } ] }, "dataFlows": [ { "streams": [ "Microsoft-Perf" ], "destinations": [ "la--1138206996" ] } ] } '@ Write-Output "Creating Linux DCR..." $linuxDcrBody = $ExecutionContext.InvokeCommand.ExpandString($linuxDcrBody) | ConvertFrom-Json New-AzResource -ResourceType "Microsoft.Insights/dataCollectionRules" -ResourceGroupName $resourceGroupName -Location $targetLocation -Name $linuxDcrName -PropertyObject $linuxDcrBody -ApiVersion "2021-04-01" -Tag $ResourceTags -Kind "Linux" -Force | Out-Null Write-Host -ForegroundColor Green "Deployment completed successfully" ================================================ FILE: Setup-LogAnalyticsWorkspaces.ps1 ================================================ param( [Parameter(Mandatory = $false)] [String] $AzureEnvironment = "AzureCloud", [Parameter(Mandatory = $false)] [String[]] $WorkspaceIds, [Parameter(Mandatory = $false)] [switch] $AutoFix, [Parameter(Mandatory = $false)] [int] $IntervalSeconds = 60 ) $ErrorActionPreference = "Stop" $ctx = Get-AzContext if (-not($ctx)) { Connect-AzAccount -Environment $AzureEnvironment $ctx = Get-AzContext } else { if ($ctx.Environment.Name -ne $AzureEnvironment) { Disconnect-AzAccount -ContextName $ctx.Name Connect-AzAccount -Environment $AzureEnvironment $ctx = Get-AzContext } } $wsIds = foreach ($workspaceId in $WorkspaceIds) { "'$workspaceId'" } if ($wsIds) { $wsIds = $wsIds -join "," $whereWsIds = " and properties.customerId in ($wsIds)" } $perfCounters = Get-Content -Path ".\perfcounters.json" | ConvertFrom-Json $ARGPageSize = 1000 $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $argQuery = "resources | where type =~ 'microsoft.operationalinsights/workspaces'$whereWsIds | order by id" $workspaces = (Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions).data Write-Output "Found $($workspaces.Count) workspaces." $laQuery = "Heartbeat | where TimeGenerated > ago(1d) and ComputerEnvironment == 'Azure' | distinct Computer | summarize AzureComputersCount = count()" foreach ($workspace in $workspaces) { $laQueryResults = $null $results = $null $laQueryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspace.properties.customerId -Query $laQuery -Timespan (New-TimeSpan -Days 1) -ErrorAction Continue if ($laQueryResults) { $results = [System.Linq.Enumerable]::ToArray($laQueryResults.Results) Write-Output "$($workspace.name) ($($workspace.properties.customerId)): $($results.AzureComputersCount) Azure computers connected." } else { Write-Output "$($workspace.name) ($($workspace.properties.customerId)): could not validate connected computers." } if ($results.AzureComputersCount -gt 0) { if ($ctx.Subscription.SubscriptionId -ne $workspace.subscriptionId) { $ctx = Set-AzContext -SubscriptionId $workspace.subscriptionId } $dsWindows = Get-AzOperationalInsightsDataSource -ResourceGroupName $workspace.resourceGroup -WorkspaceName $workspace.name -Kind WindowsPerformanceCounter foreach ($perfCounter in ($perfCounters | Where-Object {$_.osType -eq "Windows"})) { if (-not($dsWindows | Where-Object { $_.Properties.ObjectName -eq $perfCounter.objectName -and $_.Properties.InstanceName -eq $perfCounter.instance ` -and $_.Properties.CounterName -eq $perfCounter.counterName})) { Write-Output "Missing $($perfCounter.objectName)($($perfCounter.instance))\$($perfCounter.counterName)" if ($AutoFix) { Write-Output "Fixing..." $dsName = "DataSource_WindowsPerformanceCounter_$(New-Guid)" New-AzOperationalInsightsWindowsPerformanceCounterDataSource -ResourceGroupName $workspace.resourceGroup -WorkspaceName $workspace.name ` -Name $dsName -ObjectName $perfCounter.objectName -CounterName $perfCounter.counterName -InstanceName $perfCounter.instance ` -IntervalSeconds $IntervalSeconds -Force | Out-Null } } } $missingLinuxCounters = @() $dsLinux = Get-AzOperationalInsightsDataSource -ResourceGroupName $workspace.resourceGroup -WorkspaceName $workspace.name -Kind LinuxPerformanceObject foreach ($perfCounter in ($perfCounters | Where-Object {$_.osType -eq "Linux"})) { if (-not($dsLinux | Where-Object { $_.Properties.ObjectName -eq $perfCounter.objectName -and $_.Properties.InstanceName -eq $perfCounter.instance ` -and ($_.Properties.PerformanceCounters | Where-Object { $_.CounterName -eq $perfCounter.counterName }) })) { Write-Output "Missing $($perfCounter.objectName)($($perfCounter.instance))\$($perfCounter.counterName)" if ($AutoFix) { $missingLinuxCounters += $perfCounter } } } if ($AutoFix) { $fixedLinuxCounters = @() $existingLinuxObjects = ($dsLinux | Select-Object -ExpandProperty Properties | Select-Object -Property ObjectName).ObjectName foreach ($linuxObject in $existingLinuxObjects) { $missingObjectCounters = $missingLinuxCounters | Where-Object { $_.objectName -eq $linuxObject } $originalDataSource = $dsLinux | Where-Object { $_.Properties.ObjectName -eq $linuxObject } foreach ($perfCounter in $missingObjectCounters) { $fixedLinuxCounters += $perfCounter $newCounterName = New-Object -TypeName Microsoft.Azure.Commands.OperationalInsights.Models.PerformanceCounterIdentifier -Property @{CounterName = $perfCounter.counterName} $originalDataSource.Properties.PerformanceCounters.Add($newCounterName) } if ($missingObjectCounters) { Write-Output "Fixing $linuxObject object..." Set-AzOperationalInsightsDataSource -DataSource $originalDataSource | Out-Null } } $missingObjects = ($missingLinuxCounters | Select-Object -Property objectName -Unique).objectName $fixedObjects = ($fixedLinuxCounters | Select-Object -Property objectName -Unique).objectName $missingObjects = $missingObjects | Where-Object { -not($_ -in $fixedObjects) } foreach ($linuxObject in $missingObjects) { $missingObjectCounters = $missingLinuxCounters | Where-Object { $_.objectName -eq $linuxObject } $missingInstance = ($missingObjectCounters | Select-Object -Property instance -Unique -First 1).instance $missingCounterNames = ($missingObjectCounters).counterName Write-Output "Adding $linuxObject object..." New-AzOperationalInsightsLinuxPerformanceObjectDataSource -ResourceGroupName $workspace.resourceGroup -WorkspaceName $workspace.name ` -Name "DataSource_LinuxPerformanceObject_$(New-Guid)" -ObjectName $linuxObject -InstanceName $missingInstance -IntervalSeconds $IntervalSeconds ` -CounterNames $missingCounterNames -Force | Out-Null } } } } ================================================ FILE: Suppress-Recommendation.ps1 ================================================ param( [Parameter(Mandatory = $true)] [String] $RecommendationId ) $ErrorActionPreference = "Stop" function Test-IsGuid { [OutputType([bool])] param ( [Parameter(Mandatory = $true)] [string]$ObjectGuid ) # Define verification regex [regex]$guidRegex = '(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$' # Check guid against regex return $ObjectGuid -match $guidRegex } if (-not(Test-IsGuid -ObjectGuid $RecommendationId)) { Write-Host "The provided recommendation Id is invalid. Must be a valid GUID." -ForegroundColor Red Exit } $databaseConnectionSettingsPath = ".\database-connection-settings.json" $dbConnectionSettings = @{} if (Test-Path -Path $databaseConnectionSettingsPath) { $dbSettings = Get-Content -Path $databaseConnectionSettingsPath | ConvertFrom-Json Write-Host $dbSettings -ForegroundColor Green $dbSettingsReuse = Read-Host "Found existing database connection settings. Do you want to reuse them (Y/N)?" if ("Y", "y" -contains $dbSettingsReuse) { foreach ($property in $dbSettings.PSObject.Properties) { $dbConnectionSettings[$property.Name] = $property.Value } } } if (-not($dbConnectionSettings["DatabaseServer"])) { $databaseServer = Read-Host "Please, enter the AOE Azure SQL server hostname (e.g., xpto.database.windows.net)" $dbConnectionSettings["DatabaseServer"] = $databaseServer } else { $databaseServer = $dbConnectionSettings["DatabaseServer"] } if (-not($dbConnectionSettings["DatabaseName"])) { $databaseName = Read-Host "Please, enter the AOE Azure SQL Database name (e.g., azureoptimization)" $dbConnectionSettings["DatabaseName"] = $databaseName } else { $databaseName = $dbConnectionSettings["DatabaseName"] } if (-not($dbConnectionSettings["DatabaseUser"])) { $databaseUser = Read-Host "Please, enter the AOE database user name" $dbConnectionSettings["DatabaseUser"] = $databaseUser } else { $databaseUser = $dbConnectionSettings["DatabaseUser"] } $sqlPass = Read-Host "Please, input the password for the $databaseUser SQL user" -AsSecureString $sqlPassPlain = (New-Object PSCredential "user", $sqlPass).GetNetworkCredential().Password $sqlPassPlain = $sqlPassPlain.Replace("'", "''") $SqlTimeout = 120 $recommendationsTable = "Recommendations" $suppressionsTable = "Filters" Write-Host "Opening connection to the database..." -ForegroundColor Green $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$databaseServer,1433;Database=$databaseName;User ID=$databaseUser;Password='$sqlPassPlain';Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$recommendationsTable] WHERE RecommendationId = '$RecommendationId'" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Host "Failed to contact SQL at try $tries." -ForegroundColor Yellow Write-Host $Error[0] -ForegroundColor Yellow Write-Output "Waiting $($tries * 20) seconds..." Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $Conn.Close() $Conn.Dispose() if (-not($controlRows.RecommendationId)) { Write-Host "The provided recommendation Id was not found. Please, try again with a valid GUID." -ForegroundColor Red Exit } Write-Host "You are suppressing the recommendation with the below details" -ForegroundColor Green Write-Host "Recommendation: $($controlRows.RecommendationDescription)" -ForegroundColor Blue Write-Host "Recommendation sub-type id: $($controlRows.RecommendationSubTypeId)" -ForegroundColor Blue Write-Host "Category: $($controlRows.Category)" -ForegroundColor Blue Write-Host "Instance Name: $($controlRows.InstanceName)" -ForegroundColor Blue Write-Host "Resource Group: $($controlRows.ResourceGroup)" -ForegroundColor Blue Write-Host "Subscription Id: $($controlRows.SubscriptionGuid)" -ForegroundColor Blue Write-Host "Please, choose the suppression type" -ForegroundColor Green Write-Host "[E]xclude - this recommendation type will be completely excluded from the engine and will no longer be generated for any resource" -ForegroundColor Green Write-Host "[D]ismiss - this recommendation will be dismissed for the scope to be chosen next (instance, resource group or subscription)" -ForegroundColor Green Write-Host "[S]nooze - this recommendation will be postponed for the duration (in days) and scope to be chosen next (instance, resource group or subscription)" -ForegroundColor Green Write-Host "[C]ancel - no action will be taken" -ForegroundColor Green $suppOption = Read-Host "Enter your choice (E, D, S or C)" if ("E", "e" -contains $suppOption) { $suppressionType = "Exclude" } elseif ("D", "d" -contains $suppOption) { $suppressionType = "Dismiss" } elseif ("S", "s" -contains $suppOption) { $suppressionType = "Snooze" } else { Write-Host "Cancelling.. No action will be taken." -ForegroundColor Green Exit } if ($suppressionType -in ("Dismiss", "Snooze")) { Write-Host "Please, choose the scope for the suppression" -ForegroundColor Green Write-Host "[S]ubscription ($($controlRows.SubscriptionGuid))" -ForegroundColor Green Write-Host "[R]esource Group ($($controlRows.ResourceGroup))" -ForegroundColor Green Write-Host "[I]nstance ($($controlRows.InstanceName))" -ForegroundColor Green $scopeOption = Read-Host "Enter your choice (S, R, or I)" if ("S", "s" -contains $scopeOption) { $scope = $controlRows.SubscriptionGuid } elseif ("R", "r" -contains $scopeOption) { $scope = $controlRows.ResourceGroup } elseif ("I", "i" -contains $scopeOption) { $scope = $controlRows.InstanceId } else { Write-Host "Wrong input. No action will be taken." -ForegroundColor Red Exit } } $snoozeDays = 0 if ($suppressionType -eq "Snooze") { Write-Host "Please, enter the number of days the recommendation will be snoozed" -ForegroundColor Green $snoozeDays = Read-Host "Number of days (min. 14)" if (-not($snoozeDays -ge 14)) { Write-Host "Wrong snooze days. No action will be taken." -ForegroundColor Red Exit } } $author = Read-Host "Please enter your name" $notes = Read-Host "Please enter a reason for this suppression" Write-Host "You are about to suppress this recommendation" -ForegroundColor Yellow Write-Host "Recommendation: $($controlRows.RecommendationDescription)" -ForegroundColor Blue Write-Host "Suppression type: $suppressionType" -ForegroundColor Blue if ($suppressionType -in ("Dismiss", "Snooze")) { Write-Host "Scope: $scope" -ForegroundColor Blue } if ($suppressionType -eq "Snooze") { Write-Host "Snooze days: $snoozeDays" -ForegroundColor Blue } Write-Host "Author: $author" -ForegroundColor Blue Write-Host "Reason: $notes" -ForegroundColor Blue $continueInput = Read-Host "Do you want to continue (Y/N)?" if ("Y", "y" -contains $continueInput) { if ($scope) { $scope = "'$scope'" } else { $scope = "NULL" } if ($snoozeDays -ge 14) { $now = (Get-Date).ToUniversalTime() $endDate = "'$($now.Add($snoozeDays).ToString("yyyy-MM-ddTHH:mm:00Z"))'" } else { $endDate = "NULL" } $sqlStatement = "INSERT INTO [$suppressionsTable] VALUES (NEWID(), '$($controlRows.RecommendationSubTypeId)', '$suppressionType', $scope, GETDATE(), $endDate, '$author', '$notes', 1)" $Conn2 = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$databaseServer,1433;Database=$databaseName;User ID=$databaseUser;Password='$sqlPassPlain';Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn2.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn2 $Cmd.CommandText = $sqlStatement $Cmd.CommandTimeout=120 try { $Cmd.ExecuteReader() } catch { Write-Output "Failed statement: $sqlStatement" throw } $Conn2.Close() Write-Host "Suppression sucessfully added." -ForegroundColor Green } else { Write-Host "No action was taken." -ForegroundColor Green } $dbConnectionSettings | ConvertTo-Json | Out-File -FilePath $databaseConnectionSettingsPath -Force ================================================ FILE: azuredeploy-nested.bicep ================================================ param projectLocation string param templateLocation string param storageAccountName string param automationAccountName string param sqlServerName string param sqlDatabaseName string param logAnalyticsReuse bool param logAnalyticsWorkspaceName string param logAnalyticsWorkspaceRG string param logAnalyticsRetentionDays int param sqlBackupRetentionDays int param sqlAdminLogin string @secure() param sqlAdminPassword string param cloudEnvironment string param authenticationOption string param baseTime string param resourceTags object param contributorRoleAssignmentGuid string param argDiskExportJobId string = newGuid() param argVhdExportJobId string = newGuid() param argVmExportJobId string = newGuid() param argVmssExportJobId string = newGuid() param argAvailSetExportJobId string = newGuid() param advisorExportJobId string = newGuid() param consumptionExportJobId string = newGuid() param aadObjectsExportJobId string = newGuid() param argLoadBalancersExportJobId string = newGuid() param argAppGWsExportJobId string = newGuid() param rbacExportJobId string = newGuid() param argResContainersExportJobId string = newGuid() param argNICExportJobId string = newGuid() param argNSGExportJobId string = newGuid() param argPublicIPExportJobId string = newGuid() param argVNetExportJobId string = newGuid() param argSqlDbExportJobId string = newGuid() param policyStateExportJobId string = newGuid() param monitorVmssCpuMaxExportJobId string = newGuid() param monitorVmssCpuAvgExportJobId string = newGuid() param monitorVmssMemoryMinExportJobId string = newGuid() param monitorSqlDbDtuMaxExportJobId string = newGuid() param monitorSqlDbDtuAvgExportJobId string = newGuid() param monitorAppServiceCpuMaxExportJobId string = newGuid() param monitorAppServiceCpuAvgExportJobId string = newGuid() param monitorAppServiceMemoryMaxExportJobId string = newGuid() param monitorAppServiceMemoryAvgExportJobId string = newGuid() param monitorDiskIOPSAvgExportJobId string = newGuid() param monitorDiskMBPsAvgExportJobId string = newGuid() param argAppServicePlanExportJobId string = newGuid() param pricesheetExportJobId string = newGuid() param reservationPricesExportJobId string = newGuid() param reservationUsageExportJobId string = newGuid() param savingsPlansUsageExportJobId string = newGuid() param argDiskIngestJobId string = newGuid() param argVhdIngestJobId string = newGuid() param argVmIngestJobId string = newGuid() param argVmssIngestJobId string = newGuid() param argAvailSetIngestJobId string = newGuid() param advisorIngestJobId string = newGuid() param remediationLogsIngestJobId string = newGuid() param consumptionIngestJobId string = newGuid() param aadObjectsIngestJobId string = newGuid() param argLoadBalancersIngestJobId string = newGuid() param argAppGWsIngestJobId string = newGuid() param argResContainersIngestJobId string = newGuid() param rbacIngestJobId string = newGuid() param argNICIngestJobId string = newGuid() param argNSGIngestJobId string = newGuid() param argPublicIPIngestJobId string = newGuid() param argVNetIngestJobId string = newGuid() param argSqlDbIngestJobId string = newGuid() param policyStateIngestJobId string = newGuid() param monitorIngestJobId string = newGuid() param argAppServicePlanIngestJobId string = newGuid() param pricesheetIngestJobId string = newGuid() param reservationPricesIngestJobId string = newGuid() param reservationUsageIngestJobId string = newGuid() param savingsPlansUsageIngestJobId string = newGuid() param unattachedDisksRecommendationJobId string = newGuid() param advisorCostAugmentedRecommendationJobId string = newGuid() param advisorAsIsRecommendationJobId string = newGuid() param vmsHaRecommendationJobId string = newGuid() param vmOptimizationsRecommendationJobId string = newGuid() param aadExpiringCredsRecommendationJobId string = newGuid() param unusedLoadBalancersRecommendationJobId string = newGuid() param unusedAppGWsRecommendationJobId string = newGuid() param armOptimizationsRecommendationJobId string = newGuid() param vnetOptimizationsRecommendationJobId string = newGuid() param vmssOptimizationsRecommendationJobId string = newGuid() param sqldbOptimizationsRecommendationJobId string = newGuid() param storageOptimizationsRecommendationJobId string = newGuid() param appServiceOptimizationsRecommendationJobId string = newGuid() param diskOptimizationsRecommendationJobId string = newGuid() param recommendationsIngestJobId string = newGuid() param recommendationsLogAnalyticsIngestJobId string = newGuid() param suppressionsLogAnalyticsIngestJobId string = newGuid() param recommendationsCleanUpJobId string = newGuid() param roleContributor string = '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c' var advisorExportsRunbookName = 'Export-AdvisorRecommendationsToBlobStorage' var argVmExportsRunbookName = 'Export-ARGVirtualMachinesPropertiesToBlobStorage' var argVmssExportsRunbookName = 'Export-ARGVMSSPropertiesToBlobStorage' var argDisksExportsRunbookName = 'Export-ARGManagedDisksPropertiesToBlobStorage' var argVhdExportsRunbookName = 'Export-ARGUnmanagedDisksPropertiesToBlobStorage' var argAvailSetExportsRunbookName = 'Export-ARGAvailabilitySetPropertiesToBlobStorage' var consumptionExportsRunbookName = 'Export-ConsumptionToBlobStorage' var aadObjectsExportsRunbookName = 'Export-AADObjectsToBlobStorage' var argLoadBalancersExportsRunbookName = 'Export-ARGLoadBalancerPropertiesToBlobStorage' var argAppGWsExportsRunbookName = 'Export-ARGAppGatewayPropertiesToBlobStorage' var argResContainersExportsRunbookName = 'Export-ARGResourceContainersPropertiesToBlobStorage' var rbacExportsRunbookName = 'Export-RBACAssignmentsToBlobStorage' var argNICExportsRunbookName = 'Export-ARGNICPropertiesToBlobStorage' var argNSGExportsRunbookName = 'Export-ARGNSGPropertiesToBlobStorage' var argVNetExportsRunbookName = 'Export-ARGVNetPropertiesToBlobStorage' var argPublicIpExportsRunbookName = 'Export-ARGPublicIpPropertiesToBlobStorage' var argSqlDbExportsRunbookName = 'Export-ARGSqlDatabasePropertiesToBlobStorage' var policyStateExportsRunbookName = 'Export-PolicyComplianceToBlobStorage' var monitorExportsRunbookName = 'Export-AzMonitorMetricsToBlobStorage' var argAppServicePlanExportsRunbookName = 'Export-ARGAppServicePlanPropertiesToBlobStorage' var reservationsExportsRunbookName = 'Export-ReservationsUsageToBlobStorage' var reservationsPriceExportsRunbookName = 'Export-ReservationsPriceToBlobStorage' var priceSheetExportsRunbookName = 'Export-PriceSheetToBlobStorage' var savingsPlansExportsRunbookName = 'Export-SavingsPlansUsageToBlobStorage' var advisorExportsScheduleName = 'AzureOptimization_ExportAdvisorWeekly' var argExportsScheduleName = 'AzureOptimization_ExportARGDaily' var consumptionExportsScheduleName = 'AzureOptimization_ExportConsumptionDaily' var aadObjectsExportsScheduleName = 'AzureOptimization_ExportAADObjectsDaily' var rbacExportsScheduleName = 'AzureOptimization_ExportRBACDaily' var policyStateExportsScheduleName = 'AzureOptimization_ExportPolicyStateDaily' var monitorVmssCpuMaxExportsScheduleName = 'AzureOptimization_ExportMonitorVmssCpuMaxHourly' var monitorVmssCpuAvgExportsScheduleName = 'AzureOptimization_ExportMonitorVmssCpuAvgHourly' var monitorVmssMemoryMinExportsScheduleName = 'AzureOptimization_ExportMonitorVmssMemoryMinHourly' var monitorSqlDbDtuMaxExportsScheduleName = 'AzureOptimization_ExportMonitorSqlDbDtuMaxHourly' var monitorSqlDbDtuAvgExportsScheduleName = 'AzureOptimization_ExportMonitorSqlDbDtuAvgHourly' var monitorAppServiceCpuMaxExportsScheduleName = 'AzureOptimization_ExportMonitorAppServiceCpuMaxHourly' var monitorAppServiceCpuAvgExportsScheduleName = 'AzureOptimization_ExportMonitorAppServiceCpuAvgHourly' var monitorAppServiceMemoryMaxExportsScheduleName = 'AzureOptimization_ExportMonitorAppServiceMemoryMaxHourly' var monitorAppServiceMemoryAvgExportsScheduleName = 'AzureOptimization_ExportMonitorAppServiceMemoryAvgHourly' var monitorDiskIOPSAvgExportsScheduleName = 'AzureOptimization_ExportMonitorDiskIOPSHourly' var monitorDiskMBPsAvgExportsScheduleName = 'AzureOptimization_ExportMonitorDiskMBPsHourly' var priceExportsScheduleName = 'AzureOptimization_ExportPricesWeekly' var reservationsUsageExportsScheduleName = 'AzureOptimization_ExportReservationsDaily' var savingsPlansUsageExportsScheduleName = 'AzureOptimization_ExportSavingsPlansDaily' var csvExportsSchedules = [ { exportSchedule: argExportsScheduleName exportDescription: 'Daily Azure Resource Graph exports' exportTimeOffset: 'PT1H05M' exportFrequency: 'Day' } { exportSchedule: advisorExportsScheduleName exportDescription: 'Weekly Azure Advisor exports' exportTimeOffset: 'PT1H15M' exportFrequency: 'Week' } { exportSchedule: consumptionExportsScheduleName exportDescription: 'Daily Azure Consumption exports' exportTimeOffset: 'PT1H' exportFrequency: 'Day' } { exportSchedule: aadObjectsExportsScheduleName exportDescription: 'Daily Microsoft Entra Objects exports' exportTimeOffset: 'PT1H' exportFrequency: 'Day' } { exportSchedule: rbacExportsScheduleName exportDescription: 'Daily Azure RBAC exports' exportTimeOffset: 'PT1H02M' exportFrequency: 'Day' } { exportSchedule: policyStateExportsScheduleName exportDescription: 'Daily Azure Policy State exports' exportTimeOffset: 'PT1H' exportFrequency: 'Day' } { exportSchedule: monitorVmssCpuAvgExportsScheduleName exportDescription: 'Hourly Azure Monitor metrics exports for VMSS Percentage CPU (Avg.)' exportTimeOffset: 'PT1H15M' exportFrequency: 'Hour' } { exportSchedule: monitorVmssCpuMaxExportsScheduleName exportDescription: 'Hourly Azure Monitor metrics exports for VMSS Percentage CPU (Max.)' exportTimeOffset: 'PT1H15M' exportFrequency: 'Hour' } { exportSchedule: monitorVmssMemoryMinExportsScheduleName exportDescription: 'Hourly Azure Monitor metrics exports for VMSS Available Memory (Min.)' exportTimeOffset: 'PT1H15M' exportFrequency: 'Hour' } { exportSchedule: monitorSqlDbDtuMaxExportsScheduleName exportDescription: 'Hourly Azure Monitor metrics exports for SQL Database Percentage DTU (Max.)' exportTimeOffset: 'PT1H15M' exportFrequency: 'Hour' } { exportSchedule: monitorSqlDbDtuAvgExportsScheduleName exportDescription: 'Hourly Azure Monitor metrics exports for SQL Database Percentage DTU (Avg.)' exportTimeOffset: 'PT1H16M' exportFrequency: 'Hour' } { exportSchedule: monitorAppServiceCpuAvgExportsScheduleName exportDescription: 'Hourly Azure Monitor metrics exports for App Service Percentage CPU (Avg.)' exportTimeOffset: 'PT1H16M' exportFrequency: 'Hour' } { exportSchedule: monitorAppServiceCpuMaxExportsScheduleName exportDescription: 'Hourly Azure Monitor metrics exports for App Service Percentage CPU (Max.)' exportTimeOffset: 'PT1H16M' exportFrequency: 'Hour' } { exportSchedule: monitorAppServiceMemoryAvgExportsScheduleName exportDescription: 'Hourly Azure Monitor metrics exports for App Service Percentage RAM (Avg.)' exportTimeOffset: 'PT1H16M' exportFrequency: 'Hour' } { exportSchedule: monitorAppServiceMemoryMaxExportsScheduleName exportDescription: 'Hourly Azure Monitor metrics exports for App Service Percentage RAM (Max.)' exportTimeOffset: 'PT1H17M' exportFrequency: 'Hour' } { exportSchedule: monitorDiskIOPSAvgExportsScheduleName exportDescription: 'Hourly Azure Monitor metrics exports for Disk IOPS (Avg.)' exportTimeOffset: 'PT1H17M' exportFrequency: 'Hour' } { exportSchedule: monitorDiskMBPsAvgExportsScheduleName exportDescription: 'Hourly Azure Monitor metrics exports for Disk MBPs (Avg.)' exportTimeOffset: 'PT1H17M' exportFrequency: 'Hour' } { exportSchedule: priceExportsScheduleName exportDescription: 'Weekly Pricesheet and Reservation Prices exports' exportTimeOffset: 'PT1H35M' exportFrequency: 'Week' } { exportSchedule: reservationsUsageExportsScheduleName exportDescription: 'Daily Reservation Usage exports' exportTimeOffset: 'PT2H' exportFrequency: 'Day' } { exportSchedule: savingsPlansUsageExportsScheduleName exportDescription: 'Daily Savings Plans Usage exports' exportTimeOffset: 'PT2H05M' exportFrequency: 'Day' } ] var csvExports = [ { runbookName: advisorExportsRunbookName isOneToMany: false containerName: 'advisorexports' variableName: 'AzureOptimization_AdvisorContainer' variableDescription: 'The Storage Account container where Azure Advisor exports are dumped to' ingestSchedule: 'AzureOptimization_IngestAdvisorWeekly' ingestDescription: 'Weekly Azure Advisor recommendations ingests' ingestTimeOffset: 'PT1H45M' ingestFrequency: 'Week' ingestJobId: advisorIngestJobId exportSchedule: advisorExportsScheduleName exportJobId: advisorExportJobId } { runbookName: argVmExportsRunbookName isOneToMany: false containerName: 'argvmexports' variableName: 'AzureOptimization_ARGVMContainer' variableDescription: 'The Storage Account container where Azure Resource Graph Virtual Machine exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGVMsDaily' ingestDescription: 'Daily Azure Resource Graph Virtual Machines ingests' ingestTimeOffset: 'PT1H30M' ingestFrequency: 'Day' ingestJobId: argVmIngestJobId exportSchedule: argExportsScheduleName exportJobId: argVmExportJobId } { runbookName: argVmssExportsRunbookName isOneToMany: false containerName: 'argvmssexports' variableName: 'AzureOptimization_ARGVMSSContainer' variableDescription: 'The Storage Account container where Azure Resource Graph VMSS exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGVMSSDaily' ingestDescription: 'Daily Azure Resource Graph VMSS ingests' ingestTimeOffset: 'PT1H30M' ingestFrequency: 'Day' ingestJobId: argVmssIngestJobId exportSchedule: argExportsScheduleName exportJobId: argVmssExportJobId } { runbookName: argDisksExportsRunbookName isOneToMany: false containerName: 'argdiskexports' variableName: 'AzureOptimization_ARGDiskContainer' variableDescription: 'The Storage Account container where Azure Resource Graph Managed Disks exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGDisksDaily' ingestDescription: 'Daily Azure Resource Graph Managed Disks ingests' ingestTimeOffset: 'PT1H30M' ingestFrequency: 'Day' ingestJobId: argDiskIngestJobId exportSchedule: argExportsScheduleName exportJobId: argDiskExportJobId } { runbookName: argVhdExportsRunbookName isOneToMany: false containerName: 'argvhdexports' variableName: 'AzureOptimization_ARGVhdContainer' variableDescription: 'The Storage Account container where Azure Resource Graph Unmanaged Disks exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGVHDsDaily' ingestDescription: 'Daily Azure Resource Graph Unmanaged Disks ingests' ingestTimeOffset: 'PT1H30M' ingestFrequency: 'Day' ingestJobId: argVhdIngestJobId exportSchedule: argExportsScheduleName exportJobId: argVhdExportJobId } { runbookName: argAvailSetExportsRunbookName isOneToMany: false containerName: 'argavailsetexports' variableName: 'AzureOptimization_ARGAvailabilitySetContainer' variableDescription: 'The Storage Account container where Azure Resource Graph Availability Set exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGAvailSetsDaily' ingestDescription: 'Daily Azure Resource Graph Availability Sets ingests' ingestTimeOffset: 'PT1H31M' ingestFrequency: 'Day' ingestJobId: argAvailSetIngestJobId exportSchedule: argExportsScheduleName exportJobId: argAvailSetExportJobId } { runbookName: consumptionExportsRunbookName isOneToMany: false containerName: 'consumptionexports' variableName: 'AzureOptimization_ConsumptionContainer' variableDescription: 'The Storage Account container where Azure Consumption exports are dumped to' ingestSchedule: 'AzureOptimization_IngestConsumptionDaily' ingestDescription: 'Daily Azure Consumption ingests' ingestTimeOffset: 'PT2H' ingestFrequency: 'Day' ingestJobId: consumptionIngestJobId exportSchedule: consumptionExportsScheduleName exportJobId: consumptionExportJobId } { runbookName: aadObjectsExportsRunbookName isOneToMany: false containerName: 'aadobjectsexports' variableName: 'AzureOptimization_AADObjectsContainer' variableDescription: 'The Storage Account container where Microsoft Entra Objects exports are dumped to' ingestSchedule: 'AzureOptimization_IngestAADObjectsDaily' ingestDescription: 'Daily Microsoft Entra Objects ingests' ingestTimeOffset: 'PT2H' ingestFrequency: 'Day' ingestJobId: aadObjectsIngestJobId exportSchedule: aadObjectsExportsScheduleName exportJobId: aadObjectsExportJobId } { runbookName: argLoadBalancersExportsRunbookName isOneToMany: false containerName: 'arglbexports' variableName: 'AzureOptimization_ARGLoadBalancerContainer' variableDescription: 'The Storage Account container where Azure Resource Graph Load Balancer exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGLoadBalancersDaily' ingestDescription: 'Daily Azure Resource Graph Load Balancers ingests' ingestTimeOffset: 'PT1H31M' ingestFrequency: 'Day' ingestJobId: argLoadBalancersIngestJobId exportSchedule: argExportsScheduleName exportJobId: argLoadBalancersExportJobId } { runbookName: argAppGWsExportsRunbookName isOneToMany: false containerName: 'argappgwexports' variableName: 'AzureOptimization_ARGAppGatewayContainer' variableDescription: 'The Storage Account container where Azure Resource Graph Application Gateway exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGAppGWsDaily' ingestDescription: 'Daily Azure Resource Graph Application Gateways ingests' ingestTimeOffset: 'PT1H31M' ingestFrequency: 'Day' ingestJobId: argAppGWsIngestJobId exportSchedule: argExportsScheduleName exportJobId: argAppGWsExportJobId } { runbookName: argResContainersExportsRunbookName isOneToMany: false containerName: 'argrescontainersexports' variableName: 'AzureOptimization_ARGResourceContainersContainer' variableDescription: 'The Storage Account container where Azure Resource Graph Resource Containers exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGResourceContainersDaily' ingestDescription: 'Daily Azure Resource Graph Resource Containers ingests' ingestTimeOffset: 'PT1H32M' ingestFrequency: 'Day' ingestJobId: argResContainersIngestJobId exportSchedule: argExportsScheduleName exportJobId: argResContainersExportJobId } { runbookName: rbacExportsRunbookName isOneToMany: false containerName: 'rbacexports' variableName: 'AzureOptimization_RBACAssignmentsContainer' variableDescription: 'The Storage Account container where RBAC Assignments exports are dumped to' ingestSchedule: 'AzureOptimization_IngestRBACDaily' ingestDescription: 'Daily Azure RBAC ingests' ingestTimeOffset: 'PT1H32M' ingestFrequency: 'Day' ingestJobId: rbacIngestJobId exportSchedule: rbacExportsScheduleName exportJobId: rbacExportJobId } { runbookName: argNICExportsRunbookName isOneToMany: false containerName: 'argnicexports' variableName: 'AzureOptimization_ARGNICContainer' variableDescription: 'The Storage Account container where Azure Resource Graph NIC exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGNICsDaily' ingestDescription: 'Daily Azure Resource Graph NIC ingests' ingestTimeOffset: 'PT1H32M' ingestFrequency: 'Day' ingestJobId: argNICIngestJobId exportSchedule: argExportsScheduleName exportJobId: argNICExportJobId } { runbookName: argNSGExportsRunbookName isOneToMany: false containerName: 'argnsgexports' variableName: 'AzureOptimization_ARGNSGContainer' variableDescription: 'The Storage Account container where Azure Resource Graph NSG exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGNSGsDaily' ingestDescription: 'Daily Azure Resource Graph NSG ingests' ingestTimeOffset: 'PT1H32M' ingestFrequency: 'Day' ingestJobId: argNSGIngestJobId exportSchedule: argExportsScheduleName exportJobId: argNSGExportJobId } { runbookName: argVNetExportsRunbookName isOneToMany: false containerName: 'argvnetexports' variableName: 'AzureOptimization_ARGVNetContainer' variableDescription: 'The Storage Account container where Azure Resource Graph VNet exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGVNetsDaily' ingestDescription: 'Daily Azure Resource Graph Virtual Network ingests' ingestTimeOffset: 'PT1H33M' ingestFrequency: 'Day' ingestJobId: argVNetIngestJobId exportSchedule: argExportsScheduleName exportJobId: argVNetExportJobId } { runbookName: argPublicIpExportsRunbookName isOneToMany: false containerName: 'argpublicipexports' variableName: 'AzureOptimization_ARGPublicIpContainer' variableDescription: 'The Storage Account container where Azure Resource Graph Public IP exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGPublicIPsDaily' ingestDescription: 'Daily Azure Resource Graph Public IP ingests' ingestTimeOffset: 'PT1H33M' ingestFrequency: 'Day' ingestJobId: argPublicIPIngestJobId exportSchedule: argExportsScheduleName exportJobId: argPublicIPExportJobId } { runbookName: argSqlDbExportsRunbookName isOneToMany: false containerName: 'argsqldbexports' variableName: 'AzureOptimization_ARGSqlDatabaseContainer' variableDescription: 'The Storage Account container where Azure Resource Graph SQL DB exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGSqlDbDaily' ingestDescription: 'Daily Azure Resource Graph SQL DB ingests' ingestTimeOffset: 'PT1H33M' ingestFrequency: 'Day' ingestJobId: argSqlDbIngestJobId exportSchedule: argExportsScheduleName exportJobId: argSqlDbExportJobId } { runbookName: policyStateExportsRunbookName isOneToMany: false containerName: 'policystateexports' variableName: 'AzureOptimization_PolicyStatesContainer' variableDescription: 'The Storage Account container where Azure Policy State exports are dumped to' ingestSchedule: 'AzureOptimization_IngestPolicyStateDaily' ingestDescription: 'Daily Azure Policy State ingests' ingestTimeOffset: 'PT1H33M' ingestFrequency: 'Day' ingestJobId: policyStateIngestJobId exportSchedule: policyStateExportsScheduleName exportJobId: policyStateExportJobId } { runbookName: monitorExportsRunbookName isOneToMany: true containerName: 'azmonitorexports' variableName: 'AzureOptimization_AzMonitorContainer' variableDescription: 'The Storage Account container where Azure Monitor metrics exports are dumped to' ingestSchedule: 'AzureOptimization_IngestAzMonitorMetricsHourly' ingestDescription: 'Hourly Azure Monitor metrics ingests' ingestTimeOffset: 'PT2H' ingestFrequency: 'Hour' ingestJobId: monitorIngestJobId exportSchedule: null exportJobId: 'dummy' } { runbookName: argAppServicePlanExportsRunbookName isOneToMany: false containerName: 'argappserviceplanexports' variableName: 'AzureOptimization_ARGAppServicePlanContainer' variableDescription: 'The Storage Account container where Azure Resource Graph App Service Plan exports are dumped to' ingestSchedule: 'AzureOptimization_IngestARGAppServicePlanDaily' ingestDescription: 'Daily Azure Resource Graph App Service Plan ingests' ingestTimeOffset: 'PT1H34M' ingestFrequency: 'Day' ingestJobId: argAppServicePlanIngestJobId exportSchedule: argExportsScheduleName exportJobId: argAppServicePlanExportJobId } { runbookName: priceSheetExportsRunbookName isOneToMany: false containerName: 'pricesheetexports' variableName: 'AzureOptimization_PriceSheetContainer' variableDescription: 'The Storage Account container where Pricesheet exports are dumped to' ingestSchedule: 'AzureOptimization_IngestPricesheetWeekly' ingestDescription: 'Weekly Pricesheet ingests' ingestTimeOffset: 'PT2H' ingestFrequency: 'Week' ingestJobId: pricesheetIngestJobId exportSchedule: priceExportsScheduleName exportJobId: pricesheetExportJobId } { runbookName: reservationsPriceExportsRunbookName isOneToMany: false containerName: 'reservationspriceexports' variableName: 'AzureOptimization_ReservationsPriceContainer' variableDescription: 'The Storage Account container where Reservations Prices exports are dumped to' ingestSchedule: 'AzureOptimization_IngestReservationsPriceWeekly' ingestDescription: 'Weekly Reservations Prices ingests' ingestTimeOffset: 'PT2H' ingestFrequency: 'Week' ingestJobId: reservationPricesIngestJobId exportSchedule: priceExportsScheduleName exportJobId: reservationPricesExportJobId } { runbookName: reservationsExportsRunbookName isOneToMany: false containerName: 'reservationsexports' variableName: 'AzureOptimization_ReservationsContainer' variableDescription: 'The Storage Account container where Reservations Usage exports are dumped to' ingestSchedule: 'AzureOptimization_IngestReservationsUsageDaily' ingestDescription: 'Daily Reservations Usage ingests' ingestTimeOffset: 'PT2H30M' ingestFrequency: 'Day' ingestJobId: reservationUsageIngestJobId exportSchedule: reservationsUsageExportsScheduleName exportJobId: reservationUsageExportJobId } { runbookName: savingsPlansExportsRunbookName isOneToMany: false containerName: 'savingsplansexports' variableName: 'AzureOptimization_SavingsPlansContainer' variableDescription: 'The Storage Account container where Savings Plans Usage exports are dumped to' ingestSchedule: 'AzureOptimization_IngestSavingsPlansUsageDaily' ingestDescription: 'Daily Savings Plans Usage ingests' ingestTimeOffset: 'PT2H35M' ingestFrequency: 'Day' ingestJobId: savingsPlansUsageIngestJobId exportSchedule: savingsPlansUsageExportsScheduleName exportJobId: savingsPlansUsageExportJobId } ] var csvParameterizedExports = [ { runbookName: monitorExportsRunbookName exportSchedule: monitorVmssCpuMaxExportsScheduleName exportJobId: monitorVmssCpuMaxExportJobId parameters: { ResourceType: 'microsoft.compute/virtualmachinescalesets' TimeSpan: '01:00:00' aggregationType: 'Maximum' MetricNames: 'Percentage CPU' TimeGrain: '01:00:00' } } { runbookName: monitorExportsRunbookName exportSchedule: monitorVmssCpuAvgExportsScheduleName exportJobId: monitorVmssCpuAvgExportJobId parameters: { ResourceType: 'microsoft.compute/virtualmachinescalesets' TimeSpan: '01:00:00' aggregationType: 'Average' MetricNames: 'Percentage CPU' TimeGrain: '01:00:00' } } { runbookName: monitorExportsRunbookName exportSchedule: monitorVmssMemoryMinExportsScheduleName exportJobId: monitorVmssMemoryMinExportJobId parameters: { ResourceType: 'microsoft.compute/virtualmachinescalesets' TimeSpan: '01:00:00' aggregationType: 'Minimum' MetricNames: 'Available Memory Bytes' TimeGrain: '01:00:00' } } { runbookName: monitorExportsRunbookName exportSchedule: monitorSqlDbDtuMaxExportsScheduleName exportJobId: monitorSqlDbDtuMaxExportJobId parameters: { ResourceType: 'microsoft.sql/servers/databases' ARGFilter: 'sku.tier in (\'Standard\',\'Premium\')' TimeSpan: '01:00:00' aggregationType: 'Maximum' MetricNames: 'dtu_consumption_percent' TimeGrain: '01:00:00' } } { runbookName: monitorExportsRunbookName exportSchedule: monitorSqlDbDtuAvgExportsScheduleName exportJobId: monitorSqlDbDtuAvgExportJobId parameters: { ResourceType: 'microsoft.sql/servers/databases' ARGFilter: 'sku.tier in (\'Standard\',\'Premium\')' TimeSpan: '01:00:00' aggregationType: 'Average' AggregationOfType: 'Maximum' MetricNames: 'dtu_consumption_percent' TimeGrain: '00:01:00' } } { runbookName: monitorExportsRunbookName exportSchedule: monitorAppServiceCpuMaxExportsScheduleName exportJobId: monitorAppServiceCpuMaxExportJobId parameters: { ResourceType: 'microsoft.web/serverfarms' ARGFilter: 'properties.computeMode == \'Dedicated\' and sku.tier != \'Free\'' TimeSpan: '01:00:00' aggregationType: 'Maximum' MetricNames: 'CpuPercentage' TimeGrain: '01:00:00' } } { runbookName: monitorExportsRunbookName exportSchedule: monitorAppServiceCpuAvgExportsScheduleName exportJobId: monitorAppServiceCpuAvgExportJobId parameters: { ResourceType: 'microsoft.web/serverfarms' ARGFilter: 'properties.computeMode == \'Dedicated\' and sku.tier != \'Free\'' TimeSpan: '01:00:00' aggregationType: 'Average' AggregationOfType: 'Maximum' MetricNames: 'CpuPercentage' TimeGrain: '00:01:00' } } { runbookName: monitorExportsRunbookName exportSchedule: monitorAppServiceMemoryMaxExportsScheduleName exportJobId: monitorAppServiceMemoryMaxExportJobId parameters: { ResourceType: 'microsoft.web/serverfarms' ARGFilter: 'properties.computeMode == \'Dedicated\' and sku.tier != \'Free\'' TimeSpan: '01:00:00' aggregationType: 'Maximum' MetricNames: 'MemoryPercentage' TimeGrain: '01:00:00' } } { runbookName: monitorExportsRunbookName exportSchedule: monitorAppServiceMemoryAvgExportsScheduleName exportJobId: monitorAppServiceMemoryAvgExportJobId parameters: { ResourceType: 'microsoft.web/serverfarms' ARGFilter: 'properties.computeMode == \'Dedicated\' and sku.tier != \'Free\'' TimeSpan: '01:00:00' aggregationType: 'Average' AggregationOfType: 'Maximum' MetricNames: 'MemoryPercentage' TimeGrain: '00:01:00' } } { runbookName: monitorExportsRunbookName exportSchedule: monitorDiskIOPSAvgExportsScheduleName exportJobId: monitorDiskIOPSAvgExportJobId parameters: { ResourceType: 'microsoft.compute/disks' ARGFilter: 'sku.name =~ \'Premium_LRS\' and properties.diskState != \'Unattached\'' TimeSpan: '01:00:00' aggregationType: 'Average' AggregationOfType: 'Maximum' MetricNames: 'Composite Disk Read Operations/sec,Composite Disk Write Operations/sec' TimeGrain: '00:01:00' } } { runbookName: monitorExportsRunbookName exportSchedule: monitorDiskMBPsAvgExportsScheduleName exportJobId: monitorDiskMBPsAvgExportJobId parameters: { ResourceType: 'microsoft.compute/disks' ARGFilter: 'sku.name =~ \'Premium_LRS\' and properties.diskState != \'Unattached\'' TimeSpan: '01:00:00' aggregationType: 'Average' AggregationOfType: 'Maximum' MetricNames: 'Composite Disk Read Bytes/sec,Composite Disk Write Bytes/sec' TimeGrain: '00:01:00' } } ] var unattachedDisksRecommendationsRunbookName = 'Recommend-UnattachedDisksToBlobStorage' var advisorCostAugmentedRecommendationsRunbookName = 'Recommend-AdvisorCostAugmentedToBlobStorage' var advisorAsIsRecommendationsRunbookName = 'Recommend-AdvisorAsIsToBlobStorage' var vmsHARecommendationsRunbookName = 'Recommend-VMsHighAvailabilityToBlobStorage' var vmOptimizationsRecommendationsRunbookName = 'Recommend-VMOptimizationsToBlobStorage' var aadExpiringCredsRecommendationsRunbookName = 'Recommend-AADExpiringCredentialsToBlobStorage' var unusedLBsRecommendationsRunbookName = 'Recommend-UnusedLoadBalancersToBlobStorage' var unusedAppGWsRecommendationsRunbookName = 'Recommend-UnusedAppGWsToBlobStorage' var armOptimizationsRecommendationsRunbookName = 'Recommend-ARMOptimizationsToBlobStorage' var vnetOptimizationsRecommendationsRunbookName = 'Recommend-VNetOptimizationsToBlobStorage' var vmssOptimizationsRecommendationsRunbookName = 'Recommend-VMSSOptimizationsToBlobStorage' var sqldbOptimizationsRecommendationsRunbookName = 'Recommend-SqlDbOptimizationsToBlobStorage' var storageOptimizationsRecommendationsRunbookName = 'Recommend-StorageAccountOptimizationsToBlobStorage' var appServiceOptimizationsRecommendationsRunbookName = 'Recommend-AppServiceOptimizationsToBlobStorage' var diskOptimizationsRecommendationsRunbookName = 'Recommend-DiskOptimizationsToBlobStorage' var cleanUpOlderRecommendationsRunbookName = 'CleanUp-OlderRecommendationsFromSqlServer' var recommendations = [ { recommendationJobId: unattachedDisksRecommendationJobId runbookName: unattachedDisksRecommendationsRunbookName } { recommendationJobId: advisorCostAugmentedRecommendationJobId runbookName: advisorCostAugmentedRecommendationsRunbookName } { recommendationJobId: advisorAsIsRecommendationJobId runbookName: advisorAsIsRecommendationsRunbookName } { recommendationJobId: vmsHaRecommendationJobId runbookName: vmsHARecommendationsRunbookName } { recommendationJobId: vmOptimizationsRecommendationJobId runbookName: vmOptimizationsRecommendationsRunbookName } { recommendationJobId: aadExpiringCredsRecommendationJobId runbookName: aadExpiringCredsRecommendationsRunbookName } { recommendationJobId: unusedLoadBalancersRecommendationJobId runbookName: unusedLBsRecommendationsRunbookName } { recommendationJobId: unusedAppGWsRecommendationJobId runbookName: unusedAppGWsRecommendationsRunbookName } { recommendationJobId: armOptimizationsRecommendationJobId runbookName: armOptimizationsRecommendationsRunbookName } { recommendationJobId: vnetOptimizationsRecommendationJobId runbookName: vnetOptimizationsRecommendationsRunbookName } { recommendationJobId: vmssOptimizationsRecommendationJobId runbookName: vmssOptimizationsRecommendationsRunbookName } { recommendationJobId: sqldbOptimizationsRecommendationJobId runbookName: sqldbOptimizationsRecommendationsRunbookName } { recommendationJobId: storageOptimizationsRecommendationJobId runbookName: storageOptimizationsRecommendationsRunbookName } { recommendationJobId: appServiceOptimizationsRecommendationJobId runbookName: appServiceOptimizationsRecommendationsRunbookName } { recommendationJobId: diskOptimizationsRecommendationJobId runbookName: diskOptimizationsRecommendationsRunbookName } ] var remediationLogsContainerName = 'remediationlogs' var recommendationsContainerName = 'recommendationsexports' var csvIngestRunbookName = 'Ingest-OptimizationCSVExportsToLogAnalytics' var recommendationsIngestRunbookName = 'Ingest-RecommendationsToSQLServer' var recommendationsLogAnalyticsIngestRunbookName = 'Ingest-RecommendationsToLogAnalytics' var suppressionsLogAnalyticsIngestRunbookName = 'Ingest-SuppressionsToLogAnalytics' var advisorRightSizeFilteredRemediationRunbookName = 'Remediate-AdvisorRightSizeFiltered' var longDeallocatedVMsFilteredRemediationRunbookName = 'Remediate-LongDeallocatedVMsFiltered' var unattachedDisksFilteredRemediationRunbookName = 'Remediate-UnattachedDisksFiltered' var remediationLogsIngestScheduleName = 'AzureOptimization_IngestRemediationLogsDaily' var recommendationsScheduleName = 'AzureOptimization_RecommendationsWeekly' var recommendationsIngestScheduleName = 'AzureOptimization_IngestRecommendationsWeekly' var suppressionsIngestScheduleName = 'AzureOptimization_IngestSuppressionsWeekly' var recommendationsCleanUpScheduleName = 'AzureOptimization_CleanUpRecommendationsWeekly' var Az_Accounts = { name: 'Az.Accounts' url: 'https://www.powershellgallery.com/api/v2/package/Az.Accounts/2.12.1' } var Microsoft_Graph_Authentication = { name: 'Microsoft.Graph.Authentication' url: 'https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Authentication/2.4.0' } var psModules = [ { name: 'Az.Compute' url: 'https://www.powershellgallery.com/api/v2/package/Az.Compute/5.7.0' } { name: 'Az.OperationalInsights' url: 'https://www.powershellgallery.com/api/v2/package/Az.OperationalInsights/3.2.0' } { name: 'Az.ResourceGraph' url: 'https://www.powershellgallery.com/api/v2/package/Az.ResourceGraph/0.13.0' } { name: 'Az.Storage' url: 'https://www.powershellgallery.com/api/v2/package/Az.Storage/5.5.0' } { name: 'Az.Resources' url: 'https://www.powershellgallery.com/api/v2/package/Az.Resources/6.6.0' } { name: 'Az.Monitor' url: 'https://www.powershellgallery.com/api/v2/package/Az.Monitor/4.4.1' } { name: 'Az.PolicyInsights' url: 'https://www.powershellgallery.com/api/v2/package/Az.PolicyInsights/1.6.0' } { name: 'Microsoft.Graph.Users' url: 'https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Users/2.4.0' } { name: 'Microsoft.Graph.Groups' url: 'https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Groups/2.4.0' } { name: 'Microsoft.Graph.Applications' url: 'https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Applications/2.4.0' } { name: 'Microsoft.Graph.Identity.DirectoryManagement' url: 'https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Identity.DirectoryManagement/2.4.0' } ] var runbooks = [ { name: advisorExportsRunbookName version: '1.4.2.1' description: 'Exports Azure Advisor recommendations to Blob Storage using the Advisor API' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${advisorExportsRunbookName}.ps1') } { name: argDisksExportsRunbookName version: '1.3.4.1' description: 'Exports Managed Disks properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argDisksExportsRunbookName}.ps1') } { name: argVhdExportsRunbookName version: '1.1.4.1' description: 'Exports Unmanaged Disks (owned by a VM) properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argVhdExportsRunbookName}.ps1') } { name: argVmExportsRunbookName version: '1.4.4.1' description: 'Exports Virtual Machine properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argVmExportsRunbookName}.ps1') } { name: argVmssExportsRunbookName version: '1.0.2.1' description: 'Exports VMSS properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argVmssExportsRunbookName}.ps1') } { name: argAvailSetExportsRunbookName version: '1.1.4.1' description: 'Exports Availability Set properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argAvailSetExportsRunbookName}.ps1') } { name: consumptionExportsRunbookName version: '2.0.4.1' description: 'Exports Azure Consumption events to Blob Storage using Azure Consumption API' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${consumptionExportsRunbookName}.ps1') } { name: aadObjectsExportsRunbookName version: '1.2.2.1' description: 'Exports Azure AAD Objects to Blob Storage using Azure ARM API' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${aadObjectsExportsRunbookName}.ps1') } { name: argLoadBalancersExportsRunbookName version: '1.1.4.1' description: 'Exports Load Balancer properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argLoadBalancersExportsRunbookName}.ps1') } { name: argAppGWsExportsRunbookName version: '1.1.4.1' description: 'Exports Application Gateway properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argAppGWsExportsRunbookName}.ps1') } { name: argResContainersExportsRunbookName version: '1.0.5.1' description: 'Exports Resource Containers properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argResContainersExportsRunbookName}.ps1') } { name: rbacExportsRunbookName version: '1.0.4.1' description: 'Exports RBAC assignments to Blob Storage using ARM and Microsoft Entra' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${rbacExportsRunbookName}.ps1') } { name: argNICExportsRunbookName version: '1.0.2.1' description: 'Exports NIC properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argNICExportsRunbookName}.ps1') } { name: argNSGExportsRunbookName version: '1.0.2.1' description: 'Exports NSG properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argNSGExportsRunbookName}.ps1') } { name: argPublicIpExportsRunbookName version: '1.0.2.1' description: 'Exports Public IP properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argPublicIpExportsRunbookName}.ps1') } { name: argVNetExportsRunbookName version: '1.0.2.1' description: 'Exports VNet properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argVNetExportsRunbookName}.ps1') } { name: argSqlDbExportsRunbookName version: '1.0.2.1' description: 'Exports SQL DB properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argSqlDbExportsRunbookName}.ps1') } { name: policyStateExportsRunbookName version: '1.0.3.1' description: 'Exports Azure Policy State to Blob Storage' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${policyStateExportsRunbookName}.ps1') } { name: monitorExportsRunbookName version: '1.0.2.1' description: 'Exports Azure Monitor metrics to Blob Storage' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${monitorExportsRunbookName}.ps1') } { name: argAppServicePlanExportsRunbookName version: '1.0.1.1' description: 'Exports App Service Plan properties to Blob Storage using Azure Resource Graph' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${argAppServicePlanExportsRunbookName}.ps1') } { name: reservationsExportsRunbookName version: '1.1.2.1' description: 'Exports Reservations Usage to Blob Storage using the EA or MCA APIs' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${reservationsExportsRunbookName}.ps1') } { name: reservationsPriceExportsRunbookName version: '1.0.1.1' description: 'Exports Reservations Prices to Blob Storage using the Retail Prices API' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${reservationsPriceExportsRunbookName}.ps1') } { name: priceSheetExportsRunbookName version: '1.1.1.1' description: 'Exports Price Sheet to Blob Storage using the EA or MCA APIs' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${priceSheetExportsRunbookName}.ps1') } { name: savingsPlansExportsRunbookName version: '1.0.0.0' description: 'Exports Savings Plans Usage to Blob Storage using the EA or MCA APIs' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${savingsPlansExportsRunbookName}.ps1') } { name: csvIngestRunbookName version: '1.5.0.0' description: 'Ingests CSV blobs as custom logs to Log Analytics' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/data-collection/${csvIngestRunbookName}.ps1') } { name: unattachedDisksRecommendationsRunbookName version: '2.4.8.0' description: 'Generates unattached disks recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${unattachedDisksRecommendationsRunbookName}.ps1') } { name: advisorCostAugmentedRecommendationsRunbookName version: '2.9.1.0' description: 'Generates augmented Advisor Cost recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${advisorCostAugmentedRecommendationsRunbookName}.ps1') } { name: advisorAsIsRecommendationsRunbookName version: '1.5.5.0' description: 'Generates all types of Advisor recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${advisorAsIsRecommendationsRunbookName}.ps1') } { name: vmsHARecommendationsRunbookName version: '1.0.3.0' description: 'Generates VMs High Availability recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${vmsHARecommendationsRunbookName}.ps1') } { name: vmOptimizationsRecommendationsRunbookName version: '1.0.0.0' description: 'Generates VM optimizations recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${vmOptimizationsRecommendationsRunbookName}.ps1') } { name: aadExpiringCredsRecommendationsRunbookName version: '1.1.10.0' description: 'Generates AAD Objects with expiring credentials recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${aadExpiringCredsRecommendationsRunbookName}.ps1') } { name: unusedLBsRecommendationsRunbookName version: '1.2.9.0' description: 'Generates unused Load Balancers recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${unusedLBsRecommendationsRunbookName}.ps1') } { name: unusedAppGWsRecommendationsRunbookName version: '1.2.9.0' description: 'Generates unused Application Gateways recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${unusedAppGWsRecommendationsRunbookName}.ps1') } { name: armOptimizationsRecommendationsRunbookName version: '1.0.3.0' description: 'Generates ARM optimizations recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${armOptimizationsRecommendationsRunbookName}.ps1') } { name: vnetOptimizationsRecommendationsRunbookName version: '1.0.4.0' description: 'Generates Virtual Network optimizations recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${vnetOptimizationsRecommendationsRunbookName}.ps1') } { name: vmssOptimizationsRecommendationsRunbookName version: '1.1.1.0' description: 'Generates VM Scale Set optimizations recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${vmssOptimizationsRecommendationsRunbookName}.ps1') } { name: sqldbOptimizationsRecommendationsRunbookName version: '1.1.2.0' description: 'Generates SQL DB optimizations recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${sqldbOptimizationsRecommendationsRunbookName}.ps1') } { name: storageOptimizationsRecommendationsRunbookName version: '1.0.3.0' description: 'Generates Storage Account optimizations recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${storageOptimizationsRecommendationsRunbookName}.ps1') } { name: appServiceOptimizationsRecommendationsRunbookName version: '1.0.3.0' description: 'Generates App Service optimizations recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${appServiceOptimizationsRecommendationsRunbookName}.ps1') } { name: diskOptimizationsRecommendationsRunbookName version: '1.1.1.0' description: 'Generates Disk optimizations recommendations' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${diskOptimizationsRecommendationsRunbookName}.ps1') } { name: recommendationsIngestRunbookName version: '1.6.5.0' description: 'Ingests JSON-based recommendations into an Azure SQL Database' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${recommendationsIngestRunbookName}.ps1') } { name: recommendationsLogAnalyticsIngestRunbookName version: '1.0.2.0' description: 'Ingests JSON-based recommendations into Log Analytics' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${recommendationsLogAnalyticsIngestRunbookName}.ps1') } { name: suppressionsLogAnalyticsIngestRunbookName version: '1.0.0.0' description: 'Ingests suppressions into Log Analytics' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/recommendations/${suppressionsLogAnalyticsIngestRunbookName}.ps1') } { name: advisorRightSizeFilteredRemediationRunbookName version: '1.2.4.0' description: 'Remediates Azure Advisor right-size recommendations given fit and tag filters' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/remediations/${advisorRightSizeFilteredRemediationRunbookName}.ps1') } { name: longDeallocatedVMsFilteredRemediationRunbookName version: '1.0.3.0' description: 'Remediates long-deallocated VMs recommendations given fit and tag filters' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/remediations/${longDeallocatedVMsFilteredRemediationRunbookName}.ps1') } { name: unattachedDisksFilteredRemediationRunbookName version: '1.0.3.0' description: 'Remediates unattached disks recommendations given fit and tag filters' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/remediations/${unattachedDisksFilteredRemediationRunbookName}.ps1') } { name: cleanUpOlderRecommendationsRunbookName version: '1.0.0.0' description: 'Cleans up older recommendations from SQL Database' type: 'PowerShell' scriptUri: uri(templateLocation, 'runbooks/maintenance/${cleanUpOlderRecommendationsRunbookName}.ps1') } ] var automationVariables = [ { name: 'AzureOptimization_CloudEnvironment' description: 'Azure Cloud environment (e.g., AzureCloud, AzureChinaCloud, etc.)' value: '"${cloudEnvironment}"' } { name: 'AzureOptimization_AuthenticationOption' description: 'Runbook authentication type (RunAsAccount or ManagedIdentity)' value: '"${authenticationOption}"' } { name: 'AzureOptimization_StorageSink' description: 'The Azure Storage Account where data source exports are dumped to' value: '"${storageAccountName}"' } { name: 'AzureOptimization_StorageSinkRG' description: 'The resource group for the Azure Storage Account sink' value: '"${resourceGroup().name}"' } { name: 'AzureOptimization_StorageSinkSubId' description: 'The subscription Id for the Azure Storage Account sink' value: '"${subscription().subscriptionId}"' } { name: 'AzureOptimization_ConsumptionOffsetDays' description: 'The offset (in days) for querying for consumption data' value: 3 } { name: 'AzureOptimization_AdvisorFilter' description: 'The category filter to use for Azure Advisor (non-Cost) recommendations exports' value: '"HighAvailability,Security,Performance,OperationalExcellence"' } { name: 'AzureOptimization_ReferenceRegion' description: 'The Azure region used as a reference for getting details about Azure VM sizes available' value: '"${projectLocation}"' } { name: 'AzureOptimization_SQLServerDatabase' description: 'The Azure SQL Database name for the ingestion control and recommendations tables' value: '"${sqlDatabaseName}"' } { name: 'AzureOptimization_LogAnalyticsChunkSize' description: 'The size (in rows) for each chunk of Log Analytics ingestion request' value: 6000 } { name: 'AzureOptimization_StorageBlobsPageSize' description: 'The size (in blobs count) for each page of Storage Account container blob listing' value: 1000 } { name: 'AzureOptimization_SQLServerInsertSize' description: 'The size (in inserted lines) for each page of recommendations ingestion into the SQL Database' value: 900 } { name: 'AzureOptimization_LogAnalyticsLogPrefix' description: 'The prefix for all Azure Optimization custom log tables in Log Analytics' value: '"AzureOptimization"' } { name: 'AzureOptimization_LogAnalyticsWorkspaceName' description: 'The Log Analytics Workspace Name where optimization data will be ingested' value: '"${logAnalyticsWorkspaceName}"' } { name: 'AzureOptimization_LogAnalyticsWorkspaceRG' description: 'The resource group for the Log Analytics Workspace where optimization data will be ingested' value: '"${((!logAnalyticsReuse) ? resourceGroup().name : logAnalyticsWorkspaceRG)}"' } { name: 'AzureOptimization_LogAnalyticsWorkspaceSubId' description: 'The Azure subscription for the Log Analytics Workspace where optimization data will be ingested' value: '"${subscription().subscriptionId}"' } { name: 'AzureOptimization_LogAnalyticsWorkspaceTenantId' description: 'The Microsoft Entra tenant for the Log Analytics Workspace where optimization data will be ingested' value: '"${subscription().tenantId}"' } { name: 'AzureOptimization_PriceSheetMeterCategories' description: 'Comma-separated meter categories to be included in the Price Sheet (remove variable to include all categories)' value: '"Virtual Machines,Storage"' } { name: 'AzureOptimization_RetailPricesCurrencyCode' description: 'The currency code to be used for the retail prices exports (used for Reservations prices)' value: '"EUR"' } { name: 'AzureOptimization_RecommendAdvisorPeriodInDays' description: 'The period (in days) to look back for Advisor exported recommendations' value: 7 } { name: 'AzureOptimization_RecommendationLongDeallocatedVmsIntervalDays' description: 'The period (in days) for considering a VM long deallocated' value: 30 } { name: 'AzureOptimization_PerfPercentileCpu' description: 'The percentile to be used for processor metrics' value: 99 } { name: 'AzureOptimization_PerfPercentileMemory' description: 'The percentile to be used for memory metrics' value: 99 } { name: 'AzureOptimization_PerfPercentileNetwork' description: 'The percentile to be used for network metrics' value: 99 } { name: 'AzureOptimization_PerfPercentileDisk' description: 'The percentile to be used for disk metrics' value: 99 } { name: 'AzureOptimization_PerfPercentileSqlDtu' description: 'The percentile to be used for SQL DB DTU metrics' value: 99 } { name: 'AzureOptimization_PerfThresholdCpuPercentage' description: 'The processor usage percentage threshold above which the fit score is decreased or below which the instance is considered underutilized' value: 30 } { name: 'AzureOptimization_PerfThresholdMemoryPercentage' description: 'The memory usage percentage threshold above which the fit score is decreased or below which the instance is considered underutilized' value: 50 } { name: 'AzureOptimization_PerfThresholdCpuDegradedMaxPercentage' description: 'The maximum processor usage percentage threshold above which the instance is considered degraded' value: 95 } { name: 'AzureOptimization_PerfThresholdCpuDegradedAvgPercentage' description: 'The average processor usage percentage threshold above which the instance is considered degraded' value: 75 } { name: 'AzureOptimization_PerfThresholdMemoryDegradedPercentage' description: 'The memory usage percentage threshold above which the instance is considered degraded' value: 90 } { name: 'AzureOptimization_PerfThresholdNetworkMbps' description: 'The network usage threshold (in Mbps) above which the fit score is decreased' value: 750 } { name: 'AzureOptimization_PerfThresholdCpuShutdownPercentage' description: 'The processor usage percentage threshold above which the fit score is decreased (shutdown scenarios)' value: 5 } { name: 'AzureOptimization_PerfThresholdMemoryShutdownPercentage' description: 'The memory usage percentage threshold above which the fit score is decreased (shutdown scenarios)' value: 100 } { name: 'AzureOptimization_PerfThresholdNetworkShutdownMbps' description: 'The network usage threshold (in Mbps) above which the fit score is decreased (shutdown scenarios)' value: 10 } { name: 'AzureOptimization_PerfThresholdDtuPercentage' description: 'The DTU usage percentage threshold below which a SQL Database instance is considered underutilized' value: 40 } { name: 'AzureOptimization_PerfThresholdDtuDegradedPercentage' description: 'The DTU usage percentage threshold above which a SQL Database instance is considered performance degraded' value: 75 } { name: 'AzureOptimization_PerfThresholdDiskIOPSPercentage' description: 'The IOPS usage percentage threshold below which a Disk is considered underutilized' value: 5 } { name: 'AzureOptimization_PerfThresholdDiskMBsPercentage' description: 'The throughput (MBps) usage percentage threshold below which a Disk is considered underutilized' value: 5 } { name: 'AzureOptimization_RemediateRightSizeMinFitScore' description: 'The minimum fit score for right-size remediation' value: '"5.0"' } { name: 'AzureOptimization_RemediateRightSizeMinWeeksInARow' description: 'The minimum number of weeks in a row required for a right-size recommendation to be remediated' value: 4 } { name: 'AzureOptimization_RecommendationAdvisorCostRightSizeId' description: 'The Azure Advisor VM right-size recommendation ID' value: '"e10b1381-5f0a-47ff-8c7b-37bd13d7c974"' } { name: 'AzureOptimization_RemediateLongDeallocatedVMsMinFitScore' description: 'The minimum fit score for long-deallocated VM remediation' value: '"5.0"' } { name: 'AzureOptimization_RemediateLongDeallocatedVMsMinWeeksInARow' description: 'The minimum number of weeks in a row required for a long-deallocated VM recommendation to be remediated' value: 4 } { name: 'AzureOptimization_RecommendationLongDeallocatedVMsId' description: 'The long deallocated VM recommendation ID' value: '"c320b790-2e58-452a-aa63-7b62c383ad8a"' } { name: 'AzureOptimization_RemediateUnattachedDisksMinFitScore' description: 'The minimum fit score for unattached disk remediation' value: '"5.0"' } { name: 'AzureOptimization_RemediateUnattachedDisksMinWeeksInARow' description: 'The minimum number of weeks in a row required for a unattached disk recommendation to be remediated' value: 4 } { name: 'AzureOptimization_RemediateUnattachedDisksAction' description: 'The action for the unattached disk recommendation to be remediated (Delete or Downsize)' value: '"Delete"' } { name: 'AzureOptimization_RecommendationUnattachedDisksId' description: 'The unattached disk recommendation ID' value: '"c84d5e86-e2d6-4d62-be7c-cecfbd73b0db"' } { name: 'AzureOptimization_RecommendationAADMinCredValidityDays' description: 'The minimum validity of an AAD Object credential in days' value: 30 } { name: 'AzureOptimization_RecommendationAADMaxCredValidityYears' description: 'The maximum validity of an AAD Object credential in years' value: 2 } { name: 'AzureOptimization_AADObjectsFilter' description: 'The Microsoft Entra object types to export' value: '"Application,ServicePrincipal,User,Group"' } { name: 'AzureOptimization_RecommendationRBACAssignmentsPercentageThreshold' description: 'The percentage threshold (used to trigger recommendations) for total RBAC assignments limits' value: 80 } { name: 'AzureOptimization_RecommendationResourceGroupsPerSubPercentageThreshold' description: 'The percentage threshold (used to trigger recommendations) for resource group count limits' value: 80 } { name: 'AzureOptimization_RecommendationVNetSubnetMaxUsedPercentageThreshold' description: 'The percentage threshold (used to trigger recommendations) for maximum subnet address space usage' value: 80 } { name: 'AzureOptimization_RecommendationVNetSubnetMinUsedPercentageThreshold' description: 'The percentage threshold (used to trigger recommendations) for minimum subnet address space usage' value: 5 } { name: 'AzureOptimization_RecommendationVNetSubnetEmptyMinAgeInDays' description: 'The minimum age (in days) for an empty subnet to trigger an NSG rule recommendation' value: 30 } { name: 'AzureOptimization_RecommendationsMaxAgeInDays' description: 'The maximum age (in days) for a recommendation to be kept in the SQL database' value: 365 } { name: 'AzureOptimization_RecommendationStorageAcountGrowthThresholdPercentage' description: 'The minimum Storage Account growth percentage required to flag Storage as not having a retention policy in place' value: 5 } { name: 'AzureOptimization_RecommendationStorageAcountGrowthMonthlyCostThreshold' description: 'The minimum monthly cost (in your EA/MCA currency) required to flag Storage as not having a retention policy in place' value: 50 } { name: 'AzureOptimization_RecommendationStorageAcountGrowthLookbackDays' description: 'The lookback period (in days) for analyzing Storage Account growth' value: 30 } ] resource logAnalyticsWorkspace 'microsoft.operationalinsights/workspaces@2020-08-01' = if (!logAnalyticsReuse) { name: logAnalyticsWorkspaceName location: projectLocation tags: resourceTags properties: { sku: { name: 'pergb2018' } retentionInDays: logAnalyticsRetentionDays } } resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' = { name: storageAccountName location: projectLocation tags: resourceTags sku: { name: 'Standard_LRS' } kind: 'StorageV2' properties: { allowBlobPublicAccess: false networkAcls: { bypass: 'AzureServices' virtualNetworkRules: [] ipRules: [] defaultAction: 'Allow' } supportsHttpsTrafficOnly: true encryption: { services: { file: { enabled: true } blob: { enabled: true } } keySource: 'Microsoft.Storage' } minimumTlsVersion: 'TLS1_2' accessTier: 'Cool' } } resource storageBlobServices 'Microsoft.Storage/storageAccounts/blobServices@2022-09-01' = { parent: storageAccount name: 'default' properties: { cors: { corsRules: [] } deleteRetentionPolicy: { enabled: false } } } resource storageCsvExportsContainers 'Microsoft.Storage/storageAccounts/blobServices/containers@2022-09-01' = [for item in csvExports: { name: '${storageAccountName}/default/${item.containerName}' properties: { publicAccess: 'None' } dependsOn: [ storageBlobServices storageAccount ] }] resource storageRecommendationsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2022-09-01' = { name: '${storageAccountName}/default/${recommendationsContainerName}' properties: { publicAccess: 'None' } dependsOn: [ storageBlobServices storageAccount ] } resource storageRemediationLogsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2022-09-01' = { name: '${storageAccountName}/default/${remediationLogsContainerName}' properties: { publicAccess: 'None' } dependsOn: [ storageBlobServices storageAccount ] } resource storageLifecycleManagementPolicy 'Microsoft.Storage/storageAccounts/managementPolicies@2021-02-01' = { parent: storageAccount name: 'default' properties: { policy: { rules: [ { enabled: true name: 'Clean6MonthsOldBlobs' type: 'Lifecycle' definition: { actions: { baseBlob: { delete: { daysAfterModificationGreaterThan: 180 } } snapshot: { delete: { daysAfterCreationGreaterThan: 180 } } version: { delete: { daysAfterCreationGreaterThan: 180 } } } filters: { blobTypes: [ 'blockBlob' ] } } } ] } } dependsOn: [ storageBlobServices ] } resource sqlServer 'Microsoft.Sql/servers@2022-05-01-preview' = { name: sqlServerName location: projectLocation tags: resourceTags properties: { administratorLogin: sqlAdminLogin administratorLoginPassword: sqlAdminPassword version: '12.0' publicNetworkAccess: 'Enabled' minimalTlsVersion: '1.2' } } resource sqlServerFirewall 'Microsoft.Sql/servers/firewallRules@2022-05-01-preview' = { parent: sqlServer name: 'AllowAllWindowsAzureIps' properties: { endIpAddress: '0.0.0.0' startIpAddress: '0.0.0.0' } } resource sqlDatabase 'Microsoft.Sql/servers/databases@2022-05-01-preview' = { parent: sqlServer name: sqlDatabaseName location: projectLocation tags: resourceTags sku: { name: 'Basic' tier: 'Basic' capacity: 5 } properties: { collation: 'SQL_Latin1_General_CP1_CI_AS' maxSizeBytes: 2147483648 catalogCollation: 'SQL_Latin1_General_CP1_CI_AS' zoneRedundant: false readScale: 'Disabled' autoPauseDelay: 60 requestedBackupStorageRedundancy: 'Geo' } } resource sqlServerName_sqlDatabaseName_default 'Microsoft.Sql/servers/databases/backupShortTermRetentionPolicies@2022-05-01-preview' = { name: '${sqlServerName}/${sqlDatabaseName}/default' properties: { retentionDays: sqlBackupRetentionDays } dependsOn: [ sqlDatabase sqlServer ] } resource automationAccount 'Microsoft.Automation/automationAccounts@2020-01-13-preview' = { name: automationAccountName location: projectLocation tags: resourceTags identity: { type: 'SystemAssigned' } properties: { sku: { name: 'Basic' } } } resource automationModule_Az_Accounts 'Microsoft.Automation/automationAccounts/modules@2020-01-13-preview' = { parent: automationAccount name: Az_Accounts.name tags: resourceTags properties: { contentLink: { uri: Az_Accounts.url } } } resource automationModule_Microsoft_Graph_Authentication 'Microsoft.Automation/automationAccounts/modules@2020-01-13-preview' = { parent: automationAccount name: Microsoft_Graph_Authentication.name tags: resourceTags properties: { contentLink: { uri: Microsoft_Graph_Authentication.url } } } resource automationModule_All 'Microsoft.Automation/automationAccounts/modules@2020-01-13-preview' = [for item in psModules: { parent: automationAccount name: item.name tags: resourceTags properties: { contentLink: { uri: item.url } } dependsOn: [ automationModule_Az_Accounts automationModule_Microsoft_Graph_Authentication ] }] resource automationRunbooks 'Microsoft.Automation/automationAccounts/runbooks@2020-01-13-preview' = [for item in runbooks: { parent: automationAccount name: item.name tags: resourceTags location: projectLocation properties: { runbookType: item.type logProgress: false logVerbose: false description: item.description publishContentLink: { uri: item.scriptUri version: item.version } } dependsOn: [ automationModule_All ] }] resource automationVariablesAll 'Microsoft.Automation/automationAccounts/variables@2020-01-13-preview' = [for item in automationVariables: { parent: automationAccount name: item.name properties: { description: item.description value: item.value } }] resource automationVariables_csvExports 'Microsoft.Automation/automationAccounts/variables@2020-01-13-preview' = [for item in csvExports: { parent: automationAccount name: item.variableName properties: { description: item.variableDescription value: '"${item.containerName}"' } }] resource automationVariables_SQLServerHostname 'Microsoft.Automation/automationAccounts/variables@2020-01-13-preview' = { parent: automationAccount name: 'AzureOptimization_SQLServerHostname' properties: { description: 'The Azure SQL Server hostname for the ingestion control and recommendations tables' value: '"${sqlServer.properties.fullyQualifiedDomainName}"' } } resource automationVariables_LogAnalyticsWorkspaceId 'Microsoft.Automation/automationAccounts/variables@2020-01-13-preview' = { parent: automationAccount name: 'AzureOptimization_LogAnalyticsWorkspaceId' properties: { description: 'The Log Analytics Workspace ID where optimization data will be ingested' value: '"${reference(((!logAnalyticsReuse) ? logAnalyticsWorkspace.id : resourceId(logAnalyticsWorkspaceRG, 'microsoft.operationalinsights/workspaces', logAnalyticsWorkspaceName)), '2020-08-01').customerId}"' } } resource automationVariables_LogAnalyticsWorkspaceKey 'Microsoft.Automation/automationAccounts/variables@2020-01-13-preview' = { parent: automationAccount name: 'AzureOptimization_LogAnalyticsWorkspaceKey' properties: { description: 'The shared key for the Log Analytics Workspace where optimization data will be ingested' value: '"${listKeys(((!logAnalyticsReuse) ? logAnalyticsWorkspace.id : resourceId(logAnalyticsWorkspaceRG, 'microsoft.operationalinsights/workspaces', logAnalyticsWorkspaceName)), '2020-08-01').primarySharedKey}"' isEncrypted: true } } resource automatinCredentials_SQLServer 'Microsoft.Automation/automationAccounts/credentials@2020-01-13-preview' = { parent: automationAccount name: 'AzureOptimization_SQLServerCredential' properties: { description: 'Azure Optimization SQL Database Credentials' password: sqlAdminPassword userName: sqlAdminLogin } } resource automationSchedules_csvExports 'Microsoft.Automation/automationAccounts/schedules@2020-01-13-preview' = [for item in csvExportsSchedules: { parent: automationAccount name: item.exportSchedule properties: { description: item.exportDescription expiryTime: '9999-12-31T17:59:00-06:00' startTime: dateTimeAdd(baseTime, item.exportTimeOffset) interval: 1 frequency: item.exportFrequency } }] resource automationSchedules_csvIngests 'Microsoft.Automation/automationAccounts/schedules@2020-01-13-preview' = [for item in csvExports: { parent: automationAccount name: item.ingestSchedule properties: { description: item.ingestDescription expiryTime: '9999-12-31T17:59:00-06:00' startTime: dateTimeAdd(baseTime, item.ingestTimeOffset) interval: 1 frequency: item.ingestFrequency } }] resource automationSchedules_remediationCsvIngest 'Microsoft.Automation/automationAccounts/schedules@2020-01-13-preview' = { parent: automationAccount name: remediationLogsIngestScheduleName properties: { description: 'Starts the daily Remediation Logs ingests' expiryTime: '9999-12-31T17:59:00-06:00' startTime: dateTimeAdd(baseTime, 'PT1H30M') interval: 1 frequency: 'Day' } } resource automationSchedules_recommendationsExport 'Microsoft.Automation/automationAccounts/schedules@2020-01-13-preview' = { parent: automationAccount name: recommendationsScheduleName properties: { description: 'Starts the weekly Recommendations generation' expiryTime: '9999-12-31T17:59:00-06:00' startTime: dateTimeAdd(baseTime, 'PT2H30M') interval: 1 frequency: 'Week' } } resource automationSchedules_recommendationsIngest 'Microsoft.Automation/automationAccounts/schedules@2020-01-13-preview' = { parent: automationAccount name: recommendationsIngestScheduleName properties: { description: 'Starts the weekly Recommendations ingests' expiryTime: '9999-12-31T17:59:00-06:00' startTime: dateTimeAdd(baseTime, 'PT3H30M') interval: 1 frequency: 'Week' } } resource automationSchedules_suppressionsIngest 'Microsoft.Automation/automationAccounts/schedules@2020-01-13-preview' = { parent: automationAccount name: suppressionsIngestScheduleName properties: { description: 'Starts the weekly Suppressions ingests' expiryTime: '9999-12-31T17:59:00-06:00' startTime: dateTimeAdd(baseTime, 'PT3H00M') interval: 1 frequency: 'Week' } } resource automationSchedules_recommendationsCleanUp 'Microsoft.Automation/automationAccounts/schedules@2020-01-13-preview' = { parent: automationAccount name: recommendationsCleanUpScheduleName properties: { description: 'Starts the weekly Recommendations cleanup' expiryTime: '9999-12-31T17:59:00-06:00' startTime: dateTimeAdd(baseTime, 'P6D') interval: 1 frequency: 'Week' } } resource automationJobSchedules_csvExports 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = [for item in csvExports: if (!item.isOneToMany) { parent: automationAccount name: item.exportJobId properties: { schedule: { name: item.exportSchedule } runbook: { name: item.runbookName } } dependsOn: [ automationSchedules_csvExports automationModule_All automationRunbooks ] }] resource automationJobSchedules_csvParameterizedExports 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = [for item in csvParameterizedExports: { parent: automationAccount name: item.exportJobId properties: { schedule: { name: item.exportSchedule } runbook: { name: item.runbookName } parameters: item.parameters } dependsOn: [ automationSchedules_csvExports automationModule_All automationRunbooks ] }] resource automationJobSchedules_csvIngests 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = [for item in csvExports: { parent: automationAccount name: item.ingestJobId properties: { schedule: { name: item.ingestSchedule } runbook: { name: csvIngestRunbookName } parameters: { StorageSinkContainer: item.containerName } } dependsOn: [ automationSchedules_csvIngests automationModule_All automationRunbooks ] }] resource automationJobSchedules_remediationLogsIngests 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = { parent: automationAccount name: remediationLogsIngestJobId properties: { schedule: { name: remediationLogsIngestScheduleName } runbook: { name: csvIngestRunbookName } parameters: { StorageSinkContainer: remediationLogsContainerName } } dependsOn: [ automationSchedules_remediationCsvIngest automationModule_All automationRunbooks ] } resource automationJobSchedules_recommendationsExports 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = [for item in recommendations: { parent: automationAccount name: item.recommendationJobId properties: { schedule: { name: recommendationsScheduleName } runbook: { name: item.runbookName } } dependsOn: [ automationSchedules_recommendationsExport automationModule_All automationRunbooks ] }] resource automationJobSchedules_recommendationsIngests 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = { parent: automationAccount name: recommendationsIngestJobId properties: { schedule: { name: recommendationsIngestScheduleName } runbook: { name: recommendationsIngestRunbookName } } dependsOn: [ automationSchedules_recommendationsIngest automationModule_All automationRunbooks ] } resource automationJobSchedules_recommendationsLogAnalyticsIngest 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = { parent: automationAccount name: recommendationsLogAnalyticsIngestJobId properties: { schedule: { name: recommendationsIngestScheduleName } runbook: { name: recommendationsLogAnalyticsIngestRunbookName } } dependsOn: [ automationSchedules_recommendationsIngest automationModule_All automationRunbooks ] } resource automationJobSchedules_suppressionsLogAnalyticsIngest 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = { parent: automationAccount name: suppressionsLogAnalyticsIngestJobId properties: { schedule: { name: suppressionsIngestScheduleName } runbook: { name: suppressionsLogAnalyticsIngestRunbookName } } dependsOn: [ automationSchedules_suppressionsIngest automationModule_All automationRunbooks ] } resource automationJobSchedules_recommendationsCleanUp 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = { parent: automationAccount name: recommendationsCleanUpJobId properties: { schedule: { name: recommendationsCleanUpScheduleName } runbook: { name: cleanUpOlderRecommendationsRunbookName } } dependsOn: [ automationSchedules_recommendationsCleanUp automationModule_All automationRunbooks ] } resource contributorRoleAssignmentGuid_resource 'Microsoft.Authorization/roleAssignments@2018-09-01-preview' = { name: contributorRoleAssignmentGuid properties: { roleDefinitionId: roleContributor principalId: reference(automationAccount.id, '2019-06-01', 'Full').identity.principalId principalType: 'ServicePrincipal' } } output automationPrincipalId string = reference(automationAccount.id, '2019-06-01', 'Full').identity.principalId ================================================ FILE: azuredeploy.bicep ================================================ targetScope = 'subscription' param rgName string param readerRoleAssignmentGuid string = guid(subscription().subscriptionId, rgName) param contributorRoleAssignmentGuid string = guid(rgName) param projectLocation string @description('The base URI where artifacts required by this template are located') param templateLocation string param storageAccountName string param automationAccountName string param sqlServerName string param sqlDatabaseName string = 'azureoptimization' param logAnalyticsReuse bool param logAnalyticsWorkspaceName string param logAnalyticsWorkspaceRG string param logAnalyticsRetentionDays int = 120 param sqlBackupRetentionDays int = 7 param sqlAdminLogin string @secure() param sqlAdminPassword string param cloudEnvironment string = 'AzureCloud' param authenticationOption string = 'ManagedIdentity' @description('Base time for all automation runbook schedules.') param baseTime string = utcNow('u') param resourceTags object param roleReader string = '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7' resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { name: rgName location: projectLocation tags: resourceTags dependsOn: [] } module resourcesDeployment './azuredeploy-nested.bicep' = { name: 'resourcesDeployment' scope: resourceGroup(rgName) params: { projectLocation: projectLocation templateLocation: templateLocation storageAccountName: storageAccountName automationAccountName: automationAccountName sqlServerName: sqlServerName sqlDatabaseName: sqlDatabaseName logAnalyticsReuse: logAnalyticsReuse logAnalyticsWorkspaceName: logAnalyticsWorkspaceName logAnalyticsWorkspaceRG: logAnalyticsWorkspaceRG logAnalyticsRetentionDays: logAnalyticsRetentionDays sqlBackupRetentionDays: sqlBackupRetentionDays sqlAdminLogin: sqlAdminLogin sqlAdminPassword: sqlAdminPassword cloudEnvironment: cloudEnvironment authenticationOption: authenticationOption baseTime: baseTime contributorRoleAssignmentGuid: contributorRoleAssignmentGuid resourceTags: resourceTags } dependsOn: [ rg ] } resource readerRoleAssignmentGuid_resource 'Microsoft.Authorization/roleAssignments@2018-09-01-preview' = { name: readerRoleAssignmentGuid properties: { roleDefinitionId: roleReader principalId: resourcesDeployment.outputs.automationPrincipalId principalType: 'ServicePrincipal' } } output automationPrincipalId string = resourcesDeployment.outputs.automationPrincipalId ================================================ FILE: custom-recommendations-types.json ================================================ { "recommendations": [ { "category": "Cost", "typeId": "c320b790-2e58-452a-aa63-7b62c383ad8a", "impact": "Medium", "description": "Virtual Machine has been deallocated for long with disks still incurring costs" }, { "category": "Cost", "typeId": "c84d5e86-e2d6-4d62-be7c-cecfbd73b0db", "impact": "Medium", "description": "Unattached disks (without owner VM) incur in unnecessary costs" }, { "category": "Cost", "typeId": "dc3d2baa-26c8-435e-aa9d-edb2bfd6fff6", "impact": "High", "description": "Application Gateways without a backend pool incur in unnecessary costs" }, { "category": "Cost", "typeId": "f1ed3bb2-3cb5-41e6-ba38-7001d5ff87f5", "impact": "Medium", "description": "Standard Load Balancers with rules defined and without a backend pool incur in unnecessary costs" }, { "category": "Cost", "typeId": "ff68f4e5-1197-4be9-8e5f-8760d7863cb4", "impact": "High", "description": "Underused SQL Databases (performance capacity waste)" }, { "category": "Cost", "typeId": "3125883f-8b9f-4bde-a0ff-6c739858c6e1", "impact": "Low", "description": "Orphaned Public IP (without owner resource) incur in unnecessary costs" }, { "category": "Cost", "typeId": "a4955cc9-533d-46a2-8625-5c4ebd1c30d5", "impact": "High", "description": "VM Scale Set has been underutilized" }, { "category": "Cost", "typeId": "4854b5dc-4124-4ade-879e-6a7bb65350ab", "impact": "High", "description": "Premium SSD disk has been underutilized" }, { "category": "Cost", "typeId": "08e049ca-18b0-4d22-b174-131a91d0381c", "impact": "Medium", "description": "Storage Account without retention policy in place" }, { "category": "Cost", "typeId": "042adaca-ebdf-49b4-bc1b-2800b6e40fea", "impact": "High", "description": "Underused App Service Plans (performance capacity waste)" }, { "category": "Cost", "typeId": "ef525225-8b91-47a3-81f3-e674e94564b6", "impact": "High", "description": "App Service Plans without any application incur in unnecessary costs" }, { "category": "Cost", "typeId": "110fea55-a9c3-480d-8248-116f61e139a8", "impact": "High", "description": "Virtual Machine is stopped (not deallocated) and still incurring costs" }, { "category": "HighAvailability", "typeId": "255de20b-d5e4-4be5-9695-620b4a905774", "impact": "High", "description": "Availability Sets should have a fault domain count of 3 or equal or greater than half of the Virtual Machines count" }, { "category": "HighAvailability", "typeId": "9764e285-2eca-46c5-b49e-649c039cf0cf", "impact": "High", "description": "Availability Sets should have an update domain count equal or greater than half of the Virtual Machines count" }, { "category": "HighAvailability", "typeId": "e530029f-9b6a-413a-99ed-81af54502bb9", "impact": "High", "description": "Virtual Machines in unmanaged Availability Sets should not share the same Storage Account" }, { "category": "HighAvailability", "typeId": "b70f44fa-5ef9-4180-b2f9-9cc6be07ab3e", "impact": "Medium", "description": "Virtual Machines with unmanaged disks should not share the same Storage Account" }, { "category": "HighAvailability", "typeId": "998b50d8-e654-417b-ab20-a31cb11629c0", "impact": "Medium", "description": "Virtual Machines should be placed in an Availability Set together with other instances with the same role" }, { "category": "HighAvailability", "typeId": "fe577af5-dfa2-413a-82a9-f183196c1f49", "impact": "Medium", "description": "Virtual Machines should not be the only instance in an Availability Set" }, { "category": "HighAvailability", "typeId": "024049e7-f63a-4e1c-b620-f011aafbc576", "impact": "Medium", "description": "Each Virtual Machine should have its unmanaged disks stored in a single Storage Account for higher availability and manageability" }, { "category": "HighAvailability", "typeId": "b576a069-b1f2-43a6-9134-5ee75376402a", "impact": "High", "description": "Virtual Machines should use Managed Disks for higher availability and manageability" }, { "category": "HighAvailability", "typeId": "1bf03c4a-c402-4e6c-bf20-051b18af30e2", "impact": "High", "description": "Virtual Machine Scale Sets should use Managed Disks for higher availability and manageability" }, { "category": "HighAvailability", "typeId": "1a77887c-7375-434e-af19-c2543171e0b8", "impact": "High", "description": "Virtual Machines should be placed in multiple Availability Zones" }, { "category": "HighAvailability", "typeId": "47e5457c-b345-4372-b536-8887fa8f0298", "impact": "High", "description": "Virtual Machine Scale Sets should be placed in multiple Availability Zones" }, { "category": "Security", "typeId": "ecd969c8-3f16-481a-9577-5ed32e5e1a1d", "impact": "Medium", "description": "Microsoft Entra application with credentials expiration not set or too far in time" }, { "category": "Security", "typeId": "b5491cde-f76c-4423-8c4c-89e3558ff2f2", "impact": "Medium", "description": "NSG rules referring to empty or inexisting subnets" }, { "category": "Security", "typeId": "3dc1d1f8-19ef-4572-9c9d-78d62831f55a", "impact": "Medium", "description": "NSG rules referring to orphan or inexisting NICs" }, { "category": "Security", "typeId": "fe40cbe7-bdee-4cce-b072-cf25e1247b7a", "impact": "High", "description": "NSG rules referring to orphan or inexisting Public IPs" }, { "category": "OperationalExcellence", "typeId": "3292c489-2782-498b-aad0-a4cef50f6ca2", "impact": "Medium", "description": "Microsoft Entra application with credentials expired or about to expire" }, { "category": "OperationalExcellence", "typeId": "48619512-f4e6-4241-9c85-16f7c987950c", "impact": "Medium", "description": "Load Balancers without a backend pool are useless" }, { "category": "OperationalExcellence", "typeId": "5292525b-5095-4e52-803e-e17192f1d099", "impact": "Medium", "description": "Subnets with a high IP space usage may constrain operations" }, { "category": "OperationalExcellence", "typeId": "0f27b41c-869a-4563-86e9-d1c94232ba81", "impact": "Medium", "description": "Subnets with a low IP space usage are a waste of virtual network address space" }, { "category": "OperationalExcellence", "typeId": "343bbfb7-5bec-4711-8353-398454d42b7b", "impact": "Medium", "description": "Subnets without any IP usage are a waste of virtual network address space" }, { "category": "OperationalExcellence", "typeId": "4c5c2d0c-b6a4-4c59-bc18-6fff6c1f5b23", "impact": "Medium", "description": "Orphaned Network Interfaces (without owner VM or PE) unnecessarily consume IP address space" }, { "category": "OperationalExcellence", "typeId": "c6a88d8c-3242-44b0-9793-c91897ef68bc", "impact": "High", "description": "Subscriptions close to the maximum limit of RBAC assignments" }, { "category": "OperationalExcellence", "typeId": "b36dea3e-ef21-45a9-a704-6f629fab236d", "impact": "High", "description": "Management Groups close to the maximum limit of RBAC assignments" }, { "category": "OperationalExcellence", "typeId": "4468da8d-1e72-4998-b6d2-3bc38ddd9330", "impact": "High", "description": "Subscriptions close to the maximum limit of resource groups" }, { "category": "Performance", "typeId": "20a40c62-e5c8-4cc3-9fc2-f4ac75013182", "impact": "Medium", "description": "VM Scale Set performance has been constrained by lack of resources" }, { "category": "Performance", "typeId": "724ff2f5-8c83-4105-b00d-029c4560d774", "impact": "Medium", "description": "SQL Database performance has been constrained by lack of resources" }, { "category": "Performance", "typeId": "351574cb-c105-4538-a778-11dfbe4857bf", "impact": "Medium", "description": "App Service Plan performance has been constrained by lack of resources" } ] } ================================================ FILE: docs/configuring-workspaces.md ================================================ # Configuring Log Analytics workspaces ## Validating/configuring performance counters collection If you want to fully leverage the VM right-size augmented recommendation, you need to have your VMs sending logs to a Log Analytics workspace (it should normally be the one you chose at AOE installation time, but it can be a different one) and you need them to send specific performance counters. The list of required counters is defined [here](../perfcounters.json). The AOE provides a couple of tools that help you validate and fix the configured Log Analytics performance counters, depending on the type of agent you are using to collect logs from your machines. ### Azure Monitor Agent (preferred approach) With the help of the [Setup-DataCollectionRules.ps1](./Setup-DataCollectionRules.ps1) script, you can create a couple of Data Collection Rules (DCR) - one per OS type - that you configure to stream performance counters to the Log Analytics workspace of your choice. After creating the DCRs with the script below, you just have to manually or automatically (e.g., with Azure Policy) associate your VMs to the respective DCRs. #### Requirements ```powershell Install-Module -Name Az.Accounts Install-Module -Name Az.Resources Install-Module -Name Az.OperationalInsights ``` #### Usage ```powershell ./Setup-DataCollectionRules.ps1 -DestinationWorkspaceResourceId [-AzureEnvironment ] [-IntervalSeconds ] [-ResourceTags ] # Example 1 - create Linux and Windows DCRs with the default options ./Setup-DataCollectionRules.ps1 -DestinationWorkspaceResourceId "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myResourceGroup/providers/Microsoft.OperationalInsights/workspaces/myWorkspace" # Example 2 - create DCRs using a custom counter collection frequency and assigning specific tags ./Setup-DataCollectionRules.ps1 -DestinationWorkspaceResourceId "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myResourceGroup/providers/Microsoft.OperationalInsights/workspaces/myWorkspace" -IntervalSeconds 30 -ResourceTags @{"tagName"="tagValue";"otherTagName"="otherTagValue"} ``` ### Log Analytics agent (legacy Microsoft Monitoring Agent) With the help of the [Setup-LogAnalyticsWorkspaces.ps1](./Setup-LogAnalyticsWorkspaces.ps1) script, you can validate and fix the configured Log Analytics performance counters on the workspaces of your choice. In its simplest form of usage, it looks at all the Log Analytics workspaces you have access to and, for each workspace with Azure VMs onboarded, it validates performance counters configuration and tells you which counters are missing. But you can target a specific workspace and, if required, automatically fix the missing counters. See usage details below. #### Requirements ```powershell Install-Module -Name Az.Accounts Install-Module -Name Az.ResourceGraph Install-Module -Name Az.OperationalInsights ``` #### Usage ```powershell ./Setup-LogAnalyticsWorkspaces.ps1 [-AzureEnvironment ] [-WorkspaceIds ] [-IntervalSeconds ] [-AutoFix] # Example 1 - just check all the workspaces configuration ./Setup-LogAnalyticsWorkspaces.ps1 # Example 2 - fix all workspaces configuration (using default counter collection frequency) ./Setup-LogAnalyticsWorkspaces.ps1 -AutoFix # Example 3 - fix specific workspaces configuration, using a custom counter collection frequency ./Setup-LogAnalyticsWorkspaces.ps1 -AutoFix -WorkspaceIds "d69e840a-2890-4451-b63c-bcfc5580b90f","961550b2-2c4a-481a-9559-ddf53de4b455" -IntervalSeconds 30 ``` ## Estimating the cost of onboarding additional VMs or adding missing performance metrics Each performance counter entry in the `Perf` table has different sizings, depending on the [7 required counters](../perfcounters.json) per OS type. The following table enumerates the size (in bytes) per performance counter entry. OS Type | Object | Counter | Size | Collections per interval/VM --- | --- | --- | ---: | --- | Windows | Processor | % Processor Time | 200 | 1 + vCPUs count Windows | Memory | Available MBytes | 220 | 1 Windows | LogicalDisk | Disk Read Bytes/sec | 250 | 3 + data disks count Windows | LogicalDisk | Disk Write Bytes/sec | 250 | 3 + data disks count Windows | LogicalDisk | Disk Reads/sec | 250 | 3 + data disks count Windows | LogicalDisk | Disk Writes/sec | 250 | 3 + data disks count Windows | Network Adapter | Bytes Total/sec | 290 | network adapters count Linux | Processor | % Processor Time | 200 Linux | Memory | % Used Memory | 200 Linux | Logical Disk | Disk Read Bytes/sec | 250 | 3 + data disks count Linux | Logical Disk | Disk Write Bytes/sec | 250 | 3 + data disks count Linux | Logical Disk | Disk Reads/sec | 250 | 3 + data disks count Linux | Logical Disk | Disk Writes/sec | 250 | 3 + data disks count Linux | Network | Total Bytes | 200 | network adapters count In summary, a Windows VM generates, in average, 245 bytes per performance counter entry, while a Linux consumes a bit less, 230 bytes per entry. However, depending on the number of CPU cores, data disks or network adapters, a VM will generate more or less Log Analytics entries. For example, a Windows VM with 4 vCPUs, 1 data disk and 5 network adapters will generate 5 * 200 + 220 + 4 * 250 + 4 * 250 + 4 * 250 + 4 * 250 + 5 * 290 = 6670 bytes (6.5 KB) per collection interval. If you set your Performance Counters interval to 60 seconds, then you'll have 60 * 24 * 30 * 6.5 = 280800 KB (274 MB) of ingestion data per month, which means less than 0.70 EUR/month at the Log Analytics ingestion retail price (Pay As You Go). ## Using multiple Log Analytics workspaces for VM performance metrics If you have VMs onboarded to multiple Log Analytics workspaces and you want them to be fully included in the VM right-size recommendations report, you can add those workspaces to the solution just by adding a new variable to the AOE Azure Automation account. In the Automation Account _Shared Resources - Variables_ menu option, click on the _Add a variable button_ and enter `AzureOptimization_RightSizeAdditionalPerfWorkspaces` as the variable name and fill in the comma-separated list of workspace IDs (see example below). Finally, click on _Create_. ![Adding an Automation Account variable with a list of additional workspace IDs for the VM right-size recommendations](./loganalytics-additionalperfworkspaces.jpg "Additional workspace IDs variable creation") ================================================ FILE: docs/customizing-aoe.md ================================================ # Customizing the Azure Optimization Engine There are many customization options available in AOE, in the form of Azure Automation variables. The list below is a highlight of the most relevant configuration variables. To access them, go to the Automation Account _Shared Resources - Variables_ menu option. * `AzureOptimization_AdvisorFilter` - If you are not interested in getting recommendations for all the non-Cost Advisor pillars, you can specify a pillar-level filter (comma-separated list with at least one of the following: `HighAvailability,Security,Performance,OperationalExcellence`). Defaults to all pillars. * `AzureOptimization_AuthenticationOption` - The default authentication method for Automation Runbooks is `RunAsAccount`. But you can change to `ManagedIdentity` if you're using a Hybrid Worker in an Azure VM. * `AzureOptimization_ConsumptionOffsetDays` - The Azure Consumption data collection runbook queries each day for billing events that occurred 7 days ago (default). You can change to a closer offset, but bear in mind that some subscription types (e.g., MSDN) to not support a lower value. * `AzureOptimization_PerfPercentileCpu` - The default percentile for CPU metrics aggregations is 99. The lower the percentile, the less conservative will be VM right-size fit score algorithm. * `AzureOptimization_PerfPercentileDisk` - The default percentile for disk IO/throughput metrics aggregations is 99. The lower the percentile, the less conservative will be VM right-size fit score algorithm. * `AzureOptimization_PerfPercentileMemory` - The default percentile for memory metrics aggregations is 99. The lower the percentile, the less conservative will be VM right-size fit score algorithm. * `AzureOptimization_PerfPercentileNetwork` - The default percentile for network metrics aggregations is 99. The lower the percentile, the less conservative will be VM right-size fit score algorithm. * `AzureOptimization_PerfPercentileSqlDtu` - The default percentile to be used for SQL DB DTU metrics. The lower the percentile, the less conservative will be the SQL Database right-size algorithm. * `AzureOptimization_PerfThresholdCpuPercentage` - The CPU threshold (in % Processor Time) above which the VM right-size fit score will decrease or below which the VM scale set right-size Cost recommendation will trigger. * `AzureOptimization_PerfThresholdCpuShutdownPercentage` - The CPU threshold (in % Processor Time) above which the VM right-size fit score will decrease (_shutdown recommendations only_). * `AzureOptimization_PerfThresholdCpuDegradedMaxPercentage` - The CPU threshold (Maximum observed in % Processor Time) above which the VM scale set right-size Performance recommendation will trigger. * `AzureOptimization_PerfThresholdCpuDegradedAvgPercentage` - The CPU threshold (Average observed in % Processor Time) above which the VM scale set right-size Performance recommendation will trigger. * `AzureOptimization_PerfThresholdMemoryPercentage` - The memory threshold (in % Used Memory) above which the VM right-size fit score will decrease or below which the VM scale set right-size Cost recommendation will trigger. * `AzureOptimization_PerfThresholdMemoryShutdownPercentage` - The memory threshold (in % Used Memory) above which the VM right-size fit score will decrease (_shutdown recommendations only_). * `AzureOptimization_PerfThresholdMemoryDegradedPercentage` - The memory threshold (in % Used Memory) above which the VM scale set right-size Performance recommendation will trigger. * `AzureOptimization_PerfThresholdNetworkMbps` - The network threshold (in Total Mbps) above which the VM right-size fit score will decrease. * `AzureOptimization_PerfThresholdNetworkShutdownMbps` - The network threshold (in Total Mbps) above which the VM right-size fit score will decrease (_shutdown recommendations only_). * `AzureOptimization_PerfThresholdDtuPercentage` - The DTU usage percentage threshold below which a SQL Database instance is considered underutilized. * `AzureOptimization_RecommendAdvisorPeriodInDays` - The interval in days to look for Advisor recommendations in the Log Analytics repository - the default is 7, as Advisor recommendations are collected once a week. * `AzureOptimization_RecommendationAADMaxCredValidityYears` - The maximum number of years for a Service Principal credential/certificate validity - any validity above this interval will generate a Security recommendation. Defaults to 2. * `AzureOptimization_RecommendationAADMinCredValidityDays` - The minimum number of days for a Service Principal credential/certificate before it expires - any validity below this interval will generate an Operational Excellence recommendation. Defaults to 30. * `AzureOptimization_RecommendationLongDeallocatedVmsIntervalDays` - The number of consecutive days a VM has been deallocated before being recommended for deletion (_Virtual Machine has been deallocated for long with disks still incurring costs_). Defaults to 30. * `AzureOptimization_RecommendationVNetSubnetMaxUsedPercentageThreshold` - The maximum percentage tolerated for subnet IP space usage. Defaults to 80. * `AzureOptimization_RecommendationVNetSubnetMinUsedPercentageThreshold` - The minimum percentage for subnet IP space usage - any usage below this value will flag the respective subnet as using low IP space. Defaults to 5. * `AzureOptimization_RecommendationVNetSubnetEmptyMinAgeInDays` - The minimum age in days for an empty subnet to be flagged, thus avoiding flagging newly created subnets. Defaults to 30. * `AzureOptimization_RecommendationVNetSubnetUsedPercentageExclusions` - Comma-separated, single-quote enclosed list of subnet names that must be excluded from subnet usage percentage recommendations, e.g., 'gatewaysubnet','azurebastionsubnet'. Defaults to 'gatewaysubnet'. * `AzureOptimization_RecommendationRBACAssignmentsPercentageThreshold` - The maximum percentage of RBAC assignments limits usage. Defaults to 80. * `AzureOptimization_RecommendationResourceGroupsPerSubPercentageThreshold` - The maximum percentage of Resource Groups count per subscription limits usage. Defaults to 80. * `AzureOptimization_RecommendationRBACSubscriptionsAssignmentsLimit` - The maximum limit for RBAC assignments per subscription. Currently set to 2000 (as [documented](https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/azure-subscription-service-limits#azure-rbac-limits)). * `AzureOptimization_RecommendationRBACMgmtGroupsAssignmentsLimit` - The maximum limit for RBAC assignments per management group. Currently set to 500 (as [documented](https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/azure-subscription-service-limits#azure-rbac-limits)). * `AzureOptimization_RecommendationResourceGroupsPerSubLimit` - The maximum limit for Resource Group count per subscription. Currently set to 980 (as [documented](https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/azure-subscription-service-limits#subscription-limits)). * `AzureOptimization_RecommendationStorageAcountGrowthThresholdPercentage` - The minimum Storage Account growth percentage required to flag Storage as not having a retention policy in place. * `AzureOptimization_RecommendationStorageAcountGrowthMonthlyCostThreshold` - The minimum monthly cost (in your EA/MCA currency) required to flag Storage as not having a retention policy in place. * `AzureOptimization_RecommendationStorageAcountGrowthLookbackDays` - The lookback period (in days) for analyzing Storage Account growth. * `AzureOptimization_ReferenceRegion` - The Azure region used as a reference for getting the list of available SKUs (defaults to `westeurope`). * `AzureOptimization_RemediateRightSizeMinFitScore` - The minimum fit score a VM right-size recommendation must have for the remediation to occur. * `AzureOptimization_RemediateRightSizeMinWeeksInARow` - The minimum number of weeks in a row a VM right-size recommendation must have been done for the remediation to occur. * `AzureOptimization_RemediateRightSizeTagsFilter` - The tag name/value pairs a VM right-size recommendation must have for the remediation to occur. Example: `[ { "tagName": "a", "tagValue": "b" }, { "tagName": "c", "tagValue": "d" } ]` * `AzureOptimization_RemediateLongDeallocatedVMsMinFitScore` - The minimum fit score a long deallocated VM recommendation must have for the remediation to occur. * `AzureOptimization_RemediateLongDeallocatedVMsMinWeeksInARow` - The minimum number of weeks in a row a long deallocated VM recommendation must have been done for the remediation to occur. * `AzureOptimization_RemediateLongDeallocatedVMsTagsFilter` - The tag name/value pairs a long deallocated VM recommendation must have for the remediation to occur. Example: `[ { "tagName": "a", "tagValue": "b" }, { "tagName": "c", "tagValue": "d" } ]` * `AzureOptimization_RemediateUnattachedDisksMinFitScore` - The minimum fit score an unattached disk recommendation must have for the remediation to occur. * `AzureOptimization_RemediateUnattachedDisksMinWeeksInARow` - The minimum number of weeks in a row an unattached disk recommendation must have been done for the remediation to occur. * `AzureOptimization_RemediateUnattachedDisksAction` - The action to apply for an unattached disk recommendation remediation (`Delete` or `Downsize`). * `AzureOptimization_RemediateUnattachedDisksTagsFilter` - The tag name/value pairs an unattached disk recommendation must have for the remediation to occur. Example: `[ { "tagName": "a", "tagValue": "b" }, { "tagName": "c", "tagValue": "d" } ]` * `AzureOptimization_RightSizeAdditionalPerfWorkspaces` - A comma-separated list of additional Log Analytics workspace IDs where to look for VM metrics (see [Configuring Log Analytics workspaces](./configuring-workspaces.md)). * `AzureOptimization_PerfThresholdDiskIOPSPercentage` - The disk IOPS usage percentage threshold below which the underutilized Premium SSD disks recommendation will trigger. * `AzureOptimization_PerfThresholdDiskMBsPercentage` - The disk throughput usage percentage threshold below which the underutilized Premium SSD disks recommendation will trigger. * `AzureOptimization_RecommendationsMaxAgeInDays` - The maximum age (in days) for a recommendation to be kept in the SQL database. Default: 365. * `AzureOptimization_RetailPricesCurrencyCode` - The currency code (e.g., EUR, USD, etc.) used to collect the Reservations retail prices. * `AzureOptimization_PriceSheetMeterCategories` - The comma-separated meter categories used for Pricesheet filtering, in order to avoid ingesting unnecessary data. Defaults to "Virtual Machines,Storage" * `AzureOptimization_ConsumptionScope` - The scope of the consumption exports: `Subscription` (default) or `BillingAccount`. See [more details](#enabling-the-reservations-and-benefits-usage-workbooks). ================================================ FILE: docs/suppressing-recommendations.md ================================================ # Suppressing recommendations When working on the recommendations provided by AOE, you may find some cases where the recommendation does not apply for some reason. For example, AOE is suggesting high availability recommendations that do not apply to Dev/Test Virtual Machines, or recommending enabling Azure Backup for non-critical VMs. You can suppress recommendations in two ways: * If recommendations are originated from Azure Advisor, you can simply go to the Azure Portal and [dismiss/postpone the recommendation](https://docs.microsoft.com/en-us/azure/advisor/view-recommendations#dismissing-and-postponing-recommendations). * If recommendations are custom to AOE or using the Azure Advisor interface is not viable, you can suppress them in AOE using the [Suppress-Recommendation.ps1](../Suppress-Recommendation.ps1) helper script (see instructions below). ## Identifying the recommendation to suppress In the Power BI report, if you drill through the details of a recommendation (Rec. Details page), you will find the Recommendation Id in the header. Copy this Id, by using the "Copy value" right-click menu option. You'll need this ID to call the Supress-Recommendation.ps1 script. ![Copying the Recommendation Id value from the Recommendation Details page in the Power BI report](./powerbi-recdetails-recommendationid.jpg "Copy the Recommendation Id value") ## Supressing the recommendation From a PowerShell prompt, call the [Suppress-Recommendation.ps1](../Suppress-Recommendation.ps1) script as follows: ```powershell ./Suppress-Recommendation.ps1 -RecommendationId # Example ./Suppress-Recommendation.ps1 -RecommendationId A2824017-602C-47DF-860D-B0B5A8CA7768 ``` The script will ask you for the Azure SQL Server hostname, database and user credentials. After successfully finding the recommendation in the AOE database, it will ask you about the type of suppression: * **Exclude** - this recommendation type will be completely excluded from the engine and will no longer be generated for any resource * **Dismiss** - this recommendation will be dismissed for the scope to be chosen next (instance, resource group or subscription) * **Snooze** - this recommendation will be postponed for the duration (in days) and scope to be chosen next (instance, resource group or subscription) Depending on the type of suppression chosen, you can be asked to provide the suppression scope (subscription, resource group or resource instance) or the suppression duration (for Snooze suppressions). Finally, you should identify the author and the reason for the suppression. ================================================ FILE: model/filters-table.sql ================================================ SET ANSI_NULLS ON SET QUOTED_IDENTIFIER ON IF NOT EXISTS (SELECT * FROM sysobjects WHERE id = object_id(N'[dbo].[Filters]') AND OBJECTPROPERTY(id, N'IsUserTable') = 1) BEGIN CREATE TABLE [dbo].[Filters]( [FilterId] [uniqueidentifier] NOT NULL DEFAULT NEWID(), [RecommendationSubTypeId] [uniqueidentifier] NOT NULL, [FilterType] [varchar](20) NOT NULL, [InstanceId] [varchar](1000) NULL, [FilterStartDate] [datetime] NOT NULL, [FilterEndDate] [datetime] NULL, [Author] [varchar](50) NULL, [Notes] [nvarchar](max) NULL, [IsEnabled] [bit] NOT NULL ) ALTER TABLE [dbo].[Filters] ADD PRIMARY KEY CLUSTERED ( [FilterId] ASC )WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ONLINE = OFF) ON [PRIMARY] END ================================================ FILE: model/loganalyticsingestcontrol-initialize.sql ================================================ IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argvmexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argvmexports', '1901-01-01T00:00:00Z', -1, 'VMsV1', 'ARGVirtualMachine') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argdiskexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argdiskexports', '1901-01-01T00:00:00Z', -1, 'DisksV1', 'ARGManagedDisk') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argvhdexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argvhdexports', '1901-01-01T00:00:00Z', -1, 'VhdDisksV1', 'ARGUnmanagedDisk') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argavailsetexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argavailsetexports', '1901-01-01T00:00:00Z', -1, 'AvailSetsV1', 'ARGAvailabilitySet') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'advisorexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('advisorexports', '1901-01-01T00:00:00Z', -1, 'AdvisorV1', 'AzureAdvisor') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'remediationlogs') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('remediationlogs', '1901-01-01T00:00:00Z', -1, 'RemediationV1', 'RemediationLogs') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'consumptionexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('consumptionexports', '1901-01-01T00:00:00Z', -1, 'ConsumptionV1', 'AzureConsumption') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'aadobjectsexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('aadobjectsexports', '1901-01-01T00:00:00Z', -1, 'AADObjectsV1', 'AADObjects') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'arglbexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('arglbexports', '1901-01-01T00:00:00Z', -1, 'LoadBalancersV1', 'ARGLoadBalancer') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argappgwexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argappgwexports', '1901-01-01T00:00:00Z', -1, 'AppGatewaysV1', 'ARGAppGateway') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argrescontainersexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argrescontainersexports', '1901-01-01T00:00:00Z', -1, 'ResourceContainersV1', 'ARGResourceContainers') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'rbacexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('rbacexports', '1901-01-01T00:00:00Z', -1, 'RBACAssignmentsV1', 'RBACAssignments') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argvnetexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argvnetexports', '1901-01-01T00:00:00Z', -1, 'VNetsV1', 'ARGVirtualNetwork') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argnicexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argnicexports', '1901-01-01T00:00:00Z', -1, 'NICsV1', 'ARGNetworkInterface') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argnsgexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argnsgexports', '1901-01-01T00:00:00Z', -1, 'NSGsV1', 'ARGNSGRule') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argpublicipexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argpublicipexports', '1901-01-01T00:00:00Z', -1, 'PublicIPsV1', 'ARGPublicIP') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argvmssexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argvmssexports', '1901-01-01T00:00:00Z', -1, 'VMSSV1', 'ARGVMSS') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argsqldbexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argsqldbexports', '1901-01-01T00:00:00Z', -1, 'SqlDbV1', 'ARGSqlDb') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'azmonitorexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('azmonitorexports', '1901-01-01T00:00:00Z', -1, 'MonitorMetricsV1', 'MonitorMetrics') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'policystateexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('policystateexports', '1901-01-01T00:00:00Z', -1, 'PolicyStatesV1', 'PolicyStates') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'recommendationsexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('recommendationsexports', '2022-12-26T00:00:00Z', -1, 'RecommendationsV1', 'Recommendations') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'reservationsexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('reservationsexports', '1901-01-01T00:00:00Z', -1, 'ReservationsUsageV1', 'ReservationsUsage') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argappserviceplanexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('argappserviceplanexports', '1901-01-01T00:00:00Z', -1, 'AppServicePlansV1', 'AppServicePlans') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'pricesheetexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('pricesheetexports', '1901-01-01T00:00:00Z', -1, 'PricesheetV1', 'Pricesheet') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'reservationspriceexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('reservationspriceexports', '1901-01-01T00:00:00Z', -1, 'ReservationsPriceV1', 'ReservationsPrice') END IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'savingsplansexports') BEGIN INSERT INTO [dbo].[LogAnalyticsIngestControl] VALUES ('savingsplansexports', '1901-01-01T00:00:00Z', -1, 'SavingsPlansUsageV1', 'SavingsPlansUsage') END ================================================ FILE: model/loganalyticsingestcontrol-table.sql ================================================ SET ANSI_NULLS ON SET QUOTED_IDENTIFIER ON IF NOT EXISTS (SELECT * FROM sysobjects WHERE id = object_id(N'[dbo].[LogAnalyticsIngestControl]') AND OBJECTPROPERTY(id, N'IsUserTable') = 1) BEGIN CREATE TABLE [dbo].[LogAnalyticsIngestControl]( [StorageContainerName] [varchar](50) NOT NULL, [LastProcessedDateTime] [datetime] NULL, [LastProcessedLine] [int] NULL, [LogAnalyticsSuffix] [varchar](50) NOT NULL, [CollectedType] [varchar](50) NULL ) ALTER TABLE [dbo].[LogAnalyticsIngestControl] ADD PRIMARY KEY CLUSTERED ( [StorageContainerName] ASC )WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ONLINE = OFF) ON [PRIMARY] END ELSE BEGIN IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[LogAnalyticsIngestControl]') AND name = 'CollectedType' ) BEGIN ALTER TABLE [dbo].[LogAnalyticsIngestControl] ADD [CollectedType] VARCHAR (50) NULL END END ================================================ FILE: model/loganalyticsingestcontrol-upgrade.sql ================================================ UPDATE [dbo].[LogAnalyticsIngestControl] SET CollectedType = 'ARGVirtualMachine' WHERE StorageContainerName = 'argvmexports' UPDATE [dbo].[LogAnalyticsIngestControl] SET CollectedType = 'ARGManagedDisk' WHERE StorageContainerName = 'argdiskexports' UPDATE [dbo].[LogAnalyticsIngestControl] SET CollectedType = 'AzureAdvisor' WHERE StorageContainerName = 'advisorexports' UPDATE [dbo].[LogAnalyticsIngestControl] SET CollectedType = 'RemediationLogs' WHERE StorageContainerName = 'remediationlogs' UPDATE [dbo].[LogAnalyticsIngestControl] SET CollectedType = 'AzureConsumption' WHERE StorageContainerName = 'consumptionexports' ================================================ FILE: model/recommendations-sp.sql ================================================ IF OBJECT_ID ( N'[dbo].[GetRecommendations]', 'P' ) IS NOT NULL BEGIN DROP PROCEDURE dbo.GetRecommendations END EXEC('CREATE PROCEDURE dbo.GetRecommendations AS BEGIN SET NOCOUNT ON; SELECT * FROM [dbo].[Recommendations] R WHERE GeneratedDate > GETDATE()-365 AND NOT EXISTS ( SELECT * FROM [dbo].[Filters] WHERE FilterType IN (''Snooze'', ''Dismiss'') AND IsEnabled = 1 AND R.GeneratedDate > FilterStartDate AND (FilterEndDate IS NULL OR FilterEndDate > GETDATE()) AND RecommendationSubTypeId = R.RecommendationSubTypeId AND (InstanceId IS NULL OR R.InstanceId LIKE ''%'' + InstanceId + ''%'') ) END ') ================================================ FILE: model/recommendations-table.sql ================================================ SET ANSI_NULLS ON SET QUOTED_IDENTIFIER ON IF NOT EXISTS (SELECT * FROM sysobjects WHERE id = object_id(N'[dbo].[Recommendations]') AND OBJECTPROPERTY(id, N'IsUserTable') = 1) BEGIN CREATE TABLE [dbo].[Recommendations]( [RecommendationId] [uniqueidentifier] NOT NULL DEFAULT NEWID(), [GeneratedDate] [datetime] NOT NULL, [Cloud] [varchar](20) NOT NULL, [Category] [varchar](50) NOT NULL, [ImpactedArea] [varchar](300) NOT NULL, [Impact] [varchar](20) NOT NULL, [RecommendationType] [varchar](50) NOT NULL, [RecommendationSubType] [varchar](50) NOT NULL, [RecommendationSubTypeId] [uniqueidentifier] NOT NULL, [RecommendationDescription] [nvarchar](1000) NULL, [RecommendationAction] [nvarchar](1000) NULL, [InstanceId] [varchar](1000) NULL, [InstanceName] [varchar](500) NULL, [AdditionalInfo] [nvarchar](max) NULL, [ResourceGroup] [varchar](200) NULL, [SubscriptionGuid] [varchar](50) NULL, [SubscriptionName] [varchar](250) NULL, [TenantGuid] [varchar](50) NULL, [FitScore] [real] NOT NULL, [Tags] [nvarchar](max) NULL, [DetailsUrl] [nvarchar](max) NULL ) ALTER TABLE [dbo].[Recommendations] ADD PRIMARY KEY CLUSTERED ( [RecommendationId] ASC )WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ONLINE = OFF) ON [PRIMARY] CREATE INDEX IXC_Recommendations_SubTypeId ON [dbo].[Recommendations](RecommendationSubTypeId) CREATE INDEX IXC_Recommendations_GeneratedDate ON [dbo].[Recommendations](GeneratedDate) END ELSE BEGIN ALTER TABLE [dbo].[Recommendations] ALTER COLUMN [RecommendationAction] VARCHAR (1000) NULL ALTER TABLE [dbo].[Recommendations] ALTER COLUMN [InstanceId] VARCHAR (1000) NULL ALTER TABLE [dbo].[Recommendations] ALTER COLUMN [InstanceName] VARCHAR (500) NULL ALTER TABLE [dbo].[Recommendations] ALTER COLUMN [ResourceGroup] VARCHAR (200) NULL ALTER TABLE [dbo].[Recommendations] ALTER COLUMN [ImpactedArea] VARCHAR (300) NULL IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[Recommendations]') AND name = 'FitScore') BEGIN EXEC sp_rename '[dbo].[Recommendations].ConfidenceScore', 'FitScore', 'COLUMN' END IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[Recommendations]') AND name = 'SubscriptionName') BEGIN ALTER TABLE [dbo].[Recommendations] ADD [SubscriptionName] VARCHAR (250) NULL END IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[Recommendations]') AND name = 'TenantGuid') BEGIN ALTER TABLE [dbo].[Recommendations] ADD [TenantGuid] VARCHAR (50) NULL END IF NOT EXISTS (SELECT * from sysindexes WHERE id=object_id('Recommendations') and name='IXC_Recommendations_SubTypeId') BEGIN CREATE INDEX IXC_Recommendations_SubTypeId ON [dbo].[Recommendations](RecommendationSubTypeId) END IF NOT EXISTS (SELECT * from sysindexes WHERE id=object_id('Recommendations') and name='IXC_Recommendations_GeneratedDate') BEGIN CREATE INDEX IXC_Recommendations_GeneratedDate ON [dbo].[Recommendations](GeneratedDate) END END ================================================ FILE: model/sqlserveringestcontrol-initialize.sql ================================================ IF NOT EXISTS (SELECT * FROM [dbo].[SqlServerIngestControl] WHERE StorageContainerName = 'recommendationsexports') BEGIN INSERT INTO [dbo].[SqlServerIngestControl] VALUES ('recommendationsexports', '1901-01-01T00:00:00Z', -1, 'Recommendations') END ================================================ FILE: model/sqlserveringestcontrol-table.sql ================================================ SET ANSI_NULLS ON SET QUOTED_IDENTIFIER ON IF NOT EXISTS (SELECT * FROM sysobjects WHERE id = object_id(N'[dbo].[SqlServerIngestControl]') AND OBJECTPROPERTY(id, N'IsUserTable') = 1) BEGIN CREATE TABLE [dbo].[SqlServerIngestControl]( [StorageContainerName] [varchar](50) NOT NULL, [LastProcessedDateTime] [datetime] NULL, [LastProcessedLine] [int] NULL, [SqlTableName] [varchar](50) NOT NULL ) ALTER TABLE [dbo].[SqlServerIngestControl] ADD PRIMARY KEY CLUSTERED ( [StorageContainerName] ASC )WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ONLINE = OFF) ON [PRIMARY] END ================================================ FILE: perfcounters.json ================================================ [ { "objectName": "LogicalDisk", "instance": "*", "counterName": "Disk Read Bytes/sec", "osType": "Windows" }, { "objectName": "LogicalDisk", "instance": "*", "counterName": "Disk Reads/sec", "osType": "Windows" }, { "objectName": "LogicalDisk", "instance": "*", "counterName": "Disk Write Bytes/sec", "osType": "Windows" }, { "objectName": "LogicalDisk", "instance": "*", "counterName": "Disk Writes/sec", "osType": "Windows" }, { "objectName": "Memory", "instance": "*", "counterName": "Available MBytes", "osType": "Windows" }, { "objectName": "Network Adapter", "instance": "*", "counterName": "Bytes Total/sec", "osType": "Windows" }, { "objectName": "Processor", "instance": "*", "counterName": "% Processor Time", "osType": "Windows" }, { "objectName": "Logical Disk", "instance": "*", "counterName": "Disk Read Bytes/sec", "osType": "Linux" }, { "objectName": "Logical Disk", "instance": "*", "counterName": "Disk Reads/sec", "osType": "Linux" }, { "objectName": "Logical Disk", "instance": "*", "counterName": "Disk Write Bytes/sec", "osType": "Linux" }, { "objectName": "Logical Disk", "instance": "*", "counterName": "Disk Writes/sec", "osType": "Linux" }, { "objectName": "Memory", "instance": "*", "counterName": "% Used Memory", "osType": "Linux" }, { "objectName": "Network", "instance": "*", "counterName": "Total Bytes", "osType": "Linux" }, { "objectName": "Processor", "instance": "*", "counterName": "% Processor Time", "osType": "Linux" } ] ================================================ FILE: queries/rbac-spns-keys-expiring.kql ================================================ let expiryInterval = 30d; let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); let AppsAndKeys = materialize (AADObjectsTable | where ObjectType_s in ('Application','ServicePrincipal') | where ObjectSubType_s != 'ManagedIdentity' | where Keys_s startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | mv-expand Keys | evaluate bag_unpack(Keys) | union ( AADObjectsTable | where ObjectType_s in ('Application','ServicePrincipal') | where ObjectSubType_s != 'ManagedIdentity' | where isnotempty(Keys_s) and Keys_s !startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | evaluate bag_unpack(Keys) ) ); let ExpirationInRisk = AppsAndKeys | where EndDate < now()+expiryInterval and EndDate > now() | project ApplicationId_g, KeyId, RiskDate = EndDate; let NotInRisk = AppsAndKeys | where EndDate > now()+expiryInterval | project ApplicationId_g, KeyId, ComfortDate = EndDate; let ApplicationsInRisk = ExpirationInRisk | join kind=leftouter ( NotInRisk ) on ApplicationId_g | where isempty(ComfortDate) | summarize ExpiresOn = max(RiskDate) by ApplicationId_g; let ServicePrincipals = materialize(AADObjectsTable | where isnotempty(ObjectId_g) | where ObjectType_s == 'ServicePrincipal' | project SPNId = ObjectId_g, ApplicationId_g, PrincipalNames_s, DisplayName_s); let GroupMemberships = AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where PrincipalNames_s startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s | mv-expand GroupMember | union ( AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s ) | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; let DirectAssignments = RBACAssignmentsTable | join kind=inner ( ServicePrincipals ) on $left.PrincipalId_g == $right.SPNId | project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g; let GroupAssignments = RBACAssignmentsTable | join kind=inner ( GroupMemberships | join kind=inner ( ServicePrincipals ) on $left.GroupMember == $right.SPNId ) on $left.PrincipalId_g == $right.GroupId | project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g; AppsAndKeys | join kind=inner (ApplicationsInRisk) on ApplicationId_g | summarize ExpiresOn = max(EndDate) by ApplicationId_g, DisplayName_s, Cloud_s, KeyType, TenantGuid_g | join kind=inner ( GroupAssignments | union DirectAssignments ) on ApplicationId_g | distinct DisplayName_s, ExpiresOn, KeyType, RoleDefinition_s, Scope_s, Model_s, Cloud_s, TenantGuid_g | order by ExpiresOn asc ================================================ FILE: queries/rbac-spns-roles-aad-all.kql ================================================ let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); let AppsAndKeys = materialize (AADObjectsTable | where ObjectType_s in ('Application','ServicePrincipal') | where Keys_s startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | mv-expand Keys | evaluate bag_unpack(Keys) | union ( AADObjectsTable | where ObjectType_s in ('Application','ServicePrincipal') | where isnotempty(Keys_s) and Keys_s !startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | evaluate bag_unpack(Keys) ) ); let ServicePrincipals = materialize(AADObjectsTable | where isnotempty(ObjectId_g) | where ObjectType_s == 'ServicePrincipal' | join kind=inner ( AppsAndKeys ) on ApplicationId_g | project SPNId = ObjectId_g, PrincipalNames_s, DisplayName_s, KeyType, EndDate); let GroupMemberships = AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where PrincipalNames_s startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s | mv-expand GroupMember | union ( AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s ) | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; let DirectAssignments = RBACAssignmentsTable | where Model_s == 'AzureAD' | join kind=inner ( ServicePrincipals ) on $left.PrincipalId_g == $right.SPNId | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', KeyType, EndDate, Model_s, TenantGuid_g; let GroupAssignments = RBACAssignmentsTable | where Model_s == 'AzureAD' | join kind=inner ( GroupMemberships | join kind=inner ( ServicePrincipals ) on $left.GroupMember == $right.SPNId ) on $left.PrincipalId_g == $right.GroupId | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), KeyType, EndDate, Model_s, TenantGuid_g; GroupAssignments | union DirectAssignments | distinct DisplayName_s, RoleDefinition_s, Model_s, Scope_s, Assignment, KeyType, EndDate, PrincipalNames_s, TenantGuid_g | where EndDate > now() | order by DisplayName_s asc ================================================ FILE: queries/rbac-spns-roles-arm-all.kql ================================================ let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); let AppsAndKeys = materialize (AADObjectsTable | where ObjectType_s in ('Application','ServicePrincipal') | where Keys_s startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | mv-expand Keys | evaluate bag_unpack(Keys) | union ( AADObjectsTable | where ObjectType_s in ('Application','ServicePrincipal') | where isnotempty(Keys_s) and Keys_s !startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | evaluate bag_unpack(Keys) ) ); let ServicePrincipals = materialize(AADObjectsTable | where isnotempty(ObjectId_g) | where ObjectType_s == 'ServicePrincipal' | join kind=inner ( AppsAndKeys ) on ApplicationId_g | project SPNId = ObjectId_g, PrincipalNames_s, DisplayName_s, KeyType, EndDate); let GroupMemberships = AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where PrincipalNames_s startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s | mv-expand GroupMember | union ( AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s ) | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; let DirectAssignments = RBACAssignmentsTable | where Model_s == 'AzureRM' | join kind=inner ( ServicePrincipals ) on $left.PrincipalId_g == $right.SPNId | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', KeyType, EndDate, Model_s, TenantGuid_g; let GroupAssignments = RBACAssignmentsTable | where Model_s == 'AzureRM' | join kind=inner ( GroupMemberships | join kind=inner ( ServicePrincipals ) on $left.GroupMember == $right.SPNId ) on $left.PrincipalId_g == $right.GroupId | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), KeyType, EndDate, Model_s, TenantGuid_g; GroupAssignments | union DirectAssignments | distinct DisplayName_s, RoleDefinition_s, Model_s, Scope_s, Assignment, KeyType, EndDate, PrincipalNames_s, TenantGuid_g | where EndDate > now() | order by DisplayName_s asc ================================================ FILE: queries/rbac-spns-roles-arm-privileged.kql ================================================ let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); let PrivilegedRoles = dynamic(['Owner','Contributor','Global Administrator', 'Privileged Role Administrator', 'User Access Administrator','Exchange Administrator']); let AppsAndKeys = materialize (AADObjectsTable | where ObjectType_s in ('Application','ServicePrincipal') | where Keys_s startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | mv-expand Keys | evaluate bag_unpack(Keys) | union ( AADObjectsTable | where ObjectType_s in ('Application','ServicePrincipal') | where isnotempty(Keys_s) and Keys_s !startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | evaluate bag_unpack(Keys) ) ); let ServicePrincipals = materialize(AADObjectsTable | where isnotempty(ObjectId_g) | where ObjectType_s == 'ServicePrincipal' | join kind=inner ( AppsAndKeys ) on ApplicationId_g | project SPNId = ObjectId_g, PrincipalNames_s, DisplayName_s, KeyType, EndDate); let GroupMemberships = AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where PrincipalNames_s startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s | mv-expand GroupMember | union ( AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s ) | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; let DirectAssignments = RBACAssignmentsTable | where Model_s == 'AzureRM' | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' | join kind=inner ( ServicePrincipals ) on $left.PrincipalId_g == $right.SPNId | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', KeyType, EndDate, Model_s, TenantGuid_g; let GroupAssignments = RBACAssignmentsTable | where Model_s == 'AzureRM' | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' | join kind=inner ( GroupMemberships | join kind=inner ( ServicePrincipals ) on $left.GroupMember == $right.SPNId ) on $left.PrincipalId_g == $right.GroupId | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), KeyType, EndDate, Model_s, TenantGuid_g; GroupAssignments | union DirectAssignments | distinct DisplayName_s, RoleDefinition_s, Model_s, Scope_s, Assignment, KeyType, EndDate, PrincipalNames_s, TenantGuid_g | where EndDate > now() | order by DisplayName_s asc ================================================ FILE: queries/rbac-users-roles-aad-all.kql ================================================ let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); let EnabledUsers = materialize(AADObjectsTable | where isnotempty(ObjectId_g) | where ObjectType_s == 'User' and SecurityEnabled_s == 'True' | project UserId = ObjectId_g, PrincipalNames_s, DisplayName_s); let GroupMemberships = AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where PrincipalNames_s startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s | mv-expand GroupMember | union ( AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s ) | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; let DirectUserAssignments = RBACAssignmentsTable | where Model_s == 'AzureAD' | join kind=inner ( EnabledUsers ) on $left.PrincipalId_g == $right.UserId | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g; let GroupUserAssignments = RBACAssignmentsTable | where Model_s == 'AzureAD' | join kind=inner ( GroupMemberships | join kind=inner ( EnabledUsers ) on $left.GroupMember == $right.UserId ) on $left.PrincipalId_g == $right.GroupId | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g; GroupUserAssignments | union DirectUserAssignments | distinct DisplayName_s, PrincipalNames_s, RoleDefinition_s, Model_s, Scope_s, Assignment, TenantGuid_g | order by PrincipalNames_s asc ================================================ FILE: queries/rbac-users-roles-arm-privileged.kql ================================================ let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); let PrivilegedRoles = dynamic(['Owner','Contributor']); let EnabledUsers = materialize(AADObjectsTable | where isnotempty(ObjectId_g) | where ObjectType_s == 'User' and SecurityEnabled_s == 'True' | project UserId = ObjectId_g, PrincipalNames_s, DisplayName_s); let GroupMemberships = AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where PrincipalNames_s startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s | mv-expand GroupMember | union ( AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s ) | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; let DirectUserAssignments = RBACAssignmentsTable | where Model_s == 'AzureRM' | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' | join kind=inner ( EnabledUsers ) on $left.PrincipalId_g == $right.UserId | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g; let GroupUserAssignments = RBACAssignmentsTable | where Model_s == 'AzureRM' | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' | join kind=inner ( GroupMemberships | join kind=inner ( EnabledUsers ) on $left.GroupMember == $right.UserId ) on $left.PrincipalId_g == $right.GroupId | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g; GroupUserAssignments | union DirectUserAssignments | distinct DisplayName_s, PrincipalNames_s, RoleDefinition_s, Model_s, Scope_s, Assignment, TenantGuid_g | order by PrincipalNames_s asc ================================================ FILE: queries/rbac-users-roles-guests-privileged.kql ================================================ let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); let PrivilegedRoles = dynamic(['Owner','Contributor','Global Administrator', 'Privileged Role Administrator', 'User Access Administrator','Exchange Administrator']); let EnabledGuestUsers = materialize(AADObjectsTable | where isnotempty(ObjectId_g) | where ObjectType_s == 'User' and ObjectSubType_s == 'Guest' and SecurityEnabled_s == 'True' | project UserId = ObjectId_g, PrincipalNames_s, DisplayName_s); let GroupMemberships = AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where PrincipalNames_s startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s | mv-expand GroupMember | union ( AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s ) | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; let DirectUserAssignments = RBACAssignmentsTable | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' | join kind=inner ( EnabledGuestUsers ) on $left.PrincipalId_g == $right.UserId | project DisplayName_s, PrincipalNames_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g; let GroupUserAssignments = RBACAssignmentsTable | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' | join kind=inner ( GroupMemberships | join kind=inner ( EnabledGuestUsers ) on $left.GroupMember == $right.UserId ) on $left.PrincipalId_g == $right.GroupId | project DisplayName_s, PrincipalNames_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g; GroupUserAssignments | union DirectUserAssignments | distinct DisplayName_s, PrincipalNames_s, RoleDefinition_s, Model_s, Scope_s, Assignment, TenantGuid_g | order by PrincipalNames_s asc ================================================ FILE: queries/rbac-users-roles-guests.kql ================================================ let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); let EnabledGuestUsers = materialize(AADObjectsTable | where isnotempty(ObjectId_g) | where ObjectType_s == 'User' and ObjectSubType_s == 'Guest' and SecurityEnabled_s == 'True' | project UserId = ObjectId_g, PrincipalNames_s, DisplayName_s); let GroupMemberships = AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where PrincipalNames_s startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s | mv-expand GroupMember | union ( AADObjectsTable | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' | extend GroupMember = parse_json(PrincipalNames_s) | project-away PrincipalNames_s ) | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; let DirectUserAssignments = RBACAssignmentsTable | join kind=inner ( EnabledGuestUsers ) on $left.PrincipalId_g == $right.UserId | project DisplayName_s, PrincipalNames_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g; let GroupUserAssignments = RBACAssignmentsTable | join kind=inner ( GroupMemberships | join kind=inner ( EnabledGuestUsers ) on $left.GroupMember == $right.UserId ) on $left.PrincipalId_g == $right.GroupId | project DisplayName_s, PrincipalNames_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g; GroupUserAssignments | union DirectUserAssignments | distinct DisplayName_s, PrincipalNames_s, RoleDefinition_s, Model_s, Scope_s, Assignment, TenantGuid_g | order by PrincipalNames_s asc ================================================ FILE: runbooks/data-collection/Export-AADObjectsToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName, [Parameter(Mandatory = $false)] [string] $groupFilter, [Parameter(Mandatory = $false)] [string] $userFilter ) $ErrorActionPreference = "Stop" function Build-CredObjectWithDates { param ( [object] $appObject ) $credObjects = @() foreach ($obj in $appObject.KeyCredentials) { $credObject = New-Object PSObject -Property @{ DisplayName = $obj.DisplayName KeyId = $obj.KeyId KeyType = $obj.Type StartDate = (Get-Date($obj.StartDateTime)).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:00.000Z") EndDate = (Get-Date($obj.EndDateTime)).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:00.000Z") } $credObjects += $credObject } foreach ($obj in $appObject.PasswordCredentials) { $credObject = New-Object PSObject -Property @{ DisplayName = $obj.DisplayName KeyId = $obj.KeyId KeyType = "Password" StartDate = (Get-Date($obj.StartDateTime)).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:00.000Z") EndDate = (Get-Date($obj.EndDateTime)).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:00.000Z") } $credObjects += $credObject } return $credObjects } function Build-PrincipalNames { param ( [object] $appObject ) $principalNames = @() if ($appObject.Web.HomePageUrl) { $principalNames += $appObject.Web.HomePageUrl } foreach ($obj in $appObject.IdentifierUris) { $principalNames += $obj } foreach ($obj in $appObject.ServicePrincipalNames) { $principalNames += $obj } foreach ($obj in $appObject.AlternativeNames) { $principalNames += $obj } return $principalNames } $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_AADObjectsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "aadobjectsexports" } # Application,ServicePrincipal,User,Group $aadObjectsFilter = Get-AutomationVariable -Name "AzureOptimization_AADObjectsFilter" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($aadObjectsFilter)) { $aadObjectsFilter = "Application,ServicePrincipal" } $groupFilterVariable = Get-AutomationVariable -Name "AzureOptimization_AADObjectsGroupFilter" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($groupFilter) -and -not([string]::IsNullOrEmpty($groupFilterVariable))) { $groupFilter = $groupFilterVariable } $userFilterVariable = Get-AutomationVariable -Name "AzureOptimization_AADObjectsUserFilter" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($userFilter) -and -not([string]::IsNullOrEmpty($userFilterVariable))) { $userFilter = $userFilterVariable } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id #workaround for https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/888 $localPath = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile) if (-not(get-item "$localPath\.graph\" -ErrorAction SilentlyContinue)) { New-Item -Type Directory "$localPath\.graph" } Import-Module Microsoft.Graph.Authentication Import-Module Microsoft.Graph.Users Import-Module Microsoft.Graph.Applications Import-Module Microsoft.Graph.Groups switch ($cloudEnvironment) { "AzureUSGovernment" { $graphEnvironment = "USGov" break } "AzureChinaCloud" { $graphEnvironment = "China" break } "AzureGermanCloud" { $graphEnvironment = "Germany" break } Default { $graphEnvironment = "Global" } } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Microsoft Graph with $externalCredentialName external credential..." Connect-MgGraph -TenantId $externalTenantId -ClientSecretCredential $externalCredential -Environment $graphEnvironment -NoWelcome } else { "Logging in to Microsoft Graph..." Connect-MgGraph -Identity -Environment $graphEnvironment -NoWelcome } $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $aadObjectsTypes = $aadObjectsFilter.Split(",") $fileDate = $datetime.ToString("yyyyMMdd") if ("Application" -in $aadObjectsTypes) { $aadObjects = @() "Getting AAD applications..." $apps = Get-MgApplication -All -ExpandProperty Owners -Property Id,AppId,CreatedDateTime,DeletedDateTime,DisplayName,KeyCredentials,PasswordCredentials,Owners,PublisherDomain,Web,IdentifierUris "Found $($apps.Count) AAD applications" foreach ($app in $apps) { $owners = $null if ($app.Owners.Count -gt 0) { $owners = ($app.Owners | Where-Object { [string]::IsNullOrEmpty($_.DeletedDateTime) }).Id | ConvertTo-Json -Compress } $createdDate = $null if ($app.CreatedDateTime) { $createdDate = (Get-Date($app.CreatedDateTime)).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:00.000Z") } $deletedDate = $null if ($app.DeletedDateTime) { $deletedDate = (Get-Date($app.DeletedDateTime)).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:00.000Z") } $aadObject = New-Object PSObject -Property @{ Timestamp = $timestamp TenantGuid = $tenantId Cloud = $cloudEnvironment ObjectId = $app.Id ObjectType = "Application" ObjectSubType = "N/A" DisplayName = $app.DisplayName SecurityEnabled = "N/A" ApplicationId = $app.AppId Keys = (Build-CredObjectWithDates -appObject $app) | ConvertTo-Json -Compress PrincipalNames = (Build-PrincipalNames -appObject $app) | ConvertTo-Json -Compress Owners = $owners CreatedDate = $createdDate DeletedDate = $deletedDate } $aadObjects += $aadObject } $jsonExportPath = "$fileDate-$tenantId-aadobjects-apps.json" $csvExportPath = "$fileDate-$tenantId-aadobjects-apps.csv" $aadObjects | ConvertTo-Json -Depth 3 -Compress | Out-File $jsonExportPath "Exported to JSON: $($aadObjects.Count) lines" $aadObjectsJson = Get-Content -Path $jsonExportPath | ConvertFrom-Json "JSON Import: $($aadObjectsJson.Count) lines" $aadObjectsJson | Export-Csv -NoTypeInformation -Path $csvExportPath "Export to $csvExportPath" $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $csvExportPath from local disk..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $jsonExportPath from local disk..." } if ("ServicePrincipal" -in $aadObjectsTypes) { $aadObjects = @() "Getting AAD service principals..." $spns = Get-MgServicePrincipal -All -ExpandProperty Owners -Property Id,AppId,DeletedDateTime,DisplayName,KeyCredentials,PasswordCredentials,Owners,ServicePrincipalNames,ServicePrincipalType,AccountEnabled,AlternativeNames "Found $($spns.Count) AAD service principals" foreach ($spn in $spns) { $owners = $null if ($spn.Owners.Count -gt 0) { $owners = ($spn.Owners | Where-Object { [string]::IsNullOrEmpty($_.DeletedDateTime) }).Id | ConvertTo-Json -Compress } $deletedDate = $null if ($spn.DeletedDateTime) { $deletedDate = (Get-Date($spn.DeletedDateTime)).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:00.000Z") } $aadObject = New-Object PSObject -Property @{ Timestamp = $timestamp TenantGuid = $tenantId Cloud = $cloudEnvironment ObjectId = $spn.Id ObjectType = "ServicePrincipal" ObjectSubType = $spn.ServicePrincipalType DisplayName = $spn.DisplayName SecurityEnabled = $spn.AccountEnabled ApplicationId = $spn.AppId Keys = (Build-CredObjectWithDates -appObject $spn) | ConvertTo-Json -Compress PrincipalNames = (Build-PrincipalNames -appObject $spn) | ConvertTo-Json -Compress Owners = $owners DeletedDate = $deletedDate } $aadObjects += $aadObject } $jsonExportPath = "$fileDate-$tenantId-aadobjects-spns.json" $csvExportPath = "$fileDate-$tenantId-aadobjects-spns.csv" $aadObjects | ConvertTo-Json -Depth 3 -Compress | Out-File $jsonExportPath "Exported to JSON: $($aadObjects.Count) lines" $aadObjectsJson = Get-Content -Path $jsonExportPath | ConvertFrom-Json "JSON Import: $($aadObjectsJson.Count) lines" $aadObjectsJson | Export-Csv -NoTypeInformation -Path $csvExportPath "Export to $csvExportPath" $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $csvExportPath from local disk..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $jsonExportPath from local disk..." } if ("User" -in $aadObjectsTypes) { $aadObjects = @() if ([string]::IsNullOrEmpty($userFilter)) { "Getting AAD users..." $users = Get-MgUser -All -Property Id,AccountEnabled,DisplayName,UserPrincipalName,UserType,CreatedDateTime,DeletedDateTime } else { "Getting AAD users with filter $userFilter..." $users = Get-MgUser -Filter $userFilter -All -Property Id,AccountEnabled,DisplayName,UserPrincipalName,UserType,CreatedDateTime,DeletedDateTime } "Found $($users.Count) AAD users" foreach ($user in $users) { $createdDate = $null if ($user.CreatedDateTime) { $createdDate = (Get-Date($user.CreatedDateTime)).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:00.000Z") } $deletedDate = $null if ($user.DeletedDateTime) { $deletedDate = (Get-Date($user.DeletedDateTime)).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:00.000Z") } $aadObject = New-Object PSObject -Property @{ Timestamp = $timestamp TenantGuid = $tenantId Cloud = $cloudEnvironment ObjectId = $user.Id ObjectType = "User" ObjectSubType = $user.UserType DisplayName = $user.DisplayName SecurityEnabled = $user.AccountEnabled PrincipalNames = $user.UserPrincipalName CreatedDate = $createdDate DeletedDate = $deletedDate } $aadObjects += $aadObject } $jsonExportPath = "$fileDate-$tenantId-aadobjects-users.json" $csvExportPath = "$fileDate-$tenantId-aadobjects-users.csv" $aadObjects | Export-Csv -NoTypeInformation -Path $csvExportPath "Export to $csvExportPath" $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $csvExportPath from local disk..." } if ("Group" -in $aadObjectsTypes) { $aadObjects = @() if ([string]::IsNullOrEmpty($groupFilter)) { "Getting AAD groups..." $groups = Get-MgGroup -All -ExpandProperty Members -Property Id,SecurityEnabled,DisplayName,Members,CreatedDateTime,DeletedDateTime,GroupTypes } else { "Getting AAD groups with filter $groupFilter..." $groups = Get-MgGroup -Filter $groupFilter -All -ExpandProperty Members -Property Id,SecurityEnabled,DisplayName,Members,CreatedDateTime,DeletedDateTime,GroupTypes } "Found $($groups.Count) AAD groups" foreach ($group in $groups) { $groupMembers = $null if ($group.Members.Count -gt 0) { $groupMembers = $group.Members.Id | ConvertTo-Json -Compress } $createdDate = $null if ($group.CreatedDateTime) { $createdDate = (Get-Date($group.CreatedDateTime)).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:00.000Z") } $deletedDate = $null if ($group.DeletedDateTime) { $deletedDate = (Get-Date($group.DeletedDateTime)).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:00.000Z") } $aadObject = New-Object PSObject -Property @{ Timestamp = $timestamp TenantGuid = $tenantId Cloud = $cloudEnvironment ObjectId = $group.Id ObjectType = "Group" ObjectSubType = $group.GroupTypes | ConvertTo-Json -Compress DisplayName = $group.DisplayName SecurityEnabled = $group.SecurityEnabled PrincipalNames = $groupMembers CreatedDate = $createdDate DeletedDate = $deletedDate } $aadObjects += $aadObject } $jsonExportPath = "$fileDate-$tenantId-aadobjects-groups.json" $csvExportPath = "$fileDate-$tenantId-aadobjects-groups.csv" $aadObjects | ConvertTo-Json -Depth 3 -Compress | Out-File $jsonExportPath "Exported to JSON: $($aadObjects.Count) lines" $aadObjectsJson = Get-Content -Path $jsonExportPath | ConvertFrom-Json "JSON Import: $($aadObjectsJson.Count) lines" $aadObjectsJson | Export-Csv -NoTypeInformation -Path $csvExportPath "Export to $csvExportPath" $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $csvExportPath from local disk..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $jsonExportPath from local disk..." } "DONE!" ================================================ FILE: runbooks/data-collection/Export-ARGAppGatewayPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGAppGatewayContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argappgwexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allAppGWs = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $appGWsTotal = @() $resultsSoFar = 0 Write-Output "Querying for Application Gateways properties" $argQuery = @" resources | where type =~ 'Microsoft.Network/applicationGateways' | extend gatewayIPsCount = array_length(properties.gatewayIPConfigurations) | extend frontendIPsCount = array_length(properties.frontendIPConfigurations) | extend frontendPortsCount = array_length(properties.frontendPorts) | extend backendPoolsCount = array_length(properties.backendAddressPools) | extend httpSettingsCount = array_length(properties.backendHttpSettingsCollection) | extend httpListenersCount = array_length(properties.httpListeners) | extend urlPathMapsCount = array_length(properties.urlPathMaps) | extend requestRoutingRulesCount = array_length(properties.requestRoutingRules) | extend probesCount = array_length(properties.probes) | extend rewriteRulesCount = array_length(properties.rewriteRuleSets) | extend redirectConfsCount = array_length(properties.redirectConfigurations) | project id, name, resourceGroup, subscriptionId, tenantId, location, zones, skuName = properties.sku.name, skuTier = properties.sku.tier, skuCapacity = properties.sku.capacity, enableHttp2 = properties.enableHttp2, gatewayIPsCount, frontendIPsCount, frontendPortsCount, httpSettingsCount, httpListenersCount, backendPoolsCount, urlPathMapsCount, requestRoutingRulesCount, probesCount, rewriteRulesCount, redirectConfsCount, tags | join kind=leftouter ( resources | where type =~ 'Microsoft.Network/applicationGateways' | mvexpand backendPools = properties.backendAddressPools | extend backendIPCount = array_length(backendPools.properties.backendIPConfigurations) | extend backendAddressesCount = array_length(backendPools.properties.backendAddresses) | summarize backendIPCount = sum(backendIPCount), backendAddressesCount = sum(backendAddressesCount) by id ) on id | project-away id1 | order by id asc "@ do { if ($resultsSoFar -eq 0) { $appGWs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $appGWs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($appGWs -and $appGWs.GetType().Name -eq "PSResourceGraphResponse") { $appGWs = $appGWs.Data } $resultsCount = $appGWs.Count $resultsSoFar += $resultsCount $appGWsTotal += $appGWs } while ($resultsCount -eq $ARGPageSize) Write-Output "Found $($appGWsTotal.Count) Application Gateway entries" <# Building CSV entries #> $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") foreach ($appGW in $appGWsTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $appGW.tenantId SubscriptionGuid = $appGW.subscriptionId ResourceGroupName = $appGW.resourceGroup.ToLower() InstanceName = $appGW.name.ToLower() InstanceId = $appGW.id.ToLower() SkuName = $appGW.skuName SkuTier = $appGW.skuTier SkuCapacity = $appGW.skuCapacity Location = $appGW.location Zones = $appGW.zones EnableHttp2 = $appGW.enableHttp2 GatewayIPsCount = $appGW.gatewayIPsCount FrontendIPsCount = $appGW.frontendIPsCount FrontendPortsCount = $appGW.frontendPortsCount BackendIPCount = $appGW.backendIPCount BackendAddressesCount = $appGW.backendAddressesCount HttpSettingsCount = $appGW.httpSettingsCount HttpListenersCount = $appGW.httpListenersCount BackendPoolsCount = $appGW.backendPoolsCount ProbesCount = $appGW.probesCount UrlPathMapsCount = $appGW.urlPathMapsCount RequestRoutingRulesCount = $appGW.requestRoutingRulesCount RewriteRulesCount = $appGW.rewriteRulesCount RedirectConfsCount = $appGW.redirectConfsCount StatusDate = $statusDate Tags = $appGW.tags } $allAppGWs += $logentry } <# Actually exporting CSV to Azure Storage #> $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-appgws-$subscriptionSuffix.csv" $allAppGWs | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGAppServicePlanPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope if ([string]::IsNullOrEmpty($referenceRegion)) { $referenceRegion = "westeurope" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGAppServicePlanContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argappserviceplanexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allasp = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $aspTotal = @() $resultsSoFar = 0 Write-Output "Querying for App Service Plan properties" $argQuery = @" resources | where type =~ 'microsoft.web/serverfarms' | extend skuName = sku.name, skuTier = sku.tier, skuCapacity = sku.capacity, skuFamily = sku.family, skuSize = sku.size | extend computeMode = properties.computeMode, zoneRedundant = properties.zoneRedundant | extend numberOfWorkers = properties.numberOfWorkers, currentNumberOfWorkers = properties.currentNumberOfWorkers, maximumNumberOfWorkers = properties.maximumNumberOfWorkers | extend numberOfSites = properties.numberOfSites, planName = properties.planName | order by id asc "@ do { if ($resultsSoFar -eq 0) { $asp = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $asp = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($asp -and $asp.GetType().Name -eq "PSResourceGraphResponse") { $asp = $asp.Data } $resultsCount = $asp.Count $resultsSoFar += $resultsCount $aspTotal += $asp } while ($resultsCount -eq $ARGPageSize) $datetime = (Get-Date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") Write-Output "Building $($aspTotal.Count) App Service Plan entries" foreach ($asplan in $aspTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $asplan.tenantId SubscriptionGuid = $asplan.subscriptionId ResourceGroupName = $asplan.resourceGroup.ToLower() ZoneRedundant = $asplan.zoneRedundant Location = $asplan.location AppServicePlanName = $asplan.name.ToLower() InstanceId = $asplan.id.ToLower() Kind = $asplan.kind SkuName = $asplan.skuName SkuTier = $asplan.skuTier SkuCapacity = $asplan.skuCapacity SkuFamily = $asplan.skuFamily SkuSize = $asplan.skuSize ComputeMode = $asplan.computeMode NumberOfWorkers = $asplan.numberOfWorkers CurrentNumberOfWorkers = $asplan.currentNumberOfWorkers MaximumNumberOfWorkers = $asplan.maximumNumberOfWorkers NumberOfSites = $asplan.numberOfSites PlanName = $asplan.planName Tags = $asplan.tags StatusDate = $statusDate } $allasp += $logentry } Write-Output "Uploading CSV to Storage" $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-asp-$subscriptionSuffix.csv" $allasp | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGAvailabilitySetPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGAvailabilitySetContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argavailsetexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allAvSets = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $avSetsTotal = @() $resultsSoFar = 0 Write-Output "Querying for Availability Set properties" $argQuery = @" resources | where type =~ 'Microsoft.Compute/availabilitySets' | project id, name, location, resourceGroup, subscriptionId, tenantId, skuName = tostring(sku.name), faultDomains = tostring(properties.platformFaultDomainCount), updateDomains = tostring(properties.platformUpdateDomainCount), vmCount = array_length(properties.virtualMachines), tags, zones | order by id asc "@ do { if ($resultsSoFar -eq 0) { $avSets = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $avSets = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($avSets -and $avSets.GetType().Name -eq "PSResourceGraphResponse") { $avSets = $avSets.Data } $resultsCount = $avSets.Count $resultsSoFar += $resultsCount $avSetsTotal += $avSets } while ($resultsCount -eq $ARGPageSize) Write-Output "Found $($avSetsTotal.Count) Availability Set entries" <# Building CSV entries #> $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") foreach ($avSet in $avSetsTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $avSet.tenantId SubscriptionGuid = $avSet.subscriptionId ResourceGroupName = $avSet.resourceGroup.ToLower() InstanceName = $avSet.name.ToLower() InstanceId = $avSet.id.ToLower() SkuName = $avSet.skuName Location = $avSet.location FaultDomains = $avSet.faultDomains UpdateDomains = $avSet.updateDomains VmCount = $avSet.vmCount StatusDate = $statusDate Tags = $avSet.tags Zones = $avSet.zones } $allAvSets += $logentry } <# Actually exporting CSV to Azure Storage #> $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-availsets-$subscriptionSuffix.csv" $allAvSets | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGLoadBalancerPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGLoadBalancerContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "arglbexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allLBs = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $LBsTotal = @() $resultsSoFar = 0 Write-Output "Querying for Load Balancer properties" $argQuery = @" resources | where type =~ 'Microsoft.Network/loadBalancers' | extend lbType = iif(properties.frontendIPConfigurations contains 'publicIPAddress', 'Public', iif(properties.frontendIPConfigurations contains 'privateIPAddress', 'Internal', 'Unknown')) | extend lbRulesCount = array_length(properties.loadBalancingRules) | extend frontendIPsCount = array_length(properties.frontendIPConfigurations) | extend inboundNatRulesCount = array_length(properties.inboundNatRules) | extend outboundRulesCount = array_length(properties.outboundRules) | extend inboundNatPoolsCount = array_length(properties.inboundNatPools) | extend backendPoolsCount = array_length(properties.backendAddressPools) | extend probesCount = array_length(properties.probes) | project id, name, resourceGroup, subscriptionId, tenantId, location, skuName = sku.name, skuTier = sku.tier, lbType, lbRulesCount, frontendIPsCount, inboundNatRulesCount, outboundRulesCount, inboundNatPoolsCount, backendPoolsCount, probesCount, tags | join kind=leftouter ( resources | where type =~ 'Microsoft.Network/loadBalancers' | mvexpand backendPools = properties.backendAddressPools | extend backendIPCount = array_length(backendPools.properties.backendIPConfigurations) | extend backendAddressesCount = array_length(backendPools.properties.loadBalancerBackendAddresses) | summarize backendIPCount = sum(backendIPCount), backendAddressesCount = sum(backendAddressesCount) by id ) on id | project-away id1 | order by id asc "@ do { if ($resultsSoFar -eq 0) { $LBs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $LBs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($LBs -and $LBs.GetType().Name -eq "PSResourceGraphResponse") { $LBs = $LBs.Data } $resultsCount = $LBs.Count $resultsSoFar += $resultsCount $LBsTotal += $LBs } while ($resultsCount -eq $ARGPageSize) Write-Output "Found $($LBsTotal.Count) Load Balancer entries" <# Building CSV entries #> $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") foreach ($lb in $LBsTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $lb.tenantId SubscriptionGuid = $lb.subscriptionId ResourceGroupName = $lb.resourceGroup.ToLower() InstanceName = $lb.name.ToLower() InstanceId = $lb.id.ToLower() SkuName = $lb.skuName SkuTier = $lb.skuTier Location = $lb.location LbType = $lb.lbType LbRulesCount = $lb.lbRulesCount InboundNatRulesCount = $lb.inboundNatRulesCount OutboundRulesCount = $lb.outboundRulesCount FrontendIPsCount = $lb.frontendIPsCount BackendIPCount = $lb.backendIPCount BackendAddressesCount = $lb.backendAddressesCount InboundNatPoolsCount = $lb.inboundNatPoolsCount BackendPoolsCount = $lb.backendPoolsCount ProbesCount = $lb.probesCount StatusDate = $statusDate Tags = $lb.tags } $allLBs += $logentry } <# Actually exporting CSV to Azure Storage #> $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-lbs-$subscriptionSuffix.csv" $allLBs | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGManagedDisksPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGDiskContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argdiskexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $alldisks = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $mdisksTotal = @() $resultsSoFar = 0 <# Getting all Managed Disks properties with Azure Resource Graph query #> Write-Output "Querying for ARM Managed Disks properties" $argQuery = @" resources | where type =~ 'Microsoft.Compute/disks' | extend DiskId = tolower(id), OwnerVmId = tolower(managedBy) | join kind=leftouter ( resources | where type =~ 'Microsoft.Compute/virtualMachines' and array_length(properties.storageProfile.dataDisks) > 0 | extend OwnerVmId = tolower(id) | mv-expand DataDisks = properties.storageProfile.dataDisks | extend DiskId = tolower(DataDisks.managedDisk.id), diskCaching = tostring(DataDisks.caching), diskType = 'Data' | project DiskId, OwnerVmId, diskCaching, diskType | union ( resources | where type =~ 'Microsoft.Compute/virtualMachines' | extend OwnerVmId = tolower(id) | extend DiskId = tolower(properties.storageProfile.osDisk.managedDisk.id), diskCaching = tostring(properties.storageProfile.osDisk.caching), diskType = 'OS' | project DiskId, OwnerVmId, diskCaching, diskType ) ) on OwnerVmId, DiskId | project-away OwnerVmId, DiskId, OwnerVmId1, DiskId1 | order by id asc "@ do { if ($resultsSoFar -eq 0) { $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($mdisks -and $mdisks.GetType().Name -eq "PSResourceGraphResponse") { $mdisks = $mdisks.Data } $resultsCount = $mdisks.Count $resultsSoFar += $resultsCount $mdisksTotal += $mdisks } while ($resultsCount -eq $ARGPageSize) Write-Output "Found $($mdisksTotal.Count) Managed Disk entries" <# Building CSV entries #> $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") foreach ($disk in $mdisksTotal) { $ownerVmId = $null if ($null -ne $disk.managedBy) { $ownerVmId = $disk.managedBy.ToLower() } $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $disk.tenantId SubscriptionGuid = $disk.subscriptionId ResourceGroupName = $disk.resourceGroup.ToLower() DiskName = $disk.name.ToLower() InstanceId = $disk.id.ToLower() Location = $disk.location OwnerVMId = $ownerVmId DeploymentModel = "Managed" DiskType = $disk.diskType TimeCreated = $disk.properties.timeCreated DiskIOPS = $disk.properties.diskIOPSReadWrite DiskThroughput = $disk.properties.diskMBpsReadWrite DiskTier = $disk.properties.tier DiskState = $disk.properties.diskState EncryptionType = $disk.properties.encryption.type Zones = $disk.zones Caching = $disk.diskCaching DiskSizeGB = $disk.properties.diskSizeGB SKU = $disk.sku.name StatusDate = $statusDate Tags = $disk.tags } $alldisks += $logentry } <# Actually exporting CSV to Azure Storage #> $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-disks-$subscriptionSuffix.csv" $alldisks | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGNICPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope if ([string]::IsNullOrEmpty($referenceRegion)) { $referenceRegion = "westeurope" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGNICContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argnicexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allnics = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $nicsTotal = @() $resultsSoFar = 0 Write-Output "Querying for NIC properties" $argQuery = @" resources | where type =~ 'microsoft.network/networkinterfaces' | extend isPrimary = properties.primary | extend enableAcceleratedNetworking = properties.enableAcceleratedNetworking | extend enableIPForwarding = properties.enableIPForwarding | extend tapConfigurationsCount = array_length(properties.tapConfigurations) | extend hostedWorkloadsCount = array_length(properties.hostedWorkloads) | extend internalDomainNameSuffix = properties.dnsSettings.internalDomainNameSuffix | extend appliedDnsServers = properties.dnsSettings.appliedDnsServers | extend dnsServers = properties.dnsSettings.dnsServers | extend ownerVMId = tolower(properties.virtualMachine.id) | extend ownerPEId = tolower(properties.privateEndpoint.id) | extend macAddress = properties.macAddress | extend nicType = properties.nicType | extend nicNsgId = tolower(properties.networkSecurityGroup.id) | mv-expand ipconfigs = properties.ipConfigurations | project-away properties | extend privateIPAddressVersion = tostring(ipconfigs.properties.privateIPAddressVersion) | extend privateIPAllocationMethod = tostring(ipconfigs.properties.privateIPAllocationMethod) | extend isIPConfigPrimary = tostring(ipconfigs.properties.primary) | extend privateIPAddress = tostring(ipconfigs.properties.privateIPAddress) | extend publicIPId = tolower(ipconfigs.properties.publicIPAddress.id) | extend IPConfigName = tostring(ipconfigs.name) | extend subnetId = tolower(ipconfigs.properties.subnet.id) | project-away ipconfigs | order by id asc "@ do { if ($resultsSoFar -eq 0) { $nics = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $nics = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($nics -and $nics.GetType().Name -eq "PSResourceGraphResponse") { $nics = $nics.Data } $resultsCount = $nics.Count $resultsSoFar += $resultsCount $nicsTotal += $nics } while ($resultsCount -eq $ARGPageSize) $datetime = (Get-Date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") Write-Output "Building $($nicsTotal.Count) ARM VNet nic entries" foreach ($nic in $nicsTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $nic.tenantId SubscriptionGuid = $nic.subscriptionId ResourceGroupName = $nic.resourceGroup.ToLower() Location = $nic.location Name = $nic.name.ToLower() InstanceId = $nic.id.ToLower() IsPrimary = $nic.isPrimary EnableAcceleratedNetworking = $nic.enableAcceleratedNetworking EnableIPForwarding = $nic.enableIPForwarding TapConfigurationsCount = $nic.tapConfigurationsCount HostedWorkloadsCount = $nic.hostedWorkloadsCount InternalDomainNameSuffix = $nic.internalDomainNameSuffix AppliedDnsServers = $nic.appliedDnsServers DnsServers = $nic.dnsServers OwnerVMId = $nic.ownerVMId OwnerPEId = $nic.ownerPEId MacAddress = $nic.macAddress NicType = $nic.nicType NicNSGId = $nic.nicNsgId PrivateIPAddressVersion = $nic.privateIPAddressVersion PrivateIPAllocationMethod = $nic.privateIPAllocationMethod IsIPConfigPrimary = $nic.isIPConfigPrimary PrivateIPAddress = $nic.privateIPAddress PublicIPId = $nic.publicIPId IPConfigName = $nic.IPConfigName SubnetId = $nic.subnetId Tags = $nic.tags StatusDate = $statusDate } $allnics += $logentry } Write-Output "Uploading CSV to Storage" $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-nics-$subscriptionSuffix.csv" $allnics | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGNSGPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope if ([string]::IsNullOrEmpty($referenceRegion)) { $referenceRegion = "westeurope" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGNSGContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argnsgexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allnsgRules = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $nsgRulesTotal = @() $resultsSoFar = 0 Write-Output "Querying for NSG properties" $argQuery = @" resources | where type =~ 'Microsoft.Network/networkSecurityGroups' | extend nicCount = iif(isnotempty(properties.networkInterfaces),array_length(properties.networkInterfaces),0) | extend subnetCount = iif(isnotempty(properties.subnets),array_length(properties.subnets),0) | mvexpand securityRules = properties.securityRules | extend ruleName = tolower(securityRules.name) | extend ruleProtocol = tolower(securityRules.properties.protocol) | extend ruleDirection = tolower(securityRules.properties.direction) | extend rulePriority = toint(securityRules.properties.priority) | extend ruleAccess = tolower(securityRules.properties.access) | extend ruleDestinationAddresses = tolower(iif(array_length(securityRules.properties.destinationAddressPrefixes) > 0,strcat_array(securityRules.properties.destinationAddressPrefixes, ','),securityRules.properties.destinationAddressPrefix)) | extend ruleSourceAddresses = tolower(iif(array_length(securityRules.properties.sourceAddressPrefixes) > 0,strcat_array(securityRules.properties.sourceAddressPrefixes, ','),securityRules.properties.sourceAddressPrefix)) | extend ruleDestinationPorts = iif(array_length(securityRules.properties.destinationPortRanges) > 0,strcat_array(securityRules.properties.destinationPortRanges, ','),securityRules.properties.destinationPortRange) | extend ruleSourcePorts = iif(array_length(securityRules.properties.sourcePortRanges) > 0,strcat_array(securityRules.properties.sourcePortRanges, ','),securityRules.properties.sourcePortRange) | extend ruleId = tolower(securityRules.id) | project-away securityRules, properties | order by ruleId asc "@ do { if ($resultsSoFar -eq 0) { $nsgRules = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $nsgRules = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($nsgRules -and $nsgRules.GetType().Name -eq "PSResourceGraphResponse") { $nsgRules = $nsgRules.Data } $resultsCount = $nsgRules.Count $resultsSoFar += $resultsCount $nsgRulesTotal += $nsgRules } while ($resultsCount -eq $ARGPageSize) $datetime = (Get-Date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") Write-Output "Building $($nsgRulesTotal.Count) ARM NSG entries" foreach ($nsgRule in $nsgRulesTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $nsgRule.tenantId SubscriptionGuid = $nsgRule.subscriptionId ResourceGroupName = $nsgRule.resourceGroup.ToLower() Location = $nsgRule.location NSGName = $nsgRule.name.ToLower() InstanceId = $nsgRule.id.ToLower() NicCount = $nsgRule.nicCount SubnetCount = $nsgRule.subnetCount RuleName = $nsgRule.ruleName RuleProtocol = $nsgRule.ruleProtocol RuleDirection = $nsgRule.ruleDirection RulePriority = $nsgRule.rulePriority RuleAccess = $nsgRule.ruleAccess RuleDestinationAddresses = $nsgRule.ruleDestinationAddresses RuleSourceAddresses = $nsgRule.ruleSourceAddresses RuleDestinationPorts = $nsgRule.ruleDestinationPorts RuleSourcePorts = $nsgRule.ruleSourcePorts Tags = $nsgRule.tags StatusDate = $statusDate } $allnsgRules += $logentry } Write-Output "Uploading CSV to Storage" $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-nsgrules-$subscriptionSuffix.csv" $allnsgRules | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGPublicIpPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope if ([string]::IsNullOrEmpty($referenceRegion)) { $referenceRegion = "westeurope" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGPublicIpContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argpublicipexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allpips = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $pipsTotal = @() $resultsSoFar = 0 Write-Output "Querying for ARM Public IP properties" $argQuery = @" resources | where type =~ 'microsoft.network/publicipaddresses' | extend skuName = tolower(sku.name) | extend skuTier = tolower(sku.tier) | extend allocationMethod = tolower(properties.publicIPAllocationMethod) | extend addressVersion = tolower(properties.publicIPAddressVersion) | extend associatedResourceId = iif(isnotempty(properties.ipConfiguration.id),tolower(properties.ipConfiguration.id),tolower(properties.natGateway.id)) | extend ipAddress = tostring(properties.ipAddress) | extend fqdn = tolower(properties.dnsSettings.fqdn) | extend publicIpPrefixId = tostring(properties.publicIPPrefix.id) | order by id asc "@ do { if ($resultsSoFar -eq 0) { $pips = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $pips = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($pips -and $pips.GetType().Name -eq "PSResourceGraphResponse") { $pips = $pips.Data } $resultsCount = $pips.Count $resultsSoFar += $resultsCount $pipsTotal += $pips } while ($resultsCount -eq $ARGPageSize) $datetime = (Get-Date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") Write-Output "Building $($pipsTotal.Count) ARM Public IP entries" foreach ($pip in $pipsTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $pip.tenantId SubscriptionGuid = $pip.subscriptionId ResourceGroupName = $pip.resourceGroup.ToLower() Location = $pip.location Name = $pip.name.ToLower() InstanceId = $pip.id.ToLower() Model = "ARM" SkuName = $pip.skuName SkuTier = $pip.skuTier AllocationMethod = $pip.allocationMethod AddressVersion = $pip.addressVersion AssociatedResourceId = $pip.associatedResourceId PublicIpPrefixId = $pip.publicIpPrefixId IPAddress = $pip.ipAddress FQDN = $pip.fqdn Zones = $pip.zones Tags = $pip.tags StatusDate = $statusDate } $allpips += $logentry } $pipsTotal = @() $resultsSoFar = 0 Write-Output "Querying for Classic Reserved IP properties" $argQuery = @" resources | where type =~ 'microsoft.classicnetwork/reservedips' | extend ipAddress = tostring(properties.ipAddress) | extend allocationMethod = 'static' | extend addressVersion = 'ipv4' | extend associatedResourceId = tolower(properties.attachedTo.id) | extend ipAddress = tostring(properties.ipAddress) | order by id asc "@ do { if ($resultsSoFar -eq 0) { $pips = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $pips = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($pips -and $pips.GetType().Name -eq "PSResourceGraphResponse") { $pips = $pips.Data } $resultsCount = $pips.Count $resultsSoFar += $resultsCount $pipsTotal += $pips } while ($resultsCount -eq $ARGPageSize) $datetime = (Get-Date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") Write-Output "Building $($pipsTotal.Count) Classic Reserved IP entries" foreach ($pip in $pipsTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $pip.tenantId SubscriptionGuid = $pip.subscriptionId ResourceGroupName = $pip.resourceGroup.ToLower() Location = $pip.location Name = $pip.name.ToLower() InstanceId = $pip.id.ToLower() Model = "Classic" AllocationMethod = $pip.allocationMethod AddressVersion = $pip.addressVersion AssociatedResourceId = $pip.associatedResourceId IPAddress = $pip.ipAddress StatusDate = $statusDate } $allpips += $logentry } Write-Output "Uploading CSV to Storage" $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-publicips-$subscriptionSuffix.csv" $allpips | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGResourceContainersPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope if ([string]::IsNullOrEmpty($referenceRegion)) { $referenceRegion = "westeurope" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGResourceContainersContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argrescontainersexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allResourceContainers = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $rgsTotal = @() $subsTotal = @() $resultsSoFar = 0 Write-Output "Querying for resource groups..." $argQuery = @" resourcecontainers | where type == "microsoft.resources/subscriptions/resourcegroups" | join kind=leftouter ( resources | summarize ResourceCount= count() by subscriptionId, resourceGroup ) on subscriptionId, resourceGroup | extend ResourceCount = iif(isempty(ResourceCount), 0, ResourceCount) | project id, name, type, tenantId, location, subscriptionId, managedBy, tags, properties, ResourceCount | order by id asc "@ do { if ($resultsSoFar -eq 0) { $rgs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $rgs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($rgs -and $rgs.GetType().Name -eq "PSResourceGraphResponse") { $rgs = $rgs.Data } $resultsCount = $rgs.Count $resultsSoFar += $resultsCount $rgsTotal += $rgs } while ($resultsCount -eq $ARGPageSize) $resultsSoFar = 0 Write-Output "Querying for subscriptions" $argQuery = @" resourcecontainers | where type == "microsoft.resources/subscriptions" | join kind=leftouter ( resources | summarize ResourceCount= count() by subscriptionId ) on subscriptionId | extend ResourceCount = iif(isempty(ResourceCount), 0, ResourceCount) | project id, name, type, tenantId, subscriptionId, managedBy, tags, properties, ResourceCount | order by id asc "@ do { if ($resultsSoFar -eq 0) { $subs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $subs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($subs -and $subs.GetType().Name -eq "PSResourceGraphResponse") { $subs = $subs.Data } $resultsCount = $subs.Count $resultsSoFar += $resultsCount $subsTotal += $subs } while ($resultsCount -eq $ARGPageSize) $datetime = (Get-Date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") Write-Output "Building $($rgsTotal.Count) RG entries" foreach ($rg in $rgsTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $rg.tenantId SubscriptionGuid = $rg.subscriptionId Location = $rg.location ContainerType = $rg.type ContainerName = $rg.name.ToLower() InstanceId = $rg.id.ToLower() ResourceCount = $rg.ResourceCount ManagedBy = $rg.managedBy ContainerProperties = $rg.properties | ConvertTo-Json -Compress Tags = $rg.tags StatusDate = $statusDate } $allResourceContainers += $logentry } Write-Output "Building $($subsTotal.Count) subscription entries" foreach ($sub in $subsTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $sub.tenantId SubscriptionGuid = $sub.subscriptionId Location = $sub.location ContainerType = $sub.type ContainerName = $sub.name.ToLower() InstanceId = $sub.id.ToLower() ResourceCount = $sub.ResourceCount ManagedBy = $sub.managedBy ContainerProperties = $sub.properties | ConvertTo-Json -Compress Tags = $sub.tags StatusDate = $statusDate } $allResourceContainers += $logentry } Write-Output "Uploading CSV to Storage" $today = $datetime.ToString("yyyyMMdd") $jsonExportPath = "$today-rescontainers-$subscriptionSuffix.json" $csvExportPath = "$today-rescontainers-$subscriptionSuffix.csv" $allResourceContainers | ConvertTo-Json -Depth 3 -Compress | Out-File $jsonExportPath Write-Output "Exported to JSON: $($allResourceContainers.Count) lines" $allResourceContainersJson = Get-Content -Path $jsonExportPath | ConvertFrom-Json Write-Output "JSON Import: $($allResourceContainersJson.Count) lines" $allResourceContainersJson | Export-Csv -NoTypeInformation -Path $csvExportPath Write-Output "Export to $csvExportPath" $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGSqlDatabasePropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope if ([string]::IsNullOrEmpty($referenceRegion)) { $referenceRegion = "westeurope" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGSqlDatabaseContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argsqldbexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $alldbs = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $dbsTotal = @() $resultsSoFar = 0 Write-Output "Querying for SQL Databases properties" $argQuery = @" resources | where type =~ 'microsoft.sql/servers/databases' and name != 'master' | extend skuName = sku.name, skuTier = sku.tier, skuCapacity = sku.capacity | extend storageAccountType = properties.storageAccountType, licenseType = properties.licenseType, serviceObjectiveName = properties.currentServiceObjectiveName | extend zoneRedundant = properties.zoneRedundant, maxSizeBytes = properties.maxSizeBytes, maxLogSizeBytes = properties.maxLogSizeBytes | order by id asc "@ do { if ($resultsSoFar -eq 0) { $dbs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $dbs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($dbs -and $dbs.GetType().Name -eq "PSResourceGraphResponse") { $dbs = $dbs.Data } $resultsCount = $dbs.Count $resultsSoFar += $resultsCount $dbsTotal += $dbs } while ($resultsCount -eq $ARGPageSize) $datetime = (Get-Date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") Write-Output "Building $($dbsTotal.Count) SQL Database entries" foreach ($db in $dbsTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $db.tenantId SubscriptionGuid = $db.subscriptionId ResourceGroupName = $db.resourceGroup.ToLower() ZoneRedundant = $db.zoneRedundant Location = $db.location DBName = $db.name.ToLower() InstanceId = $db.id.ToLower() SkuName = $db.skuName SkuTier = $db.skuTier SkuCapacity = $db.skuCapacity ServiceObjectiveName = $db.serviceObjectiveName StorageAccountType = $db.storageAccountType LicenseType = $db.licenseType MaxSizeBytes = $db.maxSizeBytes MaxLogSizeBytes = $db.maxLogSizeBytes Tags = $db.tags StatusDate = $statusDate } $alldbs += $logentry } Write-Output "Uploading CSV to Storage" $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-sqldbs-$subscriptionSuffix.csv" $alldbs | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGUnmanagedDisksPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGVhdContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argvhdexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $alldisks = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $mdisksTotal = @() $resultsSoFar = 0 Write-Output "Querying for ARM Unmanaged OS Disks properties" $argQuery = @" resources | where type =~ 'Microsoft.Compute/virtualMachines' and isnull(properties.storageProfile.osDisk.managedDisk) | extend diskType = 'OS', diskCaching = tostring(properties.storageProfile.osDisk.caching), diskSize = tostring(properties.storageProfile.osDisk.diskSizeGB) | extend vhdUriParts = split(tostring(properties.storageProfile.osDisk.vhd.uri),'/') | extend diskStorageAccountName = tostring(split(vhdUriParts[2],'.')[0]), diskContainerName = tostring(vhdUriParts[3]), diskVhdName = tostring(vhdUriParts[4]) | order by id, diskStorageAccountName, diskContainerName, diskVhdName "@ do { if ($resultsSoFar -eq 0) { $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($mdisks -and $mdisks.GetType().Name -eq "PSResourceGraphResponse") { $mdisks = $mdisks.Data } $resultsCount = $mdisks.Count $resultsSoFar += $resultsCount $mdisksTotal += $mdisks } while ($resultsCount -eq $ARGPageSize) $resultsSoFar = 0 Write-Output "Found $($mdisksTotal.Count) Unmanaged OS Disk entries" Write-Output "Querying for ARM Unmanaged Data Disks properties" $argQuery = @" resources | where type =~ 'Microsoft.Compute/virtualMachines' and isnull(properties.storageProfile.osDisk.managedDisk) | mvexpand dataDisks = properties.storageProfile.dataDisks | extend diskType = 'Data', diskCaching = tostring(dataDisks.caching), diskSize = tostring(dataDisks.diskSizeGB) | extend vhdUriParts = split(tostring(dataDisks.vhd.uri),'/') | extend diskStorageAccountName = tostring(split(vhdUriParts[2],'.')[0]), diskContainerName = tostring(vhdUriParts[3]), diskVhdName = tostring(vhdUriParts[4]) | order by id, diskStorageAccountName, diskContainerName, diskVhdName "@ do { if ($resultsSoFar -eq 0) { $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($mdisks -and $mdisks.GetType().Name -eq "PSResourceGraphResponse") { $mdisks = $mdisks.Data } $resultsCount = $mdisks.Count $resultsSoFar += $resultsCount $mdisksTotal += $mdisks } while ($resultsCount -eq $ARGPageSize) Write-Output "Found overall $($mdisksTotal.Count) Unmanaged Disk entries" <# Building CSV entries #> $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") foreach ($disk in $mdisksTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $disk.tenantId SubscriptionGuid = $disk.subscriptionId ResourceGroupName = $disk.resourceGroup.ToLower() DiskName = $disk.diskVhdName.ToLower() InstanceId = ($disk.diskStorageAccountName + "/" + $disk.diskContainerName + "/" + $disk.diskVhdName).ToLower() OwnerVMId = $disk.id.ToLower() Location = $disk.location DeploymentModel = "Unmanaged" DiskType = $disk.diskType Caching = $disk.diskCaching DiskSizeGB = $disk.diskSize StatusDate = $statusDate Tags = $disk.tags } $alldisks += $logentry } <# Actually exporting CSV to Azure Storage #> $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-vhds-$subscriptionSuffix.csv" $alldisks | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGVMSSPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope if ([string]::IsNullOrEmpty($referenceRegion)) { $referenceRegion = "westeurope" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGVMSSContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argvmssexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } Write-Output "Getting VM sizes details for $referenceRegion" $sizes = Get-AzVMSize -Location $referenceRegion $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allvmss = @() if ($TargetSubscription) { $subscriptions = $TargetSubscription $subscriptionSuffix = "-" + $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $armVmssTotal = @() $resultsSoFar = 0 $argQuery = @" resources | where type =~ 'microsoft.compute/virtualmachinescalesets' | project id, tenantId, name, location, resourceGroup, subscriptionId, skUName = tostring(sku.name), computerNamePrefix = tostring(properties.virtualMachineProfile.osProfile.computerNamePrefix), usesManagedDisks = iif(isnull(properties.virtualMachineProfile.storageProfile.osDisk.managedDisk), 'false', 'true'), capacity = tostring(sku.capacity), priority = tostring(properties.virtualMachineProfile.priority), tags, zones, osType = iif(isnotnull(properties.virtualMachineProfile.osProfile.linuxConfiguration), "Linux", "Windows"), osDiskSize = tostring(properties.virtualMachineProfile.storageProfile.osDisk.diskSizeGB), osDiskCaching = tostring(properties.virtualMachineProfile.storageProfile.osDisk.caching), osDiskSKU = tostring(properties.virtualMachineProfile.storageProfile.osDisk.managedDisk.storageAccountType), dataDiskCount = iif(isnotnull(properties.virtualMachineProfile.storageProfile.dataDisks), array_length(properties.virtualMachineProfile.storageProfile.dataDisks), 0), nicCount = array_length(properties.virtualMachineProfile.networkProfile.networkInterfaceConfigurations), imagePublisher = iif(isnotempty(properties.virtualMachineProfile.storageProfile.imageReference.publisher),tostring(properties.virtualMachineProfile.storageProfile.imageReference.publisher),'Custom'), imageOffer = iif(isnotempty(properties.virtualMachineProfile.storageProfile.imageReference.offer),tostring(properties.virtualMachineProfile.storageProfile.imageReference.offer),tostring(properties.virtualMachineProfile.storageProfile.imageReference.id)), imageSku = tostring(properties.virtualMachineProfile.storageProfile.imageReference.sku), imageVersion = tostring(properties.virtualMachineProfile.storageProfile.imageReference.version), imageExactVersion = tostring(properties.virtualMachineProfile.storageProfile.imageReference.exactVersion), singlePlacementGroup = tostring(properties.singlePlacementGroup), upgradePolicy = tostring(properties.upgradePolicy.mode), overProvision = tostring(properties.overprovision), platformFaultDomainCount = tostring(properties.platformFaultDomainCount), zoneBalance = tostring(properties.zoneBalance) | order by id asc "@ do { if ($resultsSoFar -eq 0) { $armVmss = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $armVmss = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($armVmss -and $armVmss.GetType().Name -eq "PSResourceGraphResponse") { $armVmss = $armVmss.Data } $resultsCount = $armVmss.Count $resultsSoFar += $resultsCount $armVmssTotal += $armVmss } while ($resultsCount -eq $ARGPageSize) $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") Write-Output "Building $($armVmssTotal.Count) VMSS entries" foreach ($vmss in $armVmssTotal) { $vmSize = $sizes | Where-Object {$_.name -eq $vmss.skUName} $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $vmss.tenantId SubscriptionGuid = $vmss.subscriptionId ResourceGroupName = $vmss.resourceGroup.ToLower() Zones = $vmss.zones Location = $vmss.location VMSSName = $vmss.name.ToLower() ComputerNamePrefix = $vmss.computerNamePrefix.ToLower() InstanceId = $vmss.id.ToLower() VMSSSize = $vmSize.name.ToLower() CoresCount = $vmSize.NumberOfCores MemoryMB = $vmSize.MemoryInMB OSType = $vmss.osType DataDiskCount = $vmss.dataDiskCount NicCount = $vmss.nicCount StatusDate = $statusDate Tags = $vmss.tags Capacity = $vmss.capacity Priority = $vmss.priority OSDiskSize = $vmss.osDiskSize OSDiskCaching = $vmss.osDiskCaching OSDiskSKU = $vmss.osDiskSKU SinglePlacementGroup = $vmss.singlePlacementGroup UpgradePolicy = $vmss.upgradePolicy OverProvision = $vmss.overProvision PlatformFaultDomainCount = $vmss.platformFaultDomainCount ZoneBalance = $vmss.zoneBalance UsesManagedDisks = $vmss.usesManagedDisks ImagePublisher = $vmss.imagePublisher ImageOffer = $vmss.imageOffer ImageSku = $vmss.imageSku ImageVersion = $vmss.imageVersion ImageExactVersion = $vmss.imageExactVersion } $allvmss += $logentry } Write-Output "Uploading CSV to Storage" $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-vmss-$subscriptionSuffix.csv" $allvmss | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGVNetPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope if ([string]::IsNullOrEmpty($referenceRegion)) { $referenceRegion = "westeurope" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGVNetContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argvnetexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allsubnets = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $subnetsTotal = @() $resultsSoFar = 0 Write-Output "Querying for ARM VNet properties" $argQuery = @" resources | where type =~ 'microsoft.network/virtualnetworks' | mv-expand subnets = properties.subnets limit 400 | extend peeringsCount = array_length(properties.virtualNetworkPeerings) | extend vnetPrefixes = properties.addressSpace.addressPrefixes | extend dnsServers = properties.dhcpOptions.dnsServers | extend enableDdosProtection = properties.enableDdosProtection | project-away properties | extend subnetPrefix = tostring(subnets.properties.addressPrefix) | extend subnetDelegationsCount = array_length(subnets.properties.delegations) | extend subnetUsedIPs = iif(isnotempty(subnets.properties.ipConfigurations), array_length(subnets.properties.ipConfigurations), 0) | extend subnetTotalPrefixIPs = pow(2, 32 - toint(split(subnetPrefix,'/')[1])) - 5 | extend subnetNsgId = tolower(subnets.properties.networkSecurityGroup.id) | project id, vnetName = name, resourceGroup, subscriptionId, tenantId, location, vnetPrefixes, dnsServers, subnetName = tolower(tostring(subnets.name)), subnetPrefix, subnetDelegationsCount, subnetTotalPrefixIPs, subnetUsedIPs, subnetNsgId, peeringsCount, enableDdosProtection, tags | order by id asc "@ do { if ($resultsSoFar -eq 0) { $subnets = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $subnets = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($subnets -and $subnets.GetType().Name -eq "PSResourceGraphResponse") { $subnets = $subnets.Data } $resultsCount = $subnets.Count $resultsSoFar += $resultsCount $subnetsTotal += $subnets } while ($resultsCount -eq $ARGPageSize) $datetime = (Get-Date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") Write-Output "Building $($subnetsTotal.Count) ARM VNet subnet entries" foreach ($subnet in $subnetsTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $subnet.tenantId SubscriptionGuid = $subnet.subscriptionId ResourceGroupName = $subnet.resourceGroup.ToLower() Location = $subnet.location VNetName = $subnet.vnetName.ToLower() InstanceId = $subnet.id.ToLower() Model = "ARM" VNetPrefixes = $subnet.vnetPrefixes DNSServers = $subnet.dnsServers PeeringsCount = $subnet.peeringsCount EnableDdosProtection = $subnet.enableDdosProtection SubnetName = $subnet.subnetName SubnetPrefix = $subnet.subnetPrefix SubnetDelegationsCount = $subnet.subnetDelegationsCount SubnetTotalPrefixIPs = $subnet.subnetTotalPrefixIPs SubnetUsedIPs = $subnet.subnetUsedIPs SubnetNSGId = $subnet.subnetNsgId Tags = $subnet.tags StatusDate = $statusDate } $allsubnets += $logentry } $subnetsTotal = @() $resultsSoFar = 0 Write-Output "Querying for Classic VNet properties" $argQuery = @" resources | where type =~ 'microsoft.classicnetwork/virtualnetworks' | extend vNetId = tolower(id) | mv-expand subnets = properties.subnets limit 400 | extend subnetName = tolower(tostring(subnets.name)) | join kind=leftouter ( resources | where type =~ 'microsoft.network/virtualnetworks' | mvexpand peerings = properties.virtualNetworkPeerings limit 400 | extend vNetId = tolower(tostring(peerings.properties.remoteVirtualNetwork.id)) | where vNetId has "microsoft.classicnetwork" | summarize vNetPeerings=count() by vNetId ) on vNetId | extend peeringsCount = iif(isnotempty(vNetPeerings), vNetPeerings, 0) | extend vnetPrefixes = properties.addressSpace.addressPrefixes | extend dnsServers = properties.dhcpOptions.dnsServers | project-away properties | extend subnetPrefix = tostring(subnets.addressPrefix) | join kind=leftouter ( resources | where type =~ 'microsoft.classiccompute/virtualmachines' | extend networkProfile = properties.networkProfile | mvexpand subnets = networkProfile.virtualNetwork.subnetNames limit 400 | extend subnetName = tolower(tostring(subnets)) | project id, vNetId = tolower(tostring(networkProfile.virtualNetwork.id)), subnetName | summarize subnetUsedIPs = count() by vNetId, subnetName ) on vNetId and subnetName | extend subnetUsedIPs = iif(isnotempty(subnetUsedIPs), subnetUsedIPs, 0) | extend subnetTotalPrefixIPs = pow(2, 32 - toint(split(subnetPrefix,'/')[1])) - 5 | extend enableDdosProtection = 'false' | project vNetId, vnetName = name, resourceGroup, subscriptionId, tenantId, location, vnetPrefixes, dnsServers, subnetName, subnetPrefix, subnetTotalPrefixIPs, subnetUsedIPs, peeringsCount, enableDdosProtection | order by vNetId asc "@ do { if ($resultsSoFar -eq 0) { $subnets = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $subnets = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($subnets -and $subnets.GetType().Name -eq "PSResourceGraphResponse") { $subnets = $subnets.Data } $resultsCount = $subnets.Count $resultsSoFar += $resultsCount $subnetsTotal += $subnets } while ($resultsCount -eq $ARGPageSize) $datetime = (Get-Date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") Write-Output "Building $($subnetsTotal.Count) Classic VNet subnet entries" foreach ($subnet in $subnetsTotal) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $subnet.tenantId SubscriptionGuid = $subnet.subscriptionId ResourceGroupName = $subnet.resourceGroup.ToLower() Location = $subnet.location VNetName = $subnet.vnetName.ToLower() InstanceId = $subnet.vNetId.ToLower() Model = "Classic" VNetPrefixes = $subnet.vnetPrefixes DNSServers = $subnet.dnsServers PeeringsCount = $subnet.peeringsCount EnableDdosProtection = $subnet.enableDdosProtection SubnetName = $subnet.subnetName SubnetPrefix = $subnet.subnetPrefix SubnetTotalPrefixIPs = $subnet.subnetTotalPrefixIPs SubnetUsedIPs = $subnet.subnetUsedIPs StatusDate = $statusDate } $allsubnets += $logentry } Write-Output "Uploading CSV to Storage" $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-vnetsubnets-$subscriptionSuffix.csv" $allsubnets | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ARGVirtualMachinesPropertiesToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope if ([string]::IsNullOrEmpty($referenceRegion)) { $referenceRegion = "westeurope" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGVMContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "argvmexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } # get list of all VM sizes Write-Output "Getting VM sizes details for $referenceRegion" $sizes = Get-AzVMSize -Location $referenceRegion $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allvms = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } $armVmsTotal = @() $classicVmsTotal = @() $resultsSoFar = 0 <# Getting all ARM VMs properties with Azure Resource Graph query #> Write-Output "Querying for ARM VM properties" $argQuery = @" resources | where type =~ 'Microsoft.Compute/virtualMachines' | extend dataDiskCount = array_length(properties.storageProfile.dataDisks), nicCount = array_length(properties.networkProfile.networkInterfaces) | extend usesManagedDisks = iif(isnull(properties.storageProfile.osDisk.managedDisk), 'false', 'true') | extend availabilitySetId = tostring(properties.availabilitySet.id) | extend bootDiagnosticsEnabled = tostring(properties.diagnosticsProfile.bootDiagnostics.enabled) | extend bootDiagnosticsStorageAccount = split(split(properties.diagnosticsProfile.bootDiagnostics.storageUri, '/')[2],'.')[0] | extend powerState = tostring(properties.extended.instanceView.powerState.code) | extend imagePublisher = iif(isnotempty(properties.storageProfile.imageReference.publisher),tostring(properties.storageProfile.imageReference.publisher),'Custom') | extend imageOffer = iif(isnotempty(properties.storageProfile.imageReference.offer),tostring(properties.storageProfile.imageReference.offer),tostring(properties.storageProfile.imageReference.id)) | extend imageSku = tostring(properties.storageProfile.imageReference.sku) | extend imageVersion = tostring(properties.storageProfile.imageReference.version) | extend imageExactVersion = tostring(properties.storageProfile.imageReference.exactVersion) | extend osName = tostring(properties.extended.instanceView.osName) | extend osVersion = tostring(properties.extended.instanceView.osVersion) | order by id asc "@ do { if ($resultsSoFar -eq 0) { $armVms = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $armVms = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($armVms -and $armVms.GetType().Name -eq "PSResourceGraphResponse") { $armVms = $armVms.Data } $resultsCount = $armVms.Count $resultsSoFar += $resultsCount $armVmsTotal += $armVms } while ($resultsCount -eq $ARGPageSize) $resultsSoFar = 0 <# Getting all Classic VMs properties with Azure Resource Graph query #> Write-Output "Querying for Classic VM properties" $argQuery = @" resources | where type =~ 'Microsoft.ClassicCompute/virtualMachines' | extend dataDiskCount = iif(isnotnull(properties.storageProfile.dataDisks), array_length(properties.storageProfile.dataDisks), 0), nicCount = iif(isnotnull(properties.networkProfile.virtualNetwork.networkInterfaces), array_length(properties.networkProfile.virtualNetwork.networkInterfaces) + 1, 1) | extend usesManagedDisks = 'false' | extend availabilitySetId = tostring(properties.hardwareProfile.availabilitySet) | extend bootDiagnosticsEnabled = tostring(properties.debugProfile.bootDiagnosticsEnabled) | extend bootDiagnosticsStorageAccount = split(split(properties.debugProfile.serialOutputBlobUri, '/')[2],'.')[0] | extend powerState = tostring(properties.instanceView.status) | extend imageOffer = tostring(properties.storageProfile.operatingSystemDisk.sourceImageName) | order by id asc "@ do { if ($resultsSoFar -eq 0) { $classicVms = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $classicVms = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($classicVms -and $classicVms.GetType().Name -eq "PSResourceGraphResponse") { $classicVms = $classicVms.Data } $resultsCount = $classicVms.Count $resultsSoFar += $resultsCount $classicVmsTotal += $classicVms } while ($resultsCount -eq $ARGPageSize) <# Merging ARM + Classic VMs, enriching VM size details and building CSV entries #> $datetime = (Get-Date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") Write-Output "Building $($armVmsTotal.Count) ARM VM entries" foreach ($vm in $armVmsTotal) { $vmSize = $sizes | Where-Object {$_.name -eq $vm.properties.hardwareProfile.vmSize} $avSetId = $null if ($vm.availabilitySetId) { $avSetId = $vm.availabilitySetId.ToLower() } $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $vm.tenantId SubscriptionGuid = $vm.subscriptionId ResourceGroupName = $vm.resourceGroup.ToLower() Zones = $vm.zones Location = $vm.location VMName = $vm.name.ToLower() DeploymentModel = 'ARM' InstanceId = $vm.id.ToLower() VMSize = $vm.properties.hardwareProfile.vmSize CoresCount = $vmSize.NumberOfCores MemoryMB = $vmSize.MemoryInMB OSType = $vm.properties.storageProfile.osDisk.osType LicenseType = $vm.properties.licenseType DataDiskCount = $vm.dataDiskCount NicCount = $vm.nicCount UsesManagedDisks = $vm.usesManagedDisks AvailabilitySetId = $avSetId BootDiagnosticsEnabled = $vm.bootDiagnosticsEnabled BootDiagnosticsStorageAccount = $vm.bootDiagnosticsStorageAccount StatusDate = $statusDate PowerState = $vm.powerState ImagePublisher = $vm.imagePublisher ImageOffer = $vm.imageOffer ImageSku = $vm.imageSku ImageVersion = $vm.imageVersion ImageExactVersion = $vm.imageExactVersion OSName = $vm.osName OSVersion = $vm.osVersion Tags = $vm.tags } $allvms += $logentry } Write-Output "Building $($classicVmsTotal.Count) Classic VM entries" foreach ($vm in $classicVmsTotal) { $vmSize = $sizes | Where-Object {$_.name -eq $vm.properties.hardwareProfile.size} $avSetId = $null if ($vm.availabilitySetId) { $avSetId = $vm.availabilitySetId.ToLower() } $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $vm.tenantId SubscriptionGuid = $vm.subscriptionId ResourceGroupName = $vm.resourceGroup.ToLower() VMName = $vm.name.ToLower() DeploymentModel = 'Classic' Location = $vm.location InstanceId = $vm.id.ToLower() VMSize = $vm.properties.hardwareProfile.size CoresCount = $vmSize.NumberOfCores MemoryMB = $vmSize.MemoryInMB OSType = $vm.properties.storageProfile.operatingSystemDisk.operatingSystem LicenseType = "N/A" DataDiskCount = $vm.dataDiskCount NicCount = $vm.nicCount UsesManagedDisks = $vm.usesManagedDisks AvailabilitySetId = $avSetId BootDiagnosticsEnabled = $vm.bootDiagnosticsEnabled BootDiagnosticsStorageAccount = $vm.bootDiagnosticsStorageAccount PowerState = $vm.powerState StatusDate = $statusDate ImagePublisher = $vm.imagePublisher ImageOffer = $vm.imageOffer ImageSku = $vm.imageSku ImageVersion = $vm.imageVersion ImageExactVersion = $vm.imageExactVersion OSName = $vm.osName OSVersion = $vm.osVersion Tags = $null } $allvms += $logentry } <# Actually exporting CSV to Azure Storage #> Write-Output "Uploading CSV to Storage" $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-vms-$subscriptionSuffix.csv" $allvms | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-AdvisorRecommendationsToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $targetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_AdvisorContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "advisorexports" } $CategoryFilter = Get-AutomationVariable -Name "AzureOptimization_AdvisorFilter" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($CategoryFilter)) { $CategoryFilter = "HighAvailability,Security,Performance,OperationalExcellence" # comma-separated list of categories } $CategoryFilter += ",Cost" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudEnvironment = $externalCloudEnvironment } Write-Output "Getting subscriptions target $TargetSubscription" $tenantId = (Get-AzContext).Tenant.Id $ARGPageSize = 1000 if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $scope = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" -and $_.SubscriptionPolicies.QuotaId -notlike "AAD*" } | ForEach-Object { "$($_.Id)"} $scope = $tenantId } <# Getting Advisor recommendations for each subscription and building CSV entries #> $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $recommendationsARG = @() $resultsSoFar = 0 $FinalCategoryFilter = "" if (-not([string]::IsNullOrEmpty($CategoryFilter))) { $categories = $CategoryFilter.Split(',') for ($i = 0; $i -lt $categories.Count; $i++) { $categories[$i] = "'" + $categories[$i] + "'" } $FinalCategoryFilter = " and properties.category in (" + ($categories -join ",") + ")" } $argQuery = @" advisorresources | where type == 'microsoft.advisor/recommendations' | where isnull(properties.suppressionIds)$FinalCategoryFilter | extend resourceId = tostring(split(tolower(id),'/providers/microsoft.advisor')[0]) | join kind=leftouter (resources | project resourceId=tolower(id), resourceTags=tags) on resourceId | project id, category = properties.category, impact = properties.impact, impactedArea = properties.impactedField, description = properties.shortDescription.problem, recommendationText = properties.shortDescription.solution, recommendationTypeId = properties.recommendationTypeId, instanceName = properties.impactedValue, additionalInfo = properties.extendedProperties, tags=resourceTags | order by id asc "@ do { if ($resultsSoFar -eq 0) { $recs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $recs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($recs -and $recs.GetType().Name -eq "PSResourceGraphResponse") { $recs = $recs.Data } $resultsCount = $recs.Count $resultsSoFar += $resultsCount $recommendationsARG += $recs } while ($resultsCount -eq $ARGPageSize) Write-Output "Building $($recommendationsARG.Count) recommendations entries" $recommendations = @() foreach ($advisorRecommendation in $recommendationsARG) { $resourceIdParts = $advisorRecommendation.id.Split('/') if ($resourceIdParts.Count -ge 9) { # if the Resource ID is made of 9 parts, then the recommendation is relative to a specific Azure resource $realResourceIdParts = $resourceIdParts[0..8] $instanceId = ($realResourceIdParts -join "/").ToLower() $resourceGroup = $realResourceIdParts[4].ToLower() $subscriptionId = $realResourceIdParts[2] } else { # otherwise it is not a resource-specific recommendation (e.g., reservations) $resourceGroup = "notavailable" $instanceId = $advisorRecommendation.id.ToLower() $subscriptionId = $resourceIdParts[2] } if (-not([string]::IsNullOrEmpty($advisorRecommendation.additionalInfo))) { $additionalInfo = $advisorRecommendation.additionalInfo | ConvertTo-Json -Compress } else { $additionalInfo = $null } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment Category = $advisorRecommendation.category Impact = $advisorRecommendation.impact ImpactedArea = $advisorRecommendation.impactedArea Description = $advisorRecommendation.description RecommendationText = $advisorRecommendation.recommendationText RecommendationTypeId = $advisorRecommendation.recommendationTypeId InstanceId = $instanceId InstanceName = $advisorRecommendation.instanceName Tags = $advisorRecommendation.tags AdditionalInfo = $additionalInfo ResourceGroup = $resourceGroup SubscriptionGuid = $subscriptionId TenantGuid = $tenantId } $recommendations += $recommendation } Write-Output "Found $($recommendations.Count) ($CategoryFilter) recommendations..." $fileDate = $datetime.ToString("yyyyMMdd") $advisorFilter = $CategoryFilter.Replace(',','').ToLower() $csvExportPath = "$fileDate-$advisorFilter-$scope.csv" $recommendations | Export-Csv -NoTypeInformation -Path $csvExportPath Write-Output "Export to $csvExportPath" $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." Write-Output "DONE!" ================================================ FILE: runbooks/data-collection/Export-AzMonitorMetricsToBlobStorage.ps1 ================================================ Param ( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $true)] [string] $ResourceType, # ARM resource type [Parameter(Mandatory = $false)] [string] $ARGFilter, # e.g., name != 'master' and sku.tier in ('Basic','Standard','Premium') [Parameter(Mandatory = $true)] [string] $MetricNames, # comma-separated metrics names (use Get-AzMetricDefinition for a list of supported metric names for a given resource) [Parameter(Mandatory = $true)] [ValidateSet("Maximum", "Minimum", "Average", "Total")] [string] $AggregationType, [Parameter(Mandatory = $false)] [ValidateSet("Default", "Maximum", "Minimum", "Average", "Total")] [string] $AggregationOfType = "Default", [Parameter(Mandatory = $true)] [string] $TimeSpan, # [d.]hh:mm:ss [Parameter(Mandatory = $true)] [string] $TimeGrain, # [d.]hh:mm:ss (00:01:00, 00:05:00, 00:15:00, 00:30:00, 01:00:00, 06:00:00, 12:00:00, 1.00:00:00, 7.00:00:00, 30.00:00:00) [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_AzMonitorContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "azmonitorexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = "-" + $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } [TimeSpan]::Parse($TimeGrain) | Out-Null $TimeSpanObj = [TimeSpan]::Parse("-$TimeSpan") $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Querying for $ResourceType with page size $ARGPageSize and target subscription $TargetSubscription..." $allResources = @() $resultsSoFar = 0 $argWhere = "" if (-not([string]::IsNullOrEmpty($ARGFilter))) { $argWhere = " and $ARGFilter" } $argQuery = @" resources | where type =~ '$ResourceType'$argWhere | project id, name, subscriptionId, resourceGroup, tenantId | order by id asc "@ do { if ($resultsSoFar -eq 0) { $resources = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $resources = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($resources -and $resources.GetType().Name -eq "PSResourceGraphResponse") { $resources = $resources.Data } $resultsCount = $resources.Count $resultsSoFar += $resultsCount $allResources += $resources } while ($resultsCount -eq $ARGPageSize) $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Found $($allResources.Count) resources." $metrics = $MetricNames.Split(',') $queryDate = Get-Date $utcNow = $queryDate.ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") $utcAgo = $queryDate.Add($TimeSpanObj).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") $customMetrics = @() $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Analyzing resources for $MetricNames metrics ($AggregationType with $TimeGrain time grain) since $utcAgo..." foreach ($resource in $allResources) { $valuesAggregation = @() $foundResource = $true foreach ($metric in $metrics) { $metricValues = Get-AzMetric -ResourceId $resource.id -MetricName $metric -TimeGrain $TimeGrain -AggregationType $AggregationType ` -StartTime $utcAgo -EndTime $utcNow -WarningAction SilentlyContinue -ErrorAction Continue if ($metricValues.Data) { if ($valuesAggregation.Count -eq 0) { $valuesAggregation = $metricValues.Data."$AggregationType" } else { for ($i = 0; $i -lt $valuesAggregation.Count; $i++) { if ($metricValues.Data.Count -gt 1) { $valuesAggregation[$i] += $metricValues.Data[$i]."$AggregationType" } else { $valuesAggregation += $metricValues.Data."$AggregationType" } } } } if (-not($metricValues.Id)) { $foundResource = $false } } if ($foundResource) { $aggregatedValue = $null $finalAggregationType = $AggregationType if ($AggregationOfType -ne "Default") { $finalAggregationType = $AggregationOfType } if ($valuesAggregation.Count -gt 0) { switch ($finalAggregationType) { "Maximum" { $aggregatedValue = ($valuesAggregation | Measure-Object -Maximum).Maximum } "Minimum" { $aggregatedValue = ($valuesAggregation | Measure-Object -Minimum).Minimum } "Average" { $aggregatedValue = ($valuesAggregation | Measure-Object -Average).Average } "Total" { $aggregatedValue = ($valuesAggregation | Measure-Object -Sum).Sum } } } $customMetric = New-Object PSObject -Property @{ Timestamp = $utcNow Cloud = $cloudEnvironment TenantGuid = $resource.tenantId SubscriptionGuid = $resource.subscriptionId ResourceGroupName = $resource.resourceGroup.ToLower() ResourceName = $resource.name.ToLower() ResourceId = $resource.id.ToLower() MetricNames = $MetricNames AggregationType = $AggregationType AggregationOfType = $AggregationOfType MetricValue = $aggregatedValue TimeGrain = $TimeGrain TimeSpan = $TimeSpan } $customMetrics += $customMetric } } $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Found $($customMetrics.Count) resources to collect metrics from..." $metricMoment = $queryDate.Add($TimeSpanObj).ToUniversalTime().ToString("yyyyMMddHHmmss") $ResourceTypeName = $ResourceType.Split('/')[1].ToLower() $MetricName = $MetricNames.Replace(',','').Replace(' ','').Replace('/','').ToLower() $AggregationOfTypeName = "" if ($AggregationOfType -ne "Default") { $AggregationOfTypeName = ("-$AggregationOfType").ToLower() } $AggregationTypeName = "$($AggregationType.ToLower())$AggregationOfTypeName" $csvExportPath = "$metricMoment-metrics-$ResourceTypeName-$MetricName-$AggregationTypeName-$subscriptionSuffix.csv" $ci = [CultureInfo]::new([System.Threading.Thread]::CurrentThread.CurrentCulture.Name) if ($ci.NumberFormat.NumberDecimalSeparator -ne '.') { Write-Output "Current culture ($($ci.Name)) does not use . as decimal separator" $ci.NumberFormat.NumberDecimalSeparator = '.' [System.Threading.Thread]::CurrentThread.CurrentCulture = $ci } $customMetrics | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ConsumptionToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName, [Parameter(Mandatory = $false)] [string] $targetStartDate, # YYYY-MM-DD format [Parameter(Mandatory = $false)] [string] $targetEndDate # YYYY-MM-DD format ) $ErrorActionPreference = "Stop" $global:hadErrors = $false $global:scopesWithErrors = @() function Authenticate-AzureWithOption { param ( [string] $authOption = "ManagedIdentity", [string] $cloudEnv = "AzureCloud", [string] $clientID ) switch ($authOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnv -AccountId $clientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnv break } } } function Generate-CostDetails { param ( [string] $ScopeId, [string] $ScopeName ) $MaxTries = 20 # The typical Retry-After is set to 20 seconds. We'll give ~6 minutes overall to download the cost details report $hadErrors = $false $CostDetailsApiPath = "$ScopeId/providers/Microsoft.CostManagement/generateCostDetailsReport?api-version=2022-05-01" $body = "{ `"metric`": `"$consumptionMetric`", `"timePeriod`": { `"start`": `"$targetStartDate`", `"end`": `"$targetEndDate`" } }" $result = Invoke-AzRestMethod -Path $CostDetailsApiPath -Method POST -Payload $body $requestResultPath = $result.Headers.Location.PathAndQuery if ($result.StatusCode -in (200,202)) { $tries = 0 $requestSuccess = $false Write-Output "Obtained cost detail results endpoint: $requestResultPath..." Write-Output "Was told to wait $($result.Headers.RetryAfter.Delta.TotalSeconds) seconds." $sleepSeconds = 60 if ($result.Headers.RetryAfter.Delta.TotalSeconds -gt 0) { $sleepSeconds = $result.Headers.RetryAfter.Delta.TotalSeconds } do { $tries++ Write-Output "Checking whether export is ready (try $tries)..." Start-Sleep -Seconds $sleepSeconds $downloadResult = Invoke-AzRestMethod -Method GET -Path $requestResultPath if ($downloadResult.StatusCode -eq 200) { Write-Output "Export is ready. Proceeding with CSV download..." $downloadBlobJson = $downloadResult.Content | ConvertFrom-Json $blobCounter = 0 foreach ($blob in $downloadBlobJson.manifest.blobs) { $blobCounter++ Write-Output "Downloading blob $blobCounter..." $csvExportPath = "$env:TEMP\$targetStartDate-$ScopeName-$consumptionMetric-$blobCounter.csv" $finalCsvExportPath = "$env:TEMP\$targetStartDate-$ScopeName-$consumptionMetric-$blobCounter-final.csv" Invoke-WebRequest -Uri $blob.blobLink -OutFile $csvExportPath Write-Output "Blob downloaded to $csvExportPath successfully." $r = [IO.File]::OpenText($csvExportPath) $w = [System.IO.StreamWriter]::new($finalCsvExportPath) # header normalization between MCA and EA $headerConversion = @{ additionalInfo = "AdditionalInfo"; billingAccountId = "BillingAccountId"; billingAccountName = "BillingAccountName"; billingCurrency = "BillingCurrencyCode"; billingPeriodEndDate = "BillingPeriodEndDate"; billingPeriodStartDate = "BillingPeriodStartDate"; billingProfileId = "BillingProfileId"; billingProfileName = "BillingProfileName"; chargeType = "ChargeType"; consumedService = "ConsumedService"; costAllocationRuleName = "CostAllocationRuleName"; costCenter = "CostCenter"; costInBillingCurrency = "CostInBillingCurrency"; date = "Date"; effectivePrice = "EffectivePrice"; frequency = "Frequency"; invoiceSectionId = "InvoiceSectionId"; invoiceSectionName = "InvoiceSectionName"; isAzureCreditEligible = "IsAzureCreditEligible"; meterCategory = "MeterCategory"; meterId = "MeterId"; meterName = "MeterName"; meterRegion = "MeterRegion"; meterSubCategory = "MeterSubCategory"; offerId = "OfferId"; pricingModel = "PricingModel"; productOrderId = "ProductOrderId"; productOrderName = "ProductOrderName"; publisherName = "PublisherName"; publisherType = "PublisherType"; quantity = "Quantity"; reservationId = "ReservationId"; reservationName = "ReservationName"; resourceGroupName = "ResourceGroup"; resourceLocation = "ResourceLocation"; serviceFamily = "ServiceFamily"; serviceInfo1 = "ServiceInfo1"; serviceInfo2 = "ServiceInfo2"; subscriptionName = "SubscriptionName"; tags = "Tags"; term = "Term"; unitOfMeasure = "UnitOfMeasure"; unitPrice = "UnitPrice" } $lineCounter = 0 while ($r.Peek() -ge 0) { $line = $r.ReadLine() $lineCounter++ if ($lineCounter -eq 1) { $headers = $line.Split(",") for ($i = 0; $i -lt $headers.Length; $i++) { $header = $headers[$i] if ($headerConversion.ContainsKey($header)) { $headers[$i] = $headerConversion[$header] } } $line = $headers -join "," $w.WriteLine($line) } else { $w.WriteLine($line) } } $r.Dispose() $w.Close() $csvBlobName = [System.IO.Path]::GetFileName($finalCsvExportPath) $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $finalCsvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." Remove-Item -Path $finalCsvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $finalCsvExportPath from local disk..." } $requestSuccess = $true } elseif ($downloadResult.StatusCode -eq 202) { Write-Output "Was told to wait a bit more... $($downloadResult.Headers.RetryAfter.Delta.TotalSeconds) seconds." $sleepSeconds = 60 if ($downloadResult.Headers.RetryAfter.Delta.TotalSeconds -gt 0) { $sleepSeconds = $downloadResult.Headers.RetryAfter.Delta.TotalSeconds } } elseif ($downloadResult.StatusCode -eq 401) { Write-Output "Had an authentication issue. Will login again and sleep just a couple of seconds." if ($authenticationOption -eq "UserAssignedManagedIdentity") { Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment -clientID $uamiClientID } else { Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment } $sleepSeconds = 2 } else { $global:hadErrors = $true $global:scopesWithErrors += $ScopeName Write-Warning "Got an unexpected response code: $($downloadResult.StatusCode)" } } while (-not($requestSuccess) -and $tries -lt $MaxTries) if (-not($requestSuccess)) { $global:hadErrors = $true $global:scopesWithErrors += $ScopeName if ($tries -eq $MaxTries) { Write-Warning "Reached maximum number of tries. Aborting..." } else { Write-Warning "Error returned by the Download Cost Details API. Status Code: $($downloadResult.StatusCode). Message: $($downloadResult.Content)" } } else { Write-Output "Export download processing complete." } } else { if ($result.StatusCode -ne 204) { $global:hadErrors = $true $global:scopesWithErrors += $ScopeName Write-Warning "Error returned by the Generate Cost Details API. Status Code: $($result.StatusCode). Message: $($result.Content)" } else { Write-Output "Request returned 204 No Content" } } } $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ConsumptionContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "consumptionexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionMetric = Get-AutomationVariable -Name "AzureOptimization_ConsumptionMetric" -ErrorAction SilentlyContinue # AmortizedCost|ActualCost if ([string]::IsNullOrEmpty($consumptionMetric)) { $consumptionMetric = "AmortizedCost" } $consumptionAPIOption = Get-AutomationVariable -Name "AzureOptimization_ConsumptionAPIOption" -ErrorAction SilentlyContinue # CostDetails|UsageDetails if ([string]::IsNullOrEmpty($consumptionAPIOption)) { $consumptionAPIOption = "CostDetails" } $consumptionScope = Get-AutomationVariable -Name "AzureOptimization_ConsumptionScope" -ErrorAction SilentlyContinue # Subscription|BillingAccount if ([string]::IsNullOrEmpty($consumptionScope)) { "Consumption Scope not specified, defaulting to Subscription" $consumptionScope = "Subscription" } else { "Consumption Scope is $consumptionScope" if ($consumptionScope -eq "BillingAccount") { $BillingAccountID = Get-AutomationVariable -Name "AzureOptimization_BillingAccountID" } else { if ($consumptionScope -ne "Subscription") { throw "Invalid value for AzureOptimization_ConsumptionScope. Valid values are 'Subscription' or 'BillingAccount'." } } } if ($cloudEnvironment -eq "AzureChinaCloud") { $chinaEAEnrollment = Get-AutomationVariable -Name "AzureOptimization_AzureChinaEAEnrollment" -ErrorAction SilentlyContinue $chinaEAKey = Get-AutomationVariable -Name "AzureOptimization_AzureChinaEAKey" -ErrorAction SilentlyContinue } "Logging in to Azure with $authenticationOption..." if ($authenticationOption -eq "UserAssignedManagedIdentity") { Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment -clientID $uamiClientID } else { Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudEnvironment = $externalCloudEnvironment } # compute start+end dates if ([string]::IsNullOrEmpty($targetStartDate) -or [string]::IsNullOrEmpty($targetEndDate)) { $targetStartDate = (Get-Date).Date.AddDays($consumptionOffsetDays * -1).ToString("yyyy-MM-dd") $targetEndDate = $targetStartDate } if ($consumptionScope -eq "Subscription") { if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = Get-AzSubscription -SubscriptionId $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } } "Exporting consumption data from $targetStartDate to $targetEndDate for $($subscriptions.Count) subscriptions..." } else { "Exporting consumption data from $targetStartDate to $targetEndDate for Billing Account ID $BillingAccountID..." } # for each subscription, get billing data $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") if ($cloudEnvironment -eq "AzureChinaCloud" -and -not([string]::IsNullOrEmpty($chinaEAEnrollment)) -and -not([string]::IsNullOrEmpty($chinaEAKey))) { $targetMonth = $targetStartDate.Substring(0,7) $consumption = $null $billingEntries = @() $BillingApiUri = "https://ea.azure.cn/rest/$chinaEAEnrollment/usage-report?month=$targetMonth&type=detail&fmt=Csv" $PricesheetApiUri = "https://ea.azure.cn/rest/$chinaEAEnrollment/usage-report?month=$targetMonth&type=pricesheet&fmt=Csv" $Headers = @{} $Headers.Add("Authorization","Bearer $chinaEAKey") Write-Output "Getting pricesheet for month $targetMonth (EA enrollment $chinaEAEnrollment)..." Invoke-RestMethod -Method Get -Uri $PricesheetApiUri -Headers $Headers -OutFile "pricesheet-$targetMonth.csv" Write-Output "Pricesheet data exported to disk as CSV." $csvFile = Get-Content -Path "pricesheet-$targetMonth.csv" Write-Output "Pricesheet data imported from disk as string." Remove-Item -Path "pricesheet-$targetMonth.csv" -Force Write-Output "Removed pricesheet-$targetMonth.csv from local disk..." $csvFile2 = $csvFile[2..($csvFile.Count-1)] $headerLine = $csvFile2[0] $columnHeaders = $headerLine.Split(",") for ($i = 0; $i -lt $columnHeaders.Count; $i++) { if($columnHeaders[$i] -match '.+\((?.+)\)') { $columnHeaders[$i] = $Matches.ColumnName } } $csvFile2[0] = $columnHeaders -join "," Write-Output "Removed first 2 lines and replaced header." $pricesheet = $csvFile2 | ConvertFrom-Csv Write-Output "Starting Azure China billing export process from $targetStartDate to $targetEndDate (month $targetMonth) for EA enrollment $chinaEAEnrollment..." $tries = 0 $requestSuccess = $false do { try { $tries++ Invoke-RestMethod -Method Get -Uri $BillingApiUri -Headers $Headers -OutFile "usagedetails-$targetStartDate.csv" Write-Output "Consumption data exported to disk as CSV." $csvFile = Get-Content -Path "usagedetails-$targetStartDate.csv" Write-Output "Consumption data imported from disk as string." Remove-Item -Path "usagedetails-$targetStartDate.csv" -Force Write-Output "Removed usagedetails-$targetStartDate.csv from local disk..." $csvFile2 = $csvFile[2..($csvFile.Count-1)] $headerLine = $csvFile2[0] $columnHeaders = $headerLine.Split(",") for ($i = 0; $i -lt $columnHeaders.Count; $i++) { if($columnHeaders[$i] -match '.+\((?.+)\)') { $columnHeaders[$i] = $Matches.ColumnName } } $csvFile2[0] = $columnHeaders -join "," Write-Output "Removed first 2 lines and replaced header." $consumption = $csvFile2 | ConvertFrom-Csv $requestSuccess = $true } catch { $ErrorMessage = $_.Exception.Message Write-Warning "Error getting consumption data: $ErrorMessage. $tries of 3 tries. Waiting 60 seconds..." Start-Sleep -s 60 } } while ( -not($requestSuccess) -and $tries -lt 3 ) if (-not($requestSuccess)) { throw "Failed consumption export" } Write-Output "Consumption data in memory as CSV. Processing lines..." foreach ($consumptionLine in $consumption) { $usageDate = [Datetime]::ParseExact($consumptionLine.Date, 'MM/dd/yyyy', $null).ToString("yyyy-MM-dd") if ($usageDate -ge $targetStartDate -and $usageDate -le $targetEndDate -and ($subscriptions.Count -gt 1 -or $subscriptions.Id -eq $consumptionLine.SubscriptionGuid)) { $instanceId = $null $instanceName = $null if ($null -ne $consumptionLine.'Instance ID') { $instanceId = $consumptionLine.'Instance ID'.ToLower() $idParts = $consumptionLine.'Instance ID'.Split("/") $instanceName = $idParts[$idParts.Count-1].ToLower() } $rgName = $null if ($null -ne $consumptionLine.'Resource Group') { $rgName = $consumptionLine.'Resource Group'.ToLower() } $convertedCost = 0.0 if ([double]$consumptionLine.ExtendedCost -ne 0) { $convertedCost = [double]$consumptionLine.ExtendedCost } $convertedPrice = 0.0 if ([double]$consumptionLine.ResourceRate -ne 0) { $convertedPrice = [double]$consumptionLine.ResourceRate } $unitPrice = 0.0 $partNumber = "N/A" foreach ($priceItem in $pricesheet) { if ($priceItem.Service -eq $consumptionLine.Product) { $partNumber = $priceItem.'Part Number' if ($consumptionLine.'Meter Category' -eq "Virtual Machines") { $tempUnitPrice = [double] $priceItem.'Unit Price' $uom = $priceItem.'Unit of Measure' $currentUnitHours = [int] (Select-String -InputObject $uom -Pattern "^\d+").Matches[0].Value if ($currentUnitHours -gt 0) { $unitPrice = [double] ($tempUnitPrice / $currentUnitHours) } } else { $unitPrice = $convertedPrice } break } } $billingEntry = New-Object PSObject -Property @{ Timestamp = $timestamp SubscriptionId = $consumptionLine.SubscriptionGuid ResourceGroup = $rgName ResourceName = $instanceName ResourceId = $instanceId Date = $consumptionLine.Date Tags = $consumptionLine.Tags AdditionalInfo = $consumptionLine.AdditionalInfo BillingCurrencyCode = "CNY" ChargeType = "Usage" ConsumedService = $consumptionLine.'Consumed Service' CostInBillingCurrency = $convertedCost EffectivePrice = $convertedPrice Frequency = "UsageBased" MeterCategory = $consumptionLine.'Meter Category' MeterId = $consumptionLine.'Meter ID' MeterName = $consumptionLine.'Meter Name' MeterSubCategory = $consumptionLine.'Meter Sub-Category' PartNumber = $partNumber ProductName = $consumptionLine.Product Quantity = $consumptionLine.'Consumed Quantity' UnitOfMeasure = $consumptionLine.'Unit of Measure' UnitPrice = $unitPrice ResourceLocation = $consumptionLine.'Resource Location' AccountOwnerId = $consumptionLine.AccountOwnerId } $billingEntries += $billingEntry } } if ($targetStartDate -ne $targetEndDate) { $targetStartDate = "$targetStartDate-$targetEndDate" } $csvExportPath = "$targetStartDate-eachina.csv" $billingEntries | Export-Csv -Path $csvExportPath -NoTypeInformation Write-Output "Exported $($billingEntries.Count) entries as CSV to $csvExportPath" $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force Write-Output "Uploaded to blob storage!" Remove-Item -Path $csvExportPath -Force Write-Output "Removed $csvExportPath from local disk..." } else { if ($consumptionScope -eq "Subscription") { $CostDetailsSupportedQuotaIDs = @('EnterpriseAgreement_2014-09-01','Internal_2014-09-01','CSP_2015-05-01') $ConsumptionSupportedQuotaIDs = @('PayAsYouGo_2014-09-01','MSDN_2014-09-01') foreach ($subscription in $subscriptions) { $subscriptionQuotaID = $subscription.SubscriptionPolicies.QuotaId if ($subscriptionQuotaID -in $ConsumptionSupportedQuotaIDs -or $consumptionAPIOption -eq "UsageDetails") { $consumption = $null $billingEntries = @() $ConsumptionApiPath = "/subscriptions/$($subscription.Id)/providers/Microsoft.Consumption/usageDetails?api-version=2021-10-01&metric=$($consumptionMetric.ToLower())&%24expand=properties%2FmeterDetails%2Cproperties%2FadditionalInfo&%24filter=properties%2FusageStart%20ge%20%27$targetStartDate%27%20and%20properties%2FusageEnd%20le%20%27$targetEndDate%27" "Starting consumption export process from $targetStartDate to $targetEndDate for subscription $($subscription.Name)..." do { if (-not([string]::IsNullOrEmpty($consumption.nextLink))) { $ConsumptionApiPath = $consumption.nextLink.Substring($consumption.nextLink.IndexOf("/subscriptions/")) } $tries = 0 $requestSuccess = $false do { try { $tries++ $consumption = (Invoke-AzRestMethod -Path $ConsumptionApiPath -Method GET).Content | ConvertFrom-Json $requestSuccess = $true } catch { $ErrorMessage = $_.Exception.Message Write-Warning "Error getting consumption data: $ErrorMessage. $tries of 3 tries. Waiting 60 seconds..." Start-Sleep -s 60 } } while ( -not($requestSuccess) -and $tries -lt 3 ) foreach ($consumptionLine in $consumption.value) { if ((Get-Date $consumptionLine.properties.date).ToString("yyyy-MM-dd") -ge $targetStartDate -and (Get-Date $consumptionLine.properties.date).ToString("yyyy-MM-dd") -le $targetEndDate) { if ($consumptionLine.tags) { $tags = $consumptionLine.tags | ConvertTo-Json -Compress } else { $tags = $null } $billingEntry = New-Object PSObject -Property @{ Timestamp = $timestamp AccountName = $consumptionLine.properties.accountName AccountOwnerId = $consumptionLine.properties.accountOwnerId AdditionalInfo = $consumptionLine.properties.additionalInfo benefitId = $consumptionLine.properties.benefitId benefitName = $consumptionLine.properties.benefitName BillingAccountId = $consumptionLine.properties.billingAccountId BillingAccountName = $consumptionLine.properties.billingAccountName BillingCurrencyCode = $consumptionLine.properties.billingCurrency BillingPeriodEndDate= $consumptionLine.properties.billingPeriodEndDate BillingPeriodStartDate= $consumptionLine.properties.billingPeriodStartDate BillingProfileId = $consumptionLine.properties.billingProfileId BillingProfileName= $consumptionLine.properties.billingProfileName ChargeType = $consumptionLine.properties.chargeType ConsumedService = $consumptionLine.properties.consumedService CostAllocationRuleName = $consumptionLine.properties.costAllocationRuleName CostCenter = $consumptionLine.properties.costCenter CostInBillingCurrency = $consumptionLine.properties.cost Date = (Get-Date $consumptionLine.properties.date).ToString("MM/dd/yyyy") EffectivePrice = $consumptionLine.properties.effectivePrice Frequency = $consumptionLine.properties.frequency InvoiceSectionName = $consumptionLine.properties.invoiceSection IsAzureCreditEligible = $consumptionLine.properties.isAzureCreditEligible MeterCategory = $consumptionLine.properties.meterDetails.meterCategory MeterId = $consumptionLine.properties.meterId MeterName = $consumptionLine.properties.meterDetails.meterName MeterRegion = $consumptionLine.properties.meterDetails.meterRegion MeterSubCategory = $consumptionLine.properties.meterDetails.meterSubCategory OfferId = $consumptionLine.properties.offerId PartNumber = $consumptionLine.properties.partNumber PayGPrice = $consumptionLine.properties.PayGPrice PlanName = $consumptionLine.properties.planName PricingModel = $consumptionLine.properties.pricingModel ProductName = $consumptionLine.properties.product PublisherName = $consumptionLine.properties.publisherName PublisherType = $consumptionLine.properties.publisherType Quantity = $consumptionLine.properties.quantity ReservationId = $consumptionLine.properties.reservationId ReservationName = $consumptionLine.properties.reservationName ResourceGroup = $consumptionLine.properties.resourceGroup ResourceId = $consumptionLine.properties.resourceId ResourceLocation = $consumptionLine.properties.resourceLocation ResourceName = $consumptionLine.properties.resourceName ServiceFamily = $consumptionLine.properties.meterDetails.serviceFamily SubscriptionId = $consumptionLine.properties.subscriptionId SubscriptionName = $consumptionLine.properties.subscriptionName Tags = $tags Term = $consumptionLine.properties.term UnitOfMeasure = $consumptionLine.properties.meterDetails.unitOfMeasure UnitPrice = $consumptionLine.properties.unitPrice } $billingEntries += $billingEntry } } } while ($requestSuccess -and -not([string]::IsNullOrEmpty($consumption.nextLink))) if ($requestSuccess) { "Generated $($billingEntries.Count) entries..." "Uploading CSV to Storage" $ci = [CultureInfo]::new([System.Threading.Thread]::CurrentThread.CurrentCulture.Name) if ($ci.NumberFormat.NumberDecimalSeparator -ne '.') { "Current culture ($($ci.Name)) does not use . as decimal separator" $ci.NumberFormat.NumberDecimalSeparator = '.' [System.Threading.Thread]::CurrentThread.CurrentCulture = $ci } $csvExportPath = "$targetStartDate-$($subscription.Id)-$consumptionMetric.csv" $billingEntries | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $csvExportPath from local disk..." } else { $global:hadErrors = $true $global:scopesWithErrors += $ScopeName Write-Warning "Failed to get consumption data for subscription $($subscription.Name)..." } } elseif ($subscriptionQuotaID -in $CostDetailsSupportedQuotaIDs -or $consumptionAPIOption -eq "CostDetails") { "Starting cost details export process from $targetStartDate to $targetEndDate for subscription $($subscription.Name)..." Generate-CostDetails -ScopeId "/subscriptions/$($subscription.Id)" -ScopeName $subscription.Id } else { $global:hadErrors = $true $global:scopesWithErrors += $ScopeName Write-Warning "Subscription quota $subscriptionQuotaID not supported" } } } else { "Starting cost details export process from $targetStartDate to $targetEndDate for Billing Account ID $BillingAccountID..." Generate-CostDetails -ScopeId "/providers/Microsoft.Billing/billingAccounts/$BillingAccountID" -ScopeName $BillingAccountID } } if ($global:hadErrors) { $scopesWithErrorsString = $global:scopesWithErrors -join "," throw "There were errors during the export process with the following scopes: $scopesWithErrorsString. Please check the output for details." } ================================================ FILE: runbooks/data-collection/Export-PolicyComplianceToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetSubscription, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName, [Parameter(Mandatory = $false)] [ValidateSet("ARG", "ARM")] [string] $PolicyStatesEndpoint = "ARG" ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope if ([string]::IsNullOrEmpty($referenceRegion)) { $referenceRegion = "westeurope" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_PolicyStatesContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "policystateexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $ARGPageSize = 1000 "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } $cloudSuffix = "" if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $allpolicyStates = @() Write-Output "Getting subscriptions target $TargetSubscription" if (-not([string]::IsNullOrEmpty($TargetSubscription))) { $subscriptions = $TargetSubscription $subscriptionSuffix = $TargetSubscription } else { $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" -and $_.SubscriptionPolicies.QuotaId -notlike "AAD*" } | ForEach-Object { "$($_.Id)"} $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId } Write-Output "Building Policy display names..." $policyAssignments = @{} $policyInitiatives = @{} $policyDefinitions = @{} $excludedAssignmentScopes = @() $allInitiatives = @() if ($PolicyStatesEndpoint -eq "ARG") { $resultsSoFar = 0 $argQuery = @" policyresources | where type =~ 'microsoft.authorization/policyassignments' | extend displayName = iif(isnotempty(properties.displayName), tostring(properties.displayName), 'N/A') | distinct id, displayName | order by id asc "@ $argAssignmentsTotal = @() do { if ($resultsSoFar -eq 0) { $argAssignments = Search-AzGraph -Query $argQuery -First $ARGPageSize -UseTenantScope } else { $argAssignments = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -UseTenantScope } if ($argAssignments -and $argAssignments.GetType().Name -eq "PSResourceGraphResponse") { $argAssignments = $argAssignments.Data } $resultsCount = $argAssignments.Count $resultsSoFar += $resultsCount $argAssignmentsTotal += $argAssignments } while ($resultsCount -eq $ARGPageSize) Write-Output "Building $($argAssignmentsTotal.Count) assignment entries" foreach ($assignment in $argAssignmentsTotal) { $policyAssignments.Add($assignment.id, $assignment.displayName) } $resultsSoFar = 0 $argQuery = @" policyresources | where type =~ 'microsoft.authorization/policysetdefinitions' | extend displayName = iif(isnotempty(properties.displayName), tostring(properties.displayName), 'N/A') | distinct id, displayName | order by id asc "@ $argInitiativesTotal = @() do { if ($resultsSoFar -eq 0) { $argInitiatives = Search-AzGraph -Query $argQuery -First $ARGPageSize -UseTenantScope } else { $argInitiatives = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -UseTenantScope } if ($argInitiatives -and $argInitiatives.GetType().Name -eq "PSResourceGraphResponse") { $argInitiatives = $argInitiatives.Data } $resultsCount = $argInitiatives.Count $resultsSoFar += $resultsCount $argInitiativesTotal += $argInitiatives } while ($resultsCount -eq $ARGPageSize) Write-Output "Building $($argInitiativesTotal.Count) initiative entries" foreach ($initiative in $argInitiativesTotal) { $policyInitiatives.Add($initiative.id, $initiative.displayName) } $resultsSoFar = 0 $argQuery = @" policyresources | where type =~ 'microsoft.authorization/policydefinitions' | extend displayName = iif(isnotempty(properties.displayName), tostring(properties.displayName), 'N/A') | distinct id, displayName | order by id asc "@ $argDefinitionsTotal = @() do { if ($resultsSoFar -eq 0) { $argDefinitions = Search-AzGraph -Query $argQuery -First $ARGPageSize -UseTenantScope } else { $argDefinitions = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -UseTenantScope } if ($argDefinitions -and $argDefinitions.GetType().Name -eq "PSResourceGraphResponse") { $argDefinitions = $argDefinitions.Data } $resultsCount = $argDefinitions.Count $resultsSoFar += $resultsCount $argDefinitionsTotal += $argDefinitions } while ($resultsCount -eq $ARGPageSize) Write-Output "Building $($argDefinitionsTotal.Count) definition entries" foreach ($definition in $argDefinitionsTotal) { $policyDefinitions.Add($definition.id, $definition.displayName) } } else { foreach ($sub in $subscriptions) { Select-AzSubscription -SubscriptionId $sub | Out-Null $assignments = Get-AzPolicyAssignment -IncludeDescendent foreach ($assignment in $assignments) { if (-not($policyAssignments[$assignment.PolicyAssignmentId])) { $assignmentName = $assignment.Properties.DisplayName if([string]::IsNullOrWhiteSpace($assignmentName)) { $policyAssignments.Add($assignment.PolicyAssignmentId, 'N/A') } else { $policyAssignments.Add($assignment.PolicyAssignmentId, $assignmentName) } } if ($assignment.Properties.NotScopes -and -not($excludedAssignmentScopes | Where-Object { $_.PolicyAssignmentId -eq $assignment.PolicyAssignmentId })) { $excludedAssignmentScopes += $assignment } } $initiatives = Get-AzPolicySetDefinition foreach ($initiative in $initiatives) { if (-not($policyInitiatives[$initiative.PolicySetDefinitionId])) { $setDefinitionName = $initiative.Properties.DisplayName if([string]::IsNullOrWhiteSpace($setDefinitionName)) { $policyInitiatives.Add($initiative.PolicySetDefinitionId, 'N/A') } else { $policyInitiatives.Add($initiative.PolicySetDefinitionId, $setDefinitionName) } } if (-not($allInitiatives | Where-Object { $_.PolicySetDefinitionId -eq $initiative.PolicySetDefinitionId })) { $allInitiatives += $initiative } } $definitions = Get-AzPolicyDefinition foreach ($definition in $definitions) { if (-not($policyDefinitions[$definition.PolicyDefinitionId])) { $definitionName = $initiative.Properties.DisplayName if([string]::IsNullOrWhiteSpace($definitionName)) { $policyDefinitions.Add($definition.PolicyDefinitionId, 'N/A') } else { $policyDefinitions.Add($definition.PolicyDefinitionId, $definitionName) } } } } } $policyStatesTotal = @() Write-Output "Querying for Policy states using $PolicyStatesEndpoint endpoint..." if ($PolicyStatesEndpoint -eq "ARG") { $resultsSoFar = 0 $argQuery = @" policyresources | where type =~ 'microsoft.policyinsights/policystates' | extend complianceState = tostring(properties.complianceState) | extend complianceReason = tostring(properties.complianceReasonCode) | where complianceState != 'Compliant' and complianceReason !contains 'ResourceNotFound' | extend effect = tostring(properties.policyDefinitionAction) | extend assignmentId = tolower(properties.policyAssignmentId) | extend definitionId = tolower(properties.policyDefinitionId) | extend definitionReferenceId = tolower(properties.policyDefinitionReferenceId) | extend initiativeId = tolower(properties.policySetDefinitionId) | extend resourceId = tolower(properties.resourceId) | extend resourceType = tostring(properties.resourceType) | extend evaluatedOn = todatetime(properties.timestamp) | summarize StatesCount = count() by id, tenantId, subscriptionId, resourceGroup, resourceId, resourceType, complianceState, complianceReason, effect, assignmentId, definitionReferenceId, definitionId, initiativeId, evaluatedOn | union ( policyresources | where type =~ 'microsoft.policyinsights/policystates' | extend complianceState = tostring(properties.complianceState) | where complianceState == 'Compliant' | extend effect = tostring(properties.policyDefinitionAction) | extend assignmentId = tolower(properties.policyAssignmentId) | extend definitionId = tolower(properties.policyDefinitionId) | extend definitionReferenceId = tolower(properties.policyDefinitionReferenceId) | extend initiativeId = tolower(properties.policySetDefinitionId) | summarize StatesCount = count() by tenantId, subscriptionId, complianceState, effect, assignmentId, definitionReferenceId, definitionId, initiativeId ) | join kind=leftouter ( resources | project resourceId=tolower(id), tags ) on resourceId | project-away resourceId1 | order by id asc "@ do { if ($resultsSoFar -eq 0) { $policyStates = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions } else { $policyStates = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions } if ($policyStates -and $policyStates.GetType().Name -eq "PSResourceGraphResponse") { $policyStates = $policyStates.Data } $resultsCount = $policyStates.Count $resultsSoFar += $resultsCount $policyStatesTotal += $policyStates } while ($resultsCount -eq $ARGPageSize) Write-Output "Building $($policyStatesTotal.Count) policyState entries" } else { foreach ($sub in $subscriptions) { Select-AzSubscription -SubscriptionId $sub | Out-Null $policyStates = Get-AzPolicyState -All $nonCompliantStates = $policyStates | Where-Object { $_.ComplianceState -ne "Compliant" } foreach ($policyState in $nonCompliantStates) { $policyStateObject = New-Object PSObject -Property @{ tenantId = $tenantId subscriptionId = $sub resourceGroup = $policyState.ResourceGroup resourceId = $policyState.ResourceId resourceType = $policyState.ResourceType complianceState = $policyState.ComplianceState complianceReason = $policyState.AdditionalProperties.complianceReasonCode effect = $policyState.PolicyDefinitionAction assignmentId = $policyState.PolicyAssignmentId initiativeId = $policyState.PolicySetDefinitionId definitionId = $policyState.PolicyDefinitionId definitionReferenceId = $policyState.PolicyDefinitionReferenceId evaluatedOn = $policyState.Timestamp StatesCount = 1 } $policyStatesTotal += $policyStateObject } $compliantStates = $policyStates | Where-Object { $_.ComplianceState -eq "Compliant" } ` | Group-Object PolicyDefinitionAction, PolicyAssignmentId, PolicyDefinitionId, PolicyDefinitionReferenceId, PolicySetDefinitionId foreach ($policyState in $compliantStates) { $compliantStateProps = $policyState.Name.Split(',') $definitionReferenceId = $null if ($compliantStateProps[3]) { $definitionReferenceId = $compliantStateProps[3].Trim().ToLower() } $initiativeId = $null if ($compliantStateProps[4]) { $initiativeId = $compliantStateProps[4].Trim().ToLower() } $policyStateObject = New-Object PSObject -Property @{ tenantId = $tenantId subscriptionId = $sub complianceState = "Compliant" effect = $compliantStateProps[0] assignmentId = $compliantStateProps[1].Trim().ToLower() definitionId = $compliantStateProps[2].Trim().ToLower() definitionReferenceId = $definitionReferenceId initiativeId = $initiativeId StatesCount = $policyState.Count } $policyStatesTotal += $policyStateObject } } Write-Output "Building $($policyStatesTotal.Count) policyState entries" } $datetime = (Get-Date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $statusDate = $datetime.ToString("yyyy-MM-dd") foreach ($policyState in $policyStatesTotal) { $resourceGroup = $null if ($policyState.resourceGroup) { $resourceGroup = $policyState.resourceGroup.ToLower() } if (-not([string]::IsNullOrEmpty($policyState.tags))) { $tags = $policyState.tags | ConvertTo-Json -Compress } else { $tags = $null } $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $policyState.tenantId SubscriptionGuid = $policyState.subscriptionId ResourceGroupName = $resourceGroup ResourceId = $policyState.resourceId ResourceType = $policyState.resourceType ComplianceState = $policyState.complianceState ComplianceReason = $policyState.complianceReason Effect = $policyState.effect AssignmentId = $policyState.assignmentId AssignmentName = $policyAssignments[$policyState.assignmentId] InitiativeId = $policyState.initiativeId InitiativeName = $policyInitiatives[$policyState.initiativeId] DefinitionId = $policyState.definitionId DefinitionName = $policyDefinitions[$policyState.definitionId] DefinitionReferenceId = $policyState.definitionReferenceId EvaluatedOn = $policyState.evaluatedOn StatesCount = $policyState.StatesCount Tags = $tags StatusDate = $statusDate } $allpolicyStates += $logentry } if ($PolicyStatesEndpoint -eq "ARG") { $resultsSoFar = 0 $argQuery = @" policyresources | where type =~ 'microsoft.authorization/policyassignments' | where array_length(properties.notScopes) > 0 | mv-expand notScope = properties.notScopes | extend policyAssignmentId = tolower(id) | extend assignmentPolicyDefinitionId = tolower(properties.policyDefinitionId) | join kind=leftouter ( policyresources | where type =~ 'microsoft.authorization/policysetdefinitions' | mv-expand policyDefinition = properties.policyDefinitions | project policySetDefinitionId = tolower(id), policyDefinitionId = tolower(policyDefinition.policyDefinitionId), policyDefinitionReferenceId = tolower(policyDefinition.policyDefinitionReferenceId) ) on `$left.assignmentPolicyDefinitionId == `$right.policySetDefinitionId | project policyAssignmentId, notScope, assignmentPolicyDefinitionId, policySetDefinitionId, policyDefinitionId, policyDefinitionReferenceId | order by policyDefinitionReferenceId, tostring(notScope) "@ do { if ($resultsSoFar -eq 0) { $argExcludedAssignments = Search-AzGraph -Query $argQuery -First $ARGPageSize -UseTenantScope } else { $argExcludedAssignments = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -UseTenantScope } if ($argExcludedAssignments -and $argExcludedAssignments.GetType().Name -eq "PSResourceGraphResponse") { $argExcludedAssignments = $argExcludedAssignments.Data } $resultsCount = $argExcludedAssignments.Count $resultsSoFar += $resultsCount $excludedAssignmentScopes += $argExcludedAssignments } while ($resultsCount -eq $ARGPageSize) Write-Output "Adding excluded scopes from $($excludedAssignmentScopes.Count) assignments" foreach ($excludedAssignmentScope in $excludedAssignmentScopes) { if (-not([String]::IsNullOrEmpty($excludedAssignmentScope.policySetDefinitionId))) { $initiativeId = $excludedAssignmentScope.policySetDefinitionId $initiativeName = $policyInitiatives[$initiativeId] $definitionReferenceId = $excludedAssignmentScope.policyDefinitionReferenceId $definitionId = $excludedAssignmentScope.policyDefinitionId } else { $initiativeId = $null $initiativeName = $null $definitionReferenceId = $null $definitionId = $excludedAssignmentScope.assignmentPolicyDefinitionId } $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $tenantId ResourceId = $excludedAssignmentScope.notScope ComplianceState = 'Excluded' AssignmentId = $excludedAssignmentScope.policyAssignmentId AssignmentName = $policyAssignments[$excludedAssignmentScope.policyAssignmentId] InitiativeId = $initiativeId InitiativeName = $initiativeName DefinitionId = $definitionId DefinitionName = $policyDefinitions[$definitionId] DefinitionReferenceId = $definitionReferenceId StatusDate = $statusDate } $allpolicyStates += $logentry } } else { Write-Output "Adding excluded scopes from $($excludedAssignmentScopes.Count) assignments" foreach ($excludedAssignment in $excludedAssignmentScopes) { $excludedIDs = @() $excludedInitiative = $allInitiatives | Where-Object { $_.PolicySetDefinitionId -eq $excludedAssignment.Properties.PolicyDefinitionId } if ($excludedInitiative) { $excludedDefinitions = $excludedInitiative.Properties.PolicyDefinitions foreach ($excludedDefinition in $excludedDefinitions) { $excludedIDs += "$($excludedDefinition.policyDefinitionId)|$($excludedDefinition.policyDefinitionReferenceId)" } } else { $excludedIDs += $excludedAssignment.Properties.PolicyDefinitionId } foreach ($excludedID in $excludedIDs) { $excludedIDParts = $excludedID.Split('|') $definitionId = $excludedIDParts[0].ToLower() $definitionReferenceId = $null if (-not([string]::IsNullOrEmpty($excludedIDParts[1]))) { $definitionReferenceId = $excludedIDParts[1].ToLower() } $initiativeId = $null $initiativeName = $null if ($excludedInitiative) { $initiativeId = $excludedInitiative.PolicySetDefinitionId.ToLower() $initiativeName = $policyInitiatives[$initiativeId] } foreach ($notScope in $excludedAssignment.Properties.NotScopes) { $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment TenantGuid = $tenantId ResourceId = $notScope.ToLower() ComplianceState = 'Excluded' AssignmentId = $excludedAssignment.PolicyAssignmentId.ToLower() AssignmentName = $policyAssignments[$excludedAssignment.PolicyAssignmentId] InitiativeId = $initiativeId InitiativeName = $initiativeName DefinitionId = $definitionId DefinitionName = $policyDefinitions[$definitionId] DefinitionReferenceId = $definitionReferenceId StatusDate = $statusDate } $allpolicyStates += $logentry } } } } Write-Output "Uploading CSV to Storage" $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-policyStates-$subscriptionSuffix.csv" $allpolicyStates | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force Write-Output "Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force Write-Output "Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-PriceSheetToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $BillingAccountID, [Parameter(Mandatory = $false)] [string] $BillingProfileID, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName, [Parameter(Mandatory = $false)] [string] $billingPeriod, # YYYYMM format [Parameter(Mandatory = $false)] [string] $meterCategories, # comma-separated meter categories (e.g., "Virtual Machines,Storage") [Parameter(Mandatory = $false)] [string] $meterRegions # comma-separated billing meter regions (e.g., "EU North,EU West") ) $ErrorActionPreference = "Stop" function Authenticate-AzureWithOption { param ( [string] $authOption = "ManagedIdentity", [string] $cloudEnv = "AzureCloud", [string] $clientID ) switch ($authOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnv -AccountId $clientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnv break } } } $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_PriceSheetContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "pricesheetexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $meterCategoriesVar = Get-AutomationVariable -Name "AzureOptimization_PriceSheetMeterCategories" -ErrorAction SilentlyContinue $meterRegionsVar = Get-AutomationVariable -Name "AzureOptimization_PriceSheetMeterRegions" -ErrorAction SilentlyContinue $BillingAccountIDVar = Get-AutomationVariable -Name "AzureOptimization_BillingAccountID" -ErrorAction SilentlyContinue $BillingProfileIDVar = Get-AutomationVariable -Name "AzureOptimization_BillingProfileID" -ErrorAction SilentlyContinue "Logging in to Azure with $authenticationOption..." if ($authenticationOption -eq "UserAssignedManagedIdentity") { Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment -clientID $uamiClientID } else { Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudEnvironment = $externalCloudEnvironment } # compute billing period if ([string]::IsNullOrEmpty($billingPeriod)) { $billingPeriod = (Get-Date).Date.AddDays($consumptionOffsetDays * -1).ToString("yyyyMM") } $exportDate = (Get-Date).ToUniversalTime().ToString("yyyyMMdd") if ([string]::IsNullOrEmpty($BillingAccountID) -and -not([string]::IsNullOrEmpty($BillingAccountIDVar))) { $BillingAccountID = $BillingAccountIDVar } if ([string]::IsNullOrEmpty($BillingProfileID) -and -not([string]::IsNullOrEmpty($BillingProfileIDVar))) { $BillingProfileID = $BillingProfileIDVar } $mcaBillingAccountIdRegex = "([A-Za-z0-9]+(-[A-Za-z0-9]+)+):([A-Za-z0-9]+(-[A-Za-z0-9]+)+)_[0-9]{4}-[0-9]{2}-[0-9]{2}" $mcaBillingProfileIdRegex = "([A-Za-z0-9]+(-[A-Za-z0-9]+)+)" if ([string]::IsNullOrEmpty($BillingAccountID)) { throw "Billing Account ID undefined. Use either the AzureOptimization_BillingAccountID variable or the BillingAccountID parameter" } else { if ($BillingAccountID -match $mcaBillingAccountIdRegex) { if ([string]::IsNullOrEmpty($BillingProfileID)) { throw "Billing Profile ID undefined for MCA. Use either the AzureOptimization_BillingProfileID variable or the BillingProfileID parameter" } if (-not($BillingProfileID -match $mcaBillingProfileIdRegex)) { throw "Billing Profile ID does not follow pattern for MCA: ([A-Za-z0-9]+(-[A-Za-z0-9]+)+)" } } } if (-not([string]::IsNullOrEmpty($meterCategoriesVar))) { $meterCategories = $meterCategoriesVar } if (-not([string]::IsNullOrEmpty($meterRegionsVar))) { $meterRegions = $meterRegionsVar } $meterCategoryFilters = $null $meterRegionFilters = $null if (-not([string]::IsNullOrEmpty($meterCategories))) { $meterCategoryFilters = $meterCategories.Split(',') } if (-not([string]::IsNullOrEmpty($meterRegions))) { $meterRegionFilters = $meterRegions.Split(',') } function Generate-Pricesheet { param ( [string] $InputCSVPath, [string] $OutputCSVPath, [string] $HeaderLine ) # header normalization between MCA and EA $headerConversion = @{ 'Meter ID' = "MeterID"; meterId = "MeterID"; 'Meter name' = "MeterName"; meterName = "MeterName"; 'Meter category' = "MeterCategory"; meterCategory = "MeterCategory"; 'Meter sub-category' = "MeterSubCategory"; meterSubCategory = "MeterSubCategory"; 'Meter region' = "MeterRegion"; meterRegion = "MeterRegion"; 'Unit of measure' = "UnitOfMeasure"; unitOfMeasure = "UnitOfMeasure"; 'Part number' = "PartNumber"; 'Unit price' = "UnitPrice"; unitPrice = "UnitPrice"; 'Currency code' = "CurrencyCode"; currency = "CurrencyCode"; 'Included quantity' = "IncludedQuantity"; includedQuantity = "IncludedQuantity"; 'Offer Id' = "OfferId"; Term = "Term"; 'Price type' = "PriceType"; priceType = "PriceType" } $r = [IO.File]::OpenText($InputCSVPath) $w = [System.IO.StreamWriter]::new($OutputCSVPath) $lineCounter = 0 while ($r.Peek() -ge 0) { $line = $r.ReadLine() $lineCounter++ if ($lineCounter -eq $HeaderLine) { $headers = $line.Split(",") for ($i = 0; $i -lt $headers.Length; $i++) { $header = $headers[$i] if ($headerConversion.ContainsKey($header)) { $headers[$i] = $headerConversion[$header] } } $line = $headers -join "," if (-not($line -match "SubCategory")) { throw "Pricesheet format has changed at line $HeaderLine - $line" } Write-Output "New headers: $line" $w.WriteLine($line) } else { if ($lineCounter -gt $HeaderLine) { $categoryWriteLine = $categoryWriteLineDefault $regionWriteLine = $regionWriteLineDefault foreach ($meterCategory in $meterCategoryFilters) { if ($line -match ",$meterCategory,") { $categoryWriteLine = $true break } } foreach ($meterRegion in $meterRegionFilters) { if ($line -match ",$meterRegion,") { $regionWriteLine = $true break } } if ($categoryWriteLine -eq $true -and $regionWriteLine -eq $true) { $w.WriteLine($line) } } } } $r.Dispose() $w.Close() $csvBlobName = [System.IO.Path]::GetFileName($OutputCSVPath) $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $OutputCSVPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $InputCSVPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $InputCSVPath from local disk..." Remove-Item -Path $OutputCSVPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $OutputCSVPath from local disk..." } Write-Output "Starting pricesheet export process for $billingPeriod billing period for Billing Account $BillingAccountID..." $MaxTries = 30 # The typical Retry-After is set to 20 seconds. We'll give 10 minutes overall to download the pricesheet report if ($BillingAccountID -match $mcaBillingAccountIdRegex) { $PriceSheetApiPath = "/providers/Microsoft.Billing/billingAccounts/$BillingAccountID/billingProfiles/$BillingProfileID/providers/Microsoft.CostManagement/pricesheets/default/download?api-version=2023-03-01&format=csv" $result = Invoke-AzRestMethod -Path $PriceSheetApiPath -Method POST } else { $PriceSheetApiPath = "/providers/Microsoft.Billing/billingAccounts/$BillingAccountID/billingPeriods/$billingPeriod/providers/Microsoft.Consumption/pricesheets/download?api-version=2022-06-01&ln=en" $result = Invoke-AzRestMethod -Path $PriceSheetApiPath -Method GET } $requestResultPath = $result.Headers.Location.PathAndQuery if ($result.StatusCode -in (200,202)) { $tries = 0 $requestSuccess = $false Write-Output "Obtained pricesheet results endpoint: $requestResultPath..." Write-Output "Was told to wait $($result.Headers.RetryAfter.Delta.TotalSeconds) seconds." $sleepSeconds = 60 if ($result.Headers.RetryAfter.Delta.TotalSeconds -gt 0) { $sleepSeconds = $result.Headers.RetryAfter.Delta.TotalSeconds } do { $tries++ Write-Output "Checking whether export is ready (try $tries)..." Start-Sleep -Seconds $sleepSeconds $downloadResult = Invoke-AzRestMethod -Method GET -Path $requestResultPath if ($downloadResult.StatusCode -eq 200) { Write-Output "Filtering data with meter categories $meterCategories and meter regions $meterRegions to $finalCsvExportPath..." $categoryWriteLineDefault = $true if ($meterCategoryFilters.Count -gt 0) { $categoryWriteLineDefault = $false } $regionWriteLineDefault = $true if ($meterRegionFilters.Count -gt 0) { $regionWriteLineDefault = $false } Write-Output "Defaulting to meter categories writes $($categoryWriteLineDefault) and meter regions writes $($regionWriteLineDefault)..." if ($BillingAccountID -match $mcaBillingAccountIdRegex) { Write-Output "Export is ready. Proceeding with ZIP download..." $downloadUrl = ($downloadResult.Content | ConvertFrom-Json).publishedEntity.properties.downloadUrl $zipExportPath = "$env:TEMP\pricesheet-$BillingProfileID-$exportDate.zip" $zipExpandPath = "$env:TEMP\pricesheet" Invoke-WebRequest -Uri $downloadUrl -OutFile $zipExportPath Write-Output "Blob downloaded to $zipExportPath successfully." Expand-Archive -LiteralPath $zipExportPath -DestinationPath $zipExpandPath -Force Write-Output "Zip expanded to $zipExpandPath successfully." $csvFiles = Get-ChildItem -Path $zipExpandPath -Filter *.csv -Recurse foreach ($csvFile in $csvFiles) { $csvExportPath = $csvFile.FullName $finalCsvExportPath = "$env:TEMP\$($csvFile.Name)-final.csv" Generate-Pricesheet -InputCSVPath $csvExportPath -OutputCSVPath $finalCsvExportPath -HeaderLine 1 } Remove-Item -Path $zipExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $zipExportPath from local disk..." } else { Write-Output "Export is ready. Proceeding with CSV download..." $downloadUrl = ($downloadResult.Content | ConvertFrom-Json).properties.downloadUrl $csvExportPath = "$env:TEMP\pricesheet-$billingPeriod-$BillingAccountID.csv" $finalCsvExportPath = "$env:TEMP\pricesheet-$billingPeriod-$BillingAccountID$($meterCategories.Replace(',',''))$($meterRegions.Replace(',',''))-$exportDate-final.csv" Invoke-WebRequest -Uri $downloadUrl -OutFile $csvExportPath Write-Output "Blob downloaded to $csvExportPath successfully." Generate-Pricesheet -InputCSVPath $csvExportPath -OutputCSVPath $finalCsvExportPath -HeaderLine 3 } $requestSuccess = $true } elseif ($downloadResult.StatusCode -eq 202) { Write-Output "Was told to wait a bit more... $($downloadResult.Headers.RetryAfter.Delta.TotalSeconds) seconds." $sleepSeconds = 60 if ($downloadResult.Headers.RetryAfter.Delta.TotalSeconds -gt 0) { $sleepSeconds = $downloadResult.Headers.RetryAfter.Delta.TotalSeconds } } elseif ($downloadResult.StatusCode -eq 401) { Write-Output "Had an authentication issue. Will login again and sleep just a couple of seconds." if ($authenticationOption -eq "UserAssignedManagedIdentity") { Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment -clientID $uamiClientID } else { Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment } $sleepSeconds = 2 } else { Write-Output "Got an unexpected response code: $($downloadResult.StatusCode)" } } while (-not($requestSuccess) -and $tries -lt $MaxTries) if ($tries -ge $MaxTries) { throw "Couldn't complete request before the alloted number of $MaxTries retries" } if (-not($requestSuccess)) { throw "Error returned by the Download PriceSheet API. Status Code: $($downloadResult.StatusCode). Message: $($downloadResult.Content)" } else { Write-Output "Export download processing complete." } } else { if ($result.StatusCode -ne 204) { throw "Error returned by the Download PriceSheet API. Status Code: $($result.StatusCode). Message: $($result.Content)" } else { Write-Output "Request returned 204 No Content" } } ================================================ FILE: runbooks/data-collection/Export-RBACAssignmentsToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RBACAssignmentsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "rbacexports" } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } $roleAssignments = @() "Iterating through all reachable subscriptions..." foreach ($subscription in $subscriptions) { Select-AzSubscription -SubscriptionId $subscription.Id -TenantId $tenantId | Out-Null $assignments = Get-AzRoleAssignment -IncludeClassicAdministrators -ErrorAction Continue "Found $($assignments.Count) assignments for $($subscription.Name) subscription..." foreach ($assignment in $assignments) { if ($null -eq $assignment.ObjectId -and $assignment.Scope.Contains($subscription.Id)) { $assignmentEntry = New-Object PSObject -Property @{ Timestamp = $timestamp TenantGuid = $tenantId Cloud = $cloudEnvironment Model = "AzureClassic" PrincipalId = $assignment.SignInName Scope = $assignment.Scope RoleDefinition = $assignment.RoleDefinitionName } $roleAssignments += $assignmentEntry } else { $duplicateRoleAssignment = $roleAssignments | Where-Object { $_.PrincipalId -eq $assignment.ObjectId -and $_.Scope -eq $assignment.Scope -and $_.RoleDefinition -eq $assignment.RoleDefinitionName} if (-not($duplicateRoleAssignment)) { $assignmentEntry = New-Object PSObject -Property @{ Timestamp = $timestamp TenantGuid = $tenantId Cloud = $cloudEnvironment Model = "AzureRM" PrincipalId = $assignment.ObjectId Scope = $assignment.Scope RoleDefinition = $assignment.RoleDefinitionName } $roleAssignments += $assignmentEntry } } } } $fileDate = $datetime.ToString("yyyyMMdd") $jsonExportPath = "$fileDate-$tenantId-rbacassignments.json" $csvExportPath = "$fileDate-$tenantId-rbacassignments.csv" $roleAssignments | ConvertTo-Json -Depth 3 -Compress | Out-File $jsonExportPath "Exported to JSON: $($roleAssignments.Count) lines" $rbacObjectsJson = Get-Content -Path $jsonExportPath | ConvertFrom-Json "JSON Import: $($rbacObjectsJson.Count) lines" $rbacObjectsJson | Export-Csv -NoTypeInformation -Path $csvExportPath "Export to $csvExportPath" $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $csvExportPath from local disk..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $jsonExportPath from local disk..." $roleAssignments = @() "Getting Microsoft Entra ID roles..." #workaround for https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/888 $localPath = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile) if (-not(get-item "$localPath\.graph\" -ErrorAction SilentlyContinue)) { New-Item -Type Directory "$localPath\.graph" } Import-Module Microsoft.Graph.Identity.DirectoryManagement switch ($cloudEnvironment) { "AzureUSGovernment" { $graphEnvironment = "USGov" break } "AzureChinaCloud" { $graphEnvironment = "China" break } "AzureGermanCloud" { $graphEnvironment = "Germany" break } Default { $graphEnvironment = "Global" } } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Microsoft Graph with $externalCredentialName external credential..." Connect-MgGraph -TenantId $externalTenantId -ClientSecretCredential $externalCredential -Environment $graphEnvironment -NoWelcome } else { "Logging in to Microsoft Graph..." Connect-MgGraph -Identity -Environment $graphEnvironment -NoWelcome } $domainName = (Get-MgDomain | Where-Object { $_.IsVerified -and $_.IsDefault } | Select-Object -First 1).Id $roles = Get-MgDirectoryRole -ExpandProperty Members -Property DisplayName,Members foreach ($role in $roles) { $roleMembers = $role.Members | Where-Object { -not($_.DeletedDateTime) } foreach ($roleMember in $roleMembers) { $assignmentEntry = New-Object PSObject -Property @{ Timestamp = $timestamp TenantGuid = $tenantId Cloud = $cloudEnvironment Model = "AzureAD" PrincipalId = $roleMember.Id Scope = $domainName RoleDefinition = $role.DisplayName } $roleAssignments += $assignmentEntry } } $fileDate = $datetime.ToString("yyyyMMdd") $jsonExportPath = "$fileDate-$tenantId-aadrbacassignments.json" $csvExportPath = "$fileDate-$tenantId-aadrbacassignments.csv" $roleAssignments | ConvertTo-Json -Depth 3 -Compress | Out-File $jsonExportPath "Exported to JSON: $($roleAssignments.Count) lines" $rbacObjectsJson = Get-Content -Path $jsonExportPath | ConvertFrom-Json "JSON Import: $($rbacObjectsJson.Count) lines" $rbacObjectsJson | Export-Csv -NoTypeInformation -Path $csvExportPath "Export to $csvExportPath" $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $csvExportPath from local disk..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") "[$now] Removed $jsonExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ReservationsPriceToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $Filter = "serviceName eq 'Virtual Machines' and priceType eq 'Reservation'" # e.g., serviceName eq 'Virtual Machines' and priceType eq 'Reservation' and armRegionName eq 'northeurope' ) $ErrorActionPreference = "Stop" function Authenticate-AzureWithOption { param ( [string] $authOption = "ManagedIdentity", [string] $cloudEnv = "AzureCloud", [string] $clientID ) switch ($authOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnv -AccountId $clientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnv break } } } $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ReservationsPriceContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "reservationspriceexports" } $filterVar = Get-AutomationVariable -Name "AzureOptimization_RetailPricesFilter" -ErrorAction SilentlyContinue $currencyCode = Get-AutomationVariable -Name "AzureOptimization_RetailPricesCurrencyCode" "Logging in to Azure with $authenticationOption..." if ($authenticationOption -eq "UserAssignedManagedIdentity") { Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment -clientID $uamiClientID } else { Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudEnvironment = $externalCloudEnvironment } if (-not([string]::IsNullOrEmpty($filterVar))) { $Filter = $filterVar } Write-Output "Starting retails prices export process with $currencyCode currency code and filter: $Filter ..." $RetailPricesApiPath = "https://prices.azure.com/api/retail/prices?currencyCode='$currencyCode'&`$filter=$Filter" $prices = @() do { $Response = Invoke-RestMethod -Method Get -Uri $RetailPricesApiPath if ($Response.Items.Count -gt 0) { $prices += $Response.Items } $RetailPricesApiPath = $Response.NextPageLink } while ($Response.NextPageLink) $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyyMMdd") $fileFriendlyFilter = $Filter.Replace(" ","").Replace("'","") $csvExportPath = "reservationsprice-$timestamp-$fileFriendlyFilter.csv" $ci = [CultureInfo]::new([System.Threading.Thread]::CurrentThread.CurrentCulture.Name) if ($ci.NumberFormat.NumberDecimalSeparator -ne '.') { Write-Output "Current culture ($($ci.Name)) does not use . as decimal separator" $ci.NumberFormat.NumberDecimalSeparator = '.' [System.Threading.Thread]::CurrentThread.CurrentCulture = $ci } $prices | Export-Csv -NoTypeInformation -Path $csvExportPath Write-Output "Reservations price CSV exported to $csvExportPath successfully." $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-ReservationsUsageToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetScope, [Parameter(Mandatory = $false)] [string] $BillingAccountID, [Parameter(Mandatory = $false)] [string] $BillingProfileID, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName, [Parameter(Mandatory = $false)] [string] $targetStartDate, # YYYY-MM-DD format [Parameter(Mandatory = $false)] [string] $targetEndDate # YYYY-MM-DD format ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ReservationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "reservationsexports" } $BillingAccountIDVar = Get-AutomationVariable -Name "AzureOptimization_BillingAccountID" -ErrorAction SilentlyContinue $BillingProfileIDVar = Get-AutomationVariable -Name "AzureOptimization_BillingProfileID" -ErrorAction SilentlyContinue if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") if ([string]::IsNullOrEmpty($BillingAccountID) -and -not([string]::IsNullOrEmpty($BillingAccountIDVar))) { $BillingAccountID = $BillingAccountIDVar } if ([string]::IsNullOrEmpty($BillingProfileID) -and -not([string]::IsNullOrEmpty($BillingProfileIDVar))) { $BillingProfileID = $BillingProfileIDVar } $mcaBillingAccountIdRegex = "([A-Za-z0-9]+(-[A-Za-z0-9]+)+):([A-Za-z0-9]+(-[A-Za-z0-9]+)+)_[0-9]{4}-[0-9]{2}-[0-9]{2}" $mcaBillingProfileIdRegex = "([A-Za-z0-9]+(-[A-Za-z0-9]+)+)" "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id # compute start+end dates if ([string]::IsNullOrEmpty($targetStartDate) -or [string]::IsNullOrEmpty($targetEndDate)) { $targetStartDate = (Get-Date).Date.AddDays($consumptionOffsetDays * -1).ToString("yyyy-MM-dd") $targetEndDate = $targetStartDate } if (-not([string]::IsNullOrEmpty($TargetScope))) { $scope = $TargetScope } else { if ([string]::IsNullOrEmpty($BillingAccountID)) { throw "Billing Account ID undefined. Use either the AzureOptimization_BillingAccountID variable or the BillingAccountID parameter" } if ($BillingAccountID -match $mcaBillingAccountIdRegex) { if ([string]::IsNullOrEmpty($BillingProfileID)) { throw "Billing Profile ID undefined for MCA. Use either the AzureOptimization_BillingProfileID variable or the BillingProfileID parameter" } if (-not($BillingProfileID -match $mcaBillingProfileIdRegex)) { throw "Billing Profile ID does not follow pattern for MCA: ([A-Za-z0-9]+(-[A-Za-z0-9]+)+)" } $scope = "/providers/Microsoft.Billing/billingaccounts/$BillingAccountID/billingProfiles/$BillingProfileID" } else { $scope = "/providers/Microsoft.Billing/billingaccounts/$BillingAccountID" } } $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Starting reservations export process from $targetStartDate to $targetEndDate for scope $scope..." # get reservations details $reservationsDetailsResponse = $null $reservationsDetails = @() $reservationsDetailsPath = "$scope/reservations?api-version=2020-05-01&&refreshSummary=true" do { if (-not([string]::IsNullOrEmpty($reservationsDetailsResponse.nextLink))) { $reservationsDetailsPath = $reservationsDetailsResponse.nextLink.Substring($reservationsDetailsResponse.nextLink.IndexOf("/providers/")) } $result = Invoke-AzRestMethod -Path $reservationsDetailsPath -Method GET if (-not($result.StatusCode -in (200, 201, 202))) { throw "Error while getting reservations details: $($result.Content)" } $reservationsDetailsResponse = $result.Content | ConvertFrom-Json if ($reservationsDetailsResponse.value) { $reservationsDetails += $reservationsDetailsResponse.value } } while (-not([string]::IsNullOrEmpty($reservationsDetailsResponse.nextLink))) $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Found $($reservationsDetails.Count) reservation details." # get reservations usage $reservationsUsage = @() if ($BillingAccountID -match $mcaBillingAccountIdRegex) { $reservationsUsagePath = "$scope/providers/Microsoft.Consumption/reservationSummaries?api-version=2023-05-01&startDate=$targetStartDate&endDate=$targetEndDate&grain=daily" } else { $reservationsUsagePath = "$scope/providers/Microsoft.Consumption/reservationSummaries?api-version=2023-05-01&`$filter=properties/UsageDate ge $targetStartDate and properties/UsageDate le $targetEndDate&grain=daily" } $result = Invoke-AzRestMethod -Path $reservationsUsagePath -Method GET if (-not($result.StatusCode -in (200, 201, 202))) { throw "Error while getting reservations usage: $($result.Content)" } $reservationsUsageResponse = $result.Content | ConvertFrom-Json if ($reservationsUsageResponse.value) { $reservationsUsage += $reservationsUsageResponse.value } $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Found $($reservationsUsage.Count) reservation usages." $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $reservations = @() foreach ($usage in $reservationsUsage) { $reservationResourceId = "/providers/microsoft.capacity/reservationorders/$($usage.properties.reservationOrderId)/reservations/$($usage.properties.reservationId)" $reservationDetail = $reservationsDetails | Where-Object { $_.id -eq $reservationResourceId } $reservationEntry = New-Object PSObject -Property @{ ReservationResourceId = $reservationResourceId ReservationOrderId = $usage.properties.reservationOrderId ReservationId = $usage.properties.reservationId DisplayName = $reservationDetail.properties.displayName SKUName = $usage.properties.skuName Location = $reservationDetail.location ResourceType = $reservationDetail.properties.reservedResourceType AppliedScopeType = $reservationDetail.properties.userFriendlyAppliedScopeType Term = $reservationDetail.properties.term ProvisioningState = $reservationDetail.properties.displayProvisioningState RenewState = $reservationDetail.properties.userFriendlyRenewState PurchaseDate = $reservationDetail.properties.purchaseDate ExpiryDate = $reservationDetail.properties.expiryDate Archived = $reservationDetail.properties.archived ReservedHours = $usage.properties.reservedHours UsedHours = $usage.properties.usedHours UsageDate = $usage.properties.usageDate MinUtilPercentage = $usage.properties.minUtilizationPercentage AvgUtilPercentage = $usage.properties.avgUtilizationPercentage MaxUtilPercentage = $usage.properties.maxUtilizationPercentage PurchasedQuantity = $usage.properties.purchasedQuantity RemainingQuantity = $usage.properties.remainingQuantity TotalReservedQuantity = $usage.properties.totalReservedQuantity UsedQuantity = $usage.properties.usedQuantity UtilizedPercentage = $usage.properties.utilizedPercentage UtilTrend = $reservationDetail.properties.utilization.trend Util1Days = ($reservationDetail.properties.utilization.aggregates | Where-Object { $_.grain -eq 1 }).value Util7Days = ($reservationDetail.properties.utilization.aggregates | Where-Object { $_.grain -eq 7 }).value Util30Days = ($reservationDetail.properties.utilization.aggregates | Where-Object { $_.grain -eq 30 }).value Scope = $scope TenantGuid = $tenantId Cloud = $cloudEnvironment CollectedDate = $timestamp Timestamp = $timestamp } $reservations += $reservationEntry } $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Generated $($reservations.Count) entries..." if ($BillingAccountID -match $mcaBillingAccountIdRegex) { $csvExportPath = "$targetStartDate-$BillingProfileID.csv" } else { $csvExportPath = "$targetStartDate-$BillingAccountID-$($scope.Split('/')[-1]).csv" } $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploading CSV to Storage" $ci = [CultureInfo]::new([System.Threading.Thread]::CurrentThread.CurrentCulture.Name) if ($ci.NumberFormat.NumberDecimalSeparator -ne '.') { Write-Output "Current culture ($($ci.Name)) does not use . as decimal separator" $ci.NumberFormat.NumberDecimalSeparator = '.' [System.Threading.Thread]::CurrentThread.CurrentCulture = $ci } $reservations | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Export-SavingsPlansUsageToBlobStorage.ps1 ================================================ param( [Parameter(Mandatory = $false)] [string] $TargetScope, [Parameter(Mandatory = $false)] [string] $BillingAccountID, [Parameter(Mandatory = $false)] [string] $BillingProfileID, [Parameter(Mandatory = $false)] [string] $externalCloudEnvironment, [Parameter(Mandatory = $false)] [string] $externalTenantId, [Parameter(Mandatory = $false)] [string] $externalCredentialName ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue if (-not($storageAccountSinkEnv)) { $storageAccountSinkEnv = $cloudEnvironment } $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue $storageAccountSinkKey = $null if ($storageAccountSinkKeyCred) { $storageAccountSink = $storageAccountSinkKeyCred.UserName $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password } $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_SavingsPlansContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "savingsplansexports" } $BillingAccountIDVar = Get-AutomationVariable -Name "AzureOptimization_BillingAccountID" -ErrorAction SilentlyContinue $BillingProfileIDVar = Get-AutomationVariable -Name "AzureOptimization_BillingProfileID" -ErrorAction SilentlyContinue if (-not([string]::IsNullOrEmpty($externalCredentialName))) { $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName } if ([string]::IsNullOrEmpty($BillingAccountID) -and -not([string]::IsNullOrEmpty($BillingAccountIDVar))) { $BillingAccountID = $BillingAccountIDVar } if ([string]::IsNullOrEmpty($BillingProfileID) -and -not([string]::IsNullOrEmpty($BillingProfileIDVar))) { $BillingProfileID = $BillingProfileIDVar } $mcaBillingAccountIdRegex = "([A-Za-z0-9]+(-[A-Za-z0-9]+)+):([A-Za-z0-9]+(-[A-Za-z0-9]+)+)_[0-9]{4}-[0-9]{2}-[0-9]{2}" $mcaBillingProfileIdRegex = "([A-Za-z0-9]+(-[A-Za-z0-9]+)+)" "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } if (-not($storageAccountSinkKey)) { Write-Output "Getting Storage Account context with login" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context } else { Write-Output "Getting Storage Account context with key" $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv } if (-not([string]::IsNullOrEmpty($externalCredentialName))) { "Logging in to Azure with $externalCredentialName external credential..." Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential $cloudEnvironment = $externalCloudEnvironment } $tenantId = (Get-AzContext).Tenant.Id if (-not([string]::IsNullOrEmpty($TargetScope))) { $scope = $TargetScope } else { if ([string]::IsNullOrEmpty($BillingAccountID)) { throw "Billing Account ID undefined. Use either the AzureOptimization_BillingAccountID variable or the BillingAccountID parameter" } if ($BillingAccountID -match $mcaBillingAccountIdRegex) { if ([string]::IsNullOrEmpty($BillingProfileID)) { throw "Billing Profile ID undefined for MCA. Use either the AzureOptimization_BillingProfileID variable or the BillingProfileID parameter" } if (-not($BillingProfileID -match $mcaBillingProfileIdRegex)) { throw "Billing Profile ID does not follow pattern for MCA: ([A-Za-z0-9]+(-[A-Za-z0-9]+)+)" } #$scope = "/providers/Microsoft.BillingBenefits" $scope = "/providers/Microsoft.Billing/billingaccounts/$BillingAccountID" } else { $scope = "/providers/Microsoft.Billing/billingaccounts/$BillingAccountID" } } $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Starting savings plans export process for scope $scope..." $savingsPlansUsage = @() if ($BillingAccountID -match $mcaBillingAccountIdRegex) { #$savingsPlansUsagePath = "$scope/savingsPlans?api-version=2022-11-01&refreshsummary=true&take=100" $savingsPlansUsagePath = "$scope/savingsPlans?api-version=2022-10-01-privatepreview&refreshsummary=true&take=100&`$filter=(properties/billingProfileId eq '/providers/Microsoft.Billing/billingAccounts/$BillingAccountID/billingProfiles/$BillingProfileID')" } else { $savingsPlansUsagePath = "$scope/savingsPlans?api-version=2020-12-15-privatepreview&refreshsummary=true&take=100" } $result = Invoke-AzRestMethod -Path $savingsPlansUsagePath -Method GET if (-not($result.StatusCode -in (200, 201, 202))) { throw "Error while getting savings plans usage: $($result.Content)" } $savingsPlansUsageResponse = $result.Content | ConvertFrom-Json if ($savingsPlansUsageResponse.value) { $savingsPlansUsage += $savingsPlansUsageResponse.value } $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Found $($savingsPlansUsage.Count) savings plans usages." $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $savingsPlans = @() foreach ($usage in $savingsPlansUsage) { $savingsPlanEntry = New-Object PSObject -Property @{ SavingsPlanResourceId = $usage.id SavingsPlanOrderId = $usage.id.Substring(0,$usage.id.IndexOf("/savingsPlans/")) SavingsPlanId = $usage.id.Split("/")[-1] DisplayName = $usage.properties.displayName SKUName = $usage.sku.name Term = $usage.properties.term ProvisioningState = $usage.properties.displayProvisioningState AppliedScopeType = $usage.properties.userFriendlyAppliedScopeType RenewState = $usage.properties.renew PurchaseDate = $usage.properties.purchaseDateTime BenefitStart = $usage.properties.benefitStartTime ExpiryDate = $usage.properties.expiryDateTime EffectiveDate = $usage.properties.effectiveDateTime BillingScopeId = $usage.properties.billingScopeId BillingAccountId = $usage.properties.billingAccountId BillingProfileId = $usage.properties.billingProfileId BillingPlan = $usage.properties.billingProfileId CommitmentGrain = $usage.properties.commitment.grain CommitmentCurrencyCode = $usage.properties.commitment.currencyCode CommitmentAmount = $usage.properties.commitment.amount UtilTrend = $usage.properties.utilization.trend Util1Days = ($usage.properties.utilization.aggregates | Where-Object { $_.grain -eq 1 }).value Util7Days = ($usage.properties.utilization.aggregates | Where-Object { $_.grain -eq 7 }).value Util30Days = ($usage.properties.utilization.aggregates | Where-Object { $_.grain -eq 30 }).value Scope = $scope TenantGuid = $tenantId Cloud = $cloudEnvironment CollectedDate = $timestamp Timestamp = $timestamp } $savingsPlans += $savingsPlanEntry } $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Generated $($savingsPlans.Count) entries..." $targetDate = $datetime.ToString("yyyy-MM-dd") if ($BillingAccountID -match $mcaBillingAccountIdRegex) { $csvExportPath = "$targetDate-$BillingProfileID.csv" } else { $csvExportPath = "$targetDate-$BillingAccountID.csv" } $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploading CSV to Storage" $ci = [CultureInfo]::new([System.Threading.Thread]::CurrentThread.CurrentCulture.Name) if ($ci.NumberFormat.NumberDecimalSeparator -ne '.') { Write-Output "Current culture ($($ci.Name)) does not use . as decimal separator" $ci.NumberFormat.NumberDecimalSeparator = '.' [System.Threading.Thread]::CurrentThread.CurrentCulture = $ci } $savingsPlans | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." Remove-Item -Path $csvExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $csvExportPath from local disk..." ================================================ FILE: runbooks/data-collection/Ingest-OptimizationCSVExportsToLogAnalytics.ps1 ================================================ param( [Parameter(Mandatory = $true)] [string] $StorageSinkContainer ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $sharedKey = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceKey" $LogAnalyticsChunkSize = [int] (Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsChunkSize" -ErrorAction SilentlyContinue) if (-not($LogAnalyticsChunkSize -gt 0)) { $LogAnalyticsChunkSize = 6000 } $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = $StorageSinkContainer $StorageBlobsPageSize = [int] (Get-AutomationVariable -Name "AzureOptimization_StorageBlobsPageSize" -ErrorAction SilentlyContinue) if (-not($StorageBlobsPageSize -gt 0)) { $StorageBlobsPageSize = 1000 } $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } #region Functions # Function to create the authorization signature Function Build-OMSSignature ($workspaceId, $sharedKey, $date, $contentLength, $method, $contentType, $resource) { $xHeaders = "x-ms-date:" + $date $stringToHash = $method + "`n" + $contentLength + "`n" + $contentType + "`n" + $xHeaders + "`n" + $resource $bytesToHash = [Text.Encoding]::UTF8.GetBytes($stringToHash) $keyBytes = [Convert]::FromBase64String($sharedKey) $sha256 = New-Object System.Security.Cryptography.HMACSHA256 $sha256.Key = $keyBytes $calculatedHash = $sha256.ComputeHash($bytesToHash) $encodedHash = [Convert]::ToBase64String($calculatedHash) $authorization = 'SharedKey {0}:{1}' -f $workspaceId, $encodedHash return $authorization } # Function to create and post the request Function Post-OMSData($workspaceId, $sharedKey, $body, $logType, $TimeStampField, $AzureEnvironment) { $method = "POST" $contentType = "application/json" $resource = "/api/logs" $rfc1123date = [DateTime]::UtcNow.ToString("r") $contentLength = $body.Length $signature = Build-OMSSignature ` -workspaceId $workspaceId ` -sharedKey $sharedKey ` -date $rfc1123date ` -contentLength $contentLength ` -method $method ` -contentType $contentType ` -resource $resource $uri = "https://" + $workspaceId + ".ods.opinsights.azure.com" + $resource + "?api-version=2016-04-01" if ($AzureEnvironment -eq "AzureChinaCloud") { $uri = "https://" + $workspaceId + ".ods.opinsights.azure.cn" + $resource + "?api-version=2016-04-01" } if ($AzureEnvironment -eq "AzureUSGovernment") { $uri = "https://" + $workspaceId + ".ods.opinsights.azure.us" + $resource + "?api-version=2016-04-01" } if ($AzureEnvironment -eq "AzureGermanCloud") { throw "Azure Germany isn't suported for the Log Analytics Data Collector API" } $OMSheaders = @{ "Authorization" = $signature; "Log-Type" = $logType; "x-ms-date" = $rfc1123date; "time-generated-field" = $TimeStampField; } Try { $response = Invoke-WebRequest -Uri $uri -Method POST -ContentType $contentType -Headers $OMSheaders -Body $body -UseBasicParsing -TimeoutSec 1000 } catch { if ($_.Exception.Response.StatusCode.Value__ -eq 401) { "REAUTHENTICATING" $response = Invoke-WebRequest -Uri $uri -Method POST -ContentType $contentType -Headers $OMSheaders -Body $body -UseBasicParsing -TimeoutSec 1000 } else { return $_.Exception.Response.StatusCode.Value__ } } return $response.StatusCode } #endregion Functions # get reference to storage sink Write-Output "Getting blobs list from $storageAccountSink storage account ($storageAccountSinkContainer container)..." Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink $allblobs = @() $continuationToken = $null do { $blobs = Get-AzStorageBlob -Container $storageAccountSinkContainer -MaxCount $StorageBlobsPageSize -ContinuationToken $continuationToken -Context $sa.Context | Sort-Object -Property LastModified if ($blobs.Count -le 0) { break } $allblobs += $blobs $continuationToken = $blobs[$blobs.Count -1].ContinuationToken; } While ($null -ne $continuationToken) $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE StorageContainerName = '$storageAccountSinkContainer'" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $Conn.Close() $Conn.Dispose() if ($controlRows.Count -eq 0 -or -not($controlRows[0].LastProcessedDateTime)) { throw "Could not find a valid ingestion control row for $storageAccountSinkContainer" } $controlRow = $controlRows[0] $lastProcessedLine = $controlRow.LastProcessedLine $lastProcessedDateTime = $controlRow.LastProcessedDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") $LogAnalyticsSuffix = $controlRow.LogAnalyticsSuffix $logname = $lognamePrefix + $LogAnalyticsSuffix Write-Output "Processing blobs modified after $lastProcessedDateTime (line $lastProcessedLine) and ingesting them into the $($logname)_CL table..." $newProcessedTime = $null $unprocessedBlobs = @() foreach ($blob in $allblobs) { $blobLastModified = $blob.LastModified.UtcDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") if ($lastProcessedDateTime -lt $blobLastModified -or ` ($lastProcessedDateTime -eq $blobLastModified -and $lastProcessedLine -gt 0)) { Write-Output "$($blob.Name) found (modified on $blobLastModified)" $unprocessedBlobs += $blob } } $unprocessedBlobs = $unprocessedBlobs | Sort-Object -Property LastModified Write-Output "Found $($unprocessedBlobs.Count) new blobs to process..." foreach ($blob in $unprocessedBlobs) { $newProcessedTime = $blob.LastModified.UtcDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "About to process $($blob.Name)..." $blobFilePath = "$env:TEMP\$($blob.Name)" Get-AzStorageBlobContent -CloudBlob $blob.ICloudBlob -Context $sa.Context -Force -Destination $blobFilePath | Out-Null $r = [IO.File]::OpenText($blobFilePath) $linesProcessed = 0 $lineCounter = 0 $chunkLines = @() while ($r.Peek() -ge 0) { $line = $r.ReadLine() if ($lineCounter -eq 0) { $header = $line $chunkLines += $line } else { $linesProcessed++ } if ($lastProcessedLine -lt $linesProcessed -and $lineCounter -gt 0) { $chunkLines += $line } if (($lineCounter -eq $LogAnalyticsChunkSize -or $r.Peek() -lt 0) -and $linesProcessed -gt 0) { $csvObject = $chunkLines | ConvertFrom-Csv $jsonObject = ConvertTo-Json -InputObject $csvObject $res = Post-OMSData -workspaceId $workspaceId -sharedKey $sharedKey -body ([System.Text.Encoding]::UTF8.GetBytes($jsonObject)) -logType $logname -TimeStampField "Timestamp" -AzureEnvironment $cloudEnvironment if ($res -ge 200 -and $res -lt 300) { Write-Output "Succesfully uploaded $lineCounter $LogAnalyticsSuffix rows to Log Analytics" if ($r.Peek() -lt 0) { $lastProcessedLine = -1 } else { $lastProcessedLine = $linesProcessed - 1 } $updatedLastProcessedLine = $lastProcessedLine $updatedLastProcessedDateTime = $lastProcessedDateTime if ($r.Peek() -lt 0) { $updatedLastProcessedDateTime = $newProcessedTime } $lastProcessedDateTime = $updatedLastProcessedDateTime Write-Output "Updating last processed time / line to $($updatedLastProcessedDateTime) / $updatedLastProcessedLine" $sqlStatement = "UPDATE [$LogAnalyticsIngestControlTable] SET LastProcessedLine = $updatedLastProcessedLine, LastProcessedDateTime = '$updatedLastProcessedDateTime' WHERE StorageContainerName = '$storageAccountSinkContainer'" $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandText = $sqlStatement $Cmd.CommandTimeout=120 $Cmd.ExecuteReader() $Conn.Close() $Conn.Dispose() } else { Write-Warning "Failed to upload $lineCounter $LogAnalyticsSuffix rows. Error code: $res" $r.Dispose() Remove-Item -Path $blobFilePath -Force throw } $chunkLines = @() $chunkLines += $header $lineCounter = 1 } else { $lineCounter++ } } $r.Dispose() if ($linesProcessed -eq 0) { Write-Output "No rows found" $updatedLastProcessedLine = -1 $updatedLastProcessedDateTime = $newProcessedTime Write-Output "Updating last processed time / line to $($updatedLastProcessedDateTime) / $updatedLastProcessedLine" $sqlStatement = "UPDATE [$LogAnalyticsIngestControlTable] SET LastProcessedLine = $updatedLastProcessedLine, LastProcessedDateTime = '$updatedLastProcessedDateTime' WHERE StorageContainerName = '$storageAccountSinkContainer'" $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandText = $sqlStatement $Cmd.CommandTimeout=120 $Cmd.ExecuteReader() $Conn.Close() $Conn.Dispose() } else { Write-Output "Processed $linesProcessed row(s) in total." } Remove-Item -Path $blobFilePath -Force } Write-Output "DONE" ================================================ FILE: runbooks/maintenance/CleanUp-OlderRecommendationsFromSqlServer.ps1 ================================================ $ErrorActionPreference = "Stop" $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $RecommendationsMaxAge = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendationsMaxAgeInDays" -ErrorAction SilentlyContinue) if (-not($RecommendationsMaxAge -gt 0)) { $RecommendationsMaxAge = 365 } $recommendationsTable = "Recommendations" $tries = 0 $connectionSuccess = $false Write-Output "Cleaning up recommendations older than $RecommendationsMaxAge days..." do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = 0 $Cmd.CommandText = "DELETE FROM [dbo].[$recommendationsTable] WHERE GeneratedDate < GETDATE()-$RecommendationsMaxAge" $DeletedRows = $Cmd.ExecuteNonQuery() $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } finally { $Conn.Close() $Conn.Dispose() } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } Write-Output "Cleaned up $DeletedRows recommendations." ================================================ FILE: runbooks/recommendations/Ingest-RecommendationsToLogAnalytics.ps1 ================================================ $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $sharedKey = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceKey" $LogAnalyticsChunkSize = [int] (Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsChunkSize" -ErrorAction SilentlyContinue) if (-not($LogAnalyticsChunkSize -gt 0)) { $LogAnalyticsChunkSize = 6000 } $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $StorageBlobsPageSize = [int] (Get-AutomationVariable -Name "AzureOptimization_StorageBlobsPageSize" -ErrorAction SilentlyContinue) if (-not($StorageBlobsPageSize -gt 0)) { $StorageBlobsPageSize = 1000 } #region Functions # Function to create the authorization signature Function Build-OMSSignature ($workspaceId, $sharedKey, $date, $contentLength, $method, $contentType, $resource) { $xHeaders = "x-ms-date:" + $date $stringToHash = $method + "`n" + $contentLength + "`n" + $contentType + "`n" + $xHeaders + "`n" + $resource $bytesToHash = [Text.Encoding]::UTF8.GetBytes($stringToHash) $keyBytes = [Convert]::FromBase64String($sharedKey) $sha256 = New-Object System.Security.Cryptography.HMACSHA256 $sha256.Key = $keyBytes $calculatedHash = $sha256.ComputeHash($bytesToHash) $encodedHash = [Convert]::ToBase64String($calculatedHash) $authorization = 'SharedKey {0}:{1}' -f $workspaceId, $encodedHash return $authorization } # Function to create and post the request Function Post-OMSData($workspaceId, $sharedKey, $body, $logType, $TimeStampField, $AzureEnvironment) { $method = "POST" $contentType = "application/json" $resource = "/api/logs" $rfc1123date = [DateTime]::UtcNow.ToString("r") $contentLength = $body.Length $signature = Build-OMSSignature ` -workspaceId $workspaceId ` -sharedKey $sharedKey ` -date $rfc1123date ` -contentLength $contentLength ` -method $method ` -contentType $contentType ` -resource $resource $uri = "https://" + $workspaceId + ".ods.opinsights.azure.com" + $resource + "?api-version=2016-04-01" if ($AzureEnvironment -eq "AzureChinaCloud") { $uri = "https://" + $workspaceId + ".ods.opinsights.azure.cn" + $resource + "?api-version=2016-04-01" } if ($AzureEnvironment -eq "AzureUSGovernment") { $uri = "https://" + $workspaceId + ".ods.opinsights.azure.us" + $resource + "?api-version=2016-04-01" } if ($AzureEnvironment -eq "AzureGermanCloud") { throw "Azure Germany isn't suported for the Log Analytics Data Collector API" } $OMSheaders = @{ "Authorization" = $signature; "Log-Type" = $logType; "x-ms-date" = $rfc1123date; "time-generated-field" = $TimeStampField; } Try { $response = Invoke-WebRequest -Uri $uri -Method POST -ContentType $contentType -Headers $OMSheaders -Body $body -UseBasicParsing -TimeoutSec 1000 } catch { if ($_.Exception.Response.StatusCode.Value__ -eq 401) { "REAUTHENTICATING" $response = Invoke-WebRequest -Uri $uri -Method POST -ContentType $contentType -Headers $OMSheaders -Body $body -UseBasicParsing -TimeoutSec 1000 } else { return $_.Exception.Response.StatusCode.Value__ } } return $response.StatusCode } #endregion Functions "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } # get reference to storage sink Write-Output "Getting reference to $storageAccountSink storage account (recommendations exports sink)" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context $allblobs = @() Write-Output "Getting blobs list..." $continuationToken = $null do { $blobs = Get-AzStorageBlob -Container $storageAccountSinkContainer -MaxCount $StorageBlobsPageSize -ContinuationToken $continuationToken -Context $saCtx | Sort-Object -Property LastModified if ($blobs.Count -le 0) { break } $allblobs += $blobs $continuationToken = $blobs[$blobs.Count -1].ContinuationToken; } While ($null -ne $continuationToken) $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE StorageContainerName = '$storageAccountSinkContainer'" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $Conn.Close() $Conn.Dispose() if ($controlRows.Count -eq 0 -or -not($controlRows[0].LastProcessedDateTime)) { throw "Could not find a valid ingestion control row for $storageAccountSinkContainer" } $controlRow = $controlRows[0] $lastProcessedLine = $controlRow.LastProcessedLine $lastProcessedDateTime = $controlRow.LastProcessedDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") $LogAnalyticsSuffix = $controlRow.LogAnalyticsSuffix $logname = $lognamePrefix + $LogAnalyticsSuffix Write-Output "Processing blobs modified after $lastProcessedDateTime (line $lastProcessedLine) and ingesting them into the $($logname)_CL table..." $newProcessedTime = $null $unprocessedBlobs = @() foreach ($blob in $allblobs) { $blobLastModified = $blob.LastModified.UtcDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") if ($lastProcessedDateTime -lt $blobLastModified -or ` ($lastProcessedDateTime -eq $blobLastModified -and $lastProcessedLine -gt 0)) { Write-Output "$($blob.Name) found (modified on $blobLastModified)" $unprocessedBlobs += $blob } } $unprocessedBlobs = $unprocessedBlobs | Sort-Object -Property LastModified Write-Output "Found $($unprocessedBlobs.Count) new blobs to process..." foreach ($blob in $unprocessedBlobs) { $newProcessedTime = $blob.LastModified.UtcDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "About to process $($blob.Name)..." Get-AzStorageBlobContent -CloudBlob $blob.ICloudBlob -Context $saCtx -Force $jsonObject = Get-Content -Path $blob.Name | ConvertFrom-Json Write-Output "Blob contains $($jsonObject.Count) results..." if ($null -eq $jsonObject) { $recCount = 0 } elseif ($null -eq $jsonObject.Count) { $recCount = 1 } else { $recCount = $jsonObject.Count } $linesProcessed = 0 $jsonObjectSplitted = @() if ($recCount -gt 1) { for ($i = 0; $i -lt $recCount; $i += $LogAnalyticsChunkSize) { $jsonObjectSplitted += , @($jsonObject[$i..($i + ($LogAnalyticsChunkSize - 1))]); } } else { $jsonObjectArray = @() $jsonObjectArray += $jsonObject $jsonObjectSplitted += , $jsonObjectArray } for ($j = 0; $j -lt $jsonObjectSplitted.Count; $j++) { if ($jsonObjectSplitted[$j]) { $currentObjectLines = $jsonObjectSplitted[$j].Count if ($lastProcessedLine -lt $linesProcessed) { for ($i = 0; $i -lt $jsonObjectSplitted[$j].Count; $i++) { $jsonObjectSplitted[$j][$i].RecommendationDescription = $jsonObjectSplitted[$j][$i].RecommendationDescription.Replace("'", "") $jsonObjectSplitted[$j][$i].RecommendationAction = $jsonObjectSplitted[$j][$i].RecommendationAction.Replace("'", "") $jsonObjectSplitted[$j][$i].AdditionalInfo = $jsonObjectSplitted[$j][$i].AdditionalInfo | ConvertTo-Json -Compress $jsonObjectSplitted[$j][$i].Tags = $jsonObjectSplitted[$j][$i].Tags | ConvertTo-Json -Compress } $jsonObject = ConvertTo-Json -InputObject $jsonObjectSplitted[$j] $res = Post-OMSData -workspaceId $workspaceId -sharedKey $sharedKey -body ([System.Text.Encoding]::UTF8.GetBytes($jsonObject)) -logType $logname -TimeStampField "Timestamp" -AzureEnvironment $cloudEnvironment If ($res -ge 200 -and $res -lt 300) { Write-Output "Succesfully uploaded $currentObjectLines $LogAnalyticsSuffix rows to Log Analytics" $linesProcessed += $currentObjectLines if ($j -eq ($jsonObjectSplitted.Count - 1)) { $lastProcessedLine = -1 } else { $lastProcessedLine = $linesProcessed - 1 } $updatedLastProcessedLine = $lastProcessedLine $updatedLastProcessedDateTime = $lastProcessedDateTime if ($j -eq ($jsonObjectSplitted.Count - 1)) { $updatedLastProcessedDateTime = $newProcessedTime } $lastProcessedDateTime = $updatedLastProcessedDateTime Write-Output "Updating last processed time / line to $($updatedLastProcessedDateTime) / $updatedLastProcessedLine" $sqlStatement = "UPDATE [$LogAnalyticsIngestControlTable] SET LastProcessedLine = $updatedLastProcessedLine, LastProcessedDateTime = '$updatedLastProcessedDateTime' WHERE StorageContainerName = '$storageAccountSinkContainer'" $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandText = $sqlStatement $Cmd.CommandTimeout=120 $Cmd.ExecuteReader() $Conn.Close() $Conn.Dispose() } Else { $linesProcessed += $currentObjectLines Write-Warning "Failed to upload $currentObjectLines $LogAnalyticsSuffix rows. Error code: $res" throw } } else { $linesProcessed += $currentObjectLines } } } Remove-Item -Path $blob.Name -Force } Write-Output "DONE" ================================================ FILE: runbooks/recommendations/Ingest-RecommendationsToSQLServer.ps1 ================================================ $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $ChunkSize = [int] (Get-AutomationVariable -Name "AzureOptimization_SQLServerInsertSize" -ErrorAction SilentlyContinue) if (-not($ChunkSize -gt 0)) { $ChunkSize = 900 } $SqlTimeout = 120 $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $StorageBlobsPageSize = [int] (Get-AutomationVariable -Name "AzureOptimization_StorageBlobsPageSize" -ErrorAction SilentlyContinue) if (-not($StorageBlobsPageSize -gt 0)) { $StorageBlobsPageSize = 1000 } "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } # get reference to storage sink Write-Output "Getting reference to $storageAccountSink storage account (recommendations exports sink)" Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context $allblobs = @() Write-Output "Getting blobs list..." $continuationToken = $null do { $blobs = Get-AzStorageBlob -Container $storageAccountSinkContainer -MaxCount $StorageBlobsPageSize -ContinuationToken $continuationToken -Context $saCtx | Sort-Object -Property LastModified if ($blobs.Count -le 0) { break } $allblobs += $blobs $continuationToken = $blobs[$blobs.Count -1].ContinuationToken; } While ($null -ne $continuationToken) $SqlServerIngestControlTable = "SqlServerIngestControl" $recommendationsTable = "Recommendations" $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$SqlServerIngestControlTable] WHERE StorageContainerName = '$storageAccountSinkContainer' and SqlTableName = '$recommendationsTable'" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } if ($controlRows.Count -eq 0) { throw "Could not find a control row for $storageAccountSinkContainer container and $recommendationsTable table." } $controlRow = $controlRows[0] $lastProcessedLine = $controlRow.LastProcessedLine $lastProcessedDateTime = $controlRow.LastProcessedDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") $Conn.Close() $Conn.Dispose() Write-Output "Processing blobs modified after $lastProcessedDateTime (line $lastProcessedLine) and ingesting them into the Recommendations SQL table..." $newProcessedTime = $null $unprocessedBlobs = @() foreach ($blob in $allblobs) { $blobLastModified = $blob.LastModified.UtcDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") if ($lastProcessedDateTime -lt $blobLastModified -or ` ($lastProcessedDateTime -eq $blobLastModified -and $lastProcessedLine -gt 0)) { Write-Output "$($blob.Name) found (modified on $blobLastModified)" $unprocessedBlobs += $blob } } $unprocessedBlobs = $unprocessedBlobs | Sort-Object -Property LastModified Write-Output "Found $($unprocessedBlobs.Count) new blobs to process..." foreach ($blob in $unprocessedBlobs) { $newProcessedTime = $blob.LastModified.UtcDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "About to process $($blob.Name)..." Get-AzStorageBlobContent -CloudBlob $blob.ICloudBlob -Context $saCtx -Force $jsonObject = Get-Content -Path $blob.Name | ConvertFrom-Json Write-Output "Blob contains $($jsonObject.Count) results..." if ($null -eq $jsonObject) { $recCount = 0 } elseif ($null -eq $jsonObject.Count) { $recCount = 1 } else { $recCount = $jsonObject.Count } $linesProcessed = 0 $jsonObjectSplitted = @() if ($recCount -gt 1) { for ($i = 0; $i -lt $recCount; $i += $ChunkSize) { $jsonObjectSplitted += , @($jsonObject[$i..($i + ($ChunkSize - 1))]); } } else { $jsonObjectArray = @() $jsonObjectArray += $jsonObject $jsonObjectSplitted += , $jsonObjectArray } for ($j = 0; $j -lt $jsonObjectSplitted.Count; $j++) { if ($jsonObjectSplitted[$j]) { $currentObjectLines = $jsonObjectSplitted[$j].Count if ($lastProcessedLine -lt $linesProcessed) { $sqlStatement = "INSERT INTO [$recommendationsTable]" $sqlStatement += " (RecommendationId, GeneratedDate, Cloud, Category, ImpactedArea, Impact, RecommendationType, RecommendationSubType," $sqlStatement += " RecommendationSubTypeId, RecommendationDescription, RecommendationAction, InstanceId, InstanceName, AdditionalInfo," $sqlStatement += " ResourceGroup, SubscriptionGuid, SubscriptionName, TenantGuid, FitScore, Tags, DetailsUrl) VALUES" for ($i = 0; $i -lt $jsonObjectSplitted[$j].Count; $i++) { $jsonObjectSplitted[$j][$i].RecommendationDescription = $jsonObjectSplitted[$j][$i].RecommendationDescription.Replace("'", "") $jsonObjectSplitted[$j][$i].RecommendationAction = $jsonObjectSplitted[$j][$i].RecommendationAction.Replace("'", "") if ($null -ne $jsonObjectSplitted[$j][$i].InstanceName) { $jsonObjectSplitted[$j][$i].InstanceName = $jsonObjectSplitted[$j][$i].InstanceName.Replace("'", "") } $additionalInfoString = $jsonObjectSplitted[$j][$i].AdditionalInfo | ConvertTo-Json -Compress $tagsString = $jsonObjectSplitted[$j][$i].Tags | ConvertTo-Json -Compress $subscriptionGuid = "NULL" if ($jsonObjectSplitted[$j][$i].SubscriptionGuid) { $subscriptionGuid = "'$($jsonObjectSplitted[$j][$i].SubscriptionGuid)'" } $subscriptionName = "NULL" if ($jsonObjectSplitted[$j][$i].SubscriptionName) { $subscriptionName = $jsonObjectSplitted[$j][$i].SubscriptionName.Replace("'", "") $subscriptionName = "'$subscriptionName'" } $resourceGroup = "NULL" if ($jsonObjectSplitted[$j][$i].ResourceGroup) { $resourceGroup = "'$($jsonObjectSplitted[$j][$i].ResourceGroup)'" } $sqlStatement += " (NEWID(), CONVERT(DATETIME, '$($jsonObjectSplitted[$j][$i].Timestamp)'), '$($jsonObjectSplitted[$j][$i].Cloud)'" $sqlStatement += ", '$($jsonObjectSplitted[$j][$i].Category)', '$($jsonObjectSplitted[$j][$i].ImpactedArea)'" $sqlStatement += ", '$($jsonObjectSplitted[$j][$i].Impact)', '$($jsonObjectSplitted[$j][$i].RecommendationType)'" $sqlStatement += ", '$($jsonObjectSplitted[$j][$i].RecommendationSubType)', '$($jsonObjectSplitted[$j][$i].RecommendationSubTypeId)'" $sqlStatement += ", '$($jsonObjectSplitted[$j][$i].RecommendationDescription)', '$($jsonObjectSplitted[$j][$i].RecommendationAction)'" $sqlStatement += ", '$($jsonObjectSplitted[$j][$i].InstanceId)', '$($jsonObjectSplitted[$j][$i].InstanceName)', '$additionalInfoString'" $sqlStatement += ", $resourceGroup, $subscriptionGuid, $subscriptionName, '$($jsonObjectSplitted[$j][$i].TenantGuid)'" $sqlStatement += ", $($jsonObjectSplitted[$j][$i].FitScore), '$tagsString', '$($jsonObjectSplitted[$j][$i].DetailsURL)')" if ($i -ne ($jsonObjectSplitted[$j].Count-1)) { $sqlStatement += "," } } $Conn2 = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn2.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn2 $Cmd.CommandText = $sqlStatement $Cmd.CommandTimeout=120 try { $Cmd.ExecuteReader() } catch { Write-Output "Failed statement: $sqlStatement" throw } $Conn2.Close() $linesProcessed += $currentObjectLines Write-Output "Processed $linesProcessed lines..." if ($j -eq ($jsonObjectSplitted.Count - 1)) { $lastProcessedLine = -1 } else { $lastProcessedLine = $linesProcessed - 1 } $updatedLastProcessedLine = $lastProcessedLine $updatedLastProcessedDateTime = $lastProcessedDateTime if ($j -eq ($jsonObjectSplitted.Count - 1)) { $updatedLastProcessedDateTime = $newProcessedTime } $lastProcessedDateTime = $updatedLastProcessedDateTime Write-Output "Updating last processed time / line to $($updatedLastProcessedDateTime) / $updatedLastProcessedLine" $sqlStatement = "UPDATE [$SqlServerIngestControlTable] SET LastProcessedLine = $updatedLastProcessedLine, LastProcessedDateTime = '$updatedLastProcessedDateTime' WHERE StorageContainerName = '$storageAccountSinkContainer'" $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandText = $sqlStatement $Cmd.CommandTimeout=$SqlTimeout $Cmd.ExecuteReader() $Conn.Close() } else { $linesProcessed += $currentObjectLines } } } Remove-Item -Path $blob.Name -Force } Write-Output "DONE" ================================================ FILE: runbooks/recommendations/Ingest-SuppressionsToLogAnalytics.ps1 ================================================ $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $sharedKey = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceKey" $LogAnalyticsChunkSize = [int] (Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsChunkSize" -ErrorAction SilentlyContinue) if (-not($LogAnalyticsChunkSize -gt 0)) { $LogAnalyticsChunkSize = 6000 } $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $SqlTimeout = 300 $FiltersTable = "Filters" #region Functions # Function to create the authorization signature Function Build-OMSSignature ($workspaceId, $sharedKey, $date, $contentLength, $method, $contentType, $resource) { $xHeaders = "x-ms-date:" + $date $stringToHash = $method + "`n" + $contentLength + "`n" + $contentType + "`n" + $xHeaders + "`n" + $resource $bytesToHash = [Text.Encoding]::UTF8.GetBytes($stringToHash) $keyBytes = [Convert]::FromBase64String($sharedKey) $sha256 = New-Object System.Security.Cryptography.HMACSHA256 $sha256.Key = $keyBytes $calculatedHash = $sha256.ComputeHash($bytesToHash) $encodedHash = [Convert]::ToBase64String($calculatedHash) $authorization = 'SharedKey {0}:{1}' -f $workspaceId, $encodedHash return $authorization } # Function to create and post the request Function Post-OMSData($workspaceId, $sharedKey, $body, $logType, $TimeStampField, $AzureEnvironment) { $method = "POST" $contentType = "application/json" $resource = "/api/logs" $rfc1123date = [DateTime]::UtcNow.ToString("r") $contentLength = $body.Length $signature = Build-OMSSignature ` -workspaceId $workspaceId ` -sharedKey $sharedKey ` -date $rfc1123date ` -contentLength $contentLength ` -method $method ` -contentType $contentType ` -resource $resource $uri = "https://" + $workspaceId + ".ods.opinsights.azure.com" + $resource + "?api-version=2016-04-01" if ($AzureEnvironment -eq "AzureChinaCloud") { $uri = "https://" + $workspaceId + ".ods.opinsights.azure.cn" + $resource + "?api-version=2016-04-01" } if ($AzureEnvironment -eq "AzureUSGovernment") { $uri = "https://" + $workspaceId + ".ods.opinsights.azure.us" + $resource + "?api-version=2016-04-01" } if ($AzureEnvironment -eq "AzureGermanCloud") { throw "Azure Germany isn't suported for the Log Analytics Data Collector API" } $OMSheaders = @{ "Authorization" = $signature; "Log-Type" = $logType; "x-ms-date" = $rfc1123date; "time-generated-field" = $TimeStampField; } Try { $response = Invoke-WebRequest -Uri $uri -Method POST -ContentType $contentType -Headers $OMSheaders -Body $body -UseBasicParsing -TimeoutSec 1000 } catch { if ($_.Exception.Response.StatusCode.Value__ -eq 401) { "REAUTHENTICATING" $response = Invoke-WebRequest -Uri $uri -Method POST -ContentType $contentType -Headers $OMSheaders -Body $body -UseBasicParsing -TimeoutSec 1000 } else { return $_.Exception.Response.StatusCode.Value__ } } return $response.StatusCode } #endregion Functions Write-Output "Getting excluded recommendation sub-type IDs..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$FiltersTable] WHERE IsEnabled = 1 AND (FilterEndDate IS NULL OR FilterEndDate > GETDATE())" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $filters = New-Object System.Data.DataTable $sqlAdapter.Fill($filters) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $Conn.Close() $Conn.Dispose() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $filterObjects = @() $filterObject = New-Object PSObject -Property @{ Timestamp = $timestamp FilterId = (New-Guid).Guid RecommendationSubTypeId = [System.Guid]::empty.Guid FilterType = "Dummy" InstanceId = [System.Guid]::empty.Guid InstanceName = "Dummy" FilterStartDate = "2019-01-01T00:00:00.000Z" FilterEndDate = "2199-12-31T23:59:59.000Z" Author = "AOE" Notes = "This is a dummy suppression required to build the full suppressions schema in Log Analytics" } $filterObjects += $filterObject foreach ($filter in $filters) { $filterEndDate = $null if (-not([string]::IsNullOrEmpty($filter.FilterEndDate))) { Write-Output $filter.FilterEndDate $filterEndDate = $filter.FilterEndDate.ToString("yyyy-MM-ddTHH:mm:00.000Z") } else { $filterEndDate = "2199-12-31T23:59:59.000Z" } $filterStartDate = $null if (-not([string]::IsNullOrEmpty($filter.FilterStartDate))) { $filterStartDate = $filter.FilterStartDate.ToString("yyyy-MM-ddTHH:mm:00.000Z") } else { $filterStartDate = "2019-01-01T00:00:00.000Z" } $instanceId = $null $instanceName = $null $ObjectGuid = [System.Guid]::empty if ([System.Guid]::TryParse($filter.InstanceId, [System.Management.Automation.PSReference]$ObjectGuid)) { $instanceId = $filter.InstanceId } else { $instanceName = $filter.InstanceId } $filterObject = New-Object PSObject -Property @{ Timestamp = $timestamp FilterId = $filter.FilterId RecommendationSubTypeId = $filter.RecommendationSubTypeId FilterType = $filter.FilterType InstanceId = $instanceId InstanceName = $instanceName FilterStartDate = $filterStartDate FilterEndDate = $filterEndDate Author = $filter.Author Notes = $filter.Notes } $filterObjects += $filterObject } $filtersJson = $filterObjects | ConvertTo-Json $LogAnalyticsSuffix = "SuppressionsV1" $logname = $lognamePrefix + $LogAnalyticsSuffix $res = Post-OMSData -workspaceId $workspaceId -sharedKey $sharedKey -body ([System.Text.Encoding]::UTF8.GetBytes($filtersJson)) -logType $logname -TimeStampField "Timestamp" -AzureEnvironment $cloudEnvironment If ($res -ge 200 -and $res -lt 300) { Write-Output "Succesfully uploaded $($filterObjects.Count) $LogAnalyticsSuffix rows to Log Analytics" } Else { Write-Warning "Failed to upload $($filterObjects.Count) $LogAnalyticsSuffix rows. Error code: $res" throw } ================================================ FILE: runbooks/recommendations/Recommend-AADExpiringCredentialsToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $expiringCredsDays = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendationAADMinCredValidityDays") $notExpiringCredsDays = ([int] (Get-AutomationVariable -Name "AzureOptimization_RecommendationAADMaxCredValidityYears")) * 365 $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('AADObjects')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $aadObjectsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AADObjects' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $aadObjectsTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 1 # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } $recommendationsErrors = 0 # Execute the expiring creds recommendation query against Log Analytics $baseQuery = @" let expiryInterval = $($expiringCredsDays)d; let AppsAndKeys = materialize ($aadObjectsTableName | where TimeGenerated > ago(1d) | where ObjectType_s in ('Application','ServicePrincipal') | where ObjectSubType_s != 'ManagedIdentity' | where Keys_s startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | mv-expand Keys | evaluate bag_unpack(Keys) | union ( $aadObjectsTableName | where TimeGenerated > ago(1d) | where ObjectType_s in ('Application','ServicePrincipal') | where ObjectSubType_s != 'ManagedIdentity' | where isnotempty(Keys_s) and Keys_s !startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | evaluate bag_unpack(Keys) ) ); let ExpirationInRisk = AppsAndKeys | where EndDate < now()+expiryInterval | project ApplicationId_g, KeyId, RiskDate = EndDate; let NotInRisk = AppsAndKeys | where EndDate > now()+expiryInterval | project ApplicationId_g, KeyId, ComfortDate = EndDate; let ApplicationsInRisk = ExpirationInRisk | join kind=leftouter ( NotInRisk ) on ApplicationId_g | where isempty(ComfortDate) | summarize ExpiresOn = max(RiskDate) by ApplicationId_g; AppsAndKeys | join kind=inner (ApplicationsInRisk) on ApplicationId_g | summarize ExpiresOn = max(EndDate) by ApplicationId_g, ObjectType_s, DisplayName_s, Cloud_s, KeyType, TenantGuid_g | order by ExpiresOn desc "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.ApplicationId_g $detailsURL = "https://portal.azure.$azureTld/#blade/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/Credentials/appId/$queryInstanceId" $additionalInfoDictionary = @{} $additionalInfoDictionary["ObjectType"] = $result.ObjectType_s $additionalInfoDictionary["KeyType"] = $result.KeyType $additionalInfoDictionary["ExpiresOn"] = $result.ExpiresOn $fitScore = 5 $tags = @{} $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "OperationalExcellence" ImpactedArea = "Microsoft.AzureActiveDirectory/objects" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "AADExpiringCredentials" RecommendationSubTypeId = "3292c489-2782-498b-aad0-a4cef50f6ca2" RecommendationDescription = "Microsoft Entra application with credentials expired or about to expire" RecommendationAction = "Update the Microsoft Entra application credential before the expiration date" InstanceId = $result.ApplicationId_g InstanceName = $result.DisplayName_s AdditionalInfo = $additionalInfoDictionary TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "aadexpiringcerts-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." # Execute the not expiring in less than X years creds recommendation query against Log Analytics $baseQuery = @" let expiryInterval = $($notExpiringCredsDays)d; let AppsAndKeys = materialize ($aadObjectsTableName | where TimeGenerated > ago(1d) | where ObjectSubType_s != 'ManagedIdentity' | where Keys_s startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | mv-expand Keys | evaluate bag_unpack(Keys) | union ( $aadObjectsTableName | where TimeGenerated > ago(1d) | where ObjectSubType_s != 'ManagedIdentity' | where isnotempty(Keys_s) and Keys_s !startswith '[' | extend Keys = parse_json(Keys_s) | project-away Keys_s | evaluate bag_unpack(Keys) ) ); AppsAndKeys | where EndDate > now()+expiryInterval | project ApplicationId_g, ObjectType_s, DisplayName_s, Cloud_s, KeyType, TenantGuid_g, EndDate "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.ApplicationId_g $detailsURL = "https://portal.azure.$azureTld/#blade/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/Credentials/appId/$queryInstanceId" $additionalInfoDictionary = @{} $additionalInfoDictionary["ObjectType"] = $result.ObjectType_s $additionalInfoDictionary["KeyType"] = $result.KeyType $additionalInfoDictionary["ExpiresOn"] = $result.EndDate $fitScore = 5 $tags = @{} $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Security" ImpactedArea = "Microsoft.AzureActiveDirectory/objects" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "AADNotExpiringCredentials" RecommendationSubTypeId = "ecd969c8-3f16-481a-9577-5ed32e5e1a1d" RecommendationDescription = "Microsoft Entra application with credentials expiration not set or too far in time" RecommendationAction = "Update the Microsoft Entra application credential with a shorter expiration date" InstanceId = $result.ApplicationId_g InstanceName = $result.DisplayName_s AdditionalInfo = $additionalInfoDictionary TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "aadnotexpiringcerts-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." if ($recommendationsErrors -gt 0) { throw "Some of the recommendations queries failed. Please, review the job logs for additional information." } ================================================ FILE: runbooks/recommendations/Recommend-ARMOptimizationsToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $deploymentDate = Get-AutomationVariable -Name "AzureOptimization_DeploymentDate" # yyyy-MM-dd format $deploymentDate = $deploymentDate.Replace('"', "") $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $assignmentsPercentageThresholdVar = Get-AutomationVariable -Name "AzureOptimization_RecommendationRBACAssignmentsPercentageThreshold" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($assignmentsPercentageThresholdVar) -or $assignmentsPercentageThresholdVar -eq 0) { $assignmentsPercentageThreshold = 80 } else { $assignmentsPercentageThreshold = [int] $assignmentsPercentageThresholdVar } $assignmentsSubscriptionsLimitVar = Get-AutomationVariable -Name "AzureOptimization_RecommendationRBACSubscriptionsAssignmentsLimit" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($assignmentsSubscriptionsLimitVar) -or $assignmentsSubscriptionsLimitVar -eq 0) { $assignmentsSubscriptionsLimit = 4000 } else { $assignmentsSubscriptionsLimit = [int] $assignmentsSubscriptionsLimitVar } $assignmentsMgmtGroupsLimitVar = Get-AutomationVariable -Name "AzureOptimization_RecommendationRBACMgmtGroupsAssignmentsLimit" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($assignmentsMgmtGroupsLimitVar) -or $assignmentsMgmtGroupsLimitVar -eq 0) { $assignmentsMgmtGroupsLimit = 500 } else { $assignmentsMgmtGroupsLimit = [int] $assignmentsMgmtGroupsLimitVar } $rgPercentageThresholdVar = Get-AutomationVariable -Name "AzureOptimization_RecommendationResourceGroupsPerSubPercentageThreshold" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($rgPercentageThresholdVar) -or $rgPercentageThresholdVar -eq 0) { $rgPercentageThreshold = 80 } else { $rgPercentageThreshold = [int] $rgPercentageThresholdVar } $rgLimitVar = Get-AutomationVariable -Name "AzureOptimization_RecommendationResourceGroupsPerSubLimit" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($rgLimitVar) -or $rgLimitVar -eq 0) { $rgLimit = 980 } else { $rgLimit = [int] $rgLimitVar } $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('RBACAssignments','ARGResourceContainers')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $rbacTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'RBACAssignments' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $rbacTableName and $subscriptionsTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 30 + $consumptionOffsetDaysStart # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } $recommendationsErrors = 0 $assignmentsThreshold = $assignmentsSubscriptionsLimit * ($assignmentsPercentageThreshold / 100) Write-Output "Looking for subscriptions with more than $assignmentsPercentageThreshold% of the $assignmentsSubscriptionsLimit RBAC assignments limit..." $baseQuery = @" $rbacTableName | where TimeGenerated > ago(1d) and Model_s == 'AzureRM' and Scope_s startswith '/subscriptions/' | extend SubscriptionGuid_g = tostring(split(Scope_s, '/')[2]) | summarize AssignmentsCount=count() by SubscriptionGuid_g, TenantGuid_g, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s, Tags_s, InstanceId_s ) on SubscriptionGuid_g | where AssignmentsCount >= $assignmentsThreshold "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/users" $additionalInfoDictionary = @{} $additionalInfoDictionary["assignmentsCount"] = $result.AssignmentsCount $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "OperationalExcellence" ImpactedArea = "Microsoft.Resources/subscriptions" Impact = "High" RecommendationType = "BestPractices" RecommendationSubType = "HighRBACAssignmentsSubscriptions" RecommendationSubTypeId = "c6a88d8c-3242-44b0-9793-c91897ef68bc" RecommendationDescription = "Subscriptions close to the maximum limit of RBAC assignments" RecommendationAction = "Remove unneeded RBAC assignments or use group-based (or nested group-based) assignments" InstanceId = $result.InstanceId_s InstanceName = $result.SubscriptionName AdditionalInfo = $additionalInfoDictionary SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "subscriptionsrbaclimits-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." $assignmentsThreshold = $assignmentsMgmtGroupsLimit * ($assignmentsPercentageThreshold / 100) Write-Output "Looking for management groups with more than $assignmentsPercentageThreshold% of the $assignmentsMgmtGroupsLimit RBAC assignments limit..." $baseQuery = @" $rbacTableName | where TimeGenerated > ago(1d) and Model_s == 'AzureRM' and Scope_s has 'managementGroups' | extend ManagementGroupId = tostring(split(Scope_s, '/')[4]) | summarize AssignmentsCount=count() by ManagementGroupId, TenantGuid_g, Scope_s, Cloud_s | where AssignmentsCount >= $assignmentsThreshold "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/blade/Microsoft_Azure_ManagementGroups/ManagementGroupBrowseBlade/MGBrowse_overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["assignmentsCount"] = $result.AssignmentsCount $fitScore = 5 $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "OperationalExcellence" ImpactedArea = "Microsoft.Management/managementGroups" Impact = "High" RecommendationType = "BestPractices" RecommendationSubType = "HighRBACAssignmentsManagementGroups" RecommendationSubTypeId = "b36dea3e-ef21-45a9-a704-6f629fab236d" RecommendationDescription = "Management Groups close to the maximum limit of RBAC assignments" RecommendationAction = "Remove unneeded RBAC assignments or use group-based (or nested group-based) assignments" InstanceId = $result.Scope_s InstanceName = $result.ManagementGroupId AdditionalInfo = $additionalInfoDictionary TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "mgmtgroupsrbaclimits-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." $rgThreshold = $rgLimit * ($rgPercentageThreshold / 100) Write-Output "Looking for subscriptions with more than $rgPercentageThreshold% of the $rgLimit Resource Groups limit..." $baseQuery = @" $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions/resourceGroups' | summarize RGCount=count() by SubscriptionGuid_g, TenantGuid_g, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s, Tags_s, InstanceId_s ) on SubscriptionGuid_g | where RGCount >= $rgThreshold "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/resourceGroups" $additionalInfoDictionary = @{} $additionalInfoDictionary["resourceGroupsCount"] = $result.RGCount $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "OperationalExcellence" ImpactedArea = "Microsoft.Resources/subscriptions" Impact = "High" RecommendationType = "BestPractices" RecommendationSubType = "HighResourceGroupCountSubscriptions" RecommendationSubTypeId = "4468da8d-1e72-4998-b6d2-3bc38ddd9330" RecommendationDescription = "Subscriptions close to the maximum limit of resource groups" RecommendationAction = "Remove unneeded resource groups or split your resource groups across multiple subscriptions" InstanceId = $result.InstanceId_s InstanceName = $result.SubscriptionName AdditionalInfo = $additionalInfoDictionary SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "subscriptionsrglimits-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." if ($recommendationsErrors -gt 0) { throw "Some of the recommendations queries failed. Please, review the job logs for additional information." } ================================================ FILE: runbooks/recommendations/Recommend-AdvisorAsIsToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } # must be less than or equal to the advisor exports frequency $daysBackwards = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendAdvisorPeriodInDays" -ErrorAction SilentlyContinue) if (-not($daysBackwards -gt 0)) { $daysBackwards = 7 } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $CategoryFilter = Get-AutomationVariable -Name "AzureOptimization_AdvisorFilter" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($CategoryFilter)) { $CategoryFilter = "HighAvailability,Security,Performance,OperationalExcellence" # comma-separated list of categories } $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" $FiltersTable = "Filters" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGVirtualMachine','AzureAdvisor','ARGResourceContainers')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $advisorTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureAdvisor' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $subscriptionsTableName and $advisorTableName" $Conn.Close() $Conn.Dispose() Write-Output "Getting excluded recommendation sub-type IDs..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$FiltersTable] WHERE FilterType = 'Exclude' AND IsEnabled = 1 AND (FilterEndDate IS NULL OR FilterEndDate > GETDATE())" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $filters = New-Object System.Data.DataTable $sqlAdapter.Fill($filters) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $Conn.Close() $Conn.Dispose() # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } # Execute the recommendation query against Log Analytics $FinalCategoryFilter = "" if (-not([string]::IsNullOrEmpty($CategoryFilter))) { $categories = $CategoryFilter.Split(',') for ($i = 0; $i -lt $categories.Count; $i++) { $categories[$i] = "'" + $categories[$i] + "'" } $FinalCategoryFilter = " and Category in (" + ($categories -join ",") + ")" } $baseQuery = @" let advisorInterval = $($daysBackwards)d; $advisorTableName | where todatetime(TimeGenerated) > ago(advisorInterval)$FinalCategoryFilter | extend AdvisorRecIdIndex = indexof(InstanceId_s, '/providers/microsoft.advisor/recommendations') | extend InstanceName_s = iif(isnotempty(InstanceName_s),InstanceName_s,iif(AdvisorRecIdIndex > 0, split(substring(InstanceId_s, 0, AdvisorRecIdIndex),'/')[-1], split(InstanceId_s,'/')[-1])) | summarize by InstanceId_s, InstanceName_s, Category, Description_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroup, Cloud_s, AdditionalInfo_s, RecommendationText_s, ImpactedArea_s, Impact_s, RecommendationTypeId_g, Tags_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ Write-Output "Getting $CategoryFilter recommendations for $($daysBackwards)d Advisor..." try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $daysBackwards) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] throw "Execution aborted" } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") Write-Output "Generating fit score..." foreach ($result in $results) { if ($filters | Where-Object { $_.RecommendationSubTypeId -eq $result.RecommendationTypeId_g}) { continue } $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $additionalInfoDictionary = @{} if (-not([string]::IsNullOrEmpty($result.AdditionalInfo_s))) { ($result.AdditionalInfo_s | ConvertFrom-Json).PsObject.Properties | ForEach-Object { $additionalInfoDictionary[$_.Name] = $_.Value } } $fitScore = 5 $queryInstanceId = $result.InstanceId_s switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $recommendationSubType = "Advisor" + $result.Category $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = $result.Category ImpactedArea = $result.ImpactedArea_s Impact = $result.Impact_s RecommendationType = "BestPractices" RecommendationSubType = $recommendationSubType RecommendationSubTypeId = $result.RecommendationTypeId_g RecommendationDescription = $result.Description_s.Replace("'","") RecommendationAction = $result.RecommendationText_s.Replace("'","") InstanceId = $result.InstanceId_s InstanceName = $result.InstanceName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroup SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage Write-Output "Exporting final $($recommendations.Count) results as a JSON file..." $fileDate = $datetime.ToString("yyyyMMdd") $jsonExportPath = "advisor-asis-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath Write-Output "Uploading $jsonExportPath to blob storage..." $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json" }; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." ================================================ FILE: runbooks/recommendations/Recommend-AdvisorCostAugmentedToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" function Find-SkuHourlyPrice { param ( [object[]] $SKUPriceSheet, [string] $SKUName ) $skuPriceObject = $null if ($SKUPriceSheet) { $skuNameParts = $SKUName.Split('_') if ($skuNameParts.Count -eq 3) # e.g., Standard_D1_v2 { $skuNameFilter = "*" + $skuNameParts[1] + " *" $skuVersionFilter = "*" + $skuNameParts[2] $skuPrices = $SKUPriceSheet | Where-Object { $_.MeterName_s -like $skuNameFilter ` -and $_.MeterName_s -notlike '*Low Priority' -and $_.MeterName_s -notlike '*Expired' ` -and $_.MeterName_s -like $skuVersionFilter -and $_.MeterSubCategory_s -notlike '*Windows' -and $_.UnitPrice_s -ne 0 } if (($skuPrices -or $skuPrices.Count -ge 1) -and $skuPrices.Count -le 2) { $skuPriceObject = $skuPrices[0] } if ($skuPrices.Count -gt 2) # D1-like scenarios { $skuFilter = "*" + $skuNameParts[1] + " " + $skuNameParts[2] + "*" $skuPrices = $skuPrices | Where-Object { $_.MeterName_s -like $skuFilter } if (($skuPrices -or $skuPrices.Count -ge 1) -and $skuPrices.Count -le 2) { $skuPriceObject = $skuPrices[0] } } } if ($skuNameParts.Count -eq 2) # e.g., Standard_D1 { $skuNameFilter = "*" + $skuNameParts[1] + "*" $skuPrices = $SKUPriceSheet | Where-Object { $_.MeterName_s -like $skuNameFilter ` -and $_.MeterName_s -notlike '*Low Priority' -and $_.MeterName_s -notlike '*Expired' ` -and $_.MeterName_s -notlike '* v*' -and $_.MeterSubCategory_s -notlike '*Windows' -and $_.UnitPrice_s -ne 0 } if (($skuPrices -or $skuPrices.Count -ge 1) -and $skuPrices.Count -le 2) { $skuPriceObject = $skuPrices[0] } if ($skuPrices.Count -gt 2) # D1-like scenarios { $skuFilterLeft = "*" + $skuNameParts[1] + "/*" $skuFilterRight = "*/" + $skuNameParts[1] + "*" $skuPrices = $skuPrices | Where-Object { $_.MeterName_s -like $skuFilterLeft -or $_.MeterName_s -like $skuFilterRight } if (($skuPrices -or $skuPrices.Count -ge 1) -and $skuPrices.Count -le 2) { $skuPriceObject = $skuPrices[0] } } } } $targetHourlyPrice = [double]::MaxValue if ($null -ne $skuPriceObject) { $targetUnitHours = [int] (Select-String -InputObject $skuPriceObject.UnitOfMeasure_s -Pattern "^\d+").Matches[0].Value if ($targetUnitHours -gt 0) { $targetHourlyPrice = [double] ($skuPriceObject.UnitPrice_s / $targetUnitHours) } } return $targetHourlyPrice } # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceName = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceName" $workspaceRG = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceRG" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $workspaceTenantId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceTenantId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" # must be less than or equal to the advisor exports frequency $daysBackwards = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendAdvisorPeriodInDays" -ErrorAction SilentlyContinue) if (-not($daysBackwards -gt 0)) { $daysBackwards = 7 } $perfDaysBackwards = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendPerfPeriodInDays" -ErrorAction SilentlyContinue) if (-not($perfDaysBackwards -gt 0)) { $perfDaysBackwards = 7 } $perfTimeGrain = Get-AutomationVariable -Name "AzureOptimization_RecommendPerfTimeGrain" -ErrorAction SilentlyContinue if (-not($perfTimeGrain)) { $perfTimeGrain = "1h" } # percentiles variables $cpuPercentile = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfPercentileCpu" -ErrorAction SilentlyContinue) if (-not($cpuPercentile -gt 0)) { $cpuPercentile = 99 } $memoryPercentile = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfPercentileMemory" -ErrorAction SilentlyContinue) if (-not($memoryPercentile -gt 0)) { $memoryPercentile = 99 } $networkPercentile = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfPercentileNetwork" -ErrorAction SilentlyContinue) if (-not($networkPercentile -gt 0)) { $networkPercentile = 99 } $diskPercentile = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfPercentileDisk" -ErrorAction SilentlyContinue) if (-not($diskPercentile -gt 0)) { $diskPercentile = 99 } # perf thresholds variables $cpuPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdCpuPercentage" -ErrorAction SilentlyContinue) if (-not($cpuPercentageThreshold -gt 0)) { $cpuPercentageThreshold = 30 } $memoryPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdMemoryPercentage" -ErrorAction SilentlyContinue) if (-not($memoryPercentageThreshold -gt 0)) { $memoryPercentageThreshold = 50 } $networkMpbsThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdNetworkMbps" -ErrorAction SilentlyContinue) if (-not($networkMpbsThreshold -gt 0)) { $networkMpbsThreshold = 750 } # perf thresholds variables (shutdown) $cpuPercentageShutdownThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdCpuShutdownPercentage" -ErrorAction SilentlyContinue) if (-not($cpuPercentageShutdownThreshold -gt 0)) { $cpuPercentageShutdownThreshold = 5 } $memoryPercentageShutdownThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdMemoryShutdownPercentage" -ErrorAction SilentlyContinue) if (-not($memoryPercentageShutdownThreshold -gt 0)) { $memoryPercentageShutdownThreshold = 100 } $networkMpbsShutdownThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdNetworkShutdownMbps" -ErrorAction SilentlyContinue ) if (-not($networkMpbsShutdownThreshold -gt 0)) { $networkMpbsShutdownThreshold = 10 } $rightSizeRecommendationId = Get-AutomationVariable -Name "AzureOptimization_RecommendationAdvisorCostRightSizeId" -ErrorAction SilentlyContinue if (-not($rightSizeRecommendationId)) { $rightSizeRecommendationId = 'e10b1381-5f0a-47ff-8c7b-37bd13d7c974' } $additionalPerfWorkspaces = Get-AutomationVariable -Name "AzureOptimization_RightSizeAdditionalPerfWorkspaces" -ErrorAction SilentlyContinue $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionOffsetDaysStart = $consumptionOffsetDays + 1 $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" $FiltersTable = "Filters" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGVirtualMachine','AzureAdvisor','AzureConsumption','ARGResourceContainers','Pricesheet')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $vmsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGVirtualMachine' }).LogAnalyticsSuffix + "_CL" $advisorTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureAdvisor' }).LogAnalyticsSuffix + "_CL" $consumptionTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureConsumption' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" $pricesheetTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'Pricesheet' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $vmsTableName, $subscriptionsTableName, $advisorTableName, $pricesheetTableName and $consumptionTableName" $Conn.Close() $Conn.Dispose() Write-Output "Getting excluded recommendation sub-type IDs..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$FiltersTable] WHERE FilterType = 'Exclude' AND IsEnabled = 1 AND (FilterEndDate IS NULL OR FilterEndDate > GETDATE())" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $filters = New-Object System.Data.DataTable $sqlAdapter.Fill($filters) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 30 + $consumptionOffsetDaysStart # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } Write-Output "Getting Virtual Machine SKUs for the $referenceRegion region..." # Get all the VM SKUs information for the reference Azure region $skus = Get-AzComputeResourceSku -Location $referenceRegion | Where-Object { $_.ResourceType -eq "virtualMachines" } Write-Output "Getting the current Pricesheet..." if ($cloudEnvironment -eq "AzureCloud") { $pricesheetRegion = "EU West" } try { $pricesheetEntries = @() $baseQuery = @" $pricesheetTableName | where TimeGenerated > ago(14d) | where MeterCategory_s == 'Virtual Machines' and MeterRegion_s == '$pricesheetRegion' and PriceType_s == 'Consumption' | distinct MeterName_s, MeterSubCategory_s, MeterCategory_s, MeterRegion_s, UnitPrice_s, UnitOfMeasure_s "@ $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days 14) -Wait 600 -IncludeStatistics $pricesheetEntries = [System.Linq.Enumerable]::ToArray($queryResults.Results) Write-Output "Query finished with $($pricesheetEntries.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] Write-Output "Consumption pricesheet not available, will estimate savings based in cores count..." } $linuxMemoryPerfAdditionalWorkspaces = "" $windowsMemoryPerfAdditionalWorkspaces = "" $processorPerfAdditionalWorkspaces = "" $windowsNetworkPerfAdditionalWorkspaces = "" $diskPerfAdditionalWorkspaces = "" if ($additionalPerfWorkspaces) { $additionalWorkspaces = $additionalPerfWorkspaces.Split(",") foreach ($additionalWorkspace in $additionalWorkspaces) { $additionalWorkspace = $additionalWorkspace.Trim() $linuxMemoryPerfAdditionalWorkspaces += @" | union ( workspace('$additionalWorkspace').Perf | where TimeGenerated > ago(perfInterval) and _ResourceId in (RightSizeInstanceIds) | where CounterName == '% Used Memory' | extend WorkspaceId = TenantId | summarize hint.strategy=shuffle PMemoryPercentage = percentile(CounterValue, memoryPercentileValue) by _ResourceId, WorkspaceId) "@ $windowsMemoryPerfAdditionalWorkspaces += @" | union ( workspace('$additionalWorkspace').Perf | where TimeGenerated > ago(perfInterval) and _ResourceId in (RightSizeInstanceIds) | where CounterName == 'Available MBytes' | extend WorkspaceId = TenantId | project TimeGenerated, MemoryAvailableMBs = CounterValue, _ResourceId, WorkspaceId) "@ $processorPerfAdditionalWorkspaces += @" | union ( workspace('$additionalWorkspace').Perf | where TimeGenerated > ago(perfInterval) and _ResourceId in (RightSizeInstanceIds) | where ObjectName == 'Processor' and CounterName == '% Processor Time' and InstanceName == '_Total' | extend WorkspaceId = TenantId | summarize hint.strategy=shuffle PCPUPercentage = percentile(CounterValue, cpuPercentileValue) by _ResourceId, WorkspaceId) "@ $windowsNetworkPerfAdditionalWorkspaces += @" | union ( workspace('$additionalWorkspace').Perf | where TimeGenerated > ago(perfInterval) and _ResourceId in (RightSizeInstanceIds) | where CounterName == 'Bytes Total/sec' | extend WorkspaceId = TenantId | summarize hint.strategy=shuffle PCounter = percentile(CounterValue, networkPercentileValue) by InstanceName, _ResourceId, WorkspaceId | summarize PNetwork = sum(PCounter) by _ResourceId, WorkspaceId) "@ $diskPerfAdditionalWorkspaces += @" | union ( workspace('$additionalWorkspace').Perf | where TimeGenerated > ago(perfInterval) and _ResourceId in (RightSizeInstanceIds) | where CounterName in ('Disk Reads/sec', 'Disk Writes/sec', 'Disk Read Bytes/sec', 'Disk Write Bytes/sec') and InstanceName !in ('_Total', 'D:', '/mnt/resource', '/mnt') | extend WorkspaceId = TenantId | summarize hint.strategy=shuffle PCounter = percentile(CounterValue, diskPercentileValue) by bin(TimeGenerated, perfTimeGrain), CounterName, InstanceName, _ResourceId, WorkspaceId | summarize SumPCounter = sum(PCounter) by CounterName, TimeGenerated, _ResourceId, WorkspaceId | summarize MaxPReadIOPS = maxif(SumPCounter, CounterName == 'Disk Reads/sec'), MaxPWriteIOPS = maxif(SumPCounter, CounterName == 'Disk Writes/sec'), MaxPReadMiBps = (maxif(SumPCounter, CounterName == 'Disk Read Bytes/sec') / 1024 / 1024), MaxPWriteMiBps = (maxif(SumPCounter, CounterName == 'Disk Write Bytes/sec') / 1024 / 1024) by _ResourceId, WorkspaceId) "@ } } # Execute the recommendation query against Log Analytics $baseQuery = @" let advisorInterval = $($daysBackwards)d; let perfInterval = $($perfDaysBackwards)d; let perfTimeGrain = $perfTimeGrain; let cpuPercentileValue = $cpuPercentile; let memoryPercentileValue = $memoryPercentile; let networkPercentileValue = $networkPercentile; let diskPercentileValue = $diskPercentile; let rightSizeRecommendationId = '$rightSizeRecommendationId'; let billingInterval = 30d; let etime = todatetime(toscalar($consumptionTableName | where todatetime(Date_s) < now() and todatetime(Date_s) > ago(30d) | summarize max(todatetime(Date_s)))); let stime = etime-billingInterval; let RightSizeInstanceIds = materialize($advisorTableName | where todatetime(TimeGenerated) > ago(advisorInterval) and Category == 'Cost' and RecommendationTypeId_g == rightSizeRecommendationId | distinct InstanceId_s); let LinuxMemoryPerf = Perf | where TimeGenerated > ago(perfInterval) and _ResourceId in (RightSizeInstanceIds) | where CounterName == '% Used Memory' | extend WorkspaceId = TenantId | summarize hint.strategy=shuffle PMemoryPercentage = percentile(CounterValue, memoryPercentileValue) by _ResourceId, WorkspaceId$linuxMemoryPerfAdditionalWorkspaces; let WindowsMemoryPerf = Perf | where TimeGenerated > ago(perfInterval) and _ResourceId in (RightSizeInstanceIds) | where CounterName == 'Available MBytes' | extend WorkspaceId = TenantId | project TimeGenerated, MemoryAvailableMBs = CounterValue, _ResourceId, WorkspaceId$windowsMemoryPerfAdditionalWorkspaces; let MemoryPerf = $vmsTableName | where TimeGenerated > ago(1d) | distinct InstanceId_s, MemoryMB_s | join kind=inner hint.strategy=broadcast ( WindowsMemoryPerf ) on `$left.InstanceId_s == `$right._ResourceId | extend MemoryPercentage = todouble(toint(MemoryMB_s) - toint(MemoryAvailableMBs)) / todouble(MemoryMB_s) * 100 | summarize hint.strategy=shuffle PMemoryPercentage = percentile(MemoryPercentage, memoryPercentileValue) by _ResourceId, WorkspaceId | union LinuxMemoryPerf; let ProcessorPerf = Perf | where TimeGenerated > ago(perfInterval) and _ResourceId in (RightSizeInstanceIds) | where ObjectName == 'Processor' and CounterName == '% Processor Time' and InstanceName == '_Total' | extend WorkspaceId = TenantId | summarize hint.strategy=shuffle PCPUPercentage = percentile(CounterValue, cpuPercentileValue) by _ResourceId, WorkspaceId$processorPerfAdditionalWorkspaces; let WindowsNetworkPerf = Perf | where TimeGenerated > ago(perfInterval) and _ResourceId in (RightSizeInstanceIds) | where CounterName == 'Bytes Total/sec' | extend WorkspaceId = TenantId | summarize hint.strategy=shuffle PCounter = percentile(CounterValue, networkPercentileValue) by InstanceName, _ResourceId, WorkspaceId | summarize PNetwork = sum(PCounter) by _ResourceId, WorkspaceId$windowsNetworkPerfAdditionalWorkspaces; let DiskPerf = Perf | where TimeGenerated > ago(perfInterval) and _ResourceId in (RightSizeInstanceIds) | where CounterName in ('Disk Reads/sec', 'Disk Writes/sec', 'Disk Read Bytes/sec', 'Disk Write Bytes/sec') and InstanceName !in ('_Total', 'D:', '/mnt/resource', '/mnt') | extend WorkspaceId = TenantId | summarize hint.strategy=shuffle PCounter = percentile(CounterValue, diskPercentileValue) by bin(TimeGenerated, perfTimeGrain), CounterName, InstanceName, _ResourceId, WorkspaceId | summarize SumPCounter = sum(PCounter) by CounterName, TimeGenerated, _ResourceId, WorkspaceId | summarize MaxPReadIOPS = maxif(SumPCounter, CounterName == 'Disk Reads/sec'), MaxPWriteIOPS = maxif(SumPCounter, CounterName == 'Disk Writes/sec'), MaxPReadMiBps = (maxif(SumPCounter, CounterName == 'Disk Read Bytes/sec') / 1024 / 1024), MaxPWriteMiBps = (maxif(SumPCounter, CounterName == 'Disk Write Bytes/sec') / 1024 / 1024) by _ResourceId, WorkspaceId$diskPerfAdditionalWorkspaces; $advisorTableName | where todatetime(TimeGenerated) > ago(advisorInterval) and Category == 'Cost' | extend AdvisorRecIdIndex = indexof(InstanceId_s, '/providers/microsoft.advisor/recommendations') | extend InstanceName_s = iif(isnotempty(InstanceName_s),InstanceName_s,iif(AdvisorRecIdIndex > 0, split(substring(InstanceId_s, 0, AdvisorRecIdIndex),'/')[-1], split(InstanceId_s,'/')[-1])) | distinct InstanceId_s, InstanceName_s, Description_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroup, Cloud_s, AdditionalInfo_s, RecommendationText_s, ImpactedArea_s, Impact_s, RecommendationTypeId_g, Tags_s | join kind=leftouter ( $consumptionTableName | where todatetime(Date_s) between (stime..etime) | extend VMConsumedQuantity = iif(ResourceId contains 'virtualmachines' and MeterCategory_s == 'Virtual Machines', todouble(Quantity_s), 0.0) | extend VMPrice = iif(ResourceId contains 'virtualmachines' and MeterCategory_s == 'Virtual Machines', todouble(EffectivePrice_s), 0.0) | extend FinalCost = iif(ResourceId contains 'virtualmachines', VMPrice * VMConsumedQuantity, todouble(CostInBillingCurrency_s)) | extend InstanceId_s = tolower(ResourceId) | summarize Last30DaysCost = sum(FinalCost), Last30DaysQuantity = sum(VMConsumedQuantity) by InstanceId_s ) on InstanceId_s | join kind=leftouter ( $vmsTableName | where TimeGenerated > ago(1d) | distinct InstanceId_s, NicCount_s, DataDiskCount_s ) on InstanceId_s | where RecommendationTypeId_g != rightSizeRecommendationId or (RecommendationTypeId_g == rightSizeRecommendationId and toint(NicCount_s) >= 0 and toint(DataDiskCount_s) >= 0) | join kind=leftouter hint.strategy=broadcast ( MemoryPerf ) on `$left.InstanceId_s == `$right._ResourceId | join kind=leftouter hint.strategy=broadcast ( ProcessorPerf ) on `$left.InstanceId_s == `$right._ResourceId | join kind=leftouter hint.strategy=broadcast ( WindowsNetworkPerf ) on `$left.InstanceId_s == `$right._ResourceId | join kind=leftouter hint.strategy=broadcast ( DiskPerf ) on `$left.InstanceId_s == `$right._ResourceId | extend MaxPIOPS = MaxPReadIOPS + MaxPWriteIOPS, MaxPMiBps = MaxPReadMiBps + MaxPWriteMiBps | extend PNetworkMbps = PNetwork * 8 / 1000 / 1000 | distinct Last30DaysCost, Last30DaysQuantity, InstanceId_s, InstanceName_s, Description_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroup, Cloud_s, AdditionalInfo_s, RecommendationText_s, ImpactedArea_s, Impact_s, RecommendationTypeId_g, NicCount_s, DataDiskCount_s, PMemoryPercentage, PCPUPercentage, PNetworkMbps, MaxPIOPS, MaxPMiBps, Tags_s, WorkspaceId | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ Write-Output "Will run the following query (use this query against the LA workspace for troubleshooting): $baseQuery" Write-Output "Getting cost recommendations for $($daysBackwards)d Advisor and $($perfDaysBackwards)d Perf history and a $perfTimeGrain time grain..." try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] throw "Execution aborted" } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") $skuPricesFound = @{} Write-Output "Generating fit score..." foreach ($result in $results) { if ($filters | Where-Object { $_.RecommendationSubTypeId -eq $result.RecommendationTypeId_g}) { continue } $queryInstanceId = $result.InstanceId_s $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $additionalInfoDictionary = @{} if (-not([string]::IsNullOrEmpty($result.AdditionalInfo_s))) { ($result.AdditionalInfo_s | ConvertFrom-Json).PsObject.Properties | ForEach-Object { $additionalInfoDictionary[$_.Name] = $_.Value } } # Fixing reservation model inconsistencies if (-not([string]::IsNullOrEmpty($additionalInfoDictionary["location"]))) { $additionalInfoDictionary["region"] = $additionalInfoDictionary["location"] } if (-not([string]::IsNullOrEmpty($additionalInfoDictionary["targetResourceCount"]))) { $additionalInfoDictionary["qty"] = $additionalInfoDictionary["targetResourceCount"] } if (-not([string]::IsNullOrEmpty($additionalInfoDictionary["vmSize"]))) { $additionalInfoDictionary["displaySKU"] = $additionalInfoDictionary["vmSize"] } $additionalInfoDictionary["CostsAmount"] = [double] $result.Last30DaysCost $fitScore = 5 $hasCpuRamPerfMetrics = $false if ($additionalInfoDictionary.targetSku -and $result.RecommendationTypeId_g -eq $rightSizeRecommendationId) { $additionalInfoDictionary["SupportsDataDisksCount"] = "true" $additionalInfoDictionary["DataDiskCount"] = "$($result.DataDiskCount_s)" $additionalInfoDictionary["SupportsNICCount"] = "true" $additionalInfoDictionary["NicCount"] = "$($result.NicCount_s)" $additionalInfoDictionary["SupportsIOPS"] = "true" $additionalInfoDictionary["MetricIOPS"] = "$($result.MaxPIOPS)" $additionalInfoDictionary["SupportsMiBps"] = "true" $additionalInfoDictionary["MetricMiBps"] = "$($result.MaxPMiBps)" $additionalInfoDictionary["BelowCPUThreshold"] = "true" $additionalInfoDictionary["MetricCPUPercentage"] = "$($result.PCPUPercentage)" $additionalInfoDictionary["BelowMemoryThreshold"] = "true" $additionalInfoDictionary["MetricMemoryPercentage"] = "$($result.PMemoryPercentage)" $additionalInfoDictionary["BelowNetworkThreshold"] = "true" $additionalInfoDictionary["MetricNetworkMbps"] = "$($result.PNetworkMbps)" $targetSku = $null if ($additionalInfoDictionary.targetSku -ne "Shutdown") { $currentSku = $skus | Where-Object { $_.Name -eq $additionalInfoDictionary.currentSku } $currentSkuvCPUs = [int]($currentSku.Capabilities | Where-Object { $_.Name -eq 'vCPUsAvailable' }).Value $targetSku = $skus | Where-Object { $_.Name -eq $additionalInfoDictionary.targetSku } $targetSkuvCPUs = [int]($targetSku.Capabilities | Where-Object { $_.Name -eq 'vCPUsAvailable' }).Value $targetMaxDataDiskCount = [int]($targetSku.Capabilities | Where-Object { $_.Name -eq 'MaxDataDiskCount' }).Value if ($targetMaxDataDiskCount -gt 0) { if (-not([string]::isNullOrEmpty($result.DataDiskCount_s))) { if ([int]$result.DataDiskCount_s -gt $targetMaxDataDiskCount) { $fitScore = 1 $additionalInfoDictionary["SupportsDataDisksCount"] = "false:needs$($result.DataDiskCount_s)-max$targetMaxDataDiskCount" } } else { $fitScore -= 1 $additionalInfoDictionary["SupportsDataDisksCount"] = "unknown:max$targetMaxDataDiskCount" } } else { $fitScore -= 1 $additionalInfoDictionary["SupportsDataDisksCount"] = "unknown:needs$($result.DataDiskCount_s)" } $targetMaxNICCount = [int]($targetSku.Capabilities | Where-Object { $_.Name -eq 'MaxNetworkInterfaces' }).Value if ($targetMaxNICCount -gt 0) { if (-not([string]::isNullOrEmpty($result.NicCount_s))) { if ([int]$result.NicCount_s -gt $targetMaxNICCount) { $fitScore = 1 $additionalInfoDictionary["SupportsNICCount"] = "false:needs$($result.NicCount_s)-max$targetMaxNICCount" } } else { $fitScore -= 1 $additionalInfoDictionary["SupportsNICCount"] = "unknown:max$targetMaxNICCount" } } else { $fitScore -= 1 $additionalInfoDictionary["SupportsNICCount"] = "unknown:needs$($result.NicCount_s)" } $targetUncachedDiskIOPS = [int]($targetSku.Capabilities | Where-Object { $_.Name -eq 'UncachedDiskIOPS' }).Value if ($targetUncachedDiskIOPS -gt 0) { if (-not([string]::isNullOrEmpty($result.MaxPIOPS))) { if ([double]$result.MaxPIOPS -ge [double]$targetUncachedDiskIOPS) { $fitScore -= 1 $additionalInfoDictionary["SupportsIOPS"] = "false:needs$($result.MaxPIOPS)-max$targetUncachedDiskIOPS" } } else { $fitScore -= 0.5 $additionalInfoDictionary["SupportsIOPS"] = "unknown:max$targetUncachedDiskIOPS" } } else { $fitScore -= 1 $additionalInfoDictionary["SupportsIOPS"] = "unknown:needs$($result.MaxPIOPS)" } $targetUncachedDiskMiBps = [double]([int]($targetSku.Capabilities | Where-Object { $_.Name -eq 'UncachedDiskBytesPerSecond' }).Value) / 1024 / 1024 if ($targetUncachedDiskMiBps -gt 0) { if (-not([string]::isNullOrEmpty($result.MaxPMiBps))) { if ([double]$result.MaxPMiBps -ge $targetUncachedDiskMiBps) { $fitScore -= 1 $additionalInfoDictionary["SupportsMiBps"] = "false:needs$($result.MaxPMiBps)-max$targetUncachedDiskMiBps" } } else { $fitScore -= 0.5 $additionalInfoDictionary["SupportsMiBps"] = "unknown:max$targetUncachedDiskMiBps" } } else { $additionalInfoDictionary["SupportsMiBps"] = "unknown:needs$($result.MaxPMiBps)" } $savingCoefficient = [double] $currentSkuvCPUs / $targetSkuvCPUs if ($savingCoefficient -gt 1) { $targetSkuSavingsMonthly = [double]$result.Last30DaysCost - ([double]$result.Last30DaysCost / $savingCoefficient) } else { $targetSkuSavingsMonthly = [double]$result.Last30DaysCost / 2 } if ($targetSku -and $null -eq $skuPricesFound[$targetSku.Name]) { $skuPricesFound[$targetSku.Name] = Find-SkuHourlyPrice -SKUName $targetSku.Name -SKUPriceSheet $pricesheetEntries } $tentativeTargetSkuSavingsMonthly = -1 if ($targetSku -and $skuPricesFound[$targetSku.Name] -gt 0 -and $skuPricesFound[$targetSku.Name] -lt [double]::MaxValue) { $targetSkuPrice = $skuPricesFound[$targetSku.Name] if ($null -eq $skuPricesFound[$currentSku.Name]) { $skuPricesFound[$currentSku.Name] = Find-SkuHourlyPrice -SKUName $currentSku.Name -SKUPriceSheet $pricesheetEntries } if ($skuPricesFound[$currentSku.Name] -gt 0) { $currentSkuPrice = $skuPricesFound[$currentSku.Name] $tentativeTargetSkuSavingsMonthly = ($currentSkuPrice * [double] $result.Last30DaysQuantity) - ($targetSkuPrice * [double] $result.Last30DaysQuantity) } else { $tentativeTargetSkuSavingsMonthly = [double]$result.Last30DaysCost - ($targetSkuPrice * [double] $result.Last30DaysQuantity) } } if ($tentativeTargetSkuSavingsMonthly -ge 0) { $targetSkuSavingsMonthly = $tentativeTargetSkuSavingsMonthly } if ($targetSkuSavingsMonthly -eq [double]::PositiveInfinity) { $targetSkuSavingsMonthly = [double] $result.Last30DaysCost / 2 } $savingsMonthly = $targetSkuSavingsMonthly } else { $savingsMonthly = [double]$result.Last30DaysCost } $cpuThreshold = $cpuPercentageThreshold $memoryThreshold = $memoryPercentageThreshold $networkThreshold = $networkMpbsThreshold if ($additionalInfoDictionary.targetSku -eq "Shutdown") { $cpuThreshold = $cpuPercentageShutdownThreshold $memoryThreshold = $memoryPercentageShutdownThreshold $networkThreshold = $networkMpbsShutdownThreshold } if (-not([string]::isNullOrEmpty($result.PCPUPercentage))) { if ([double]$result.PCPUPercentage -ge [double]$cpuThreshold) { $fitScore -= 0.5 $additionalInfoDictionary["BelowCPUThreshold"] = "false:needs$($result.PCPUPercentage)-max$cpuThreshold" } $hasCpuRamPerfMetrics = $true } else { $fitScore -= 0.5 $additionalInfoDictionary["BelowCPUThreshold"] = "unknown:max$cpuThreshold" } if (-not([string]::isNullOrEmpty($result.PMemoryPercentage))) { if ([double]$result.PMemoryPercentage -ge [double]$memoryThreshold) { $fitScore -= 0.5 $additionalInfoDictionary["BelowMemoryThreshold"] = "false:needs$($result.PMemoryPercentage)-max$memoryThreshold" } $hasCpuRamPerfMetrics = $true } else { $fitScore -= 0.5 $additionalInfoDictionary["BelowMemoryThreshold"] = "unknown:max$memoryThreshold" } if (-not([string]::isNullOrEmpty($result.PNetworkMbps))) { if ([double]$result.PNetworkMbps -ge [double]$networkThreshold) { $fitScore -= 0.1 $additionalInfoDictionary["BelowNetworkThreshold"] = "false:needs$($result.PNetworkMbps)-max$networkThreshold" } } else { $fitScore -= 0.1 $additionalInfoDictionary["BelowNetworkThreshold"] = "unknown:max$networkThreshold" } $fitScore = [Math]::max(0.0, $fitScore) } else { if (-not([string]::IsNullOrEmpty($additionalInfoDictionary["annualSavingsAmount"]))) { $savingsMonthly = [double] $additionalInfoDictionary["annualSavingsAmount"] / 12 } else { if ($result.RecommendationTypeId_g -eq $rightSizeRecommendationId) { $savingsMonthly = [double] $result.Last30DaysCost } else { $savingsMonthly = 0.0 # unknown } } } $additionalInfoDictionary["savingsAmount"] = [double] $savingsMonthly $queryInstanceId = $result.InstanceId_s if (-not($hasCpuRamPerfMetrics)) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" } else { $queryWorkspace = "" if (-not([string]::IsNullOrEmpty($result.WorkspaceId)) -and $result.WorkspaceId -ne $workspaceId) { $queryWorkspace = "workspace('$($result.WorkspaceId)')." } $queryText = @" let perfInterval = $($perfDaysBackwards)d; let armId = tolower(`'$queryInstanceId`'); let gInt = $perfTimeGrain; let LinuxMemoryPerf = $($queryWorkspace)Perf | where TimeGenerated > ago(perfInterval) | where CounterName == '% Used Memory' and _ResourceId =~ armId | project TimeGenerated, MemoryPercentage = CounterValue; let WindowsMemoryPerf = $($queryWorkspace)Perf | where TimeGenerated > ago(perfInterval) | where CounterName == 'Available MBytes' and _ResourceId =~ armId | extend MemoryAvailableMBs = CounterValue, InstanceId = tolower(_ResourceId) | project TimeGenerated, MemoryAvailableMBs, InstanceId; let MemoryPerf = WindowsMemoryPerf | join kind=inner ( $vmsTableName | where TimeGenerated > ago(1d) | extend InstanceId = tolower(InstanceId_s) | distinct InstanceId, MemoryMB_s ) on InstanceId | extend MemoryPercentage = todouble(toint(MemoryMB_s) - toint(MemoryAvailableMBs)) / todouble(MemoryMB_s) * 100 | project TimeGenerated, MemoryPercentage | union LinuxMemoryPerf | summarize P$($memoryPercentile)MemoryPercentage = percentile(MemoryPercentage, $memoryPercentile) by bin(TimeGenerated, gInt); let ProcessorPerf = $($queryWorkspace)Perf | where TimeGenerated > ago(perfInterval) | where CounterName == '% Processor Time' and InstanceName == '_Total' and _ResourceId =~ armId | summarize P$($cpuPercentile)CPUPercentage = percentile(CounterValue, $cpuPercentile) by bin(TimeGenerated, gInt); MemoryPerf | join kind=inner (ProcessorPerf) on TimeGenerated | render timechart "@ switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $datetime.AddDays(-30).ToString("yyyy-MM-dd") $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = $result.ImpactedArea_s Impact = $result.Impact_s RecommendationType = "Saving" RecommendationSubType = "AdvisorCost" RecommendationSubTypeId = $result.RecommendationTypeId_g RecommendationDescription = $result.Description_s RecommendationAction = $result.RecommendationText_s InstanceId = $result.InstanceId_s InstanceName = $result.InstanceName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroup SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage Write-Output "Exporting final $($recommendations.Count) results as a JSON file..." $fileDate = $datetime.ToString("yyyyMMdd") $jsonExportPath = "advisor-cost-augmented-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath Write-Output "Uploading $jsonExportPath to blob storage..." $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json" }; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." ================================================ FILE: runbooks/recommendations/Recommend-AppServiceOptimizationsToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceName = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceName" $workspaceRG = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceRG" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $workspaceTenantId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceTenantId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $deploymentDate = Get-AutomationVariable -Name "AzureOptimization_DeploymentDate" # yyyy-MM-dd format $deploymentDate = $deploymentDate.Replace('"', "") $perfDaysBackwards = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendPerfPeriodInDays" -ErrorAction SilentlyContinue) if (-not($perfDaysBackwards -gt 0)) { $perfDaysBackwards = 7 } $perfTimeGrain = Get-AutomationVariable -Name "AzureOptimization_RecommendPerfTimeGrain" -ErrorAction SilentlyContinue if (-not($perfTimeGrain)) { $perfTimeGrain = "1h" } # percentiles variables $cpuPercentile = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfPercentileCpu" -ErrorAction SilentlyContinue) if (-not($cpuPercentile -gt 0)) { $cpuPercentile = 99 } $memoryPercentile = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfPercentileMemory" -ErrorAction SilentlyContinue) if (-not($memoryPercentile -gt 0)) { $memoryPercentile = 99 } # perf thresholds variables $cpuPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdCpuPercentage" -ErrorAction SilentlyContinue) if (-not($cpuPercentageThreshold -gt 0)) { $cpuPercentageThreshold = 30 } $memoryPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdMemoryPercentage" -ErrorAction SilentlyContinue) if (-not($memoryPercentageThreshold -gt 0)) { $memoryPercentageThreshold = 50 } $cpuDegradedMaxPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdCpuDegradedMaxPercentage" -ErrorAction SilentlyContinue) if (-not($cpuDegradedMaxPercentageThreshold -gt 0)) { $cpuDegradedMaxPercentageThreshold = 95 } $cpuDegradedAvgPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdCpuDegradedAvgPercentage" -ErrorAction SilentlyContinue) if (-not($cpuDegradedAvgPercentageThreshold -gt 0)) { $cpuDegradedAvgPercentageThreshold = 75 } $memoryDegradedPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdMemoryDegradedPercentage" -ErrorAction SilentlyContinue) if (-not($memoryDegradedPercentageThreshold -gt 0)) { $memoryDegradedPercentageThreshold = 90 } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionOffsetDaysStart = $consumptionOffsetDays + 1 $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('AppServicePlans','MonitorMetrics','AzureConsumption','ARGResourceContainers')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $appServicePlansTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AppServicePlans' }).LogAnalyticsSuffix + "_CL" $metricsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'MonitorMetrics' }).LogAnalyticsSuffix + "_CL" $consumptionTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureConsumption' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $appServicePlansTableName, $subscriptionsTableName, $metricsTableName and $consumptionTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 30 + $consumptionOffsetDaysStart # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } $recommendationsErrors = 0 # Execute the recommendation query against Log Analytics Write-Output "Looking for underused App Service Plans, with less than $cpuPercentageThreshold% CPU and $memoryPercentageThreshold% RAM usage..." $baseQuery = @" let billingInterval = 30d; let perfInterval = $($perfDaysBackwards)d; let cpuPercentileValue = $cpuPercentile; let memoryPercentileValue = $memoryPercentile; let etime = todatetime(toscalar($consumptionTableName | where todatetime(Date_s) < now() and todatetime(Date_s) > ago(30d) | summarize max(todatetime(Date_s)))); let stime = etime-billingInterval; let BilledPlans = $consumptionTableName | where todatetime(Date_s) between (stime..etime) and ResourceId has 'microsoft.web/serverfarms' | extend ConsumedQuantity = todouble(Quantity_s) | extend FinalCost = todouble(EffectivePrice_s) * ConsumedQuantity | extend InstanceId_s = tolower(ResourceId) | summarize Last30DaysCost = sum(FinalCost), Last30DaysQuantity = sum(ConsumedQuantity) by InstanceId_s; let ProcessorPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | where ResourceId has 'microsoft.web/serverfarms' | where MetricNames_s == "CpuPercentage" and AggregationType_s == 'Maximum' | extend InstanceId_s = ResourceId | summarize PCPUPercentage = percentile(todouble(MetricValue_s), cpuPercentileValue) by InstanceId_s; let MemoryPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | where ResourceId has 'microsoft.web/serverfarms' | where MetricNames_s == "MemoryPercentage" and AggregationType_s == 'Maximum' | extend InstanceId_s = ResourceId | summarize PMemoryPercentage = percentile(todouble(MetricValue_s), memoryPercentileValue) by InstanceId_s; $appServicePlansTableName | where TimeGenerated > ago(1d) and ComputeMode_s == 'Dedicated' and SkuTier_s != 'Free' | distinct InstanceId_s, AppServicePlanName_s, ResourceGroupName_s, SubscriptionGuid_g, Cloud_s, TenantGuid_g, SkuSize_s, NumberOfWorkers_s, Tags_s | join kind=inner ( BilledPlans ) on InstanceId_s | join kind=leftouter ( MemoryPerf ) on InstanceId_s | join kind=leftouter ( ProcessorPerf ) on InstanceId_s | project InstanceId_s, AppServicePlan = AppServicePlanName_s, ResourceGroup = ResourceGroupName_s, SubscriptionId = SubscriptionGuid_g, Cloud_s, TenantGuid_g, SkuSize_s, NumberOfWorkers_s, PMemoryPercentage, PCPUPercentage, Tags_s, Last30DaysCost, Last30DaysQuantity | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionId = SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionId | where isnotempty(PMemoryPercentage) and isnotempty(PCPUPercentage) and PMemoryPercentage < $memoryPercentageThreshold and PCPUPercentage < $cpuPercentageThreshold "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.InstanceId_s $queryText = @" let perfInterval = $($perfDaysBackwards)d; let armId = `'$queryInstanceId`'; let gInt = $perfTimeGrain; let MemoryPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'MemoryPercentage' and AggregationType_s == 'Maximum' | extend MemoryPercentage = todouble(MetricValue_s) | summarize percentile(MemoryPercentage, $memoryPercentile) by bin(CollectedDate, gInt); let ProcessorPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'CpuPercentage' and AggregationType_s == 'Maximum' | extend ProcessorPercentage = todouble(MetricValue_s) | summarize percentile(ProcessorPercentage, $cpuPercentile) by bin(CollectedDate, gInt); MemoryPerf | join kind=inner (ProcessorPerf) on CollectedDate | render timechart "@ $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $datetime.AddDays(-30).ToString("yyyy-MM-dd") $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["currentSku"] = "$($result.SkuSize_s)" $additionalInfoDictionary["InstanceCount"] = [int] $result.NumberOfWorkers_s $additionalInfoDictionary["MetricCPUPercentage"] = "$($result.PCPUPercentage)" $additionalInfoDictionary["MetricMemoryPercentage"] = "$($result.PMemoryPercentage)" $additionalInfoDictionary["CostsAmount"] = [double] $result.Last30DaysCost $additionalInfoDictionary["savingsAmount"] = ([double] $result.Last30DaysCost / 2) $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = "Microsoft.Web/serverFarms" Impact = "High" RecommendationType = "Saving" RecommendationSubType = "UnderusedAppServicePlans" RecommendationSubTypeId = "042adaca-ebdf-49b4-bc1b-2800b6e40fea" RecommendationDescription = "Underused App Service Plans (performance capacity waste)" RecommendationAction = "Right-size underused App Service Plans or scale it in" InstanceId = $result.InstanceId_s InstanceName = $result.AppServicePlan AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroup SubscriptionGuid = $result.SubscriptionId SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "appserviceplans-underused-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for performance constrained App Service Plans, with more than $cpuDegradedMaxPercentageThreshold% Max. CPU, $cpuDegradedAvgPercentageThreshold% Avg. CPU and $memoryDegradedPercentageThreshold% RAM usage..." $baseQuery = @" let perfInterval = $($perfDaysBackwards)d; let MemoryPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | where ResourceId has 'microsoft.web/serverfarms' | where MetricNames_s == "MemoryPercentage" and AggregationType_s == 'Average' and AggregationOfType_s == 'Maximum' | extend InstanceId_s = ResourceId | summarize PMemoryPercentage = avg(todouble(MetricValue_s)) by InstanceId_s; let ProcessorMaxPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | where ResourceId has 'microsoft.web/serverfarms' | where MetricNames_s == "CpuPercentage" and AggregationType_s == 'Maximum' | extend InstanceId_s = ResourceId | summarize PCPUMaxPercentage = avg(todouble(MetricValue_s)) by InstanceId_s; let ProcessorAvgPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | where ResourceId has 'microsoft.web/serverfarms' | where MetricNames_s == "CpuPercentage" and AggregationType_s == 'Average' and AggregationOfType_s == 'Maximum' | extend InstanceId_s = ResourceId | summarize PCPUAvgPercentage = avg(todouble(MetricValue_s)) by InstanceId_s; $appServicePlansTableName | where TimeGenerated > ago(1d) and ComputeMode_s == 'Dedicated' and SkuTier_s != 'Free' | distinct InstanceId_s, AppServicePlanName_s, ResourceGroupName_s, SubscriptionGuid_g, Cloud_s, TenantGuid_g, SkuSize_s, NumberOfWorkers_s, Tags_s | join kind=leftouter ( MemoryPerf ) on InstanceId_s | join kind=leftouter ( ProcessorMaxPerf ) on InstanceId_s | join kind=leftouter ( ProcessorAvgPerf ) on InstanceId_s | project InstanceId_s, AppServicePlan = AppServicePlanName_s, ResourceGroup = ResourceGroupName_s, SubscriptionId = SubscriptionGuid_g, Cloud_s, TenantGuid_g, SkuSize_s, NumberOfWorkers_s, PMemoryPercentage, PCPUMaxPercentage, PCPUAvgPercentage, Tags_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionId = SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionId | where isnotempty(PMemoryPercentage) and isnotempty(PCPUAvgPercentage) and isnotempty(PCPUMaxPercentage) and (PMemoryPercentage > $memoryDegradedPercentageThreshold or (PCPUMaxPercentage > $cpuDegradedMaxPercentageThreshold and PCPUAvgPercentage > $cpuDegradedAvgPercentageThreshold)) "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.InstanceId_s $queryText = @" let perfInterval = $($perfDaysBackwards)d; let armId = `'$queryInstanceId`'; let gInt = $perfTimeGrain; let MemoryPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'MemoryPercentage' and AggregationType_s == 'Average' and AggregationOfType_s == 'Maximum' | extend MemoryPercentage = todouble(MetricValue_s) | summarize percentile(MemoryPercentage, $memoryPercentile) by bin(CollectedDate, gInt); let ProcessorMaxPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'CpuPercentage' and AggregationType_s == 'Maximum' | extend ProcessorMaxPercentage = todouble(MetricValue_s) | summarize percentile(ProcessorMaxPercentage, $cpuPercentile) by bin(CollectedDate, gInt); let ProcessorAvgPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'CpuPercentage' and AggregationType_s == 'Average' and AggregationOfType_s == 'Maximum' | extend ProcessorAvgPercentage = todouble(MetricValue_s) | summarize percentile(ProcessorAvgPercentage, $cpuPercentile) by bin(CollectedDate, gInt); MemoryPerf | join kind=inner (ProcessorMaxPerf) on CollectedDate | join kind=inner (ProcessorAvgPerf) on CollectedDate | render timechart "@ $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $datetime.AddDays(-30).ToString("yyyy-MM-dd") $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["currentSku"] = "$($result.SkuSize_s)" $additionalInfoDictionary["InstanceCount"] = [int] $result.NumberOfWorkers_s $additionalInfoDictionary["MetricCPUAvgPercentage"] = "$($result.PCPUAvgPercentage)" $additionalInfoDictionary["MetricCPUMaxPercentage"] = "$($result.PCPUMaxPercentage)" $additionalInfoDictionary["MetricMemoryPercentage"] = "$($result.PMemoryPercentage)" $fitScore = 3 # needs a more complete analysis to improve score if ([double] $result.PCPUMaxPercentage -gt [double] $cpuDegradedMaxPercentageThreshold -and [double] $result.PCPUAvgPercentage -gt [double] $cpuDegradedAvgPercentageThreshold) { $fitScore = 4 } $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Performance" ImpactedArea = "Microsoft.Web/serverFarms" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "PerfConstrainedAppServicePlans" RecommendationSubTypeId = "351574cb-c105-4538-a778-11dfbe4857bf" RecommendationDescription = "App Service Plan performance has been constrained by lack of resources" RecommendationAction = "Resize App Service Plan to higher SKU or scale it out" InstanceId = $result.InstanceId_s InstanceName = $result.AppServicePlan AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroup SubscriptionGuid = $result.SubscriptionId SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "appserviceplans-perfconstrained-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for empty App Service Plans..." $baseQuery = @" let interval = 30d; let etime = todatetime(toscalar($consumptionTableName | where todatetime(Date_s) < now() and todatetime(Date_s) > ago(interval) | summarize max(todatetime(Date_s)))); let stime = etime-interval; $appServicePlansTableName | where TimeGenerated > ago(1d) and ComputeMode_s == 'Dedicated' and SkuTier_s != 'Free' and toint(NumberOfSites_s) == 0 | distinct AppServicePlanName_s, InstanceId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, SkuSize_s, NumberOfWorkers_s, Tags_s, Cloud_s | join kind=leftouter ( $consumptionTableName | where todatetime(Date_s) between (stime..etime) | project InstanceId_s=tolower(ResourceId), CostInBillingCurrency_s, Date_s ) on InstanceId_s | summarize Last30DaysCost=sum(todouble(CostInBillingCurrency_s)) by AppServicePlanName_s, InstanceId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, SkuSize_s, NumberOfWorkers_s, Tags_s, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.InstanceId_s $queryText = @" $appServicePlansTableName | where InstanceId_s == '$queryInstanceId' | where toint(NumberOfSites_s) == 0 | distinct InstanceId_s, AppServicePlanName_s, TimeGenerated | summarize FirstUnusedDate = min(TimeGenerated) by InstanceId_s, AppServicePlanName_s | join kind=leftouter ( $consumptionTableName | project InstanceId_s=tolower(ResourceId), CostInBillingCurrency_s, Date_s ) on InstanceId_s | summarize CostsSinceUnused = sumif(todouble(CostInBillingCurrency_s), todatetime(Date_s) > FirstUnusedDate) by AppServicePlanName_s, FirstUnusedDate "@ $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $deploymentDate $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["currentSku"] = $result.SkuSize_s $additionalInfoDictionary["InstanceCount"] = $result.NumberOfWorkers_s $additionalInfoDictionary["CostsAmount"] = [double] $result.Last30DaysCost $additionalInfoDictionary["savingsAmount"] = [double] $result.Last30DaysCost $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = "Microsoft.Web/serverFarms" Impact = "High" RecommendationType = "Saving" RecommendationSubType = "EmptyAppServicePlans" RecommendationSubTypeId = "ef525225-8b91-47a3-81f3-e674e94564b6" RecommendationDescription = "App Service Plans without any application incur in unnecessary costs" RecommendationAction = "Delete the App Service Plan" InstanceId = $result.InstanceId_s InstanceName = $result.AppServicePlanName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "appserviceplans-empty-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." if ($recommendationsErrors -gt 0) { throw "Some of the recommendations queries failed. Please, review the job logs for additional information." } ================================================ FILE: runbooks/recommendations/Recommend-DiskOptimizationsToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" function Find-DiskMonthlyPrice { param ( [object[]] $SKUPriceSheet, [string] $DiskSizeTier ) $diskSkus = $SKUPriceSheet | Where-Object { $_.MeterName_s.Replace(" Disks","") -eq $DiskSizeTier } $targetMonthlyPrice = [double]::MaxValue if ($diskSkus) { $targetMonthlyPrice = [double] ($diskSkus | Sort-Object -Property UnitPrice_s | Select-Object -First 1).UnitPrice_s } return $targetMonthlyPrice } # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceName = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceName" $workspaceRG = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceRG" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $workspaceTenantId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceTenantId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $deploymentDate = Get-AutomationVariable -Name "AzureOptimization_DeploymentDate" # yyyy-MM-dd format $deploymentDate = $deploymentDate.Replace('"', "") $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } # perf thresholds variables $iopsPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdDiskIOPSPercentage" -ErrorAction SilentlyContinue) if (-not($iopsPercentageThreshold -gt 0)) { $iopsPercentageThreshold = 5 } $mbsPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdDiskMBsPercentage" -ErrorAction SilentlyContinue) if (-not($mbsPercentageThreshold -gt 0)) { $mbsPercentageThreshold = 5 } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionOffsetDaysStart = $consumptionOffsetDays + 1 $perfDaysBackwards = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendPerfPeriodInDays" -ErrorAction SilentlyContinue) if (-not($perfDaysBackwards -gt 0)) { $perfDaysBackwards = 7 } $perfTimeGrain = Get-AutomationVariable -Name "AzureOptimization_RecommendPerfTimeGrain" -ErrorAction SilentlyContinue if (-not($perfTimeGrain)) { $perfTimeGrain = "1h" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGManagedDisk','MonitorMetrics','ARGResourceContainers','AzureConsumption','Pricesheet')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $disksTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGManagedDisk' }).LogAnalyticsSuffix + "_CL" $metricsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'MonitorMetrics' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" $consumptionTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureConsumption' }).LogAnalyticsSuffix + "_CL" $pricesheetTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'Pricesheet' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $disksTableName, $metricsTableName, $subscriptionsTableName, $pricesheetTableName and $consumptionTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 30 + $consumptionOffsetDaysStart # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } Write-Output "Getting Disks SKUs for the $referenceRegion region..." $skus = Get-AzComputeResourceSku -Location $referenceRegion | Where-Object { $_.ResourceType -eq "disks" } Write-Output "Getting the current Pricesheet..." if ($cloudEnvironment -eq "AzureCloud") { $pricesheetRegion = "EU West" } try { $pricesheetEntries = @() $baseQuery = @" $pricesheetTableName | where TimeGenerated > ago(14d) | where MeterCategory_s == 'Storage' and MeterSubCategory_s endswith "Managed Disks" and MeterName_s endswith "Disks" and MeterRegion_s == '$pricesheetRegion' and PriceType_s == 'Consumption' | distinct MeterName_s, MeterSubCategory_s, MeterCategory_s, MeterRegion_s, UnitPrice_s, UnitOfMeasure_s "@ $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days 14) -Wait 600 -IncludeStatistics $pricesheetEntries = [System.Linq.Enumerable]::ToArray($queryResults.Results) Write-Output "Query finished with $($pricesheetEntries.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] Write-Output "Consumption pricesheet not available, will estimate savings based in price difference ratio..." } $skuPricesFound = @{} Write-Output "Looking for underutilized Disks, with less than $iopsPercentageThreshold% IOPS and $mbsPercentageThreshold% MB/s usage..." $baseQuery = @" let billingInterval = 30d; let perfInterval = $($perfDaysBackwards)d; let etime = todatetime(toscalar($consumptionTableName | where todatetime(Date_s) < now() and todatetime(Date_s) > ago(billingInterval) | summarize max(todatetime(Date_s)))); let stime = etime-billingInterval; let BilledDisks = $consumptionTableName | where todatetime(Date_s) between (stime..etime) and ResourceId contains '/disks/' and MeterCategory_s == 'Storage' and MeterSubCategory_s has 'Premium' and MeterName_s has 'Disk' | extend DiskConsumedQuantity = todouble(Quantity_s) | extend DiskPrice = todouble(EffectivePrice_s) | extend FinalCost = DiskPrice * DiskConsumedQuantity | extend ResourceId = tolower(ResourceId) | summarize Last30DaysCost = sum(FinalCost), Last30DaysQuantity = sum(DiskConsumedQuantity) by ResourceId; $metricsTableName | where MetricNames_s == 'Composite Disk Read Operations/sec,Composite Disk Write Operations/sec' and TimeGenerated > ago(perfInterval) and isnotempty(MetricValue_s) | summarize MaxIOPSMetric = max(todouble(MetricValue_s)) by ResourceId | join kind=inner ( $disksTableName | where TimeGenerated > ago(1d) and DiskState_s != 'Unattached' and SKU_s startswith 'Premium' | project ResourceId=InstanceId_s, DiskName_s, ResourceGroup = ResourceGroupName_s, SubscriptionId = SubscriptionGuid_g, Cloud_s, TenantGuid_g, Tags_s, MaxIOPSDisk=toint(DiskIOPS_s), DiskSizeGB_s, SKU_s, DiskTier_s, DiskType_s ) on ResourceId | project-away ResourceId1 | extend IOPSPercentage = MaxIOPSMetric/MaxIOPSDisk*100 | where IOPSPercentage < $iopsPercentageThreshold | join kind=inner ( $metricsTableName | where MetricNames_s == 'Composite Disk Read Bytes/sec,Composite Disk Write Bytes/sec' and TimeGenerated > ago(perfInterval) and isnotempty(MetricValue_s) | summarize MaxMBsMetric = max(todouble(MetricValue_s)/1024/1024) by ResourceId | join kind=inner ( $disksTableName | where TimeGenerated > ago(1d) and DiskState_s != 'Unattached' and SKU_s startswith 'Premium' | project ResourceId=InstanceId_s, DiskName_s, ResourceGroup = ResourceGroupName_s, SubscriptionId = SubscriptionGuid_g, Cloud_s, TenantGuid_g, Tags_s, MaxMBsDisk=toint(DiskThroughput_s), DiskSizeGB_s, SKU_s, DiskTier_s, DiskType_s ) on ResourceId | project-away ResourceId1 | extend MBsPercentage = MaxMBsMetric/MaxMBsDisk*100 | where MBsPercentage < $mbsPercentageThreshold ) on ResourceId | join kind=inner ( BilledDisks ) on ResourceId | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionId = SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionId "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] throw "Execution aborted" } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $targetSku = $null $currentDiskTier = $null if ([string]::IsNullOrEmpty($result.DiskTier_s)) # older disks do not have Tier info in their properties { $currentSkuCandidates = @() foreach ($sku in $skus) { $currentSkuCandidate = $null $skuMinSizeGB = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'MinSizeGiB' }).Value $skuMaxSizeGB = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'MaxSizeGiB' }).Value $skuMaxIOps = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'MaxIOps' }).Value $skuMaxBandwidthMBps = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'MaxBandwidthMBps' }).Value if ($sku.Name -eq $result.SKU_s -and $skuMinSizeGB -lt [int]$result.DiskSizeGB_s -and $skuMaxSizeGB -ge [int]$result.DiskSizeGB_s ` -and [int]$skuMaxIOps -eq [int]$result.MaxIOPSDisk -and [int]$skuMaxBandwidthMBps -eq [int]$result.MaxMBsDisk) { if ($null -eq $skuPricesFound[$sku.Size]) { $skuPricesFound[$sku.Size] = Find-DiskMonthlyPrice -DiskSizeTier $sku.Size -SKUPriceSheet $pricesheetEntries } $currentSkuCandidate = New-Object PSObject -Property @{ Name = $sku.Size MaxSizeGB = $skuMaxSizeGB } $currentSkuCandidates += $currentSkuCandidate } } $currentDiskTier = ($currentSkuCandidates | Sort-Object -Property MaxSizeGB | Select-Object -First 1).Name } else { $currentDiskTier = $result.DiskTier_s } if ($null -eq $skuPricesFound[$currentDiskTier]) { $skuPricesFound[$currentDiskTier] = Find-DiskMonthlyPrice -DiskSizeTier $currentDiskTier -SKUPriceSheet $pricesheetEntries } $targetSkuPerfTier = $result.SKU_s.Replace("Premium", "StandardSSD") $targetSkuCandidates = @() foreach ($sku in $skus) { $targetSkuCandidate = $null $skuMinSizeGB = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'MinSizeGiB' }).Value $skuMaxSizeGB = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'MaxSizeGiB' }).Value $skuMaxIOps = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'MaxIOps' }).Value $skuMaxBandwidthMBps = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'MaxBandwidthMBps' }).Value if ($sku.Name -eq $targetSkuPerfTier -and $skuMinSizeGB -lt [int]$result.DiskSizeGB_s -and $skuMaxSizeGB -ge [int]$result.DiskSizeGB_s ` -and [double]$skuMaxIOps -ge [double]$result.MaxIOPSMetric -and [double]$skuMaxBandwidthMBps -ge [double]$result.MaxMBsMetric) { if ($null -eq $skuPricesFound[$sku.Size]) { $skuPricesFound[$sku.Size] = Find-DiskMonthlyPrice -DiskSizeTier $sku.Size -SKUPriceSheet $pricesheetEntries } if ($skuPricesFound[$sku.Size] -lt [double]::MaxValue -and $skuPricesFound[$sku.Size] -lt $skuPricesFound[$currentDiskTier]) { $targetSkuCandidate = New-Object PSObject -Property @{ Name = $sku.Size MonthlyPrice = $skuPricesFound[$sku.Size] MaxSizeGB = $skuMaxSizeGB MaxIOPS = $skuMaxIOps MaxMBps = $skuMaxBandwidthMBps } $targetSkuCandidates += $targetSkuCandidate } } } $targetSku = $targetSkuCandidates | Sort-Object -Property MonthlyPrice | Select-Object -First 1 if ($null -ne $targetSku) { $queryInstanceId = $result.ResourceId $queryText = @" let billingInterval = 30d; let armId = `'$queryInstanceId`'; let gInt = $perfTimeGrain; let ThroughputMBsPerf = $metricsTableName | where TimeGenerated > ago(billingInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'Composite Disk Read Bytes/sec,Composite Disk Write Bytes/sec' and AggregationType_s == 'Average' and AggregationOfType_s == 'Maximum' | extend ThroughputMBs = todouble(MetricValue_s)/1024/1024 | project CollectedDate, ThroughputMBs, InstanceId_s=ResourceId | join kind=inner ( $disksTableName | where TimeGenerated > ago(1d) | distinct InstanceId_s, DiskThroughput_s ) on InstanceId_s | extend MBsPercentage = ThroughputMBs / todouble(DiskThroughput_s) * 100 | summarize max(MBsPercentage) by bin(CollectedDate, gInt); let IOPSPerf = $metricsTableName | where TimeGenerated > ago(billingInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'Composite Disk Read Operations/sec,Composite Disk Write Operations/sec' and AggregationType_s == 'Average' and AggregationOfType_s == 'Maximum' | extend IOPS = todouble(MetricValue_s) | project CollectedDate, IOPS, InstanceId_s=ResourceId | join kind=inner ( $disksTableName | where TimeGenerated > ago(1d) | distinct InstanceId_s, DiskIOPS_s ) on InstanceId_s | extend IOPSPercentage = IOPS / todouble(DiskIOPS_s) * 100 | summarize max(IOPSPercentage) by bin(CollectedDate, gInt); ThroughputMBsPerf | join kind=inner (IOPSPerf) on CollectedDate | render timechart "@ switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $datetime.AddDays(-30).ToString("yyyy-MM-dd") $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["DiskType"] = "Managed" $additionalInfoDictionary["currentSku"] = $result.SKU_s $additionalInfoDictionary["targetSku"] = $targetSkuPerfTier $additionalInfoDictionary["DiskSizeGB"] = [int] $result.DiskSizeGB_s $additionalInfoDictionary["currentTier"] = $currentDiskTier $additionalInfoDictionary["targetTier"] = $targetSku.Name $additionalInfoDictionary["MaxIOPSMetric"] = [double] $($result.MaxIOPSMetric) $additionalInfoDictionary["MaxMBpsMetric"] = [double] $($result.MaxMBsMetric) $additionalInfoDictionary["MetricIOPSPercentage"] = [double] $($result.IOPSPercentage) $additionalInfoDictionary["MetricMBpsPercentage"] = [double] $($result.MBsPercentage) $additionalInfoDictionary["targetMaxSizeGB"] = [int] $targetSku.MaxSizeGB $additionalInfoDictionary["targetMaxIOPS"] = [int] $targetSku.MaxIOPS $additionalInfoDictionary["targetMaxMBps"] =[int] $targetSku.MaxMBps $fitScore = 4 # needs Maximum of Maximum for metrics to have higher fit score if ([int] $result.DiskSizeGB_s -gt 512) { $fitScore = 3.5 #disk will not support credit-based bursting, therefore the recommendation risk increases a bit } $fitScore = [Math]::max(0.0, $fitScore) $savingCoefficient = 2 # Standard SSD is generally close to half the price of Premium SSD $targetSkuSavingsMonthly = $result.Last30DaysCost - ($result.Last30DaysCost / $savingCoefficient) $tentativeTargetSkuSavingsMonthly = -1 if ($targetSku -and $skuPricesFound[$targetSku.Name] -lt [double]::MaxValue) { $targetSkuPrice = $skuPricesFound[$targetSku.Name] if ($skuPricesFound[$currentDiskTier] -lt [double]::MaxValue) { $currentSkuPrice = $skuPricesFound[$currentDiskTier] $tentativeTargetSkuSavingsMonthly = ($currentSkuPrice * [double] $result.Last30DaysQuantity) - ($targetSkuPrice * [double] $result.Last30DaysQuantity) } else { $tentativeTargetSkuSavingsMonthly = $result.Last30DaysCost - ($targetSkuPrice * [double] $result.Last30DaysQuantity) } } if ($tentativeTargetSkuSavingsMonthly -ge 0) { $targetSkuSavingsMonthly = $tentativeTargetSkuSavingsMonthly } $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } if ($targetSkuSavingsMonthly -eq [double]::PositiveInfinity) { $targetSkuSavingsMonthly = [double] $result.Last30DaysCost / 2 } $additionalInfoDictionary["savingsAmount"] = [double] $targetSkuSavingsMonthly $additionalInfoDictionary["CostsAmount"] = [double] $result.Last30DaysCost $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = "Microsoft.Compute/disks" Impact = "High" RecommendationType = "Saving" RecommendationSubType = "UnderusedPremiumSSDDisks" RecommendationSubTypeId = "4854b5dc-4124-4ade-879e-6a7bb65350ab" RecommendationDescription = "Premium SSD disk has been underutilized" RecommendationAction = "Change disk tier at least to the equivalent for Standard SSD" InstanceId = $result.ResourceId InstanceName = $result.DiskName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroup SubscriptionGuid = $result.SubscriptionId SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } } # Export the recommendations as JSON to blob storage Write-Output "Exporting final $($recommendations.Count) results as a JSON file..." $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "disks-underutilized-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." ================================================ FILE: runbooks/recommendations/Recommend-SqlDbOptimizationsToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceName = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceName" $workspaceRG = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceRG" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $workspaceTenantId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceTenantId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $perfDaysBackwards = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendPerfPeriodInDays" -ErrorAction SilentlyContinue) if (-not($perfDaysBackwards -gt 0)) { $perfDaysBackwards = 7 } $dtuPercentile = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfPercentileSqlDtu" -ErrorAction SilentlyContinue) if (-not($dtuPercentile -gt 0)) { $dtuPercentile = 99 } $dtuPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdDtuPercentage" -ErrorAction SilentlyContinue) if (-not($dtuPercentageThreshold -gt 0)) { $dtuPercentageThreshold = 40 } $dtuDegradedPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdDtuDegradedPercentage" -ErrorAction SilentlyContinue) if (-not($dtuDegradedPercentageThreshold -gt 0)) { $dtuDegradedPercentageThreshold = 75 } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionOffsetDaysStart = $consumptionOffsetDays + 1 $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGSqlDb','MonitorMetrics','AzureConsumption','ARGResourceContainers')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $sqlDbsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGSqlDb' }).LogAnalyticsSuffix + "_CL" $metricsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'MonitorMetrics' }).LogAnalyticsSuffix + "_CL" $consumptionTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureConsumption' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $sqlDbsTableName, $subscriptionsTableName, $metricsTableName and $consumptionTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 30 + $consumptionOffsetDaysStart # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } $recommendationsErrors = 0 # Execute the recommendation query against Log Analytics Write-Output "Looking for underused SQL Databases, with less than $dtuPercentageThreshold % Max. DTU usage..." $baseQuery = @" let DTUPercentageThreshold = $dtuPercentageThreshold; let MetricsInterval = $($perfDaysBackwards)d; let BillingInterval = 30d; let dtuPercentPercentile = $dtuPercentile; let etime = todatetime(toscalar($consumptionTableName | where todatetime(Date_s) < now() and todatetime(Date_s) > ago(BillingInterval) | summarize max(todatetime(Date_s)))); let stime = etime-BillingInterval; let CandidateDatabaseIds = $sqlDbsTableName | where TimeGenerated > ago(1d) and SkuName_s in ('Standard','Premium') | distinct InstanceId_s; $metricsTableName | where TimeGenerated > ago(MetricsInterval) | where ResourceId in (CandidateDatabaseIds) and MetricNames_s == 'dtu_consumption_percent' and AggregationType_s == 'Maximum' | summarize P99DTUPercentage = percentile(todouble(MetricValue_s), dtuPercentPercentile) by ResourceId | where P99DTUPercentage < DTUPercentageThreshold | join ( $sqlDbsTableName | where TimeGenerated > ago(1d) | project ResourceId = InstanceId_s, DBName_s, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, SkuName_s, ServiceObjectiveName_s, Tags_s, Cloud_s ) on ResourceId | join kind=leftouter ( $consumptionTableName | where todatetime(Date_s) between (stime..etime) | project ResourceId=tolower(ResourceId), CostInBillingCurrency_s, Date_s ) on ResourceId | summarize Last30DaysCost=sum(todouble(CostInBillingCurrency_s)) by DBName_s, ResourceId, TenantGuid_g, SubscriptionGuid_g, ResourceGroupName_s, SkuName_s, ServiceObjectiveName_s, Tags_s, Cloud_s, P99DTUPercentage | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.ResourceId $queryText = @" $metricsTableName | where ResourceId == '$queryInstanceId' | where MetricNames_s == 'dtu_consumption_percent' and AggregationType_s == 'Maximum' | project TimeGenerated, DTUPercentage = toint(MetricValue_s) | render timechart "@ $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $datetime.AddDays(-30).ToString("yyyy-MM-dd") $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["currentSku"] = "$($result.SkuName_s) $($result.ServiceObjectiveName_s)" $additionalInfoDictionary["DTUPercentage"] = [int] $result.P99DTUPercentage $additionalInfoDictionary["CostsAmount"] = [double] $result.Last30DaysCost $additionalInfoDictionary["savingsAmount"] = ([double] $result.Last30DaysCost / 2) $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = "Microsoft.Sql/servers/databases" Impact = "High" RecommendationType = "Saving" RecommendationSubType = "UnderusedSqlDatabases" RecommendationSubTypeId = "ff68f4e5-1197-4be9-8e5f-8760d7863cb4" RecommendationDescription = "Underused SQL Databases (performance capacity waste)" RecommendationAction = "Right-size underused SQL Databases" InstanceId = $result.ResourceId InstanceName = $result.DBName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "sqldbs-underused-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for performance constrained SQL Databases, with more than $dtuDegradedPercentageThreshold % Avg. DTU usage..." $baseQuery = @" let DTUPercentageThreshold = $dtuDegradedPercentageThreshold; let MetricsInterval = $($perfDaysBackwards)d; let CandidateDatabaseIds = $sqlDbsTableName | where TimeGenerated > ago(1d) and SkuName_s in ('Basic','Standard','Premium') | distinct InstanceId_s; $metricsTableName | where TimeGenerated > ago(MetricsInterval) | where ResourceId in (CandidateDatabaseIds) and MetricNames_s == 'dtu_consumption_percent' and AggregationType_s == 'Average' and AggregationOfType_s == 'Maximum' | summarize AvgDTUPercentage = avg(todouble(MetricValue_s)) by ResourceId | where AvgDTUPercentage > DTUPercentageThreshold | join ( $sqlDbsTableName | where TimeGenerated > ago(1d) | project ResourceId = InstanceId_s, DBName_s, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, SkuName_s, ServiceObjectiveName_s, Tags_s, Cloud_s ) on ResourceId | project DBName_s, ResourceId, TenantGuid_g, SubscriptionGuid_g, ResourceGroupName_s, SkuName_s, ServiceObjectiveName_s, Tags_s, Cloud_s, AvgDTUPercentage | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.ResourceId $queryText = @" $metricsTableName | where ResourceId == '$queryInstanceId' | where MetricNames_s == 'dtu_consumption_percent' and AggregationType_s == 'Average' | project TimeGenerated, DTUPercentage = toint(MetricValue_s) | render timechart "@ $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $datetime.AddDays(-30).ToString("yyyy-MM-dd") $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["currentSku"] = "$($result.SkuName_s) $($result.ServiceObjectiveName_s)" $additionalInfoDictionary["DTUPercentage"] = [int] $result.AvgDTUPercentage $fitScore = 4 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Performance" ImpactedArea = "Microsoft.Sql/servers/databases" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "PerfConstrainedSqlDatabases" RecommendationSubTypeId = "724ff2f5-8c83-4105-b00d-029c4560d774" RecommendationDescription = "SQL Database performance has been constrained by lack of resources" RecommendationAction = "Resize SQL Database to higher SKU or scale it out" InstanceId = $result.ResourceId InstanceName = $result.DBName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "sqldbs-perfconstrained-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." if ($recommendationsErrors -gt 0) { throw "Some of the recommendations queries failed. Please, review the job logs for additional information." } ================================================ FILE: runbooks/recommendations/Recommend-StorageAccountOptimizationsToBlobStorage.ps1 ================================================ function ConvertTo-Hashtable { [CmdletBinding()] [OutputType('hashtable')] param ( [Parameter(ValueFromPipeline)] $InputObject ) process { if ($null -eq $InputObject) { return $null } if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { $collection = @( foreach ($object in $InputObject) { ConvertTo-Hashtable -InputObject $object } ) Write-Output -NoEnumerate $collection } elseif ($InputObject -is [psobject]) { $hash = @{} foreach ($property in $InputObject.PSObject.Properties) { $hash[$property.Name] = ConvertTo-Hashtable -InputObject $property.Value } $hash } else { $InputObject } } } $ErrorActionPreference = "Stop" # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceName = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceName" $workspaceRG = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceRG" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $workspaceTenantId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceTenantId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } # storage account thresholds variables $growthPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendationStorageAcountGrowthThresholdPercentage" -ErrorAction SilentlyContinue) if (-not($growthPercentageThreshold -gt 0)) { $growthPercentageThreshold = 5 } $monthlyCostThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendationStorageAcountGrowthMonthlyCostThreshold" -ErrorAction SilentlyContinue) if (-not($monthlyCostThreshold -gt 0)) { $monthlyCostThreshold = 50 } $growthLookbackDays = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendationStorageAcountGrowthLookbackDays" -ErrorAction SilentlyContinue) if (-not($growthLookbackDays -gt 0)) { $growthLookbackDays = 30 } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionOffsetDaysStart = $consumptionOffsetDays + 1 $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } $tenantId = (Get-AzContext).Tenant.Id Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGResourceContainers','AzureConsumption')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" $consumptionTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureConsumption' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $subscriptionsTableName and $consumptionTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = $growthLookbackDays + $consumptionOffsetDaysStart # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } Write-Output "Looking for ever growing Storage Accounts, with more than $monthlyCostThreshold/month costs, growing more than $growthPercentageThreshold% over the last $growthLookbackDays days..." $dailyCostThreshold = [Math]::Round($monthlyCostThreshold / 30) $baseQuery = @" let interval = $($growthLookbackDays)d; let etime = endofday(todatetime(toscalar($consumptionTableName | where todatetime(Date_s) > ago(interval) and todatetime(Date_s) < now() | summarize max(todatetime(Date_s))))); let etime_subs = endofday(todatetime(toscalar($subscriptionsTableName | where TimeGenerated > ago(interval) | summarize max(TimeGenerated)))); let stime = endofday(etime-interval); let lastday_stime = endofday(etime-1d); let lastday_stime_subs = endofday(etime_subs-1d); let costThreshold = $dailyCostThreshold; let growthPercentageThreshold = $growthPercentageThreshold; let StorageAccountsWithLastTags = $consumptionTableName | where todatetime(Date_s) between (lastday_stime..etime) | where MeterCategory_s == 'Storage' and ConsumedService_s == 'Microsoft.Storage' and MeterName_s endswith 'Data Stored' and ChargeType_s == 'Usage' | extend ResourceId = tolower(ResourceId) | distinct ResourceId, Tags_s; $consumptionTableName | where todatetime(Date_s) between (stime..etime) | where MeterCategory_s == 'Storage' and ConsumedService_s == 'Microsoft.Storage' and MeterName_s endswith 'Data Stored' and ChargeType_s == 'Usage' | extend ResourceId = tolower(ResourceId) | make-series CostSum=sum(todouble(CostInBillingCurrency_s)) default=0.0 on todatetime(Date_s) from stime to etime step 1d by ResourceId, ResourceGroup, SubscriptionId | extend InitialDailyCost = todouble(CostSum[0]), CurrentDailyCost = todouble(CostSum[array_length(CostSum)-1]) | extend GrowthPercentage = round((CurrentDailyCost-InitialDailyCost)/InitialDailyCost*100) | where InitialDailyCost > 0 and CurrentDailyCost > costThreshold and GrowthPercentage > growthPercentageThreshold | project ResourceId, InitialDailyCost, CurrentDailyCost, GrowthPercentage, ResourceGroup, SubscriptionId | join kind=leftouter (StorageAccountsWithLastTags) on ResourceId | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > lastday_stime_subs | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionId=SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionId "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] throw "Execution aborted" } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.ResourceId $queryText = @" $consumptionTableName | where MeterCategory_s == 'Storage' and ConsumedService_s == 'Microsoft.Storage' and MeterName_s endswith 'Data Stored' and ChargeType_s == 'Usage' | extend ResourceId = tolower(ResourceId) | where ResourceId =~ '$queryInstanceId' | summarize DailyCosts = sum(todouble(CostInBillingCurrency_s)) by bin(todatetime(Date_s), 1d) | render timechart "@ switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $datetime.AddDays(-1 * $recommendationSearchTimeSpan).ToString("yyyy-MM-dd") $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $costsAmount = ([double] $result.InitialDailyCost + [double] $result.CurrentDailyCost) / 2 * 30 $additionalInfoDictionary["InitialDailyCost"] = $result.InitialDailyCost $additionalInfoDictionary["CurrentDailyCost"] = $result.CurrentDailyCost $additionalInfoDictionary["GrowthPercentage"] = $result.GrowthPercentage $additionalInfoDictionary["CostsAmount"] = $costsAmount $additionalInfoDictionary["savingsAmount"] = $costsAmount * 0.25 # estimated 25% savings $fitScore = 4 # savings are estimated with a significant error margin $fitScore = [Math]::max(0.0, $fitScore) $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { if (-not($result.Tags_s -like "{*")) { $result.Tags_s = '{' + $result.Tags_s + '}' } $tags = ConvertFrom-Json $result.Tags_s | ConvertTo-Hashtable } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $cloudEnvironment Category = "Cost" ImpactedArea = "Microsoft.Storage/storageAccounts" Impact = "Medium" RecommendationType = "Saving" RecommendationSubType = "StorageAccountsGrowing" RecommendationSubTypeId = "08e049ca-18b0-4d22-b174-131a91d0381c" RecommendationDescription = "Storage Account without retention policy in place" RecommendationAction = "Review whether the Storage Account has a retention policy for example via Lifecycle Management" InstanceId = $result.ResourceId InstanceName = $result.ResourceId.Split('/')[-1] AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroup SubscriptionGuid = $result.SubscriptionId SubscriptionName = $result.SubscriptionName TenantGuid = $tenantId FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage Write-Output "Exporting final $($recommendations.Count) results as a JSON file..." $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "storageaccounts-costsgrowing-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." ================================================ FILE: runbooks/recommendations/Recommend-UnattachedDisksToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceName = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceName" $workspaceRG = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceRG" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $workspaceTenantId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceTenantId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $deploymentDate = Get-AutomationVariable -Name "AzureOptimization_DeploymentDate" # yyyy-MM-dd format $deploymentDate = $deploymentDate.Replace('"', "") $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionOffsetDaysStart = $consumptionOffsetDays + 1 $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGManagedDisk','AzureConsumption','ARGResourceContainers')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $disksTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGManagedDisk' }).LogAnalyticsSuffix + "_CL" $consumptionTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureConsumption' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $disksTableName, $subscriptionsTableName and $consumptionTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 30 + $consumptionOffsetDaysStart # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } # Execute the recommendation query against Log Analytics $baseQuery = @" let interval = 30d; let etime = todatetime(toscalar($consumptionTableName | where todatetime(Date_s) < now() and todatetime(Date_s) > ago(interval) | summarize max(todatetime(Date_s)))); let stime = etime-interval; $disksTableName | where TimeGenerated > ago(1d) and isempty(OwnerVMId_s) and Tags_s !has 'ASR-ReplicaDisk' and Tags_s !has 'asrseeddisk' | distinct DiskName_s, InstanceId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, SKU_s, DiskSizeGB_s, Tags_s, Cloud_s | join kind=leftouter ( $consumptionTableName | where todatetime(Date_s) between (stime..etime) | project InstanceId_s=tolower(ResourceId), CostInBillingCurrency_s, Date_s ) on InstanceId_s | summarize Last30DaysCost=sum(todouble(CostInBillingCurrency_s)) by DiskName_s, InstanceId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, SKU_s, DiskSizeGB_s, Tags_s, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] throw "Execution aborted" } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.InstanceId_s $queryText = @" $disksTableName | where InstanceId_s == '$queryInstanceId' and isempty(OwnerVMId_s) | distinct InstanceId_s, DiskName_s, DiskSizeGB_s, SKU_s, TimeGenerated | summarize LastAttachedDate = min(TimeGenerated) by InstanceId_s, DiskName_s, DiskSizeGB_s, SKU_s | join kind=leftouter ( $consumptionTableName | project InstanceId_s=tolower(ResourceId), CostInBillingCurrency_s, Date_s ) on InstanceId_s | summarize CostsSinceDetached = sumif(todouble(CostInBillingCurrency_s), todatetime(Date_s) > LastAttachedDate) by DiskName_s, LastAttachedDate, DiskSizeGB_s, SKU_s "@ $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $deploymentDate $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["DiskType"] = "Managed" $additionalInfoDictionary["currentSku"] = $result.SKU_s $additionalInfoDictionary["DiskSizeGB"] = [int] $result.DiskSizeGB_s $additionalInfoDictionary["CostsAmount"] = [double] $result.Last30DaysCost $additionalInfoDictionary["savingsAmount"] = [double] $result.Last30DaysCost $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = "Microsoft.Compute/disks" Impact = "Medium" RecommendationType = "Saving" RecommendationSubType = "UnattachedDisks" RecommendationSubTypeId = "c84d5e86-e2d6-4d62-be7c-cecfbd73b0db" RecommendationDescription = "Unattached disks (without owner VM) incur in unnecessary costs" RecommendationAction = "Delete or downgrade disk to Standard SKU" InstanceId = $result.InstanceId_s InstanceName = $result.DiskName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "unattacheddisks-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." ================================================ FILE: runbooks/recommendations/Recommend-UnusedAppGWsToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceName = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceName" $workspaceRG = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceRG" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $workspaceTenantId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceTenantId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $deploymentDate = Get-AutomationVariable -Name "AzureOptimization_DeploymentDate" # yyyy-MM-dd format $deploymentDate = $deploymentDate.Replace('"', "") $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionOffsetDaysStart = $consumptionOffsetDays + 1 $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGAppGateway','AzureConsumption','ARGResourceContainers')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $appGWsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGAppGateway' }).LogAnalyticsSuffix + "_CL" $consumptionTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureConsumption' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $appGWsTableName, $subscriptionsTableName and $consumptionTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 30 + $consumptionOffsetDaysStart # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } # Execute the Cost recommendation query against Log Analytics $baseQuery = @" let interval = 30d; let etime = todatetime(toscalar($consumptionTableName | where todatetime(Date_s) < now() and todatetime(Date_s) > ago(interval) | summarize max(todatetime(Date_s)))); let stime = etime-interval; $appGWsTableName | where TimeGenerated > ago(1d) | where toint(BackendPoolsCount_s) == 0 or ((BackendIPCount_s == 0 or isempty(BackendIPCount_s)) and (BackendAddressesCount_s == 0 or isempty(BackendAddressesCount_s))) | distinct InstanceName_s, InstanceId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, SkuName_s, SkuCapacity_s, Tags_s, Cloud_s | join kind=leftouter ( $consumptionTableName | where todatetime(Date_s) between (stime..etime) | project InstanceId_s=tolower(ResourceId), CostInBillingCurrency_s, Date_s ) on InstanceId_s | summarize Last30DaysCost=sum(todouble(CostInBillingCurrency_s)) by InstanceName_s, InstanceId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, SkuName_s, SkuCapacity_s, Tags_s, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] throw "Execution aborted" } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.InstanceId_s $queryText = @" $appGWsTableName | where InstanceId_s == '$queryInstanceId' | where toint(BackendPoolsCount_s) == 0 or ((toint(BackendIPCount_s) == 0 or isempty(BackendIPCount_s)) and (toint(BackendAddressesCount_s) == 0 or isempty(BackendAddressesCount_s))) | distinct InstanceId_s, InstanceName_s, TimeGenerated | summarize FirstUnusedDate = min(TimeGenerated) by InstanceId_s, InstanceName_s | join kind=leftouter ( $consumptionTableName | project InstanceId_s=tolower(ResourceId), CostInBillingCurrency_s, Date_s ) on InstanceId_s | summarize CostsSinceUnused = sumif(todouble(CostInBillingCurrency_s), todatetime(Date_s) > FirstUnusedDate) by InstanceName_s, FirstUnusedDate "@ $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $deploymentDate $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["currentSku"] = $result.SkuName_s $additionalInfoDictionary["InstanceCount"] = $result.SkuCapacity_s $additionalInfoDictionary["CostsAmount"] = [double] $result.Last30DaysCost $additionalInfoDictionary["savingsAmount"] = [double] $result.Last30DaysCost $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = "Microsoft.Network/applicationGateways" Impact = "High" RecommendationType = "Saving" RecommendationSubType = "UnusedAppGateways" RecommendationSubTypeId = "dc3d2baa-26c8-435e-aa9d-edb2bfd6fff6" RecommendationDescription = "Application Gateways without a backend pool incur in unnecessary costs" RecommendationAction = "Delete the Application Gateway" InstanceId = $result.InstanceId_s InstanceName = $result.InstanceName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "unusedappgateways-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." ================================================ FILE: runbooks/recommendations/Recommend-UnusedLoadBalancersToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceName = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceName" $workspaceRG = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceRG" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $workspaceTenantId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceTenantId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $deploymentDate = Get-AutomationVariable -Name "AzureOptimization_DeploymentDate" # yyyy-MM-dd format $deploymentDate = $deploymentDate.Replace('"', "") $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionOffsetDaysStart = $consumptionOffsetDays + 1 $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGLoadBalancer','AzureConsumption','ARGResourceContainers')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $lbsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGLoadBalancer' }).LogAnalyticsSuffix + "_CL" $consumptionTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureConsumption' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $lbsTableName, $subscriptionsTableName and $consumptionTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 30 + $consumptionOffsetDaysStart # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } $recommendationsErrors = 0 # Execute the Cost recommendation query against Log Analytics $baseQuery = @" let interval = 30d; let etime = todatetime(toscalar($consumptionTableName | where todatetime(Date_s) < now() and todatetime(Date_s) > ago(interval) | summarize max(todatetime(Date_s)))); let stime = etime-interval; $lbsTableName | where TimeGenerated > ago(1d) | where SkuName_s == 'Standard' | where (toint(BackendPoolsCount_s) == 0 or ((toint(BackendIPCount_s) == 0 or isempty(BackendIPCount_s)) and (toint(BackendAddressesCount_s) == 0 or isempty(BackendAddressesCount_s)))) and toint(InboundNatPoolsCount_s) == 0 | where toint(LbRulesCount_s) != 0 or toint(InboundNatRulesCount_s) != 0 or toint(OutboundRulesCount_s) != 0 | distinct InstanceName_s, InstanceId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, SkuName_s, Tags_s, Cloud_s | join kind=leftouter ( $consumptionTableName | where todatetime(Date_s) between (stime..etime) | project InstanceId_s=tolower(ResourceId), CostInBillingCurrency_s, Date_s ) on InstanceId_s | summarize Last30DaysCost=sum(todouble(CostInBillingCurrency_s)) by InstanceName_s, InstanceId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, SkuName_s, Tags_s, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Costs query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.InstanceId_s $queryText = @" $lbsTableName | where InstanceId_s == '$queryInstanceId' | where SkuName_s == 'Standard' | where (toint(BackendPoolsCount_s) == 0 or ((BackendIPCount_s == 0 or isempty(BackendIPCount_s)) and (BackendAddressesCount_s == 0 or isempty(BackendAddressesCount_s)))) and toint(InboundNatPoolsCount_s) == 0 | where toint(LbRulesCount_s) != 0 or toint(InboundNatRulesCount_s) != 0 or toint(OutboundRulesCount_s) != 0 | distinct InstanceId_s, InstanceName_s, SkuName_s, TimeGenerated | summarize FirstUnusedDate = min(TimeGenerated) by InstanceId_s, InstanceName_s, SkuName_s | join kind=leftouter ( $consumptionTableName | project InstanceId_s=tolower(ResourceId), CostInBillingCurrency_s, Date_s ) on InstanceId_s | summarize CostsSinceUnused = sumif(todouble(CostInBillingCurrency_s), todatetime(Date_s) > FirstUnusedDate) by InstanceName_s, FirstUnusedDate, SkuName_s "@ $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $deploymentDate $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["currentSku"] = $result.SkuName_s $additionalInfoDictionary["CostsAmount"] = [double] $result.Last30DaysCost $additionalInfoDictionary["savingsAmount"] = [double] $result.Last30DaysCost $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = "Microsoft.Network/loadBalancers" Impact = "Medium" RecommendationType = "Saving" RecommendationSubType = "UnusedStandardLoadBalancers" RecommendationSubTypeId = "f1ed3bb2-3cb5-41e6-ba38-7001d5ff87f5" RecommendationDescription = "Standard Load Balancers with rules defined and without a backend pool incur in unnecessary costs" RecommendationAction = "Delete the Load Balancer" InstanceId = $result.InstanceId_s InstanceName = $result.InstanceName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "unusedstdloadbalancers-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." # Execute the Operational Excellence recommendation query against Log Analytics $baseQuery = @" $lbsTableName | where TimeGenerated > ago(1d) | where (toint(BackendPoolsCount_s) == 0 or BackendIPCount_s == 0 or isempty(BackendIPCount_s)) and toint(InboundNatPoolsCount_s) == 0 | distinct InstanceName_s, InstanceId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, SkuName_s, Tags_s, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days 2) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Operational Excellence query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$workspaceTenantId/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["currentSku"] = $result.SkuName_s $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "OperationalExcellence" ImpactedArea = "Microsoft.Network/loadBalancers" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "UnusedLoadBalancers" RecommendationSubTypeId = "48619512-f4e6-4241-9c85-16f7c987950c" RecommendationDescription = "Load Balancers without a backend pool are useless" RecommendationAction = "Delete the Load Balancer" InstanceId = $result.InstanceId_s InstanceName = $result.InstanceName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "unusedloadbalancers-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." if ($recommendationsErrors -gt 0) { throw "Some of the recommendations queries failed. Please, review the job logs for additional information." } ================================================ FILE: runbooks/recommendations/Recommend-VMOptimizationsToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceName = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceName" $workspaceRG = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceRG" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $workspaceTenantId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceTenantId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $deploymentDate = Get-AutomationVariable -Name "AzureOptimization_DeploymentDate" # yyyy-MM-dd format $deploymentDate = $deploymentDate.Replace('"', "") $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $deallocatedIntervalDays = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendationLongDeallocatedVmsIntervalDays") $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionOffsetDaysStart = $consumptionOffsetDays + 1 $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGManagedDisk','ARGVirtualMachine','AzureConsumption','ARGResourceContainers')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $vmsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGVirtualMachine' }).LogAnalyticsSuffix + "_CL" $disksTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGManagedDisk' }).LogAnalyticsSuffix + "_CL" $consumptionTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureConsumption' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $vmsTableName, $disksTableName, $subscriptionsTableName and $consumptionTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = $deallocatedIntervalDays + $consumptionOffsetDaysStart $offlineInterval = $deallocatedIntervalDays + $consumptionOffsetDays $billingInterval = 30 + $consumptionOffsetDays # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } $recommendationsErrors = 0 Write-Output "Looking for VMs that have been deallocated for more than 30 days..." # Execute the recommendation query against Log Analytics $baseQuery = @" let offlineInterval = $($offlineInterval)d; let billingInterval = $($billingInterval)d; let billingWindowIntervalEnd = $($consumptionOffsetDays)d; let billingWindowIntervalStart = $($consumptionOffsetDaysStart)d; let etime = todatetime(toscalar($consumptionTableName | where todatetime(Date_s) < now() and todatetime(Date_s) > ago(billingInterval) | summarize max(todatetime(Date_s)))); let stime = etime-offlineInterval; let BilledVMs = $consumptionTableName | where todatetime(Date_s) between (stime..etime) | where ResourceId like 'microsoft.compute/virtualmachines/' or ResourceId like 'microsoft.classiccompute/virtualmachines/' | extend InstanceId_s = tolower(ResourceId) | distinct InstanceId_s; let RunningVMs = $vmsTableName | where TimeGenerated > ago(billingWindowIntervalStart) and TimeGenerated < ago(billingWindowIntervalEnd) | where PowerState_s has_any ('running','starting','readyrole') | distinct InstanceId_s; let BilledDisks = $consumptionTableName | where todatetime(Date_s) between (stime..etime) | where ResourceId like 'microsoft.compute/disks/' | extend BillingInstanceId = tolower(ResourceId) | summarize DisksCosts = sum(todouble(CostInBillingCurrency_s)) by BillingInstanceId; $vmsTableName | where TimeGenerated > ago(billingWindowIntervalStart) and TimeGenerated < ago(billingWindowIntervalEnd) | where InstanceId_s !in (RunningVMs) | join kind=leftouter (BilledVMs) on InstanceId_s | where isempty(InstanceId_s1) | project InstanceId_s, VMName_s, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, Cloud_s, Tags_s | join kind=leftouter ( $disksTableName | where TimeGenerated > ago(1d) | project DiskInstanceId = InstanceId_s, SKU_s, OwnerVMId_s ) on `$left.InstanceId_s == `$right.OwnerVMId_s | join kind=leftouter ( BilledDisks ) on `$left.DiskInstanceId == `$right.BillingInstanceId | summarize TotalDisksCosts = sum(DisksCosts) by InstanceId_s, VMName_s, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, Cloud_s, Tags_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.InstanceId_s $queryText = @" let offlineInterval = $($offlineInterval)d; $consumptionTableName | extend ResourceId = tolower(ResourceId) | where ResourceId =~ '$queryInstanceId' | where todatetime(Date_s) < now() | join kind=inner ( $disksTableName | extend DiskInstanceId = InstanceId_s ) on `$left.ResourceId == `$right.OwnerVMId_s | summarize DeallocatedSince = max(todatetime(Date_s)) by DiskName_s, DiskSizeGB_s, SKU_s, DiskInstanceId | join kind=inner ( $consumptionTableName | where todatetime(Date_s) > ago(offlineInterval) | extend DiskInstanceId = tolower(ResourceId) | summarize DiskCosts = sum(todouble(CostInBillingCurrency_s)) by DiskInstanceId ) on DiskInstanceId | project DeallocatedSince, DiskName_s, DiskSizeGB_s, SKU_s, MonthlyCosts = DiskCosts "@ $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $deploymentDate $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["LongDeallocatedThreshold"] = $deallocatedIntervalDays $additionalInfoDictionary["CostsAmount"] = [double] $result.TotalDisksCosts $additionalInfoDictionary["savingsAmount"] = [double] $result.TotalDisksCosts $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = "Microsoft.Compute/virtualMachines" Impact = "Medium" RecommendationType = "Saving" RecommendationSubType = "LongDeallocatedVms" RecommendationSubTypeId = "c320b790-2e58-452a-aa63-7b62c383ad8a" RecommendationDescription = "Virtual Machine has been deallocated for long with disks still incurring costs" RecommendationAction = "Delete Virtual Machine or downgrade its disks to Standard HDD SKU" InstanceId = $result.InstanceId_s InstanceName = $result.VMName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "longdeallocatedvms-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for VMs that are stopped (not deallocated)..." # Execute the recommendation query against Log Analytics $baseQuery = @" $vmsTableName | where TimeGenerated > ago(1d) | where PowerState_s has 'stopped' | project InstanceId_s, VMName_s, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, Cloud_s, Tags_s | join kind=leftouter ( $consumptionTableName | where TimeGenerated > ago(1d) and MeterCategory_s == 'Virtual Machines' | project InstanceId_s=tolower(ResourceId), UnitPrice_s, EffectivePrice_s | summarize arg_max(todouble(EffectivePrice_s), *) by InstanceId_s | project InstanceId_s, MonthlyCost=24*todouble(iif(todouble(UnitPrice_s) > 0, todouble(UnitPrice_s), todouble(EffectivePrice_s)))*30 ) on InstanceId_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.InstanceId_s $queryText = @" let LastNonStopped = toscalar($vmsTableName | where InstanceId_s =~ '$queryInstanceId' | where TimeGenerated < now() | where PowerState_s !has 'stopped' | summarize max(todatetime(StatusDate_s))); $consumptionTableName | where ResourceId =~ '$queryInstanceId' | where todatetime(Date_s) >= LastNonStopped | where MeterCategory_s == 'Virtual Machines' | summarize ComputeCostsSinceStopped = sum(todouble(Quantity_s)*todouble(UnitPrice_s)) by MeterSubCategory_s, StoppedSince=LastNonStopped "@ $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $deploymentDate $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["CostsAmount"] = [double] $result.MonthlyCost $additionalInfoDictionary["savingsAmount"] = [double] $result.MonthlyCost $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = "Microsoft.Compute/virtualMachines" Impact = "High" RecommendationType = "Saving" RecommendationSubType = "StoppedVms" RecommendationSubTypeId = "110fea55-a9c3-480d-8248-116f61e139a8" RecommendationDescription = "Virtual Machine is stopped (not deallocated) and still incurring costs" RecommendationAction = "Deallocate Virtual Machine" InstanceId = $result.InstanceId_s InstanceName = $result.VMName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "stoppedvms-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." if ($recommendationsErrors -gt 0) { throw "Some of the recommendations queries failed. Please, review the job logs for additional information." } ================================================ FILE: runbooks/recommendations/Recommend-VMSSOptimizationsToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" function Find-SkuHourlyPrice { param ( [object[]] $SKUPriceSheet, [string] $SKUName ) $skuPriceObject = $null if ($SKUPriceSheet) { $skuNameParts = $SKUName.Split('_') if ($skuNameParts.Count -eq 3) # e.g., Standard_D1_v2 { $skuNameFilter = "*" + $skuNameParts[1] + " *" $skuVersionFilter = "*" + $skuNameParts[2] $skuPrices = $SKUPriceSheet | Where-Object { $_.MeterName_s -like $skuNameFilter ` -and $_.MeterName_s -notlike '*Low Priority' -and $_.MeterName_s -notlike '*Expired' ` -and $_.MeterName_s -like $skuVersionFilter -and $_.MeterSubCategory_s -notlike '*Windows' -and $_.UnitPrice_s -ne 0 } if (($skuPrices -or $skuPrices.Count -ge 1) -and $skuPrices.Count -le 2) { $skuPriceObject = $skuPrices[0] } if ($skuPrices.Count -gt 2) # D1-like scenarios { $skuFilter = "*" + $skuNameParts[1] + " " + $skuNameParts[2] + "*" $skuPrices = $skuPrices | Where-Object { $_.MeterName_s -like $skuFilter } if (($skuPrices -or $skuPrices.Count -ge 1) -and $skuPrices.Count -le 2) { $skuPriceObject = $skuPrices[0] } } } if ($skuNameParts.Count -eq 2) # e.g., Standard_D1 { $skuNameFilter = "*" + $skuNameParts[1] + "*" $skuPrices = $SKUPriceSheet | Where-Object { $_.MeterName_s -like $skuNameFilter ` -and $_.MeterName_s -notlike '*Low Priority' -and $_.MeterName_s -notlike '*Expired' ` -and $_.MeterName_s -notlike '* v*' -and $_.MeterSubCategory_s -notlike '*Windows' -and $_.UnitPrice_s -ne 0 } if (($skuPrices -or $skuPrices.Count -ge 1) -and $skuPrices.Count -le 2) { $skuPriceObject = $skuPrices[0] } if ($skuPrices.Count -gt 2) # D1-like scenarios { $skuFilterLeft = "*" + $skuNameParts[1] + "/*" $skuFilterRight = "*/" + $skuNameParts[1] + "*" $skuPrices = $skuPrices | Where-Object { $_.MeterName_s -like $skuFilterLeft -or $_.MeterName_s -like $skuFilterRight } if (($skuPrices -or $skuPrices.Count -ge 1) -and $skuPrices.Count -le 2) { $skuPriceObject = $skuPrices[0] } } } } $targetHourlyPrice = [double]::MaxValue if ($null -ne $skuPriceObject) { $targetUnitHours = [int] (Select-String -InputObject $skuPriceObject.UnitOfMeasure_s -Pattern "^\d+").Matches[0].Value if ($targetUnitHours -gt 0) { $targetHourlyPrice = [double] ($skuPriceObject.UnitPrice_s / $targetUnitHours) } } return $targetHourlyPrice } # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceName = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceName" $workspaceRG = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceRG" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $workspaceTenantId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceTenantId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $deploymentDate = Get-AutomationVariable -Name "AzureOptimization_DeploymentDate" # yyyy-MM-dd format $deploymentDate = $deploymentDate.Replace('"', "") $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } # percentiles variables $cpuPercentile = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfPercentileCpu" -ErrorAction SilentlyContinue) if (-not($cpuPercentile -gt 0)) { $cpuPercentile = 99 } $memoryPercentile = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfPercentileMemory" -ErrorAction SilentlyContinue) if (-not($memoryPercentile -gt 0)) { $memoryPercentile = 99 } # perf thresholds variables $cpuPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdCpuPercentage" -ErrorAction SilentlyContinue) if (-not($cpuPercentageThreshold -gt 0)) { $cpuPercentageThreshold = 30 } $memoryPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdMemoryPercentage" -ErrorAction SilentlyContinue) if (-not($memoryPercentageThreshold -gt 0)) { $memoryPercentageThreshold = 50 } $cpuDegradedMaxPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdCpuDegradedMaxPercentage" -ErrorAction SilentlyContinue) if (-not($cpuDegradedMaxPercentageThreshold -gt 0)) { $cpuDegradedMaxPercentageThreshold = 95 } $cpuDegradedAvgPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdCpuDegradedAvgPercentage" -ErrorAction SilentlyContinue) if (-not($cpuDegradedAvgPercentageThreshold -gt 0)) { $cpuDegradedAvgPercentageThreshold = 75 } $memoryDegradedPercentageThreshold = [int] (Get-AutomationVariable -Name "AzureOptimization_PerfThresholdMemoryDegradedPercentage" -ErrorAction SilentlyContinue) if (-not($memoryDegradedPercentageThreshold -gt 0)) { $memoryDegradedPercentageThreshold = 90 } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionOffsetDaysStart = $consumptionOffsetDays + 1 $perfDaysBackwards = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendPerfPeriodInDays" -ErrorAction SilentlyContinue) if (-not($perfDaysBackwards -gt 0)) { $perfDaysBackwards = 7 } $perfTimeGrain = Get-AutomationVariable -Name "AzureOptimization_RecommendPerfTimeGrain" -ErrorAction SilentlyContinue if (-not($perfTimeGrain)) { $perfTimeGrain = "1h" } $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGVMSS','MonitorMetrics','ARGResourceContainers','AzureConsumption','Pricesheet')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $vmssTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGVMSS' }).LogAnalyticsSuffix + "_CL" $metricsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'MonitorMetrics' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" $consumptionTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureConsumption' }).LogAnalyticsSuffix + "_CL" $pricesheetTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'Pricesheet' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $vmssTableName, $metricsTableName, $subscriptionsTableName, $pricesheetTableName and $consumptionTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 30 + $consumptionOffsetDaysStart # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } Write-Output "Getting Virtual Machine SKUs for the $referenceRegion region..." $skus = Get-AzComputeResourceSku -Location $referenceRegion | Where-Object { $_.ResourceType -eq "virtualMachines" } Write-Output "Getting the current Pricesheet..." if ($cloudEnvironment -eq "AzureCloud") { $pricesheetRegion = "EU West" } try { $pricesheetEntries = @() $baseQuery = @" $pricesheetTableName | where TimeGenerated > ago(14d) | where MeterCategory_s == 'Virtual Machines' and MeterRegion_s == '$pricesheetRegion' and PriceType_s == 'Consumption' | distinct MeterName_s, MeterSubCategory_s, MeterCategory_s, MeterRegion_s, UnitPrice_s, UnitOfMeasure_s "@ $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days 14) -Wait 600 -IncludeStatistics $pricesheetEntries = [System.Linq.Enumerable]::ToArray($queryResults.Results) Write-Output "Query finished with $($pricesheetEntries.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] Write-Output "Consumption pricesheet not available, will estimate savings based in cores count..." } $skuPricesFound = @{} $recommendationsErrors = 0 Write-Output "Looking for underutilized Scale Sets, with less than $cpuPercentageThreshold% CPU and $memoryPercentageThreshold% RAM usage..." $baseQuery = @" let billingInterval = 30d; let perfInterval = $($perfDaysBackwards)d; let cpuPercentileValue = $cpuPercentile; let memoryPercentileValue = $memoryPercentile; let etime = todatetime(toscalar($consumptionTableName | where todatetime(Date_s) < now() and todatetime(Date_s) > ago(30d) | summarize max(todatetime(Date_s)))); let stime = etime-billingInterval; let BilledVMs = $consumptionTableName | where todatetime(Date_s) between (stime..etime) and ResourceId contains 'virtualmachinescalesets' | extend VMConsumedQuantity = iif(ResourceId contains 'virtualmachinescalesets' and MeterCategory_s == 'Virtual Machines', todouble(Quantity_s), 0.0) | extend VMPrice = iif(ResourceId contains 'virtualmachinescalesets' and MeterCategory_s == 'Virtual Machines', todouble(EffectivePrice_s), 0.0) | extend FinalCost = VMPrice * VMConsumedQuantity | extend InstanceId_s = tolower(ResourceId) | summarize Last30DaysCost = sum(FinalCost), Last30DaysQuantity = sum(VMConsumedQuantity) by InstanceId_s; let MemoryPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | where MetricNames_s == "Available Memory Bytes" and AggregationType_s == "Minimum" | extend MemoryAvailableMBs = todouble(MetricValue_s)/1024/1024 | project TimeGenerated, MemoryAvailableMBs, InstanceId_s=ResourceId | join kind=inner ( $vmssTableName | where TimeGenerated > ago(1d) | distinct InstanceId_s, MemoryMB_s ) on InstanceId_s | extend MemoryPercentage = todouble(toint(MemoryMB_s) - toint(MemoryAvailableMBs)) / todouble(MemoryMB_s) * 100 | summarize PMemoryPercentage = percentile(MemoryPercentage, memoryPercentileValue) by InstanceId_s; let ProcessorPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | where MetricNames_s == "Percentage CPU" and AggregationType_s == 'Maximum' | extend InstanceId_s = ResourceId | summarize PCPUPercentage = percentile(todouble(MetricValue_s), cpuPercentileValue) by InstanceId_s; $vmssTableName | where TimeGenerated > ago(1d) | distinct InstanceId_s, VMSSName_s, ResourceGroupName_s, SubscriptionGuid_g, Cloud_s, TenantGuid_g, VMSSSize_s, NicCount_s, DataDiskCount_s, Capacity_s, Tags_s | join kind=inner ( BilledVMs ) on InstanceId_s | join kind=leftouter ( MemoryPerf ) on InstanceId_s | join kind=leftouter ( ProcessorPerf ) on InstanceId_s | project InstanceId_s, VMSSName = VMSSName_s, ResourceGroup = ResourceGroupName_s, SubscriptionId = SubscriptionGuid_g, Cloud_s, TenantGuid_g, VMSSSize_s, NicCount_s, DataDiskCount_s, Capacity_s, PMemoryPercentage, PCPUPercentage, Tags_s, Last30DaysCost, Last30DaysQuantity | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionId = SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionId | where isnotempty(PMemoryPercentage) and isnotempty(PCPUPercentage) and PMemoryPercentage < $memoryPercentageThreshold and PCPUPercentage < $cpuPercentageThreshold "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $targetSku = $null $currentSku = $skus | Where-Object { $_.Name -eq $result.VMSSSize_s } $currentSkuvCPUs = [int]($currentSku.Capabilities | Where-Object { $_.Name -eq 'vCPUsAvailable' }).Value $memoryNeeded = [double]($currentSku.Capabilities | Where-Object { $_.Name -eq 'MemoryGB' }).Value * ([double] $result.PMemoryPercentage / 100) $cpuNeeded = [double]$currentSkuvCPUs * ([double] $result.PCPUPercentage / 100) $currentPremiumIO = [bool] ($currentSku.Capabilities | Where-Object { $_.Name -eq 'PremiumIO' }).Value $currentCpuArch = ($currentSku.Capabilities | Where-Object { $_.Name -eq 'CpuArchitectureType' }).Value if ($null -eq $skuPricesFound[$currentSku.Name]) { $skuPricesFound[$currentSku.Name] = Find-SkuHourlyPrice -SKUName $currentSku.Name -SKUPriceSheet $pricesheetEntries } $targetSkuCandidates = @() foreach ($sku in $skus) { $targetSkuCandidate = $null $skuCPUs = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'vCPUsAvailable' }).Value $skuMemory = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'MemoryGB' }).Value $skuMaxDataDisks = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'MaxDataDiskCount' }).Value $skuMaxNICs = [int] ($sku.Capabilities | Where-Object { $_.Name -eq 'MaxNetworkInterfaces' }).Value $skuPremiumIO = [bool] ($sku.Capabilities | Where-Object { $_.Name -eq 'PremiumIO' }).Value $skuCpuArch = ($sku.Capabilities | Where-Object { $_.Name -eq 'CpuArchitectureType' }).Value if ($currentSku.Name -ne $sku.Name -and -not($sku.Name -like "*Promo*") -and [double]$skuCPUs -ge $cpuNeeded -and [double]$skuMemory -ge $memoryNeeded ` -and $skuMaxDataDisks -ge [int] $result.DataDiskCount_s -and $skuMaxNICs -ge [int] $result.NicCount_s ` -and ($currentPremiumIO -eq $false -or $skuPremiumIO -eq $currentPremiumIO) -and $skuCpuArch -eq $currentCpuArch) { if ($null -eq $skuPricesFound[$sku.Name]) { $skuPricesFound[$sku.Name] = Find-SkuHourlyPrice -SKUName $sku.Name -SKUPriceSheet $pricesheetEntries } if ($skuPricesFound[$currentSku.Name] -eq 0 -or $skuPricesFound[$sku.Name] -lt $skuPricesFound[$currentSku.Name]) { $targetSkuCandidate = New-Object PSObject -Property @{ Name = $sku.Name HourlyPrice = $skuPricesFound[$sku.Name] vCPUsAvailable = $skuCPUs MemoryGB = $skuMemory } $targetSkuCandidates += $targetSkuCandidate } } } $targetSku = $targetSkuCandidates | Sort-Object -Property HourlyPrice,MemoryGB,vCPUsAvailable | Select-Object -First 1 if ($null -ne $targetSku) { $queryInstanceId = $result.InstanceId_s $queryText = @" let billingInterval = 30d; let armId = `'$queryInstanceId`'; let gInt = $perfTimeGrain; let MemoryPerf = $metricsTableName | where TimeGenerated > ago(billingInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'Available Memory Bytes' and AggregationType_s == 'Minimum' | extend MemoryAvailableMBs = todouble(MetricValue_s)/1024/1024 | project CollectedDate, MemoryAvailableMBs, InstanceId_s=ResourceId | join kind=inner ( $vmssTableName | where TimeGenerated > ago(1d) | distinct InstanceId_s, MemoryMB_s ) on InstanceId_s | extend MemoryPercentage = todouble(toint(MemoryMB_s) - toint(MemoryAvailableMBs)) / todouble(MemoryMB_s) * 100 | summarize percentile(MemoryPercentage, $memoryPercentile) by bin(CollectedDate, gInt); let ProcessorPerf = $metricsTableName | where TimeGenerated > ago(billingInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'Percentage CPU' and AggregationType_s == 'Maximum' | extend ProcessorPercentage = todouble(MetricValue_s) | summarize percentile(ProcessorPercentage, $cpuPercentile) by bin(CollectedDate, gInt); MemoryPerf | join kind=inner (ProcessorPerf) on CollectedDate | render timechart "@ switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $datetime.AddDays(-30).ToString("yyyy-MM-dd") $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["SupportsDataDisksCount"] = "true" $additionalInfoDictionary["SupportsNICCount"] = "true" $additionalInfoDictionary["BelowCPUThreshold"] = "true" $additionalInfoDictionary["BelowMemoryThreshold"] = "true" $additionalInfoDictionary["currentSku"] = "$($result.VMSSSize_s)" $additionalInfoDictionary["InstanceCount"] = [int] $result.Capacity_s $additionalInfoDictionary["targetSku"] = "$($targetSku.Name)" $additionalInfoDictionary["DataDiskCount"] = "$($result.DataDiskCount_s)" $additionalInfoDictionary["NicCount"] = "$($result.NicCount_s)" $additionalInfoDictionary["MetricCPUPercentage"] = "$($result.PCPUPercentage)" $additionalInfoDictionary["MetricMemoryPercentage"] = "$($result.PMemoryPercentage)" $fitScore = 4 # needs disk IOPS and throughput analysis to improve score $fitScore = [Math]::max(0.0, $fitScore) $savingCoefficient = [double] $currentSkuvCPUs / [double] $targetSku.vCPUsAvailable if ($targetSku -and $null -eq $skuPricesFound[$targetSku.Name]) { $skuPricesFound[$targetSku.Name] = Find-SkuHourlyPrice -SKUName $targetSku.Name -SKUPriceSheet $pricesheetEntries } $targetSkuSavingsMonthly = $result.Last30DaysCost - ($result.Last30DaysCost / $savingCoefficient) $tentativeTargetSkuSavingsMonthly = -1 if ($targetSku -and $skuPricesFound[$targetSku.Name] -lt [double]::MaxValue) { $targetSkuPrice = $skuPricesFound[$targetSku.Name] if ($null -eq $skuPricesFound[$currentSku.Name]) { $skuPricesFound[$currentSku.Name] = Find-SkuHourlyPrice -SKUName $currentSku.Name -SKUPriceSheet $pricesheetEntries } if ($skuPricesFound[$currentSku.Name] -lt [double]::MaxValue) { $currentSkuPrice = $skuPricesFound[$currentSku.Name] $tentativeTargetSkuSavingsMonthly = ($currentSkuPrice * [double] $result.Last30DaysQuantity) - ($targetSkuPrice * [double] $result.Last30DaysQuantity) } else { $tentativeTargetSkuSavingsMonthly = $result.Last30DaysCost - ($targetSkuPrice * [double] $result.Last30DaysQuantity) } } if ($tentativeTargetSkuSavingsMonthly -ge 0) { $targetSkuSavingsMonthly = $tentativeTargetSkuSavingsMonthly } $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } if ($targetSkuSavingsMonthly -eq [double]::PositiveInfinity) { $targetSkuSavingsMonthly = [double] $result.Last30DaysCost / 2 } $additionalInfoDictionary["savingsAmount"] = [double] $targetSkuSavingsMonthly $additionalInfoDictionary["CostsAmount"] = [double] $result.Last30DaysCost $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = "Microsoft.Compute/virtualMachineScaleSets" Impact = "High" RecommendationType = "Saving" RecommendationSubType = "UnderusedVMSS" RecommendationSubTypeId = "a4955cc9-533d-46a2-8625-5c4ebd1c30d5" RecommendationDescription = "VM Scale Set has been underutilized" RecommendationAction = "Resize VM Scale Set to lower SKU or scale it in" InstanceId = $result.InstanceId_s InstanceName = $result.VMSSName AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroup SubscriptionGuid = $result.SubscriptionId SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "vmss-underutilized-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for performance constrained Scale Sets, with more than $cpuDegradedMaxPercentageThreshold% Max. CPU, $cpuDegradedAvgPercentageThreshold% Avg. CPU and $memoryDegradedPercentageThreshold% RAM usage..." $baseQuery = @" let perfInterval = $($perfDaysBackwards)d; let MemoryPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | where MetricNames_s == "Available Memory Bytes" and AggregationType_s == "Minimum" | extend MemoryAvailableMBs = todouble(MetricValue_s)/1024/1024 | project TimeGenerated, MemoryAvailableMBs, InstanceId_s=ResourceId | join kind=inner ( $vmssTableName | where TimeGenerated > ago(1d) | distinct InstanceId_s, MemoryMB_s ) on InstanceId_s | extend MemoryPercentage = todouble(toint(MemoryMB_s) - toint(MemoryAvailableMBs)) / todouble(MemoryMB_s) * 100 | summarize PMemoryPercentage = avg(MemoryPercentage) by InstanceId_s; let ProcessorMaxPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | where MetricNames_s == "Percentage CPU" and AggregationType_s == 'Maximum' | extend InstanceId_s = ResourceId | summarize PCPUMaxPercentage = avg(todouble(MetricValue_s)) by InstanceId_s; let ProcessorAvgPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | where MetricNames_s == "Percentage CPU" and AggregationType_s == 'Average' | extend InstanceId_s = ResourceId | summarize PCPUAvgPercentage = avg(todouble(MetricValue_s)) by InstanceId_s; $vmssTableName | where TimeGenerated > ago(1d) | distinct InstanceId_s, VMSSName_s, ResourceGroupName_s, SubscriptionGuid_g, Cloud_s, TenantGuid_g, VMSSSize_s, NicCount_s, DataDiskCount_s, Capacity_s, Tags_s | join kind=leftouter ( MemoryPerf ) on InstanceId_s | join kind=leftouter ( ProcessorMaxPerf ) on InstanceId_s | join kind=leftouter ( ProcessorAvgPerf ) on InstanceId_s | project InstanceId_s, VMSSName = VMSSName_s, ResourceGroup = ResourceGroupName_s, SubscriptionId = SubscriptionGuid_g, Cloud_s, TenantGuid_g, VMSSSize_s, NicCount_s, DataDiskCount_s, Capacity_s, PMemoryPercentage, PCPUMaxPercentage, PCPUAvgPercentage, Tags_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionId = SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionId | where isnotempty(PMemoryPercentage) and isnotempty(PCPUAvgPercentage) and isnotempty(PCPUMaxPercentage) and (PMemoryPercentage > $memoryDegradedPercentageThreshold or (PCPUMaxPercentage > $cpuDegradedMaxPercentageThreshold and PCPUAvgPercentage > $cpuDegradedAvgPercentageThreshold)) "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.InstanceId_s $queryText = @" let perfInterval = $($perfDaysBackwards)d; let armId = `'$queryInstanceId`'; let gInt = $perfTimeGrain; let MemoryPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'Available Memory Bytes' and AggregationType_s == 'Minimum' | extend MemoryAvailableMBs = todouble(MetricValue_s)/1024/1024 | project CollectedDate, MemoryAvailableMBs, InstanceId_s=ResourceId | join kind=inner ( $vmssTableName | where TimeGenerated > ago(1d) | distinct InstanceId_s, MemoryMB_s ) on InstanceId_s | extend MemoryPercentage = todouble(toint(MemoryMB_s) - toint(MemoryAvailableMBs)) / todouble(MemoryMB_s) * 100 | summarize avg(MemoryPercentage) by bin(CollectedDate, gInt); let ProcessorMaxPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'Percentage CPU' and AggregationType_s == 'Maximum' | extend ProcessorMaxPercentage = todouble(MetricValue_s) | summarize percentile(ProcessorMaxPercentage, $cpuPercentile) by bin(CollectedDate, gInt); let ProcessorAvgPerf = $metricsTableName | where TimeGenerated > ago(perfInterval) | extend CollectedDate = todatetime(strcat(format_datetime(TimeGenerated, 'yyyy-MM-dd'),'T',format_datetime(TimeGenerated, 'HH'),':00:00Z')) | where ResourceId == armId | where MetricNames_s == 'Percentage CPU' and AggregationType_s == 'Average' | extend ProcessorAvgPercentage = todouble(MetricValue_s) | summarize percentile(ProcessorAvgPercentage, $cpuPercentile) by bin(CollectedDate, gInt); MemoryPerf | join kind=inner (ProcessorMaxPerf) on CollectedDate | join kind=inner (ProcessorAvgPerf) on CollectedDate | render timechart "@ switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $datetime.AddDays(-30).ToString("yyyy-MM-dd") $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["currentSku"] = "$($result.VMSSSize_s)" $additionalInfoDictionary["InstanceCount"] = [int] $result.Capacity_s $additionalInfoDictionary["MetricCPUAvgPercentage"] = "$($result.PCPUAvgPercentage)" $additionalInfoDictionary["MetricCPUMaxPercentage"] = "$($result.PCPUMaxPercentage)" $additionalInfoDictionary["MetricMemoryPercentage"] = "$($result.PMemoryPercentage)" $fitScore = 3 # needs disk IOPS and throughput analysis to improve score if ([double] $result.PCPUMaxPercentage -gt [double] $cpuDegradedMaxPercentageThreshold -and [double] $result.PCPUAvgPercentage -gt [double] $cpuDegradedAvgPercentageThreshold) { $fitScore = 4 } $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Performance" ImpactedArea = "Microsoft.Compute/virtualMachineScaleSets" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "PerfConstrainedVMSS" RecommendationSubTypeId = "20a40c62-e5c8-4cc3-9fc2-f4ac75013182" RecommendationDescription = "VM Scale Set performance has been constrained by lack of resources" RecommendationAction = "Resize VM Scale Set to higher SKU or scale it out" InstanceId = $result.InstanceId_s InstanceName = $result.VMSSName AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroup SubscriptionGuid = $result.SubscriptionId SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "vmss-perfconstrained-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." if ($recommendationsErrors -gt 0) { throw "Some of the recommendations queries failed. Please, review the job logs for additional information." } ================================================ FILE: runbooks/recommendations/Recommend-VMsHighAvailabilityToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGVirtualMachine','ARGUnmanagedDisk','ARGAvailabilitySet','ARGResourceContainers','ARGVMSS')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $availSetTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGAvailabilitySet' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" $vmsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGVirtualMachine' }).LogAnalyticsSuffix + "_CL" $vhdsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGUnmanagedDisk' }).LogAnalyticsSuffix + "_CL" $vmssTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGVMSS' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $availSetTableName, $vmsTableName, $vmssTableName, $vhdsTableName and $subscriptionsTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 1 # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } $recommendationsErrors = 0 Write-Output "Looking for Availability Sets with a low fault domain count..." # Execute the recommendation query against Log Analytics $baseQuery = @" $availSetTableName | where TimeGenerated > ago(1d) and toint(FaultDomains_s) < 3 and toint(FaultDomains_s) < todouble(VmCount_s)/2 | project TimeGenerated, InstanceId_s, InstanceName_s, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, Cloud_s, Tags_s, FaultDomains_s, VmCount_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["FaultDomainCount"] = $result.FaultDomains_s $additionalInfoDictionary["VMCount"] = $result.VmCount_s $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "HighAvailability" ImpactedArea = "Microsoft.Compute/virtualMachines" Impact = "High" RecommendationType = "BestPractices" RecommendationSubType = "AvailSetLowFaultDomainCount" RecommendationSubTypeId = "255de20b-d5e4-4be5-9695-620b4a905774" RecommendationDescription = "Availability Sets should have a fault domain count of 3 or equal or greater than half of the Virtual Machines count" RecommendationAction = "Increase the fault domain count of your Availability Set" InstanceId = $result.InstanceId_s InstanceName = $result.InstanceName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "availsetsfaultdomaincount-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for Availability Sets with a low update domain count..." $baseQuery = @" $availSetTableName | where TimeGenerated > ago(1d) and toint(UpdateDomains_s) < todouble(VmCount_s)/2 | project TimeGenerated, InstanceId_s, InstanceName_s, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, Cloud_s, Tags_s, UpdateDomains_s, VmCount_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["UpdateDomainCount"] = $result.UpdateDomains_s $additionalInfoDictionary["VMCount"] = $result.VmCount_s $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "HighAvailability" ImpactedArea = "Microsoft.Compute/virtualMachines" Impact = "High" RecommendationType = "BestPractices" RecommendationSubType = "AvailSetLowUpdateDomainCount" RecommendationSubTypeId = "9764e285-2eca-46c5-b49e-649c039cf0cf" RecommendationDescription = "Availability Sets should have an update domain count equal or greater than half of the Virtual Machines count" RecommendationAction = "Increase the update domain count of your Availability Set" InstanceId = $result.InstanceId_s InstanceName = $result.InstanceName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "availsetsupdatedomaincount-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for Availability Sets with VMs sharing storage accounts..." $baseQuery = @" $vhdsTableName | where TimeGenerated > ago(1d) | extend StorageAccountName = tostring(split(InstanceId_s, '/')[0]) | distinct TimeGenerated, StorageAccountName, OwnerVMId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s | join kind=inner ( $vmsTableName | where TimeGenerated > ago(1d) and isnotempty(AvailabilitySetId_s) | distinct VMName_s, InstanceId_s, AvailabilitySetId_s, Cloud_s, Tags_s ) on `$left.OwnerVMId_s == `$right.InstanceId_s | extend AvailabilitySetName = tostring(split(AvailabilitySetId_s,'/')[8]) | summarize TimeGenerated = any(TimeGenerated), Tags_s=any(Tags_s), VMCount = count() by AvailabilitySetName, AvailabilitySetId_s, StorageAccountName, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, Cloud_s | where VMCount > 1 | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics -ErrorAction Continue if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.AvailabilitySetId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["SharedStorageAccountName"] = $result.StorageAccountName $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "HighAvailability" ImpactedArea = "Microsoft.Compute/virtualMachines" Impact = "High" RecommendationType = "BestPractices" RecommendationSubType = "AvailSetSharedStorageAccount" RecommendationSubTypeId = "e530029f-9b6a-413a-99ed-81af54502bb9" RecommendationDescription = "Virtual Machines in unmanaged Availability Sets should not share the same Storage Account" RecommendationAction = "Migrate Virtual Machines disks to Managed Disks or keep the disks in a dedicated Storage Account per VM" InstanceId = $result.AvailabilitySetId_s InstanceName = $result.AvailabilitySetName AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "availsetsharedsa-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for Storage Accounts with multiple VMs..." $baseQuery = @" $vhdsTableName | where TimeGenerated > ago(1d) | extend StorageAccountName = tostring(split(InstanceId_s, '/')[0]) | distinct TimeGenerated, StorageAccountName, OwnerVMId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, Cloud_s | join kind=inner ( $vmsTableName | where TimeGenerated > ago(1d) | distinct InstanceId_s, Tags_s ) on `$left.OwnerVMId_s == `$right.InstanceId_s | summarize TimeGenerated = any(TimeGenerated), Tags_s=any(Tags_s), VMCount = count() by StorageAccountName, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, Cloud_s | where VMCount > 1 | extend StorageAccountId = strcat('/subscriptions/', SubscriptionGuid_g, '/resourcegroups/', ResourceGroupName_s, '/providers/microsoft.storage/storageaccounts/', StorageAccountName) | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics -ErrorAction Continue if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.StorageAccountId $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["VirtualMachineCount"] = $result.VMCount $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "HighAvailability" ImpactedArea = "Microsoft.Compute/virtualMachines" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "StorageAccountsMultipleVMs" RecommendationSubTypeId = "b70f44fa-5ef9-4180-b2f9-9cc6be07ab3e" RecommendationDescription = "Virtual Machines with unmanaged disks should not share the same Storage Account" RecommendationAction = "Migrate Virtual Machines disks to Managed Disks or keep the disks in a dedicated Storage Account per VM" InstanceId = $result.StorageAccountId InstanceName = $result.StorageAccountName AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "storageaccountsmultiplevms-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for VMs with no Availability Set..." $baseQuery = @" $vmsTableName | where TimeGenerated > ago(1d) and isempty(AvailabilitySetId_s) and isempty(Zones_s) and Tags_s !has 'databricks-instance-name' | project TimeGenerated, VMName_s, InstanceId_s, Tags_s, TenantGuid_g, SubscriptionGuid_g, ResourceGroupName_s, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "HighAvailability" ImpactedArea = "Microsoft.Compute/virtualMachines" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "VMsNoAvailSet" RecommendationSubTypeId = "998b50d8-e654-417b-ab20-a31cb11629c0" RecommendationDescription = "Virtual Machines should be placed in an Availability Set together with other instances with the same role" RecommendationAction = "Add VM to an Availability Set together with other VMs of the same role" InstanceId = $result.InstanceId_s InstanceName = $result.VMName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "vmsnoavailset-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for VMs alone in an Availability Set..." $baseQuery = @" $vmsTableName | where TimeGenerated > ago(1d) and isnotempty(AvailabilitySetId_s) and isempty(Zones_s) | distinct TimeGenerated, VMName_s, InstanceId_s, AvailabilitySetId_s, TenantGuid_g, SubscriptionGuid_g, ResourceGroupName_s, Cloud_s, Tags_s | summarize any(TimeGenerated, VMName_s, InstanceId_s, Tags_s), VMCount = count() by AvailabilitySetId_s, TenantGuid_g, SubscriptionGuid_g, ResourceGroupName_s, Cloud_s | where VMCount == 1 | project TimeGenerated = any_TimeGenerated, VMName_s = any_VMName_s, InstanceId_s = any_InstanceId_s, Tags_s = any_Tags_s, TenantGuid_g, SubscriptionGuid_g, ResourceGroupName_s, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "HighAvailability" ImpactedArea = "Microsoft.Compute/virtualMachines" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "VMsSingleInAvailSet" RecommendationSubTypeId = "fe577af5-dfa2-413a-82a9-f183196c1f49" RecommendationDescription = "Virtual Machines should not be the only instance in an Availability Set" RecommendationAction = "Add more VMs of the same role to the Availability Set" InstanceId = $result.InstanceId_s InstanceName = $result.VMName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "vmssingleinavailset-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for VMs with disks in multiple Storage Accounts..." $baseQuery = @" $vhdsTableName | where TimeGenerated > ago(1d) | extend StorageAccountName = tostring(split(InstanceId_s, '/')[0]) | distinct TimeGenerated, StorageAccountName, OwnerVMId_s | summarize TimeGenerated = any(TimeGenerated), StorageAcccountCount = count() by OwnerVMId_s | where StorageAcccountCount > 1 | join kind=inner ( $vmsTableName | where TimeGenerated > ago(1d) | distinct VMName_s, InstanceId_s, Cloud_s, TenantGuid_g, SubscriptionGuid_g, ResourceGroupName_s, Tags_s ) on `$left.OwnerVMId_s == `$right.InstanceId_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics -ErrorAction Continue if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["StorageAccountsUsed"] = $result.StorageAcccountCount $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "HighAvailability" ImpactedArea = "Microsoft.Compute/virtualMachines" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "DisksMultipleStorageAccounts" RecommendationSubTypeId = "024049e7-f63a-4e1c-b620-f011aafbc576" RecommendationDescription = "Each Virtual Machine should have its unmanaged disks stored in a single Storage Account for higher availability and manageability" RecommendationAction = "Migrate Virtual Machines disks to Managed Disks or move VHDs to the same Storage Account" InstanceId = $result.InstanceId_s InstanceName = $result.VMName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "disksmultiplesa-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for VMs using unmanaged disks..." $baseQuery = @" $vmsTableName | where TimeGenerated > ago(1d) and UsesManagedDisks_s == 'false' | distinct InstanceId_s, VMName_s, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, DeploymentModel_s, Tags_s, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["DeploymentModel"] = $result.DeploymentModel_s $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "HighAvailability" ImpactedArea = "Microsoft.Compute/virtualMachines" Impact = "High" RecommendationType = "BestPractices" RecommendationSubType = "UnmanagedDisks" RecommendationSubTypeId = "b576a069-b1f2-43a6-9134-5ee75376402a" RecommendationDescription = "Virtual Machines should use Managed Disks for higher availability and manageability" RecommendationAction = "Migrate Virtual Machines disks to Managed Disks" InstanceId = $result.InstanceId_s InstanceName = $result.VMName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "unmanageddisks-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for Resource Groups with VMs not in multiple AZs..." $baseQuery = @" let VMsInZones = materialize($vmsTableName | where TimeGenerated > ago(1d) and isempty(AvailabilitySetId_s) and isnotempty(Zones_s)); VMsInZones | distinct ResourceGroupName_s, Zones_s, SubscriptionGuid_g, TenantGuid_g, Cloud_s | summarize ZonesCount=count() by ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, Cloud_s | where ZonesCount < 3 | join kind=inner ( VMsInZones | where PowerState_s has 'running' | distinct VMName_s, ResourceGroupName_s, SubscriptionGuid_g | summarize VMCount=count() by ResourceGroupName_s, SubscriptionGuid_g ) on ResourceGroupName_s and SubscriptionGuid_g | where VMCount == 1 or VMCount > ZonesCount | project-away SubscriptionGuid_g1, ResourceGroupName_s1 | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g | extend InstanceId = strcat('/subscriptions/', SubscriptionGuid_g, '/resourcegroups/', ResourceGroupName_s) "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["ZonesCount"] = $result.ZonesCount $additionalInfoDictionary["VMsCount"] = $result.VMCount $fitScore = 4 # a resource group may contain VMs from multiple applications which may lead to false negatives $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "HighAvailability" ImpactedArea = "Microsoft.Compute/virtualMachines" Impact = "High" RecommendationType = "BestPractices" RecommendationSubType = "VMsMultipleAZs" RecommendationSubTypeId = "1a77887c-7375-434e-af19-c2543171e0b8" RecommendationDescription = "Virtual Machines should be placed in multiple Availability Zones" RecommendationAction = "Distribute Virtual Machines instances of the same role in multiple Availability Zones" InstanceId = $result.InstanceId InstanceName = $result.ResourceGroupName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "vmsmultipleazs-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for VMSS not in multiple AZs..." $baseQuery = @" $vmssTableName | where TimeGenerated > ago(1d) | where (isempty(Zones_s) and toint(Capacity_s) > 1) or (array_length(split(Zones_s, ' ')) != 3 and toint(Capacity_s) > 2) | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["Zones"] = $result.Zones_s $additionalInfoDictionary["VMSSCapacity"] = $result.Capacity_s $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "HighAvailability" ImpactedArea = "Microsoft.Compute/virtualMachineScaleSets" Impact = "High" RecommendationType = "BestPractices" RecommendationSubType = "VMSSMultipleAZs" RecommendationSubTypeId = "47e5457c-b345-4372-b536-8887fa8f0298" RecommendationDescription = "Virtual Machine Scale Sets should be placed in multiple Availability Zones" RecommendationAction = "Reprovision the Scale Set leveraging enough Availability Zones" InstanceId = $result.InstanceId_s InstanceName = $result.VMSSName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "vmssmultipleazs-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for VMSS using unmanaged disks..." $baseQuery = @" $vmssTableName | where TimeGenerated > ago(1d) and UsesManagedDisks_s == 'false' | distinct InstanceId_s, VMSSName_s, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, Tags_s, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "HighAvailability" ImpactedArea = "Microsoft.Compute/virtualMachineScaleSets" Impact = "High" RecommendationType = "BestPractices" RecommendationSubType = "UnmanagedDisksVMSS" RecommendationSubTypeId = "1bf03c4a-c402-4e6c-bf20-051b18af30e2" RecommendationDescription = "Virtual Machine Scale Sets should use Managed Disks for higher availability and manageability" RecommendationAction = "Migrate Virtual Machine Scale Sets disks to Managed Disks" InstanceId = $result.InstanceId_s InstanceName = $result.VMSSName_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "unmanageddisksvmss-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." if ($recommendationsErrors -gt 0) { throw "Some of the recommendations queries failed. Please, review the job logs for additional information." } ================================================ FILE: runbooks/recommendations/Recommend-VNetOptimizationsToBlobStorage.ps1 ================================================ $ErrorActionPreference = "Stop" # Collect generic and recommendation-specific variables $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" $workspaceName = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceName" $workspaceRG = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceRG" $workspaceSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceSubId" $workspaceTenantId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceTenantId" $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RecommendationsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "recommendationsexports" } $deploymentDate = Get-AutomationVariable -Name "AzureOptimization_DeploymentDate" # yyyy-MM-dd format $deploymentDate = $deploymentDate.Replace('"', "") $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($lognamePrefix)) { $lognamePrefix = "AzureOptimization" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $subnetMaxUsedThresholdVar = Get-AutomationVariable -Name "AzureOptimization_RecommendationVNetSubnetMaxUsedPercentageThreshold" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($subnetMaxUsedThresholdVar) -or $subnetMaxUsedThresholdVar -eq 0) { $subnetMaxUsedThreshold = 80 } else { $subnetMaxUsedThreshold = [int] $subnetMaxUsedThresholdVar } $subnetMinUsedThresholdVar = Get-AutomationVariable -Name "AzureOptimization_RecommendationVNetSubnetMinUsedPercentageThreshold" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($subnetMinUsedThresholdVar) -or $subnetMinUsedThresholdVar -eq 0) { $subnetMinUsedThreshold = 5 } else { $subnetMinUsedThreshold = [int] $subnetMinUsedThresholdVar } # must be a comma-separated, single-quote enclosed list of subnet names, e.g., 'gatewaysubnet','azurebastionsubnet' $subnetFreeExclusions = Get-AutomationVariable -Name "AzureOptimization_RecommendationVNetSubnetUsedPercentageExclusions" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($subnetFreeExclusions)) { $subnetFreeExclusions = "'gatewaysubnet'" } $subnetMinAgeVar = Get-AutomationVariable -Name "AzureOptimization_RecommendationVNetSubnetEmptyMinAgeInDays" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($subnetMinAgeVar) -or $subnetMinAgeVar -eq 0) { $subnetMinAge = 30 } else { $subnetMinAge = [int] $subnetMinAgeVar } $consumptionOffsetDays = [int] (Get-AutomationVariable -Name "AzureOptimization_ConsumptionOffsetDays") $consumptionOffsetDaysStart = $consumptionOffsetDays + 1 $SqlTimeout = 120 $LogAnalyticsIngestControlTable = "LogAnalyticsIngestControl" # Authenticate against Azure "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } Write-Output "Finding tables where recommendations will be generated from..." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = "SELECT * FROM [dbo].[$LogAnalyticsIngestControlTable] WHERE CollectedType IN ('ARGNetworkInterface','ARGVirtualNetwork','ARGResourceContainers', 'ARGNSGRule', 'ARGPublicIP','AzureConsumption')" $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $controlRows = New-Object System.Data.DataTable $sqlAdapter.Fill($controlRows) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } $nicsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGNetworkInterface' }).LogAnalyticsSuffix + "_CL" $vNetsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGVirtualNetwork' }).LogAnalyticsSuffix + "_CL" $subscriptionsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGResourceContainers' }).LogAnalyticsSuffix + "_CL" $nsgRulesTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGNSGRule' }).LogAnalyticsSuffix + "_CL" $publicIpsTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'ARGPublicIP' }).LogAnalyticsSuffix + "_CL" $consumptionTableName = $lognamePrefix + ($controlRows | Where-Object { $_.CollectedType -eq 'AzureConsumption' }).LogAnalyticsSuffix + "_CL" Write-Output "Will run query against tables $nicsTableName, $nsgRulesTableName, $publicIpsTableName, $subscriptionsTableName, $consumptionTableName and $vNetsTableName" $Conn.Close() $Conn.Dispose() $recommendationSearchTimeSpan = 30 + $consumptionOffsetDaysStart # Grab a context reference to the Storage Account where the recommendations file will be stored Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $sa = Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink if ($workspaceSubscriptionId -ne $storageAccountSinkSubscriptionId) { Select-AzSubscription -SubscriptionId $workspaceSubscriptionId } $recommendationsErrors = 0 Write-Output "Looking for subnets with free IP space less than $subnetMaxUsedThreshold%, excluding $subnetFreeExclusions..." $baseQuery = @" $vNetsTableName | where TimeGenerated > ago(1d) | where SubnetName_s !in ($subnetFreeExclusions) | extend FreeIPs = toint(SubnetTotalPrefixIPs_s) - toint(SubnetUsedIPs_s) | extend UsedIPPercentage = (todouble(SubnetUsedIPs_s) / todouble(SubnetTotalPrefixIPs_s)) * 100 | where UsedIPPercentage >= $subnetMaxUsedThreshold | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/subnets" $additionalInfoDictionary = @{} $additionalInfoDictionary["subnetName"] = $result.SubnetName_s $additionalInfoDictionary["subnetPrefix"] = $result.SubnetPrefix_s $additionalInfoDictionary["subnetTotalIPs"] = $result.SubnetTotalPrefixIPs_s $additionalInfoDictionary["subnetFreeIPs"] = $result.FreeIPs $additionalInfoDictionary["subnetUsedIPPercentage"] = $result.UsedIPPercentage $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "OperationalExcellence" ImpactedArea = "Microsoft.Network/virtualNetworks" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "HighSubnetIPSpaceUsage" RecommendationSubTypeId = "5292525b-5095-4e52-803e-e17192f1d099" RecommendationDescription = "Subnets with a high IP space usage may constrain operations" RecommendationAction = "Move network devices to a subnet with a larger address space" InstanceId = $result.InstanceId_s InstanceName = "$($result.VNetName_s)/$($result.SubnetName_s)" AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "subnetshighspaceusage-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for subnets with used IP space less than $subnetMinUsedThreshold%..." $baseQuery = @" $vNetsTableName | where TimeGenerated > ago(1d) | where SubnetName_s !in ($subnetFreeExclusions) | extend FreeIPs = toint(SubnetTotalPrefixIPs_s) - toint(SubnetUsedIPs_s) | extend UsedIPPercentage = (todouble(SubnetUsedIPs_s) / todouble(SubnetTotalPrefixIPs_s)) * 100 | where UsedIPPercentage > 0 and UsedIPPercentage <= $subnetMinUsedThreshold | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/subnets" $additionalInfoDictionary = @{} $additionalInfoDictionary["subnetName"] = $result.SubnetName_s $additionalInfoDictionary["subnetPrefix"] = $result.SubnetPrefix_s $additionalInfoDictionary["subnetTotalIPs"] = $result.SubnetTotalPrefixIPs_s $additionalInfoDictionary["subnetUsedIPs_s"] = $result.SubnetUsedIPs_s $additionalInfoDictionary["subnetUsedIPPercentage"] = $result.UsedIPPercentage $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "OperationalExcellence" ImpactedArea = "Microsoft.Network/virtualNetworks" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "LowSubnetIPSpaceUsage" RecommendationSubTypeId = "0f27b41c-869a-4563-86e9-d1c94232ba81" RecommendationDescription = "Subnets with a low IP space usage are a waste of virtual network address space" RecommendationAction = "Move network devices to a subnet with a smaller address space" InstanceId = $result.InstanceId_s InstanceName = "$($result.VNetName_s)/$($result.SubnetName_s)" AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "subnetslowspaceusage-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for subnets without any device..." $baseQuery = @" $vNetsTableName | where TimeGenerated > ago(1d) | where toint(SubnetUsedIPs_s) == 0 and toint(SubnetDelegationsCount_s) == 0 | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/subnets" $additionalInfoDictionary = @{} $additionalInfoDictionary["subnetName"] = $result.SubnetName_s $additionalInfoDictionary["subnetPrefix"] = $result.SubnetPrefix_s $additionalInfoDictionary["subnetTotalIPs"] = $result.SubnetTotalPrefixIPs_s $additionalInfoDictionary["subnetUsedIPs_s"] = $result.SubnetUsedIPs_s $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "OperationalExcellence" ImpactedArea = "Microsoft.Network/virtualNetworks" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "NoSubnetIPSpaceUsage" RecommendationSubTypeId = "343bbfb7-5bec-4711-8353-398454d42b7b" RecommendationDescription = "Subnets without any IP usage are a waste of virtual network address space" RecommendationAction = "Delete the subnet to reclaim address space" InstanceId = $result.InstanceId_s InstanceName = "$($result.VNetName_s)/$($result.SubnetName_s)" AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "subnetsnospaceusage-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for orphaned NICs..." $baseQuery = @" $nicsTableName | where TimeGenerated > ago(1d) | where isempty(OwnerVMId_s) and isempty(OwnerPEId_s) | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.InstanceId_s $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["privateIpAddress"] = $result.PrivateIPAddress_s $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "OperationalExcellence" ImpactedArea = "Microsoft.Network/networkInterfaces" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "OrphanedNIC" RecommendationSubTypeId = "4c5c2d0c-b6a4-4c59-bc18-6fff6c1f5b23" RecommendationDescription = "Orphaned Network Interfaces (without owner VM or PE) unnecessarily consume IP address space" RecommendationAction = "Delete the NIC to reclaim address space" InstanceId = $result.InstanceId_s InstanceName = $result.Name_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "orphanednics-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for NSG rules referring empty or removed subnets..." $baseQuery = @" let MinimumSubnetAge = $($subnetMinAge)d; let SubnetsToday = materialize( $vNetsTableName | where TimeGenerated > ago(1d) | extend SubnetId = tolower(strcat(InstanceId_s, '/subnets/', SubnetName_s)) | distinct SubnetId, SubnetPrefix_s, SubnetUsedIPs_s, SubnetDelegationsCount_s ); let SubnetsBefore = materialize( $vNetsTableName | where TimeGenerated < ago(1d) | extend SubnetId = tolower(strcat(InstanceId_s, '/subnets/', SubnetName_s)) | summarize ExistsSince = min(todatetime(StatusDate_s)) by SubnetId, SubnetPrefix_s ); let SubnetsExistingLongEnoughIds = SubnetsBefore | where ExistsSince < ago(MinimumSubnetAge) | distinct SubnetId; let EmptySubnets = SubnetsToday | where SubnetId in (SubnetsExistingLongEnoughIds) and toint(SubnetUsedIPs_s) == 0 and toint(SubnetDelegationsCount_s) == 0; let SubnetsTodayIds = SubnetsToday | distinct SubnetId; let SubnetsTodayPrefixes = SubnetsToday | distinct SubnetPrefix_s; let RemovedSubnets = SubnetsBefore | where SubnetId !in (SubnetsTodayIds) and SubnetPrefix_s !in (SubnetsTodayPrefixes); let NSGRules = materialize($nsgRulesTableName | where TimeGenerated > ago(1d) | extend SourceAddresses = split(RuleSourceAddresses_s,',') | mvexpand SourceAddresses | extend SourceAddress = tostring(SourceAddresses) | extend DestinationAddresses = split(RuleDestinationAddresses_s,',') | mvexpand DestinationAddresses | extend DestinationAddress = tostring(DestinationAddresses) | project NSGId = InstanceId_s, RuleName_s, DestinationAddress, SourceAddress, SubscriptionGuid_g, Cloud_s, TenantGuid_g, ResourceGroupName_s, NSGName = NSGName_s, Tags_s); let EmptySubnetsAsSource = EmptySubnets | join kind=inner ( NSGRules ) on `$left.SubnetPrefix_s == `$right.SourceAddress | extend SubnetState = 'empty'; let EmptySubnetsAsDestination = EmptySubnets | join kind=inner ( NSGRules ) on `$left.SubnetPrefix_s == `$right.DestinationAddress | extend SubnetState = 'empty'; let RemovedSubnetsAsSource = RemovedSubnets | join kind=inner ( NSGRules ) on `$left.SubnetPrefix_s == `$right.SourceAddress | extend SubnetState = 'inexisting'; let RemovedSubnetsAsDestination = RemovedSubnets | join kind=inner ( NSGRules ) on `$left.SubnetPrefix_s == `$right.DestinationAddress | extend SubnetState = 'inexisting'; EmptySubnetsAsSource | union EmptySubnetsAsDestination | union RemovedSubnetsAsSource | union RemovedSubnetsAsDestination | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g | where isnotempty(SubnetPrefix_s) | distinct NSGId, NSGName, RuleName_s, SubscriptionGuid_g, SubscriptionName, ResourceGroupName_s, TenantGuid_g, Cloud_s, SubnetId, SubnetPrefix_s, SubnetState, Tags_s "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.NSGId $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["subnetId"] = $result.SubnetId $additionalInfoDictionary["subnetPrefix"] = $result.SubnetPrefix_s $additionalInfoDictionary["subnetState"] = $result.SubnetState $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Security" ImpactedArea = "Microsoft.Network/networkSecurityGroups" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "NSGRuleForEmptyOrInexistingSubnet" RecommendationSubTypeId = "b5491cde-f76c-4423-8c4c-89e3558ff2f2" RecommendationDescription = "NSG rules referring to empty or inexisting subnets" RecommendationAction = "Update or remove the NSG rule to improve your network security posture" InstanceId = $result.NSGId InstanceName = "$($result.NSGName)/$($result.RuleName_s)" AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "nsgrules-emptyinexistingsubnets-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for NSG rules referring orphan or removed NICs..." $baseQuery = @" let NICsToday = materialize( $nicsTableName | where TimeGenerated > ago(1d) | extend NICId = tolower(InstanceId_s) | distinct NICId, PrivateIPAddress_s, PublicIPId_s, OwnerVMId_s, OwnerPEId_s ); let NICsBefore = $nicsTableName | where TimeGenerated < ago(1d) | extend NICId = tolower(InstanceId_s) | distinct NICId, PrivateIPAddress_s, PublicIPId_s; let OrphanNICs = NICsToday | where isempty(OwnerVMId_s) and isempty(OwnerPEId_s) | extend PublicIPId_s = tolower(PublicIPId_s) | join kind=leftouter ( $publicIpsTableName | where TimeGenerated > ago(1d) | project PublicIPId_s = tolower(InstanceId_s), PublicIPAddress = IPAddress ) on PublicIPId_s; let NICsTodayIds = NICsToday | distinct NICId; let NICsTodayIPs = NICsToday | distinct PrivateIPAddress_s; let RemovedNICs = NICsBefore | where NICId !in (NICsTodayIds) and PrivateIPAddress_s !in (NICsTodayIPs) | extend PublicIPId_s = tolower(PublicIPId_s) | join kind=leftouter ( $publicIpsTableName | where TimeGenerated < ago(1d) | project PublicIPId_s = tolower(InstanceId_s), PublicIPAddress = IPAddress ) on PublicIPId_s; let NSGRules = materialize($nsgRulesTableName | where TimeGenerated > ago(1d) | extend SourceAddresses = split(RuleSourceAddresses_s,',') | mvexpand SourceAddresses | extend SourceAddress = replace('/32','',tostring(SourceAddresses)) | extend DestinationAddresses = split(RuleDestinationAddresses_s,',') | mvexpand DestinationAddresses | extend DestinationAddress = replace('/32','',tostring(DestinationAddresses)) | project NSGId = InstanceId_s, RuleName_s, DestinationAddress, SourceAddress, SubscriptionGuid_g, Cloud_s, TenantGuid_g, ResourceGroupName_s, NSGName = NSGName_s, Tags_s); let OrphanNICsAsPrivateSource = OrphanNICs | join kind=inner ( NSGRules ) on `$left.PrivateIPAddress_s == `$right.SourceAddress | extend NICState = 'orphan', IPAddress = PrivateIPAddress_s; let OrphanNICsAsPublicSource = OrphanNICs | join kind=inner ( NSGRules ) on `$left.PublicIPAddress == `$right.SourceAddress | extend NICState = 'orphan', IPAddress = PublicIPAddress; let OrphanNICsAsPrivateDestination = OrphanNICs | join kind=inner ( NSGRules ) on `$left.PrivateIPAddress_s == `$right.DestinationAddress | extend NICState = 'orphan', IPAddress = PrivateIPAddress_s; let OrphanNICsAsPublicDestination = OrphanNICs | join kind=inner ( NSGRules ) on `$left.PublicIPAddress == `$right.DestinationAddress | extend NICState = 'orphan', IPAddress = PublicIPAddress; let RemovedNICsAsPrivateSource = RemovedNICs | join kind=inner ( NSGRules ) on `$left.PrivateIPAddress_s == `$right.SourceAddress | extend NICState = 'inexisting', IPAddress = PrivateIPAddress_s; let RemovedNICsAsPublicSource = RemovedNICs | join kind=inner ( NSGRules ) on `$left.PublicIPAddress == `$right.SourceAddress | extend NICState = 'inexisting', IPAddress = PublicIPAddress; let RemovedNICsAsPrivateDestination = RemovedNICs | join kind=inner ( NSGRules ) on `$left.PrivateIPAddress_s == `$right.DestinationAddress | extend NICState = 'inexisting', IPAddress = PrivateIPAddress_s; let RemovedNICsAsPublicDestination = RemovedNICs | join kind=inner ( NSGRules ) on `$left.PublicIPAddress == `$right.DestinationAddress | extend NICState = 'inexisting', IPAddress = PublicIPAddress; OrphanNICsAsPrivateSource | union OrphanNICsAsPublicSource | union OrphanNICsAsPrivateDestination | union OrphanNICsAsPublicDestination | union RemovedNICsAsPrivateSource | union RemovedNICsAsPublicSource | union RemovedNICsAsPrivateDestination | union RemovedNICsAsPublicDestination | where isnotempty(IPAddress) | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g | distinct NSGId, NSGName, RuleName_s, SubscriptionGuid_g, SubscriptionName, ResourceGroupName_s, TenantGuid_g, Cloud_s, NICId, IPAddress, NICState, Tags_s "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.NSGId $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["nicId"] = $result.NICId $additionalInfoDictionary["ipAddress"] = $result.IPAddress $additionalInfoDictionary["nicState"] = $result.NICState $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Security" ImpactedArea = "Microsoft.Network/networkSecurityGroups" Impact = "Medium" RecommendationType = "BestPractices" RecommendationSubType = "NSGRuleForOrphanOrInexistingNIC" RecommendationSubTypeId = "3dc1d1f8-19ef-4572-9c9d-78d62831f55a" RecommendationDescription = "NSG rules referring to orphan or inexisting NICs" RecommendationAction = "Update or remove the NSG rule to improve your network security posture" InstanceId = $result.NSGId InstanceName = "$($result.NSGName)/$($result.RuleName_s)" AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "nsgrules-orphaninexistingnics-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for NSG rules referring orphan or removed Public IPs..." $baseQuery = @" let PIPsToday = materialize( $publicIpsTableName | where TimeGenerated > ago(1d) | extend PublicIPId = tolower(InstanceId_s) | distinct PublicIPId, AssociatedResourceId_s, AllocationMethod_s, IPAddress ); let PIPsBefore = materialize( $publicIpsTableName | where TimeGenerated < ago(1d) | extend PublicIPId = tolower(InstanceId_s) | distinct PublicIPId, IPAddress ); let OrphanStaticPIPs = PIPsToday | where isempty(AssociatedResourceId_s) and AllocationMethod_s == 'static'; let OrphanDynamicPIPIDs = PIPsToday | where isempty(AssociatedResourceId_s) and AllocationMethod_s == 'dynamic' | distinct PublicIPId; let PIPsTodayIds = PIPsToday | distinct PublicIPId; let PIPsTodayIPs = PIPsToday | distinct IPAddress; let OrphanDynamicPIPs = PIPsBefore | where PublicIPId in (OrphanDynamicPIPIDs) and isnotempty(IPAddress) and IPAddress !in (PIPsTodayIPs); let RemovedPIPs = PIPsBefore | where PublicIPId !in (PIPsTodayIds) and isnotempty(IPAddress) and IPAddress !in (PIPsTodayIPs); let NSGRules = materialize( $nsgRulesTableName | where TimeGenerated > ago(1d) | extend SourceAddresses = split(RuleSourceAddresses_s,',') | mvexpand SourceAddresses | extend SourceAddress = replace('/32','',tostring(SourceAddresses)) | extend DestinationAddresses = split(RuleDestinationAddresses_s,',') | mvexpand DestinationAddresses | extend DestinationAddress = replace('/32','',tostring(DestinationAddresses)) | project NSGId = InstanceId_s, RuleName_s, DestinationAddress, SourceAddress, SubscriptionGuid_g, Cloud_s, TenantGuid_g, ResourceGroupName_s, NSGName = NSGName_s, Tags_s); let OrphanStaticPIPsAsSource = OrphanStaticPIPs | join kind=inner ( NSGRules ) on `$left.IPAddress == `$right.SourceAddress | extend PIPState = 'orphan'; let OrphanStaticPIPsAsDestination = OrphanStaticPIPs | join kind=inner ( NSGRules ) on `$left.IPAddress == `$right.DestinationAddress | extend PIPState = 'orphan'; let OrphanDynamicPIPsAsSource = OrphanDynamicPIPs | join kind=inner ( NSGRules ) on `$left.IPAddress == `$right.SourceAddress | extend PIPState = 'orphan'; let OrphanDynamicPIPsAsDestination = OrphanDynamicPIPs | join kind=inner ( NSGRules ) on `$left.IPAddress == `$right.DestinationAddress | extend PIPState = 'orphan'; let RemovedPIPsAsSource = RemovedPIPs | join kind=inner ( NSGRules ) on `$left.IPAddress == `$right.SourceAddress | extend PIPState = 'inexisting'; let RemovedPIPsAsDestination = RemovedPIPs | join kind=inner ( NSGRules ) on `$left.IPAddress == `$right.DestinationAddress | extend PIPState = 'inexisting'; OrphanStaticPIPsAsSource | union OrphanDynamicPIPsAsSource | union OrphanStaticPIPsAsDestination | union OrphanDynamicPIPsAsDestination | union RemovedPIPsAsSource | union RemovedPIPsAsDestination | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g | distinct NSGId, NSGName, RuleName_s, SubscriptionGuid_g, SubscriptionName, ResourceGroupName_s, TenantGuid_g, Cloud_s, PublicIPId, IPAddress, PIPState, AllocationMethod_s, Tags_s "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { switch ($result.Cloud_s) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $queryInstanceId = $result.NSGId $detailsURL = "https://portal.azure.$azureTld/#@$($result.TenantGuid_g)/resource/$queryInstanceId/overview" $additionalInfoDictionary = @{} $additionalInfoDictionary["publicIPId"] = $result.PublicIPId $additionalInfoDictionary["ipAddress"] = $result.IPAddress $additionalInfoDictionary["publicIPState"] = $result.PIPState $additionalInfoDictionary["allocationMethod"] = $result.AllocationMethod_s $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Security" ImpactedArea = "Microsoft.Network/networkSecurityGroups" Impact = "High" RecommendationType = "BestPractices" RecommendationSubType = "NSGRuleForOrphanOrInexistingPublicIP" RecommendationSubTypeId = "fe40cbe7-bdee-4cce-b072-cf25e1247b7a" RecommendationDescription = "NSG rules referring to orphan or inexisting Public IPs" RecommendationAction = "Update or remove the NSG rule to improve your network security posture" InstanceId = $result.NSGId InstanceName = "$($result.NSGName)/$($result.RuleName_s)" AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "nsgrules-orphaninexistingpublicips-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." Write-Output "Looking for orphaned Public IPs..." $baseQuery = @" let interval = 30d; let etime = todatetime(toscalar($consumptionTableName | where todatetime(Date_s) < now() and todatetime(Date_s) > ago(30d) | summarize max(todatetime(Date_s)))); let stime = etime-interval; $publicIpsTableName | where TimeGenerated > ago(1d) and isempty(AssociatedResourceId_s) | distinct Name_s, InstanceId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, SkuName_s, AllocationMethod_s, Tags_s, Cloud_s | join kind=leftouter ( $consumptionTableName | where todatetime(Date_s) between (stime..etime) | project InstanceId_s=tolower(ResourceId), CostInBillingCurrency_s, Date_s ) on InstanceId_s | summarize Last30DaysCost=sum(todouble(CostInBillingCurrency_s)) by Name_s, InstanceId_s, SubscriptionGuid_g, TenantGuid_g, ResourceGroupName_s, SkuName_s, AllocationMethod_s, Tags_s, Cloud_s | join kind=leftouter ( $subscriptionsTableName | where TimeGenerated > ago(1d) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionGuid_g, SubscriptionName = ContainerName_s ) on SubscriptionGuid_g "@ try { $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $baseQuery -Timespan (New-TimeSpan -Days $recommendationSearchTimeSpan) -Wait 600 -IncludeStatistics if ($queryResults) { $results = [System.Linq.Enumerable]::ToArray($queryResults.Results) } } catch { Write-Warning -Message "Query failed. Debug the following query in the AOE Log Analytics workspace: $baseQuery" Write-Warning -Message $error[0] $recommendationsErrors++ } Write-Output "Query finished with $($results.Count) results." Write-Output "Query statistics: $($queryResults.Statistics.query)" # Build the recommendations objects $recommendations = @() $datetime = (get-date).ToUniversalTime() $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") foreach ($result in $results) { $queryInstanceId = $result.InstanceId_s $queryText = @" $publicIpsTableName | where InstanceId_s == '$queryInstanceId' and isempty(AssociatedResourceId_s) | distinct InstanceId_s, Name_s, AllocationMethod_s, SkuName_s, TimeGenerated | summarize LastAttachedDate = min(TimeGenerated) by InstanceId_s, Name_s, AllocationMethod_s, SkuName_s | join kind=leftouter ( $consumptionTableName | project InstanceId_s=tolower(ResourceId), CostInBillingCurrency_s, Date_s ) on InstanceId_s | summarize CostsSinceDetached = sumif(todouble(CostInBillingCurrency_s), todatetime(Date_s) > LastAttachedDate) by Name_s, LastAttachedDate, AllocationMethod_s, SkuName_s "@ $encodedQuery = [System.Uri]::EscapeDataString($queryText) $detailsQueryStart = $deploymentDate $detailsQueryEnd = $datetime.AddDays(8).ToString("yyyy-MM-dd") switch ($cloudEnvironment) { "AzureCloud" { $azureTld = "com" } "AzureChinaCloud" { $azureTld = "cn" } "AzureUSGovernment" { $azureTld = "us" } default { $azureTld = "com" } } $detailsURL = "https://portal.azure.$azureTld#@$workspaceTenantId/blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F$workspaceSubscriptionId%2Fresourcegroups%2F$workspaceRG%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F$workspaceName/source/LogsBlade.AnalyticsShareLinkToQuery/query/$encodedQuery/timespan/$($detailsQueryStart)T00%3A00%3A00.000Z%2F$($detailsQueryEnd)T00%3A00%3A00.000Z" $additionalInfoDictionary = @{} $additionalInfoDictionary["currentSku"] = $result.SkuName_s $additionalInfoDictionary["allocationMethod"] = $result.AllocationMethod_s $additionalInfoDictionary["CostsAmount"] = [double] $result.Last30DaysCost $additionalInfoDictionary["savingsAmount"] = [double] $result.Last30DaysCost $fitScore = 5 $tags = @{} if (-not([string]::IsNullOrEmpty($result.Tags_s))) { $tagPairs = $result.Tags_s.Substring(2, $result.Tags_s.Length - 3).Split(';') foreach ($tagPairString in $tagPairs) { $tagPair = $tagPairString.Split('=') if (-not([string]::IsNullOrEmpty($tagPair[0])) -and -not([string]::IsNullOrEmpty($tagPair[1]))) { $tagName = $tagPair[0].Trim() $tagValue = $tagPair[1].Trim() $tags[$tagName] = $tagValue } } } $recommendation = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $result.Cloud_s Category = "Cost" ImpactedArea = "Microsoft.Network/publicIPAddresses" Impact = "Low" RecommendationType = "Saving" RecommendationSubType = "OrphanedPublicIP" RecommendationSubTypeId = "3125883f-8b9f-4bde-a0ff-6c739858c6e1" RecommendationDescription = "Orphaned Public IP (without owner resource) incur in unnecessary costs" RecommendationAction = "Delete the Public IP or change its configuration to dynamic allocation" InstanceId = $result.InstanceId_s InstanceName = $result.Name_s AdditionalInfo = $additionalInfoDictionary ResourceGroup = $result.ResourceGroupName_s SubscriptionGuid = $result.SubscriptionGuid_g SubscriptionName = $result.SubscriptionName TenantGuid = $result.TenantGuid_g FitScore = $fitScore Tags = $tags DetailsURL = $detailsURL } $recommendations += $recommendation } # Export the recommendations as JSON to blob storage $fileDate = $datetime.ToString("yyyy-MM-dd") $jsonExportPath = "orphanedpublicips-$fileDate.json" $recommendations | ConvertTo-Json | Out-File $jsonExportPath $jsonBlobName = $jsonExportPath $jsonProperties = @{"ContentType" = "application/json"}; Set-AzStorageBlobContent -File $jsonExportPath -Container $storageAccountSinkContainer -Properties $jsonProperties -Blob $jsonBlobName -Context $sa.Context -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Uploaded $jsonBlobName to Blob Storage..." Remove-Item -Path $jsonExportPath -Force $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") Write-Output "[$now] Removed $jsonExportPath from local disk..." if ($recommendationsErrors -gt 0) { throw "Some of the recommendations queries failed. Please, review the job logs for additional information." } ================================================ FILE: runbooks/remediations/Remediate-AdvisorRightSizeFiltered.ps1 ================================================ param( [Parameter(Mandatory = $false)] [bool] $Simulate = $true ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RemediationLogsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "remediationlogs" } $minFitScore = [double] (Get-AutomationVariable -Name "AzureOptimization_RemediateRightSizeMinFitScore" -ErrorAction SilentlyContinue) if (-not($minFitScore -gt 0.0)) { $minFitScore = 5.0 } $minWeeksInARow = [int] (Get-AutomationVariable -Name "AzureOptimization_RemediateRightSizeMinWeeksInARow" -ErrorAction SilentlyContinue) if (-not($minWeeksInARow -gt 0)) { $minWeeksInARow = 4 } $tagsFilter = Get-AutomationVariable -Name "AzureOptimization_RemediateRightSizeTagsFilter" -ErrorAction SilentlyContinue # example: '[ { "tagName": "a", "tagValue": "b" }, { "tagName": "c", "tagValue": "d" } ]' if (-not($tagsFilter)) { $tagsFilter = '{}' } $tagsFilter = $tagsFilter | ConvertFrom-Json $rightSizeRecommendationId = Get-AutomationVariable -Name "AzureOptimization_RecommendationAdvisorCostRightSizeId" -ErrorAction SilentlyContinue if (-not($rightSizeRecommendationId)) { $rightSizeRecommendationId = 'e10b1381-5f0a-47ff-8c7b-37bd13d7c974' } $SqlTimeout = 0 $recommendationsTable = "Recommendations" "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } # get reference to storage sink Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context Write-Output "Querying for right-size recommendations with fit score >= $minFitScore made consecutively for the last $minWeeksInARow weeks." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = @" SELECT InstanceId, Cloud, TenantGuid, JSON_VALUE(AdditionalInfo, '`$.currentSku') AS CurrentSKU, JSON_VALUE(AdditionalInfo, '`$.targetSku') AS TargetSKU, COUNT(InstanceId) FROM [dbo].[$recommendationsTable] WHERE RecommendationSubTypeId = '$rightSizeRecommendationId' AND FitScore >= $minFitScore AND GeneratedDate >= GETDATE()-(7*$minWeeksInARow) GROUP BY InstanceId, Cloud, TenantGuid, JSON_VALUE(AdditionalInfo, '`$.currentSku'), JSON_VALUE(AdditionalInfo, '`$.targetSku') HAVING COUNT(InstanceId) >= $minWeeksInARow "@ $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $vmsToRightSize = New-Object System.Data.DataTable $sqlAdapter.Fill($vmsToRightSize) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } Write-Output "Found $($vmsToRightSize.Rows.Count) remediation opportunities." $Conn.Close() $Conn.Dispose() $logEntries = @() $datetime = (get-date).ToUniversalTime() $hour = $datetime.Hour $min = $datetime.Minute $timestamp = $datetime.ToString("yyyy-MM-ddT$($hour):$($min):00.000Z") $ctx = Get-AzContext foreach ($vm in $vmsToRightSize.Rows) { $isEligible = $false $logDetails = $null if ([string]::IsNullOrEmpty($tagsFilter)) { $isEligible = $true } else { $vmTags = Get-AzTag -ResourceId $vm.InstanceId -ErrorAction SilentlyContinue if ($vmTags) { foreach ($tagFilter in $tagsFilter) { if ($vmTags.Properties.TagsProperty.($tagFilter.tagName) -eq $tagFilter.tagValue) { $isEligible = $true } else { $isEligible = $false break } } } } $subscriptionId = $vm.InstanceId.Split("/")[2] $resourceGroup = $vm.InstanceId.Split("/")[4] $instanceName = $vm.InstanceId.Split("/")[8] if ($isEligible) { Write-Output "Downsizing (SIMULATE=$Simulate) $($vm.InstanceId) to $($vm.TargetSKU)..." if (-not($Simulate) -and $ctx.Environment.Name -eq $vm.Cloud -and $ctx.Tenant.Id -eq $vm.TenantGuid) { if ($ctx.Subscription.Id -ne $subscriptionId) { Select-AzSubscription -SubscriptionId $subscriptionId | Out-Null $ctx = Get-AzContext } $vmObj = Get-AzVM -ResourceGroupName $resourceGroup -VMName $instanceName -ErrorAction SilentlyContinue if ($vmObj) { $vmObj.HardwareProfile.VmSize = $vm.TargetSKU Update-AzVM -VM $vmObj -ResourceGroupName $resourceGroup } else { Write-Output "Skipping as VM was already removed." } } else { Write-Output "Did not apply remediation." } } $logDetails = @{ IsEligible = $isEligible CurrentSku = $vm.CurrentSKU TargetSku = $vm.TargetSKU } $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $vm.Cloud TenantGuid = $vm.TenantGuid SubscriptionGuid = $subscriptionId ResourceGroupName = $resourceGroup.ToLower() InstanceName = $instanceName.ToLower() InstanceId = $vm.InstanceId.ToLower() Simulate = $Simulate LogDetails = $logDetails | ConvertTo-Json -Compress RecommendationSubTypeId = $rightSizeRecommendationId } $logEntries += $logentry } $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-rightsizefiltered.csv" $logEntries | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force ================================================ FILE: runbooks/remediations/Remediate-LongDeallocatedVMsFiltered.ps1 ================================================ param( [Parameter(Mandatory = $false)] [bool] $Simulate = $true ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RemediationLogsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "remediationlogs" } $minFitScore = [double] (Get-AutomationVariable -Name "AzureOptimization_RemediateLongDeallocatedVMsMinFitScore" -ErrorAction SilentlyContinue) if (-not($minFitScore -gt 0.0)) { $minFitScore = 5.0 } $minWeeksInARow = [int] (Get-AutomationVariable -Name "AzureOptimization_RemediateLongDeallocatedVMsMinWeeksInARow" -ErrorAction SilentlyContinue) if (-not($minWeeksInARow -gt 0)) { $minWeeksInARow = 4 } $tagsFilter = Get-AutomationVariable -Name "AzureOptimization_RemediateLongDeallocatedVMsTagsFilter" -ErrorAction SilentlyContinue # example: '[ { "tagName": "a", "tagValue": "b" }, { "tagName": "c", "tagValue": "d" } ]' if (-not($tagsFilter)) { $tagsFilter = '{}' } $tagsFilter = $tagsFilter | ConvertFrom-Json $recommendationId = Get-AutomationVariable -Name "AzureOptimization_RecommendationLongDeallocatedVMsId" -ErrorAction SilentlyContinue if (-not($recommendationId)) { $recommendationId = 'c320b790-2e58-452a-aa63-7b62c383ad8a' } $SqlTimeout = 0 $recommendationsTable = "Recommendations" "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } # get reference to storage sink Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context Write-Output "Querying for long-deallocated recommendations with fit score >= $minFitScore made consecutively for the last $minWeeksInARow weeks." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = @" SELECT InstanceId, Cloud, TenantGuid, COUNT(InstanceId) FROM [dbo].[$recommendationsTable] WHERE RecommendationSubTypeId = '$recommendationId' AND FitScore >= $minFitScore AND GeneratedDate >= GETDATE()-(7*$minWeeksInARow) GROUP BY InstanceId, Cloud, TenantGuid HAVING COUNT(InstanceId) >= $minWeeksInARow "@ $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $deallocatedVMs = New-Object System.Data.DataTable $sqlAdapter.Fill($deallocatedVMs) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } Write-Output "Found $($deallocatedVMs.Rows.Count) remediation opportunities." $Conn.Close() $Conn.Dispose() $logEntries = @() $datetime = (get-date).ToUniversalTime() $hour = $datetime.Hour $min = $datetime.Minute $timestamp = $datetime.ToString("yyyy-MM-ddT$($hour):$($min):00.000Z") $ctx = Get-AzContext foreach ($vm in $deallocatedVMs.Rows) { $isEligible = $false $logDetails = $null if ([string]::IsNullOrEmpty($tagsFilter)) { $isEligible = $true } else { $vmTags = Get-AzTag -ResourceId $vm.InstanceId -ErrorAction SilentlyContinue if ($vmTags) { foreach ($tagFilter in $tagsFilter) { if ($vmTags.Properties.TagsProperty.($tagFilter.tagName) -eq $tagFilter.tagValue) { $isEligible = $true } else { $isEligible = $false break } } } } $subscriptionId = $vm.InstanceId.Split("/")[2] $resourceGroup = $vm.InstanceId.Split("/")[4] $instanceName = $vm.InstanceId.Split("/")[8] if ($isEligible) { $vmState = "Unknown" $hasManagedDisks = $false $osDiskSkuName = "Unknown" $dataDisksSkuNames = "Unknown" Write-Output "Downsizing (SIMULATE=$Simulate) $($vm.InstanceId) disks to Standard_LRS..." if ($ctx.Environment.Name -eq $vm.Cloud -and $ctx.Tenant.Id -eq $vm.TenantGuid) { if ($ctx.Subscription.Id -ne $subscriptionId) { Select-AzSubscription -SubscriptionId $subscriptionId | Out-Null $ctx = Get-AzContext } $vmObj = Get-AzVM -ResourceGroupName $resourceGroup -VMName $instanceName -Status -ErrorAction SilentlyContinue if ($vmObj.PowerState -eq 'VM deallocated') { $vmState = "Deallocated" $osDiskId = $vmObj.StorageProfile.OsDisk.ManagedDisk.Id $dataDiskIds = $vmObj.StorageProfile.DataDisks.ManagedDisk.Id if ($osDiskId) { $hasManagedDisks = $true $disk = Get-AzDisk -ResourceGroupName $osDiskId.Split("/")[4] -DiskName $osDiskId.Split("/")[8] $osDiskSkuName = $disk.Sku.Name if (-not($Simulate) -and $disk.Sku.Name -ne 'Standard_LRS') { $disk.Sku = [Microsoft.Azure.Management.Compute.Models.DiskSku]::new('Standard_LRS') $disk | Update-AzDisk | Out-Null } else { Write-Output "Skipping as OS disk is already HDD." } foreach ($dataDiskId in $dataDiskIds) { $disk = Get-AzDisk -ResourceGroupName $dataDiskId.Split("/")[4] -DiskName $dataDiskId.Split("/")[8] if ($dataDisksSkuNames -eq 'Unknown') { $dataDisksSkuNames = $disk.Sku.Name } else { if ($dataDisksSkuNames -notlike "*$($disk.Sku.Name)*") { $dataDisksSkuNames += ",$($disk.Sku.Name)" } } if (-not($Simulate) -and $disk.Sku.Name -ne 'Standard_LRS') { $disk.Sku = [Microsoft.Azure.Management.Compute.Models.DiskSku]::new('Standard_LRS') $disk | Update-AzDisk | Out-Null } else { Write-Output "Skipping as Data disk is already HDD." } } } else { Write-Output "Skipping as disks are not Managed Disks." $hasManagedDisks = $false } } else { if ($vmObj) { Write-Output "Skipping as VM is not deallocated." $vmState = "Running" } else { Write-Output "Skipping as VM was already removed." $vmState = "Removed" } } } else { Write-Output "Could not apply remediation as VM is in another cloud/tenant." } } $logDetails = @{ IsEligible = $isEligible VMState = $vmState HasManagedDisks = $hasManagedDisks OsDiskSkuName = $osDiskSkuName DataDisksSkuName = $dataDisksSkuNames } $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $vm.Cloud TenantGuid = $vm.TenantGuid SubscriptionGuid = $subscriptionId ResourceGroupName = $resourceGroup.ToLower() InstanceName = $instanceName.ToLower() InstanceId = $vm.InstanceId.ToLower() Simulate = $Simulate LogDetails = $logDetails | ConvertTo-Json -Compress RecommendationSubTypeId = $recommendationId } $logEntries += $logentry } $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-longdeallocatedvmsfiltered.csv" $logEntries | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force ================================================ FILE: runbooks/remediations/Remediate-UnattachedDisksFiltered.ps1 ================================================ param( [Parameter(Mandatory = $false)] [bool] $Simulate = $true ) $ErrorActionPreference = "Stop" $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud if ([string]::IsNullOrEmpty($cloudEnvironment)) { $cloudEnvironment = "AzureCloud" } $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity if ([string]::IsNullOrEmpty($authenticationOption)) { $authenticationOption = "ManagedIdentity" } if ($authenticationOption -eq "UserAssignedManagedIdentity") { $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" } $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" $SqlUsername = $sqlserverCredential.UserName $SqlPass = $sqlserverCredential.GetNetworkCredential().Password $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($sqldatabase)) { $sqldatabase = "azureoptimization" } $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RemediationLogsContainer" -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { $storageAccountSinkContainer = "remediationlogs" } $minFitScore = [double] (Get-AutomationVariable -Name "AzureOptimization_RemediateUnattachedDisksMinFitScore" -ErrorAction SilentlyContinue) if (-not($minFitScore -gt 0.0)) { $minFitScore = 5.0 } $minWeeksInARow = [int] (Get-AutomationVariable -Name "AzureOptimization_RemediateUnattachedDisksMinWeeksInARow" -ErrorAction SilentlyContinue) if (-not($minWeeksInARow -gt 0)) { $minWeeksInARow = 4 } $tagsFilter = Get-AutomationVariable -Name "AzureOptimization_RemediateUnattachedDisksTagsFilter" -ErrorAction SilentlyContinue # example: '[ { "tagName": "a", "tagValue": "b" }, { "tagName": "c", "tagValue": "d" } ]' if (-not($tagsFilter)) { $tagsFilter = '{}' } $tagsFilter = $tagsFilter | ConvertFrom-Json $remediationAction = Get-AutomationVariable -Name "AzureOptimization_RemediateUnattachedDisksAction" -ErrorAction SilentlyContinue # Delete / Downsize if (-not($remediationAction)) { $remediationAction = "Delete" } $recommendationId = Get-AutomationVariable -Name "AzureOptimization_RecommendationUnattachedDisksId" -ErrorAction SilentlyContinue if (-not($recommendationId)) { $recommendationId = 'c84d5e86-e2d6-4d62-be7c-cecfbd73b0db' } $SqlTimeout = 0 $recommendationsTable = "Recommendations" "Logging in to Azure with $authenticationOption..." switch ($authenticationOption) { "UserAssignedManagedIdentity" { Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID break } Default { #ManagedIdentity Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment break } } # get reference to storage sink Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context Write-Output "Querying for unattached disks recommendations with fit score >= $minFitScore made consecutively for the last $minWeeksInARow weeks." $tries = 0 $connectionSuccess = $false do { $tries++ try { $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") $Conn.Open() $Cmd=new-object system.Data.SqlClient.SqlCommand $Cmd.Connection = $Conn $Cmd.CommandTimeout = $SqlTimeout $Cmd.CommandText = @" SELECT InstanceId, Cloud, TenantGuid, COUNT(InstanceId) FROM [dbo].[$recommendationsTable] WHERE RecommendationSubTypeId = '$recommendationId' AND FitScore >= $minFitScore AND GeneratedDate >= GETDATE()-(7*$minWeeksInARow) GROUP BY InstanceId, Cloud, TenantGuid HAVING COUNT(InstanceId) >= $minWeeksInARow "@ $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter $sqlAdapter.SelectCommand = $Cmd $unattachedDisks = New-Object System.Data.DataTable $sqlAdapter.Fill($unattachedDisks) | Out-Null $connectionSuccess = $true } catch { Write-Output "Failed to contact SQL at try $tries." Write-Output $Error[0] Start-Sleep -Seconds ($tries * 20) } } while (-not($connectionSuccess) -and $tries -lt 3) if (-not($connectionSuccess)) { throw "Could not establish connection to SQL." } Write-Output "Found $($unattachedDisks.Rows.Count) remediation opportunities." $Conn.Close() $Conn.Dispose() $logEntries = @() $datetime = (get-date).ToUniversalTime() $hour = $datetime.Hour $min = $datetime.Minute $timestamp = $datetime.ToString("yyyy-MM-ddT$($hour):$($min):00.000Z") $ctx = Get-AzContext foreach ($disk in $unattachedDisks.Rows) { $isEligible = $false $logDetails = $null if ([string]::IsNullOrEmpty($tagsFilter)) { $isEligible = $true } else { $diskTags = Get-AzTag -ResourceId $disk.InstanceId -ErrorAction SilentlyContinue if ($diskTags) { foreach ($tagFilter in $tagsFilter) { if ($diskTags.Properties.TagsProperty.($tagFilter.tagName) -eq $tagFilter.tagValue) { $isEligible = $true } else { $isEligible = $false break } } } } $subscriptionId = $disk.InstanceId.Split("/")[2] $resourceGroup = $disk.InstanceId.Split("/")[4] $instanceName = $disk.InstanceId.Split("/")[8] if ($isEligible) { $diskState = "Unknown" $currentSku = "Unknown" Write-Output "Performing $remediationAction action (SIMULATE=$Simulate) on $($disk.InstanceId) disk..." if ($ctx.Environment.Name -eq $disk.Cloud -and $ctx.Tenant.Id -eq $disk.TenantGuid) { if ($ctx.Subscription.Id -ne $subscriptionId) { Select-AzSubscription -SubscriptionId $subscriptionId | Out-Null $ctx = Get-AzContext } $diskObj = Get-AzDisk -ResourceGroupName $resourceGroup -DiskName $instanceName -ErrorAction SilentlyContinue if (-not($diskObj.ManagedBy)) { $diskState = "Unattached" $currentSku = $diskObj.Sku.Name if ($remediationAction -eq "Downsize") { if (-not($Simulate) -and $diskObj.Sku.Name -ne 'Standard_LRS') { $diskObj.Sku = [Microsoft.Azure.Management.Compute.Models.DiskSku]::new('Standard_LRS') $diskObj | Update-AzDisk | Out-Null } else { Write-Output "Skipping as disk is already HDD." } } elseif ($remediationAction -eq "Delete") { if (-not($Simulate)) { Remove-AzDisk -ResourceGroupName $resourceGroup -DiskName $instanceName -Force | Out-Null } } else { Write-Output "Skipping as action is not supported." } } else { if ($diskObj) { Write-Output "Skipping as disk is not unattached." $diskState = "Attached" } else { Write-Output "Skipping as disk was already removed." $diskState = "Removed" } } } else { Write-Output "Could not apply remediation as disk is in another cloud/tenant." } } $logDetails = @{ IsEligible = $isEligible RemediationAction = $remediationAction DiskState = $diskState CurrentSku = $currentSku } $logentry = New-Object PSObject -Property @{ Timestamp = $timestamp Cloud = $disk.Cloud TenantGuid = $disk.TenantGuid SubscriptionGuid = $subscriptionId ResourceGroupName = $resourceGroup.ToLower() InstanceName = $instanceName.ToLower() InstanceId = $disk.InstanceId.ToLower() Simulate = $Simulate LogDetails = $logDetails | ConvertTo-Json -Compress RecommendationSubTypeId = $recommendationId } $logEntries += $logentry } $today = $datetime.ToString("yyyyMMdd") $csvExportPath = "$today-unattacheddisksfiltered.csv" $logEntries | Export-Csv -Path $csvExportPath -NoTypeInformation $csvBlobName = $csvExportPath $csvProperties = @{"ContentType" = "text/csv"}; Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force ================================================ FILE: upgrade-manifest.json ================================================ { "modules": [ { "name": "Az.Accounts", "url": "https://www.powershellgallery.com/api/v2/package/Az.Accounts/2.12.1" }, { "name": "Microsoft.Graph.Authentication", "url": "https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Authentication/2.4.0" }, { "name": "Az.Compute", "url": "https://www.powershellgallery.com/api/v2/package/Az.Compute/5.7.0" }, { "name": "Az.OperationalInsights", "url": "https://www.powershellgallery.com/api/v2/package/Az.OperationalInsights/3.2.0" }, { "name": "Az.ResourceGraph", "url": "https://www.powershellgallery.com/api/v2/package/Az.ResourceGraph/0.13.0" }, { "name": "Az.Storage", "url": "https://www.powershellgallery.com/api/v2/package/Az.Storage/5.5.0" }, { "name": "Az.Resources", "url": "https://www.powershellgallery.com/api/v2/package/Az.Resources/6.6.0" }, { "name": "Az.Monitor", "url": "https://www.powershellgallery.com/api/v2/package/Az.Monitor/4.4.1" }, { "name": "Az.PolicyInsights", "url": "https://www.powershellgallery.com/api/v2/package/Az.PolicyInsights/1.6.0" }, { "name": "Microsoft.Graph.Users", "url": "https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Users/2.4.0" }, { "name": "Microsoft.Graph.Groups", "url": "https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Groups/2.4.0" }, { "name": "Microsoft.Graph.Applications", "url": "https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Applications/2.4.0" }, { "name": "Microsoft.Graph.Identity.DirectoryManagement", "url": "https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Identity.DirectoryManagement/2.4.0" } ], "schedules": [ { "name": "AzureOptimization_ExportAADObjectsDaily", "offset": "PT1H", "frequency": "Day" }, { "name": "AzureOptimization_IngestAADObjectsDaily", "offset": "PT2H", "frequency": "Day" }, { "name": "AzureOptimization_ExportAdvisorWeekly", "offset": "PT1H15M", "frequency": "Week" }, { "name": "AzureOptimization_IngestAdvisorWeekly", "offset": "PT1H45M", "frequency": "Week" }, { "name": "AzureOptimization_ExportARGDaily", "offset": "PT1H05M", "frequency": "Day" }, { "name": "AzureOptimization_ExportPolicyStateDaily", "offset": "PT1H", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGAppGWsDaily", "offset": "PT1H30M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGAvailSetsDaily", "offset": "PT1H30M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGLoadBalancersDaily", "offset": "PT1H30M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGDisksDaily", "offset": "PT1H30M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGPublicIPsDaily", "offset": "PT1H31M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGNICsDaily", "offset": "PT1H31M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGNSGsDaily", "offset": "PT1H31M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGVNetsDaily", "offset": "PT1H31M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGVHDsDaily", "offset": "PT1H32M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGVMsDaily", "offset": "PT1H32M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGVMSSDaily", "offset": "PT1H32M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGSqlDbDaily", "offset": "PT1H32M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGAppServicePlanDaily", "offset": "PT1H33M", "frequency": "Day" }, { "name": "AzureOptimization_IngestPolicyStateDaily", "offset": "PT1H33M", "frequency": "Day" }, { "name": "AzureOptimization_IngestARGResourceContainersDaily", "offset": "PT1H33M", "frequency": "Day" }, { "name": "AzureOptimization_ExportConsumptionDaily", "offset": "PT1H", "frequency": "Day" }, { "name": "AzureOptimization_IngestConsumptionDaily", "offset": "PT2H", "frequency": "Day" }, { "name": "AzureOptimization_ExportRBACDaily", "offset": "PT1H02M", "frequency": "Day" }, { "name": "AzureOptimization_IngestRBACDaily", "offset": "PT2H02M", "frequency": "Day" }, { "name": "AzureOptimization_RecommendationsWeekly", "offset": "PT2H30M", "frequency": "Week" }, { "name": "AzureOptimization_IngestRecommendationsWeekly", "offset": "PT3H30M", "frequency": "Week" }, { "name": "AzureOptimization_IngestSuppressionsWeekly", "offset": "PT3H00M", "frequency": "Week" }, { "name": "AzureOptimization_IngestRemediationLogsDaily", "offset": "PT1H35M", "frequency": "Day" }, { "name": "AzureOptimization_ExportMonitorVmssCpuMaxHourly", "offset": "PT1H15M", "frequency": "Hour" }, { "name": "AzureOptimization_ExportMonitorVmssCpuAvgHourly", "offset": "PT1H15M", "frequency": "Hour" }, { "name": "AzureOptimization_ExportMonitorVmssMemoryMinHourly", "offset": "PT1H15M", "frequency": "Hour" }, { "name": "AzureOptimization_ExportMonitorSqlDbDtuMaxHourly", "offset": "PT1H15M", "frequency": "Hour" }, { "name": "AzureOptimization_ExportMonitorSqlDbDtuAvgHourly", "offset": "PT1H16M", "frequency": "Hour" }, { "name": "AzureOptimization_ExportMonitorAppServiceCpuMaxHourly", "offset": "PT1H16M", "frequency": "Hour" }, { "name": "AzureOptimization_ExportMonitorAppServiceCpuAvgHourly", "offset": "PT1H16M", "frequency": "Hour" }, { "name": "AzureOptimization_ExportMonitorAppServiceMemoryMaxHourly", "offset": "PT1H16M", "frequency": "Hour" }, { "name": "AzureOptimization_ExportMonitorAppServiceMemoryAvgHourly", "offset": "PT1H17M", "frequency": "Hour" }, { "name": "AzureOptimization_ExportMonitorDiskIOPSHourly", "offset": "PT1H17M", "frequency": "Hour" }, { "name": "AzureOptimization_ExportMonitorDiskMBPsHourly", "offset": "PT1H17M", "frequency": "Hour" }, { "name": "AzureOptimization_IngestAzMonitorMetricsHourly", "offset": "PT2H", "frequency": "Hour" }, { "name": "AzureOptimization_CleanUpRecommendationsWeekly", "offset": "P6D", "frequency": "Week" }, { "name": "AzureOptimization_ExportPricesWeekly", "offset": "PT1H35M", "frequency": "Week" }, { "name": "AzureOptimization_IngestPricesheetWeekly", "offset": "PT2H10M", "frequency": "Week" }, { "name": "AzureOptimization_IngestReservationsPriceWeekly", "offset": "PT2H10M", "frequency": "Week" }, { "name": "AzureOptimization_ExportReservationsDaily", "offset": "PT2H", "frequency": "Day" }, { "name": "AzureOptimization_ExportSavingsPlansDaily", "offset": "PT2H05M", "frequency": "Day" }, { "name": "AzureOptimization_IngestReservationsUsageDaily", "offset": "PT2H30M", "frequency": "Day" }, { "name": "AzureOptimization_IngestSavingsPlansUsageDaily", "offset": "PT2H35M", "frequency": "Day" } ], "baseIngest": [ { "runbook": { "name": "runbooks/data-collection/Ingest-OptimizationCSVExportsToLogAnalytics.ps1", "version": "1.5.0.0" }, "source": "dataCollection" }, { "runbook": { "name": "runbooks/recommendations/Ingest-RecommendationsToSQLServer.ps1", "version": "1.6.5.0" }, "source": "recommendations", "schedule": "AzureOptimization_IngestRecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Ingest-RecommendationsToLogAnalytics.ps1", "version": "1.0.2.0" }, "source": "recommendations", "schedule": "AzureOptimization_IngestRecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Ingest-SuppressionsToLogAnalytics.ps1", "version": "1.0.0.0" }, "source": "recommendations", "schedule": "AzureOptimization_IngestSuppressionsWeekly" }, { "runbook": { "name": "runbooks/maintenance/CleanUp-OlderRecommendationsFromSqlServer.ps1", "version": "1.0.0.0" }, "source": "maintenance", "schedule": "AzureOptimization_CleanUpRecommendationsWeekly" } ], "dataCollection": [ { "runbook": { "name": "runbooks/data-collection/Export-AADObjectsToBlobStorage.ps1", "version": "1.2.2.1" }, "container": "aadobjectsexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportAADObjectsDaily", "ingestSchedule": "AzureOptimization_IngestAADObjectsDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGAppGatewayPropertiesToBlobStorage.ps1", "version": "1.1.4.1" }, "container": "argappgwexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGAppGWsDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGAvailabilitySetPropertiesToBlobStorage.ps1", "version": "1.1.4.1" }, "container": "argavailsetexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGAvailSetsDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGLoadBalancerPropertiesToBlobStorage.ps1", "version": "1.1.4.1" }, "container": "arglbexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGLoadBalancersDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGManagedDisksPropertiesToBlobStorage.ps1", "version": "1.3.4.1" }, "container": "argdiskexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGDisksDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGNICPropertiesToBlobStorage.ps1", "version": "1.0.2.1" }, "container": "argnicexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGNICsDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGNSGPropertiesToBlobStorage.ps1", "version": "1.0.2.1" }, "container": "argnsgexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGNSGsDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGPublicIpPropertiesToBlobStorage.ps1", "version": "1.0.2.1" }, "container": "argpublicipexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGPublicIPsDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGResourceContainersPropertiesToBlobStorage.ps1", "version": "1.0.5.1" }, "container": "argrescontainersexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGResourceContainersDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGUnmanagedDisksPropertiesToBlobStorage.ps1", "version": "1.1.4.1" }, "container": "argvhdexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGVHDsDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGVNetPropertiesToBlobStorage.ps1", "version": "1.0.2.1" }, "container": "argvnetexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGVNetsDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGVirtualMachinesPropertiesToBlobStorage.ps1", "version": "1.4.4.1" }, "container": "argvmexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGVMsDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGVMSSPropertiesToBlobStorage.ps1", "version": "1.0.2.1" }, "container": "argvmssexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGVMSSDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGSqlDatabasePropertiesToBlobStorage.ps1", "version": "1.0.2.1" }, "container": "argsqldbexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGSqlDbDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-ARGAppServicePlanPropertiesToBlobStorage.ps1", "version": "1.0.1.1" }, "container": "argappserviceplanexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportARGDaily", "ingestSchedule": "AzureOptimization_IngestARGAppServicePlanDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-AdvisorRecommendationsToBlobStorage.ps1", "version": "1.4.2.1" }, "container": "advisorexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportAdvisorWeekly", "ingestSchedule": "AzureOptimization_IngestAdvisorWeekly" }, { "runbook": { "name": "runbooks/data-collection/Export-ConsumptionToBlobStorage.ps1", "version": "2.0.4.1" }, "container": "consumptionexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportConsumptionDaily", "ingestSchedule": "AzureOptimization_IngestConsumptionDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-RBACAssignmentsToBlobStorage.ps1", "version": "1.0.4.1" }, "container": "rbacexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportRBACDaily", "ingestSchedule": "AzureOptimization_IngestRBACDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-PolicyComplianceToBlobStorage.ps1", "version": "1.0.3.1" }, "container": "policystateexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportPolicyStateDaily", "ingestSchedule": "AzureOptimization_IngestPolicyStateDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-AzMonitorMetricsToBlobStorage.ps1", "version": "1.0.2.1" }, "container": "azmonitorexports", "requiredVariables": [ ], "exportSchedules": [ { "schedule": "AzureOptimization_ExportMonitorVmssCpuMaxHourly", "parameters": { "ResourceType": "microsoft.compute/virtualmachinescalesets", "TimeSpan": "01:00:00", "aggregationType": "Maximum", "MetricNames": "Percentage CPU", "TimeGrain": "01:00:00" } }, { "schedule": "AzureOptimization_ExportMonitorVmssCpuAvgHourly", "parameters": { "ResourceType": "microsoft.compute/virtualmachinescalesets", "TimeSpan": "01:00:00", "aggregationType": "Average", "MetricNames": "Percentage CPU", "TimeGrain": "01:00:00" } }, { "schedule": "AzureOptimization_ExportMonitorVmssMemoryMinHourly", "parameters": { "ResourceType": "microsoft.compute/virtualmachinescalesets", "TimeSpan": "01:00:00", "aggregationType": "Minimum", "MetricNames": "Available Memory Bytes", "TimeGrain": "01:00:00" } }, { "schedule": "AzureOptimization_ExportMonitorSqlDbDtuMaxHourly", "parameters": { "ResourceType": "microsoft.sql/servers/databases", "ARGFilter": "sku.tier in ('Standard','Premium')", "TimeSpan": "01:00:00", "aggregationType": "Maximum", "MetricNames": "dtu_consumption_percent", "TimeGrain": "01:00:00" } }, { "schedule": "AzureOptimization_ExportMonitorSqlDbDtuAvgHourly", "parameters": { "ResourceType": "microsoft.sql/servers/databases", "ARGFilter": "sku.tier in ('Standard','Premium')", "TimeSpan": "01:00:00", "AggregationOfType": "Maximum", "aggregationType": "Average", "MetricNames": "dtu_consumption_percent", "TimeGrain": "00:01:00" } }, { "schedule": "AzureOptimization_ExportMonitorAppServiceCpuMaxHourly", "parameters": { "ResourceType": "microsoft.web/serverfarms", "ARGFilter": "properties.computeMode == 'Dedicated' and sku.tier != 'Free'", "TimeSpan": "01:00:00", "aggregationType": "Maximum", "MetricNames": "CpuPercentage", "TimeGrain": "01:00:00" } }, { "schedule": "AzureOptimization_ExportMonitorAppServiceCpuAvgHourly", "parameters": { "ResourceType": "microsoft.web/serverfarms", "ARGFilter": "properties.computeMode == 'Dedicated' and sku.tier != 'Free'", "TimeSpan": "01:00:00", "aggregationType": "Average", "AggregationOfType": "Maximum", "MetricNames": "CpuPercentage", "TimeGrain": "00:01:00" } }, { "schedule": "AzureOptimization_ExportMonitorAppServiceMemoryMaxHourly", "parameters": { "ResourceType": "microsoft.web/serverfarms", "ARGFilter": "properties.computeMode == 'Dedicated' and sku.tier != 'Free'", "TimeSpan": "01:00:00", "aggregationType": "Maximum", "MetricNames": "MemoryPercentage", "TimeGrain": "01:00:00" } }, { "schedule": "AzureOptimization_ExportMonitorAppServiceMemoryAvgHourly", "parameters": { "ResourceType": "microsoft.web/serverfarms", "ARGFilter": "properties.computeMode == 'Dedicated' and sku.tier != 'Free'", "TimeSpan": "01:00:00", "aggregationType": "Average", "AggregationOfType": "Maximum", "MetricNames": "MemoryPercentage", "TimeGrain": "00:01:00" } }, { "schedule": "AzureOptimization_ExportMonitorDiskIOPSHourly", "parameters": { "ResourceType": "microsoft.compute/disks", "ARGFilter": "sku.name =~ 'Premium_LRS' and properties.diskState != 'Unattached'", "TimeSpan": "01:00:00", "aggregationType": "Average", "AggregationOfType": "Maximum", "MetricNames": "Composite Disk Read Operations/sec,Composite Disk Write Operations/sec", "TimeGrain": "00:01:00" } }, { "schedule": "AzureOptimization_ExportMonitorDiskMBPsHourly", "parameters": { "ResourceType": "microsoft.compute/disks", "ARGFilter": "sku.name =~ 'Premium_LRS' and properties.diskState != 'Unattached'", "TimeSpan": "01:00:00", "aggregationType": "Average", "AggregationOfType": "Maximum", "MetricNames": "Composite Disk Read Bytes/sec,Composite Disk Write Bytes/sec", "TimeGrain": "00:01:00" } } ], "ingestSchedule": "AzureOptimization_IngestAzMonitorMetricsHourly" }, { "runbook": { "name": "runbooks/data-collection/Export-PriceSheetToBlobStorage.ps1", "version": "1.1.1.1" }, "container": "pricesheetexports", "requiredVariables": [ { "name": "AzureOptimization_PriceSheetMeterCategories", "defaultValue": "Virtual Machines,Storage" } ], "exportSchedule": "AzureOptimization_ExportPricesWeekly", "ingestSchedule": "AzureOptimization_IngestPricesheetWeekly" }, { "runbook": { "name": "runbooks/data-collection/Export-ReservationsPriceToBlobStorage.ps1", "version": "1.0.1.1" }, "container": "reservationspriceexports", "requiredVariables": [ { "name": "AzureOptimization_RetailPricesCurrencyCode", "defaultValue": "EUR" } ], "exportSchedule": "AzureOptimization_ExportPricesWeekly", "ingestSchedule": "AzureOptimization_IngestReservationsPriceWeekly" }, { "runbook": { "name": "runbooks/data-collection/Export-ReservationsUsageToBlobStorage.ps1", "version": "1.1.2.1" }, "container": "reservationsexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportReservationsDaily", "ingestSchedule": "AzureOptimization_IngestReservationsUsageDaily" }, { "runbook": { "name": "runbooks/data-collection/Export-SavingsPlansUsageToBlobStorage.ps1", "version": "1.0.0.0" }, "container": "savingsplansexports", "requiredVariables": [ ], "exportSchedule": "AzureOptimization_ExportSavingsPlansDaily", "ingestSchedule": "AzureOptimization_IngestSavingsPlansUsageDaily" } ], "recommendations": [ { "runbook": { "name": "runbooks/recommendations/Recommend-AADExpiringCredentialsToBlobStorage.ps1", "version": "1.1.10.0" }, "requiredVariables": [ { "name": "AzureOptimization_RecommendationAADMinCredValidityDays", "defaultValue": 30 }, { "name": "AzureOptimization_RecommendationAADMaxCredValidityYears", "defaultValue": 2 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-AdvisorAsIsToBlobStorage.ps1", "version": "1.5.5.0" }, "requiredVariables": [ ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-AdvisorCostAugmentedToBlobStorage.ps1", "version": "2.9.1.0" }, "requiredVariables": [ { "name": "AzureOptimization_ConsumptionOffsetDays", "defaultValue": 3 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-VMOptimizationsToBlobStorage.ps1", "version": "1.0.0.0" }, "requiredVariables": [ { "name": "AzureOptimization_RecommendationLongDeallocatedVmsIntervalDays", "defaultValue": 30 }, { "name": "AzureOptimization_ConsumptionOffsetDays", "defaultValue": 3 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-UnattachedDisksToBlobStorage.ps1", "version": "2.4.8.0" }, "requiredVariables": [ { "name": "AzureOptimization_ConsumptionOffsetDays", "defaultValue": 3 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-UnusedAppGWsToBlobStorage.ps1", "version": "1.2.9.0" }, "requiredVariables": [ { "name": "AzureOptimization_ConsumptionOffsetDays", "defaultValue": 3 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-UnusedLoadBalancersToBlobStorage.ps1", "version": "1.2.9.0" }, "requiredVariables": [ { "name": "AzureOptimization_ConsumptionOffsetDays", "defaultValue": 3 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-VMsHighAvailabilityToBlobStorage.ps1", "version": "1.0.3.0" }, "requiredVariables": [ ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-ARMOptimizationsToBlobStorage.ps1", "version": "1.0.3.0" }, "requiredVariables": [ { "name": "AzureOptimization_RecommendationRBACAssignmentsPercentageThreshold", "defaultValue": 80 }, { "name": "AzureOptimization_RecommendationResourceGroupsPerSubPercentageThreshold", "defaultValue": 80 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-VNetOptimizationsToBlobStorage.ps1", "version": "1.0.4.0" }, "requiredVariables": [ { "name": "AzureOptimization_RecommendationVNetSubnetMaxUsedPercentageThreshold", "defaultValue": 80 }, { "name": "AzureOptimization_RecommendationVNetSubnetMinUsedPercentageThreshold", "defaultValue": 5 }, { "name": "AzureOptimization_RecommendationVNetSubnetEmptyMinAgeInDays", "defaultValue": 30 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-VMSSOptimizationsToBlobStorage.ps1", "version": "1.1.1.0" }, "requiredVariables": [ { "name": "AzureOptimization_PerfThresholdCpuDegradedMaxPercentage", "defaultValue": 95 }, { "name": "AzureOptimization_PerfThresholdCpuDegradedAvgPercentage", "defaultValue": 75 }, { "name": "AzureOptimization_PerfThresholdMemoryDegradedPercentage", "defaultValue": 90 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-SqlDbOptimizationsToBlobStorage.ps1", "version": "1.1.2.0" }, "requiredVariables": [ { "name": "AzureOptimization_PerfPercentileSqlDtu", "defaultValue": 99 }, { "name": "AzureOptimization_PerfThresholdDtuPercentage", "defaultValue": 40 }, { "name": "AzureOptimization_PerfThresholdDtuDegradedPercentage", "defaultValue": 75 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-StorageAccountOptimizationsToBlobStorage.ps1", "version": "1.0.3.0" }, "requiredVariables": [ { "name": "AzureOptimization_RecommendationStorageAcountGrowthThresholdPercentage", "defaultValue": 5 }, { "name": "AzureOptimization_RecommendationStorageAcountGrowthMonthlyCostThreshold", "defaultValue": 50 }, { "name": "AzureOptimization_RecommendationStorageAcountGrowthLookbackDays", "defaultValue": 30 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-AppServiceOptimizationsToBlobStorage.ps1", "version": "1.0.3.0" }, "requiredVariables": [ { "name": "AzureOptimization_PerfThresholdCpuDegradedMaxPercentage", "defaultValue": 95 }, { "name": "AzureOptimization_PerfThresholdCpuDegradedAvgPercentage", "defaultValue": 75 }, { "name": "AzureOptimization_PerfThresholdMemoryDegradedPercentage", "defaultValue": 90 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" }, { "runbook": { "name": "runbooks/recommendations/Recommend-DiskOptimizationsToBlobStorage.ps1", "version": "1.1.1.0" }, "requiredVariables": [ { "name": "AzureOptimization_PerfThresholdDiskIOPSPercentage", "defaultValue": 5 }, { "name": "AzureOptimization_PerfThresholdDiskMBsPercentage", "defaultValue": 5 } ], "exportSchedule": "AzureOptimization_RecommendationsWeekly" } ], "remediations": [ { "runbook": { "name": "runbooks/remediations/Remediate-AdvisorRightSizeFiltered.ps1", "version": "1.2.4.0" }, "requiredVariables": [ { "name": "AzureOptimization_RemediateRightSizeMinFitScore", "defaultValue": 5.0 }, { "name": "AzureOptimization_RemediateRightSizeMinWeeksInARow", "defaultValue": 4 } ] }, { "runbook": { "name": "runbooks/remediations/Remediate-LongDeallocatedVMsFiltered.ps1", "version": "1.0.3.0" }, "requiredVariables": [ { "name": "AzureOptimization_RemediateLongDeallocatedVMsMinFitScore", "defaultValue": 5.0 }, { "name": "AzureOptimization_RemediateLongDeallocatedVMsMinWeeksInARow", "defaultValue": 4 } ] }, { "runbook": { "name": "runbooks/remediations/Remediate-UnattachedDisksFiltered.ps1", "version": "1.0.3.0" }, "requiredVariables": [ { "name": "AzureOptimization_RemediateUnattachedDisksMinFitScore", "defaultValue": 5.0 }, { "name": "AzureOptimization_RemediateUnattachedDisksMinWeeksInARow", "defaultValue": 4 }, { "name": "AzureOptimization_RemediateUnattachedDisksAction", "defaultValue": "Delete" } ] } ], "deprecatedRunbooks": [ "Recommend-AvailSetsWithLowFaultDomainCountToBlobStorage", "Recommend-AvailSetsWithLowUpdateDomainCountToBlobStorage", "Recommend-AvailSetsWithVMsSharingStorageAccountsToBlobStorage", "Recommend-StorageAccountsWithMultipleVMsToBlobStorage", "Recommend-VMsNoAvailSetToBlobStorage", "Recommend-VMsSingleInAvailSetToBlobStorage", "Recommend-VMsWithDisksMultipleStorageAccountsToBlobStorage", "Recommend-VMsWithUnmanagedDisksToBlobStorage", "Recommend-LongDeallocatedVmsToBlobStorage" ], "overwriteVariables": [ { "name": "AzureOptimization_LogAnalyticsChunkSize", "value": 6000 } ] } ================================================ FILE: views/powerbi-query.m ================================================ let Source = Sql.Database("aoedevgithub-sql.database.windows.net", "azureoptimization", [Query="EXEC GetRecommendations", CommandTimeout=#duration(0, 2, 0, 0)]), #"Parsed JSON Tags" = Table.TransformColumns(Source,{{"Tags", Json.Document}}), #"Expanded Tags" = Table.ExpandRecordColumn(#"Parsed JSON Tags", "Tags", {"environment", "costcenter"}, {"Tags.Environment", "Tags.CostCenter"}), #"Trimmed Text" = Table.TransformColumns(#"Expanded Tags",{{"Tags.Environment", Text.Trim, type text}, {"Tags.CostCenter", Text.Trim, type text}}), #"Duplicated AdditionalInfo" = Table.DuplicateColumn(#"Trimmed Text","AdditionalInfo", "AddInfoJSON"), #"Parsed JSON AdditionalInfo" = Table.TransformColumns(#"Duplicated AdditionalInfo",{{"AdditionalInfo", Json.Document}}), #"Expanded AdditionalInfo" = Table.ExpandRecordColumn(#"Parsed JSON AdditionalInfo", "AdditionalInfo", {"BelowNetworkThreshold", "SupportsDataDisksCount", "MetricIOPS", "SupportsIOPS", "BelowMemoryThreshold", "currentSku", "targetSku", "SupportsNICCount", "DataDiskCount", "MetricMemoryPercentage", "MetricNetworkMbps", "MetricMiBps", "SupportsMiBps", "BelowCPUThreshold", "NicCount", "MetricCPUPercentage", "annualSavingsAmount", "savingsCurrency", "savingsAmount", "CostsAmount", "reservationType", "vmSize", "DiskType", "DiskSizeGB", "MaxMemoryP95", "MaxCpuP95", "MaxTotalNetworkP95", "DeploymentModel", "scope", "region", "qty", "displaySKU", "term"}, {"AddInfo.BelowNetworkThreshold", "AddInfo.SupportsDataDisksCount", "AddInfo.MetricIOPS", "AddInfo.SupportsIOPS", "AddInfo.BelowMemoryThreshold", "AddInfo.currentSku", "AddInfo.targetSku", "AddInfo.SupportsNICCount", "AddInfo.DataDiskCount", "AddInfo.MetricMemoryPercentage", "AddInfo.MetricNetworkMbps", "AddInfo.MetricMiBps", "AddInfo.SupportsMiBps", "AddInfo.BelowCPUThreshold", "AddInfo.NicCount", "AddInfo.MetricCPUPercentage", "AddInfo.annualSavingsAmount", "AddInfo.savingsCurrency", "AddInfo.savingsAmount", "AddInfo.CostsAmount", "AddInfo.reservationType", "AddInfo.vmSize", "AddInfo.DiskType", "AddInfo.DiskSizeGB", "AddInfo.MaxMemoryP95", "AddInfo.MaxCpuP95", "AddInfo.MaxTotalNetworkP95", "AddInfo.DeploymentModel", "AddInfo.Scope", "AddInfo.Region", "AddInfo.Quantity", "AddInfo.ReservationSKU", "AddInfo.Term"}), #"Split Column by Delimiter" = Table.SplitColumn(#"Expanded AdditionalInfo", "AddInfo.BelowNetworkThreshold", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.BelowNetworkThreshold.1", "AddInfo.BelowNetworkThreshold.2"}), #"Changed Type" = Table.TransformColumnTypes(#"Split Column by Delimiter",{{"AddInfo.BelowNetworkThreshold.1", type text}, {"AddInfo.BelowNetworkThreshold.2", type text}}), #"Renamed Columns" = Table.RenameColumns(#"Changed Type",{{"AddInfo.BelowNetworkThreshold.2", "AddInfo.BelowNetworkThresholdDetails"}, {"AddInfo.BelowNetworkThreshold.1", "AddInfo.BelowNetworkThresholdResult"}}), #"Split Column by Delimiter1" = Table.SplitColumn(#"Renamed Columns", "AddInfo.SupportsDataDisksCount", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.SupportsDataDisksCount.1", "AddInfo.SupportsDataDisksCount.2"}), #"Changed Type1" = Table.TransformColumnTypes(#"Split Column by Delimiter1",{{"AddInfo.SupportsDataDisksCount.1", type logical}, {"AddInfo.SupportsDataDisksCount.2", type text}}), #"Renamed Columns1" = Table.RenameColumns(#"Changed Type1",{{"AddInfo.SupportsDataDisksCount.1", "AddInfo.SupportsDataDisksCountResult"}, {"AddInfo.SupportsDataDisksCount.2", "AddInfo.SupportsDataDisksCountDetails"}}), #"Split Column by Delimiter2" = Table.SplitColumn(#"Renamed Columns1", "AddInfo.SupportsIOPS", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.SupportsIOPS.1", "AddInfo.SupportsIOPS.2"}), #"Changed Type2" = Table.TransformColumnTypes(#"Split Column by Delimiter2",{{"AddInfo.SupportsIOPS.1", type text}, {"AddInfo.SupportsIOPS.2", type text}}), #"Renamed Columns2" = Table.RenameColumns(#"Changed Type2",{{"AddInfo.SupportsIOPS.1", "AddInfo.SupportsIOPSResult"}, {"AddInfo.SupportsIOPS.2", "AddInfo.SupportsIOPSDetails"}}), #"Split Column by Delimiter3" = Table.SplitColumn(#"Renamed Columns2", "AddInfo.BelowMemoryThreshold", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.BelowMemoryThreshold.1", "AddInfo.BelowMemoryThreshold.2"}), #"Changed Type3" = Table.TransformColumnTypes(#"Split Column by Delimiter3",{{"AddInfo.BelowMemoryThreshold.1", type text}, {"AddInfo.BelowMemoryThreshold.2", type text}}), #"Renamed Columns3" = Table.RenameColumns(#"Changed Type3",{{"AddInfo.BelowMemoryThreshold.1", "AddInfo.BelowMemoryThresholdResult"}, {"AddInfo.BelowMemoryThreshold.2", "AddInfo.BelowMemoryThresholdDetails"}}), #"Split Column by Delimiter4" = Table.SplitColumn(#"Renamed Columns3", "AddInfo.SupportsNICCount", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.SupportsNICCount.1", "AddInfo.SupportsNICCount.2"}), #"Changed Type4" = Table.TransformColumnTypes(#"Split Column by Delimiter4",{{"AddInfo.SupportsNICCount.1", type logical}, {"AddInfo.SupportsNICCount.2", type text}}), #"Renamed Columns4" = Table.RenameColumns(#"Changed Type4",{{"AddInfo.SupportsNICCount.1", "AddInfo.SupportsNICCountResult"}, {"AddInfo.SupportsNICCount.2", "AddInfo.SupportsNICCountDetails"}}), #"Split Column by Delimiter5" = Table.SplitColumn(#"Renamed Columns4", "AddInfo.SupportsMiBps", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.SupportsMiBps.1", "AddInfo.SupportsMiBps.2"}), #"Changed Type5" = Table.TransformColumnTypes(#"Split Column by Delimiter5",{{"AddInfo.SupportsMiBps.1", type text}, {"AddInfo.SupportsMiBps.2", type text}}), #"Renamed Columns5" = Table.RenameColumns(#"Changed Type5",{{"AddInfo.SupportsMiBps.1", "AddInfo.SupportsMiBpsResult"}, {"AddInfo.SupportsMiBps.2", "AddInfo.SupportsMiBpsDetails"}}), #"Split Column by Delimiter6" = Table.SplitColumn(#"Renamed Columns5", "AddInfo.BelowCPUThreshold", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.BelowCPUThreshold.1", "AddInfo.BelowCPUThreshold.2"}), #"Changed Type6" = Table.TransformColumnTypes(#"Split Column by Delimiter6",{{"AddInfo.BelowCPUThreshold.1", type text}, {"AddInfo.BelowCPUThreshold.2", type text}}), #"Renamed Columns6" = Table.RenameColumns(#"Changed Type6",{{"AddInfo.BelowCPUThreshold.1", "AddInfo.BelowCPUThresholdResult"}, {"AddInfo.BelowCPUThreshold.2", "AddInfo.BelowCPUThresholdDetails"}}), #"Changed Type7" = Table.TransformColumnTypes(#"Renamed Columns6",{{"AddInfo.CostsAmount", type number}}, "en-US"), #"Changed Type8" = Table.TransformColumnTypes(#"Changed Type7",{{"AddInfo.savingsAmount", type number}}, "en-US"), #"Changed Type9" = Table.TransformColumnTypes(#"Changed Type8",{{"AddInfo.DiskSizeGB", Int64.Type}}), #"Changed Type10" = Table.TransformColumnTypes(#"Changed Type9",{{"AddInfo.MaxMemoryP95", Int64.Type}}), #"Changed Type11" = Table.TransformColumnTypes(#"Changed Type10",{{"AddInfo.MaxCpuP95", Int64.Type}}), #"Changed Type12" = Table.TransformColumnTypes(#"Changed Type11",{{"AddInfo.MaxTotalNetworkP95", Int64.Type}}), #"Changed Type13" = Table.TransformColumnTypes(#"Changed Type12",{{"AddInfo.annualSavingsAmount", type number}}, "en-US"), #"Changed Type14" = Table.TransformColumnTypes(#"Changed Type13",{{"AddInfo.MetricCPUPercentage", type number}}, "en-US"), #"Changed Type15" = Table.TransformColumnTypes(#"Changed Type14",{{"AddInfo.NicCount", Int64.Type}}), #"Changed Type16" = Table.TransformColumnTypes(#"Changed Type15",{{"AddInfo.DataDiskCount", Int64.Type}}), #"Changed Type17" = Table.TransformColumnTypes(#"Changed Type16",{{"AddInfo.MetricMiBps", type number}}, "en-US"), #"Changed Type18" = Table.TransformColumnTypes(#"Changed Type17",{{"AddInfo.MetricIOPS", type number}}, "en-US"), #"Changed Type19" = Table.TransformColumnTypes(#"Changed Type18",{{"AddInfo.MetricNetworkMbps", type number}}, "en-US"), #"Changed Type20" = Table.TransformColumnTypes(#"Changed Type19",{{"AddInfo.MetricMemoryPercentage", type number}}, "en-US") in #"Changed Type20" ================================================ FILE: views/workbooks/benefits-simulation.bicep ================================================ @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') param workbookDisplayName string = 'Benefits Simulation' @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') param workbookType string = 'workbook' @description('The id of resource instance to which the workbook will be associated') param workbookSourceId string @description('The unique guid for this workbook instance') param workbookId string = '96fabefe-1f3e-4526-a5db-c442661617e5' param resourceTags object param resourceGroupLocation string = resourceGroup().location resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { name: workbookId location: resourceGroupLocation tags: resourceTags kind: 'shared' properties: { displayName: workbookDisplayName serializedData: string(loadJsonContent('benefits-simulation.json')) version: '1.0' sourceId: workbookSourceId category: workbookType } dependsOn: [] } output workbookId string = workbookId_resource.id ================================================ FILE: views/workbooks/benefits-simulation.json ================================================ { "version": "Notebook/1.0", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "b58b4eb8-5821-44d2-bc7e-54054df27320", "version": "KqlParameterItem/1.0", "name": "LookbackPeriod", "label": "Lookback Period", "type": 4, "isRequired": true, "typeSettings": { "selectableValues": [ { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ], "allowCustom": true }, "timeContext": { "durationMs": 86400000 }, "value": { "durationMs": 2592000000 } }, { "id": "121c03f2-6ca3-4438-a828-8dffec3e208a", "version": "KqlParameterItem/1.0", "name": "Subscriptions", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| distinct SubscriptionName\r\n| order by SubscriptionName asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ] }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "id": "0c9fd864-c30b-4f8f-8065-e44aa1b55a51", "version": "KqlParameterItem/1.0", "name": "GroupBy", "label": "Group by", "type": 2, "isRequired": true, "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "jsonData": "[{ \"value\": \"SubscriptionName\", \"label\": \"Subscription\", \"selected\": true }, { \"value\": \"SKUName\", \"label\": \"VM Size\" },{ \"value\": \"ISFGroup\", \"label\": \"Instance Size Flexibility Group\" }]" } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "parameters - 0" }, { "type": 1, "content": { "json": "Unless specified, usage values unit corresponds to your billing currency. **Only applies to Virtual Machines usage in Azure Global**.", "style": "info" }, "name": "text - 7" }, { "type": 1, "content": { "json": "If below tabs are reporting query errors, you must set up Pricesheet exports. See more details [here](https://aka.ms/AzureOptimizationEngine/commitmentssetup).", "style": "warning" }, "name": "text - 6" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| where SubscriptionName in ({Subscriptions})\r\n| extend VMSize = tolower(parse_json(AdditionalInfo_s)['ServiceType'])\r\n| extend SKUName = strcat(VMSize, ' ', ResourceLocation_s)\r\n| project Date_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ResourceLocation_s, MeterId=MeterId_g, ResourceId, OnDemandPrice=todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s)), SubscriptionName, VMSize, SKUName\r\n| join kind=leftouter ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| extend ISFGroup = strcat(ISFGroup, ' ', ResourceLocation_s)\r\n| extend OnDemandCost = QtyHours * OnDemandPrice\r\n| summarize HourlyCost=sum(OnDemandCost)/24 by bin(todatetime(Date_s), 1d), {GroupBy}", "size": 1, "aggregation": 3, "title": "Average On-Demand hourly usage (actual cost)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" } ] } }, "name": "onDemandUsageAsIs" }, { "type": 11, "content": { "version": "LinkItem/1.0", "style": "tabs", "links": [ { "id": "1d33e1ca-6d19-4b74-8903-00c5671b8f87", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Savings Plans", "subTarget": "SavingsPlans", "style": "link" }, { "id": "b26e72a1-167d-4449-a8b6-6665814331a3", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Reservations", "subTarget": "Reservations", "style": "link" } ] }, "name": "analysisTabs" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 1, "content": { "json": "Enter a hourly commitment to estimate your savings according to the lookback period", "style": "info" }, "name": "text - 1" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "977209de-fdec-4c84-86fd-7b0815aa71e1", "version": "KqlParameterItem/1.0", "name": "SavingsPlanTerm", "label": "Savings Plan Term", "type": 10, "description": "Savings Plan term to get the Savings Plan prices from", "isRequired": true, "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "jsonData": "[\"1 Year\", \"3 Years\"]", "timeContext": { "durationMs": 86400000 }, "value": "3 Years" }, { "id": "368dffea-79c4-45a9-9876-fc77cf691541", "version": "KqlParameterItem/1.0", "name": "HourlyCommitment", "label": "Hourly commitment", "type": 1, "isRequired": true, "value": "" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "savingsPlansParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nlet LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandPrice;\r\nlet LinuxSavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and MeterSubCategory_s !endswith \"Windows\" and Term_s == iif('{SavingsPlanTerm}' == '3 Years','P3Y','P1Y')\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet SavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and Term_s == iif('{SavingsPlanTerm}' == '3 Years','P3Y','P1Y')\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxSavingsPlanPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend SavingsPlanPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, SavingsPlanPrice;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| where SubscriptionName in ({Subscriptions})\r\n| join kind=inner ( OnDemandPriceSheet ) on $left.MeterId_g == $right.MeterID_g\r\n| extend VMSize = tolower(parse_json(AdditionalInfo_s)['ServiceType'])\r\n| extend ResourceLocation_s=tolower(ResourceLocation_s)\r\n| extend SKUName = strcat(VMSize, ' ', ResourceLocation_s)\r\n| project Date_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ResourceLocation_s, MeterId=MeterId_g, ResourceId, OnDemandPrice, SubscriptionName, VMSize, SKUName\r\n| join kind=leftouter ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| extend ISFGroup = strcat(ISFGroup, ' ', ResourceLocation_s)\r\n| join kind=inner ( SavingsPlanPriceSheet ) on $left.MeterId == $right.MeterID_g\r\n| extend SavingsPlanCost = QtyHours * SavingsPlanPrice\r\n| summarize HourlyCost=sum(SavingsPlanCost)/24 by todatetime(Date_s), {GroupBy}", "size": 1, "aggregation": 3, "title": "Average On-Demand hourly usage (Savings Plan prices)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" } ] } }, "name": "onDemandUsageSavingsPlansPerspective" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let HourlyCommitment = {HourlyCommitment};\r\nlet LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandPrice;\r\nlet LinuxSavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and MeterSubCategory_s !endswith \"Windows\" and Term_s == iif('{SavingsPlanTerm}' == '3 Years','P3Y','P1Y')\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet SavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and Term_s == iif('{SavingsPlanTerm}' == '3 Years','P3Y','P1Y')\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxSavingsPlanPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend SavingsPlanPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, SavingsPlanPrice;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| where SubscriptionName in ({Subscriptions})\r\n| join kind=inner ( OnDemandPriceSheet ) on $left.MeterId_g == $right.MeterID_g\r\n| extend ResourceLocation_s=tolower(ResourceLocation_s)\r\n| project Date_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ArmRegion=ResourceLocation_s, MeterId=MeterId_g, ResourceId, OnDemandPrice, SubscriptionName\r\n| join kind=inner ( SavingsPlanPriceSheet ) on $left.MeterId == $right.MeterID_g\r\n| extend SavingsPlanCost = QtyHours * SavingsPlanPrice\r\n| extend OnDemandCost = QtyHours * OnDemandPrice\r\n| extend SavingsPlanDiscount = 1 - SavingsPlanPrice / OnDemandPrice\r\n| extend MeterAndRegion = strcat(BillingMeter, ' - ', ArmRegion)\r\n| summarize HourlySavingsPlanCost=sum(SavingsPlanCost)/24, HourlyOnDemandCost=sum(OnDemandCost)/24, SavingsPlanDiscount=round(avg(SavingsPlanDiscount), 3) by Date_s, MeterAndRegion\r\n| order by todatetime(Date_s), SavingsPlanDiscount\r\n| extend SavingsPlanHourlyBalance = row_cumsum(HourlySavingsPlanCost, prev(Date_s) != Date_s)\r\n| extend CutOff = iif(prev(Date_s) != Date_s or prev(SavingsPlanHourlyBalance, 1, 0) < HourlyCommitment, false, true)\r\n| where not(CutOff)\r\n| extend SavingsAllocationPercentage = iif(SavingsPlanHourlyBalance < HourlyCommitment, 1.0, 1 - (SavingsPlanHourlyBalance - HourlyCommitment) / HourlySavingsPlanCost)\r\n| summarize SavedAmount=sum((HourlyOnDemandCost - HourlySavingsPlanCost) * SavingsAllocationPercentage)*24 by todatetime(Date_s)", "size": 1, "title": "Estimated savings (in your billing currency)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" }, { "seriesName": "SavedAmount", "label": "Saved Amount" } ] } }, "name": "onDemandUsageSavingsPlansSimulation" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let HourlyCommitment = {HourlyCommitment};\r\nlet LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandPrice;\r\nlet LinuxSavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and MeterSubCategory_s !endswith \"Windows\" and Term_s == iif('{SavingsPlanTerm}' == '3 Years','P3Y','P1Y')\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet SavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and Term_s == iif('{SavingsPlanTerm}' == '3 Years','P3Y','P1Y')\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxSavingsPlanPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend SavingsPlanPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, SavingsPlanPrice;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| where SubscriptionName in ({Subscriptions})\r\n| join kind=inner ( OnDemandPriceSheet ) on $left.MeterId_g == $right.MeterID_g\r\n| extend ResourceLocation_s=tolower(ResourceLocation_s)\r\n| project Date_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ArmRegion=ResourceLocation_s, MeterId=MeterId_g, ResourceId, OnDemandPrice, SubscriptionName\r\n| join kind=inner ( SavingsPlanPriceSheet ) on $left.MeterId == $right.MeterID_g\r\n| extend SavingsPlanCost = QtyHours * SavingsPlanPrice\r\n| extend OnDemandCost = QtyHours * OnDemandPrice\r\n| extend SavingsPlanDiscount = 1 - SavingsPlanPrice / OnDemandPrice\r\n| extend MeterAndRegion = strcat(BillingMeter, ' - ', ArmRegion)\r\n| summarize HourlySavingsPlanCost=sum(SavingsPlanCost)/24, HourlyOnDemandCost=sum(OnDemandCost)/24, SavingsPlanDiscount=round(avg(SavingsPlanDiscount), 3) by Date_s, MeterAndRegion\r\n| order by todatetime(Date_s), SavingsPlanDiscount\r\n| extend SavingsPlanHourlyBalance = row_cumsum(HourlySavingsPlanCost, prev(Date_s) != Date_s)\r\n| extend CutOff = iif(prev(Date_s) != Date_s or prev(SavingsPlanHourlyBalance, 1, 0) < HourlyCommitment, false, true)\r\n| where not(CutOff)\r\n| extend SavingsAllocationPercentage = iif(SavingsPlanHourlyBalance < HourlyCommitment, 1.0, 1 - (SavingsPlanHourlyBalance - HourlyCommitment) / HourlySavingsPlanCost)\r\n| summarize SavedAmount=sum((HourlyOnDemandCost - HourlySavingsPlanCost) * SavingsAllocationPercentage)*24, AmountWouldSpendOnDemand=(sum(HourlyOnDemandCost * SavingsAllocationPercentage))*24 by Date_s\r\n| extend SavedPercentage = SavedAmount / AmountWouldSpendOnDemand\r\n| project SavedPercentage, todatetime(Date_s)", "size": 1, "aggregation": 3, "title": "Estimated savings (percentage)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" }, { "seriesName": "SavingsPlanUsagePercentage", "label": "Efficiency" } ], "ySettings": { "numberFormatSettings": { "unit": 17, "options": { "style": "percent", "useGrouping": true } } } } }, "name": "onDemandUsageSavingsPlansPercentageSimulation" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let HourlyCommitment = {HourlyCommitment};\r\nlet LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandPrice;\r\nlet LinuxSavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and MeterSubCategory_s !endswith \"Windows\" and Term_s == iif('{SavingsPlanTerm}' == '3 Years','P3Y','P1Y')\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet SavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and Term_s == iif('{SavingsPlanTerm}' == '3 Years','P3Y','P1Y')\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxSavingsPlanPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend SavingsPlanPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, SavingsPlanPrice;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| where SubscriptionName in ({Subscriptions})\r\n| join kind=inner ( OnDemandPriceSheet ) on $left.MeterId_g == $right.MeterID_g\r\n| extend ResourceLocation_s=tolower(ResourceLocation_s)\r\n| project Date_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ArmRegion=ResourceLocation_s, MeterId=MeterId_g, ResourceId, OnDemandPrice=OnDemandPrice, SubscriptionName\r\n| join kind=inner ( SavingsPlanPriceSheet ) on $left.MeterId == $right.MeterID_g\r\n| extend SavingsPlanCost = QtyHours * SavingsPlanPrice\r\n| extend OnDemandCost = QtyHours * OnDemandPrice\r\n| extend SavingsPlanDiscount = 1 - SavingsPlanPrice / OnDemandPrice\r\n| extend MeterAndRegion = strcat(BillingMeter, ' - ', ArmRegion)\r\n| summarize HourlySavingsPlanCost=sum(SavingsPlanCost)/24, HourlyOnDemandCost=sum(OnDemandCost)/24, SavingsPlanDiscount=round(avg(SavingsPlanDiscount), 3) by Date_s, MeterAndRegion\r\n| order by todatetime(Date_s), SavingsPlanDiscount\r\n| extend SavingsPlanHourlyBalance = row_cumsum(HourlySavingsPlanCost, prev(Date_s) != Date_s)\r\n| extend CutOff = iif(prev(Date_s) != Date_s or prev(SavingsPlanHourlyBalance, 1, 0) < HourlyCommitment, false, true)\r\n| where not(CutOff)\r\n| extend SavingsAllocationPercentage = iif(SavingsPlanHourlyBalance < HourlyCommitment, 1.0, 1 - (SavingsPlanHourlyBalance - HourlyCommitment) / HourlySavingsPlanCost)\r\n| summarize AmountSpentOnSavingsPlan=(sum(HourlySavingsPlanCost * SavingsAllocationPercentage))*24 by todatetime(Date_s)\r\n| extend SavingsPlanUsagePercentage = AmountSpentOnSavingsPlan / ({HourlyCommitment}*24)\r\n| project-away AmountSpentOnSavingsPlan", "size": 1, "aggregation": 3, "title": "Estimated efficiency", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" }, { "seriesName": "SavingsPlanUsagePercentage", "label": "Savings Plan Usage" } ], "customThresholdLine": "1", "customThresholdLineStyle": 4, "ySettings": { "numberFormatSettings": { "unit": 17, "options": { "style": "percent", "useGrouping": true } } } } }, "name": "onDemandUsageSavingsPlansEfficiencySimulation" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "SavingsPlans" }, "name": "savingsPlansGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 1, "content": { "json": "Enter the number of reserved instances for a given size and region to estimate your savings according to the lookback period.", "style": "info" }, "name": "text - 5" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "5955658a-2b0d-4e33-b68d-a55f0792bd48", "version": "KqlParameterItem/1.0", "name": "ReservationTerm", "label": "Reservation Term", "type": 10, "description": "Reservation term to get the Reservations prices from", "isRequired": true, "typeSettings": { "additionalResourceOptions": [] }, "jsonData": "[\"1 Year\", \"3 Years\"]", "timeContext": { "durationMs": 86400000 }, "value": "3 Years" }, { "version": "KqlParameterItem/1.0", "name": "UseISF", "label": "Instance Size Flexibility?", "type": 10, "isRequired": true, "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "jsonData": "[\"Yes\", \"No\"]", "timeContext": { "durationMs": 86400000 }, "value": "No", "id": "a3a21a9b-d89d-4d30-a3fd-0ec6a1896602" }, { "id": "8b97a186-0656-4edc-bc7f-d2077ba9ade0", "version": "KqlParameterItem/1.0", "name": "VMSize", "label": "Size", "type": 2, "isRequired": true, "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| where SubscriptionName in ({Subscriptions})\r\n| extend ServiceType = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| join kind=inner ( ISFGroups ) on $left.ServiceType == $right.ArmSKUName\r\n| distinct ServiceType\r\n| order by ServiceType asc", "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "timeContext": { "durationMs": 0 }, "timeContextFromParameter": "LookbackPeriod", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": null }, { "id": "52beb8df-e32a-4ce1-b38e-c363c2a4714f", "version": "KqlParameterItem/1.0", "name": "VMRegion", "label": "Region", "type": 2, "isRequired": true, "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| where SubscriptionName in ({Subscriptions})\r\n| extend ServiceType = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| join kind=inner ( ISFGroups ) on $left.ServiceType == $right.ArmSKUName\r\n| where ServiceType == '{VMSize}'\r\n| extend ResourceLocation_s=tolower(ResourceLocation_s)\r\n| distinct ResourceLocation_s\r\n| order by ResourceLocation_s asc", "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "timeContext": { "durationMs": 2592000000 }, "timeContextFromParameter": "LookbackPeriod", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": null }, { "id": "fa4fe488-5590-4df7-8a22-a3f9639892e6", "version": "KqlParameterItem/1.0", "name": "VMQuantity", "label": "Quantity", "type": 1, "isRequired": true, "timeContext": { "durationMs": 0 }, "timeContextFromParameter": "LookbackPeriod", "value": "" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "reservationsParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = materialize(externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName));\r\nlet SelectedISFGroup = toscalar(ISFGroups | where ArmSKUName == '{VMSize}' | project ISFGroup);\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| where SubscriptionName in ({Subscriptions})\r\n| extend ServiceType = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| join kind=inner ( ISFGroups ) on $left.ServiceType == $right.ArmSKUName\r\n| project Date_s, QtyHours=todouble(Quantity_s), ArmRegion=tolower(ResourceLocation_s), MeterId=MeterId_g, ResourceId, ServiceType, SubscriptionName, ISFGroup\r\n| where iif('{UseISF}' == 'Yes', ISFGroup == SelectedISFGroup, ServiceType == '{VMSize}')\r\n| where ArmRegion == '{VMRegion}'\r\n| extend ReservationSKU = strcat(ServiceType, ' ', ArmRegion)\r\n| summarize HourlyVMs=sum(QtyHours/24) by bin(todatetime(Date_s), 1d), ReservationSKU", "size": 1, "aggregation": 3, "title": "Average On-Demand usage (VMs #)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" } ] } }, "name": "onDemandUsageReservationsPerspective" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = materialize(externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName));\r\nlet SelectedISFGroup = toscalar(ISFGroups | where ArmSKUName == '{VMSize}' | project ISFGroup);\r\nlet ISFRatio = toscalar(ISFGroups | where ArmSKUName == '{VMSize}' | project Ratio);\r\nlet TermDivider = iif('{ReservationTerm}' == '3 Years', 3, 1);\r\nlet VMQuantity = {VMQuantity};\r\nlet LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandPrice;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| where SubscriptionName in ({Subscriptions})\r\n| extend ServiceType = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| join kind=inner ( OnDemandPriceSheet ) on $left.MeterId_g == $right.MeterID_g\r\n| project Date_s, QtyHours=todouble(Quantity_s), ArmRegion=tolower(ResourceLocation_s), ServiceType, MeterId=MeterId_g, ResourceId, OnDemandPrice, SubscriptionName\r\n| join kind=inner ( ISFGroups ) on $left.ServiceType == $right.ArmSKUName\r\n| where iif('{UseISF}' == 'Yes', ISFGroup == SelectedISFGroup, ServiceType == '{VMSize}')\r\n| where ArmRegion == '{VMRegion}'\r\n| join kind=inner ( \r\n AzureOptimizationReservationsPriceV1_CL\r\n | where TimeGenerated > ago(14d)\r\n | where serviceName_s == 'Virtual Machines' and reservationTerm_s == '{ReservationTerm}'\r\n | extend ReservationPrice = todouble(replace_string(unitPrice_s,',','.'))/TermDivider/12/730\r\n | summarize arg_max(TimeGenerated, ReservationPrice) by ArmRegion=tolower(armRegionName_s), ServiceType=tolower(armSkuName_s)\r\n) on ArmRegion and ServiceType\r\n| order by todatetime(Date_s)\r\n| extend RIConsumed = row_cumsum(QtyHours * Ratio / ISFRatio / 24, prev(Date_s) != Date_s)\r\n| extend CutOff = iif(prev(Date_s) != Date_s or prev(RIConsumed, 1, 0) < VMQuantity, false, true)\r\n| where not(CutOff)\r\n| extend RICost = QtyHours * ReservationPrice\r\n| extend OnDemandCost = QtyHours * OnDemandPrice\r\n| summarize DailyRICost=sum(RICost), DailyOnDemandCost=sum(OnDemandCost), MaxRIConsumed=max(RIConsumed) by Date_s\r\n| extend RIAllocationPercentage = iif(MaxRIConsumed < VMQuantity, 1.0, 1 - (MaxRIConsumed - VMQuantity) / MaxRIConsumed)\r\n| project todatetime(Date_s), SavedAmount = (DailyOnDemandCost-DailyRICost) * RIAllocationPercentage\r\n", "size": 1, "title": "Estimated savings (in your billing currency)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" }, { "seriesName": "SavedAmount", "label": "Saved Amount" } ] } }, "name": "onDemandUsageRISimulation" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = materialize(externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName));\r\nlet SelectedISFGroup = toscalar(ISFGroups | where ArmSKUName == '{VMSize}' | project ISFGroup);\r\nlet ISFRatio = toscalar(ISFGroups | where ArmSKUName == '{VMSize}' | project Ratio);\r\nlet TermDivider = iif('{ReservationTerm}' == '3 Years', 3, 1);\r\nlet VMQuantity = {VMQuantity};\r\nlet LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandPrice;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| where SubscriptionName in ({Subscriptions})\r\n| extend ServiceType = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| join kind=inner ( OnDemandPriceSheet ) on $left.MeterId_g == $right.MeterID_g\r\n| project Date_s, QtyHours=todouble(Quantity_s), ArmRegion=tolower(ResourceLocation_s), ServiceType, MeterId=MeterId_g, ResourceId, OnDemandPrice, SubscriptionName\r\n| join kind=inner ( ISFGroups ) on $left.ServiceType == $right.ArmSKUName\r\n| where iif('{UseISF}' == 'Yes', ISFGroup == SelectedISFGroup, ServiceType == '{VMSize}')\r\n| where ArmRegion == '{VMRegion}'\r\n| join kind=inner ( \r\n AzureOptimizationReservationsPriceV1_CL\r\n | where TimeGenerated > ago(14d)\r\n | where serviceName_s == 'Virtual Machines' and reservationTerm_s == '{ReservationTerm}'\r\n | extend ReservationPrice = todouble(replace_string(unitPrice_s,',','.'))/TermDivider/12/730\r\n | summarize arg_max(TimeGenerated, ReservationPrice) by ArmRegion=tolower(armRegionName_s), ServiceType=tolower(armSkuName_s)\r\n) on ArmRegion and ServiceType\r\n| order by todatetime(Date_s)\r\n| extend RIConsumed = row_cumsum(QtyHours * Ratio / ISFRatio / 24, prev(Date_s) != Date_s)\r\n| extend CutOff = iif(prev(Date_s) != Date_s or prev(RIConsumed, 1, 0) < VMQuantity, false, true)\r\n| where not(CutOff)\r\n| extend RICost = QtyHours * ReservationPrice\r\n| extend OnDemandCost = QtyHours * OnDemandPrice\r\n| summarize DailyRICost=sum(RICost), DailyOnDemandCost=sum(OnDemandCost), MaxRIConsumed=max(RIConsumed) by Date_s\r\n| extend RIAllocationPercentage = iif(MaxRIConsumed < VMQuantity, 1.0, 1 - (MaxRIConsumed - VMQuantity) / MaxRIConsumed)\r\n| project Date_s, SavedAmount = (DailyOnDemandCost-DailyRICost) * RIAllocationPercentage, AmountWouldSpendOnDemand = DailyOnDemandCost * RIAllocationPercentage\r\n| extend SavedPercentage = SavedAmount / AmountWouldSpendOnDemand\r\n| project SavedPercentage, todatetime(Date_s)", "size": 1, "aggregation": 3, "title": "Estimated savings (percentage)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" }, { "seriesName": "SavingsPlanUsagePercentage", "label": "Efficiency" } ], "ySettings": { "numberFormatSettings": { "unit": 17, "options": { "style": "percent", "useGrouping": true } } } } }, "name": "onDemandUsageRIPercentageSimulation" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = materialize(externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName));\r\nlet SelectedISFGroup = toscalar(ISFGroups | where ArmSKUName == '{VMSize}' | project ISFGroup);\r\nlet ISFRatio = toscalar(ISFGroups | where ArmSKUName == '{VMSize}' | project Ratio);\r\nlet TermDivider = iif('{ReservationTerm}' == '3 Years', 3, 1);\r\nlet VMQuantity = {VMQuantity};\r\nlet LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandPrice;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| where SubscriptionName in ({Subscriptions})\r\n| extend ServiceType = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| join kind=inner ( OnDemandPriceSheet ) on $left.MeterId_g == $right.MeterID_g\r\n| project Date_s, QtyHours=todouble(Quantity_s), ArmRegion=tolower(ResourceLocation_s), ServiceType, MeterId=MeterId_g, ResourceId, OnDemandPrice, SubscriptionName\r\n| join kind=inner ( ISFGroups ) on $left.ServiceType == $right.ArmSKUName\r\n| where iif('{UseISF}' == 'Yes', ISFGroup == SelectedISFGroup, ServiceType == '{VMSize}')\r\n| where ArmRegion == '{VMRegion}'\r\n| join kind=inner ( \r\n AzureOptimizationReservationsPriceV1_CL\r\n | where TimeGenerated > ago(14d)\r\n | where serviceName_s == 'Virtual Machines' and reservationTerm_s == '{ReservationTerm}'\r\n | extend ReservationPrice = todouble(replace_string(unitPrice_s,',','.'))/TermDivider/12/730\r\n | summarize arg_max(TimeGenerated, ReservationPrice) by ArmRegion=tolower(armRegionName_s), ServiceType=tolower(armSkuName_s)\r\n) on ArmRegion and ServiceType\r\n| order by todatetime(Date_s)\r\n| extend RIConsumed = row_cumsum(QtyHours * Ratio / ISFRatio / 24, prev(Date_s) != Date_s)\r\n| extend CutOff = iif(prev(Date_s) != Date_s or prev(RIConsumed, 1, 0) < VMQuantity, false, true)\r\n| where not(CutOff)\r\n| summarize MaxRIConsumed=max(RIConsumed) by todatetime(Date_s)\r\n| extend RIUsagePercentage = iif(MaxRIConsumed >= VMQuantity, 1.0, MaxRIConsumed / VMQuantity)\r\n| project-away MaxRIConsumed", "size": 1, "aggregation": 3, "title": "Estimated efficiency", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" }, { "seriesName": "SavingsPlanUsagePercentage", "label": "Efficiency" }, { "seriesName": "RIUsagePercentage", "label": "Reservation Usage" } ], "customThresholdLine": "1", "customThresholdLineStyle": 4, "ySettings": { "numberFormatSettings": { "unit": 17, "options": { "style": "percent", "useGrouping": true } } } } }, "name": "onDemandUsageRIEfficiencySimulation" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Reservations" }, "name": "reservationsGroup" } ], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" } ================================================ FILE: views/workbooks/benefits-usage.bicep ================================================ @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') param workbookDisplayName string = 'Benefits Usage' @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') param workbookType string = 'workbook' @description('The id of resource instance to which the workbook will be associated') param workbookSourceId string @description('The unique guid for this workbook instance') param workbookId string = '4730b9ab-9f13-4c28-a999-bcce218b283d' param resourceTags object param resourceGroupLocation string = resourceGroup().location resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { name: workbookId location: resourceGroupLocation tags: resourceTags kind: 'shared' properties: { displayName: workbookDisplayName serializedData: string(loadJsonContent('benefits-usage.json')) version: '1.0' sourceId: workbookSourceId category: workbookType } dependsOn: [] } output workbookId string = workbookId_resource.id ================================================ FILE: views/workbooks/benefits-usage.json ================================================ { "version": "Notebook/1.0", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "b58b4eb8-5821-44d2-bc7e-54054df27320", "version": "KqlParameterItem/1.0", "name": "LookbackPeriod", "label": "Lookback Period", "type": 4, "isRequired": true, "typeSettings": { "selectableValues": [ { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ], "allowCustom": true }, "timeContext": { "durationMs": 86400000 }, "value": { "durationMs": 2592000000 } } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "parameters - 0" }, { "type": 1, "content": { "json": "Unless specified, usage values unit corresponds to your billing currency. **Only applies to Virtual Machines usage in Azure Global**.", "style": "info" }, "name": "text - 7" }, { "type": 1, "content": { "json": "If below tabs are reporting query errors, you must set up Pricesheet exports. See more details [here](https://aka.ms/AzureOptimizationEngine/commitmentssetup).", "style": "warning" }, "name": "text - 8" }, { "type": 11, "content": { "version": "LinkItem/1.0", "style": "tabs", "links": [ { "id": "b171fed6-fb31-436a-bfab-c2a9d99bda88", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Overview", "subTarget": "Overview", "style": "link" }, { "id": "b26e72a1-167d-4449-a8b6-6665814331a3", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Reservations", "subTarget": "Reservations", "style": "link" }, { "id": "1d33e1ca-6d19-4b74-8903-00c5671b8f87", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Savings Plans", "subTarget": "SavingsPlans", "style": "link" }, { "id": "0b891e89-0bbe-41f0-bfd7-b70180ae2f22", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Spot", "subTarget": "Spot", "style": "link" }, { "id": "acf819ca-ec0e-45be-9dbc-4f5703e00c7a", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "On-Demand", "subTarget": "OnDemand", "style": "link" } ] }, "name": "analysisTabs" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 1, "content": { "json": "This chart shows you what is the pricing model proportion across all your VMs usage: Reservation, Savings Plan, Spot, or On-Demand (i.e., Pay-As-You-Go). All Reservations and Savings Plan usage is presented at amortized prices.", "style": "info" }, "name": "preambleText" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| extend FullCost = todouble(Quantity_s) * todouble(EffectivePrice_s)\r\n| summarize FullCost=sum(FullCost) by PricingModel, bin(todatetime(Date_s), 1d)", "size": 1, "title": "Pricing Model usage", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" }, { "seriesName": "Spot", "color": "greenDarkDark" }, { "seriesName": "SavingsPlan", "color": "yellow" } ] } }, "name": "pricingModelOverTime" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Overview" }, "name": "overviewGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 1, "content": { "json": "This chart allows you to compare your Reservations usage with scenarios in which the VMs were being consumed in other pricing models and understand what you are saving with Reservations. Reservations usage is presented at Reservation prices (i.e., amortized cost). This means that, for example, if you see in the chart below 100 USD for Reservation and 120 USD for On-Demand in a specific day, you are saving 20 USD against the On-Demand price (Pay-As-You-Go). Likewise for the Reservation vs. Savings Plan comparison chart.", "style": "info" }, "name": "text - 2" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "5955658a-2b0d-4e33-b68d-a55f0792bd48", "version": "KqlParameterItem/1.0", "name": "ReservationTerm", "label": "Reservation Term", "type": 10, "description": "Reservation term to get the Reservations prices from", "isRequired": true, "typeSettings": { "additionalResourceOptions": [] }, "jsonData": "[\"1 Year\", \"3 Years\"]", "timeContext": { "durationMs": 86400000 }, "value": "3 Years" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "parameters - 3" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let YearFactor = toscalar(iif('{ReservationTerm}' == '3 Years',3,1));\r\nlet LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandPrice;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'Reservation'\r\n| extend VmSize = tostring(parse_json(AdditionalInfo_s).ServiceType)\r\n| project Date_s, VmSize, ReservationName_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ArmRegion=tolower(ResourceLocation_s), ResourceId, MeterId_g\r\n| join kind=inner ( OnDemandPriceSheet ) on $left.MeterId_g == $right.MeterID_g\r\n| join kind=inner ( \r\n AzureOptimizationReservationsPriceV1_CL\r\n | where TimeGenerated > ago(14d)\r\n | where serviceName_s == 'Virtual Machines' and reservationTerm_s == '{ReservationTerm}'\r\n | extend ReservationPrice = todouble(replace_string(unitPrice_s, ',', '.'))/YearFactor/12/730\r\n | distinct ReservationMeter=meterName_s, VmSize=armSkuName_s, ReservationPrice, ArmRegion=tolower(armRegionName_s)\r\n) on VmSize and ArmRegion\r\n| project-away VmSize1, ArmRegion1\r\n| extend OnDemandCost = QtyHours * OnDemandPrice, ReservationsCost = QtyHours * ReservationPrice\r\n| summarize Reservation=sum(ReservationsCost), OnDemand=sum(OnDemandCost) by bin(todatetime(Date_s), 1d)", "size": 1, "title": "Reservation usage vs. On-Demand prices", "noDataMessage": "There is no Reservations consumption or Pricesheet data for the selected lookback period", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "unstackedbar", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" } ] } }, "name": "reservationsComparisonWithOnDemand" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let YearFactor = toscalar(iif('{ReservationTerm}' == '3 Years',3,1));\r\nlet LinuxSavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and MeterSubCategory_s !endswith \"Windows\" and Term_s == iif('{ReservationTerm}' == '3 Years','P3Y','P1Y')\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet SavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and Term_s == iif('{ReservationTerm}' == '3 Years','P3Y','P1Y')\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxSavingsPlanPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend SavingsPlanPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, SavingsPlanPrice;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'Reservation'\r\n| extend VmSize = tostring(parse_json(AdditionalInfo_s).ServiceType)\r\n| project Date_s, VmSize, ReservationName_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ArmRegion=tolower(ResourceLocation_s), MeterId=MeterId_g, ResourceId\r\n| join kind=inner ( \r\n AzureOptimizationReservationsPriceV1_CL\r\n | where TimeGenerated > ago(14d)\r\n | where serviceName_s == 'Virtual Machines' and reservationTerm_s == '{ReservationTerm}'\r\n | extend ReservationPrice = todouble(replace_string(unitPrice_s, ',', '.'))/YearFactor/12/730\r\n | distinct ReservationMeter=meterName_s, VmSize=armSkuName_s, ReservationPrice, ArmRegion=tolower(armRegionName_s)\r\n) on VmSize and ArmRegion\r\n| project-away VmSize1, ArmRegion1\r\n| extend ReservationsCost = QtyHours * ReservationPrice\r\n| join kind=inner ( SavingsPlanPriceSheet ) on $left.MeterId == $right.MeterID_g\r\n| extend SavingsPlanCost = QtyHours * SavingsPlanPrice \r\n| summarize Reservation=sum(ReservationsCost), SavingsPlan=sum(SavingsPlanCost) by bin(todatetime(Date_s), 1d)", "size": 1, "title": "Reservation usage vs. Savings Plan prices", "noDataMessage": "There is no Reservations consumption or Pricesheet data for the selected lookback period", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "unstackedbar", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "SavingsPlan", "color": "yellow" } ] } }, "name": "reservationsToSavingsPlanTradeInAnalysis" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Reservations" }, "name": "reservationsGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 1, "content": { "json": "This chart allows you to compare your Savings Plans usage with scenarios in which the VMs were being consumed in other pricing models and understand what you are saving with Savings Plans. Savings Plans usage is presented at Savings Plan prices (i.e., amortized cost). This means that, for example, if you see in the chart below 100 USD for Savings Plan and 120 USD for On-Demand in a specific day, you are saving 20 USD against the On-Demand price (Pay-As-You-Go).\r\n\r\n", "style": "info" }, "name": "text - 1" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "977209de-fdec-4c84-86fd-7b0815aa71e1", "version": "KqlParameterItem/1.0", "name": "SavingsPlanTerm", "label": "Savings Plan Term", "type": 10, "description": "Savings Plan term to get the Savings Plan prices from", "isRequired": true, "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "jsonData": "[\"1 Year\", \"3 Years\"]", "timeContext": { "durationMs": 86400000 }, "value": "3 Years" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "parameters - 2" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandPrice;\r\nlet LinuxSavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and MeterSubCategory_s !endswith \"Windows\" and Term_s == iif('{SavingsPlanTerm}' == '3 Years','P3Y','P1Y')\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet SavingsPlanPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and Term_s == iif('{SavingsPlanTerm}' == '3 Years','P3Y','P1Y')\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxSavingsPlanPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend SavingsPlanPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, SavingsPlanPrice;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'SavingsPlan'\r\n| project Date_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ArmRegion=tolower(ResourceLocation_s), MeterId=MeterId_g, ResourceId\r\n| join kind=inner ( OnDemandPriceSheet ) on $left.MeterId == $right.MeterID_g\r\n| join kind=inner ( SavingsPlanPriceSheet ) on $left.MeterId == $right.MeterID_g\r\n| extend OnDemandCost = QtyHours * OnDemandPrice, SavingsPlanCost = QtyHours * SavingsPlanPrice\r\n| summarize SavingsPlan=sum(SavingsPlanCost), OnDemand=sum(OnDemandCost) by bin(todatetime(Date_s), 1d)", "size": 1, "title": "Savings Plans usage vs. On-Demand prices", "noDataMessage": "There is no Savings Plans consumption or Pricesheet data for the selected lookback period", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "unstackedbar", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" }, { "seriesName": "SavingsPlan", "color": "yellow" } ] } }, "name": "savingsPlansComparisonWithOnDemand" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "SavingsPlans" }, "name": "savingsPlansGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "cb40bd72-bb9a-4116-8edd-c5b3c4f2d224", "version": "KqlParameterItem/1.0", "name": "OnDemandCostFactor", "label": "Default Price Multiplier", "type": 1, "description": "Price multiplier for the cases where there is not direct match between Spot meter and pricesheet meter", "isRequired": true, "isHiddenWhenLocked": true, "timeContext": { "durationMs": 86400000 }, "value": "4" } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "spotParameters" }, { "type": 1, "content": { "json": "This chart allows you to compare your Spot usage with scenarios in which the VMs were being consumed in other pricing models and understand what you are saving with Spot. Spot usage is presented at Spot prices. This means that, for example, if you see in the chart below 100 USD for Spot and 120 USD for On-Demand in a specific day, you are saving 20 USD against the On-Demand price (Pay-As-You-Go).", "style": "info" }, "name": "text - 2" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let OnDemandCostFactor = {OnDemandCostFactor};\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'Spot'\r\n| extend MeterRegion = tostring(split(ProductName_s, ' - ')[2])\r\n| extend BillingMeter = substring(MeterName_s, 0, indexof(MeterName_s, ' Spot'))\r\n| project Date_s, QtyHours=todouble(Quantity_s), BillingMeter, MeterRegion, ResourceId, SpotPrice=todouble(EffectivePrice_s)\r\n| join kind=leftouter ( \r\n AzureOptimizationPricesheetV1_CL\r\n | where TimeGenerated > ago(14d)\r\n | where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !has 'Windows'\r\n | extend UnitHrs = toint(substring(UnitOfMeasure_s, 0, indexof(UnitOfMeasure_s, 'Hour')-1))\r\n | extend OnDemandPrice = todouble(UnitPrice_s)/UnitHrs\r\n | summarize OnDemandPrice=max(OnDemandPrice) by BillingMeter=MeterName_s, MeterRegion=MeterRegion_s\r\n) on BillingMeter, MeterRegion\r\n| extend OnDemandCost = iif(isnotempty(OnDemandPrice), QtyHours * OnDemandPrice, QtyHours * SpotPrice * OnDemandCostFactor), SpotCost = QtyHours * SpotPrice\r\n| summarize Spot=sum(SpotCost), OnDemand=sum(OnDemandCost) by bin(todatetime(Date_s), 1d)", "size": 1, "title": "Spot usage vs. On-Demand prices", "noDataMessage": "There is no Savings Plans consumption for the selected lookback period", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "unstackedbar", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" }, { "seriesName": "Spot", "color": "greenDarkDark" } ] } }, "name": "spotComparisonWithOnDemand" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Spot" }, "name": "spotGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 1, "content": { "json": "Identify the potential hourly commitments for either Savings Plans or Reservations.\r\n\r\nThe first chart shows you the actual hourly cost at On-Demand prices. You can use it as a reference to compare with different Savings Plans commitment terms.\r\n\r\nFor the Savings Plan analysis, the chart shows the average hourly On-Demand (PAYG) usage in your currency (at Savings Plans prices) for each Azure subscription. It shows you the hourly amount you need to commit for (at Savings Plans prices) to cover the On-Demand usage.\r\n\r\nFor the vCPUs (Reservations) analysis, the chart shows the average hourly On-Demand (PAYG) usage in vCPUs for each VM size family. For example, if VM size family Dv5 in West Europe has 48 vCPUs of average hourly On-Demand usage, you need a Reservation commitment of 24 instances of the D2_v5 size (24x2=48 vCPUs) to fully cover its On-Demand usage with Reservation discounts.\r\n", "style": "upsell" }, "name": "text - 2" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "95342903-8525-4dae-af71-11eaa72be92a", "version": "KqlParameterItem/1.0", "name": "SavingsPlanTerm", "label": "Savings Plan Term", "type": 10, "description": "Savings Plan term to get the Savings Plan prices from", "isRequired": true, "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "jsonData": "[\"1 Year\", \"3 Years\"]", "timeContext": { "durationMs": 86400000 }, "value": "3 Years" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "parameters - 3" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| project Date_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ArmRegion=ResourceLocation_s, MeterId=MeterId_g, ResourceId, OnDemandPrice=todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s)), SubscriptionName\r\n| extend OnDemandCost = QtyHours * OnDemandPrice\r\n| summarize HourlyCost=sum(OnDemandCost)/24 by bin(todatetime(Date_s), 1d), SubscriptionName", "size": 1, "aggregation": 3, "title": "Average On-Demand hourly usage (actual cost)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" } ] } }, "name": "onDemandUsageAsIs" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| project Date_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ArmRegion=tolower(ResourceLocation_s), MeterId=MeterId_g, ResourceId, SubscriptionName\r\n| join kind=inner ( \r\n AzureOptimizationPricesheetV1_CL\r\n | where TimeGenerated > ago(14d)\r\n | where MeterCategory_s == 'Virtual Machines' and PriceType_s in ('Savings Plan','SavingsPlan') and Term_s == iif('{SavingsPlanTerm}' == '3 Years', 'P3Y', 'P1Y')\r\n | extend UnitHrs = toint(substring(UnitOfMeasure_s, 0, indexof(UnitOfMeasure_s, 'Hour')-1))\r\n | extend SavingsPlanPrice = todouble(UnitPrice_s)/UnitHrs\r\n | summarize SavingsPlanPrice=max(SavingsPlanPrice) by MeterId=MeterID_g\r\n) on MeterId\r\n| extend SavingsPlanCost = QtyHours * SavingsPlanPrice\r\n| summarize HourlyCost=sum(SavingsPlanCost)/24 by bin(todatetime(Date_s), 1d), SubscriptionName", "size": 1, "aggregation": 3, "title": "Average On-Demand hourly usage (Savings Plan prices)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" } ] } }, "name": "onDemandUsageSavingsPlansPerspective" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend SubscriptionName = iif(isnotempty(SubscriptionName_s), SubscriptionName_s, SubscriptionName)\r\n| extend VCPUs = toint(parse_json(AdditionalInfo_s).VCPUs)\r\n| extend MeterRegion = tostring(split(ProductName_s, ' - ')[2])\r\n| project Date_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ArmRegion=ResourceLocation_s, MeterRegion, MeterId=MeterId_g, ResourceId, SubscriptionName, VCPUs, MeterSubCategory_s\r\n| extend ReservationSKU = strcat(MeterSubCategory_s, ' ', MeterRegion)\r\n| summarize HourlyVCPUs=sum(VCPUs*QtyHours/24) by bin(todatetime(Date_s), 1d), ReservationSKU", "size": 1, "aggregation": 3, "title": "Average On-Demand hourly usage (vCPUs for VM Reservations)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Reservation", "color": "green" }, { "seriesName": "OnDemand", "color": "red" } ] } }, "name": "onDemandUsageReservationsPerspective" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "OnDemand" }, "name": "ondemandPlansGroup" } ], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" } ================================================ FILE: views/workbooks/blockblobstorage-usage.bicep ================================================ @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') param workbookDisplayName string = 'Block Blob Storage Usage' @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') param workbookType string = 'workbook' @description('The id of resource instance to which the workbook will be associated') param workbookSourceId string @description('The unique guid for this workbook instance') param workbookId string = '871eb144-c8bb-4824-90c3-f84fe197933a' param resourceTags object param resourceGroupLocation string = resourceGroup().location resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { name: workbookId location: resourceGroupLocation tags: resourceTags kind: 'shared' properties: { displayName: workbookDisplayName serializedData: string(loadJsonContent('blockblobstorage-usage.json')) version: '1.0' sourceId: workbookSourceId category: workbookType } dependsOn: [] } output workbookId string = workbookId_resource.id ================================================ FILE: views/workbooks/blockblobstorage-usage.json ================================================ { "version": "Notebook/1.0", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "8ef006f0-db8d-40b2-b51f-7a62b03e235e", "version": "KqlParameterItem/1.0", "name": "LookbackPeriod", "label": "Lookback Period", "type": 4, "isRequired": true, "typeSettings": { "selectableValues": [ { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2419200000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ], "allowCustom": true }, "timeContext": { "durationMs": 86400000 }, "value": { "durationMs": 2592000000 } }, { "id": "e560c503-9664-4cea-977e-8dbec62ddd64", "version": "KqlParameterItem/1.0", "name": "Subscriptions", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "resourcecontainers\r\n| where type == 'microsoft.resources/subscriptions'\r\n| project subscriptionId, name\r\n| order by name asc", "crossComponentResources": [ "value::tenant" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ] }, "timeContext": { "durationMs": 86400000 }, "queryType": 1, "resourceType": "microsoft.resources/tenants", "value": [ "value::all" ] }, { "id": "d91ac170-ca28-403d-b06d-164b4239aaa3", "version": "KqlParameterItem/1.0", "name": "Currency", "type": 1, "timeContext": { "durationMs": 86400000 }, "value": "EUR" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "globalParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| summarize ['Usage Start']=min(todatetime(Date_s)), ['Usage End']=max(todatetime(Date_s))\r\n| extend ['Usage Days'] = toint(format_timespan(['Usage End']-['Usage Start'],'d'))", "size": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "table", "tileSettings": { "showBorder": false } }, "customWidth": "50", "name": "usagePeriod" }, { "type": 1, "content": { "json": "Only Block Blob storage usage is presented (Files, Queues, Page Blobs, etc. are not included). Costs are presented at the on-demand price, i.e., as if no Storage reservations were applied to usage.", "style": "info" }, "name": "disclaimer" }, { "type": 11, "content": { "version": "LinkItem/1.0", "style": "tabs", "links": [ { "id": "1dcb642e-3696-4e44-9d71-00d434709cee", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Overview", "subTarget": "Overview", "style": "link" }, { "id": "980caea9-179c-4bcb-b501-bdb68d4a578c", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Standard v2", "subTarget": "StorageV2", "style": "link" }, { "id": "642cdd5a-b00a-4713-8fcf-41da789a3a22", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Standard v1", "subTarget": "StorageV1", "style": "link" }, { "id": "ae51c121-2917-43b1-bea1-9a519847c4a9", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Premium", "subTarget": "Premium", "style": "link" } ] }, "name": "areaTabs" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| extend StorageOffer = iif(MeterSubCategory_s == 'General Block Blob', 'Standard v1', iif(MeterSubCategory_s has 'Premium', 'Premium', 'Standard v2'))\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize Cost=sum(FullCost) by StorageOffer", "size": 4, "title": "Storage Offers ({Currency:value})", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "33", "name": "storageOffers" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| extend FileStructure = iif(MeterSubCategory_s has 'Hierarchical Namespace', 'HNS', 'Flat')\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize Cost=sum(FullCost) by FileStructure", "size": 4, "title": "File Structure ({Currency:value})", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "33", "name": "fileStructure" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let StorageReplication = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterName_s has 'Data Stored'\r\n| extend Replication = tostring(split(MeterName_s, ' ')[-3])\r\n| distinct ResourceId, Replication;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| join kind=inner (StorageReplication) on ResourceId\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize Cost=sum(FullCost) by Replication\r\n\r\n\r\n", "size": 4, "title": "Replication ({Currency:value})", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "33", "name": "replication" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let StorageReplication = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterName_s has 'Data Stored'\r\n| extend Replication = tostring(split(MeterName_s, ' ')[-3])\r\n| distinct ResourceId, Replication;\r\nlet StorageSize = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterName_s has 'Data Stored'\r\n| summarize SizeGB=sum(todouble(Quantity_s)) by ResourceId, Date_s\r\n| summarize arg_max(todatetime(Date_s), SizeGB) by ResourceId\r\n| project SizeGB=round(SizeGB*30), ResourceId;\r\nlet StorageTransactions = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterName_s has 'Operations'\r\n| summarize Transactions=round(sum(todouble(Quantity_s))*10000) by ResourceId;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| extend StorageOffer = iif(MeterSubCategory_s == 'General Block Blob', 'Standard v1', iif(MeterSubCategory_s has 'Premium', 'Premium', 'Standard v2'))\r\n| join kind=leftouter (StorageReplication) on ResourceId\r\n| join kind=leftouter (StorageSize) on ResourceId\r\n| join kind=leftouter (StorageTransactions) on ResourceId\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| extend FileStructure = iif(MeterSubCategory_s has 'Hierarchical Namespace', 'HNS', 'Flat')\r\n| summarize ['Cost ({Currency:value})']=round(sum(FullCost)) by ['Storage Account']=ResourceId, Offer=StorageOffer, ['File Structure']=FileStructure, Replication, ['Size (GB)']=SizeGB, Transactions\r\n| order by ['Cost ({Currency:value})']\r\n\r\n\r\n", "size": 0, "showAnalytics": true, "title": "Storage Accounts List", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "table", "gridSettings": { "formatters": [ { "columnMatch": "Size", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Transactions", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Cost (EUR)", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } } ], "rowLimit": 10000 } }, "name": "storageAccountsCosts" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Overview" }, "name": "overviewGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s !has 'Premium' and MeterSubCategory_s != 'General Block Blob'\r\n| extend FileStructure = iif(MeterSubCategory_s has 'Hierarchical Namespace', 'HNS', 'Flat')\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize Cost=sum(FullCost) by FileStructure", "size": 4, "title": "File Structure ({Currency:value})", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "25", "name": "fileStructure" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let StorageReplication = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s !has 'Premium' and MeterSubCategory_s != 'General Block Blob'\r\n| where MeterName_s has 'Data Stored'\r\n| extend Replication = tostring(split(MeterName_s, ' ')[-3])\r\n| distinct ResourceId, Replication;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where MeterSubCategory_s !has 'Premium' and MeterSubCategory_s != 'General Block Blob'\r\n| join kind=inner (StorageReplication) on ResourceId\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize Cost=sum(FullCost) by Replication", "size": 4, "title": "Replication ({Currency:value})", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "25", "name": "replication" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s !has 'Premium' and MeterSubCategory_s != 'General Block Blob'\r\n| where MeterName_s has 'Data Stored' and (MeterName_s has 'Hot' or MeterName_s has 'Cool' or MeterName_s has 'Archive')\r\n| extend Tier = iif(MeterName_s has 'Hot', 'Hot', iif(MeterName_s has 'Cool', 'Cool', 'Archive'))\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize Cost=sum(FullCost) by Tier", "size": 4, "title": "Data Stored Tiering ({Currency:value})", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "25", "name": "dataStoredTiering" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s !has 'Premium' and MeterSubCategory_s != 'General Block Blob'\r\n| where MeterName_s has 'Operations' and (MeterName_s has 'Hot' or MeterName_s has 'Cool' or MeterName_s has 'Archive')\r\n| extend Tier = iif(MeterName_s has 'Hot', 'Hot', iif(MeterName_s has 'Cool', 'Cool', 'Archive'))\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize Cost=sum(FullCost) by Tier", "size": 4, "title": "Transactions Tiering ({Currency:value})", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "25", "name": "transactionsTiering" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "aa472561-a676-4733-8e13-e9ce6366a187", "version": "KqlParameterItem/1.0", "name": "Hot2CoolTarget", "label": "Hot to Cool Target (%)", "type": 1, "description": "The % of Hot data estimated to be moved to Cool with LCM", "isRequired": true, "value": "50" }, { "id": "0344b31d-bfbe-4b4b-a3a4-63310cd5a719", "version": "KqlParameterItem/1.0", "name": "MoveToHotThreshold", "label": "Trans. Move to Hot (%)", "type": 1, "description": "The max. % of transactions costs vs. total costs and of Cool transactions costs vs. total transactions costs", "isRequired": true, "timeContext": { "durationMs": 86400000 }, "value": "50" }, { "id": "de446dba-7535-4cc0-ab16-23c9ae3aa9fa", "version": "KqlParameterItem/1.0", "name": "ErrorMargin", "label": "Comfort Margin (%)", "type": 1, "description": "The comfort margin (%) for estimation errors (difference between Enable vs. Maybe LCM)", "isRequired": true, "timeContext": { "durationMs": 86400000 }, "value": "20" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "storageV2Parameters" }, { "type": 1, "content": { "json": "Enable Lifecycle Management recommendations may not apply when there is a very large object count to be moved to cooler tiers. Each object moved to a cooler tier will trigger a SetBlobTier transaction billed at Cool Write Operations pricing. Please review the blob count metrics and estimate Lifecycle Management operations costs before applying the recommendation. See more details [here](https://learn.microsoft.com/en-us/azure/storage/blobs/access-tiers-overview#changing-a-blobs-access-tier). For pricing, check [here](https://azure.microsoft.com/en-us/pricing/details/storage/blobs/).", "style": "warning" }, "name": "lcmWarning" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let MoveToHotThreshold = todouble({MoveToHotThreshold})/100;\r\nlet Hot2CoolPercentage = todouble({Hot2CoolTarget})/100;\r\nlet ErrorMarginPercentage = todouble({ErrorMargin})/100;\r\nlet StorageReplication = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s !has 'Premium' and MeterSubCategory_s != 'General Block Blob'\r\n| where MeterName_s has 'Data Stored'\r\n| extend Replication = tostring(split(MeterName_s, ' ')[-3])\r\n| distinct ResourceId, Replication;\r\nlet StorageSize = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s !has 'Premium' and MeterSubCategory_s != 'General Block Blob'\r\n| where MeterName_s has 'Data Stored'\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize SizeGB=sum(todouble(Quantity_s)), CostStored=sum(FullCost) by ResourceId, Date_s\r\n| summarize arg_max(todatetime(Date_s), SizeGB), CostStored=round(sum(CostStored)) by ResourceId\r\n| project SizeGB=round(SizeGB*30), CostStored, ResourceId;\r\nlet StorageTransactions = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s !has 'Premium' and MeterSubCategory_s != 'General Block Blob'\r\n| where MeterName_s has 'Operations'\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize Transactions=round(sum(todouble(Quantity_s))*10000), TransactionsCost=round(sum(FullCost)) by ResourceId;\r\nlet HotStorageSize = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s !has 'Premium' and MeterSubCategory_s != 'General Block Blob'\r\n| where MeterName_s has 'Data Stored' and MeterName_s has 'Hot'\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize HotSizeCost=round(sum(FullCost)) by ResourceId;\r\nlet HotTransactions = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s !has 'Premium' and MeterSubCategory_s != 'General Block Blob'\r\n| where MeterName_s has 'Operations' and (MeterName_s has 'Hot' or MeterName_s has 'List and Create Container' or MeterName_s has 'All Other')\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize HotTransactions=round(sum(todouble(Quantity_s))*10000), HotTransactionsCost=round(sum(FullCost)) by ResourceId;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s !has 'Premium' and MeterSubCategory_s != 'General Block Blob'\r\n| join kind=leftouter (StorageReplication) on ResourceId\r\n| join kind=leftouter (StorageSize) on ResourceId\r\n| join kind=leftouter (StorageTransactions) on ResourceId\r\n| join kind=leftouter (HotStorageSize) on ResourceId\r\n| join kind=leftouter (HotTransactions) on ResourceId\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| extend FileStructure = iif(MeterSubCategory_s has 'Hierarchical Namespace', 'HNS', 'Flat')\r\n| summarize TotalCost=round(sum(FullCost)) by ['Storage Account']=ResourceId, Structure=FileStructure, Replication, ['Size (GB)']=SizeGB, CostStored, HotSizeCost, Transactions, TransactionsCost, HotTransactionsCost\r\n| extend TransactionsFactor = round(TransactionsCost/TotalCost,1)\r\n| extend HotTransactionsFactor = round(HotTransactionsCost/TransactionsCost,1)\r\n| extend CostStored = iif(isnotempty(CostStored), CostStored, 0.0)\r\n| extend HotSizeCost = iif(isnotempty(HotSizeCost), HotSizeCost, 0.0)\r\n| extend TransactionsCost = iif(isnotempty(TransactionsCost), TransactionsCost, 0.0)\r\n| extend HotTransactionsCost = iif(isnotempty(HotTransactionsCost), HotTransactionsCost, 0.0)\r\n| extend EstimatedSavings = round(HotSizeCost*Hot2CoolPercentage/2-HotTransactionsCost*Hot2CoolPercentage*2)\r\n| extend EstimatedSavingsWithError = round(HotSizeCost*Hot2CoolPercentage/2*(1-ErrorMarginPercentage)-HotTransactionsCost*Hot2CoolPercentage*2)*(1+ErrorMarginPercentage)\r\n| extend Recommendation = iif(TransactionsFactor > MoveToHotThreshold and HotTransactionsFactor < MoveToHotThreshold, 'Move to Hot', iif(EstimatedSavingsWithError > 0, 'Enable LCM', iif(EstimatedSavings > 0, 'Maybe LCM', 'No tier change')))\r\n| extend EstimatedSavings = iif(Recommendation != 'Move to Hot', EstimatedSavings, round((TransactionsCost-HotTransactionsCost)/2-(CostStored-HotSizeCost)*2))\r\n| extend Recommendation = iif(Recommendation != 'No tier change' and EstimatedSavings <= 0, 'No tier change', Recommendation)\r\n| project-away HotTransactionsFactor, TransactionsFactor, EstimatedSavingsWithError\r\n| order by TotalCost\r\n| project-rename ['Total ({Currency})']=TotalCost, ['Size ({Currency})']=CostStored, ['Hot Size ({Currency})']=HotSizeCost, ['Trans. ({Currency})']=TransactionsCost, ['Hot Trans. ({Currency})']=HotTransactionsCost, ['Savings ({Currency})']=EstimatedSavings", "size": 0, "showAnalytics": true, "title": "Storage Accounts List", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "table", "gridSettings": { "formatters": [ { "columnMatch": "Storage Account", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "29ch" } }, { "columnMatch": "Size", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Transactions", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Trans. (EUR)", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Hot Trans. (EUR)", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Total Cost (EUR)", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Est. Savings (EUR)", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Recommendation", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "Move to Hot", "representation": "3", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Enable LCM", "representation": "success", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Maybe LCM", "representation": "1", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "stopped", "text": "{0}{1}" } ] } }, { "columnMatch": "File Structure", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "17ch" } }, { "columnMatch": "Cost (EUR)", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } } ], "rowLimit": 10000 } }, "name": "storageAccountsCosts" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "StorageV2" }, "name": "storageV2Group" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let StorageReplication = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s == 'General Block Blob'\r\n| where MeterName_s has 'Data Stored'\r\n| extend Replication = tostring(split(MeterName_s, ' ')[-3])\r\n| distinct ResourceId, Replication;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where MeterSubCategory_s == 'General Block Blob'\r\n| join kind=inner (StorageReplication) on ResourceId\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize Cost=sum(FullCost) by Replication\r\n\r\n\r\n", "size": 1, "title": "Replication ({Currency:value})", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "33", "name": "replication" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let StorageReplication = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s == 'General Block Blob'\r\n| where MeterName_s has 'Data Stored'\r\n| extend Replication = tostring(split(MeterName_s, ' ')[-3])\r\n| distinct ResourceId, Replication;\r\nlet StorageSize = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s == 'General Block Blob'\r\n| where MeterName_s has 'Data Stored'\r\n| summarize SizeGB=sum(todouble(Quantity_s)) by ResourceId, Date_s\r\n| summarize arg_max(todatetime(Date_s), SizeGB) by ResourceId\r\n| project SizeGB=round(SizeGB*30), ResourceId;\r\nlet StorageTransactions = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s == 'General Block Blob'\r\n| where MeterName_s has 'Operations'\r\n| summarize Transactions=round(sum(todouble(Quantity_s))*10000) by ResourceId;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s == 'General Block Blob'\r\n| join kind=leftouter (StorageReplication) on ResourceId\r\n| join kind=leftouter (StorageSize) on ResourceId\r\n| join kind=leftouter (StorageTransactions) on ResourceId\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize ['Cost ({Currency:value})']=round(sum(FullCost)) by ['Storage Account']=ResourceId, Replication, ['Size (GB)']=SizeGB, Transactions\r\n| order by ['Cost ({Currency:value})']", "size": 0, "showAnalytics": true, "title": "Storage Accounts List", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "table", "gridSettings": { "formatters": [ { "columnMatch": "Size", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Transactions", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Cost (EUR)", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } } ], "rowLimit": 10000 } }, "name": "storageAccountsCosts" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "StorageV1" }, "name": "storageV1Group" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s has 'Premium'\r\n| extend FileStructure = iif(MeterSubCategory_s has 'Hierarchical Namespace', 'HNS', 'Flat')\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize Cost=sum(FullCost) by FileStructure", "size": 4, "title": "File Structure ({Currency:value})", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "33", "name": "fileStructure" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let StorageReplication = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s has 'Premium'\r\n| where MeterName_s has 'Data Stored'\r\n| extend Replication = tostring(split(MeterName_s, ' ')[-3])\r\n| distinct ResourceId, Replication;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s has 'Premium'\r\n| join kind=inner (StorageReplication) on ResourceId\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| summarize Cost=sum(FullCost) by Replication", "size": 1, "title": "Replication ({Currency:value})", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "33", "name": "replication" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let StorageReplication = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s has 'Premium'\r\n| where MeterName_s has 'Data Stored'\r\n| extend Replication = tostring(split(MeterName_s, ' ')[-3])\r\n| distinct ResourceId, Replication;\r\nlet StorageSize = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s has 'Premium'\r\n| where MeterName_s has 'Data Stored'\r\n| summarize SizeGB=sum(todouble(Quantity_s)) by ResourceId, Date_s\r\n| summarize arg_max(todatetime(Date_s), SizeGB) by ResourceId\r\n| project SizeGB=round(SizeGB*30), ResourceId;\r\nlet StorageTransactions = AzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s has 'Premium'\r\n| where MeterName_s has 'Operations'\r\n| summarize Transactions=round(sum(todouble(Quantity_s))*10000) by ResourceId;\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) between (todatetime('{LookbackPeriod:startISO}')..todatetime('{LookbackPeriod:endISO}'))\r\n| where SubscriptionId in ({Subscriptions:value})\r\n| where MeterCategory_s == 'Storage' and (MeterSubCategory_s has 'Block Blob' or MeterSubCategory_s has 'Hierarchical Namespace' or MeterSubCategory_s has 'Blob Storage')\r\n| where ResourceId has 'microsoft.storage' or ResourceId has 'microsoft.classicstorage'\r\n| where MeterSubCategory_s has 'Premium'\r\n| join kind=leftouter (StorageReplication) on ResourceId\r\n| join kind=leftouter (StorageSize) on ResourceId\r\n| join kind=leftouter (StorageTransactions) on ResourceId\r\n| extend FullCost = todouble(Quantity_s) * todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n| extend FileStructure = iif(MeterSubCategory_s has 'Hierarchical Namespace', 'HNS', 'Flat')\r\n| summarize ['Cost ({Currency:value})']=round(sum(FullCost)) by ['Storage Account']=ResourceId, ['File Structure']=FileStructure, Replication, ['Size (GB)']=SizeGB, Transactions\r\n| order by ['Cost ({Currency:value})']", "size": 0, "showAnalytics": true, "title": "Storage Accounts List", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "table", "gridSettings": { "formatters": [ { "columnMatch": "Size", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Transactions", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } }, { "columnMatch": "Cost (EUR)", "formatter": 2, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true } } } ], "rowLimit": 10000 } }, "name": "storageAccountsCosts" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Premium" }, "name": "premiumGroup" } ], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" } ================================================ FILE: views/workbooks/costs-growing.bicep ================================================ @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') param workbookDisplayName string = 'Costs Growing' @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') param workbookType string = 'workbook' @description('The id of resource instance to which the workbook will be associated') param workbookSourceId string @description('The unique guid for this workbook instance') param workbookId string = '81afe6eb-8e9e-4315-811c-89b5de245c9a' param resourceTags object param resourceGroupLocation string = resourceGroup().location resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { name: workbookId location: resourceGroupLocation tags: resourceTags kind: 'shared' properties: { displayName: workbookDisplayName serializedData: string(loadJsonContent('costs-growing.json')) version: '1.0' sourceId: workbookSourceId category: workbookType } dependsOn: [] } output workbookId string = workbookId_resource.id ================================================ FILE: views/workbooks/costs-growing.json ================================================ { "version": "Notebook/1.0", "items": [ { "type": 1, "content": { "json": "### Outliers/growing costs conditions" }, "customWidth": "50", "name": "text - 5" }, { "type": 1, "content": { "json": "### Filters" }, "customWidth": "50", "name": "text - 5 - Copy" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "017ba09e-f6c5-496e-bc96-b7cfc09cf561", "version": "KqlParameterItem/1.0", "name": "CostTimeRange", "label": "Cost Time Range", "type": 4, "isRequired": true, "value": { "durationMs": 604800000 }, "typeSettings": { "selectableValues": [ { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ], "allowCustom": true }, "timeContext": { "durationMs": 1209600000 } }, { "id": "85603c03-1d4b-474b-9cdb-9ba97192fc9e", "version": "KqlParameterItem/1.0", "name": "InstanceMinCost", "label": "Min. daily cost", "type": 1, "description": "The minimum cost to be considered for an instance to be reported", "isRequired": true, "value": "1", "typeSettings": { "paramValidationRules": [ { "regExp": "^[1-9][0-9]*$", "match": true, "message": "Must be an integer greater than 0" } ] }, "timeContext": { "durationMs": 0 } }, { "id": "5cf46ddd-b223-4333-a025-65c79eb7ee78", "version": "KqlParameterItem/1.0", "name": "GrowthPercentage", "label": "Growth (%)", "type": 1, "description": "Cost growth from start to end date", "isRequired": true, "value": "1", "typeSettings": { "paramValidationRules": [ { "regExp": "^-?[0-9][0-9]*$", "match": true, "message": "Must be an integer" } ] }, "timeContext": { "durationMs": 0 } }, { "id": "af718458-abd1-4608-85eb-ae60152caa13", "version": "KqlParameterItem/1.0", "name": "TopN", "label": "Top #", "type": 1, "isRequired": true, "value": "20", "timeContext": { "durationMs": 0 } } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "parameters - 1" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "crossComponentResources": [ "value::all" ], "parameters": [ { "id": "b48a696e-cf64-450b-a7cc-9e0a5e457170", "version": "KqlParameterItem/1.0", "name": "SelectedSubscriptions", "label": "Subscription", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "resourcecontainers\r\n| where type =~ 'microsoft.resources/subscriptions'\r\n| project subscriptionId, name", "crossComponentResources": [ "value::all" ], "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "timeContext": { "durationMs": 0 }, "queryType": 1, "resourceType": "microsoft.resourcegraph/resources" } ], "style": "pills", "queryType": 1, "resourceType": "microsoft.resourcegraph/resources" }, "customWidth": "50", "name": "parameters - 7" }, { "type": 1, "content": { "json": "Adjust the filters above according to what you consider being the appropriate thresholds for an abnormal consumption growth. If the first tile below (\"Top Growing/Outliers\") shows results, select each line to analyze the details of the costs growth over time for the specific line item.", "style": "info" }, "name": "infoText" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let CostStartIndex = 1;\r\nlet GrowthFactor = 1 + todouble({GrowthPercentage}) / 100;\r\nlet SubscriptionSeries = materialize(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and SubscriptionId in ({SelectedSubscriptions:value})\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by SubscriptionId, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by SubscriptionId\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| where StartCost >= 0 and EndCost > {InstanceMinCost} and iif(GrowthFactor > 1, EndCost > (StartCost * GrowthFactor), iif(GrowthFactor < 1, EndCost < (StartCost * GrowthFactor), EndCost == StartCost)));\r\nlet ResourceGroupSeries = materialize(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and isnotempty(ResourceGroup) and SubscriptionId in ({SelectedSubscriptions:value})\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by ResourceGroup, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by ResourceGroup\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| where StartCost >= 0 and EndCost > {InstanceMinCost} and iif(GrowthFactor > 1, EndCost > (StartCost * GrowthFactor), iif(GrowthFactor < 1, EndCost < (StartCost * GrowthFactor), EndCost == StartCost)));\r\nlet ResourceSeries = materialize(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and SubscriptionId in ({SelectedSubscriptions:value})\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by ResourceId, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by ResourceId\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| where StartCost >= 0 and EndCost > {InstanceMinCost} and iif(GrowthFactor > 1, EndCost > (StartCost * GrowthFactor), iif(GrowthFactor < 1, EndCost < (StartCost * GrowthFactor), EndCost == StartCost)));\r\nlet MeterCategorySeries = materialize(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and SubscriptionId in ({SelectedSubscriptions:value})\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by MeterCategory_s, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by MeterCategory_s\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| where StartCost >= 0 and EndCost > {InstanceMinCost} and iif(GrowthFactor > 1, EndCost > (StartCost * GrowthFactor), iif(GrowthFactor < 1, EndCost < (StartCost * GrowthFactor), EndCost == StartCost)));\r\nlet MeterSubCategorySeries = materialize(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and isnotempty(MeterSubCategory_s) and SubscriptionId in ({SelectedSubscriptions:value})\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by MeterSubCategory_s, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by MeterSubCategory_s\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| where StartCost >= 0 and EndCost > {InstanceMinCost} and iif(GrowthFactor > 1, EndCost > (StartCost * GrowthFactor), iif(GrowthFactor < 1, EndCost < (StartCost * GrowthFactor), EndCost == StartCost)));\r\nlet MeterNameSeries = materialize(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and SubscriptionId in ({SelectedSubscriptions:value})\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by MeterName_s, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by MeterName_s\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| where StartCost >= 0 and EndCost > {InstanceMinCost} and iif(GrowthFactor > 1, EndCost > (StartCost * GrowthFactor), iif(GrowthFactor < 1, EndCost < (StartCost * GrowthFactor), EndCost == StartCost)));\r\nSubscriptionSeries\r\n| extend outliers=series_decompose_anomalies(Cost, 1.5)\r\n| mvexpand outliers, Date_s\r\n| summarize arg_max(todatetime(Date_s), *) by SubscriptionId\r\n| where outliers == 1\r\n| extend SeriesType='Outliers', PerspectiveType = 'SubscriptionId', PerspectiveId = SubscriptionId\r\n| distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n| union (\r\n SubscriptionSeries\r\n | extend SeriesType='Growing', PerspectiveType = 'SubscriptionId', PerspectiveId = SubscriptionId\r\n | distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n)\r\n| union ( \r\n ResourceGroupSeries\r\n | extend outliers=series_decompose_anomalies(Cost, 1.5)\r\n | mvexpand outliers, Date_s\r\n | summarize arg_max(todatetime(Date_s), *) by ResourceGroup\r\n | where outliers == 1\r\n | extend SeriesType='Outliers', PerspectiveType = 'ResourceGroup', PerspectiveId = ResourceGroup\r\n | distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n)\r\n| union (\r\n ResourceGroupSeries\r\n | extend SeriesType='Growing', PerspectiveType = 'ResourceGroup', PerspectiveId = ResourceGroup\r\n | distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n)\r\n| union ( \r\n ResourceSeries\r\n | extend outliers=series_decompose_anomalies(Cost, 1.5)\r\n | mvexpand outliers, Date_s\r\n | summarize arg_max(todatetime(Date_s), *) by ResourceId\r\n | where outliers == 1\r\n | extend SeriesType='Outliers', PerspectiveType = 'ResourceId', PerspectiveId = ResourceId\r\n | distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n)\r\n| union (\r\n ResourceSeries\r\n | extend SeriesType='Growing', PerspectiveType = 'ResourceId', PerspectiveId = ResourceId\r\n | distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n)\r\n| union ( \r\n MeterCategorySeries\r\n | extend outliers=series_decompose_anomalies(Cost, 1.5)\r\n | mvexpand outliers, Date_s\r\n | summarize arg_max(todatetime(Date_s), *) by MeterCategory_s\r\n | where outliers == 1\r\n | extend SeriesType='Outliers', PerspectiveType = 'MeterCategory_s', PerspectiveId = MeterCategory_s\r\n | distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n)\r\n| union (\r\n MeterCategorySeries\r\n | extend SeriesType='Growing', PerspectiveType = 'MeterCategory_s', PerspectiveId = MeterCategory_s\r\n | distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n)\r\n| union ( \r\n MeterSubCategorySeries\r\n | extend outliers=series_decompose_anomalies(Cost, 1.5)\r\n | mvexpand outliers, Date_s\r\n | summarize arg_max(todatetime(Date_s), *) by MeterSubCategory_s\r\n | where outliers == 1\r\n | extend SeriesType='Outliers', PerspectiveType = 'MeterSubCategory_s', PerspectiveId = MeterSubCategory_s\r\n | distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n)\r\n| union (\r\n MeterSubCategorySeries\r\n | extend SeriesType='Growing', PerspectiveType = 'MeterSubCategory_s', PerspectiveId = MeterSubCategory_s\r\n | distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n)\r\n| union ( \r\n MeterNameSeries\r\n | extend outliers=series_decompose_anomalies(Cost, 1.5)\r\n | mvexpand outliers, Date_s\r\n | summarize arg_max(todatetime(Date_s), *) by MeterName_s\r\n | where outliers == 1\r\n | extend SeriesType='Outliers', PerspectiveType = 'MeterName_s', PerspectiveId = MeterName_s\r\n | distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n)\r\n| union (\r\n MeterNameSeries\r\n | extend SeriesType='Growing', PerspectiveType = 'MeterName_s', PerspectiveId = MeterName_s\r\n | distinct PerspectiveId, SeriesType, PerspectiveType, StartCost, EndCost\r\n)\r\n| extend FirstCost = round(StartCost, 0), LastCost = round(EndCost, 0)\r\n| distinct PerspectiveId, PerspectiveType, FirstCost, LastCost\r\n| top {TopN} by iif(GrowthFactor >= 1, LastCost-FirstCost, FirstCost-LastCost) desc", "size": 1, "showAnalytics": true, "title": "Top Growing/Outliers (daily costs)", "noDataMessage": "The query returned no results. Your costs do not have anomalies and are not growing given the conditions filters above.", "timeContextFromParameter": "CostTimeRange", "exportedParameters": [ { "fieldName": "PerspectiveType", "parameterName": "SelectedPerspective", "parameterType": 1 }, { "fieldName": "PerspectiveId", "parameterName": "SelectedPerspectiveId", "parameterType": 1 } ], "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "GrowingAndOutliers" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where {SelectedPerspective} in~ ('{SelectedPerspectiveId}') and TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize PerspectiveCost = sum(FinalCost) by bin(todatetime(Date_s), 1d)\r\n| render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Evolution over time (select growing/outlier cost)", "timeContextFromParameter": "CostTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "outliers" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let CostStartIndex = 1;\r\nlet ContributingInstances = AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage'\r\n| where {SelectedPerspective} == '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by SubscriptionId, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by SubscriptionId\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| top {TopN} by iif({GrowthPercentage} >= 0, EndCost-StartCost, StartCost-EndCost)\r\n| project SubscriptionId;\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and SubscriptionId in (ContributingInstances)\r\n| where {SelectedPerspective} == '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by bin(todatetime(Date_s), 1d), SubscriptionId\r\n| join kind=inner ( \r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}')\r\n | where ContainerType_s =~ 'microsoft.resources/subscriptions' \r\n | extend SubscriptionName = ContainerName_s\r\n | extend SubscriptionId = SubscriptionGuid_g\r\n | distinct SubscriptionId, SubscriptionName \r\n) on SubscriptionId \r\n| project-away SubscriptionId, SubscriptionId1\r\n| render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Top contributing subscriptions over time", "timeContextFromParameter": "CostTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "subscriptionDetails" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let CostStartIndex = 1;\r\nlet ContributingInstances = AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage'\r\n| where {SelectedPerspective} == '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| extend ResourceGroup = tolower(ResourceGroup)\r\n| summarize InstanceCost = sum(FinalCost) by ResourceGroup, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by ResourceGroup\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| top {TopN} by iif({GrowthPercentage} >= 0, EndCost-StartCost, StartCost-EndCost)\r\n| project ResourceGroup;\r\nAzureOptimizationConsumptionV1_CL\r\n| extend ResourceGroup = tolower(ResourceGroup)\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and ResourceGroup in (ContributingInstances)\r\n| where {SelectedPerspective} == '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by bin(todatetime(Date_s), 1d), ResourceGroup\r\n| render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Top contributing resource groups over time", "timeContextFromParameter": "CostTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "resourceGroupDetails" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let CostStartIndex = 1;\r\nlet ContributingInstances = AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage'\r\n| extend ResourceId = tolower(ResourceId)\r\n| where {SelectedPerspective} =~ '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by ResourceId, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by ResourceId\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| top {TopN} by iif({GrowthPercentage} >= 0, EndCost-StartCost, StartCost-EndCost)\r\n| project ResourceId;\r\nAzureOptimizationConsumptionV1_CL\r\n| extend ResourceId = tolower(ResourceId)\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and ResourceId in~ (ContributingInstances)\r\n| where {SelectedPerspective} =~ '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by bin(todatetime(Date_s), 1d), tostring(split(ResourceId,'/')[-1])\r\n| render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Top contributing instances over time", "timeContextFromParameter": "CostTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "instanceDetails" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let CostStartIndex = 1;\r\nlet ContributingInstances = AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage'\r\n| where {SelectedPerspective} == '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by MeterCategory_s, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by MeterCategory_s\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| top {TopN} by iif({GrowthPercentage} >= 0, EndCost-StartCost, StartCost-EndCost)\r\n| project MeterCategory_s;\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s in (ContributingInstances)\r\n| where {SelectedPerspective} == '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by bin(todatetime(Date_s), 1d), MeterCategory_s\r\n| render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Top contributing meter categories over time", "timeContextFromParameter": "CostTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "meterCategoryDetails" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let CostStartIndex = 1;\r\nlet ContributingInstances = AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage'\r\n| where {SelectedPerspective} == '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by MeterSubCategory_s, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by MeterSubCategory_s\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| top {TopN} by iif({GrowthPercentage} >= 0, EndCost-StartCost, StartCost-EndCost)\r\n| project MeterSubCategory_s;\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and MeterSubCategory_s in (ContributingInstances)\r\n| where {SelectedPerspective} == '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by bin(todatetime(Date_s), 1d), MeterSubCategory_s\r\n| render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Top contributing meter subcategories over time", "timeContextFromParameter": "CostTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "meterSubCategoryDetails" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let CostStartIndex = 1;\r\nlet ContributingInstances = AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage'\r\n| where {SelectedPerspective} == '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by MeterName_s, Date_s\r\n| order by todatetime(Date_s) asc\r\n| make-series Cost=sum(InstanceCost) on todatetime(Date_s) step 1d by MeterName_s\r\n| extend StartCost = todouble(Cost[CostStartIndex]), EndCost = todouble(Cost[array_length(Cost)-1])\r\n| top {TopN} by iif({GrowthPercentage} >= 0, EndCost-StartCost, StartCost-EndCost)\r\n| project MeterName_s;\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > datetime('{CostTimeRange:startISO}') and TimeGenerated < datetime('{CostTimeRange:endISO}') and ChargeType_s == 'Usage' and MeterName_s in (ContributingInstances)\r\n| where {SelectedPerspective} == '{SelectedPerspectiveId}'\r\n| extend FinalCost = todouble(CostInBillingCurrency_s)\r\n| summarize InstanceCost = sum(FinalCost) by bin(todatetime(Date_s), 1d), MeterName_s\r\n| render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Top contributing meter names over time", "timeContextFromParameter": "CostTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "meterNameDetails" } ], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" } ================================================ FILE: views/workbooks/identities-roles.bicep ================================================ @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') param workbookDisplayName string = 'Identities and Roles' @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') param workbookType string = 'workbook' @description('The id of resource instance to which the workbook will be associated') param workbookSourceId string @description('The unique guid for this workbook instance') param workbookId string = '4946ffbe-16c1-4a32-81a4-8ed024278ab2' param resourceTags object param resourceGroupLocation string = resourceGroup().location resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { name: workbookId location: resourceGroupLocation tags: resourceTags kind: 'shared' properties: { displayName: workbookDisplayName serializedData: string(loadJsonContent('identities-roles.json')) version: '1.0' sourceId: workbookSourceId category: workbookType } dependsOn: [] } output workbookId string = workbookId_resource.id ================================================ FILE: views/workbooks/identities-roles.json ================================================ { "version": "Notebook/1.0", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "a1de1642-eb3d-47b4-84b1-ab98bc398a5b", "version": "KqlParameterItem/1.0", "name": "TenantId", "label": "Microsoft Entra Tenant", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationRBACAssignmentsV1_CL\r\n| where Model_s == 'AzureAD'\r\n| distinct TenantGuid_g, Scope_s", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "tenantParam" }, { "type": 1, "content": { "json": "For full functionality, this workbook requires Microsoft Entra ID exports. If you had not assigned the Global Reader role to the AOE managed identity yet, please do so ([see requirements](https://aka.ms/AzureOptimizationEngine/requirements)).", "style": "info" }, "name": "text - 22" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAADObjectsV1_CL\r\n| where TenantGuid_g in ({TenantId})\r\n| distinct ObjectId_g, ObjectType_s\r\n| summarize count() by ObjectType_s", "size": 4, "title": "Microsoft Entra objects count (today)", "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "50", "name": "objectTypesStats" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAADObjectsV1_CL\r\n| where TenantGuid_g in ({TenantId}) and ObjectType_s == 'User'\r\n| distinct ObjectId_g, ObjectSubType_s\r\n| summarize count() by ObjectSubType_s", "size": 4, "title": "Microsoft Entra ID users count (today)", "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "50", "name": "userObjectSubTypesStats" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationRBACAssignmentsV1_CL\r\n| where TenantGuid_g in ({TenantId}) and Model_s == 'AzureAD'\r\n| distinct PrincipalId_g, RoleDefinition_s\r\n| summarize count() by RoleDefinition_s", "size": 4, "title": "Microsoft Entra ID roles count (today)", "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "50", "name": "aadRolesStats" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationRBACAssignmentsV1_CL\r\n| where TenantGuid_g in ({TenantId}) and Model_s == 'AzureRM'\r\n| distinct PrincipalId_g, RoleDefinition_s\r\n| summarize count() by RoleDefinition_s", "size": 4, "title": "Azure RM roles count (today)", "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "50", "name": "armRolesStats" }, { "type": 11, "content": { "version": "LinkItem/1.0", "style": "tabs", "links": [ { "id": "18ec41b4-6b72-4311-ae38-28ffb61afab1", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Microsoft Entra ID Credentials", "subTarget": "aadCredentials", "style": "link" }, { "id": "bd735f44-2601-43be-b9d2-537cbce18176", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Microsoft Entra ID Roles", "subTarget": "aadRoles", "style": "link" }, { "id": "98683d29-655a-4248-8774-a5934e575579", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Azure RM Roles", "subTarget": "armRoles", "style": "link" }, { "id": "6f6a1e50-cc93-4d40-abd8-391eb282f9be", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Azure Classic Roles", "subTarget": "classicRoles", "style": "link" } ] }, "name": "topLevelTabs" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "21477f68-03d2-43a7-8181-1a436faa5451", "version": "KqlParameterItem/1.0", "name": "aadRoleDefinitions", "label": "Roles", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationRBACAssignmentsV1_CL\r\n| where TimeGenerated > ago(1d) and Model_s == 'AzureAD' and TenantGuid_g in ({TenantId})\r\n| distinct RoleDefinition_s\r\n| order by RoleDefinition_s asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "id": "da707803-554b-4d55-b01e-0960f764b22f", "version": "KqlParameterItem/1.0", "name": "aadObjectType", "label": "Object Type", "type": 2, "isRequired": true, "value": "User", "typeSettings": { "additionalResourceOptions": [] }, "jsonData": "[\"User\",\"ServicePrincipal\"]", "timeContext": { "durationMs": 86400000 } }, { "id": "c204a5b5-c895-4c66-8e6e-9067d24b546a", "version": "KqlParameterItem/1.0", "name": "aadObjectSubType", "label": "Sub Type", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationAADObjectsV1_CL\r\n| where TimeGenerated > ago(1d) and TenantGuid_g in ({TenantId}) and ObjectType_s == '{aadObjectType}'\r\n| distinct ObjectSubType_s\r\n| order by ObjectSubType_s asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "id": "ed6eb3b2-0ce6-4070-93f3-d4fe6212fe12", "version": "KqlParameterItem/1.0", "name": "aadAssignmentType", "label": "Assignment Type", "type": 2, "isRequired": true, "value": "All", "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "jsonData": "[\"All\", \"Direct\", \"Group\"]" }, { "id": "d042742e-4a0c-4670-ace7-528f6862ae1a", "version": "KqlParameterItem/1.0", "name": "aadRoleHistoryRange", "label": "History Range", "type": 4, "isRequired": true, "value": { "durationMs": 2592000000 }, "typeSettings": { "selectableValues": [ { "durationMs": 172800000 }, { "durationMs": 259200000 }, { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ], "allowCustom": true } } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "aadRoles" }, "name": "aadRolesParams" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "2aeff133-7cd7-4bf9-aab9-a3131c33a1b8", "version": "KqlParameterItem/1.0", "name": "armRoleDefinitions", "label": "Roles", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationRBACAssignmentsV1_CL\r\n| where TimeGenerated > ago(1d) and Model_s in ('AzureRM') and TenantGuid_g in ({TenantId})\r\n| distinct RoleDefinition_s\r\n| order by RoleDefinition_s asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "id": "b713b16b-3d87-47e4-bfe0-e6f2ee008da4", "version": "KqlParameterItem/1.0", "name": "armObjectType", "label": "Object Type", "type": 2, "isRequired": true, "value": "User", "typeSettings": { "additionalResourceOptions": [] }, "jsonData": "[\"User\",\"ServicePrincipal\"]", "timeContext": { "durationMs": 86400000 } }, { "id": "c49dba90-280e-4327-9b58-b8ef6df41d36", "version": "KqlParameterItem/1.0", "name": "armObjectSubType", "label": "Sub Type", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationAADObjectsV1_CL\r\n| where TimeGenerated > ago(1d) and TenantGuid_g in ({TenantId}) and ObjectType_s == '{armObjectType}' and isnotempty(ObjectSubType_s) and isnotempty(ObjectSubType_s)\r\n| distinct ObjectSubType_s\r\n| order by ObjectSubType_s asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "id": "ed6eb3b2-0ce6-4070-93f3-d4fe6212fe12", "version": "KqlParameterItem/1.0", "name": "armAssignmentType", "label": "Assignment Type", "type": 2, "isRequired": true, "value": "All", "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "jsonData": "[\"All\", \"Direct\", \"Group\"]" }, { "id": "56a07f4a-d0d5-4f5b-b07b-0e1d9dfcd91f", "version": "KqlParameterItem/1.0", "name": "armRoleHistoryRange", "label": "History Range", "type": 4, "isRequired": true, "value": { "durationMs": 2592000000 }, "typeSettings": { "selectableValues": [ { "durationMs": 172800000 }, { "durationMs": 259200000 }, { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ], "allowCustom": true } } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "armRoles" }, "name": "armRolesParams" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "4d485d7b-dc8d-403f-8103-f3bcc1c44d3f", "version": "KqlParameterItem/1.0", "name": "classicRoleDefinitions", "label": "Roles", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationRBACAssignmentsV1_CL\r\n| where TimeGenerated > ago(1d) and Model_s in ('AzureClassic') and TenantGuid_g in ({TenantId})\r\n| distinct RoleDefinition_s\r\n| order by RoleDefinition_s asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "id": "c16ec55d-c9c6-4c7d-8bee-5c5871788749", "version": "KqlParameterItem/1.0", "name": "classicRoleHistoryRange", "label": "History Range", "type": 4, "isRequired": true, "value": { "durationMs": 2592000000 }, "typeSettings": { "selectableValues": [ { "durationMs": 172800000 }, { "durationMs": 259200000 }, { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ], "allowCustom": true } } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "classicRoles" }, "name": "classicRolesParams" }, { "type": 11, "content": { "version": "LinkItem/1.0", "style": "tabs", "links": [ { "id": "ee2235e8-eb13-4e74-9f8c-e92f2af8b0ce", "cellValue": "selectedCredTab", "linkTarget": "parameter", "linkLabel": "Credentials about to expire", "subTarget": "expiringCreds", "style": "link" }, { "id": "0bb5a248-8bb7-4ebf-a3ae-379b95ac8218", "cellValue": "selectedCredTab", "linkTarget": "parameter", "linkLabel": "Credentials not set to expire", "subTarget": "notExpiringCreds", "style": "link" }, { "id": "c9398c96-ea60-4775-b03d-eaf7e5960e22", "cellValue": "selectedCredTab", "linkTarget": "parameter", "linkLabel": "Credentials expired", "subTarget": "expiredCreds", "style": "link" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "aadCredentials" }, "name": "aadCredsTabs" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "b284dfc4-2d79-4634-8b50-37fed8a67592", "version": "KqlParameterItem/1.0", "name": "ExpirySpan", "label": "Expires in (days)", "type": 1, "isRequired": true, "value": "30", "typeSettings": { "paramValidationRules": [ { "regExp": "^[1-9][0-9]*$", "match": true, "message": "Must be an integer greater than 0" } ] }, "timeContext": { "durationMs": 86400000 } } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibilities": [ { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "aadCredentials" }, { "parameterName": "selectedCredTab", "comparison": "isEqualTo", "value": "expiringCreds" } ], "name": "expiringCredsSpanParam" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "db97f60d-a481-430a-9320-da3b0f942540", "version": "KqlParameterItem/1.0", "name": "MinExpirySpan", "label": "Expires at least in (days)", "type": 1, "isRequired": true, "value": "365", "typeSettings": { "paramValidationRules": [ { "regExp": "^[1-9][0-9]*$", "match": true, "message": "Must be an integer greater than 0" } ] }, "timeContext": { "durationMs": 86400000 } } ], "style": "pills", "doNotRunWhenHidden": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibilities": [ { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "aadCredentials" }, { "parameterName": "selectedCredTab", "comparison": "isEqualTo", "value": "notExpiringCreds" } ], "name": "notExpiringCredsSpanParam" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let expiryInterval = {ExpirySpan}d;\r\nlet AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}));\r\nlet RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}));\r\nlet AppsAndKeys = materialize (AADObjectsTable\r\n| where ObjectType_s in ('Application','ServicePrincipal')\r\n| where ObjectSubType_s != 'ManagedIdentity'\r\n| where Keys_s startswith '['\r\n| extend Keys = parse_json(Keys_s)\r\n| project-away Keys_s\r\n| mv-expand Keys\r\n| evaluate bag_unpack(Keys)\r\n| union ( \r\n AADObjectsTable\r\n | where ObjectType_s in ('Application','ServicePrincipal')\r\n | where ObjectSubType_s != 'ManagedIdentity'\r\n | where isnotempty(Keys_s) and Keys_s !startswith '['\r\n | extend Keys = parse_json(Keys_s)\r\n | project-away Keys_s\r\n | evaluate bag_unpack(Keys)\r\n)\r\n);\r\nlet ExpirationInRisk = AppsAndKeys\r\n| where EndDate < now()+expiryInterval and EndDate > now()\r\n| project ApplicationId_g, KeyId, RiskDate = EndDate;\r\nlet NotInRisk = AppsAndKeys\r\n| where EndDate > now()+expiryInterval\r\n| project ApplicationId_g, KeyId, ComfortDate = EndDate;\r\nlet ApplicationsInRisk = ExpirationInRisk\r\n| join kind=leftouter ( NotInRisk ) on ApplicationId_g\r\n| where isempty(ComfortDate)\r\n| summarize ExpiresOn = max(RiskDate) by ApplicationId_g;\r\nlet ServicePrincipals = materialize(AADObjectsTable\r\n| where isnotempty(ObjectId_g)\r\n| where ObjectType_s == 'ServicePrincipal'\r\n| project SPNId = ObjectId_g, ApplicationId_g, PrincipalNames_s, DisplayName_s);\r\nlet GroupMemberships = AADObjectsTable\r\n| where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n| where PrincipalNames_s startswith '['\r\n| extend GroupMember = parse_json(PrincipalNames_s)\r\n| project-away PrincipalNames_s\r\n| mv-expand GroupMember\r\n| union (\r\n AADObjectsTable\r\n | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '['\r\n | extend GroupMember = parse_json(PrincipalNames_s)\r\n | project-away PrincipalNames_s\r\n)\r\n| project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s;\r\nlet DirectAssignments = RBACAssignmentsTable\r\n| join kind=inner (\r\n ServicePrincipals\r\n) on $left.PrincipalId_g == $right.SPNId\r\n| project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g;\r\nlet GroupAssignments = RBACAssignmentsTable\r\n| join kind=inner (\r\n GroupMemberships\r\n | join kind=inner ( \r\n ServicePrincipals\r\n ) on $left.GroupMember == $right.SPNId\r\n) on $left.PrincipalId_g == $right.GroupId\r\n| project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g;\r\nAppsAndKeys\r\n| join kind=inner (ApplicationsInRisk) on ApplicationId_g\r\n| summarize ExpiresOn = max(EndDate) by ApplicationId_g, DisplayName_s, Cloud_s, KeyType, TenantGuid_g\r\n| join kind=leftouter (\r\n GroupAssignments\r\n | union DirectAssignments\r\n) on ApplicationId_g\r\n| summarize countif(isnotempty(RoleDefinition_s)) by DisplayName_s, ExpiresOn, KeyType, TenantGuid_g, ApplicationId_g\r\n| order by countif_ desc, ExpiresOn asc", "size": 1, "showAnalytics": true, "timeContext": { "durationMs": 86400000 }, "exportFieldName": "ApplicationId_g", "exportParameterName": "selectedApp", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "countif_", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": ">", "thresholdValue": "0", "representation": "2", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "Blank", "text": "{0}{1}" } ] } } ], "labelSettings": [ { "columnId": "DisplayName_s", "label": "Application" }, { "columnId": "ExpiresOn", "label": "Expires On" }, { "columnId": "KeyType", "label": "Key Type" }, { "columnId": "TenantGuid_g", "label": "Tenant ID" }, { "columnId": "ApplicationId_g", "label": "Application ID" }, { "columnId": "countif_", "label": "Role Assignments" } ] } }, "conditionalVisibilities": [ { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "aadCredentials" }, { "parameterName": "selectedCredTab", "comparison": "isEqualTo", "value": "expiringCreds" } ], "name": "expiringCredsTable" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let expiryInterval = {MinExpirySpan}d;\r\nlet AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}));\r\nlet RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}));\r\nlet AppsAndKeys = materialize (AADObjectsTable\r\n| where ObjectType_s in ('Application','ServicePrincipal')\r\n| where ObjectSubType_s != 'ManagedIdentity'\r\n| where Keys_s startswith '['\r\n| extend Keys = parse_json(Keys_s)\r\n| project-away Keys_s\r\n| mv-expand Keys\r\n| evaluate bag_unpack(Keys)\r\n| union ( \r\n AADObjectsTable\r\n | where ObjectType_s in ('Application','ServicePrincipal')\r\n | where ObjectSubType_s != 'ManagedIdentity'\r\n | where isnotempty(Keys_s) and Keys_s !startswith '['\r\n | extend Keys = parse_json(Keys_s)\r\n | project-away Keys_s\r\n | evaluate bag_unpack(Keys)\r\n)\r\n);\r\nlet NotExpiringSoon = AppsAndKeys\r\n| where EndDate > now()+expiryInterval\r\n| project ApplicationId_g, ObjectType_s, DisplayName_s, Cloud_s, KeyType, TenantGuid_g, ExpiresOn = EndDate;\r\nlet ServicePrincipals = materialize(AADObjectsTable\r\n| where isnotempty(ObjectId_g)\r\n| where ObjectType_s == 'ServicePrincipal'\r\n| project SPNId = ObjectId_g, ApplicationId_g, PrincipalNames_s, DisplayName_s);\r\nlet GroupMemberships = AADObjectsTable\r\n| where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n| where PrincipalNames_s startswith '['\r\n| extend GroupMember = parse_json(PrincipalNames_s)\r\n| project-away PrincipalNames_s\r\n| mv-expand GroupMember\r\n| union (\r\n AADObjectsTable\r\n | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '['\r\n | extend GroupMember = parse_json(PrincipalNames_s)\r\n | project-away PrincipalNames_s\r\n)\r\n| project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s;\r\nlet DirectAssignments = RBACAssignmentsTable\r\n| join kind=inner (\r\n ServicePrincipals\r\n) on $left.PrincipalId_g == $right.SPNId\r\n| project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g;\r\nlet GroupAssignments = RBACAssignmentsTable\r\n| join kind=inner (\r\n GroupMemberships\r\n | join kind=inner ( \r\n ServicePrincipals\r\n ) on $left.GroupMember == $right.SPNId\r\n) on $left.PrincipalId_g == $right.GroupId\r\n| project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g;\r\nNotExpiringSoon\r\n| join kind=leftouter (\r\n GroupAssignments\r\n | union DirectAssignments\r\n) on ApplicationId_g\r\n| summarize countif(isnotempty(RoleDefinition_s)) by DisplayName_s, ExpiresOn, KeyType, TenantGuid_g, ApplicationId_g\r\n| order by countif_ desc, ExpiresOn desc", "size": 1, "showAnalytics": true, "timeContext": { "durationMs": 86400000 }, "exportFieldName": "ApplicationId_g", "exportParameterName": "selectedApp", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "countif_", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": ">", "thresholdValue": "0", "representation": "2", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "Blank", "text": "{0}{1}" } ] } } ], "labelSettings": [ { "columnId": "DisplayName_s", "label": "Application" }, { "columnId": "ExpiresOn", "label": "Expires On" }, { "columnId": "KeyType", "label": "Key Type" }, { "columnId": "TenantGuid_g", "label": "Tenant ID" }, { "columnId": "ApplicationId_g", "label": "Application ID" }, { "columnId": "countif_", "label": "Role Assignments" } ] } }, "conditionalVisibilities": [ { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "aadCredentials" }, { "parameterName": "selectedCredTab", "comparison": "isEqualTo", "value": "notExpiringCreds" } ], "name": "notExpiringCredsTable" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}));\r\nlet RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}));\r\nlet AppsAndKeys = materialize (AADObjectsTable\r\n| where ObjectType_s in ('Application','ServicePrincipal')\r\n| where ObjectSubType_s != 'ManagedIdentity'\r\n| where Keys_s startswith '['\r\n| extend Keys = parse_json(Keys_s)\r\n| project-away Keys_s\r\n| mv-expand Keys\r\n| evaluate bag_unpack(Keys)\r\n| union ( \r\n AADObjectsTable\r\n | where ObjectType_s in ('Application','ServicePrincipal')\r\n | where ObjectSubType_s != 'ManagedIdentity'\r\n | where isnotempty(Keys_s) and Keys_s !startswith '['\r\n | extend Keys = parse_json(Keys_s)\r\n | project-away Keys_s\r\n | evaluate bag_unpack(Keys)\r\n)\r\n);\r\nlet ExpirationInRisk = AppsAndKeys\r\n| where EndDate < now()\r\n| project ApplicationId_g, KeyId, RiskDate = EndDate;\r\nlet NotInRisk = AppsAndKeys\r\n| where EndDate > now()\r\n| project ApplicationId_g, KeyId, ComfortDate = EndDate;\r\nlet ApplicationsInRisk = ExpirationInRisk\r\n| join kind=leftouter ( NotInRisk ) on ApplicationId_g\r\n| where isempty(ComfortDate)\r\n| summarize ExpiresOn = max(RiskDate) by ApplicationId_g;\r\nlet ServicePrincipals = materialize(AADObjectsTable\r\n| where isnotempty(ObjectId_g)\r\n| where ObjectType_s == 'ServicePrincipal'\r\n| project SPNId = ObjectId_g, ApplicationId_g, PrincipalNames_s, DisplayName_s);\r\nlet GroupMemberships = AADObjectsTable\r\n| where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n| where PrincipalNames_s startswith '['\r\n| extend GroupMember = parse_json(PrincipalNames_s)\r\n| project-away PrincipalNames_s\r\n| mv-expand GroupMember\r\n| union (\r\n AADObjectsTable\r\n | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '['\r\n | extend GroupMember = parse_json(PrincipalNames_s)\r\n | project-away PrincipalNames_s\r\n)\r\n| project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s;\r\nlet DirectAssignments = RBACAssignmentsTable\r\n| join kind=inner (\r\n ServicePrincipals\r\n) on $left.PrincipalId_g == $right.SPNId\r\n| project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g;\r\nlet GroupAssignments = RBACAssignmentsTable\r\n| join kind=inner (\r\n GroupMemberships\r\n | join kind=inner ( \r\n ServicePrincipals\r\n ) on $left.GroupMember == $right.SPNId\r\n) on $left.PrincipalId_g == $right.GroupId\r\n| project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g;\r\nAppsAndKeys\r\n| join kind=inner (ApplicationsInRisk) on ApplicationId_g\r\n| summarize ExpiresOn = max(EndDate) by ApplicationId_g, DisplayName_s, Cloud_s, KeyType, TenantGuid_g\r\n| join kind=leftouter (\r\n GroupAssignments\r\n | union DirectAssignments\r\n) on ApplicationId_g\r\n| summarize countif(isnotempty(RoleDefinition_s)) by DisplayName_s, ExpiresOn, KeyType, TenantGuid_g, ApplicationId_g\r\n| order by countif_ desc", "size": 1, "showAnalytics": true, "timeContext": { "durationMs": 86400000 }, "exportFieldName": "ApplicationId_g", "exportParameterName": "selectedApp", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "countif_", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": ">", "thresholdValue": "0", "representation": "2", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "Blank", "text": "{0}{1}" } ] } } ], "labelSettings": [ { "columnId": "DisplayName_s", "label": "Application" }, { "columnId": "ExpiresOn", "label": "Expires On" }, { "columnId": "KeyType", "label": "Key Type" }, { "columnId": "TenantGuid_g", "label": "Tenant ID" }, { "columnId": "ApplicationId_g", "label": "Application ID" }, { "columnId": "countif_", "label": "Role Assignments" } ] } }, "conditionalVisibilities": [ { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "aadCredentials" }, { "parameterName": "selectedCredTab", "comparison": "isEqualTo", "value": "expiredCreds" } ], "name": "expiredCredsTable" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}));\r\nlet RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}));\r\nlet ServicePrincipals = materialize(AADObjectsTable\r\n| where ApplicationId_g == '{selectedApp}'\r\n| where isnotempty(ObjectId_g)\r\n| where ObjectType_s == 'ServicePrincipal'\r\n| project SPNId = ObjectId_g, ApplicationId_g, PrincipalNames_s, DisplayName_s);\r\nlet GroupMemberships = AADObjectsTable\r\n| where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n| where PrincipalNames_s startswith '['\r\n| extend GroupMember = parse_json(PrincipalNames_s)\r\n| project-away PrincipalNames_s\r\n| mv-expand GroupMember\r\n| union (\r\n AADObjectsTable\r\n | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '['\r\n | extend GroupMember = parse_json(PrincipalNames_s)\r\n | project-away PrincipalNames_s\r\n)\r\n| project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s;\r\nlet DirectAssignments = RBACAssignmentsTable\r\n| join kind=inner (\r\n ServicePrincipals\r\n) on $left.PrincipalId_g == $right.SPNId\r\n| project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g;\r\nlet GroupAssignments = RBACAssignmentsTable\r\n| join kind=inner (\r\n GroupMemberships\r\n | join kind=inner ( \r\n ServicePrincipals\r\n ) on $left.GroupMember == $right.SPNId\r\n) on $left.PrincipalId_g == $right.GroupId\r\n| project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g;\r\nGroupAssignments\r\n| union DirectAssignments\r\n| distinct DisplayName_s, ApplicationId_g, RoleDefinition_s, Scope_s, Assignment, Model_s", "size": 1, "showAnalytics": true, "title": "Roles assigned to application (selected above)", "noDataMessage": "No roles assigned", "timeContext": { "durationMs": 86400000 }, "exportFieldName": "ApplicationId_g", "exportParameterName": "selectedApp", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "aadCredentials" }, "name": "assignedRolesTable" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}));\r\nlet RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}) | where RoleDefinition_s in ({aadRoleDefinitions}));\r\nlet EnabledObjects = materialize(AADObjectsTable\r\n| where isnotempty(ObjectId_g)\r\n| where ObjectType_s == '{aadObjectType}' and ObjectSubType_s in ({aadObjectSubType}) and SecurityEnabled_s in ('True','N/A')\r\n| project ObjectId = ObjectId_g, PrincipalNames_s, DisplayName_s, ObjectSubType_s);\r\nlet GroupMemberships = AADObjectsTable\r\n| where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n| where PrincipalNames_s startswith '['\r\n| extend GroupMember = parse_json(PrincipalNames_s)\r\n| project-away PrincipalNames_s\r\n| mv-expand GroupMember\r\n| union (\r\n AADObjectsTable\r\n | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '['\r\n | extend GroupMember = parse_json(PrincipalNames_s)\r\n | project-away PrincipalNames_s\r\n)\r\n| project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s;\r\nlet DirectUserAssignments = RBACAssignmentsTable\r\n| where Model_s == 'AzureAD'\r\n| join kind=inner (\r\n EnabledObjects\r\n) on $left.PrincipalId_g == $right.ObjectId\r\n| project PrincipalNames_s, PrincipalId_g, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g, ObjectSubType_s;\r\nlet GroupUserAssignments = RBACAssignmentsTable\r\n| where Model_s == 'AzureAD'\r\n| join kind=inner (\r\n GroupMemberships\r\n | join kind=inner ( \r\n EnabledObjects\r\n ) on $left.GroupMember == $right.ObjectId\r\n) on $left.PrincipalId_g == $right.GroupId\r\n| project PrincipalNames_s, PrincipalId_g, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g, ObjectSubType_s;\r\nGroupUserAssignments\r\n| union DirectUserAssignments\r\n| distinct DisplayName_s, PrincipalNames_s, PrincipalId_g, ObjectSubType_s, RoleDefinition_s, Scope_s, Assignment\r\n| where '{aadAssignmentType}' == 'All' or Assignment startswith '{aadAssignmentType}'\r\n| order by PrincipalNames_s asc", "size": 1, "showAnalytics": true, "timeContext": { "durationMs": 86400000 }, "exportFieldName": "PrincipalId_g", "exportParameterName": "AADPrincipalId", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "PrincipalId_g", "formatter": 5 } ], "rowLimit": 1000, "filter": true, "labelSettings": [ { "columnId": "DisplayName_s", "label": "Name" }, { "columnId": "PrincipalNames_s", "label": "Principal Name" }, { "columnId": "ObjectSubType_s", "label": "Sub Type" }, { "columnId": "RoleDefinition_s", "label": "Role" }, { "columnId": "Scope_s", "label": "Scope" } ] } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "aadRoles" }, "name": "aadRolesTable" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}));\r\nlet RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}) | where RoleDefinition_s in ({armRoleDefinitions}));\r\nlet EnabledObjects = materialize(AADObjectsTable\r\n| where isnotempty(ObjectId_g)\r\n| where ObjectType_s == '{armObjectType}' and ObjectSubType_s in ({armObjectSubType}) and SecurityEnabled_s in ('True','N/A')\r\n| project ObjectId = ObjectId_g, PrincipalNames_s, DisplayName_s, ObjectSubType_s);\r\nlet GroupMemberships = AADObjectsTable\r\n| where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n| where PrincipalNames_s startswith '['\r\n| extend GroupMember = parse_json(PrincipalNames_s)\r\n| project-away PrincipalNames_s\r\n| mv-expand GroupMember\r\n| union (\r\n AADObjectsTable\r\n | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True'\r\n | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '['\r\n | extend GroupMember = parse_json(PrincipalNames_s)\r\n | project-away PrincipalNames_s\r\n)\r\n| project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s;\r\nlet DirectUserAssignments = RBACAssignmentsTable\r\n| where Model_s == 'AzureRM'\r\n| join kind=inner (\r\n EnabledObjects\r\n) on $left.PrincipalId_g == $right.ObjectId\r\n| project PrincipalNames_s, PrincipalId_g, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g, ObjectSubType_s;\r\nlet GroupUserAssignments = RBACAssignmentsTable\r\n| where Model_s == 'AzureRM'\r\n| join kind=inner (\r\n GroupMemberships\r\n | join kind=inner ( \r\n EnabledObjects\r\n ) on $left.GroupMember == $right.ObjectId\r\n) on $left.PrincipalId_g == $right.GroupId\r\n| project PrincipalNames_s, PrincipalId_g, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g, ObjectSubType_s;\r\nGroupUserAssignments\r\n| union DirectUserAssignments\r\n| distinct DisplayName_s, PrincipalNames_s, PrincipalId_g, ObjectSubType_s, RoleDefinition_s, Scope_s, Assignment\r\n| where '{armAssignmentType}' == 'All' or Assignment startswith '{armAssignmentType}'\r\n| order by PrincipalNames_s asc", "size": 1, "showAnalytics": true, "timeContext": { "durationMs": 86400000 }, "exportFieldName": "PrincipalId_g", "exportParameterName": "ARMPrincipalId", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "PrincipalId_g", "formatter": 5 } ], "rowLimit": 5000, "filter": true, "labelSettings": [ { "columnId": "DisplayName_s", "label": "Name" }, { "columnId": "PrincipalNames_s", "label": "Principal Name" }, { "columnId": "ObjectSubType_s", "label": "Sub Type" }, { "columnId": "RoleDefinition_s", "label": "Role" }, { "columnId": "Scope_s", "label": "Scope" } ] } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "armRoles" }, "name": "armRolesTable" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d) | where TenantGuid_g in ({TenantId}) | where RoleDefinition_s in ({classicRoleDefinitions}));\r\nlet DirectUserAssignments = RBACAssignmentsTable\r\n| where Model_s == 'AzureClassic'\r\n| project PrincipalId_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g;\r\nDirectUserAssignments\r\n| distinct PrincipalId_s, RoleDefinition_s, Scope_s, Assignment\r\n| order by PrincipalId_s asc", "size": 1, "showAnalytics": true, "timeContext": { "durationMs": 86400000 }, "exportFieldName": "PrincipalId_s", "exportParameterName": "ClassicPrincipalId", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "PrincipalId_g", "formatter": 5 } ], "rowLimit": 5000, "filter": true, "labelSettings": [ { "columnId": "PrincipalId_s", "label": "Principal Name" }, { "columnId": "RoleDefinition_s", "label": "Role" }, { "columnId": "Scope_s", "label": "Scope" } ] } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "classicRoles" }, "name": "classicRolesTable" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAADObjectsV1_CL\r\n| where TimeGenerated > ago(1d) and ObjectId_g == '{AADPrincipalId}'\r\n| project PrincipalId = ObjectId_g, DisplayName_s\r\n| join kind=inner ( \r\n AzureOptimizationRBACAssignmentsV1_CL\r\n | where TimeGenerated {aadRoleHistoryRange:value} and Model_s == 'AzureAD'\r\n | project PrincipalId = PrincipalId_g, Scope_s, RoleDefinition_s, TimeGenerated\r\n) on PrincipalId\r\n| summarize RoleFirstSeen = min(TimeGenerated), RoleLastSeen = max(TimeGenerated) by DisplayName_s, Scope_s, RoleDefinition_s\r\n| order by RoleLastSeen desc", "size": 1, "title": "Role history for selected principal", "timeContextFromParameter": "aadRoleHistoryRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "aadRoles" }, "name": "aadRoleHistory" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAADObjectsV1_CL\r\n| where TimeGenerated > ago(1d) and ObjectId_g == '{ARMPrincipalId}'\r\n| project PrincipalId = ObjectId_g, DisplayName_s\r\n| join kind=inner ( \r\n AzureOptimizationRBACAssignmentsV1_CL\r\n | where TimeGenerated {armRoleHistoryRange:value} and Model_s in ('AzureRM')\r\n | project PrincipalId = PrincipalId_g, Scope_s, RoleDefinition_s, TimeGenerated\r\n) on PrincipalId\r\n| summarize RoleFirstSeen = min(TimeGenerated), RoleLastSeen = max(TimeGenerated) by DisplayName_s, Scope_s, RoleDefinition_s\r\n| order by RoleLastSeen desc", "size": 1, "title": "Role history for selected principal", "timeContextFromParameter": "armRoleHistoryRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "armRoles" }, "name": "armRoleHistory" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationRBACAssignmentsV1_CL\r\n| where TimeGenerated {classicRoleHistoryRange:value} and Model_s in ('AzureClassic') and PrincipalId_s == '{ClassicPrincipalId}'\r\n| project PrincipalId = PrincipalId_s, Scope_s, RoleDefinition_s, TimeGenerated\r\n| summarize RoleFirstSeen = min(TimeGenerated), RoleLastSeen = max(TimeGenerated) by PrincipalId, Scope_s, RoleDefinition_s\r\n| order by RoleLastSeen desc", "size": 1, "title": "Role history for selected principal", "timeContext": { "durationMs": 2592000000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "classicRoles" }, "name": "classicRoleHistory" } ], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" } ================================================ FILE: views/workbooks/policy-compliance.bicep ================================================ @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') param workbookDisplayName string = 'Policy Compliance' @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') param workbookType string = 'workbook' @description('The id of resource instance to which the workbook will be associated') param workbookSourceId string @description('The unique guid for this workbook instance') param workbookId string = '8fceeb3c-4c7a-4ba9-b97b-a6d9fc8dd6aa' param resourceTags object param resourceGroupLocation string = resourceGroup().location resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { name: workbookId location: resourceGroupLocation tags: resourceTags kind: 'shared' properties: { displayName: workbookDisplayName serializedData: string(loadJsonContent('policy-compliance.json')) version: '1.0' sourceId: workbookSourceId category: workbookType } dependsOn: [] } output workbookId string = workbookId_resource.id ================================================ FILE: views/workbooks/policy-compliance.json ================================================ { "version": "Notebook/1.0", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "fc2daade-225d-4204-9ade-c10f20c23fb5", "version": "KqlParameterItem/1.0", "name": "LastSubsGeneratedDateTime", "label": "Subscription Names Generated On", "type": 1, "isRequired": true, "query": "AzureOptimizationResourceContainersV1_CL\r\n| where TimeGenerated > ago(90d)\r\n| summarize max(TimeGenerated)", "isHiddenWhenLocked": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "version": "KqlParameterItem/1.0", "name": "LastPolicyGeneratedDateTime", "label": "Policy States Generated On", "type": 1, "isRequired": true, "query": "AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > ago(90d)\r\n| summarize max(TimeGenerated)", "isHiddenWhenLocked": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "id": "76f1ffbe-3079-4782-b38c-4385d1f30690" }, { "id": "398ed1df-fb30-4ed7-a872-c1f10a752b40", "version": "KqlParameterItem/1.0", "name": "Subscription", "type": 2, "description": "The subscription filter does not impact other filters", "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationResourceContainersV1_CL\r\n| where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n| where ContainerType_s == 'microsoft.resources/subscriptions'\r\n| project SubscriptionGuid_g, ContainerName_s\r\n| order by ContainerName_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "id": "e8de19a9-bd49-43c6-a0d7-5e6a3eddf3c0", "version": "KqlParameterItem/1.0", "name": "Initiative", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| distinct InitiativeId_s, InitiativeName_s\r\n| where isnotempty(InitiativeId_s)\r\n| distinct InitiativeId_s, InitiativeName_s\r\n| order by InitiativeName_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "", "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "id": "34bfe4a4-f7d0-4970-96ba-9fae4136706b", "version": "KqlParameterItem/1.0", "name": "FilterByInitiative", "label": "Filter by Initiative", "type": 10, "description": "Whether to filter definitions and assignments by selected initiatives", "isRequired": true, "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "jsonData": "[\"Yes\", \"No\"]", "value": "No" }, { "id": "494d3d43-f908-490a-b1cf-8f501ec873f5", "version": "KqlParameterItem/1.0", "name": "Definition", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where iif('{FilterByInitiative}' == 'Yes', InitiativeId_s in ({Initiative}), true)\r\n| distinct DefinitionId_s, DefinitionName_s\r\n| distinct DefinitionId_s, DefinitionName_s\r\n| order by DefinitionName_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "id": "0b3fe5df-b854-42c6-8b1f-9879da99ecee", "version": "KqlParameterItem/1.0", "name": "Assignment", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationPolicyStatesV1_CL\r\n| where iif('{FilterByInitiative}' == 'Yes', InitiativeId_s in ({Initiative}), InitiativeId_s in ({Initiative}) or DefinitionId_s in ({Definition}))\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| distinct AssignmentId_s, AssignmentName_s\r\n| order by AssignmentName_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "id": "adad6608-5599-40ae-8ddf-75184552e017", "version": "KqlParameterItem/1.0", "name": "Effect", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where isnotempty(Effect_s)\r\n| distinct Effect_s", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "id": "894c0d85-0b62-4070-9046-8d47aceb8771", "version": "KqlParameterItem/1.0", "name": "ComplianceState", "label": "Compliance State", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| distinct ComplianceState_s", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "id": "700ba782-ca74-4d59-b3bb-9174c6dde4df", "version": "KqlParameterItem/1.0", "name": "TagName", "label": "Tag Name", "type": 2, "query": "AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where isnotempty(Tags_s)\r\n| extend jsonTags = parse_json(Tags_s)\r\n| extend tagKeys = bag_keys(jsonTags)\r\n| mv-expand tagKey = tagKeys\r\n| extend tagKey = trim(' ', tostring(tagKey))\r\n| where tagKey !startswith 'hidden' and tagKey !startswith \"aks-managed\" and tagKey !startswith \"kubernetes.io\"\r\n| distinct tagKey\r\n| order by tagKey asc", "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": null }, { "id": "21aafeaa-f60a-4480-9335-6388b98a3296", "version": "KqlParameterItem/1.0", "name": "TagValue", "label": "Tag Value", "type": 2, "query": "AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where isnotempty(Tags_s) and isnotempty(parse_json(Tags_s)['{TagName:label}'])\r\n| extend tagValue = tostring(parse_json(Tags_s)['{TagName:label}'])\r\n| distinct tagValue\r\n| order by tagValue asc", "typeSettings": { "additionalResourceOptions": [] }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": null } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "parameters-0" }, { "type": 1, "content": { "json": "The **Overview** tab is based on Azure Resource Graph and thus represents the _current state_. **Policy Analysis** and **Full Report** tabs are based on Log Analytics entries collected daily by the Azure Optimization Engine and thus represent the _most recent Policy states snapshot_. Tag filtering applies to Non-Compliant and Exempt states only, with the exception of the Overview tab, where all states are filterable by tag. The Excluded state is not supported in the Overview tab.", "style": "info" }, "name": "introText" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "policyresources\r\n| where type == 'microsoft.policyinsights/policystates'\r\n| extend assignmentId = tolower(properties.policyAssignmentId)\r\n| extend initiativeId = tolower(properties.policySetDefinitionId)\r\n| extend policyId = tolower(properties.policyDefinitionId)\r\n| where initiativeId in ({Initiative}) and policyId in ({Definition}) and assignmentId in ({Assignment})\r\n| where (\"{Subscription}\" == \"'*'\" or subscriptionId in ({Subscription}))\r\n| extend resourceId = tolower(properties.resourceId)\r\n| project resourceId, resourceGroup, subscriptionId, assignmentId, initiativeId, policyId\r\n| join kind=leftouter (\r\n resources\r\n | project resourceId=tolower(id), tags\r\n) on resourceId\r\n| project-away resourceId1\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(tags['{TagName:label}']) and tostring(tags['{TagName:label}']) =~ '{TagValue}')\r\n| summarize StatesCount=count() by initiativeId", "size": 0, "queryType": 1, "resourceType": "microsoft.resources/tenants", "crossComponentResources": [ "value::tenant" ] }, "conditionalVisibility": { "parameterName": "debug", "comparison": "isEqualTo", "value": "true" }, "name": "totalStatesByInitiative" }, { "type": 11, "content": { "version": "LinkItem/1.0", "style": "tabs", "links": [ { "id": "0b5190ae-fdca-4672-a756-bfd3706b48f1", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Overview", "subTarget": "Overview", "style": "link" }, { "id": "3924fee1-f28a-4ab6-b169-2af239fa0e16", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Policy Analysis", "subTarget": "PolicyAnalysis", "style": "link" }, { "id": "032c17fe-3eae-4093-ba6e-e0b1ab4ba51f", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Full Report", "subTarget": "FullReport", "style": "link" } ] }, "name": "links - 5" }, { "type": 1, "content": { "json": "Compliant and Excluded states are aggregated and do not allow for drilling down to resources compliance details.", "style": "info" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isNotEqualTo", "value": "Overview" }, "name": "compliantExplanation" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 1, "content": { "json": "Select the Tag Name only (leaving the Tag Value unset) to see the Non-Compliant states distribution by tag.", "style": "upsell" }, "name": "tagAggregationHint" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "\r\npolicyresources\r\n| where type == 'microsoft.policyinsights/policystates'\r\n| extend assignmentId = tolower(properties.policyAssignmentId)\r\n| extend initiativeId = tolower(properties.policySetDefinitionId)\r\n| extend policyId = tolower(properties.policyDefinitionId)\r\n| where iif('{FilterByInitiative}' == 'Yes', initiativeId in ({Initiative}) and policyId in ({Definition}) and assignmentId in ({Assignment}), policyId in ({Definition}) and assignmentId in ({Assignment}))\r\n| extend complianceState = tostring(properties.complianceState)\r\n| where complianceState in ({ComplianceState}) and (\"{Subscription}\" == \"'*'\" or subscriptionId in ({Subscription}))\r\n| extend resourceType = tolower(properties.resourceType)\r\n| extend stateWeight = tostring(properties.stateWeight)\r\n| extend timeGenerated = tostring(properties.timestamp)\r\n| extend resourceId = tolower(properties.resourceId)\r\n| join kind=inner ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policyassignments'\r\n | project assignmentId = tolower(id), assignmentName = tostring(properties.displayName)\r\n) on assignmentId\r\n| join kind=leftouter ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policysetdefinitions'\r\n | project initiativeId = tolower(id), initiativeName = tostring(properties.displayName)\r\n) on initiativeId\r\n| join kind=inner ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policydefinitions'\r\n | project policyId = tolower(id), policyName = tostring(properties.displayName)\r\n) on policyId\r\n| project resourceId, resourceType, resourceGroup, subscriptionId, assignmentId, assignmentName, initiativeId, initiativeName, policyId, policyName, complianceState, stateWeight, timeGenerated\r\n| join kind=leftouter (\r\n resources\r\n | project resourceId=tolower(id), tags\r\n) on resourceId\r\n| project-away resourceId1\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(tags['{TagName:label}']) and tostring(tags['{TagName:label}']) =~ '{TagValue}')\r\n| summarize count() by complianceState", "size": 1, "title": "Overall Compliance (raw states)", "queryType": 1, "resourceType": "microsoft.resources/tenants", "crossComponentResources": [ "value::tenant" ], "visualization": "piechart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Compliant", "color": "green" }, { "seriesName": "Excluded", "color": "gray" }, { "seriesName": "NonCompliant", "color": "redBright" }, { "seriesName": "Exempt", "color": "turquoise" } ] } }, "customWidth": "50", "name": "argComplianceSummary" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "policyresources\r\n| where type == 'microsoft.policyinsights/policystates'\r\n| extend assignmentId = tolower(properties.policyAssignmentId)\r\n| extend initiativeId = tolower(properties.policySetDefinitionId)\r\n| extend policyId = tolower(properties.policyDefinitionId)\r\n| where iif('{FilterByInitiative}' == 'Yes', initiativeId in ({Initiative}) and policyId in ({Definition}) and assignmentId in ({Assignment}), policyId in ({Definition}) and assignmentId in ({Assignment}))\r\n| extend complianceState = tostring(properties.complianceState)\r\n| where complianceState in ({ComplianceState}) and (\"{Subscription}\" == \"'*'\" or subscriptionId in ({Subscription}))\r\n| extend resourceType = tolower(properties.resourceType)\r\n| extend stateWeight = tostring(properties.stateWeight)\r\n| extend timeGenerated = tostring(properties.timestamp)\r\n| extend resourceId = tolower(properties.resourceId)\r\n| join kind=inner ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policyassignments'\r\n | project assignmentId = tolower(id), assignmentName = tostring(properties.displayName)\r\n) on assignmentId\r\n| join kind=leftouter ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policysetdefinitions'\r\n | project initiativeId = tolower(id), initiativeName = tostring(properties.displayName)\r\n) on initiativeId\r\n| join kind=inner ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policydefinitions'\r\n | project policyId = tolower(id), policyName = tostring(properties.displayName)\r\n) on policyId\r\n| project resourceId, resourceType, resourceGroup, subscriptionId, assignmentId, assignmentName, initiativeId, initiativeName, policyId, policyName, complianceState, stateWeight, timeGenerated\r\n| join kind=leftouter (\r\n resources\r\n | project resourceId=tolower(id), tags\r\n) on resourceId\r\n| project-away resourceId1\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(tags['{TagName:label}']) and tostring(tags['{TagName:label}']) =~ '{TagValue}')\r\n| distinct resourceId, complianceState\r\n| summarize complianceStates=make_list(complianceState) by resourceId\r\n| extend fullComplianceState = iif(complianceStates !contains 'NonCompliant', iif(complianceStates contains 'Exempt' and array_length(complianceStates) == 1, 'Exempt', 'Compliant'), 'NonCompliant')\r\n| summarize dcount(resourceId) by fullComplianceState", "size": 1, "title": "Resource Compliance", "queryType": 1, "resourceType": "microsoft.resources/tenants", "crossComponentResources": [ "value::tenant" ], "visualization": "piechart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Compliant", "color": "green" }, { "seriesName": "Excluded", "color": "gray" }, { "seriesName": "NonCompliant", "color": "redBright" }, { "seriesName": "Exempt", "color": "turquoise" } ] } }, "customWidth": "50", "name": "argComplianceSummaryResources" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "policyresources\r\n| where type == 'microsoft.policyinsights/policystates'\r\n| extend assignmentId = tolower(properties.policyAssignmentId)\r\n| extend initiativeId = tolower(properties.policySetDefinitionId)\r\n| extend policyId = tolower(properties.policyDefinitionId)\r\n| where iif('{FilterByInitiative}' == 'Yes', initiativeId in ({Initiative}) and policyId in ({Definition}) and assignmentId in ({Assignment}), policyId in ({Definition}) and assignmentId in ({Assignment}))\r\n| extend complianceState = tostring(properties.complianceState)\r\n| where complianceState == 'NonCompliant' and (\"{Subscription}\" == \"'*'\" or subscriptionId in ({Subscription}))\r\n| extend resourceType = tolower(properties.resourceType)\r\n| extend stateWeight = tostring(properties.stateWeight)\r\n| extend timeGenerated = tostring(properties.timestamp)\r\n| extend resourceId = tolower(properties.resourceId)\r\n| project resourceId, resourceType, resourceGroup, subscriptionId, assignmentId, initiativeId, policyId, complianceState, stateWeight, timeGenerated\r\n| join kind=leftouter (\r\n resources\r\n | project resourceId=tolower(id), tags\r\n) on resourceId\r\n| project-away resourceId1\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(tags['{TagName:label}']) and tostring(tags['{TagName:label}']) =~ '{TagValue}')\r\n| distinct resourceId, subscriptionId\r\n| summarize dcount(resourceId) by subscriptionId\r\n| join kind=leftouter (\r\n resourcecontainers\r\n | where type == 'microsoft.resources/subscriptions'\r\n | project Subscription=name, subscriptionId\r\n) on subscriptionId\r\n| project-away subscriptionId, subscriptionId1", "size": 1, "title": "Non-compliant resources by Subscription", "queryType": 1, "resourceType": "microsoft.resources/tenants", "crossComponentResources": [ "value::tenant" ], "visualization": "piechart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Compliant", "color": "green" }, { "seriesName": "Excluded", "color": "gray" }, { "seriesName": "NonCompliant", "color": "redBright" }, { "seriesName": "Exempt", "color": "turquoise" } ] } }, "customWidth": "50", "name": "argNonComplianceBySubscriptionSummaryResources" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "policyresources\r\n| where type == 'microsoft.policyinsights/policystates'\r\n| extend assignmentId = tolower(properties.policyAssignmentId)\r\n| extend initiativeId = tolower(properties.policySetDefinitionId)\r\n| extend policyId = tolower(properties.policyDefinitionId)\r\n| where iif('{FilterByInitiative}' == 'Yes', initiativeId in ({Initiative}) and policyId in ({Definition}) and assignmentId in ({Assignment}), policyId in ({Definition}) and assignmentId in ({Assignment}))\r\n| extend complianceState = tostring(properties.complianceState)\r\n| where complianceState == 'NonCompliant' and (\"{Subscription}\" == \"'*'\" or subscriptionId in ({Subscription}))\r\n| extend resourceType = tolower(properties.resourceType)\r\n| extend stateWeight = tostring(properties.stateWeight)\r\n| extend timeGenerated = tostring(properties.timestamp)\r\n| extend resourceId = tolower(properties.resourceId)\r\n| project resourceId, resourceType, resourceGroup, subscriptionId, assignmentId, initiativeId, policyId, complianceState, stateWeight, timeGenerated\r\n| join kind=leftouter (\r\n resources\r\n | project resourceId=tolower(id), tags\r\n) on resourceId\r\n| project-away resourceId1\r\n| where iif('{TagName:label}' == '', true, iif('{TagValue:label}' == '', true, isnotempty(tags['{TagName:label}']) and tostring(tags['{TagName:label}']) =~ '{TagValue}'))\r\n| extend TagValue = iif('{TagName:label}' == '', 'N/A', tostring(tags['{TagName:label}']))\r\n| distinct resourceId, TagValue\r\n| summarize dcount(resourceId) by TagValue", "size": 1, "title": "Non-compliant resources by Tag", "queryType": 1, "resourceType": "microsoft.resources/tenants", "crossComponentResources": [ "value::tenant" ], "visualization": "piechart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Compliant", "color": "green" }, { "seriesName": "Excluded", "color": "gray" }, { "seriesName": "NonCompliant", "color": "redBright" }, { "seriesName": "Exempt", "color": "turquoise" } ] } }, "customWidth": "50", "name": "argNonComplianceByTagSummaryResources" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "policyresources\r\n| where type == 'microsoft.policyinsights/policystates'\r\n| extend assignmentId = tolower(properties.policyAssignmentId)\r\n| extend initiativeId = tolower(properties.policySetDefinitionId)\r\n| extend policyId = tolower(properties.policyDefinitionId)\r\n| where iif('{FilterByInitiative}' == 'Yes', initiativeId in ({Initiative}) and policyId in ({Definition}) and assignmentId in ({Assignment}), policyId in ({Definition}) and assignmentId in ({Assignment}))\r\n| extend complianceState = tostring(properties.complianceState)\r\n| where complianceState in ({ComplianceState}) and (\"{Subscription}\" == \"'*'\" or subscriptionId in ({Subscription}))\r\n| extend resourceId = tolower(properties.resourceId)\r\n| join kind=leftouter ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policysetdefinitions'\r\n | project initiativeId = tolower(id), initiativeName = tostring(properties.displayName)\r\n) on initiativeId\r\n| where isnotempty(initiativeName)\r\n| project resourceId, resourceGroup, subscriptionId, assignmentId, initiativeId, initiativeName, policyId, complianceState\r\n| join kind=leftouter (\r\n resources\r\n | project resourceId=tolower(id), tags\r\n) on resourceId\r\n| project-away resourceId1\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(tags['{TagName:label}']) and tostring(tags['{TagName:label}']) =~ '{TagValue}')\r\n| distinct resourceId, complianceState, initiativeName\r\n| summarize complianceStates=make_list(complianceState) by resourceId, initiativeName\r\n| extend fullComplianceState = iif(complianceStates !contains 'NonCompliant', iif(complianceStates contains 'Exempt' and array_length(complianceStates) == 1, 'Exempt', 'Compliant'), 'NonCompliant')\r\n| summarize StatesCount=dcount(resourceId), CompliantCount=dcountif(resourceId, fullComplianceState == 'Compliant'), NonCompliantCount=dcountif(resourceId, fullComplianceState == 'NonCompliant'), ExemptCount=dcountif(resourceId, fullComplianceState == 'Exempt') by initiativeName\r\n| project initiativeName, CompliantPercent = round(todouble(CompliantCount)/StatesCount*100), NonCompliantPercent = round(todouble(NonCompliantCount)/StatesCount*100), ExemptPercent = round(todouble(ExemptCount)/StatesCount*100)\r\n| order by NonCompliantPercent, initiativeName asc", "size": 0, "title": "Resource Compliance (by Initiative)", "queryType": 1, "resourceType": "microsoft.resources/tenants", "crossComponentResources": [ "value::tenant" ], "visualization": "table", "gridSettings": { "formatters": [ { "columnMatch": "CompliantPercent", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": ">", "thresholdValue": "75", "representation": "Sev4", "text": "{0}{1}" }, { "operator": ">", "thresholdValue": "50", "representation": "Sev2", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "Sev1", "text": "{0}{1}" } ] }, "numberFormat": { "unit": 1, "options": { "style": "decimal" } } }, { "columnMatch": "NonCompliantPercent", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": ">", "thresholdValue": "50", "representation": "Sev1", "text": "{0}{1}" }, { "operator": ">", "thresholdValue": "25", "representation": "Sev2", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "Sev4", "text": "{0}{1}" } ] }, "numberFormat": { "unit": 1, "options": { "style": "decimal" } } }, { "columnMatch": "ExemptPercent", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal" } } } ], "labelSettings": [ { "columnId": "initiativeName", "label": "Initiative" }, { "columnId": "CompliantPercent", "label": "Compliant" }, { "columnId": "NonCompliantPercent", "label": "Non Compliant" }, { "columnId": "ExemptPercent", "label": "Exempt" } ] }, "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Compliant", "color": "green" }, { "seriesName": "Excluded", "color": "gray" }, { "seriesName": "NonCompliant", "color": "redBright" }, { "seriesName": "Exempt", "color": "turquoise" } ] } }, "customWidth": "50", "name": "argComplianceByInitiativePercentageResources" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "policyresources\r\n| where type == 'microsoft.policyinsights/policystates'\r\n| extend assignmentId = tolower(properties.policyAssignmentId)\r\n| extend initiativeId = tolower(properties.policySetDefinitionId)\r\n| extend policyId = tolower(properties.policyDefinitionId)\r\n| where iif('{FilterByInitiative}' == 'Yes', initiativeId in ({Initiative}) and policyId in ({Definition}) and assignmentId in ({Assignment}), policyId in ({Definition}) and assignmentId in ({Assignment}))\r\n| extend complianceState = tostring(properties.complianceState)\r\n| where complianceState in ({ComplianceState}) and (\"{Subscription}\" == \"'*'\" or subscriptionId in ({Subscription}))\r\n| extend resourceId = tolower(properties.resourceId)\r\n| join kind=leftouter ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policydefinitions'\r\n | project policyId = tolower(id), definitionName = tostring(properties.displayName)\r\n) on policyId\r\n| project resourceId, resourceGroup, subscriptionId, assignmentId, initiativeId, policyId, definitionName, complianceState\r\n| join kind=leftouter (\r\n resources\r\n | project resourceId=tolower(id), tags\r\n) on resourceId\r\n| project-away resourceId1\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(tags['{TagName:label}']) and tostring(tags['{TagName:label}']) =~ '{TagValue}')\r\n| summarize StatesCount=count(), CompliantCount=countif(complianceState == 'Compliant'), NonCompliantCount=countif(complianceState == 'NonCompliant'), ExemptCount=countif(complianceState == 'Exempt') by definitionName\r\n| project definitionName, CompliantPercent = round(todouble(CompliantCount)/StatesCount*100), NonCompliantPercent = round(todouble(NonCompliantCount)/StatesCount*100), ExemptPercent = round(todouble(ExemptCount)/StatesCount*100)\r\n| order by NonCompliantPercent, definitionName asc", "size": 0, "title": "Resource Compliance (by Definition)", "queryType": 1, "resourceType": "microsoft.resources/tenants", "crossComponentResources": [ "value::tenant" ], "visualization": "table", "gridSettings": { "formatters": [ { "columnMatch": "CompliantPercent", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": ">", "thresholdValue": "75", "representation": "Sev4", "text": "{0}{1}" }, { "operator": ">", "thresholdValue": "50", "representation": "Sev2", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "Sev1", "text": "{0}{1}" } ] }, "numberFormat": { "unit": 1, "options": { "style": "decimal" } } }, { "columnMatch": "NonCompliantPercent", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": ">", "thresholdValue": "50", "representation": "Sev1", "text": "{0}{1}" }, { "operator": ">", "thresholdValue": "25", "representation": "Sev2", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "Sev4", "text": "{0}{1}" } ] }, "numberFormat": { "unit": 1, "options": { "style": "decimal" } } }, { "columnMatch": "ExemptPercent", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal" } } } ], "rowLimit": 10000, "sortBy": [ { "itemKey": "$gen_thresholds_NonCompliantPercent_2", "sortOrder": 2 } ], "labelSettings": [ { "columnId": "definitionName", "label": "Definition" }, { "columnId": "CompliantPercent", "label": "Compliant" }, { "columnId": "NonCompliantPercent", "label": "Non Compliant" }, { "columnId": "ExemptPercent", "label": "Exempt" } ] }, "sortBy": [ { "itemKey": "$gen_thresholds_NonCompliantPercent_2", "sortOrder": 2 } ], "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Compliant", "color": "green" }, { "seriesName": "Excluded", "color": "gray" }, { "seriesName": "NonCompliant", "color": "redBright" }, { "seriesName": "Exempt", "color": "turquoise" } ] } }, "customWidth": "50", "name": "argComplianceByDefinitionPercentage" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "policyresources\r\n| where type == 'microsoft.policyinsights/policystates'\r\n| extend assignmentId = tolower(properties.policyAssignmentId)\r\n| extend initiativeId = tolower(properties.policySetDefinitionId)\r\n| extend policyId = tolower(properties.policyDefinitionId)\r\n| where iif('{FilterByInitiative}' == 'Yes', initiativeId in ({Initiative}) and policyId in ({Definition}) and assignmentId in ({Assignment}), policyId in ({Definition}) and assignmentId in ({Assignment}))\r\n| extend complianceState = tostring(properties.complianceState)\r\n| where complianceState == 'NonCompliant' and (\"{Subscription}\" == \"'*'\" or subscriptionId in ({Subscription}))\r\n| extend resourceType = tolower(properties.resourceType)\r\n| extend stateWeight = tostring(properties.stateWeight)\r\n| extend timeGenerated = tostring(properties.timestamp)\r\n| extend resourceId = tolower(properties.resourceId)\r\n| join kind=inner ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policyassignments'\r\n | project assignmentId = tolower(id), assignmentName = tostring(properties.displayName)\r\n) on assignmentId\r\n| join kind=leftouter ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policysetdefinitions'\r\n | project initiativeId = tolower(id), initiativeName = tostring(properties.displayName)\r\n) on initiativeId\r\n| where isnotempty(initiativeName)\r\n| join kind=inner ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policydefinitions'\r\n | project policyId = tolower(id), policyName = tostring(properties.displayName)\r\n) on policyId\r\n| project resourceId, resourceType, resourceGroup, subscriptionId, assignmentId, assignmentName, initiativeId, initiativeName, policyId, policyName, complianceState, stateWeight, timeGenerated\r\n| join kind=leftouter (\r\n resources\r\n | project resourceId=tolower(id), tags\r\n) on resourceId\r\n| project-away resourceId1\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(tags['{TagName:label}']) and tostring(tags['{TagName:label}']) =~ '{TagValue}')\r\n| distinct resourceId, initiativeName\r\n| summarize dcount(resourceId) by initiativeName\r\n| order by dcount_resourceId", "size": 0, "title": "Non-compliant resources by Initiative", "queryType": 1, "resourceType": "microsoft.resources/tenants", "crossComponentResources": [ "value::tenant" ], "gridSettings": { "labelSettings": [ { "columnId": "initiativeName", "label": "Initiative" }, { "columnId": "dcount_resourceId", "label": "Resource #" } ] }, "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Compliant", "color": "green" }, { "seriesName": "Excluded", "color": "gray" }, { "seriesName": "NonCompliant", "color": "redBright" }, { "seriesName": "Exempt", "color": "turquoise" } ] } }, "customWidth": "50", "name": "argComplianceByInitiativeResources" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "policyresources\r\n| where type == 'microsoft.policyinsights/policystates'\r\n| extend assignmentId = tolower(properties.policyAssignmentId)\r\n| extend initiativeId = tolower(properties.policySetDefinitionId)\r\n| extend policyId = tolower(properties.policyDefinitionId)\r\n| where iif('{FilterByInitiative}' == 'Yes', initiativeId in ({Initiative}) and policyId in ({Definition}) and assignmentId in ({Assignment}), policyId in ({Definition}) and assignmentId in ({Assignment}))\r\n| extend complianceState = tostring(properties.complianceState)\r\n| where complianceState == 'NonCompliant' and (\"{Subscription}\" == \"'*'\" or subscriptionId in ({Subscription}))\r\n| extend resourceType = tolower(properties.resourceType)\r\n| extend stateWeight = tostring(properties.stateWeight)\r\n| extend timeGenerated = tostring(properties.timestamp)\r\n| extend resourceId = tolower(properties.resourceId)\r\n| join kind=inner ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policyassignments'\r\n | project assignmentId = tolower(id), assignmentName = tostring(properties.displayName)\r\n) on assignmentId\r\n| join kind=leftouter ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policysetdefinitions'\r\n | project initiativeId = tolower(id), initiativeName = tostring(properties.displayName)\r\n) on initiativeId\r\n| join kind=inner ( \r\n policyresources\r\n | where type == 'microsoft.authorization/policydefinitions'\r\n | project policyId = tolower(id), policyName = tostring(properties.displayName)\r\n) on policyId\r\n| project resourceId, resourceType, resourceGroup, subscriptionId, assignmentId, assignmentName, initiativeId, initiativeName, policyId, policyName, complianceState, stateWeight, timeGenerated\r\n| join kind=leftouter (\r\n resources\r\n | project resourceId=tolower(id), tags\r\n) on resourceId\r\n| project-away resourceId1\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(tags['{TagName:label}']) and tostring(tags['{TagName:label}']) =~ '{TagValue}')\r\n| summarize ['Resource #']=dcount(resourceId) by policyName\r\n| order by ['Resource #']", "size": 0, "title": "Non-compliant resources by Definition", "queryType": 1, "resourceType": "microsoft.resources/tenants", "crossComponentResources": [ "value::tenant" ], "visualization": "table", "gridSettings": { "labelSettings": [ { "columnId": "policyName", "label": "Definition" }, { "columnId": "count_", "label": "Resource #" } ] }, "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Compliant", "color": "green" }, { "seriesName": "Excluded", "color": "gray" }, { "seriesName": "NonCompliant", "color": "redBright" }, { "seriesName": "Exempt", "color": "turquoise" } ] } }, "customWidth": "50", "name": "argComplianceByDefinition" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Overview" }, "name": "overviewGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ExcludedStates = AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where iif('{FilterByInitiative}' == 'Yes', InitiativeId_s in ({Initiative}) and DefinitionId_s in ({Definition}) and AssignmentId_s in ({Assignment}), DefinitionId_s in ({Definition}) and AssignmentId_s in ({Assignment}))\r\n| where ComplianceState_s == 'Excluded' and 'Excluded' in ({ComplianceState})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| extend SubscriptionGuid_g = iif(ResourceId startswith '/subscriptions/', tostring(split(ResourceId,'/')[2]), '')\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project SubscriptionGuid_g, SubscriptionResourceCount=ResourceCount_s\r\n )\r\n on SubscriptionGuid_g\r\n| extend ManagementGroupId = iif(isempty(SubscriptionGuid_g),ResourceId, '')\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | mv-expand MGAncestor = parse_json(ContainerProperties_s).managementGroupAncestorsChain\r\n | extend ManagementGroupId = tolower(strcat('/providers/microsoft.management/managementgroups/',MGAncestor.name))\r\n | project SubscriptionIdForMG=SubscriptionGuid_g, ManagementGroupId, MGSubscriptionResourceCount=ResourceCount_s\r\n) on ManagementGroupId\r\n| extend SubscriptionGuid_g = iif(isempty(SubscriptionGuid_g), SubscriptionIdForMG, SubscriptionGuid_g)\r\n| where (\"{Subscription}\" == \"'*'\" or SubscriptionGuid_g in ({Subscription}))\r\n| extend ResourceGroupId = ResourceId\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions/resourcegroups'\r\n | extend ResourceGroupId = InstanceId_s\r\n | project ResourceGroupId, ResourceGroupResourceCount=ResourceCount_s\r\n) on ResourceGroupId\r\n| extend ResourceCount = iif(ResourceId !has 'resourcegroups', iif(ResourceId has 'subscriptions', toint(SubscriptionResourceCount), toint(MGSubscriptionResourceCount)), iif(isnotempty(ResourceGroupResourceCount), toint(ResourceGroupResourceCount), 1))\r\n| summarize StatesCount=sum(ResourceCount) by SubscriptionGuid_g, InitiativeName_s, AssignmentName_s, DefinitionName_s, Effect_s, ComplianceState_s;\r\nlet AllButCompliantOrExcludedStates = AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where iif('{FilterByInitiative}' == 'Yes', InitiativeId_s in ({Initiative}) and DefinitionId_s in ({Definition}) and AssignmentId_s in ({Assignment}), DefinitionId_s in ({Definition}) and AssignmentId_s in ({Assignment}))\r\n| where Effect_s in ({Effect}) and ComplianceState_s in ({ComplianceState}) and (\"{Subscription}\" == \"'*'\" or SubscriptionGuid_g in ({Subscription}))\r\n| where ComplianceState_s !in ('Compliant','Excluded')\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| summarize StatesCount=count() by SubscriptionGuid_g, InitiativeName_s, AssignmentName_s, DefinitionName_s, Effect_s, ComplianceState_s;\r\nAzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where iif('{FilterByInitiative}' == 'Yes', InitiativeId_s in ({Initiative}) and DefinitionId_s in ({Definition}) and AssignmentId_s in ({Assignment}), DefinitionId_s in ({Definition}) and AssignmentId_s in ({Assignment}))\r\n| where Effect_s in ({Effect}) and ComplianceState_s in ({ComplianceState}) and (\"{Subscription}\" == \"'*'\" or SubscriptionGuid_g in ({Subscription}))\r\n| where ComplianceState_s == 'Compliant'\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| project SubscriptionGuid_g, InitiativeName_s, AssignmentName_s, DefinitionName_s, Effect_s, ComplianceState_s, StatesCount=tolong(StatesCount_s)\r\n| union (AllButCompliantOrExcludedStates)\r\n| union (ExcludedStates)\r\n| join kind=leftouter ( \r\n AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h \r\n | where ContainerType_s =~ 'microsoft.resources/subscriptions' \r\n | project SubscriptionGuid_g, SubscriptionName = ContainerName_s\r\n) on SubscriptionGuid_g\r\n| project-away SubscriptionGuid_g*\r\n| order by InitiativeName_s asc, DefinitionName_s asc, SubscriptionName asc\r\n| project-reorder DefinitionName_s, InitiativeName_s, AssignmentName_s, SubscriptionName, Effect_s, ComplianceState_s, StatesCount", "size": 0, "showAnalytics": true, "title": "Policy Compliance (select line to analyze further)", "exportedParameters": [ { "fieldName": "DefinitionName_s", "parameterName": "selectedDefinition", "parameterType": 1 }, { "fieldName": "InitiativeName_s", "parameterName": "selectedInitiative", "parameterType": 1 }, { "fieldName": "AssignmentName_s", "parameterName": "selectedAssignment", "parameterType": 1 }, { "fieldName": "SubscriptionName", "parameterName": "selectedSubscription", "parameterType": 1 }, { "fieldName": "ComplianceState_s", "parameterName": "selectedState", "parameterType": 1 } ], "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "table", "gridSettings": { "formatters": [ { "columnMatch": "DefinitionName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "InitiativeName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "AssignmentName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "ComplianceState_s", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "Compliant", "representation": "success", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "NonCompliant", "representation": "error", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Exempt", "representation": "info", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "more", "text": "{0}{1}" } ] } }, { "columnMatch": "SubscriptionGuid_g", "formatter": 15, "formatOptions": { "linkTarget": null, "showIcon": true } } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "DefinitionName_s", "label": "Definition" }, { "columnId": "InitiativeName_s", "label": "Initiative" }, { "columnId": "AssignmentName_s", "label": "Assignment" }, { "columnId": "SubscriptionName", "label": "Subscription" }, { "columnId": "Effect_s", "label": "Effect" }, { "columnId": "ComplianceState_s", "label": "Compliance" }, { "columnId": "StatesCount", "label": "Resources #" } ] } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "PolicyAnalysis" }, "name": "mainQuery" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ExcludedStates = AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where InitiativeName_s == '{selectedInitiative}' and DefinitionName_s == '{selectedDefinition}' and AssignmentName_s == '{selectedAssignment}' and ComplianceState_s == 'Excluded'\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| extend SubscriptionGuid_g = iif(ResourceId startswith '/subscriptions/', tostring(split(ResourceId,'/')[2]), '')\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project SubscriptionGuid_g, SubscriptionResourceCount=ResourceCount_s\r\n )\r\n on SubscriptionGuid_g\r\n| extend ManagementGroupId = iif(isempty(SubscriptionGuid_g),ResourceId, '')\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | mv-expand MGAncestor = parse_json(ContainerProperties_s).managementGroupAncestorsChain\r\n | extend ManagementGroupId = tolower(strcat('/providers/microsoft.management/managementgroups/',MGAncestor.name))\r\n | project SubscriptionIdForMG=SubscriptionGuid_g, ManagementGroupId, MGSubscriptionResourceCount=ResourceCount_s\r\n) on ManagementGroupId\r\n| extend SubscriptionGuid_g = iif(isempty(SubscriptionGuid_g), SubscriptionIdForMG, SubscriptionGuid_g)\r\n| extend ResourceGroupId = ResourceId\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions/resourcegroups'\r\n | extend ResourceGroupId = InstanceId_s\r\n | project ResourceGroupId, ResourceGroupResourceCount=ResourceCount_s\r\n) on ResourceGroupId\r\n| extend ResourceCount = iif(ResourceId !has 'resourcegroups', iif(ResourceId has 'subscriptions', toint(SubscriptionResourceCount), toint(MGSubscriptionResourceCount)), iif(isnotempty(ResourceGroupResourceCount), toint(ResourceGroupResourceCount), 1));\r\nlet AllButCompliantOrExcludedStates = AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where InitiativeName_s == '{selectedInitiative}' and DefinitionName_s == '{selectedDefinition}' and AssignmentName_s == '{selectedAssignment}' and ComplianceState_s !in ('Compliant','Excluded')\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}');\r\nAzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where InitiativeName_s == '{selectedInitiative}' and DefinitionName_s == '{selectedDefinition}' and AssignmentName_s == '{selectedAssignment}' and ComplianceState_s == 'Compliant'\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| union (AllButCompliantOrExcludedStates)\r\n| union (ExcludedStates)\r\n| join kind=leftouter ( \r\n AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h \r\n | where ContainerType_s =~ 'microsoft.resources/subscriptions' \r\n | project SubscriptionGuid_g, SubscriptionName = ContainerName_s\r\n) on SubscriptionGuid_g\r\n| project-away SubscriptionGuid_g*\r\n| where SubscriptionName == '{selectedSubscription}' and ComplianceState_s == '{selectedState}'\r\n| extend ResourceId = iif(ResourceId has 'policyinsights',substring(ResourceId,0,indexof(ResourceId,'/providers/microsoft.policyinsights/')),ResourceId)\r\n| extend ResourceCount = iif(isnotempty(ResourceCount), ResourceCount, iif(isnotempty(StatesCount_s), toint(StatesCount_s), 1))\r\n| project ResourceId, SubscriptionName, DefinitionName_s, AssignmentName_s, Effect_s, ComplianceState_s, ComplianceReason_s, ResourceCount", "size": 1, "showAnalytics": true, "title": "Resources compliance (select a line from above)", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "table", "gridSettings": { "formatters": [ { "columnMatch": "DefinitionName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "AssignmentName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "ComplianceState_s", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "Compliant", "representation": "success", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "NonCompliant", "representation": "error", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Exempt", "representation": "info", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "more", "text": "{0}{1}" } ] } }, { "columnMatch": "InitiativeName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "SubscriptionGuid_g", "formatter": 15, "formatOptions": { "linkTarget": null, "showIcon": true } } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "ResourceId", "label": "Resource" }, { "columnId": "SubscriptionName", "label": "Subscription" }, { "columnId": "DefinitionName_s", "label": "Definition" }, { "columnId": "AssignmentName_s", "label": "Assignment" }, { "columnId": "Effect_s", "label": "Effect" }, { "columnId": "ComplianceState_s", "label": "Compliance" }, { "columnId": "ComplianceReason_s", "label": "Reason" } ] } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "PolicyAnalysis" }, "name": "complianceDetails" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "5cc0b316-c55e-4afa-9b02-6fdeeec10ed0", "version": "KqlParameterItem/1.0", "name": "policyHistoryRange", "label": "Policy History Time Range", "type": 4, "value": { "durationMs": 604800000 }, "typeSettings": { "selectableValues": [ { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2419200000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ], "allowCustom": true }, "timeContext": { "durationMs": 86400000 } } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "PolicyAnalysis" }, "name": "parameters-1" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ExcludedStates = AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{policyHistoryRange:startISO}') and TimeGenerated < todatetime('{policyHistoryRange:endISO}')\r\n| where InitiativeName_s == '{selectedInitiative}' and DefinitionName_s == '{selectedDefinition}' and AssignmentName_s == '{selectedAssignment}' and ComplianceState_s == 'Excluded'\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| extend SubscriptionGuid_g = iif(ResourceId startswith '/subscriptions/', tostring(split(ResourceId,'/')[2]), '')\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project SubscriptionGuid_g, SubscriptionResourceCount=ResourceCount_s\r\n )\r\n on SubscriptionGuid_g\r\n| extend ManagementGroupId = iif(isempty(SubscriptionGuid_g),ResourceId, '')\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | mv-expand MGAncestor = parse_json(ContainerProperties_s).managementGroupAncestorsChain\r\n | extend ManagementGroupId = tolower(strcat('/providers/microsoft.management/managementgroups/',MGAncestor.name))\r\n | project SubscriptionIdForMG=SubscriptionGuid_g, ManagementGroupId, MGSubscriptionResourceCount=ResourceCount_s\r\n) on ManagementGroupId\r\n| extend SubscriptionGuid_g = iif(isempty(SubscriptionGuid_g), SubscriptionIdForMG, SubscriptionGuid_g)\r\n| extend ResourceGroupId = ResourceId\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions/resourcegroups'\r\n | extend ResourceGroupId = InstanceId_s\r\n | project ResourceGroupId, ResourceGroupResourceCount=ResourceCount_s\r\n) on ResourceGroupId\r\n| extend ResourceCount = iif(ResourceId !has 'resourcegroups', iif(ResourceId has 'subscriptions', toint(SubscriptionResourceCount), toint(MGSubscriptionResourceCount)), iif(isnotempty(ResourceGroupResourceCount), toint(ResourceGroupResourceCount), 1))\r\n| distinct TimeGenerated, ResourceId, SubscriptionGuid_g, DefinitionName_s, Effect_s, ComplianceState_s, ResourceCount;\r\nlet AllButCompliantOrExcludedStates = AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{policyHistoryRange:startISO}') and TimeGenerated < todatetime('{policyHistoryRange:endISO}')\r\n| where InitiativeName_s == '{selectedInitiative}' and DefinitionName_s == '{selectedDefinition}' and AssignmentName_s == '{selectedAssignment}' and ComplianceState_s !in ('Compliant','Excluded') and ComplianceState_s == '{selectedState}'\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}');\r\nAzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{policyHistoryRange:startISO}') and TimeGenerated < todatetime('{policyHistoryRange:endISO}')\r\n| where InitiativeName_s == '{selectedInitiative}' and DefinitionName_s == '{selectedDefinition}' and AssignmentName_s == '{selectedAssignment}' and ComplianceState_s == 'Compliant' and ComplianceState_s == '{selectedState}'\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| union (ExcludedStates)\r\n| union (AllButCompliantOrExcludedStates)\r\n| join kind=leftouter ( \r\n AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h \r\n | where ContainerType_s =~ 'microsoft.resources/subscriptions' \r\n | project SubscriptionGuid_g, SubscriptionName = ContainerName_s\r\n) on SubscriptionGuid_g\r\n| project-away SubscriptionGuid_g*\r\n| where SubscriptionName == '{selectedSubscription}' and ComplianceState_s == '{selectedState}'\r\n| extend ResourceId = iif(ResourceId has 'policyinsights',substring(ResourceId,0,indexof(ResourceId,'/providers/microsoft.policyinsights/')),ResourceId)\r\n| extend ResourceCount = iif(isnotempty(ResourceCount), ResourceCount, iif(isnotempty(StatesCount_s), toint(StatesCount_s), 1))\r\n| distinct TimeGenerated, ResourceId, SubscriptionName, DefinitionName_s, Effect_s, ComplianceState_s, ResourceCount\r\n| summarize sum(ResourceCount) by bin(TimeGenerated, 1d)", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Compliance over time (select a line from top grid)", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "timechart", "gridSettings": { "formatters": [ { "columnMatch": "DefinitionName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "ComplianceState_s", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "Compliant", "representation": "success", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "NonCompliant", "representation": "error", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Exempt", "representation": "info", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "more", "text": "{0}{1}" } ] } }, { "columnMatch": "InitiativeName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "AssignmentName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "SubscriptionGuid_g", "formatter": 15, "formatOptions": { "linkTarget": null, "showIcon": true } } ], "rowLimit": 5000 } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "PolicyAnalysis" }, "name": "complianceOverTime" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "PolicyAnalysis" }, "name": "policyAnalysisGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "35390c0a-62fa-4561-8537-0171b41c5a47", "version": "KqlParameterItem/1.0", "name": "FRTagName", "label": "Group by Tag Name", "type": 2, "query": "AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where isnotempty(Tags_s)\r\n| extend jsonTags = parse_json(Tags_s)\r\n| extend tagKeys = bag_keys(jsonTags)\r\n| mv-expand tagKey = tagKeys\r\n| extend tagKey = trim(' ', tostring(tagKey))\r\n| where tagKey !startswith 'hidden' and tagKey !startswith \"aks-managed\" and tagKey !startswith \"kubernetes.io\"\r\n| distinct tagKey\r\n| order by tagKey asc", "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": null } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "fullReportParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ExcludedStates = AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where iif('{FilterByInitiative}' == 'Yes', InitiativeId_s in ({Initiative}) and DefinitionId_s in ({Definition}) and AssignmentId_s in ({Assignment}), DefinitionId_s in ({Definition}) and AssignmentId_s in ({Assignment}))\r\n| where ComplianceState_s == 'Excluded' and 'Excluded' in ({ComplianceState})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| extend SubscriptionGuid_g = iif(ResourceId startswith '/subscriptions/', tostring(split(ResourceId,'/')[2]), '')\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project SubscriptionGuid_g, SubscriptionResourceCount=ResourceCount_s\r\n )\r\n on SubscriptionGuid_g\r\n| extend ManagementGroupId = iif(isempty(SubscriptionGuid_g),ResourceId, '')\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | mv-expand MGAncestor = parse_json(ContainerProperties_s).managementGroupAncestorsChain\r\n | extend ManagementGroupId = tolower(strcat('/providers/microsoft.management/managementgroups/',MGAncestor.name))\r\n | project SubscriptionIdForMG=SubscriptionGuid_g, ManagementGroupId, MGSubscriptionResourceCount=ResourceCount_s\r\n) on ManagementGroupId\r\n| extend SubscriptionGuid_g = iif(isempty(SubscriptionGuid_g), SubscriptionIdForMG, SubscriptionGuid_g)\r\n| where (\"{Subscription}\" == \"'*'\" or SubscriptionGuid_g in ({Subscription}))\r\n| extend ResourceGroupId = ResourceId\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s == 'microsoft.resources/subscriptions/resourcegroups'\r\n | extend ResourceGroupId = InstanceId_s\r\n | project ResourceGroupId, ResourceGroupResourceCount=ResourceCount_s\r\n) on ResourceGroupId\r\n| extend ResourceCount = iif(ResourceId !has 'resourcegroups', iif(ResourceId has 'subscriptions', toint(SubscriptionResourceCount), toint(MGSubscriptionResourceCount)), iif(isnotempty(ResourceGroupResourceCount), toint(ResourceGroupResourceCount), 1))\r\n| project ResourceId, SubscriptionGuid_g, DefinitionName_s, InitiativeName_s, AssignmentName_s, Effect_s, ComplianceState_s, ComplianceReason_s, ResourceCount, GroupByTag='';\r\nlet AllButCompliantOrExcludedStates = AzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where iif('{FilterByInitiative}' == 'Yes', InitiativeId_s in ({Initiative}) and DefinitionId_s in ({Definition}) and AssignmentId_s in ({Assignment}), DefinitionId_s in ({Definition}) and AssignmentId_s in ({Assignment}))\r\n| where Effect_s in ({Effect}) and ComplianceState_s in ({ComplianceState}) and (\"{Subscription}\" == \"'*'\" or SubscriptionGuid_g in ({Subscription}))\r\n| where ComplianceState_s !in ('Compliant','Excluded')\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| extend GroupByTag = iif('{FRTagName:label}' == '' or isempty(parse_json(Tags_s)['{FRTagName:label}']), '', tostring(parse_json(Tags_s)['{FRTagName:label}']))\r\n| distinct ResourceId, SubscriptionGuid_g, DefinitionName_s, InitiativeName_s, AssignmentName_s, Effect_s, ComplianceState_s, ComplianceReason_s, GroupByTag;\r\nAzureOptimizationPolicyStatesV1_CL\r\n| where TimeGenerated > todatetime('{LastPolicyGeneratedDateTime}')-12h\r\n| where (\"{Initiative}\" == \"'*'\" or InitiativeId_s in ({Initiative})) and DefinitionId_s in ({Definition}) and AssignmentId_s in ({Assignment}) and Effect_s in ({Effect}) and ComplianceState_s in ({ComplianceState}) and (\"{Subscription}\" == \"'*'\" or SubscriptionGuid_g in ({Subscription}))\r\n| where ComplianceState_s == 'Compliant'\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| project SubscriptionGuid_g, InitiativeName_s, AssignmentName_s, DefinitionName_s, Effect_s, ComplianceState_s, StatesCount=tolong(StatesCount_s), GroupByTag=''\r\n| union (AllButCompliantOrExcludedStates)\r\n| union (ExcludedStates)\r\n| join kind=leftouter ( AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > todatetime('{LastSubsGeneratedDateTime}')-12h\r\n | where ContainerType_s =~ 'microsoft.resources/subscriptions' \r\n | project SubscriptionGuid_g, SubscriptionName = ContainerName_s\r\n) on SubscriptionGuid_g\r\n| project-away SubscriptionGuid_g*\r\n| order by InitiativeName_s asc, DefinitionName_s asc, SubscriptionName asc\r\n| extend ResourceId = iif(ResourceId has 'policyinsights', substring(ResourceId, 0, indexof(ResourceId, '/providers/microsoft.policyinsights/')), ResourceId)\r\n| extend ResourceCount = iif(isnotempty(ResourceCount), ResourceCount, iif(isnotempty(StatesCount), StatesCount, 1))\r\n| distinct ResourceId, GroupByTag, SubscriptionName, DefinitionName_s, InitiativeName_s, AssignmentName_s, Effect_s, ComplianceState_s, ComplianceReason_s, ResourceCount\r\n| order by InitiativeName_s asc", "size": 2, "showAnalytics": true, "title": "Policy Compliance Full Report", "exportedParameters": [ { "fieldName": "DefinitionName_s", "parameterName": "selectedDefinition", "parameterType": 1 }, { "fieldName": "InitiativeName_s", "parameterName": "selectedInitiative", "parameterType": 1 }, { "fieldName": "AssignmentName_s", "parameterName": "selectedAssignment", "parameterType": 1 }, { "fieldName": "SubscriptionName", "parameterName": "selectedSubscription", "parameterType": 1 }, { "fieldName": "ComplianceState_s", "parameterName": "selectedState", "parameterType": 1 } ], "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "table", "gridSettings": { "formatters": [ { "columnMatch": "ResourceId", "formatter": 13, "formatOptions": { "linkTarget": null, "showIcon": true, "customColumnWidthSetting": "28ch" } }, { "columnMatch": "SubscriptionName", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "18ch" } }, { "columnMatch": "DefinitionName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "InitiativeName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "AssignmentName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "Effect_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "14ch" } }, { "columnMatch": "ComplianceState_s", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "Compliant", "representation": "success", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "NonCompliant", "representation": "error", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Exempt", "representation": "info", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "more", "text": "{0}{1}" } ] } }, { "columnMatch": "ComplianceReason_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "19ch" } }, { "columnMatch": "SubscriptionGuid_g", "formatter": 5 } ], "rowLimit": 10000, "labelSettings": [ { "columnId": "ResourceId", "label": "Resource" }, { "columnId": "GroupByTag", "label": "Group By Tag" }, { "columnId": "SubscriptionName", "label": "Subscription" }, { "columnId": "DefinitionName_s", "label": "Definition" }, { "columnId": "InitiativeName_s", "label": "Initiative" }, { "columnId": "AssignmentName_s", "label": "Assignment" }, { "columnId": "Effect_s", "label": "Effect" }, { "columnId": "ComplianceState_s", "label": "Compliance" }, { "columnId": "ComplianceReason_s", "label": "Reason" }, { "columnId": "ResourceCount", "label": "Resources #" } ] } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "FullReport" }, "name": "fullReport" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "FullReport" }, "name": "fullReportGroup" } ], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" } ================================================ FILE: views/workbooks/recommendations.bicep ================================================ @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') param workbookDisplayName string = 'Recommendations' @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') param workbookType string = 'workbook' @description('The id of resource instance to which the workbook will be associated') param workbookSourceId string @description('The unique guid for this workbook instance') param workbookId string = '5b6ec066-e5a8-463e-a319-919c0a7d7bb6' param resourceTags object param resourceGroupLocation string = resourceGroup().location resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { name: workbookId location: resourceGroupLocation tags: resourceTags kind: 'shared' properties: { displayName: workbookDisplayName serializedData: string(loadJsonContent('recommendations.json')) version: '1.0' sourceId: workbookSourceId category: workbookType } dependsOn: [] } output workbookId string = workbookId_resource.id ================================================ FILE: views/workbooks/recommendations.json ================================================ { "version": "Notebook/1.0", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "f0273bce-7653-4934-92bf-9f1d89832330", "version": "KqlParameterItem/1.0", "name": "LastRecommendationDateTime", "label": "Generated On", "type": 1, "isRequired": true, "query": "AzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > ago(90d)\r\n| summarize max(TimeGenerated)", "isHiddenWhenLocked": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "id": "36de52f8-3a82-4ec5-b55a-38f3d5ac2a87", "version": "KqlParameterItem/1.0", "name": "Subscriptions", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| where isnotempty(SubscriptionName_s)\r\n| distinct SubscriptionName_s\r\n| order by SubscriptionName_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "id": "8c645a8b-ff34-4402-b064-48ef7f5d14ad", "version": "KqlParameterItem/1.0", "name": "Impact", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "typeSettings": { "additionalResourceOptions": [ "value::all" ] }, "jsonData": "[\"High\",\"Medium\",\"Low\"]", "value": [ "value::all" ] }, { "version": "KqlParameterItem/1.0", "name": "Category", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "jsonData": "[\"Cost\", \"Security\", \"OperationalExcellence\", \"HighAvailability\", \"Performance\"]", "value": [ "value::all" ], "id": "be856cc3-5d47-4c41-a368-4ac80def811b" }, { "version": "KqlParameterItem/1.0", "name": "TagName", "label": "Tag Name", "type": 2, "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where isnotempty(Tags_s)\r\n| extend jsonTags = parse_json(Tags_s)\r\n| extend tagKeys = bag_keys(jsonTags)\r\n| mv-expand tagKey = tagKeys\r\n| extend tagKey = trim(' ', tostring(tagKey))\r\n| where tagKey !startswith 'hidden' and tagKey !startswith \"aks-managed\" and tagKey !startswith \"kubernetes.io\"\r\n| distinct tagKey\r\n| order by tagKey asc", "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": null, "id": "cb207140-200f-4237-a00a-f93f61b1ea8e" }, { "version": "KqlParameterItem/1.0", "name": "TagValue", "label": "Tag Value", "type": 2, "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where isnotempty(Tags_s) and isnotempty(parse_json(Tags_s)['{TagName:label}'])\r\n| extend tagValue = tostring(parse_json(Tags_s)['{TagName:label}'])\r\n| distinct tagValue\r\n| order by tagValue asc", "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": null, "id": "5c4ee4a3-a1a9-4210-8281-888dd20e5394" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "globalParameters" }, { "type": 1, "content": { "json": "# Latest Recommendations\r\nGenerated on **{LastRecommendationDateTime}**. If recommendations generation date is *unset*, review the latest Automation Account runbooks status and fix accordingly." }, "name": "titleText" }, { "type": 11, "content": { "version": "LinkItem/1.0", "style": "tabs", "links": [ { "id": "550ca300-c6e8-4d5e-a4bf-ce30d5032dd5", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Overview", "subTarget": "Overview", "style": "link" }, { "id": "e38a5bd0-19c3-48ae-b299-d1eebe76f02a", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Cost", "subTarget": "Cost", "style": "link" }, { "id": "27c86ef8-c64d-43be-af08-dd73bd44c0bc", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Security", "subTarget": "Security", "style": "link" }, { "id": "1636acc7-3a5c-4553-8a58-16636461d65e", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Operational Excellence", "subTarget": "OperationalExcellence", "style": "link" }, { "id": "5c94d48d-e48b-443b-b9d6-76cc1d2c4b51", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "High Availability", "subTarget": "HighAvailability", "style": "link" }, { "id": "12182804-33fe-48a3-aca2-7aaeb2367d23", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Performance", "subTarget": "Performance", "style": "link" } ] }, "name": "links - 4" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by Category\r\n", "size": 4, "title": "Count by category", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "HighAvailability", "color": "lightBlue" }, { "seriesName": "OperationalExcellence", "color": "magenta" }, { "seriesName": "Cost", "color": "turquoise" }, { "seriesName": "Security", "color": "brown" }, { "seriesName": "Performance", "color": "blueDarkDark" } ] } }, "customWidth": "50", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Overview" }, "showPin": true, "name": "categoriesSummary" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by Impact_s\r\n", "size": 4, "title": "Count by impact", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Medium", "color": "yellow" }, { "seriesName": "Low", "color": "blue" }, { "seriesName": "High", "color": "redBright" } ] } }, "customWidth": "50", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Overview" }, "showPin": true, "name": "impactSummary" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by SubscriptionName_s\r\n", "size": 4, "title": "Count by subscription", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Overview" }, "showPin": true, "name": "subscriptionsSummary" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by ImpactedArea_s", "size": 4, "title": "Count by impacted area", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Overview" }, "showPin": true, "name": "impactedAreaSummary" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > ago(730d)\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| extend Weight = iif(Impact_s == 'Low', 1, iif(Impact_s == 'Medium', 5, 15))\r\n| summarize WeightedCount=sum(Weight) by Category, bin(TimeGenerated, 7d)\r\n| join kind=inner ( \r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(730d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | where \"'*'\" == \"{Subscriptions}\" or ContainerName_s in ({Subscriptions})\r\n | summarize ResourceCount=sum(toint(ResourceCount_s)) by bin(TimeGenerated, 1d)\r\n) on TimeGenerated\r\n| extend Score = round((1-todouble(WeightedCount)/todouble(ResourceCount))*100,1)\r\n| project Category, TimeGenerated, Score", "size": 1, "aggregation": 3, "title": "Optimization Score by Category Over Time (weighted by recommendation impact and overall resources count)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "timechart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Cost", "color": "turquoise" }, { "seriesName": "OperationalExcellence", "color": "magenta" }, { "seriesName": "HighAvailability", "color": "lightBlue" }, { "seriesName": "Security", "color": "brown" }, { "seriesName": "Performance", "color": "blueDarkDark" } ] } }, "customWidth": "80", "name": "scoreByCategoryOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > todatetime('{LastRecommendationDateTime}')-27d\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| make-series RecCounts=count() on TimeGenerated from todatetime('{LastRecommendationDateTime}')-27d to todatetime('{LastRecommendationDateTime}') step 7d by InstanceId_s, RecommendationDescription_s\r\n| where array_length(RecCounts) == 4 and not(set_has_element(RecCounts, 0))\r\n| summarize count() by Text='over past 4 weeks'", "size": 4, "title": "Long standing recommendations #", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "tiles", "tileSettings": { "titleContent": { "columnMatch": "count_", "formatter": 12, "formatOptions": { "palette": "none" }, "numberFormat": { "unit": 0, "options": { "style": "decimal" } } }, "subtitleContent": { "columnMatch": "Text", "formatter": 1 }, "rightContent": { "columnMatch": "Count", "formatter": 12, "formatOptions": { "palette": "none" } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "20", "name": "longStandingRecs", "styleSettings": { "margin": "20px", "padding": "20px" } }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > ago(730d)\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| summarize count() by Category, bin(TimeGenerated, 7d)", "size": 1, "aggregation": 3, "title": "Recommendations # by Category Over Time", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "timechart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "OperationalExcellence", "color": "magenta" }, { "seriesName": "HighAvailability", "color": "lightBlue" }, { "seriesName": "Cost", "color": "turquoise" }, { "seriesName": "Security", "color": "brown" }, { "seriesName": "Performance", "color": "blueDarkDark" } ] } }, "customWidth": "80", "name": "recsCategoryOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let PreviousWeek = AzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > todatetime('{LastRecommendationDateTime}')-8d and TimeGenerated < todatetime('{LastRecommendationDateTime}')-1d\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| summarize PreviousWeekCount=count() by InstanceId_s, RecommendationSubTypeId_g;\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > todatetime('{LastRecommendationDateTime}')-1d\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| summarize ThisWeekCount=count() by InstanceId_s, RecommendationSubTypeId_g\r\n| join kind=leftouter (PreviousWeek) on InstanceId_s, RecommendationSubTypeId_g\r\n| where isempty(InstanceId_s1) and isempty(RecommendationSubTypeId_g1)\r\n| summarize sum(ThisWeekCount) by Text='since previous week'", "size": 4, "title": "New recommendations #", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "tiles", "tileSettings": { "titleContent": { "columnMatch": "sum_ThisWeekCount", "formatter": 12, "formatOptions": { "palette": "none" }, "numberFormat": { "unit": 0, "options": { "style": "decimal" } } }, "subtitleContent": { "columnMatch": "Text", "formatter": 1 }, "rightContent": { "columnMatch": "Count", "formatter": 12, "formatOptions": { "palette": "none" } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "20", "name": "newRecs", "styleSettings": { "margin": "20px", "padding": "20px" } }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > ago(730d)\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| summarize count() by Impact_s, bin(TimeGenerated, 7d)", "size": 1, "aggregation": 3, "title": "Recommendations # by Impact Over Time", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "timechart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Medium", "color": "yellow" }, { "seriesName": "High", "color": "redBright" }, { "seriesName": "Low", "color": "blue" } ] } }, "customWidth": "80", "name": "recsImpactOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let PreviousWeek = AzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > todatetime('{LastRecommendationDateTime}')-8d and TimeGenerated < todatetime('{LastRecommendationDateTime}')-1d\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| summarize PreviousWeekCount=count() by InstanceId_s, RecommendationSubTypeId_g;\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > todatetime('{LastRecommendationDateTime}')-1d\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| summarize ThisWeekCount=count() by InstanceId_s, RecommendationSubTypeId_g\r\n| join kind=rightouter (PreviousWeek) on InstanceId_s, RecommendationSubTypeId_g\r\n| where isempty(InstanceId_s) and isempty(RecommendationSubTypeId_g)\r\n| summarize sum(PreviousWeekCount) by Text='since previous week'", "size": 4, "title": "Dropped recommendations #", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "tiles", "tileSettings": { "titleContent": { "columnMatch": "sum_PreviousWeekCount", "formatter": 12, "formatOptions": { "palette": "none" }, "numberFormat": { "unit": 0, "options": { "style": "decimal" } } }, "subtitleContent": { "columnMatch": "Text", "formatter": 1 }, "rightContent": { "columnMatch": "Count", "formatter": 12, "formatOptions": { "palette": "none" } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "20", "name": "droppedRecs", "styleSettings": { "margin": "20px", "padding": "20px" } }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > ago(730d)\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where Category in ({Category})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| summarize RecCount=count() by bin(TimeGenerated, 7d)\r\n| join kind=inner ( \r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(730d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | where \"'*'\" == \"{Subscriptions}\" or ContainerName_s in ({Subscriptions})\r\n | summarize ResourceCount=sum(toint(ResourceCount_s)) by bin(TimeGenerated, 1d)\r\n) on TimeGenerated\r\n| project TimeGenerated, ResourceCount", "size": 1, "aggregation": 3, "title": "Resources # Over Time", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "timechart", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "Cost", "color": "turquoise" }, { "seriesName": "OperationalExcellence", "color": "magenta" }, { "seriesName": "HighAvailability", "color": "lightBlue" }, { "seriesName": "Security", "color": "brown" }, { "seriesName": "Performance", "color": "blueDarkDark" } ] } }, "customWidth": "80", "name": "resourcesOverTime" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Overview" }, "name": "overviewGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 1, "content": { "json": "* Estimated cost savings are relative to 30 days of consumption.\r\n* Cost values are based on the currency of your consumption agreement. Adjust the currency parameter below to reflect your actual currency (changing the currency code does not perform any currency exchange in cost values).", "style": "info" }, "name": "text - 5" }, { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "143959c5-1b91-4f61-bc65-970aa2c66b8d", "version": "KqlParameterItem/1.0", "name": "Currency", "type": 1, "description": "The currency to display savings amounts. Must be your Azure consumption agreement currency.", "isRequired": true, "timeContext": { "durationMs": 86400000 }, "value": "EUR" }, { "id": "1d6e5f51-df51-4989-90ee-0a014253bbf9", "version": "KqlParameterItem/1.0", "name": "USDRate", "label": "USD Rate", "type": 1, "description": "Used to convert some of the Azure Advisor-generated USD savings into your currency. If your currency is USD, set the rate to 1.", "isRequired": true, "timeContext": { "durationMs": 86400000 }, "value": "0.93" }, { "id": "941690c0-8e7f-48fe-b8ad-f668b066bae5", "version": "KqlParameterItem/1.0", "name": "CostFitScore", "label": "Min. Fit Score", "type": 1, "description": "Minimum acceptable fit score", "isRequired": true, "timeContext": { "durationMs": 86400000 }, "value": "-1" }, { "id": "69ecab4f-05bb-4b53-aa39-68a30e09a70b", "version": "KqlParameterItem/1.0", "name": "Origin", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "typeSettings": { "additionalResourceOptions": [ "value::all" ] }, "jsonData": "[\"Advisor\",\"Custom\"]", "value": [ "value::all" ] }, { "id": "57abf104-8b39-4d00-934b-1365a8d34266", "version": "KqlParameterItem/1.0", "name": "RecommendationsSubType", "label": "Type", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Cost'\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| where Origin in ({Origin:value})\r\n| distinct RecommendationSubType_s\r\n| order by RecommendationSubType_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "version": "KqlParameterItem/1.0", "name": "CostRecommendations", "label": "Recommendations", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Cost'\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| where Origin in ({Origin:value})\r\n| where RecommendationSubType_s in ({RecommendationsSubType:value})\r\n| distinct RecommendationDescription_s\r\n| order by RecommendationDescription_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ], "id": "64fb1183-a7fa-43e7-be95-cc7a3afc4733" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "costParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Cost'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {CostFitScore}\r\n| where RecommendationDescription_s in ({CostRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend SavingsAmount = todouble(parse_json(AdditionalInfo_s).savingsAmount)\r\n| extend Currency = iif(isnotempty(parse_json(AdditionalInfo_s).savingsCurrency), parse_json(AdditionalInfo_s).savingsCurrency, '{Currency}')\r\n| extend SavingsAmount = iif(Currency == 'USD', SavingsAmount*{USDRate}, SavingsAmount)\r\n| summarize arg_max(SavingsAmount, *) by RecommendationSubTypeId_g, InstanceName_s, ResourceGroup, SubscriptionGuid_g, tostring(parse_json(AdditionalInfo_s).sku)\r\n| summarize sum(SavingsAmount) by SubscriptionName_s", "size": 4, "title": "Potential cost savings by subscription (in {Currency})", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Cost" }, "showPin": true, "name": "costSavingsBySubscription" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Cost'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {CostFitScore}\r\n| where RecommendationDescription_s in ({CostRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend SavingsAmount = todouble(parse_json(AdditionalInfo_s).savingsAmount)\r\n| extend Currency = iif(isnotempty(parse_json(AdditionalInfo_s).savingsCurrency), parse_json(AdditionalInfo_s).savingsCurrency, '{Currency}')\r\n| extend SavingsAmount = iif(Currency == 'USD', SavingsAmount*{USDRate}, SavingsAmount)\r\n| summarize arg_max(SavingsAmount, *) by RecommendationSubTypeId_g, InstanceName_s, ResourceGroup, SubscriptionGuid_g, tostring(parse_json(AdditionalInfo_s).sku)\r\n| summarize sum(SavingsAmount) by RecommendationSubType_s", "size": 4, "title": "Potential savings by type (in {Currency})", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Cost" }, "showPin": true, "name": "costSavingsByType" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Cost'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {CostFitScore}\r\n| where RecommendationDescription_s in ({CostRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend SavingsAmount = todouble(parse_json(AdditionalInfo_s).savingsAmount)\r\n| extend Currency = iif(isnotempty(parse_json(AdditionalInfo_s).savingsCurrency), parse_json(AdditionalInfo_s).savingsCurrency, '{Currency}')\r\n| extend SavingsAmount = iif(Currency == 'USD', SavingsAmount*{USDRate}, SavingsAmount)\r\n| summarize arg_max(SavingsAmount, *) by RecommendationSubTypeId_g, InstanceName_s, ResourceGroup, SubscriptionGuid_g, tostring(parse_json(AdditionalInfo_s).sku)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| summarize sum(SavingsAmount) by Origin", "size": 4, "title": "Potential savings by origin (in {Currency})", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Cost" }, "showPin": true, "name": "costSavingsByOrigin" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| where Category == 'Cost'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {CostFitScore}\r\n| where RecommendationDescription_s in ({CostRecommendations})\r\n| extend SavingsAmount = todouble(parse_json(AdditionalInfo_s).savingsAmount)\r\n| summarize arg_max(SavingsAmount, *) by RecommendationSubTypeId_g, InstanceName_s, ResourceGroup, SubscriptionGuid_g, tostring(parse_json(AdditionalInfo_s).sku)\r\n| extend Currency = iif(isnotempty(parse_json(AdditionalInfo_s).savingsCurrency), parse_json(AdditionalInfo_s).savingsCurrency, '{Currency}')\r\n| extend SavingsAmount = iif(Currency == 'USD', SavingsAmount*{USDRate}, SavingsAmount)\r\n| project Recommendation=RecommendationDescription_s, Instance=InstanceName_s, ['Resource Group']=ResourceGroup, Subscription=SubscriptionName_s, Impact=Impact_s, ['Fit Score']=FitScore_d, ['Savings ({Currency})']=SavingsAmount, Tags=parse_json(Tags_s), ['Additional Info']=parse_json(AdditionalInfo_s), Details=DetailsURL_s\r\n| order by ['Savings ({Currency})']", "size": 0, "noDataMessage": "No Cost recommendations available", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "Recommendation", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "Instance", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "Resource Group", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "Subscription", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "25ch" } }, { "columnMatch": "Impact", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "High", "representation": "Sev1", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Medium", "representation": "Sev2", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Low", "representation": "Sev3", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "more", "text": "{0}{1}" } ] } }, { "columnMatch": "Fit Score", "formatter": 8, "formatOptions": { "min": 0, "max": 5, "palette": "redGreen" } }, { "columnMatch": "Savings", "formatter": 2, "formatOptions": { "aggregation": "Sum" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "minimumFractionDigits": 2, "maximumFractionDigits": 2 } } }, { "columnMatch": "Tags", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "9ch" } }, { "columnMatch": "Additional Info", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "18ch" } }, { "columnMatch": "Details", "formatter": 7, "formatOptions": { "linkTarget": "Url", "linkLabel": "Link" } } ], "rowLimit": 10000 } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Cost" }, "name": "costRecommendationsList" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Cost" }, "name": "costGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "941690c0-8e7f-48fe-b8ad-f668b066bae5", "version": "KqlParameterItem/1.0", "name": "SecurityFitScore", "label": "Min. Fit Score", "type": 1, "description": "Minimum acceptable fit score", "isRequired": true, "timeContext": { "durationMs": 86400000 }, "value": "-1" }, { "id": "404387de-475b-4729-b0d9-b6acf8d9594e", "version": "KqlParameterItem/1.0", "name": "Origin", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "typeSettings": { "additionalResourceOptions": [ "value::all" ] }, "jsonData": "[\"Advisor\",\"Custom\"]", "value": [ "value::all" ] }, { "id": "57abf104-8b39-4d00-934b-1365a8d34266", "version": "KqlParameterItem/1.0", "name": "RecommendationsSubType", "label": "Type", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Security'\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| where Origin in ({Origin:value})\r\n| distinct RecommendationSubType_s\r\n| order by RecommendationSubType_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "version": "KqlParameterItem/1.0", "name": "SecurityRecommendations", "label": "Recommendations", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Security'\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| where Origin in ({Origin:value})\r\n| where RecommendationSubType_s in ({RecommendationsSubType:value})\r\n| distinct RecommendationDescription_s\r\n| order by RecommendationDescription_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ], "id": "42b5d3ed-d221-496e-b911-b91c0ca73983" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "costParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Security'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {SecurityFitScore}\r\n| where RecommendationDescription_s in ({SecurityRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by SubscriptionName_s", "size": 4, "title": "Recommendations by subscription", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Security" }, "showPin": true, "name": "securityRecsBySubscription" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Security'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {SecurityFitScore}\r\n| where RecommendationDescription_s in ({SecurityRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by RecommendationSubType_s", "size": 4, "title": "Recommendations by type", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Security" }, "showPin": true, "name": "securityRecommendationsByType" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Security'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {SecurityFitScore}\r\n| where RecommendationDescription_s in ({SecurityRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| summarize count() by Origin", "size": 4, "title": "Recommendations by origin", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Security" }, "showPin": true, "name": "securityRecommendationsByOrigin" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| where Category == 'Security'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {SecurityFitScore}\r\n| where RecommendationDescription_s in ({SecurityRecommendations})\r\n| project Recommendation=RecommendationDescription_s, Instance=InstanceName_s, ['Resource Group']=ResourceGroup, Subscription=SubscriptionName_s, Impact=Impact_s, ['Fit Score']=FitScore_d, Tags=parse_json(Tags_s), ['Additional Info']=parse_json(AdditionalInfo_s), Details=DetailsURL_s\r\n| order by Impact asc, ['Fit Score']", "size": 2, "noDataMessage": "No Security recommendations available", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "Recommendation", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "Instance", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "Resource Group", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "Subscription", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "25ch" } }, { "columnMatch": "Impact", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "High", "representation": "Sev1", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Medium", "representation": "Sev2", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Low", "representation": "Sev3", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "more", "text": "{0}{1}" } ] } }, { "columnMatch": "Fit Score", "formatter": 8, "formatOptions": { "min": 0, "max": 5, "palette": "redGreen" } }, { "columnMatch": "Tags", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "9ch" } }, { "columnMatch": "Additional Info", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "18ch" } }, { "columnMatch": "Details", "formatter": 7, "formatOptions": { "linkTarget": "Url", "linkLabel": "Link" } }, { "columnMatch": "Savings", "formatter": 2, "formatOptions": { "aggregation": "Sum" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "minimumFractionDigits": 2, "maximumFractionDigits": 2 } } } ], "rowLimit": 10000 } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Security" }, "name": "securityRecommendationsList" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Security" }, "name": "securityGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "941690c0-8e7f-48fe-b8ad-f668b066bae5", "version": "KqlParameterItem/1.0", "name": "OperationalExcellenceFitScore", "label": "Min. Fit Score", "type": 1, "description": "Minimum acceptable fit score", "isRequired": true, "timeContext": { "durationMs": 86400000 }, "value": "-1" }, { "id": "88e40f64-4813-4766-a0bc-0e067dbfceb9", "version": "KqlParameterItem/1.0", "name": "Origin", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "typeSettings": { "additionalResourceOptions": [ "value::all" ] }, "jsonData": "[\"Advisor\",\"Custom\"]", "value": [ "value::all" ] }, { "id": "57abf104-8b39-4d00-934b-1365a8d34266", "version": "KqlParameterItem/1.0", "name": "RecommendationsSubType", "label": "Type", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'OperationalExcellence'\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| where Origin in ({Origin:value})\r\n| distinct RecommendationSubType_s\r\n| order by RecommendationSubType_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "version": "KqlParameterItem/1.0", "name": "OperationalExcellenceRecommendations", "label": "Recommendations", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'OperationalExcellence'\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| where Origin in ({Origin:value})\r\n| where RecommendationSubType_s in ({RecommendationsSubType:value})\r\n| distinct RecommendationDescription_s\r\n| order by RecommendationDescription_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ], "id": "2f82e306-088a-417f-b896-0fa126974807" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "operationalExcellenceParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'OperationalExcellence'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {OperationalExcellenceFitScore}\r\n| where RecommendationDescription_s in ({OperationalExcellenceRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by SubscriptionName_s", "size": 4, "title": "Recommendations by subscription", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "OperationalExcellence" }, "showPin": true, "name": "operationalExcellenceRecsBySubscription" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'OperationalExcellence'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {OperationalExcellenceFitScore}\r\n| where RecommendationDescription_s in ({OperationalExcellenceRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by RecommendationSubType_s", "size": 4, "title": "Recommendations by type", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "OperationalExcellence" }, "showPin": true, "name": "operationalExcellenceRecommendationsByType" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'OperationalExcellence'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {OperationalExcellenceFitScore}\r\n| where RecommendationDescription_s in ({OperationalExcellenceRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| summarize count() by Origin", "size": 4, "title": "Recommendations by origin", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "OperationalExcellence" }, "showPin": true, "name": "operationalExcellenceRecommendationsByOrigin" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| where Category == 'OperationalExcellence'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {OperationalExcellenceFitScore}\r\n| where RecommendationDescription_s in ({OperationalExcellenceRecommendations})\r\n| project Recommendation=RecommendationDescription_s, Instance=InstanceName_s, ['Resource Group']=ResourceGroup, Subscription=SubscriptionName_s, Impact=Impact_s, ['Fit Score']=FitScore_d, Tags=parse_json(Tags_s), ['Additional Info']=parse_json(AdditionalInfo_s), Details=DetailsURL_s\r\n| order by Impact asc, ['Fit Score']", "size": 2, "noDataMessage": "No Operational Excellence recommendations available", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "Recommendation", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "Instance", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "Resource Group", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "Subscription", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "25ch" } }, { "columnMatch": "Impact", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "High", "representation": "Sev1", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Medium", "representation": "Sev2", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Low", "representation": "Sev3", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "more", "text": "{0}{1}" } ] } }, { "columnMatch": "Fit Score", "formatter": 8, "formatOptions": { "min": 0, "max": 5, "palette": "redGreen" } }, { "columnMatch": "Tags", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "9ch" } }, { "columnMatch": "Additional Info", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "18ch" } }, { "columnMatch": "Details", "formatter": 7, "formatOptions": { "linkTarget": "Url", "linkLabel": "Link" } }, { "columnMatch": "Savings", "formatter": 2, "formatOptions": { "aggregation": "Sum" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "minimumFractionDigits": 2, "maximumFractionDigits": 2 } } } ], "rowLimit": 10000 } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "OperationalExcellence" }, "name": "operationalExcellenceRecommendationsList" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "OperationalExcellence" }, "name": "operationalExcellenceGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "941690c0-8e7f-48fe-b8ad-f668b066bae5", "version": "KqlParameterItem/1.0", "name": "HighAvailabilityFitScore", "label": "Min. Fit Score", "type": 1, "description": "Minimum acceptable fit score", "isRequired": true, "timeContext": { "durationMs": 86400000 }, "value": "-1" }, { "id": "c5da94b9-1eaa-4a1a-aa0f-62733fb6a3cc", "version": "KqlParameterItem/1.0", "name": "Origin", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "jsonData": "[\"Advisor\",\"Custom\"]", "value": [ "value::all" ] }, { "id": "57abf104-8b39-4d00-934b-1365a8d34266", "version": "KqlParameterItem/1.0", "name": "RecommendationsSubType", "label": "Type", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'HighAvailability'\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| where Origin in ({Origin:value})\r\n| distinct RecommendationSubType_s\r\n| order by RecommendationSubType_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "version": "KqlParameterItem/1.0", "name": "HighAvailabilityRecommendations", "label": "Recommendations", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'HighAvailability'\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| where Origin in ({Origin:value})\r\n| where RecommendationSubType_s in ({RecommendationsSubType:value})\r\n| distinct RecommendationDescription_s\r\n| order by RecommendationDescription_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ], "id": "06f783a2-135e-4906-a8b6-cbb6eedfb919" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "highAvailabilityParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'HighAvailability'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {HighAvailabilityFitScore}\r\n| where RecommendationDescription_s in ({HighAvailabilityRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by SubscriptionName_s", "size": 4, "title": "Recommendations by subscription", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "HighAvailability" }, "showPin": true, "name": "highAvailabilityRecsBySubscription" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'HighAvailability'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {HighAvailabilityFitScore}\r\n| where RecommendationDescription_s in ({HighAvailabilityRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by RecommendationSubType_s", "size": 4, "title": "Recommendations by type", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "HighAvailability" }, "showPin": true, "name": "highAvailabilityRecommendationsByType" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'HighAvailability'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {HighAvailabilityFitScore}\r\n| where RecommendationDescription_s in ({HighAvailabilityRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| summarize count() by Origin", "size": 4, "title": "Recommendations by origin", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "HighAvailability" }, "showPin": true, "name": "highAvailabilityRecommendationsByOrigin" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| where Category == 'HighAvailability'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {HighAvailabilityFitScore}\r\n| where RecommendationDescription_s in ({HighAvailabilityRecommendations})\r\n| project Recommendation=RecommendationDescription_s, Instance=InstanceName_s, ['Resource Group']=ResourceGroup, Subscription=SubscriptionName_s, Impact=Impact_s, ['Fit Score']=FitScore_d, Tags=parse_json(Tags_s), ['Additional Info']=parse_json(AdditionalInfo_s), Details=DetailsURL_s\r\n| order by Impact asc, ['Fit Score']", "size": 2, "noDataMessage": "No High Availability recommendations available", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "Recommendation", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "Instance", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "Resource Group", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "Subscription", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "25ch" } }, { "columnMatch": "Impact", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "High", "representation": "Sev1", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Medium", "representation": "Sev2", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Low", "representation": "Sev3", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "more", "text": "{0}{1}" } ] } }, { "columnMatch": "Fit Score", "formatter": 8, "formatOptions": { "min": 0, "max": 5, "palette": "redGreen" } }, { "columnMatch": "Tags", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "9ch" } }, { "columnMatch": "Additional Info", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "18ch" } }, { "columnMatch": "Details", "formatter": 7, "formatOptions": { "linkTarget": "Url", "linkLabel": "Link" } }, { "columnMatch": "Savings", "formatter": 2, "formatOptions": { "aggregation": "Sum" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "minimumFractionDigits": 2, "maximumFractionDigits": 2 } } } ], "rowLimit": 10000 } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "HighAvailability" }, "name": "highAvailabilityRecommendationsList" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "HighAvailability" }, "name": "highAvailabilityGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "941690c0-8e7f-48fe-b8ad-f668b066bae5", "version": "KqlParameterItem/1.0", "name": "PerformanceFitScore", "label": "Min. Fit Score", "type": 1, "description": "Minimum acceptable fit score", "isRequired": true, "timeContext": { "durationMs": 86400000 }, "value": "-1" }, { "id": "74975b92-2f5d-4e1f-81ec-7af18fe851e6", "version": "KqlParameterItem/1.0", "name": "Origin", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "typeSettings": { "additionalResourceOptions": [ "value::all" ] }, "jsonData": "[\"Advisor\",\"Custom\"]", "value": [ "value::all" ] }, { "id": "57abf104-8b39-4d00-934b-1365a8d34266", "version": "KqlParameterItem/1.0", "name": "RecommendationsSubType", "label": "Type", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Performance'\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| where Origin in ({Origin:value})\r\n| distinct RecommendationSubType_s\r\n| order by RecommendationSubType_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "version": "KqlParameterItem/1.0", "name": "PerformanceRecommendations", "label": "Recommendations", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Performance'\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| where Origin in ({Origin:value})\r\n| where RecommendationSubType_s in ({RecommendationsSubType:value})\r\n| distinct RecommendationDescription_s\r\n| order by RecommendationDescription_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ], "id": "3e1c7c88-d638-41d2-88ca-9f237eb2dc95" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "performanceParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Performance'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {PerformanceFitScore}\r\n| where RecommendationDescription_s in ({PerformanceRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by SubscriptionName_s", "size": 4, "title": "Recommendations by subscription", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Performance" }, "showPin": true, "name": "performanceRecsBySubscription" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Performance'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {PerformanceFitScore}\r\n| where RecommendationDescription_s in ({PerformanceRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| summarize count() by RecommendationSubType_s", "size": 4, "title": "Recommendations by type", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Performance" }, "showPin": true, "name": "performanceRecommendationsByType" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| where Category == 'Performance'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {PerformanceFitScore}\r\n| where RecommendationDescription_s in ({PerformanceRecommendations})\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| extend Origin = iif(RecommendationSubType_s startswith \"Advisor\", \"Advisor\", \"Custom\")\r\n| summarize count() by Origin", "size": 4, "title": "Recommendations by origin", "noDataMessage": "No recommendations available. Review the latest Automation Account runbooks status and fix accordingly.", "noDataMessageStyle": 4, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "piechart", "tileSettings": { "titleContent": { "columnMatch": "Category", "formatter": 1 }, "leftContent": { "columnMatch": "sum_SavingsAmount", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 2, "maximumSignificantDigits": 3 } } }, "showBorder": false }, "textSettings": { "style": "bignumber" } }, "customWidth": "33", "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Performance" }, "showPin": true, "name": "performanceRecommendationsByOrigin" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let beginTime = todatetime('{LastRecommendationDateTime}')-6d;\r\nlet ActiveGlobalSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isempty(InstanceId_g) and isempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g;\r\nlet ActiveSubscriptionSuppressions = AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceId_g)\r\n| extend SubscriptionGuid_g = InstanceId_g\r\n| project RecommendationSubTypeId_g, SubscriptionGuid_g;\r\nlet ActiveInstanceSuppressions = materialize(AzureOptimizationSuppressionsV1_CL \r\n| where TimeGenerated > beginTime and FilterStartDate_t < beginTime and FilterEndDate_t > now()\r\n| where FilterType_s in ('Snooze','Dismiss')\r\n| where isnotempty(InstanceName_s)\r\n| project RecommendationSubTypeId_g, InstanceName_s);\r\nAzureOptimizationRecommendationsV1_CL\r\n| where TimeGenerated > beginTime\r\n| join kind=leftouter ( ActiveGlobalSuppressions ) on RecommendationSubTypeId_g\r\n| where isempty(RecommendationSubTypeId_g1)\r\n| join kind=leftouter ( ActiveSubscriptionSuppressions ) on RecommendationSubTypeId_g and SubscriptionGuid_g\r\n| where isempty(RecommendationSubTypeId_g2)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.InstanceName_s == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g3)\r\n| join kind=leftouter ( ActiveInstanceSuppressions ) on RecommendationSubTypeId_g and $left.ResourceGroup == $right.InstanceName_s\r\n| where isempty(RecommendationSubTypeId_g4)\r\n| where Category == 'Performance'\r\n| where \"'*'\" == \"{Subscriptions}\" or SubscriptionName_s in ({Subscriptions})\r\n| where Impact_s in ({Impact})\r\n| where iif('{TagName:label}' == '' or '{TagValue:label}' == '', true, isnotempty(parse_json(Tags_s)['{TagName:label}']) and tostring(parse_json(Tags_s)['{TagName:label}']) =~ '{TagValue}')\r\n| where FitScore_d >= {PerformanceFitScore}\r\n| where RecommendationDescription_s in ({PerformanceRecommendations})\r\n| project Recommendation=RecommendationDescription_s, Instance=InstanceName_s, ['Resource Group']=ResourceGroup, Subscription=SubscriptionName_s, Impact=Impact_s, ['Fit Score']=FitScore_d, Tags=parse_json(Tags_s), ['Additional Info']=parse_json(AdditionalInfo_s), Details=DetailsURL_s\r\n| order by Impact asc, ['Fit Score']", "size": 2, "noDataMessage": "No Performance recommendations available", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "Recommendation", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "Instance", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "Resource Group", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "Subscription", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "25ch" } }, { "columnMatch": "Impact", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "High", "representation": "Sev1", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Medium", "representation": "Sev2", "text": "{0}{1}" }, { "operator": "==", "thresholdValue": "Low", "representation": "Sev3", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "more", "text": "{0}{1}" } ] } }, { "columnMatch": "Fit Score", "formatter": 8, "formatOptions": { "min": 0, "max": 5, "palette": "redGreen" } }, { "columnMatch": "Tags", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "9ch" } }, { "columnMatch": "Additional Info", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "18ch" } }, { "columnMatch": "Details", "formatter": 7, "formatOptions": { "linkTarget": "Url", "linkLabel": "Link" } }, { "columnMatch": "Savings", "formatter": 2, "formatOptions": { "aggregation": "Sum" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "minimumFractionDigits": 2, "maximumFractionDigits": 2 } } } ], "rowLimit": 10000 } }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Performance" }, "name": "performanceRecommendationsList" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "Performance" }, "name": "performanceGroup" } ], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" } ================================================ FILE: views/workbooks/reservations-potential.bicep ================================================ @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') param workbookDisplayName string = 'Reservations Potential' @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') param workbookType string = 'workbook' @description('The id of resource instance to which the workbook will be associated') param workbookSourceId string @description('The unique guid for this workbook instance') param workbookId string = '14707f9b-03c4-43ff-9811-2b2cc1c74b61' param resourceTags object param resourceGroupLocation string = resourceGroup().location resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { name: workbookId location: resourceGroupLocation tags: resourceTags kind: 'shared' properties: { displayName: workbookDisplayName serializedData: string(loadJsonContent('reservations-potential.json')) version: '1.0' sourceId: workbookSourceId category: workbookType } dependsOn: [] } output workbookId string = workbookId_resource.id ================================================ FILE: views/workbooks/reservations-potential.json ================================================ { "version": "Notebook/1.0", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "b58b4eb8-5821-44d2-bc7e-54054df27320", "version": "KqlParameterItem/1.0", "name": "LookbackPeriod", "label": "Lookback Period", "type": 4, "isRequired": true, "value": { "durationMs": 2592000000 }, "typeSettings": { "selectableValues": [ { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ] }, "timeContext": { "durationMs": 86400000 } }, { "id": "3e36a073-14b2-4406-a84a-1b6d0a15f363", "version": "KqlParameterItem/1.0", "name": "UseISF", "label": "Instance Size Flexibility?", "type": 10, "isRequired": true, "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "jsonData": "[\"Yes\", \"No\"]", "value": "No" }, { "id": "367a8fc6-9e49-47cd-af4b-deea6cfcf538", "version": "KqlParameterItem/1.0", "name": "AggregatorTag", "label": "Aggregator Tag", "type": 1, "description": "Tag name for the RI potential by tag value analysis for a specific size/ISF group", "value": null } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "parameters - 0" }, { "type": 1, "content": { "json": "Consumption data is updated once every 24 hours and is presented in the currency of your Azure consumption agreement.", "style": "info" }, "name": "info" }, { "type": 1, "content": { "json": "If below tiles are reporting query errors, you must set up Pricesheet exports. See more details [here](https://aka.ms/AzureOptimizationEngine/commitmentssetup).", "style": "warning" }, "name": "text - 5" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nAzureOptimizationConsumptionV1_CL\r\n| where todatetime(Date_s) > todatetime('{LookbackPeriod:startISO}') and todatetime(Date_s) < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and MeterCategory_s == 'Virtual Machines'\r\n| join kind=leftouter (\r\n AzureOptimizationResourceContainersV1_CL\r\n | where TimeGenerated > ago(2d)\r\n | where ContainerType_s == 'microsoft.resources/subscriptions'\r\n | distinct SubscriptionId=SubscriptionGuid_g, SubscriptionName=ContainerName_s, Cloud=Cloud_s\r\n) on SubscriptionId\r\n| where Cloud == 'AzureCloud'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend VMSize = tolower(parse_json(AdditionalInfo_s)['ServiceType'])\r\n| extend SKUName = strcat(VMSize, ' ', ResourceLocation_s)\r\n| project Date_s, QtyHours=todouble(Quantity_s), BillingMeter=MeterName_s, ResourceLocation_s, MeterId=MeterId_g, ResourceId, OnDemandPrice=todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s)), SubscriptionName, VMSize, SKUName\r\n| join kind=leftouter ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| extend ISFGroup = strcat(ISFGroup, ' ', ResourceLocation_s)\r\n| extend OnDemandCost = QtyHours * OnDemandPrice\r\n| summarize DailyCost=sum(OnDemandCost) by bin(todatetime(Date_s), 1d), iif(\"{UseISF}\" == \"Yes\", ISFGroup, SKUName)\r\n| order by DailyCost", "size": 0, "title": "Average on-demand (PAYG) daily consumption (actual cost - Virtual Machines only)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart" }, "name": "onDemandUsageAsIs" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and ChargeType_s == 'Usage' and ResourceLocation_s !startswith 'china'\r\n| where ConsumedService_s in ('Microsoft.Compute','Microsoft.ClassicCompute','Microsoft.Batch','Microsoft.MachineLearningServices','Microsoft.Kusto')\r\n| where MeterCategory_s != 'Virtual Machines Licenses'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend VMSize=tostring(parse_json(AdditionalInfo_s).ServiceType)\r\n| extend ResourceLocation_s = tolower(ResourceLocation_s)\r\n| where isnotempty(VMSize)\r\n| summarize UsedQuantity = sum(todouble(Quantity_s)) by ResourceId, Date_s, VMSize, ResourceLocation_s\r\n| summarize RIPotential=sum(UsedQuantity/24), AvgSizeUsageHours=avg(UsedQuantity) by Date_s, VMSize, ResourceLocation_s\r\n| summarize RIPotential=round(avg(RIPotential),1), AvgSizeUsageHours=round(avg(AvgSizeUsageHours)) by VMSize, ResourceLocation_s\r\n| extend Fragmentation = case(AvgSizeUsageHours >= 24.0, 0.0, AvgSizeUsageHours >= 18.0 and AvgSizeUsageHours < 24.0, 0.25, AvgSizeUsageHours >= 12.0 and AvgSizeUsageHours < 18.0, 0.5, AvgSizeUsageHours >= 6.0 and AvgSizeUsageHours < 12.0, 0.75, 1.0)\r\n| project-reorder VMSize, ResourceLocation_s, RIPotential, Fragmentation\r\n| order by Fragmentation asc, RIPotential desc", "size": 0, "title": "On-demand sizes usage and RI potential/fragmentation (click on a line for more details)", "exportedParameters": [ { "fieldName": "VMSize", "parameterName": "VMSize", "parameterType": 1 }, { "fieldName": "ResourceLocation_s", "parameterName": "Location", "parameterType": 1 }, { "fieldName": "RIPotential", "parameterName": "RIPotential", "parameterType": 1 } ], "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "Fragmentation", "formatter": 18, "formatOptions": { "thresholdsOptions": "colors", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "0", "representation": "green", "text": "low to none" }, { "operator": "==", "thresholdValue": "0.25", "representation": "yellow", "text": "some" }, { "operator": "==", "thresholdValue": "0.5", "representation": "orange", "text": "some to high" }, { "operator": "==", "thresholdValue": "0.75", "representation": "purple", "text": "high" }, { "operator": "==", "thresholdValue": "1", "representation": "red", "text": "very high" }, { "operator": "Default", "thresholdValue": null, "representation": "blue", "text": "{0}{1}" } ] }, "tooltipFormat": { "tooltip": "On-demand usage has {0} fragmentation across multiple VMs with respect to the average count" } }, { "columnMatch": "AvgSizeUsageHours", "formatter": 5 } ], "rowLimit": 1000, "labelSettings": [ { "columnId": "VMSize", "label": "Size" }, { "columnId": "ResourceLocation_s", "label": "Region" }, { "columnId": "RIPotential", "label": "VMs # (Avg.)" }, { "columnId": "Fragmentation", "label": "Fragmentation" }, { "columnId": "AvgSizeUsageHours", "label": "Usage (Avg. Hrs.)" } ] } }, "customWidth": "45", "name": "riPotential" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and ChargeType_s == 'Usage'\r\n| where ConsumedService_s in ('Microsoft.Compute','Microsoft.ClassicCompute','Microsoft.Batch','Microsoft.MachineLearningServices','Microsoft.Kusto')\r\n| where MeterCategory_s != 'Virtual Machines Licenses'\r\n| extend VMSize=tostring(parse_json(AdditionalInfo_s).ServiceType)\r\n| extend ResourceLocation_s = tolower(ResourceLocation_s)\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| where VMSize == '{VMSize}' and ResourceLocation_s =~ '{Location}'\r\n| summarize UsedQuantity = round(sum(todouble(Quantity_s)/24)) by todatetime(Date_s)\r\n| extend RIPotential = {RIPotential}\r\n| render timechart", "size": 0, "aggregation": 3, "title": "Instance count for selected size/location (click on a line in the table at the left)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "UsedQuantity", "label": "Instance #" }, { "seriesName": "RIPotential", "label": "RI Potential" } ] } }, "customWidth": "55", "name": "riPotentialOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and ChargeType_s == 'Usage'\r\n| where ConsumedService_s in ('Microsoft.Compute','Microsoft.ClassicCompute','Microsoft.Batch','Microsoft.MachineLearningServices','Microsoft.Kusto')\r\n| where MeterCategory_s != 'Virtual Machines Licenses'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend VMSize=tostring(parse_json(AdditionalInfo_s).ServiceType)\r\n| extend ResourceLocation_s = tolower(ResourceLocation_s)\r\n| where VMSize == '{VMSize}' and ResourceLocation_s =~ '{Location}'\r\n| summarize UsedQuantity = sum(todouble(Quantity_s)) by Date_s, ResourceId, SubscriptionId\r\n| summarize AvgUsedQuantity = round(avg(UsedQuantity),1) by ResourceId, SubscriptionId\r\n| join kind=leftouter ( AzureOptimizationResourceContainersV1_CL | where TimeGenerated > ago(1d) and ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionName=ContainerName_s, SubscriptionId=SubscriptionGuid_g) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| project-reorder ResourceId, SubscriptionName\r\n| extend AvgUsedVMs = AvgUsedQuantity / 24\r\n| order by ResourceId asc", "size": 1, "title": "Daily on-demand usage for selected size/location by resource (click on a line in the table above)", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "SubscriptionName", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "20ch" } }, { "columnMatch": "AvgUsedQuantity", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "25ch" } }, { "columnMatch": "AvgUsedVMs", "formatter": 0, "numberFormat": { "unit": 0, "options": { "style": "decimal", "maximumFractionDigits": 2 } } } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "ResourceId", "label": "Resource" }, { "columnId": "SubscriptionName", "label": "Subscription" }, { "columnId": "AvgUsedQuantity", "label": "Avg. hrs" }, { "columnId": "AvgUsedVMs", "label": "Avg. VMs" } ] } }, "customWidth": "50", "name": "riPotentialInstances" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and ChargeType_s == 'Usage'\r\n| where ConsumedService_s in ('Microsoft.Compute','Microsoft.ClassicCompute','Microsoft.Batch','Microsoft.MachineLearningServices','Microsoft.Kusto')\r\n| where MeterCategory_s != 'Virtual Machines Licenses'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend VMSize=tostring(parse_json(AdditionalInfo_s).ServiceType)\r\n| extend ResourceLocation_s = tolower(ResourceLocation_s)\r\n| where VMSize == '{VMSize}' and ResourceLocation_s =~ '{Location}'\r\n| extend Tags_s = iif(Tags_s startswith \"{\", Tags_s, strcat(\"{\", Tags_s, \"}\"))\r\n| extend AggregatorTag = tostring(parse_json(Tags_s)['{AggregatorTag}'])\r\n| summarize UsedQuantity = sum(todouble(Quantity_s)) by Date_s, AggregatorTag, SubscriptionId\r\n| summarize AvgUsedQuantity = round(avg(UsedQuantity),1) by AggregatorTag, SubscriptionId\r\n| join kind=leftouter ( AzureOptimizationResourceContainersV1_CL | where TimeGenerated > ago(1d) and ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionName=ContainerName_s, SubscriptionId=SubscriptionGuid_g) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| project-reorder AggregatorTag, SubscriptionName\r\n| extend AvgUsedVMs = AvgUsedQuantity / 24\r\n| order by AggregatorTag asc", "size": 1, "title": "Daily on-demand usage for selected size/location by tag (click on a line in the table above)", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "SubscriptionName", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "20ch" } }, { "columnMatch": "AvgUsedQuantity", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "25ch" } }, { "columnMatch": "AvgUsedVMs", "formatter": 0, "numberFormat": { "unit": 0, "options": { "style": "decimal", "maximumFractionDigits": 2 } } } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "AggregatorTag", "label": "Aggregator Tag" }, { "columnId": "SubscriptionName", "label": "Subscription" }, { "columnId": "AvgUsedQuantity", "label": "Avg. hrs" }, { "columnId": "AvgUsedVMs", "label": "Avg. VMs" } ] } }, "customWidth": "50", "name": "riPotentialTag" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandPrice;\r\nAzureOptimizationReservationsPriceV1_CL\r\n| where TimeGenerated > ago(7d)\r\n| where armSkuName_s =~ '{VMSize}' and tolower(armRegionName_s) =~ '{Location}'\r\n| extend reservationUnitPrice = iif(reservationTerm_s == '1 Year', todouble(unitPrice_s)/12/730, todouble(unitPrice_s)/3/12/730)\r\n| project unitPrice_s, reservationUnitPrice, currencyCode_s, reservationTerm_s, armSkuName_s, armRegionName_s\r\n| join kind=leftouter ( \r\n AzureOptimizationConsumptionV1_CL\r\n | where ChargeType_s == 'Usage' and ResourceLocation_s !startswith 'china'\r\n | extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n | where PricingModel == 'OnDemand'\r\n | extend armSkuName_s=tostring(parse_json(AdditionalInfo_s).ServiceType)\r\n | extend armRegionName_s = tolower(ResourceLocation_s)\r\n | extend UnitPrice=todouble(iif(todouble(UnitPrice_s) > 0.0, UnitPrice_s, EffectivePrice_s))\r\n | distinct armSkuName_s, armRegionName_s, MeterId_g\r\n | join kind=inner ( OnDemandPriceSheet ) on $left.MeterId_g == $right.MeterID_g\r\n | summarize OnDemandUnitPrice=max(OnDemandPrice) by armSkuName_s, armRegionName_s\r\n) on armRegionName_s, armSkuName_s\r\n| extend savingsPercentage = (1 - reservationUnitPrice / OnDemandUnitPrice) * 100\r\n| extend commitmentCost = todouble(unitPrice_s) * bin({RIPotential},1)\r\n| extend vmCount = bin({RIPotential},1)\r\n| project reservationTerm_s, vmCount, commitmentCost, currencyCode_s, savingsPercentage", "size": 4, "title": "Estimated Commitment and Savings", "noDataMessage": "No reservations available for this VM size", "timeContext": { "durationMs": 604800000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "reservationTerm_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "10ch" } }, { "columnMatch": "commitmentCost", "formatter": 1, "numberFormat": { "unit": 0, "options": { "style": "decimal", "maximumFractionDigits": 2 } } }, { "columnMatch": "currencyCode_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "13ch" } }, { "columnMatch": "savingsPercentage", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "15ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 1 } } } ], "labelSettings": [ { "columnId": "reservationTerm_s", "label": "Term" }, { "columnId": "vmCount", "label": "VMs" }, { "columnId": "commitmentCost", "label": "Commitment" }, { "columnId": "currencyCode_s", "label": "Currency" }, { "columnId": "savingsPercentage", "label": "Savings" } ] } }, "customWidth": "50", "name": "reservationPriceEstimation" } ] }, "conditionalVisibility": { "parameterName": "UseISF", "comparison": "isEqualTo", "value": "No" }, "name": "noISFGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 1, "content": { "json": "VMs/instance count for each Instance Size Flexibility Group is proportional to the VM size with the lowest ratio (1).", "style": "warning" }, "name": "text - 4" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and ChargeType_s == 'Usage' and ResourceLocation_s !startswith 'china'\r\n| where ConsumedService_s in ('Microsoft.Compute','Microsoft.ClassicCompute','Microsoft.Batch','Microsoft.MachineLearningServices','Microsoft.Kusto')\r\n| where MeterCategory_s != 'Virtual Machines Licenses'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend VMSize=tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| extend ResourceLocation_s = tolower(ResourceLocation_s)\r\n| where isnotempty(VMSize)\r\n| join kind=inner ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| summarize UsedQuantity = sum(todouble(Quantity_s)) by ResourceId, Date_s, VMSize, ISFGroup, Ratio, ResourceLocation_s\r\n| summarize RIPotential=sum(UsedQuantity/24*Ratio), AvgSizeUsageHours=avg(UsedQuantity) by Date_s, ISFGroup, ResourceLocation_s\r\n| summarize RIPotential=round(avg(RIPotential),1), AvgSizeUsageHours=round(avg(AvgSizeUsageHours)) by ISFGroup, ResourceLocation_s\r\n| extend Fragmentation = case(AvgSizeUsageHours >= 24.0, 0.0, AvgSizeUsageHours >= 18.0 and AvgSizeUsageHours < 24.0, 0.25, AvgSizeUsageHours >= 12.0 and AvgSizeUsageHours < 18.0, 0.5, AvgSizeUsageHours >= 6.0 and AvgSizeUsageHours < 12.0, 0.75, 1.0)\r\n| project-reorder ISFGroup, ResourceLocation_s, RIPotential, Fragmentation\r\n| order by Fragmentation asc, RIPotential desc", "size": 0, "title": "On-demand ISF group usage and RI potential/fragmentation (click on a line for more details)", "exportedParameters": [ { "fieldName": "ISFGroup", "parameterName": "ISFGroup", "parameterType": 1 }, { "fieldName": "ResourceLocation_s", "parameterName": "Location", "parameterType": 1 }, { "fieldName": "RIPotential", "parameterName": "RIPotential", "parameterType": 1 } ], "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ISFGroup", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "26ch" } }, { "columnMatch": "ResourceLocation_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "14ch" } }, { "columnMatch": "Fragmentation", "formatter": 18, "formatOptions": { "thresholdsOptions": "colors", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "0", "representation": "green", "text": "low to none" }, { "operator": "==", "thresholdValue": "0.25", "representation": "yellow", "text": "some" }, { "operator": "==", "thresholdValue": "0.5", "representation": "orange", "text": "some to high" }, { "operator": "==", "thresholdValue": "0.75", "representation": "purple", "text": "high" }, { "operator": "==", "thresholdValue": "1", "representation": "red", "text": "very high" }, { "operator": "Default", "thresholdValue": null, "representation": "blue", "text": "{0}{1}" } ] }, "tooltipFormat": { "tooltip": "On-demand usage has {0} fragmentation across multiple VMs with respect to the average count" } }, { "columnMatch": "AvgSizeUsageHours", "formatter": 5 } ], "rowLimit": 1000, "labelSettings": [ { "columnId": "ISFGroup", "label": "ISF Group" }, { "columnId": "ResourceLocation_s", "label": "Region" }, { "columnId": "RIPotential", "label": "VMs # (Avg.)" }, { "columnId": "Fragmentation", "label": "Fragmentation" }, { "columnId": "AvgSizeUsageHours", "label": "Usage (Avg. Hrs.)" } ] } }, "customWidth": "45", "name": "riPotential" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and ChargeType_s == 'Usage'\r\n| where ConsumedService_s in ('Microsoft.Compute','Microsoft.ClassicCompute','Microsoft.Batch','Microsoft.MachineLearningServices','Microsoft.Kusto')\r\n| where MeterCategory_s != 'Virtual Machines Licenses'\r\n| extend VMSize=tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| extend ResourceLocation_s = tolower(ResourceLocation_s)\r\n| join kind=inner ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| where ISFGroup == '{ISFGroup}' and ResourceLocation_s =~ '{Location}'\r\n| summarize UsedQuantity = round(sum(todouble(Quantity_s)/24*Ratio)) by todatetime(Date_s)\r\n| extend RIPotential = {RIPotential}\r\n| render timechart", "size": 0, "aggregation": 3, "title": "Instance count for selected ISF Group/location (click on a line in the table at the left)", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "chartSettings": { "seriesLabelSettings": [ { "seriesName": "UsedQuantity", "label": "Instance #" }, { "seriesName": "RIPotential", "label": "RI Potential" } ] } }, "customWidth": "55", "name": "riPotentialOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and ChargeType_s == 'Usage'\r\n| where ConsumedService_s in ('Microsoft.Compute','Microsoft.ClassicCompute','Microsoft.Batch','Microsoft.MachineLearningServices','Microsoft.Kusto')\r\n| where MeterCategory_s != 'Virtual Machines Licenses'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend VMSize=tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| extend ResourceLocation_s = tolower(ResourceLocation_s)\r\n| join kind=inner ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| where ISFGroup == '{ISFGroup}' and ResourceLocation_s =~ '{Location}'\r\n| summarize UsedQuantity = sum(todouble(Quantity_s)) by Date_s, ResourceId, SubscriptionId, VMSize, Ratio\r\n| summarize AvgUsedQuantity = round(avg(UsedQuantity),1) by ResourceId, SubscriptionId, VMSize, Ratio\r\n| join kind=leftouter ( AzureOptimizationResourceContainersV1_CL | where TimeGenerated > ago(1d) and ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionName=ContainerName_s, SubscriptionId=SubscriptionGuid_g) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| project-reorder ResourceId, SubscriptionName\r\n| extend AvgUsedVMs = AvgUsedQuantity / 24\r\n| order by ResourceId asc", "size": 1, "title": "Daily on-demand usage for selected ISF group/location by resource (click on a line in the table above)", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ResourceId", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "20ch" } }, { "columnMatch": "SubscriptionName", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "20ch" } }, { "columnMatch": "VMSize", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "20ch" } }, { "columnMatch": "Ratio", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "10ch" } }, { "columnMatch": "AvgUsedQuantity", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "12ch" } }, { "columnMatch": "AvgUsedVMs", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "14ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "maximumFractionDigits": 2 } } } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "ResourceId", "label": "Resource" }, { "columnId": "SubscriptionName", "label": "Subscription" }, { "columnId": "VMSize", "label": "Size" }, { "columnId": "Ratio", "label": "Ratio" }, { "columnId": "AvgUsedQuantity", "label": "Avg. hrs" }, { "columnId": "AvgUsedVMs", "label": "Avg. VMs" } ] } }, "customWidth": "50", "name": "riPotentialInstances" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and ChargeType_s == 'Usage'\r\n| where ConsumedService_s in ('Microsoft.Compute','Microsoft.ClassicCompute','Microsoft.Batch','Microsoft.MachineLearningServices','Microsoft.Kusto')\r\n| where MeterCategory_s != 'Virtual Machines Licenses'\r\n| extend PricingModel = iif(isnotempty(PricingModel_s), PricingModel_s, iif(isnotempty(ReservationName_s), 'Reservation', iif(MeterName_s endswith 'Spot', 'Spot', iif(isnotempty(benefitName_s), 'SavingsPlan', 'OnDemand'))))\r\n| where PricingModel == 'OnDemand'\r\n| extend VMSize=tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| extend ResourceLocation_s = tolower(ResourceLocation_s)\r\n| join kind=inner ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| where ISFGroup == '{ISFGroup}' and ResourceLocation_s =~ '{Location}'\r\n| extend Tags_s = iif(Tags_s startswith \"{\", Tags_s, strcat(\"{\", Tags_s, \"}\"))\r\n| extend AggregatorTag = tostring(parse_json(Tags_s)['{AggregatorTag}'])\r\n| summarize UsedQuantity = sum(todouble(Quantity_s)) by Date_s, AggregatorTag, SubscriptionId, VMSize, Ratio\r\n| summarize AvgUsedQuantity = round(avg(UsedQuantity),1) by AggregatorTag, SubscriptionId, VMSize, Ratio\r\n| join kind=leftouter ( AzureOptimizationResourceContainersV1_CL | where TimeGenerated > ago(1d) and ContainerType_s =~ 'microsoft.resources/subscriptions' | project SubscriptionName=ContainerName_s, SubscriptionId=SubscriptionGuid_g) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| project-reorder AggregatorTag, SubscriptionName\r\n| extend AvgUsedVMs = AvgUsedQuantity / 24\r\n| order by AggregatorTag asc", "size": 1, "title": "Daily on-demand usage for selected ISF group/location by tag (click on a line in the table above)", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "AggregatorTag", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "19ch" } }, { "columnMatch": "SubscriptionName", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "20ch" } }, { "columnMatch": "VMSize", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "20ch" } }, { "columnMatch": "Ratio", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "10ch" } }, { "columnMatch": "AvgUsedQuantity", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "12ch" } }, { "columnMatch": "AvgUsedVMs", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "14ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "maximumFractionDigits": 2 } } }, { "columnMatch": "ResourceId", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "22ch" } } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "AggregatorTag", "label": "Aggregator Tag" }, { "columnId": "SubscriptionName", "label": "Subscription" }, { "columnId": "VMSize", "label": "Size" }, { "columnId": "Ratio", "label": "Ratio" }, { "columnId": "AvgUsedQuantity", "label": "Avg. hrs" }, { "columnId": "AvgUsedVMs", "label": "Avg. VMs" } ] } }, "customWidth": "50", "name": "riPotentialTags" } ] }, "conditionalVisibility": { "parameterName": "UseISF", "comparison": "isEqualTo", "value": "Yes" }, "name": "yesISFGroup" } ], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" } ================================================ FILE: views/workbooks/reservations-usage.bicep ================================================ @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') param workbookDisplayName string = 'Reservations Usage' @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') param workbookType string = 'workbook' @description('The id of resource instance to which the workbook will be associated') param workbookSourceId string @description('The unique guid for this workbook instance') param workbookId string = '5fc75aa1-db43-4938-bbea-90dcb71ef5a2' param resourceTags object param resourceGroupLocation string = resourceGroup().location resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { name: workbookId location: resourceGroupLocation tags: resourceTags kind: 'shared' properties: { displayName: workbookDisplayName serializedData: string(loadJsonContent('reservations-usage.json')) version: '1.0' sourceId: workbookSourceId category: workbookType } dependsOn: [] } output workbookId string = workbookId_resource.id ================================================ FILE: views/workbooks/reservations-usage.json ================================================ { "version": "Notebook/1.0", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "b58b4eb8-5821-44d2-bc7e-54054df27320", "version": "KqlParameterItem/1.0", "name": "LookbackPeriod", "label": "Lookback Period", "type": 4, "isRequired": true, "value": { "durationMs": 2592000000 }, "typeSettings": { "selectableValues": [ { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ], "allowCustom": true }, "timeContext": { "durationMs": 86400000 } }, { "id": "08c51931-8b6c-419d-8de2-f9d24e8e6dd7", "version": "KqlParameterItem/1.0", "name": "ResourceType", "label": "Resource Type", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationReservationsUsageV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ProvisioningState_s == 'Succeeded'\r\n| distinct ResourceType\r\n| order by ResourceType asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "timeContext": { "durationMs": 0 }, "timeContextFromParameter": "LookbackPeriod", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "id": "5b2d78e9-7177-4d9b-86fa-2a9b12dd470a", "version": "KqlParameterItem/1.0", "name": "Reservation", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationReservationsUsageV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ProvisioningState_s == 'Succeeded' and ResourceType in ({ResourceType:value})\r\n| distinct ReservationId_g, DisplayName_s\r\n| order by DisplayName_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "timeContext": { "durationMs": 0 }, "timeContextFromParameter": "LookbackPeriod", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "version": "KqlParameterItem/1.0", "name": "Aggregator", "label": "Aggregator Tag", "type": 1, "isRequired": true, "timeContext": { "durationMs": 2592000000 }, "id": "a3fb4877-28ef-43fc-8821-376df486fa2a" }, { "id": "3e36a073-14b2-4406-a84a-1b6d0a15f363", "version": "KqlParameterItem/1.0", "name": "UseISF", "label": "Instance Size Flexibility?", "type": 10, "description": "Groups reservation by ISF (excludes non-VM reservations)", "isRequired": true, "typeSettings": { "additionalResourceOptions": [], "showDefault": false }, "jsonData": "[\"Yes\", \"No\"]", "value": "No" } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "parameters" }, { "type": 1, "content": { "json": "Consumption data is updated once every 24 hours and is presented in the currency of your Azure consumption agreement.", "style": "info" }, "name": "text - 7" }, { "type": 1, "content": { "json": "If below tiles are reporting query errors, you must set up Pricesheet exports. See more details [here](https://aka.ms/AzureOptimizationEngine/commitmentssetup).", "style": "warning" }, "name": "text - 10" }, { "type": 11, "content": { "version": "LinkItem/1.0", "style": "tabs", "links": [ { "id": "93e7a6c7-cb1f-49ee-b135-468b9f528b04", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Reservation Usage Analysis", "subTarget": "reservationAnalysis", "style": "link" }, { "id": "abd2af9f-2f88-45a2-9d09-4b439e736a71", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Usage by Tag", "subTarget": "usageByTag", "style": "link" }, { "id": "96332944-d3f7-4b0b-ad56-ab25a9e91049", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Full Usage Report", "subTarget": "fullReport", "style": "link" }, { "id": "1f438af9-e6ff-470e-9b11-b1b5a701e51b", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Unused Reservations Analysis", "subTarget": "unusedReservations", "style": "link" } ] }, "name": "tabs" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet VMOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandUnitPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandUnitPrice;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s != 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| summarize OnDemandUnitPrice = max(todouble(UnitPrice_s)) by MeterID_g\r\n| distinct MeterID_g, OnDemandUnitPrice\r\n| union (VMOnDemandPriceSheet)\r\n| summarize OnDemandUnitPrice=min(OnDemandUnitPrice) by MeterID_g;\r\nlet ReservationPricesheet = AzureOptimizationReservationsPriceV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| extend Term_s = iif(reservationTerm_s == '3 Years', 'P3Y', 'P1Y')\r\n| extend TermDivider = iif(reservationTerm_s == '3 Years', 3, 1)\r\n| extend ReservationPrice = todouble(replace_string(unitPrice_s,',','.'))/TermDivider/12/730\r\n| summarize arg_max(TimeGenerated, ReservationPrice) by Location_s=tolower(armRegionName_s), SkuName=tolower(armSkuName_s), Term_s;\r\nlet ReservationOnDemandMeters = AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| extend SkuName = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| distinct ReservationId_g, MeterId_g, SkuName;\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| where ReservationId_g in ({Reservation:value})\r\n| extend RINormalizationRatio = tostring(parse_json(AdditionalInfo_s).RINormalizationRatio)\r\n| extend UsedRIs = todouble(Quantity_s) * todouble(RINormalizationRatio) / 24\r\n| summarize UsedRIsDaily=round(sum(UsedRIs),2) by Date_s, ReservationId_g\r\n| summarize AvgRIsUsedDaily=round(avg(UsedRIsDaily),2) by ReservationId_g\r\n| join kind=rightouter (\r\n AzureOptimizationReservationsUsageV1_CL\r\n | where TimeGenerated > ago(1d)\r\n | where ReservationId_g in ({Reservation:value})\r\n | summarize arg_max(TimeGenerated, *) by ReservationId_g\r\n | where ProvisioningState_s == 'Succeeded'\r\n | project ReservationId_g, ReservationName_s=DisplayName_s, SKUName_s, Location_s, Util7Days_s, Util30Days_s, TotalReservedQuantity_s, Term_s\r\n) on ReservationId_g\r\n| project ReservationId_g=ReservationId_g1, ReservationName_s, TotalReservedQuantity_s, SKUName_s=tolower(SKUName_s), Location_s, AvgRIsUsedDaily=iif(isempty(AvgRIsUsedDaily), 0.0, AvgRIsUsedDaily), Util7Days_s, Util30Days_s, Term_s\r\n| join kind=leftouter (ReservationOnDemandMeters) on ReservationId_g\r\n| summarize arg_max(MeterId_g, *) by ReservationId_g\r\n| join kind=leftouter (ReservationPricesheet) on SkuName and Location_s and Term_s\r\n| join kind=leftouter (OnDemandPriceSheet) on $left.MeterId_g == $right.MeterID_g\r\n| extend DiscountPercent = (1 - ReservationPrice/OnDemandUnitPrice) * 100\r\n| project ReservationId_g, ReservationName_s, TotalReservedQuantity_s=round(todouble(TotalReservedQuantity_s)), SKUName_s, Location_s, AvgRIsUsedDaily, Util7Days_s=round(todouble(Util7Days_s)), Util30Days_s=round(todouble(Util30Days_s)), DiscountPercent, SavingsMargin=round(todouble(Util7Days_s))-100.0+DiscountPercent \r\n| order by Util7Days_s asc", "size": 0, "showAnalytics": true, "title": "Reservation Usage Details (click on a line for more details)", "timeContextFromParameter": "LookbackPeriod", "exportedParameters": [ { "fieldName": "ReservationId_g", "parameterName": "selectedReservation", "parameterType": 1 }, { "fieldName": "TotalReservedQuantity_s", "parameterName": "selectedQuantity", "parameterType": 1 } ], "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 }, { "columnMatch": "ReservationName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "17ch" } }, { "columnMatch": "TotalReservedQuantity_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "9ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "maximumFractionDigits": 0 } } }, { "columnMatch": "SKUName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "18ch" } }, { "columnMatch": "Location_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "13ch" } }, { "columnMatch": "Util7Days_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "14ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "Util30Days_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "15ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "DiscountPercent", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "13ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "SavingsMargin", "formatter": 18, "formatOptions": { "thresholdsOptions": "colors", "thresholdsGrid": [ { "operator": "<", "thresholdValue": "0", "representation": "redBright", "text": "{0}{1}" }, { "operator": "<", "thresholdValue": "5", "representation": "yellow", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "green", "text": "{0}{1}" } ], "customColumnWidthSetting": "12ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } } ], "rowLimit": 5000, "sortBy": [ { "itemKey": "$gen_number_Util7Days_s_6", "sortOrder": 1 } ], "labelSettings": [ { "columnId": "ReservationName_s", "label": "Reservation" }, { "columnId": "TotalReservedQuantity_s", "label": "Qty." }, { "columnId": "SKUName_s", "label": "Size" }, { "columnId": "Location_s", "label": "Region" }, { "columnId": "AvgRIsUsedDaily", "label": "Qty. Used (Avg)" }, { "columnId": "Util7Days_s", "label": "Used (7d)" }, { "columnId": "Util30Days_s", "label": "Used (30d)" }, { "columnId": "DiscountPercent", "label": "Discount" }, { "columnId": "SavingsMargin", "label": "Savings" } ] }, "sortBy": [ { "itemKey": "$gen_number_Util7Days_s_6", "sortOrder": 1 } ] }, "customWidth": "55", "name": "riUsageDetailsV2" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| where ReservationId_g == '{selectedReservation:value}'\r\n| extend RINormalizationRatio = tostring(parse_json(AdditionalInfo_s).RINormalizationRatio)\r\n| extend VMSize = tostring(parse_json(AdditionalInfo_s).ServiceType)\r\n| extend ConsumedSize = iif(isnotempty(VMSize), VMSize, strcat(MeterSubCategory_s, ' ', MeterName_s))\r\n| extend UsedRIs = todouble(Quantity_s) * todouble(RINormalizationRatio) / 24\r\n| summarize round(sum(UsedRIs),1) by todatetime(Date_s), ConsumedSize", "size": 0, "aggregation": 3, "showAnalytics": true, "title": "Average RI Usage Count by Size (click on a line in the table at the left)", "timeContextFromParameter": "LookbackPeriod", "exportFieldName": "ReservationId_g", "exportParameterName": "selectedReservation", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 } ], "rowLimit": 1000 }, "chartSettings": { "group": "ConsumedSize", "createOtherGroup": null, "customThresholdLine": "{selectedQuantity}", "customThresholdLineStyle": 1 } }, "customWidth": "45", "name": "riUsageDailyAverageBySize" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let absoluteRIBought = toscalar(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}')\r\n and TimeGenerated < todatetime('{LookbackPeriod:endISO}')\r\n and ChargeType_s == 'Usage'\r\n and isnotempty(ReservationName_s)\r\n| where ReservationId_g == '{selectedReservation:value}'\r\n| summarize (datetime_diff('Day',max(todatetime(Date_s)),min(todatetime(Date_s)))+1)*{selectedQuantity});\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| where ReservationId_g == '{selectedReservation:value}'\r\n| extend RINormalizationRatio = tostring(parse_json(AdditionalInfo_s).RINormalizationRatio)\r\n| extend UsedRIs = todouble(Quantity_s) * todouble(RINormalizationRatio) / 24\r\n| summarize UsedRIPercentage=round(sum(UsedRIs) / absoluteRIBought * 100, 2) by ResourceId, SubscriptionId\r\n| join kind=leftouter ( \r\n AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > ago(1d) and ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project Subscription=ContainerName_s, SubscriptionId=SubscriptionGuid_g\r\n) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| project-reorder ResourceId, Subscription, UsedRIPercentage\r\n| order by ResourceId asc, UsedRIPercentage", "size": 1, "showAnalytics": true, "title": "Average Daily Reservation Usage by Resource (click on a line in the table above)", "timeContextFromParameter": "LookbackPeriod", "exportFieldName": "ReservationId_g", "exportParameterName": "selectedReservation", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "ResourceId", "label": "Resource" }, { "columnId": "Subscription", "label": "Used RI (%)" }, { "columnId": "UsedRIPercentage", "label": "Used RI (%)" } ] } }, "customWidth": "50", "name": "riUsageDailyAverageByResource" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let absoluteRIBought = toscalar(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}')\r\n and TimeGenerated < todatetime('{LookbackPeriod:endISO}')\r\n and ChargeType_s == 'Usage'\r\n and isnotempty(ReservationName_s)\r\n| where ReservationId_g == '{selectedReservation:value}'\r\n| summarize (datetime_diff('Day',max(todatetime(Date_s)),min(todatetime(Date_s)))+1)*{selectedQuantity});\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| where ReservationId_g == '{selectedReservation:value}'\r\n| extend RINormalizationRatio = tostring(parse_json(AdditionalInfo_s).RINormalizationRatio)\r\n| extend Tags_s = iif(Tags_s startswith \"{\", Tags_s, strcat(\"{\", Tags_s, \"}\"))\r\n| extend AggregatorTag = tostring(parse_json(Tags_s)['{Aggregator}'])\r\n| extend UsedRIs = todouble(Quantity_s) * todouble(RINormalizationRatio) / 24\r\n| summarize UsedRIPercentage=round(sum(UsedRIs) / absoluteRIBought * 100, 2) by AggregatorTag, SubscriptionId\r\n| join kind=leftouter ( \r\n AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > ago(1d) and ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project Subscription=ContainerName_s, SubscriptionId=SubscriptionGuid_g\r\n) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| project-reorder AggregatorTag, Subscription, UsedRIPercentage\r\n| order by AggregatorTag asc, UsedRIPercentage", "size": 1, "showAnalytics": true, "title": "Average Daily Reservation Usage by Tag (click on a line in the table above)", "timeContextFromParameter": "LookbackPeriod", "exportFieldName": "ReservationId_g", "exportParameterName": "selectedReservation", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "AggregatorTag", "label": "Aggregator Tag" }, { "columnId": "UsedRIPercentage", "label": "Used RI (%)" } ] } }, "customWidth": "50", "name": "riUsageDailyAverageByInstance" } ] }, "conditionalVisibilities": [ { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "reservationAnalysis" }, { "parameterName": "UseISF", "comparison": "isEqualTo", "value": "No" } ], "name": "reservationAnalysisGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 1, "content": { "json": "VM count for Instance Size Flexibility Groups is proportional to the VM size with the lowest ratio (1).", "style": "warning" }, "name": "text - 3" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nlet LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet VMOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandUnitPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandUnitPrice;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s != 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| summarize OnDemandUnitPrice = max(todouble(UnitPrice_s)) by MeterID_g\r\n| distinct MeterID_g, OnDemandUnitPrice\r\n| union (VMOnDemandPriceSheet)\r\n| summarize OnDemandUnitPrice=min(OnDemandUnitPrice) by MeterID_g;\r\nlet ReservationPricesheet = AzureOptimizationReservationsPriceV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| extend Term_s = iif(reservationTerm_s == '3 Years', 'P3Y', 'P1Y')\r\n| extend TermDivider = iif(reservationTerm_s == '3 Years', 3, 1)\r\n| extend ReservationPrice = todouble(replace_string(unitPrice_s,',','.'))/TermDivider/12/730\r\n| summarize arg_max(TimeGenerated, ReservationPrice) by Location_s=tolower(armRegionName_s), SkuName=tolower(armSkuName_s), Term_s;\r\nlet ReservationOnDemandMeters = AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| extend SkuName = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| distinct ReservationId_g, MeterId_g, SkuName;\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| where ReservationId_g in ({Reservation:value})\r\n| extend RINormalizationRatio = tostring(parse_json(AdditionalInfo_s).RINormalizationRatio)\r\n| extend UsedRIs = todouble(Quantity_s) * todouble(RINormalizationRatio) / 24\r\n| summarize UsedRIsDaily=round(sum(UsedRIs),2) by Date_s, ReservationId_g\r\n| summarize AvgRIsUsedDaily=round(avg(UsedRIsDaily),2) by ReservationId_g\r\n| join kind=rightouter (\r\n AzureOptimizationReservationsUsageV1_CL\r\n | where TimeGenerated > ago(1d)\r\n | where ReservationId_g in ({Reservation:value})\r\n | summarize arg_max(TimeGenerated, *) by ReservationId_g\r\n | where ProvisioningState_s == 'Succeeded'\r\n | extend UsedQuantity = todouble(TotalReservedQuantity_s) * todouble(Util7Days_s) / 100\r\n | extend UsedQuantity30d = todouble(TotalReservedQuantity_s) * todouble(Util30Days_s) / 100\r\n | extend SKUName_s=tolower(SKUName_s)\r\n | project ReservationId_g, ReservationName_s=DisplayName_s, SKUName_s, Location_s, UsedQuantity, UsedQuantity30d, TotalReservedQuantity_s, Term_s, AppliedScopeType_s\r\n) on ReservationId_g\r\n| project ReservationId_g=ReservationId_g1, ReservationName_s, TotalReservedQuantity_s, SKUName_s, Location_s, AvgRIsUsedDaily=iif(isempty(AvgRIsUsedDaily), 0.0, AvgRIsUsedDaily), UsedQuantity, UsedQuantity30d, Term_s, AppliedScopeType_s\r\n| join kind=inner ( ISFGroups ) on $left.SKUName_s == $right.ArmSKUName\r\n| join kind=leftouter ( ReservationOnDemandMeters ) on ReservationId_g\r\n| summarize arg_max(MeterId_g, *) by ReservationId_g\r\n| join kind=leftouter ( ReservationPricesheet ) on SkuName and Location_s and Term_s\r\n| join kind=leftouter ( OnDemandPriceSheet ) on $left.MeterId_g == $right.MeterID_g\r\n| extend DiscountPercent = (1 - ReservationPrice/OnDemandUnitPrice) * 100\r\n| extend AvgRIsUsedInSmallestRatio = Ratio * AvgRIsUsedDaily\r\n| summarize TotalReservedQuantity_s=sum(todouble(TotalReservedQuantity_s)*Ratio), AvgRIsUsedDaily=sum(AvgRIsUsedInSmallestRatio), UsedQuantity=sum(UsedQuantity*Ratio), UsedQuantity30d=sum(UsedQuantity30d*Ratio), AvgDiscountPercent=avg(DiscountPercent) by ISFGroup, Location_s, Term_s, AppliedScopeType_s\r\n| extend Util7Days_s = UsedQuantity/TotalReservedQuantity_s*100, Util30Days_s = UsedQuantity30d/TotalReservedQuantity_s*100\r\n| extend AvgRIUsagePercentInSmallestRatio = round(AvgRIsUsedDaily / TotalReservedQuantity_s * 100, 1)\r\n| extend AvgDiscountPercent=iif(AvgDiscountPercent > 0.0, AvgDiscountPercent, 0.0)\r\n| extend SavingsMargin=round(todouble(Util7Days_s))-100.0+AvgDiscountPercent \r\n| project-away AvgRIUsagePercentInSmallestRatio, AvgRIsUsedDaily\r\n| project-reorder ISFGroup, Location_s, Term_s, AppliedScopeType_s, TotalReservedQuantity_s, Util7Days_s, UsedQuantity, Util30Days_s, UsedQuantity30d\r\n| order by Util7Days_s asc", "size": 0, "showAnalytics": true, "title": "Reservation Usage Details grouped by ISF group (click on a line for more details)", "timeContextFromParameter": "LookbackPeriod", "exportedParameters": [ { "fieldName": "ISFGroup", "parameterName": "selectedISFGroup", "parameterType": 1 }, { "fieldName": "TotalReservedQuantity_s", "parameterName": "selectedQuantity", "parameterType": 1 }, { "fieldName": "Location_s", "parameterName": "selectedRegion", "parameterType": 1 } ], "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ISFGroup", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "14ch" } }, { "columnMatch": "Location_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "15ch" } }, { "columnMatch": "AppliedScopeType_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "11ch" } }, { "columnMatch": "TotalReservedQuantity_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "10ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "maximumFractionDigits": 0 } } }, { "columnMatch": "Util7Days_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "14ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "UsedQuantity", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "18ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "maximumFractionDigits": 0 } } }, { "columnMatch": "Util30Days_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "15ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "UsedQuantity30d", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "20ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "maximumFractionDigits": 0 } } }, { "columnMatch": "AvgDiscountPercent", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "18ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "SavingsMargin", "formatter": 18, "formatOptions": { "thresholdsOptions": "colors", "thresholdsGrid": [ { "operator": "<", "thresholdValue": "0", "representation": "redBright", "text": "{0}{1}" }, { "operator": "<", "thresholdValue": "5", "representation": "yellow", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "green", "text": "{0}{1}" } ], "customColumnWidthSetting": "12ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "AvgRIsUsedDaily", "formatter": 0, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "maximumFractionDigits": 0 } } }, { "columnMatch": "AvgRIUsagePercentInSmallestRatio", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal", "minimumFractionDigits": 1, "maximumFractionDigits": 1 } } }, { "columnMatch": "ReservationId_g", "formatter": 5 }, { "columnMatch": "ReservationName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "30ch" } }, { "columnMatch": "SKUName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "20ch" } } ], "rowLimit": 5000, "sortBy": [ { "itemKey": "$gen_number_Util7Days_s_5", "sortOrder": 1 } ], "labelSettings": [ { "columnId": "ISFGroup", "label": "ISF Group" }, { "columnId": "Location_s", "label": "Region" }, { "columnId": "Term_s", "label": "Term" }, { "columnId": "AppliedScopeType_s", "label": "Scope" }, { "columnId": "TotalReservedQuantity_s", "label": "Qty." }, { "columnId": "Util7Days_s", "label": "Used (7d)" }, { "columnId": "UsedQuantity", "label": "Used Qty. (7d)" }, { "columnId": "Util30Days_s", "label": "Used (30d)" }, { "columnId": "UsedQuantity30d", "label": "Used Qty. (30d)" }, { "columnId": "AvgDiscountPercent", "label": "Avg. Discount" }, { "columnId": "SavingsMargin", "label": "Savings" } ] }, "sortBy": [ { "itemKey": "$gen_number_Util7Days_s_5", "sortOrder": 1 } ] }, "customWidth": "65", "name": "riUsageDetailsV2ISF" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| where ResourceLocation_s =~ '{selectedRegion}'\r\n| extend VMSize = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| join kind=inner ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| where ISFGroup == '{selectedISFGroup:value}'\r\n| extend RINormalizationRatio = tostring(parse_json(AdditionalInfo_s).RINormalizationRatio)\r\n| extend ConsumedSize = iif(isnotempty(VMSize), VMSize, strcat(MeterSubCategory_s, ' ', MeterName_s))\r\n| extend UsedRIs = todouble(Quantity_s) / 24 * Ratio\r\n| summarize round(sum(UsedRIs),1) by todatetime(Date_s), ConsumedSize", "size": 0, "aggregation": 3, "showAnalytics": true, "title": "Average RI Usage Count by Size (click on a line in the table at the left)", "timeContextFromParameter": "LookbackPeriod", "exportFieldName": "ReservationId_g", "exportParameterName": "selectedReservation", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 } ], "rowLimit": 1000 }, "chartSettings": { "group": "ConsumedSize", "createOtherGroup": null, "customThresholdLine": "{selectedQuantity}", "customThresholdLineStyle": 1 } }, "customWidth": "35", "name": "riUsageDailyAverageBySizeISF" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nlet absoluteRIBought = toscalar(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}')\r\n and TimeGenerated < todatetime('{LookbackPeriod:endISO}')\r\n and ChargeType_s == 'Usage'\r\n and isnotempty(ReservationName_s)\r\n| extend VMSize = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| where ResourceLocation_s =~ '{selectedRegion}'\r\n| join kind=inner ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| where ISFGroup == '{selectedISFGroup:value}'\r\n| summarize (datetime_diff('Day',max(todatetime(Date_s)),min(todatetime(Date_s)))+1)*{selectedQuantity});\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| extend VMSize = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| where ResourceLocation_s =~ '{selectedRegion}'\r\n| join kind=inner ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| where ISFGroup == '{selectedISFGroup:value}'\r\n| extend UsedRIs = todouble(Quantity_s) * Ratio / 24\r\n| summarize UsedRIPercentage=round(sum(UsedRIs) / absoluteRIBought * 100, 2) by ResourceId, SubscriptionId\r\n| join kind=leftouter ( \r\n AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > ago(1d) and ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project Subscription=ContainerName_s, SubscriptionId=SubscriptionGuid_g\r\n) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| project-reorder ResourceId, Subscription, UsedRIPercentage\r\n| order by ResourceId asc, UsedRIPercentage", "size": 1, "showAnalytics": true, "title": "Average Daily Reservation Usage by Resource (click on a line in the table above)", "timeContextFromParameter": "LookbackPeriod", "exportFieldName": "ReservationId_g", "exportParameterName": "selectedReservation", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "ResourceId", "label": "Resource" }, { "columnId": "Subscription", "label": "Subscription" }, { "columnId": "UsedRIPercentage", "label": "Used RI (%)" } ] } }, "customWidth": "50", "name": "riUsageDailyAverageByResourceISF" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nlet absoluteRIBought = toscalar(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}')\r\n and TimeGenerated < todatetime('{LookbackPeriod:endISO}')\r\n and ChargeType_s == 'Usage'\r\n and isnotempty(ReservationName_s)\r\n| extend VMSize = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| where ResourceLocation_s =~ '{selectedRegion}'\r\n| join kind=inner ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| where ISFGroup == '{selectedISFGroup:value}'\r\n| summarize (datetime_diff('Day',max(todatetime(Date_s)),min(todatetime(Date_s)))+1)*{selectedQuantity});\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| extend VMSize = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| where ResourceLocation_s =~ '{selectedRegion}'\r\n| join kind=inner ( ISFGroups ) on $left.VMSize == $right.ArmSKUName\r\n| where ISFGroup == '{selectedISFGroup:value}'\r\n| extend Tags_s = iif(Tags_s startswith \"{\", Tags_s, strcat(\"{\", Tags_s, \"}\"))\r\n| extend AggregatorTag = tostring(parse_json(Tags_s)['{Aggregator}'])\r\n| extend UsedRIs = todouble(Quantity_s) * Ratio / 24\r\n| summarize UsedRIPercentage=round(sum(UsedRIs) / absoluteRIBought * 100, 2) by AggregatorTag, SubscriptionId\r\n| join kind=leftouter ( \r\n AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > ago(1d) and ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project Subscription=ContainerName_s, SubscriptionId=SubscriptionGuid_g\r\n) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| project-reorder AggregatorTag, Subscription, UsedRIPercentage\r\n| order by AggregatorTag asc, UsedRIPercentage", "size": 1, "showAnalytics": true, "title": "Average Daily Reservation Usage by Tag (click on a line in the table above)", "timeContextFromParameter": "LookbackPeriod", "exportFieldName": "ReservationId_g", "exportParameterName": "selectedReservation", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "AggregatorTag", "label": "Aggregator Tag" }, { "columnId": "Subscription", "label": "Subscription" }, { "columnId": "UsedRIPercentage", "label": "Used RI (%)" } ] } }, "customWidth": "50", "name": "riUsageDailyAverageByInstanceISF" } ] }, "conditionalVisibilities": [ { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "reservationAnalysis" }, { "parameterName": "UseISF", "comparison": "isEqualTo", "value": "Yes" } ], "name": "reservationAnalysisGroupISF" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let DaysSeenByReservation = AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| where ReservationId_g in ({Reservation:value})\r\n| summarize DaysSeen=datetime_diff('Day', max(todatetime(Date_s)), min(todatetime(Date_s)))+1 by ReservationId_g;\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| where ReservationId_g in ({Reservation:value})\r\n| extend RINormalizationRatio = tostring(parse_json(AdditionalInfo_s).RINormalizationRatio)\r\n| extend Tags_s = iif(Tags_s startswith \"{\", Tags_s, strcat(\"{\", Tags_s, \"}\"))\r\n| extend AggregatorTag = tostring(parse_json(Tags_s)['{Aggregator}'])\r\n| extend UsedRIs = todouble(Quantity_s) * todouble(RINormalizationRatio) / 24\r\n| summarize TotalUsedRIs=round(sum(UsedRIs),2) by ReservationId_g, AggregatorTag, SubscriptionId\r\n| join kind=inner (DaysSeenByReservation) on ReservationId_g\r\n| project-away ReservationId_g1\r\n| join kind=leftouter ( \r\n AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > ago(1d) and ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project Subscription=ContainerName_s, SubscriptionId=SubscriptionGuid_g\r\n) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| join kind=inner (\r\n AzureOptimizationReservationsUsageV1_CL\r\n | where TimeGenerated > todatetime('{LookbackPeriod:startISO}')\r\n | where ReservationId_g in ({Reservation:value})\r\n | summarize arg_max(TimeGenerated, *) by ReservationId_g\r\n | project ReservationId_g, TotalReservedQuantity_s, ReservationName_s=DisplayName_s\r\n) on ReservationId_g\r\n| project-away ReservationId_g1\r\n| extend UsedRIPercentage = TotalUsedRIs / (todouble(TotalReservedQuantity_s) * DaysSeen) * 100\r\n| project-reorder ReservationName_s, AggregatorTag\r\n| order by ReservationId_g", "size": 2, "showAnalytics": true, "exportedParameters": [ { "fieldName": "ReservationId_g", "parameterName": "selectedReservation" }, { "fieldName": "ReservationName_s", "parameterName": "selectedReservationName", "parameterType": 1 } ], "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 }, { "columnMatch": "TotalUsedRIs", "formatter": 5 }, { "columnMatch": "DaysSeen", "formatter": 5 }, { "columnMatch": "ReservationId_g1", "formatter": 5 }, { "columnMatch": "TotalReservedQuantity_s", "formatter": 5 }, { "columnMatch": "UsedRIPercentage", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 1 } } } ], "rowLimit": 10000, "labelSettings": [ { "columnId": "ReservationName_s", "label": "Reservation" }, { "columnId": "AggregatorTag", "label": "Aggregator Tag" }, { "columnId": "UsedRIPercentage", "label": "Used RI" } ] } }, "name": "riUsageByTag" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "usageByTag" }, "name": "usageByTagGroup" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'UnusedReservation'\r\n| where ReservationId_g in ({Reservation:value})\r\n| extend UnusedCost = todouble(CostInBillingCurrency_s)\r\n| project todatetime(Date_s), ReservationName_s, UnusedCost", "size": 0, "title": "Cost of Unused Reservations over time", "timeContextFromParameter": "LookbackPeriod", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "unusedReservations" }, "name": "unusedReservationsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'UnusedReservation'\r\n| where ReservationId_g in ({Reservation:value})\r\n| extend UnusedCost = todouble(CostInBillingCurrency_s)\r\n| join kind=leftouter (\r\n AzureOptimizationReservationsUsageV1_CL\r\n | where TimeGenerated > ago(1d)\r\n | where ProvisioningState_s == 'Succeeded'\r\n | extend UnusedQuantity = todouble(TotalReservedQuantity_s) - (todouble(TotalReservedQuantity_s) * todouble(Util7Days_s) / 100)\r\n | extend UnusedQuantity30d = todouble(TotalReservedQuantity_s) - (todouble(TotalReservedQuantity_s) * todouble(Util30Days_s) / 100)\r\n | project ReservationId_g, SKUName_s=tolower(SKUName_s), Location_s\r\n) on ReservationId_g\r\n| join kind=leftouter ( ISFGroups ) on $left.SKUName_s == $right.ArmSKUName\r\n| extend SKUName = iif('{UseISF}' == 'Yes', strcat(ISFGroup, \" \", Location_s), strcat(SKUName_s, \" \", Location_s))\r\n| extend SKUName = iif(strlen(SKUName) < 2, 'Canceled RIs', SKUName)\r\n| summarize sum(UnusedCost) by todatetime(Date_s), SKUName", "size": 0, "showAnalytics": true, "title": "Cost of Unused Reservations over time (by SKU)", "timeContextFromParameter": "LookbackPeriod", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "unusedReservations" }, "name": "unusedReservationsOverTimebySKU" }, { "type": 1, "content": { "json": "Canceled RIs are not considered in the historical view. VM count for Instance Size Flexibility Groups is proportional to the VM size with the lowest ratio (1).", "style": "warning" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "unusedReservations" }, "name": "warningISFCanceled" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'UnusedReservation'\r\n| where ReservationId_g in ({Reservation:value})\r\n| extend UnusedHours = todouble(Quantity_s)\r\n| join kind=inner (\r\n AzureOptimizationReservationsUsageV1_CL\r\n | where TimeGenerated > ago(1d)\r\n | where ProvisioningState_s == 'Succeeded'\r\n | extend UnusedQuantity = todouble(TotalReservedQuantity_s) - (todouble(TotalReservedQuantity_s) * todouble(Util7Days_s) / 100)\r\n | extend UnusedQuantity30d = todouble(TotalReservedQuantity_s) - (todouble(TotalReservedQuantity_s) * todouble(Util30Days_s) / 100)\r\n | project ReservationId_g, SKUName_s=tolower(SKUName_s), Location_s\r\n) on ReservationId_g\r\n| join kind=leftouter ( ISFGroups ) on $left.SKUName_s == $right.ArmSKUName\r\n| extend SKUName = iif('{UseISF}' == 'Yes', strcat(ISFGroup, \" \", Location_s), strcat(SKUName_s, \" \", Location_s))\r\n| extend UnusedVMs = iif('{UseISF}' == 'Yes', UnusedHours * Ratio / 24, UnusedHours / 24)\r\n| summarize sum(UnusedVMs) by todatetime(Date_s), SKUName", "size": 0, "showAnalytics": true, "title": "Unused Reservations over time (by VM count)", "timeContextFromParameter": "LookbackPeriod", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart" }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "unusedReservations" }, "name": "unusedReservationsOverTimebyISFGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'UnusedReservation'\r\n| where ReservationId_g in ({Reservation:value})\r\n| summarize TotalUnusedCost = sum(todouble(CostInBillingCurrency_s)) by ReservationId_g\r\n| where round(TotalUnusedCost) > 0\r\n| join kind=inner (\r\n AzureOptimizationReservationsUsageV1_CL\r\n | where TimeGenerated > ago(1d)\r\n | where ProvisioningState_s == 'Succeeded'\r\n | extend UnusedQuantity = todouble(TotalReservedQuantity_s) - (todouble(TotalReservedQuantity_s) * todouble(Util7Days_s) / 100)\r\n | extend UnusedQuantity30d = todouble(TotalReservedQuantity_s) - (todouble(TotalReservedQuantity_s) * todouble(Util30Days_s) / 100)\r\n | project ReservationId_g, ReservationName_s=DisplayName_s, ExpiryDate_s, SKUName_s, Location_s, TotalReservedQuantity_s, Term_s, AppliedScopeType_s, Util7Days_s, UnusedQuantity, Util30Days_s, UnusedQuantity30d\r\n) on ReservationId_g\r\n| project-away ReservationId_g1\r\n| project-reorder ReservationName_s, TotalUnusedCost\r\n| order by TotalUnusedCost\r\n", "size": 2, "showAnalytics": true, "title": "Unused Reservations Details", "timeContextFromParameter": "LookbackPeriod", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ReservationName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "TotalUnusedCost", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "22ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "maximumFractionDigits": 0 } } }, { "columnMatch": "ReservationId_g", "formatter": 5 }, { "columnMatch": "TotalReservedQuantity_s", "formatter": 1, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "maximumFractionDigits": 0 } } }, { "columnMatch": "Util7Days_s", "formatter": 1, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "UnusedQuantity", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "20ch" }, "numberFormat": { "unit": 17, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "Util30Days_s", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "ProvisioningState_s", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "Cancelled", "representation": "4", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "success", "text": "{0}{1}" } ] } } ], "labelSettings": [ { "columnId": "ReservationName_s", "label": "Reservation" }, { "columnId": "TotalUnusedCost", "label": "Total Unused Cost" }, { "columnId": "ExpiryDate_s", "label": "Expires On" }, { "columnId": "SKUName_s", "label": "Size" }, { "columnId": "Location_s", "label": "Region" }, { "columnId": "TotalReservedQuantity_s", "label": "Qty." }, { "columnId": "Term_s", "label": "Term" }, { "columnId": "AppliedScopeType_s", "label": "Scope" }, { "columnId": "Util7Days_s", "label": "Used (7d)" }, { "columnId": "UnusedQuantity", "label": "Unused Qty. (7d)" }, { "columnId": "Util30Days_s", "label": "Used (30d)" }, { "columnId": "UnusedQuantity30d", "label": "Unused Qty. (30d)" } ] } }, "name": "unusedReservationsDetails" } ] }, "conditionalVisibilities": [ { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "unusedReservations" }, { "parameterName": "UseISF", "comparison": "isEqualTo", "value": "No" } ], "name": "unusedReservationsGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 1, "content": { "json": "VM count for Instance Size Flexibility Groups is proportional to the VM size with the lowest ratio (1).", "style": "warning" }, "name": "text - 1" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend ArmSKUName=tolower(ArmSKUName);\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'UnusedReservation'\r\n| where ReservationId_g in ({Reservation:value})\r\n| summarize TotalUnusedCost = sum(todouble(CostInBillingCurrency_s)) by ReservationId_g\r\n| where round(TotalUnusedCost) > 0\r\n| join kind=inner (\r\n AzureOptimizationReservationsUsageV1_CL\r\n | where TimeGenerated > ago(1d)\r\n | where ProvisioningState_s == 'Succeeded'\r\n | extend UnusedQuantity = todouble(TotalReservedQuantity_s) - (todouble(TotalReservedQuantity_s) * todouble(Util7Days_s) / 100)\r\n | extend UnusedQuantity30d = todouble(TotalReservedQuantity_s) - (todouble(TotalReservedQuantity_s) * todouble(Util30Days_s) / 100)\r\n | project ReservationId_g, ReservationName_s=DisplayName_s, SKUName_s=tolower(SKUName_s), Location_s, TotalReservedQuantity_s, Term_s, AppliedScopeType_s, UnusedQuantity, UnusedQuantity30d\r\n) on ReservationId_g\r\n| project-away ReservationId_g1\r\n| join kind=inner ( ISFGroups ) on $left.SKUName_s == $right.ArmSKUName\r\n| summarize TotalUnusedCost=sum(TotalUnusedCost), TotalReservedQuantity_s=sum(todouble(TotalReservedQuantity_s)*Ratio), UnusedQuantity=sum(UnusedQuantity*Ratio), UnusedQuantity30d=sum(UnusedQuantity30d*Ratio) by ISFGroup, Location_s, Term_s, AppliedScopeType_s\r\n| extend Util7Days_s = (1-UnusedQuantity/TotalReservedQuantity_s)*100, Util30Days_s = (1-UnusedQuantity30d/TotalReservedQuantity_s)*100\r\n| project-reorder ISFGroup, TotalUnusedCost, Location_s, Term_s, AppliedScopeType_s, TotalReservedQuantity_s, Util7Days_s, UnusedQuantity, Util30Days_s\r\n| order by TotalUnusedCost", "size": 2, "showAnalytics": true, "title": "Unused Reservations Details (grouped by Instance Size Flexibility group)", "timeContextFromParameter": "LookbackPeriod", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "TotalUnusedCost", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "22ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "maximumFractionDigits": 0 } } }, { "columnMatch": "TotalReservedQuantity_s", "formatter": 1, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "maximumFractionDigits": 0 } } }, { "columnMatch": "Util7Days_s", "formatter": 1, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "UnusedQuantity", "formatter": 1, "numberFormat": { "unit": 0, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "Util30Days_s", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "ReservationName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "ReservationId_g", "formatter": 5 }, { "columnMatch": "ProvisioningState_s", "formatter": 18, "formatOptions": { "thresholdsOptions": "icons", "thresholdsGrid": [ { "operator": "==", "thresholdValue": "Cancelled", "representation": "4", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "success", "text": "{0}{1}" } ] } } ], "labelSettings": [ { "columnId": "ISFGroup", "label": "ISF Group" }, { "columnId": "TotalUnusedCost", "label": "Total Unused Cost" }, { "columnId": "Location_s", "label": "Region" }, { "columnId": "Term_s", "label": "Term" }, { "columnId": "AppliedScopeType_s", "label": "Scope" }, { "columnId": "TotalReservedQuantity_s", "label": "Qty." }, { "columnId": "Util7Days_s", "label": "Used (7d)" }, { "columnId": "UnusedQuantity", "label": "Unused Qty. (7d)" }, { "columnId": "Util30Days_s", "label": "Used (30d)" }, { "columnId": "UnusedQuantity30d", "label": "Unused Qty. (30d)" } ] } }, "name": "unusedReservationsDetailsISF" } ] }, "conditionalVisibilities": [ { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "unusedReservations" }, { "parameterName": "UseISF", "comparison": "isEqualTo", "value": "Yes" } ], "name": "unusedReservationsGroupISF" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet VMOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandUnitPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandUnitPrice;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s != 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| summarize OnDemandUnitPrice = max(todouble(UnitPrice_s)) by MeterID_g\r\n| union (VMOnDemandPriceSheet)\r\n| summarize OnDemandUnitPrice=min(OnDemandUnitPrice) by MeterID_g;\r\nlet ReservationPricesheet = materialize(AzureOptimizationReservationsPriceV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| extend Term_s = iif(reservationTerm_s == '3 Years', 'P3Y', 'P1Y')\r\n| extend TermDivider = iif(reservationTerm_s == '3 Years', 3, 1)\r\n| extend ReservationPrice = todouble(replace_string(unitPrice_s,',','.'))/TermDivider/12/730\r\n| summarize arg_max(TimeGenerated, ReservationPrice) by Location_s=tolower(armRegionName_s), SkuName=tolower(armSkuName_s), Term_s);\r\nlet ReservationOnDemandMeters = AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| extend SkuName = tolower(parse_json(AdditionalInfo_s).ServiceType)\r\n| distinct ReservationId_g, MeterId_g, SkuName;\r\nlet ISFGroups = externaldata(ISFGroup:string, ArmSKUName:string, Ratio:double) [@\"https://aka.ms/isf\"] with(ignoreFirstRecord=true) | extend skuName=tolower(ArmSKUName);\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and isnotempty(ReservationName_s)\r\n| where ReservationId_g in ({Reservation:value})\r\n| extend RINormalizationRatio = tostring(parse_json(AdditionalInfo_s).RINormalizationRatio)\r\n| extend UsedRIs = todouble(Quantity_s) * todouble(RINormalizationRatio) / 24\r\n| summarize UsedRIsDaily=round(sum(UsedRIs),2) by Date_s, ReservationId_g\r\n| summarize AvgRIsUsedDaily=round(avg(UsedRIsDaily),2) by ReservationId_g\r\n| join kind=rightouter (\r\n AzureOptimizationReservationsUsageV1_CL\r\n | where TimeGenerated > ago(1d)\r\n | where ReservationId_g in ({Reservation:value})\r\n | summarize arg_max(TimeGenerated, *) by ReservationId_g\r\n | where ProvisioningState_s == 'Succeeded'\r\n | project ReservationId_g, ReservationName_s=DisplayName_s, SKUName_s, Location_s, Util7Days_s, Util30Days_s, TotalReservedQuantity_s, Term_s, ExpiryDate_s\r\n) on ReservationId_g\r\n| project ReservationId_g=ReservationId_g1, ReservationName_s, TotalReservedQuantity_s, SKUName_s=tolower(SKUName_s), Location_s, AvgRIsUsedDaily=iif(isempty(AvgRIsUsedDaily), 0.0, AvgRIsUsedDaily), Util7Days_s, Util30Days_s, Term_s, ExpiryDate_s\r\n| join kind=leftouter ( ISFGroups ) on $left.SKUName_s==$right.skuName\r\n| join kind=leftouter (ReservationOnDemandMeters) on ReservationId_g\r\n| summarize arg_max(MeterId_g, *) by ReservationId_g\r\n| join kind=leftouter (ReservationPricesheet) on SkuName and Location_s and Term_s\r\n| join kind=leftouter (OnDemandPriceSheet) on $left.MeterId_g == $right.MeterID_g\r\n| extend DiscountPercent = (1 - ReservationPrice/OnDemandUnitPrice) * 100\r\n| project-away ReservationPrice\r\n| join kind=leftouter (ReservationPricesheet) on $left.SKUName_s==$right.SkuName and Location_s and Term_s\r\n| extend HoursUntilExpiry=(todatetime(ExpiryDate_s)-now())/1h\r\n| extend TotalReservedHoursToConsume=todouble(TotalReservedQuantity_s)*HoursUntilExpiry\r\n| extend AmountRemainingToConsume = round(TotalReservedHoursToConsume * ReservationPrice, 2)\r\n| project ReservationId_g, ReservationName_s, TotalReservedQuantity_s=round(todouble(TotalReservedQuantity_s)), SKUName_s, ISFGroup, Location_s, Term_s, ExpiryDate_s, AmountRemainingToConsume, AvgRIsUsedDaily, Util7Days_s=round(todouble(Util7Days_s)), Util30Days_s=round(todouble(Util30Days_s)), DiscountPercent, SavingsMargin=round(todouble(Util7Days_s))-100.0+DiscountPercent \r\n| order by Util7Days_s asc", "size": 0, "showAnalytics": true, "timeContextFromParameter": "LookbackPeriod", "exportedParameters": [ { "fieldName": "ReservationId_g", "parameterName": "selectedReservation", "parameterType": 1 }, { "fieldName": "TotalReservedQuantity_s", "parameterName": "selectedQuantity", "parameterType": 1 } ], "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 }, { "columnMatch": "ReservationName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "24ch" } }, { "columnMatch": "TotalReservedQuantity_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "9ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "maximumFractionDigits": 0 } } }, { "columnMatch": "SKUName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "18ch" } }, { "columnMatch": "ISFGroup", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "20ch" } }, { "columnMatch": "Location_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "13ch" } }, { "columnMatch": "AmountRemainingToConsume", "formatter": 0, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "minimumFractionDigits": 2, "maximumFractionDigits": 2 } }, "tooltipFormat": { "tooltip": "Remaining commitment to be consumed until the reservation expiry date (value in the currency of your consumption agreement)" } }, { "columnMatch": "Util7Days_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "14ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "Util30Days_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "15ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "DiscountPercent", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "13ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "SavingsMargin", "formatter": 18, "formatOptions": { "thresholdsOptions": "colors", "thresholdsGrid": [ { "operator": "<", "thresholdValue": "0", "representation": "redBright", "text": "{0}{1}" }, { "operator": "<", "thresholdValue": "5", "representation": "yellow", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "green", "text": "{0}{1}" } ], "customColumnWidthSetting": "12ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "ReservationId_g", "label": "ID" }, { "columnId": "ReservationName_s", "label": "Reservation" }, { "columnId": "TotalReservedQuantity_s", "label": "Qty." }, { "columnId": "SKUName_s", "label": "Size" }, { "columnId": "ISFGroup", "label": "ISF Group" }, { "columnId": "Location_s", "label": "Region" }, { "columnId": "Term_s", "label": "Term" }, { "columnId": "ExpiryDate_s", "label": "Expires On" }, { "columnId": "AmountRemainingToConsume", "label": "Remain. Commit.", "comment": "" }, { "columnId": "AvgRIsUsedDaily", "label": "Qty. Used (Avg)" }, { "columnId": "Util7Days_s", "label": "Used (7d)" }, { "columnId": "Util30Days_s", "label": "Used (30d)" }, { "columnId": "DiscountPercent", "label": "Discount" }, { "columnId": "SavingsMargin", "label": "Savings" } ] }, "sortBy": [] }, "name": "riUsageDetailsFull" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "fullReport" }, "name": "fullReport" } ], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" } ================================================ FILE: views/workbooks/resources-inventory.bicep ================================================ @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') param workbookDisplayName string = 'Resources Inventory' @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') param workbookType string = 'workbook' @description('The id of resource instance to which the workbook will be associated') param workbookSourceId string @description('The unique guid for this workbook instance') param workbookId string = '065fc198-6435-4724-99b2-60cea8a2d7d2' param resourceTags object param resourceGroupLocation string = resourceGroup().location resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { name: workbookId location: resourceGroupLocation tags: resourceTags kind: 'shared' properties: { displayName: workbookDisplayName serializedData: string(loadJsonContent('resources-inventory.json')) version: '1.0' sourceId: workbookSourceId category: workbookType } dependsOn: [] } output workbookId string = workbookId_resource.id ================================================ FILE: views/workbooks/resources-inventory.json ================================================ { "version": "Notebook/1.0", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "d2503809-fae8-47d2-953c-3a2255d0d9fc", "version": "KqlParameterItem/1.0", "name": "ResourcesTimeRange", "label": "Time Range", "type": 4, "isRequired": true, "value": { "durationMs": 2592000000 }, "typeSettings": { "selectableValues": [ { "durationMs": 86400000 }, { "durationMs": 172800000 }, { "durationMs": 259200000 }, { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ], "allowCustom": true }, "timeContext": { "durationMs": 86400000 } }, { "id": "b48a696e-cf64-450b-a7cc-9e0a5e457170", "version": "KqlParameterItem/1.0", "name": "SelectedSubscriptions", "label": "Subscription", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') | where ContainerType_s =~ 'microsoft.resources/subscriptions' | project subscriptionId = SubscriptionGuid_g, SubscriptionName = ContainerName_s | distinct subscriptionId, SubscriptionName | order by SubscriptionName asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "timeContext": { "durationMs": 0 }, "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" } ], "style": "pills", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "parameters - 0" }, { "type": 11, "content": { "version": "LinkItem/1.0", "style": "tabs", "links": [ { "id": "818bfe0d-ac41-432d-b11e-132a88c2ee35", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "Overview", "subTarget": "General", "style": "link" }, { "id": "9fe4860d-95b6-43b8-bded-9502e535d26e", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "VMs", "subTarget": "VirtualMachines", "style": "link" }, { "id": "f85c45e2-e0de-4cb1-98e8-7c3300d43bf9", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "VM Disks", "subTarget": "Disks", "style": "link" }, { "id": "49f1781d-600e-4740-ba6f-5bb77c3fa510", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "VM NICs", "subTarget": "NetworkInterfaces", "style": "link" }, { "id": "b53be37d-ec7d-43a6-880b-ba2744f74f99", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "VM Scale Sets", "subTarget": "VMSS", "style": "link" }, { "id": "46b12a25-7de3-4ce2-8168-4ad86c1260da", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "VMSS Disks", "subTarget": "VMSSDisks", "style": "link" }, { "id": "6fe330d4-6150-448b-88bd-ed0cfbe5a569", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "VNets", "subTarget": "VirtualNetworks", "style": "link" }, { "id": "cc05569b-dfb3-455e-a472-3cd9209bf4ad", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "NSGs", "subTarget": "NSGs", "style": "link" }, { "id": "27d46a4c-8485-44e6-971d-35c2091ecf13", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "Load Balancers", "subTarget": "LoadBalancers", "style": "link" }, { "id": "e0bd5390-0209-4d07-aaae-4aedbe44627a", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "Public IPs", "subTarget": "PublicIPs", "style": "link" }, { "id": "cdcd8c8c-4024-4266-90a3-3f8d05f51d76", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "Application Gateways", "subTarget": "ApplicationGateways", "style": "link" }, { "id": "4bc436f7-e363-4bfc-b884-59c27272b9f5", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "App Service Plans", "subTarget": "AppServicePlans", "style": "link" }, { "id": "b6def48d-032d-49f7-809a-d3108e1a617a", "cellValue": "SelectedTab", "linkTarget": "parameter", "linkLabel": "SQL Databases", "subTarget": "SQLDatabases", "style": "link" } ] }, "name": "tabs" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ResourceType = 'VMs' | summarize TileValue=count() by ResourceType\r\n| union isfuzzy=true ( AzureOptimizationVMSSV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ResourceType = 'Scale Sets' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationVMSSV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ResourceType = 'Scale Set VMs' | summarize TileValue=sum(toint(Capacity_s)) by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationVMSSV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ResourceType = 'Scale Set Disks' | summarize TileValue=sum(toint(Capacity_s) + toint(Capacity_s) * toint(DataDiskCount_s)) by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationDisksV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ResourceType = 'Managed Disks' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationVhdDisksV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ResourceType = 'VHDs' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationVNetsV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | distinct InstanceId_s | extend ResourceType = 'VNets' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationVNetsV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ResourceType = 'Subnets' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationNICsV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | distinct InstanceId_s | extend ResourceType = 'NICs' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationLoadBalancersV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ResourceType = 'Load Balancers' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationAppGatewaysV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ResourceType = 'App GWs' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationPublicIPsV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ResourceType = 'Public IPs' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationNSGsV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | distinct InstanceId_s | extend ResourceType = 'NSGs' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationAppServicePlansV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | distinct InstanceId_s | extend ResourceType = 'App Service Plans' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationSqlDbV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | distinct InstanceId_s | extend ResourceType = 'SQL DBs' | summarize TileValue=count() by ResourceType )\r\n| union isfuzzy=true ( AzureOptimizationResourceContainersV1_CL | where todatetime(TimeGenerated) > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend ResourceType = 'All Resources' | summarize TileValue=sum(toint(ResourceCount_s)) by ResourceType )\r\n| project ResourceType, TileValue", "size": 3, "title": "Total Resources (today)", "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "tiles", "tileSettings": { "titleContent": { "columnMatch": "ResourceType", "formatter": 1 }, "leftContent": { "columnMatch": "TileValue", "formatter": 12, "formatOptions": { "palette": "auto" }, "numberFormat": { "unit": 0, "options": { "style": "decimal" } } }, "showBorder": true, "sortCriteriaField": "TileValue", "sortOrderField": 2, "size": "auto" } }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "General" }, "name": "resourcesTiles" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionType = tostring(parse_json(ContainerProperties_s).subscriptionPolicies.quotaId) | summarize count() by SubscriptionType, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Subscriptions by Type", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "General" }, "name": "SubscriptionsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend ManagementGroup = tostring(parse_json(ContainerProperties_s).managementGroupAncestorsChain[0].displayName) | summarize count() by ManagementGroup, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Subscriptions by Management Group", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "General" }, "name": "MGsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions/resourcegroups' | project InstanceId_s, SubscriptionGuid_g, todatetime(StatusDate_s) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Resource Groups by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "chartSettings": { "customThresholdLine": "980", "customThresholdLineStyle": 5 } }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "General" }, "name": "rgsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | summarize sum(toint(ResourceCount_s)) by ContainerName_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Resources by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "General" }, "name": "resourcesOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > datetime('{ResourcesTimeRange:startISO}') and Model_s == 'AzureRM' and Scope_s startswith '/subscriptions/' | extend SubscriptionGuid_g = tostring(split(Scope_s, '/')[2]) | where SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner (AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(TimeGenerated, 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "RBAC Assignments by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "chartSettings": { "customThresholdLine": "4000", "customThresholdLineStyle": 5 } }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "General" }, "name": "subRbacOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > datetime('{ResourcesTimeRange:startISO}') and Model_s == 'AzureRM' and Scope_s has 'managementGroups' | extend ManagementGroupId = tostring(split(Scope_s, '/')[4]) | summarize count() by ManagementGroupId, bin(TimeGenerated, 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "RBAC Assignments by Management Group", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "chartSettings": { "customThresholdLine": "500", "customThresholdLineStyle": 5 } }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "General" }, "name": "mgRbacOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "General" }, "name": "overviewGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "24131817-f7b9-4a2f-9cc9-7deceb0a87c4", "version": "KqlParameterItem/1.0", "name": "vmRegion", "label": "Region", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | distinct Location_s | order by Location_s asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "version": "KqlParameterItem/1.0", "name": "vmSize", "label": "Size", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | distinct VMSize_s | order by VMSize_s asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "id": "5d2ec47b-c62e-4ffb-a569-9f0bb3b7f54b" }, { "version": "KqlParameterItem/1.0", "name": "vmOsType", "label": "OS Type", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | distinct OSType_s | order by OSType_s asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "id": "fad59446-6bf3-4b91-abf2-150b79937416" }, { "version": "KqlParameterItem/1.0", "name": "vmOsModel", "label": "OS Model", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | distinct OSModel | order by OSModel asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "id": "f093b915-126e-44ba-b5fa-621ec5ef504b" }, { "version": "KqlParameterItem/1.0", "name": "vmImageModel", "label": "Image Model", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | distinct ImageModel | order by ImageModel asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "id": "ac8bd008-07cc-4ea1-a2c9-52437772db0f" }, { "version": "KqlParameterItem/1.0", "name": "vmPowerState", "label": "Power State", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | distinct PowerState | order by PowerState asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "id": "98e37c72-b912-412e-a3a4-061acadf2d1c" } ], "style": "pills", "doNotRunWhenHidden": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VMs by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VMs by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmLocationsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(toint(CoresCount_s)) by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Cores by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "coresSubscriptionsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | summarize sum(toint(CoresCount_s)) by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Cores by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "coresLocationsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | summarize count() by tolower(VMSize_s), bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VMs by Size", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmSizesOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where TimeGenerated > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | extend Size = tolower(VMSize_s) | summarize count() by Size | order by count_ ", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VMs by Size", "timeContextFromParameter": "ResourcesTimeRange", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "filter": true } }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmSizesLast" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | summarize count() by OSType_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VMs by OS Type", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmOsTypeOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | summarize count() by PowerState, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VMs by Power State", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmPowerStateOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | summarize count() by OSModel, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VMs by OS Model", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmOSModelOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where TimeGenerated > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | summarize count() by OSModel | order by count_", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VMs by OS Model", "timeContextFromParameter": "ResourcesTimeRange", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "filter": true } }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmOSModelLast" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | summarize count() by ImageModel, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VMs by Image Model", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmImageModelOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where TimeGenerated > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | summarize count() by ImageModel | order by count_", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VMs by Image Model", "timeContextFromParameter": "ResourcesTimeRange", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "filter": true } }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmImageModelLast" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSModel = iif(isnotempty(OSName_s), strcat(OSName_s, \"_\", OSVersion_s), 'NotAvailable/NotRunning') | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | extend PowerState = iif(isnotempty(PowerState_s), iif(PowerState_s has 'PowerState', tostring(split(PowerState_s, '/')[1]), PowerState_s), 'Unsupported') | where ('{vmRegion:label}' == 'All' or Location_s in ({vmRegion:value})) and ('{vmSize:label}' == 'All' or VMSize_s in ({vmSize:value})) and ('{vmOsType:label}' == 'All' or OSType_s in ({vmOsType:value})) and ('{vmOsModel:label}' == 'All' or OSModel in ({vmOsModel:value})) and ('{vmImageModel:label}' == 'All' or ImageModel in ({vmImageModel:value})) and ('{vmPowerState:label}' == 'All' or PowerState in ({vmPowerState:value})) | extend DiskType = iif(UsesManagedDisks_s == 'true', 'Managed', 'Unmanaged') | summarize count() by DiskType, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VMs by Disk Model", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmManagedDisksOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualMachines" }, "name": "vmGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationNICsV1_CL | distinct InstanceId_s, SubscriptionGuid_g, StatusDate_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "NICs by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NetworkInterfaces" }, "name": "nicsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationNICsV1_CL | distinct InstanceId_s, SubscriptionGuid_g, Location_s, StatusDate_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "NICs by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NetworkInterfaces" }, "name": "nicsLocationOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationNICsV1_CL | distinct InstanceId_s, SubscriptionGuid_g, StatusDate_s, EnableAcceleratedNetworking_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | extend AcceleratedNetworking = iif(tobool(EnableAcceleratedNetworking_s), 'Enabled', 'Disabled') | summarize count() by AcceleratedNetworking, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "NICs by Accelerated Networking Enabled", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NetworkInterfaces" }, "name": "nicsAccelNetOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationNICsV1_CL | distinct InstanceId_s, SubscriptionGuid_g, StatusDate_s, PrivateIPAllocationMethod_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by PrivateIPAllocationMethod_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "NICs by Allocation Method", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NetworkInterfaces" }, "name": "nicsAllocMethodOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationNICsV1_CL | where isnotempty(PublicIPId_s) | distinct InstanceId_s, SubscriptionGuid_g, StatusDate_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "NICs with Public IP by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NetworkInterfaces" }, "name": "nicsPublicIPOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationNICsV1_CL | where isempty(OwnerPEId_s) and isempty(OwnerVMId_s) | distinct InstanceId_s, SubscriptionGuid_g, StatusDate_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Unattached NICs by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NetworkInterfaces" }, "name": "unusedNICsOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NetworkInterfaces" }, "name": "nicGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "0d1dc8de-e6d4-4a49-835b-4c7695a17252", "version": "KqlParameterItem/1.0", "name": "diskSKU", "label": "SKU", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | union isfuzzy=true ( AzureOptimizationVhdDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) ) | extend SKU = tolower(iif(isnotempty(SKU_s), SKU_s, 'Unsupported')) | distinct SKU | order by SKU asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "id": "c2b60b5b-6c48-4b29-9f7d-f586ba5f8dce", "version": "KqlParameterItem/1.0", "name": "diskType", "label": "Disk Type", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | union isfuzzy=true ( AzureOptimizationVhdDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) ) | extend DiskType = iif(isnotempty(DiskType_s), DiskType_s, 'NotAvailable') | distinct DiskType | order by DiskType asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" } ], "style": "pills", "doNotRunWhenHidden": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "Disks" }, "name": "diskParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | union isfuzzy=true ( AzureOptimizationVhdDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) ) | extend DiskType = iif(isnotempty(DiskType_s), DiskType_s, 'NotAvailable') | extend SKU = tolower(iif(isnotempty(SKU_s), SKU_s, 'Unsupported')) | where ('{diskType:label}' == 'All' or DiskType in ({diskType:value})) and ('{diskSKU:label}' == 'All' or SKU in ({diskSKU:value})) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Disks by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "Disks" }, "name": "disksOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | union isfuzzy=true ( AzureOptimizationVhdDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) ) | extend DiskType = iif(isnotempty(DiskType_s), DiskType_s, 'NotAvailable') | extend SKU = tolower(iif(isnotempty(SKU_s), SKU_s, 'Unsupported')) | where ('{diskType:label}' == 'All' or DiskType in ({diskType:value})) and ('{diskSKU:label}' == 'All' or SKU in ({diskSKU:value})) | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Disks by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "Disks" }, "name": "disksLocationsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | union isfuzzy=true ( AzureOptimizationVhdDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) ) | extend DiskType = iif(isnotempty(DiskType_s), DiskType_s, 'NotAvailable') | extend SKU = tolower(iif(isnotempty(SKU_s), SKU_s, 'Unsupported')) | where ('{diskType:label}' == 'All' or DiskType in ({diskType:value})) and ('{diskSKU:label}' == 'All' or SKU in ({diskSKU:value})) | summarize count() by SKU, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Disks by SKU", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "Disks" }, "name": "disksSkusOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | union isfuzzy=true ( AzureOptimizationVhdDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) ) | extend DiskType = iif(isnotempty(DiskType_s), DiskType_s, 'NotAvailable') | extend SKU = tolower(iif(isnotempty(SKU_s), SKU_s, 'Unsupported')) | where ('{diskType:label}' == 'All' or DiskType in ({diskType:value})) and ('{diskSKU:label}' == 'All' or SKU in ({diskSKU:value})) | summarize count() by DeploymentModel_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Disks by Model", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "Disks" }, "name": "disksModelOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | union isfuzzy=true ( AzureOptimizationVhdDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) ) | extend DiskType = iif(isnotempty(DiskType_s), DiskType_s, 'NotAvailable') | extend SKU = tolower(iif(isnotempty(SKU_s), SKU_s, 'Unsupported')) | where ('{diskType:label}' == 'All' or DiskType in ({diskType:value})) and ('{diskSKU:label}' == 'All' or SKU in ({diskSKU:value})) | summarize count() by DiskType, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Disks by Type", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "Disks" }, "name": "disksTypeOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | union isfuzzy=true ( AzureOptimizationVhdDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) ) | extend DiskType = iif(isnotempty(DiskType_s), DiskType_s, 'NotAvailable') | extend SKU = tolower(iif(isnotempty(SKU_s), SKU_s, 'Unsupported')) | where ('{diskType:label}' == 'All' or DiskType in ({diskType:value})) and ('{diskSKU:label}' == 'All' or SKU in ({diskSKU:value})) | extend Caching = iif(isnotempty(Caching_s), Caching_s, 'NotAvailable') | summarize count() by Caching, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Disks by Caching", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "Disks" }, "name": "disksCachingOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) and isempty(OwnerVMId_s) | union isfuzzy=true ( AzureOptimizationVhdDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) and isempty(OwnerVMId_s) ) | extend DiskType = iif(isnotempty(DiskType_s), DiskType_s, 'NotAvailable') | extend SKU = tolower(iif(isnotempty(SKU_s), SKU_s, 'Unsupported')) | where ('{diskType:label}' == 'All' or DiskType in ({diskType:value})) and ('{diskSKU:label}' == 'All' or SKU in ({diskSKU:value})) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Unattached Disks by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "Disks" }, "name": "disksUnattachedOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | union isfuzzy=true ( AzureOptimizationVhdDisksV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) ) | extend DiskType = iif(isnotempty(DiskType_s), DiskType_s, 'NotAvailable') | extend SKU = tolower(iif(isnotempty(SKU_s), SKU_s, 'Unsupported')) | where ('{diskType:label}' == 'All' or DiskType in ({diskType:value})) and ('{diskSKU:label}' == 'All' or SKU in ({diskSKU:value})) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(todouble(DiskSizeGB_s)/1024) by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Total Disks Size (TB) by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "Disks" }, "name": "disksSizeOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "Disks" }, "name": "diskGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "edb124b8-f4f0-40ca-8139-b2b512f1b9a4", "version": "KqlParameterItem/1.0", "name": "vmssRegion", "label": "Region", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | distinct Location_s | order by Location_s asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "id": "832671d5-9add-4043-8321-5f944cafbe82", "version": "KqlParameterItem/1.0", "name": "vmssSize", "label": "Size", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | distinct VMSSSize_s | order by VMSSSize_s asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "id": "cf1f8d5f-345c-4354-9933-7d77d76b238a", "version": "KqlParameterItem/1.0", "name": "vmssOsType", "label": "OS Type", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | distinct OSType_s | order by OSType_s asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, { "id": "d88e19c0-bce5-4e92-a789-04a72884f142", "version": "KqlParameterItem/1.0", "name": "vmssImageModel", "label": "Image Model", "type": 2, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | distinct ImageModel | order by ImageModel asc", "value": [ "value::all" ], "typeSettings": { "additionalResourceOptions": [ "value::all" ], "selectAllValue": "*", "showDefault": false }, "timeContext": { "durationMs": 86400000 }, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" } ], "style": "pills", "doNotRunWhenHidden": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssParameters" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value})) and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Sets by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value})) and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(toint(Capacity_s)) by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Set VMs by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssVMsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value})) and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Sets by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssLocationsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value})) and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | summarize sum(toint(Capacity_s)) by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Set VMs by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssVMsLocationsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value})) and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(toint(CoresCount_s)*toint(Capacity_s)) by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Set Cores by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssCoresSubscriptionsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value})) and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | summarize sum(toint(CoresCount_s)*toint(Capacity_s)) by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Set Cores by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssCoresLocationsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value})) and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | summarize count() by tolower(VMSSSize_s), bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Sets by Size", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssSizesOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value})) and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | summarize sum(toint(Capacity_s)) by tolower(VMSSSize_s), bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Set VMs by Size", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssVMsSizesOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where TimeGenerated > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value}))and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | extend Size = tolower(VMSSSize_s) | summarize count() by Size | order by count_ ", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Sets by Size", "timeContextFromParameter": "ResourcesTimeRange", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "filter": true } }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssSizesLast" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where TimeGenerated > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value}))and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | extend Size = tolower(VMSSSize_s) | summarize VMs=sum(toint(Capacity_s)) by Size | order by VMs", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Set VMs by Size", "timeContextFromParameter": "ResourcesTimeRange", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "filter": true } }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssVMsSizesLast" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value}))and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value}))| summarize count() by OSType_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Sets by OS Type", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssOsTypeOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value})) and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | extend DiskType = iif(UsesManagedDisks_s == 'true', 'Managed', 'Unmanaged') | summarize count() by DiskType, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Sets by Disk Model", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssManagedDisksOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value}))and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | summarize count() by ImageModel, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Sets by Image Model", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssImageModelOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where TimeGenerated > ago(1d) and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend ImageModel = iif(isnotempty(ImageOffer_s), iif(ImageOffer_s startswith '/', strcat(\"Custom_\", ImageOffer_s), strcat(ImageOffer_s, \"_\", ImageSku_s)), 'NotAvailable') | where ('{vmssRegion:label}' == 'All' or Location_s in ({vmssRegion:value})) and ('{vmssSize:label}' == 'All' or VMSSSize_s in ({vmssSize:value})) and ('{vmssOsType:label}' == 'All' or OSType_s in ({vmssOsType:value}))and ('{vmssImageModel:label}' == 'All' or ImageModel in ({vmssImageModel:value})) | summarize count() by ImageModel | order by count_", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Scale Sets by Image Model", "timeContextFromParameter": "ResourcesTimeRange", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "filter": true } }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssImageModelLast" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSS" }, "name": "vmssGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | extend DiskCount = toint(Capacity_s) + toint(Capacity_s) * toint(DataDiskCount_s) | summarize sum(DiskCount) by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Disks by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSSDisks" }, "name": "vmssDisksOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | extend DiskCount = toint(Capacity_s) + toint(Capacity_s) * toint(DataDiskCount_s) | summarize sum(DiskCount) by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Disks by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSSDisks" }, "name": "vmssDisksLocationsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend SKU = tolower(iif(isnotempty(OSDiskSKU_s), OSDiskSKU_s, 'Unsupported')) | extend DiskCount = toint(Capacity_s) | summarize sum(DiskCount) by SKU, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "OS Disks by SKU", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSSDisks" }, "name": "vmssOsdisksSkusOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | extend OSDiskCount = toint(Capacity_s), DataDiskCount = toint(Capacity_s) * toint(DataDiskCount_s) | summarize OS=sum(OSDiskCount), Data=sum(DataDiskCount) by bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Disks by Type", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSSDisks" }, "name": "vmssDisksTypeOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVMSSV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(todouble(OSDiskSize_s)*todouble(Capacity_s)/1024) by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Total OS Disks Size (TB) by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSSDisks" }, "name": "vmssDisksSizeOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VMSSDisks" }, "name": "vmssDisksGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVNetsV1_CL | distinct InstanceId_s, SubscriptionGuid_g, StatusDate_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VNets by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualNetworks" }, "name": "vnetsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVNetsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Subnets by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualNetworks" }, "name": "subnetsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVNetsV1_CL | distinct InstanceId_s, SubscriptionGuid_g, Location_s, StatusDate_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "VNets by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualNetworks" }, "name": "vnetsLocationOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVNetsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Subnets by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualNetworks" }, "name": "subnetsLocationOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let VNetTotalIPs = AzureOptimizationVNetsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | summarize SubnetCount=count() by InstanceId_s, VNetPrefixes_s, StatusDate_s | extend AddressSpace = split(VNetPrefixes_s, ' ') | mvexpand AddressSpace | extend TotalIPs = pow(2, 32 - toint(split(tostring(AddressSpace), '/')[1])) | summarize VNetIPs=sum(TotalIPs) by InstanceId_s, SubnetCount, StatusDate_s | extend TotalVNetIPs = VNetIPs - (5*SubnetCount) | summarize by TotalVNetIPs, InstanceId_s, StatusDate_s; AzureOptimizationVNetsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | join kind=inner ( VNetTotalIPs ) on InstanceId_s, StatusDate_s | summarize UsedIPs=sum(toint(SubnetUsedIPs_s)) by InstanceId_s, VNetName_s, TotalVNetIPs, SubscriptionName, StatusDate_s | extend VNet = strcat(VNetName_s, '_', SubscriptionName), AvailableIPs = TotalVNetIPs - UsedIPs | summarize by VNet, AvailableIPs, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Available IPs by VNet", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualNetworks" }, "name": "vnetsAvailableIPsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let VNetTotalIPs = AzureOptimizationVNetsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | summarize SubnetCount=count() by InstanceId_s, VNetPrefixes_s, StatusDate_s | extend AddressSpace = split(VNetPrefixes_s, ' ') | mvexpand AddressSpace | extend TotalIPs = pow(2, 32 - toint(split(tostring(AddressSpace), '/')[1])) | summarize VNetIPs=sum(TotalIPs) by InstanceId_s, SubnetCount, StatusDate_s | extend TotalVNetIPs = VNetIPs - (5*SubnetCount) | summarize by TotalVNetIPs, InstanceId_s, StatusDate_s; AzureOptimizationVNetsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | join kind=inner ( VNetTotalIPs ) on InstanceId_s, StatusDate_s | summarize UsedIPs=sum(toint(SubnetUsedIPs_s)) by InstanceId_s, VNetName_s, TotalVNetIPs, SubscriptionName, StatusDate_s | extend VNet = strcat(VNetName_s, '_', SubscriptionName), IPUsagePercentage = todouble(UsedIPs) / todouble(TotalVNetIPs) * 100 | summarize by VNet, IPUsagePercentage, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "IP Usage % by VNet", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualNetworks" }, "name": "vnetsIPUsageOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVNetsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | extend Subnet = strcat(VNetName_s, '_', SubnetName_s, '_', SubscriptionName), IPUsagePercentage = todouble(SubnetUsedIPs_s) / todouble(SubnetTotalPrefixIPs_s) * 100 | where IPUsagePercentage > 70 | summarize by Subnet, IPUsagePercentage, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Subnets over 70% IP Usage", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualNetworks" }, "name": "subnetsIPUsageOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVNetsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | distinct InstanceId_s, VNetName_s, StatusDate_s, SubscriptionName, PeeringsCount_s | extend VNet = strcat(VNetName_s, '_', SubscriptionName), Peerings = toint(PeeringsCount_s) | summarize by VNet, Peerings, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Peerings by VNet", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualNetworks" }, "name": "vnetsPeeringsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVNetsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize TotalUsedIPs=sum(toint(SubnetUsedIPs_s)) by InstanceId_s, VNetName_s, SubscriptionName, StatusDate_s | where TotalUsedIPs == 0 | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Unused VNets by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualNetworks" }, "name": "unusedVNetsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationVNetsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | where SubnetUsedIPs_s == 0 | extend VNet = strcat(VNetName_s, '_', SubscriptionName) | summarize count() by VNet, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Unused subnets by VNet", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualNetworks" }, "name": "unusedSubnetsOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "VirtualNetworks" }, "name": "vnetGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationNSGsV1_CL | distinct InstanceId_s, SubscriptionGuid_g, StatusDate_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "NSGs by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NSGs" }, "name": "nsgsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationNSGsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "NSG Rules by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NSGs" }, "name": "nsgRulesOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationNSGsV1_CL | distinct InstanceId_s, SubscriptionGuid_g, Location_s, StatusDate_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "NSGs by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NSGs" }, "name": "nsgsLocationOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationNSGsV1_CL | where toint(SubnetCount_s) > 0 or toint(NicCount_s) > 0 | distinct InstanceId_s, SubscriptionGuid_g, StatusDate_s, SubnetCount_s, NicCount_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | extend UsageMode = iif(toint(SubnetCount_s) > 0, iif(toint(NicCount_s) > 0, 'Subnet+NIC', 'Subnet'), 'NIC') | summarize count() by UsageMode, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "NSGs by Usage Mode", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NSGs" }, "name": "nsgsUsageModeOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationNSGsV1_CL | where toint(SubnetCount_s) == 0 and toint(NicCount_s) == 0 | distinct InstanceId_s, SubscriptionGuid_g, StatusDate_s | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Unused NSGs by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NSGs" }, "name": "unusedNSGsOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "NSGs" }, "name": "nsgGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationLoadBalancersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Load Balancers by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "LoadBalancers" }, "name": "lbsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationLoadBalancersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Load Balancers by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "LoadBalancers" }, "name": "lbsLocationOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationLoadBalancersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | extend SKU = strcat(SkuName_s,'_',SkuTier_s) | summarize count() by SKU, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Load Balancers by SKU", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "LoadBalancers" }, "name": "lbsSKUsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationLoadBalancersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by LbType_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Load Balancers by Type", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "LoadBalancers" }, "name": "lbsTypesOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationLoadBalancersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | where (toint(BackendPoolsCount_s) == 0 or ((toint(BackendIPCount_s) == 0 or isempty(BackendIPCount_s)) and (toint(BackendAddressesCount_s) == 0 or isempty(BackendAddressesCount_s)))) and toint(InboundNatPoolsCount_s) == 0 | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Unused Load Balancers by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "LoadBalancers" }, "name": "unusedLBsOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "LoadBalancers" }, "name": "lbGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationPublicIPsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Public IPs by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "PublicIPs" }, "name": "publicIPsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationPublicIPsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Public IPs by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "PublicIPs" }, "name": "publicIPsLocationOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationPublicIPsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | extend SKU = iif(isnotempty(SkuName_s), strcat(SkuTier_s, '_', SkuName_s), 'classic') | summarize count() by SKU, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Public IPs by SKU", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "PublicIPs" }, "name": "publicIPsSKUsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationPublicIPsV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by AllocationMethod_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Public IPs by Allocation Method", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "PublicIPs" }, "name": "publicIPsAllocMethodOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationPublicIPsV1_CL | where isempty(AssociatedResourceId_s) | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Unattached Public IPs by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "PublicIPs" }, "name": "unusedPublicIPsOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "PublicIPs" }, "name": "pipGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppGatewaysV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Application Gateways by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "ApplicationGateways" }, "name": "appGWsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppGatewaysV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Application Gateways by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "ApplicationGateways" }, "name": "appGWsLocationOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppGatewaysV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SkuName_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Application Gateways by SKU", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "ApplicationGateways" }, "name": "appGWsSKUsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppGatewaysV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(toint(SkuCapacity_s)) by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Application Gateways Capacity by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "ApplicationGateways" }, "name": "appGWsCapacityOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppGatewaysV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(toint(SkuCapacity_s)) by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Application Gateways Capacity by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "ApplicationGateways" }, "name": "appGWsCapacityRegionOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppGatewaysV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(toint(SkuCapacity_s)) by SkuName_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Application Gateways Capacity by SKU", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "ApplicationGateways" }, "name": "appGWsCapacitySKUOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppGatewaysV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | where toint(BackendPoolsCount_s) == 0 or ((toint(BackendIPCount_s) == 0 or isempty(BackendIPCount_s)) and (toint(BackendAddressesCount_s) == 0 or isempty(BackendAddressesCount_s))) | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Unused Application Gateways by Subscription ", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "ApplicationGateways" }, "name": "unusedAppGWsOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "ApplicationGateways" }, "name": "appGWGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppServicePlansV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "App Service Plans by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "aspOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppServicePlansV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "App Service Plans by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "aspLocationOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppServicePlansV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SkuTier_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "App Service Plans by Tier", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "aspTiersOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppServicePlansV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SkuName_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "App Service Plans by SKU", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "aspSKUsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppServicePlansV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by Kind_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "App Service Plans by Kind", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "aspKindsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppServicePlansV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(toint(SkuCapacity_s)) by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "App Service Plan Capacity by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "aspCapacityOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppServicePlansV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(toint(SkuCapacity_s)) by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "App Service Plan Capacity by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "aspCapacityRegionOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppServicePlansV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(toint(SkuCapacity_s)) by SkuTier_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "App Service Plan Capacity by Tier", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "aspCapacityTierOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppServicePlansV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(toint(NumberOfSites_s)) by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "App Service Plan Sites by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "aspSitesOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppServicePlansV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize sum(toint(NumberOfSites_s)) by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "App Service Plan Sites by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "aspSitesRegionOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationAppServicePlansV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | where SkuTier_s != 'Free' and toint(NumberOfSites_s) == 0 | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "Unused Paid App Service Plans by Subscription ", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "unusedASPsOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "AppServicePlans" }, "name": "appServicePlansGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationSqlDbV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "SQL Databases by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "sqlOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationSqlDbV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "SQL Databases by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "sqlLocationOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationSqlDbV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by SkuTier_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "SQL Databases by Tier", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "sqlTiersOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationSqlDbV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by ServiceObjectiveName_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "SQL Databases by SKU", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "sqlSKUsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationSqlDbV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | where SkuTier_s in ('Basic','Standard','Premium') | summarize sum(toint(SkuCapacity_s)) by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "SQL Database (Single) DTUs by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "sqlDTUsOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationSqlDbV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | where SkuTier_s in ('Basic','Standard','Premium') | summarize sum(toint(SkuCapacity_s)) by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "SQL Database (Single) DTUs by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "sqlDTUsLocationOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationSqlDbV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | where SkuTier_s !in ('Basic','Standard','Premium','DataWarehouse') | summarize sum(toint(SkuCapacity_s)) by SubscriptionName, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "SQL Database vCores by Subscription", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "sqlCoresOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationSqlDbV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | where SkuTier_s !in ('Basic','Standard','Premium','DataWarehouse') | summarize sum(toint(SkuCapacity_s)) by Location_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "SQL Database vCores by Region", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "sqlCoresLocationOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationSqlDbV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | join kind=inner ( AzureOptimizationResourceContainersV1_CL | where todatetime(StatusDate_s) > datetime('{ResourcesTimeRange:startISO}') and SubscriptionGuid_g in ({SelectedSubscriptions:value}) | where ContainerType_s =~ 'microsoft.resources/subscriptions' | extend SubscriptionName = ContainerName_s | distinct SubscriptionGuid_g, SubscriptionName ) on SubscriptionGuid_g | summarize count() by StorageAccountType_s, bin(todatetime(StatusDate_s), 1d) | render timechart", "size": 1, "aggregation": 5, "showAnalytics": true, "title": "SQL Databases by Backup Storage Redundancy", "timeContextFromParameter": "ResourcesTimeRange", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "customWidth": "50", "name": "sqlRedundancyOverTime" } ] }, "conditionalVisibility": { "parameterName": "SelectedTab", "comparison": "isEqualTo", "value": "SQLDatabases" }, "name": "sqlGroup" } ], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" } ================================================ FILE: views/workbooks/savingsplans-usage.bicep ================================================ @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') param workbookDisplayName string = 'Savings Plans Usage' @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') param workbookType string = 'workbook' @description('The id of resource instance to which the workbook will be associated') param workbookSourceId string @description('The unique guid for this workbook instance') param workbookId string = 'a4a4bb1e-0a20-45b8-ab47-4bc38f9cc22e' param resourceTags object param resourceGroupLocation string = resourceGroup().location resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { name: workbookId location: resourceGroupLocation tags: resourceTags kind: 'shared' properties: { displayName: workbookDisplayName serializedData: string(loadJsonContent('savingsplans-usage.json')) version: '1.0' sourceId: workbookSourceId category: workbookType } dependsOn: [] } output workbookId string = workbookId_resource.id ================================================ FILE: views/workbooks/savingsplans-usage.json ================================================ { "version": "Notebook/1.0", "items": [ { "type": 9, "content": { "version": "KqlParameterItem/1.0", "parameters": [ { "id": "b58b4eb8-5821-44d2-bc7e-54054df27320", "version": "KqlParameterItem/1.0", "name": "LookbackPeriod", "label": "Lookback Period", "type": 4, "isRequired": true, "value": { "durationMs": 2592000000 }, "typeSettings": { "selectableValues": [ { "durationMs": 604800000 }, { "durationMs": 1209600000 }, { "durationMs": 2592000000 }, { "durationMs": 5184000000 }, { "durationMs": 7776000000 } ], "allowCustom": true }, "timeContext": { "durationMs": 86400000 } }, { "id": "5b2d78e9-7177-4d9b-86fa-2a9b12dd470a", "version": "KqlParameterItem/1.0", "name": "SavingsPlan", "label": "Savings Plan", "type": 2, "isRequired": true, "multiSelect": true, "quote": "'", "delimiter": ",", "query": "AzureOptimizationSavingsPlansUsageV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ProvisioningState_s == 'Succeeded'\r\n| distinct SavingsPlanId_g, DisplayName_s\r\n| order by DisplayName_s asc", "typeSettings": { "additionalResourceOptions": [ "value::all" ], "showDefault": false }, "timeContext": { "durationMs": 0 }, "timeContextFromParameter": "LookbackPeriod", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "value": [ "value::all" ] }, { "version": "KqlParameterItem/1.0", "name": "Aggregator", "label": "Aggregator Tag", "type": 1, "isRequired": true, "timeContext": { "durationMs": 2592000000 }, "id": "a3fb4877-28ef-43fc-8821-376df486fa2a", "value": null } ], "style": "above", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces" }, "name": "parameters" }, { "type": 1, "content": { "json": "Consumption data is updated once every 24 hours and is presented in the currency of your Azure consumption agreement.", "style": "info" }, "name": "text - 7" }, { "type": 1, "content": { "json": "If below tiles are reporting query errors, you must set up Pricesheet exports. See more details [here](https://aka.ms/AzureOptimizationEngine/commitmentssetup).", "style": "warning" }, "name": "text - 10" }, { "type": 11, "content": { "version": "LinkItem/1.0", "style": "tabs", "links": [ { "id": "93e7a6c7-cb1f-49ee-b135-468b9f528b04", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Savings Plans Usage Analysis", "subTarget": "savingsPlansAnalysis", "style": "link" }, { "id": "b9dd48c2-3dce-4977-af3e-44df1d8758b7", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Usage by Tag", "subTarget": "usageByTag", "style": "link" }, { "id": "96332944-d3f7-4b0b-ad56-ab25a9e91049", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Full Usage Report", "subTarget": "fullReport", "style": "link" }, { "id": "1f438af9-e6ff-470e-9b11-b1b5a701e51b", "cellValue": "selectedTab", "linkTarget": "parameter", "linkLabel": "Unused Savings Plans Analysis", "subTarget": "unusedSavingsPlans", "style": "link" } ] }, "name": "tabs" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet VMOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandUnitPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandUnitPrice;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s != 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| summarize OnDemandUnitPrice = max(todouble(UnitPrice_s)) by MeterID_g\r\n| distinct MeterID_g, OnDemandUnitPrice\r\n| union (VMOnDemandPriceSheet)\r\n| summarize OnDemandUnitPrice=min(OnDemandUnitPrice) by MeterID_g;\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and PricingModel_s == 'SavingsPlan'\r\n| extend savingsPlanId = tostring(split(benefitId_s,'/')[-1])\r\n| where savingsPlanId in ({SavingsPlan:value})\r\n| join kind=leftouter (OnDemandPriceSheet) on $left.MeterId_g == $right.MeterID_g\r\n| extend DiscountPercent = (1 - todouble(EffectivePrice_s)/OnDemandUnitPrice) * 100\r\n| summarize UsedSPsHourly=round(sum(todouble(CostInBillingCurrency_s)/24),2), DiscountPercent=round(avg(DiscountPercent),2) by Date_s, savingsPlanId\r\n| summarize UsedSPsHourly=round(avg(UsedSPsHourly),2), DiscountPercent=round(avg(DiscountPercent),2) by savingsPlanId\r\n| join kind=rightouter (\r\n AzureOptimizationSavingsPlansUsageV1_CL\r\n | where TimeGenerated > ago(1d)\r\n | where SavingsPlanId_g in~ ({SavingsPlan:value})\r\n | summarize arg_max(TimeGenerated, *) by SavingsPlanId_g\r\n | where ProvisioningState_s == 'Succeeded'\r\n | project savingsPlanId=SavingsPlanId_g, benefitName_s=DisplayName_s, CommitmentAmount_s, CommitmentGrain_s, CommitmentCurrencyCode_s, Term_s, Util7Days_s=round(todouble(Util7Days_s)), Util30Days_s=round(todouble(Util30Days_s))\r\n) on savingsPlanId\r\n| project savingsPlanId, benefitName_s, CommitmentAmount_s, UsedSPsHourly=iif(isempty(UsedSPsHourly),0.0,UsedSPsHourly), CommitmentCurrencyCode_s, CommitmentGrain_s, Term_s, Util7Days_s, Util30Days_s, DiscountPercent", "size": 0, "title": "Savings Plan Usage Details", "timeContextFromParameter": "LookbackPeriod", "exportFieldName": "savingsPlanId", "exportParameterName": "selectedSavingsPlan", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "savingsPlanId", "formatter": 5 }, { "columnMatch": "benefitName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "24ch" } }, { "columnMatch": "CommitmentAmount_s", "formatter": 0, "numberFormat": { "unit": 0, "options": { "style": "decimal", "minimumFractionDigits": 2, "maximumFractionDigits": 2 } } }, { "columnMatch": "UsedSPsHourly", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "15ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "minimumFractionDigits": 2, "maximumFractionDigits": 2 } } }, { "columnMatch": "CommitmentCurrencyCode_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "13ch" } }, { "columnMatch": "CommitmentGrain_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "10ch" } }, { "columnMatch": "Util7Days_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "14ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal" } } }, { "columnMatch": "Util30Days_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "15ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal" } } }, { "columnMatch": "DiscountPercent", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "13ch" }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 0 } } }, { "columnMatch": "benefitId_s", "formatter": 5 } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "savingsPlanId", "label": "Savings Plan ID" }, { "columnId": "benefitName_s", "label": "Savings Plan" }, { "columnId": "CommitmentAmount_s", "label": "Commited" }, { "columnId": "UsedSPsHourly", "label": "Used (Avg.)" }, { "columnId": "CommitmentCurrencyCode_s", "label": "Currency" }, { "columnId": "CommitmentGrain_s", "label": "Grain" }, { "columnId": "Term_s", "label": "Term" }, { "columnId": "Util7Days_s", "label": "Used (7d)" }, { "columnId": "Util30Days_s", "label": "Used (30d)" }, { "columnId": "DiscountPercent", "label": "Discount" } ] }, "sortBy": [] }, "customWidth": "55", "name": "spUsageDetailsV2" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and PricingModel_s == 'SavingsPlan'\r\n| extend savingsPlanId = tostring(split(benefitId_s,'/')[-1])\r\n| where savingsPlanId == '{selectedSavingsPlan:value}'\r\n| extend VMSize = tostring(parse_json(AdditionalInfo_s).ServiceType)\r\n| extend ConsumedSize = iif(isnotempty(VMSize), VMSize, strcat(MeterSubCategory_s, ' ', MeterName_s))\r\n| summarize round(sum(todouble(CostInBillingCurrency_s)/24),1) by todatetime(Date_s), ConsumedSize", "size": 0, "aggregation": 3, "title": "Avg. Savings Plan Hourly Usage by SKU (click on a line in the table at the left)", "timeContextFromParameter": "LookbackPeriod", "exportFieldName": "ReservationId_g", "exportParameterName": "selectedReservation", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 } ], "rowLimit": 1000 }, "chartSettings": { "group": "ConsumedSize", "createOtherGroup": null, "customThresholdLine": "{selectedQuantity}", "customThresholdLineStyle": 1 } }, "customWidth": "45", "name": "spUsageDailyAverageBySize" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let absoluteSPUsage = toscalar(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}')\r\n and TimeGenerated < todatetime('{LookbackPeriod:endISO}')\r\n and ChargeType_s == 'Usage'\r\n and PricingModel_s == 'SavingsPlan'\r\n| extend savingsPlanId = tostring(split(benefitId_s,'/')[-1])\r\n| where savingsPlanId == '{selectedSavingsPlan:value}'\r\n| summarize sum(todouble(CostInBillingCurrency_s)));\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and PricingModel_s == 'SavingsPlan'\r\n| extend savingsPlanId = tostring(split(benefitId_s,'/')[-1])\r\n| where savingsPlanId == '{selectedSavingsPlan:value}'\r\n| summarize UsedSPPercentage=round(sum(todouble(CostInBillingCurrency_s)) / absoluteSPUsage * 100, 2) by ResourceId, SubscriptionId, ResourceLocation_s\r\n| join kind=leftouter ( \r\n AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > ago(1d) and ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project Subscription=ContainerName_s, SubscriptionId=SubscriptionGuid_g\r\n) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| project-reorder ResourceId, ResourceLocation_s, Subscription, UsedSPPercentage\r\n| order by ResourceId asc, UsedSPPercentage", "size": 0, "showAnalytics": true, "title": "Savings Plan Usage by Resource (click on a line in the table above)", "timeContextFromParameter": "LookbackPeriod", "exportFieldName": "benefitId_s", "exportParameterName": "selectedSavingsPlan", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "ResourceId", "label": "Resource ID" }, { "columnId": "ResourceLocation_s", "label": "Location" }, { "columnId": "Subscription", "label": "Subscription" }, { "columnId": "UsedSPPercentage", "label": "Savings Plan Usage %" } ] } }, "customWidth": "50", "name": "spUsageDailyAverageByResource" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let absoluteSPUsage = toscalar(AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}')\r\n and TimeGenerated < todatetime('{LookbackPeriod:endISO}')\r\n and ChargeType_s == 'Usage'\r\n and PricingModel_s == 'SavingsPlan'\r\n| extend savingsPlanId = tostring(split(benefitId_s,'/')[-1])\r\n| where savingsPlanId == '{selectedSavingsPlan:value}'\r\n| summarize sum(todouble(CostInBillingCurrency_s)));\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and PricingModel_s == 'SavingsPlan'\r\n| extend savingsPlanId = tostring(split(benefitId_s,'/')[-1])\r\n| where savingsPlanId == '{selectedSavingsPlan:value}'\r\n| extend Tags_s = iif(Tags_s startswith \"{\", Tags_s, strcat(\"{\", Tags_s, \"}\"))\r\n| extend AggregatorTag = tostring(parse_json(Tags_s)['{Aggregator}'])\r\n| summarize UsedSPPercentage=round(sum(todouble(CostInBillingCurrency_s)) / absoluteSPUsage * 100, 2) by AggregatorTag, ResourceLocation_s, SubscriptionId\r\n| join kind=leftouter ( \r\n AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > ago(1d) and ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project Subscription=ContainerName_s, SubscriptionId=SubscriptionGuid_g\r\n) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| project-reorder AggregatorTag, ResourceLocation_s, Subscription, UsedSPPercentage\r\n| order by AggregatorTag asc, UsedSPPercentage", "size": 0, "showAnalytics": true, "title": "Savings Plan Usage by Tag (click on a line in the table above)", "timeContextFromParameter": "LookbackPeriod", "exportFieldName": "benefitId_s", "exportParameterName": "selectedSavingsPlan", "showExportToExcel": true, "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "ReservationId_g", "formatter": 5 } ], "rowLimit": 5000, "labelSettings": [ { "columnId": "AggregatorTag", "label": "Aggregator Tag" }, { "columnId": "ResourceLocation_s", "label": "Location" }, { "columnId": "Subscription", "label": "Subscription" }, { "columnId": "UsedSPPercentage", "label": "Savings Plan Usage %" } ] } }, "customWidth": "50", "name": "spUsageDailyAverageByInstance" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "savingsPlansAnalysis" }, "name": "savingsPlanAnalysisGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let UsageBySavingsPlan = AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and PricingModel_s == 'SavingsPlan'\r\n| extend savingsPlanId = tostring(split(benefitId_s,'/')[-1])\r\n| where savingsPlanId in ({SavingsPlan:value})\r\n| summarize TotalSPUsage=sum(todouble(CostInBillingCurrency_s)) by benefitId_s;\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and PricingModel_s == 'SavingsPlan'\r\n| extend savingsPlanId = tostring(split(benefitId_s,'/')[-1])\r\n| where savingsPlanId in ({SavingsPlan:value})\r\n| extend Tags_s = iif(Tags_s startswith \"{\", Tags_s, strcat(\"{\", Tags_s, \"}\"))\r\n| extend AggregatorTag = tostring(parse_json(Tags_s)['{Aggregator}'])\r\n| join kind=inner (UsageBySavingsPlan) on benefitId_s\r\n| summarize UsedSP=round(sum(todouble(CostInBillingCurrency_s)), 2) by benefitName_s, AggregatorTag, SubscriptionId, TotalSPUsage\r\n| join kind=leftouter ( \r\n AzureOptimizationResourceContainersV1_CL \r\n | where TimeGenerated > ago(1d) and ContainerType_s == 'microsoft.resources/subscriptions'\r\n | project Subscription=ContainerName_s, SubscriptionId=SubscriptionGuid_g\r\n) on SubscriptionId\r\n| project-away SubscriptionId*\r\n| extend UsedSPPercentage = UsedSP / TotalSPUsage * 100\r\n| project benefitName_s, AggregatorTag, Subscription, UsedSPPercentage\r\n| order by AggregatorTag asc, UsedSPPercentage", "size": 2, "exportFieldName": "ReservationName_s", "exportParameterName": "selectedReservationName", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "UsedSPPercentage", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 1 } } }, { "columnMatch": "ReservationId_g", "formatter": 5 }, { "columnMatch": "TotalUsedRIs", "formatter": 5 }, { "columnMatch": "DaysSeen", "formatter": 5 }, { "columnMatch": "ReservationId_g1", "formatter": 5 }, { "columnMatch": "TotalReservedQuantity_s", "formatter": 5 }, { "columnMatch": "UsedRIPercentage", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 1 } } } ], "rowLimit": 10000, "labelSettings": [ { "columnId": "benefitName_s", "label": "Savings Plan" }, { "columnId": "AggregatorTag", "label": "Aggregator Tag" }, { "columnId": "UsedSPPercentage", "label": "Savings Plan Usage" } ] } }, "name": "spUsageByTagQuery" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "usageByTag" }, "name": "usageByTagGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "let LinuxOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption' and MeterSubCategory_s !endswith \"Windows\"\r\n| extend MeterSubCategory_s = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Linux'))\r\n| summarize LinuxUnitPrice=max(todouble(UnitPrice_s)) by LinuxMeterId=MeterID_g, MeterName_s, MeterSubCategory_s, MeterRegion_s, LinuxUnitOfMeasure=UnitOfMeasure_s;\r\nlet VMOnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s == 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| extend NonWindowsMeterSubcategory = substring(MeterSubCategory_s, 0, indexof(MeterSubCategory_s, ' Windows'))\r\n| extend WindowsMeterSubCategory = MeterSubCategory_s\r\n| extend NonWindowsMeterSubcategory = substring(NonWindowsMeterSubcategory, 0, indexof(NonWindowsMeterSubcategory, ' Linux'))\r\n| summarize UnitPrice_s=max(todouble(UnitPrice_s)) by MeterID_g, MeterName_s, NonWindowsMeterSubcategory, WindowsMeterSubCategory, MeterRegion_s, UnitOfMeasure_s\r\n| join kind=leftouter ( LinuxOnDemandPriceSheet ) on MeterName_s, MeterRegion_s, $left.NonWindowsMeterSubcategory == $right.MeterSubCategory_s\r\n| extend PricesheetPrice = iif(isnotempty(LinuxUnitPrice), LinuxUnitPrice, UnitPrice_s)\r\n| extend PricesheetUnitOfMeasure = iif(isnotempty(LinuxUnitOfMeasure), LinuxUnitOfMeasure, UnitOfMeasure_s)\r\n| extend UnitHrs = toint(substring(PricesheetUnitOfMeasure, 0, indexof(PricesheetUnitOfMeasure, 'Hour')-1))\r\n| extend OnDemandUnitPrice = PricesheetPrice/UnitHrs\r\n| distinct MeterID_g, OnDemandUnitPrice;\r\nlet OnDemandPriceSheet = AzureOptimizationPricesheetV1_CL\r\n| where TimeGenerated > ago(14d)\r\n| where MeterCategory_s != 'Virtual Machines' and PriceType_s == 'Consumption'\r\n| summarize OnDemandUnitPrice = max(todouble(UnitPrice_s)) by MeterID_g\r\n| distinct MeterID_g, OnDemandUnitPrice\r\n| union (VMOnDemandPriceSheet)\r\n| summarize OnDemandUnitPrice=min(OnDemandUnitPrice) by MeterID_g;\r\nAzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'Usage' and PricingModel_s == 'SavingsPlan'\r\n| extend savingsPlanId = tostring(split(benefitId_s,'/')[-1])\r\n| where savingsPlanId in ({SavingsPlan:value})\r\n| join kind=leftouter (OnDemandPriceSheet) on $left.MeterId_g == $right.MeterID_g\r\n| extend DiscountPercent = (1 - todouble(EffectivePrice_s)/OnDemandUnitPrice) * 100\r\n| summarize UsedSPsHourly=round(sum(todouble(CostInBillingCurrency_s)/24),2), DiscountPercent=round(avg(DiscountPercent),2) by Date_s, savingsPlanId\r\n| summarize UsedSPsHourly=round(avg(UsedSPsHourly),2), DiscountPercent=round(avg(DiscountPercent),2) by savingsPlanId\r\n| join kind=rightouter (\r\n AzureOptimizationSavingsPlansUsageV1_CL\r\n | where TimeGenerated > ago(1d)\r\n | where SavingsPlanId_g in~ ({SavingsPlan:value})\r\n | summarize arg_max(TimeGenerated, *) by SavingsPlanId_g\r\n | where ProvisioningState_s == 'Succeeded'\r\n | project savingsPlanId=SavingsPlanId_g, benefitName_s=DisplayName_s, CommitmentAmount_s, CommitmentGrain_s, CommitmentCurrencyCode_s, Term_s, AppliedScopeType_s, Util7Days_s=round(todouble(Util7Days_s)), Util30Days_s=round(todouble(Util30Days_s)), ExpiryDate_t\r\n) on savingsPlanId\r\n| extend HoursUntilExpiry=(ExpiryDate_t-now())/1h\r\n| extend AmountRemainingToConsume = round(todouble(CommitmentAmount_s) * HoursUntilExpiry)\r\n| project savingsPlanId, benefitName_s, CommitmentAmount_s, UsedSPsHourly=iif(isempty(UsedSPsHourly),0.0,UsedSPsHourly), CommitmentCurrencyCode_s, CommitmentGrain_s, Term_s, AppliedScopeType_s, ExpiryDate_t, AmountRemainingToConsume, Util7Days_s, Util30Days_s, DiscountPercent, SavingsMargin=round(todouble(Util7Days_s))-100.0+DiscountPercent ", "size": 2, "exportedParameters": [ { "fieldName": "ReservationId_g", "parameterName": "selectedReservation" }, { "fieldName": "ReservationName_s", "parameterName": "selectedReservationName", "parameterType": 1 } ], "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "savingsPlanId", "formatter": 5 }, { "columnMatch": "CommitmentAmount_s", "formatter": 0, "numberFormat": { "unit": 0, "options": { "style": "decimal", "minimumFractionDigits": 2, "maximumFractionDigits": 2 } } }, { "columnMatch": "UsedSPsHourly", "formatter": 0, "numberFormat": { "unit": 0, "options": { "style": "decimal", "minimumFractionDigits": 2, "maximumFractionDigits": 2 } } }, { "columnMatch": "ExpiryDate_t", "formatter": 6, "formatOptions": { "customColumnWidthSetting": "15ch" }, "dateFormat": { "showUtcTime": true, "formatName": "shortDatePattern" } }, { "columnMatch": "AmountRemainingToConsume", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "20ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "minimumFractionDigits": 2, "maximumFractionDigits": 2 } } }, { "columnMatch": "Util7Days_s", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal" } } }, { "columnMatch": "Util30Days_s", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal" } } }, { "columnMatch": "DiscountPercent", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 1 } } }, { "columnMatch": "SavingsMargin", "formatter": 18, "formatOptions": { "thresholdsOptions": "colors", "thresholdsGrid": [ { "operator": "<", "thresholdValue": "5", "representation": "yellow" }, { "operator": "<", "thresholdValue": "0", "representation": "redBright", "text": "{0}{1}" }, { "operator": "Default", "thresholdValue": null, "representation": "green", "text": "{0}{1}" } ] }, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 1 } } }, { "columnMatch": "UsedSPPercentage", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 1 } } }, { "columnMatch": "ReservationId_g", "formatter": 5 }, { "columnMatch": "TotalUsedRIs", "formatter": 5 }, { "columnMatch": "DaysSeen", "formatter": 5 }, { "columnMatch": "ReservationId_g1", "formatter": 5 }, { "columnMatch": "TotalReservedQuantity_s", "formatter": 5 }, { "columnMatch": "UsedRIPercentage", "formatter": 0, "numberFormat": { "unit": 1, "options": { "style": "decimal", "maximumFractionDigits": 1 } } } ], "rowLimit": 10000, "labelSettings": [ { "columnId": "savingsPlanId", "label": "Savings Plan ID" }, { "columnId": "benefitName_s", "label": "Savings Plan" }, { "columnId": "CommitmentAmount_s", "label": "Committed" }, { "columnId": "UsedSPsHourly", "label": "Used (Avg.)" }, { "columnId": "CommitmentCurrencyCode_s", "label": "Currency" }, { "columnId": "CommitmentGrain_s", "label": "Grain" }, { "columnId": "Term_s", "label": "Term" }, { "columnId": "AppliedScopeType_s", "label": "Scope" }, { "columnId": "ExpiryDate_t", "label": "Expires On" }, { "columnId": "AmountRemainingToConsume", "label": "Remain. Commit." }, { "columnId": "Util7Days_s", "label": "Used (7d)" }, { "columnId": "Util30Days_s", "label": "Used (30d)" }, { "columnId": "DiscountPercent", "label": "Discount" }, { "columnId": "SavingsMargin", "label": "Savings" } ] } }, "name": "spUsageFullReport" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "fullReport" }, "name": "fullReportGroup" }, { "type": 12, "content": { "version": "NotebookGroup/1.0", "groupType": "editable", "items": [ { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'UnusedSavingsPlan'\r\n| extend savingsPlanId = tostring(split(benefitId_s,'/')[-1])\r\n| where savingsPlanId in ({SavingsPlan:value})\r\n| where todouble(CostInBillingCurrency_s) > 0\r\n| extend UnusedCost = todouble(CostInBillingCurrency_s)\r\n| project todatetime(Date_s), benefitName_s, UnusedCost", "size": 1, "title": "Cost of Unused Savings Plans over time", "timeContextFromParameter": "LookbackPeriod", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "visualization": "barchart" }, "customWidth": "50", "name": "unusedSavingsPlansOverTime" }, { "type": 3, "content": { "version": "KqlItem/1.0", "query": "AzureOptimizationConsumptionV1_CL\r\n| where TimeGenerated > todatetime('{LookbackPeriod:startISO}') and TimeGenerated < todatetime('{LookbackPeriod:endISO}') and ChargeType_s == 'UnusedSavingsPlan'\r\n| extend savingsPlanId = tostring(split(benefitId_s,'/')[-1])\r\n| where savingsPlanId in ({SavingsPlan:value})\r\n| summarize TotalUnusedCost = sum(todouble(CostInBillingCurrency_s)) by benefitName_s, savingsPlanId\r\n| where round(TotalUnusedCost) > 0\r\n| join kind=inner (\r\n AzureOptimizationSavingsPlansUsageV1_CL\r\n | where TimeGenerated > ago(1d)\r\n | where SavingsPlanId_g in~ ({SavingsPlan:value})\r\n | summarize arg_max(TimeGenerated, *) by SavingsPlanId_g\r\n | where ProvisioningState_s == 'Succeeded'\r\n | extend UnusedQuantity = todouble(CommitmentAmount_s) - (todouble(CommitmentAmount_s) * todouble(Util7Days_s) / 100)\r\n | extend UnusedQuantity30d = todouble(CommitmentAmount_s) - (todouble(CommitmentAmount_s) * todouble(Util30Days_s) / 100)\r\n | project savingsPlanId=SavingsPlanId_g, CommitmentAmount_s, CommitmentGrain_s, CommitmentCurrencyCode_s, Term_s, Util7Days_s=round(todouble(Util7Days_s)), UnusedQuantity, Util30Days_s=round(todouble(Util30Days_s)), UnusedQuantity30d, ExpiryDate_t\r\n) on savingsPlanId\r\n| project-reorder benefitName_s, TotalUnusedCost\r\n| order by TotalUnusedCost\r\n", "size": 2, "title": "Unused Savings Plans Details", "timeContextFromParameter": "LookbackPeriod", "queryType": 0, "resourceType": "microsoft.operationalinsights/workspaces", "gridSettings": { "formatters": [ { "columnMatch": "benefitName_s", "formatter": 0, "formatOptions": { "customColumnWidthSetting": "40ch" } }, { "columnMatch": "TotalUnusedCost", "formatter": 1, "formatOptions": { "customColumnWidthSetting": "22ch" }, "numberFormat": { "unit": 0, "options": { "style": "decimal", "useGrouping": true, "maximumFractionDigits": 0 } } } ], "labelSettings": [ { "columnId": "benefitName_s", "label": "Savings Plan" }, { "columnId": "TotalUnusedCost", "label": "Total Unused Cost" } ] } }, "name": "unusedSavingsPlansDetails" } ] }, "conditionalVisibility": { "parameterName": "selectedTab", "comparison": "isEqualTo", "value": "unusedSavingsPlans" }, "name": "unusedSavingsPlansGroup" } ], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" }