Skip to content

Commit c615dea

Browse files
adileiadileiclaude
authored
Add PAYG billing policy management sample to infrastructure (#512)
Adds scripts, Power Automate solution, and unpacked source for automating Power Platform pay-as-you-go billing policy management at scale. Co-authored-by: adilei <adileibowiz@microsoft.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9a3cd8f commit c615dea

25 files changed

Lines changed: 1738 additions & 0 deletions

infrastructure/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ Deployment templates and infrastructure configurations for Copilot Studio.
1313

1414
| Folder | Description |
1515
|--------|-------------|
16+
| [manage-paygo/](./manage-paygo/) | PAYG billing policy management — bulk assignment, cost monitoring, and auto-unlinking |
1617
| [vnet-support/](./vnet-support/) | VNet integration ARM templates |
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
---
2+
title: PAYG Billing Policy Management
3+
parent: Infrastructure
4+
nav_order: 2
5+
---
6+
# PAYG Billing Policy Management for Power Platform
7+
8+
New
9+
{: .label .label-green }
10+
11+
Companion artifacts for the blog post **"Herding Clouds: Taming Pay-As-You-Go Billing Policies in Power Platform at Scale"**.
12+
13+
This folder contains everything you need to follow along end-to-end: a bulk-assignment script, an Azure Automation runbook, a test harness, sample webhook data, and the importable Power Automate solution.
14+
15+
---
16+
17+
## Folder Structure
18+
19+
```
20+
manage-paygo/
21+
├── scripts/
22+
│ ├── bulk-assign-billing-policy.ps1 # Bulk-link environments to billing policies via CSV
23+
│ ├── UnlinkBillingPolicyRunbook.ps1 # Azure Automation runbook (triggered by budget alert)
24+
│ └── TestRunbook.ps1 # Manual runbook trigger for end-to-end testing
25+
├── samples/
26+
│ └── Webhooktestdata.json # Simulated Azure Monitor budget alert payload
27+
└── solution/
28+
└── BillingPolicyManagement_1_0_0_3.zip # Importable Power Automate solution
29+
```
30+
31+
---
32+
33+
## Prerequisites
34+
35+
| Requirement | Details |
36+
|---|---|
37+
| **Azure CLI** | Installed and authenticated (`az login`) |
38+
| **PowerShell 7+** | Required to run the scripts |
39+
| **Azure Automation Account** | With a System-Assigned Managed Identity |
40+
| **Power Platform role** | Power Platform Admin, Global Admin, or Dynamics 365 Admin |
41+
| **Power Automate environment** | To import the solution into |
42+
43+
{: .note }
44+
> **Permissions:** The Automation Account's Managed Identity only needs permission to call the Power Automate HTTP trigger (token audience: `https://service.flow.microsoft.com/`). The Power Platform Admin permissions are held by the connection credentials configured inside the Power Automate solution — those connections must be authenticated by an account with Power Platform Admin rights.
45+
46+
---
47+
48+
## Scripts
49+
50+
### `bulk-assign-billing-policy.ps1`
51+
52+
Bulk-assigns Power Platform environments to billing policies from a CSV file. Runs in six stages: verifies Azure CLI login, validates the CSV, resolves billing policies by name, resolves environment IDs by display name (with tenant-wide pagination for large tenants), links each environment to its policy, and writes results back to the CSV.
53+
54+
**CSV format expected:**
55+
56+
```csv
57+
EnvironmentName,EnvironmentID,BillingPolicyName,Status
58+
Sales-Production,,ProductionBillingPolicy,
59+
Marketing-Sandbox,a1b2c3d4-e5f6-...,DevBillingPolicy,
60+
HR-Production,,ProductionBillingPolicy,
61+
```
62+
63+
- `EnvironmentID` is optional — the script resolves it from `EnvironmentName` if blank.
64+
- `Status` is populated by the script after each run (`Succeeded` or `Failed: <reason>`).
65+
- Only **Production** and **Sandbox** environments are eligible. Developer, Trial, and Default types are skipped with a clear status message.
66+
67+
**Usage:**
68+
69+
```powershell
70+
# Preview what would happen — always run this first
71+
.\bulk-assign-billing-policy.ps1 -InputFile ".\environments.csv" -DryRun
72+
73+
# Execute for real
74+
.\bulk-assign-billing-policy.ps1 -InputFile ".\environments.csv"
75+
```
76+
77+
---
78+
79+
### `UnlinkBillingPolicyRunbook.ps1`
80+
81+
An Azure Automation runbook that acts as the bridge between an Azure Budget alert and the Power Automate unlinking flow. Intended to be hosted in an Azure Automation Account and triggered via webhook from an Azure Action Group.
82+
83+
**What it does:**
84+
1. Parses the incoming Azure Monitor Common Alert Schema webhook payload
85+
2. Extracts the subscription ID and resource group from the `alertId` path
86+
3. Authenticates using the Automation Account's Managed Identity (`Connect-AzAccount -Identity`)
87+
4. Acquires a bearer token for the Power Automate service endpoint
88+
5. POSTs the subscription and resource group context to the Power Automate HTTP trigger flow
89+
90+
**Before deploying, update the hardcoded values:**
91+
92+
| Line | Variable | What to replace with |
93+
|---|---|---|
94+
| 27 | `$Url` | The HTTP trigger URL from your imported Power Automate solution (found in the flow's trigger details) |
95+
96+
```powershell
97+
# Line 27 — replace with your own flow trigger URL
98+
$Url = "https://<your-environment>.environment.api.powerplatform.com/powerautomate/..."
99+
```
100+
101+
---
102+
103+
### `TestRunbook.ps1`
104+
105+
Manually triggers the `UnlinkBillingPolicies` runbook with a local test payload file — so you can validate the entire chain end-to-end without waiting for an actual budget breach.
106+
107+
**Before running, update the hardcoded values at the top of the file:**
108+
109+
| Variable | Description |
110+
|---|---|
111+
| `$SubscriptionId` | Your Azure subscription ID |
112+
| `$AutomationAccountName` | Your Automation Account name |
113+
| `$ResourceGroupName` | Resource group hosting the Automation Account |
114+
| `$RunbookName` | Name of the runbook as deployed in the Automation Account |
115+
116+
**Usage:**
117+
118+
```powershell
119+
.\TestRunbook.ps1
120+
```
121+
122+
This triggers the runbook with the payload from `../samples/Webhooktestdata.json`. The runbook parses it, calls Power Automate, and the flow unlinks all environments from the matching billing policy — full end-to-end, no real spend required.
123+
124+
---
125+
126+
## Samples
127+
128+
### `Webhooktestdata.json`
129+
130+
A realistic Azure Monitor Common Alert Schema payload simulating a budget threshold breach. Used by `TestRunbook.ps1` to trigger the runbook manually.
131+
132+
**Simulated scenario:**
133+
- Budget name: `prodbilling`
134+
- Monthly budget: `$2.00`
135+
- Alert threshold: `$1.60` (80%)
136+
- Simulated spend: `$4.00` (200%)
137+
138+
The `alertId` field in this payload encodes a real subscription ID and resource group (`Azurevnetforpowerplatform`). The runbook extracts these to identify which billing policy to act on. **Update this file** if your test environment uses a different subscription or resource group.
139+
140+
---
141+
142+
## Solution
143+
144+
### `BillingPolicyManagement_1_0_0_3.zip`
145+
146+
| Property | Value |
147+
|---|---|
148+
| **Unique Name** | BillingPolicyManagement |
149+
| **Display Name** | Billing Policy Management Demo |
150+
| **Version** | 1.0.0.3 |
151+
| **Publisher** | MCS CAT (`mcscat`) |
152+
| **Type** | Unmanaged |
153+
154+
An importable Power Automate solution containing **2 custom connectors** and **3 cloud flows**:
155+
156+
#### Custom Connectors
157+
158+
| Connector | Host | API Version | Operations |
159+
|---|---|---|---|
160+
| **Azure Usage** (`mcscat_azureusage`) | `management.azure.com` | `2025-03-01` | `Query_Usage` — POST to `/{scope}/providers/Microsoft.CostManagement/query` to retrieve cost/usage data |
161+
| **Power Platform Billing Policy** (`mcscat_powerplatformbilliinpolicy`) | `api.powerplatform.com` | `2022-03-01-preview` | `ListBillingPolicies`, `GetBillingPolicy`. Auth: OAuth 2.0 (`https://api.powerplatform.com/.default`) |
162+
163+
#### Cloud Flows
164+
165+
| Flow | Trigger | Description |
166+
|---|---|---|
167+
| **Poll Cost and Unlink Environments** | Recurrence (every 4 hours) | Queries Azure Cost Management for actual costs. If pre-tax cost exceeds **$65**, invokes the unlinking child flow. |
168+
| **UnlinkAllEnvironmentsFromResourceGroup** | HTTP POST (child flow) | Lists billing policies, finds matches by subscription/resource group, unlinks environments. Returns audit log. |
169+
| **UnlinkAllEnvironmentsFromBillingPolicy** | Manual (Button) | Matches billing policy by name, unlinks all linked environments. Returns audit log. |
170+
171+
#### Connection References
172+
173+
| Logical Name | Connector |
174+
|---|---|
175+
| `mcscat_sharedazureusage…` | Azure Usage (custom) |
176+
| `mcscat_sharedpowerplatformbilliinpolicy…` | Power Platform Billing Policy (custom) |
177+
| `new_sharedpowerplatformadminv2_498a1` | Power Platform Admin V2 (standard) |
178+
179+
**To deploy:**
180+
1. Import the solution into a Power Platform environment where the connection owner has Power Platform Admin rights
181+
2. Authenticate the three connectors during import (Azure Usage, Power Platform Billing Policy, Power Platform Admin V2)
182+
3. Update the `QueryScope` variable in the *Poll Cost and Unlink Environments* flow to target your Azure subscription/resource group
183+
4. Adjust the cost threshold (default: **$65**) in the same flow if needed
184+
5. Copy the HTTP trigger URL from the *UnlinkAllEnvironmentsFromResourceGroup* flow
185+
6. Paste that URL into `UnlinkBillingPolicyRunbook.ps1` at line 27
186+
7. Turn on the scheduled *Poll Cost and Unlink Environments* flow
187+
188+
---
189+
190+
## End-to-End Flow
191+
192+
```
193+
environments.csv
194+
195+
196+
bulk-assign-billing-policy.ps1
197+
Environments linked to billing policies
198+
199+
│ (later, when spend threshold is crossed)
200+
201+
Azure Budget Alert → Action Group → Automation Account webhook
202+
203+
204+
UnlinkBillingPolicyRunbook.ps1
205+
Parses alert → gets token via Managed Identity → calls Power Automate
206+
207+
208+
BillingPolicyManagement solution
209+
Finds policy by name → loops environments → unlinks each one
210+
Environments unlinked — audit log returned
211+
```
212+
213+
To test the right half of this chain at any time, run `TestRunbook.ps1` with `Webhooktestdata.json` — no real budget breach required.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"schemaId":"azureMonitorCommonAlertSchema","data":{"essentials":{"monitoringService":"CostAlerts","firedDateTime":"2026-04-24T15:44:27.6355249Z","description":"Your spend for budget prodbilling is now $4.00 exceeding your specified threshold $1.60.","essentialsVersion":"1.0","alertContextVersion":"1.0","alertId":"/subscriptions/8be5abeb-d89e-4b5e-a459-154ebc5a4601/resourceGroups/Azurevnetforpowerplatform/providers/Microsoft.CostManagement/alerts/e4f7fe09-d3da-4ad2-85ff-24bb30028ff5","alertRule":null,"severity":null,"signalType":null,"monitorCondition":null,"alertTargetIDs":null,"configurationItems":["budgets"],"originAlertId":null},"alertContext":{"AlertCategory":"budgets","AlertData":{"Scope":"/subscriptions/8be5abeb-d89e-4b5e-a459-154ebc5a4601/resourceGroups/Azurevnetforpowerplatform/","ThresholdType":"Actual","BudgetType":"Cost","BudgetThreshold":"$2.00","NotificationThresholdAmount":"$1.60","BudgetName":"prodbilling","BudgetId":"/subscriptions/8be5abeb-d89e-4b5e-a459-154ebc5a4601/resourceGroups/Azurevnetforpowerplatform/providers/Microsoft.Consumption/budgets/prodbilling","BudgetStartDate":"2026-04-01","BudgetCreator":"luispim@MngEnvMCAP917066.onmicrosoft.com","Unit":"USD","SpentAmount":"$4.00"}}}}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
$sampleFile='./Webhooktestdata.json'
2+
3+
# Kick off an Azure Automation Runbook
4+
$SubscriptionId = "<<replace with your subscription>>"
5+
$AutomationAccountName = "<AuatomationAccountName>"
6+
$ResourceGroupName = "<ResourceGroupName>"
7+
$RunbookName = "<<Replace with your runbook name>>"
8+
9+
az account set -s $SubscriptionId
10+
az automation runbook start --name $RunbookName --resource-group $ResourceGroupName --automation-account-name $AutomationAccountName --parameters webhookData='@./Webhooktestdata.json'
11+
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
param (
2+
[Parameter(Mandatory=$false)]
3+
[object] $WebhookData
4+
)
5+
$WebhookData | ConvertTo-Json -depth 99
6+
$alertId = $WebhookData.data.essentials.alertId
7+
$subscriptionId = ($alertId -split '/')[2]
8+
$resourceGroupName = ($alertId -split '/')[4]
9+
$subscriptionId
10+
$resourceGroupName
11+
12+
#Audience for Azure Public Cloud
13+
# For other clouds see https://learn.microsoft.com/en-us/power-automate/oauth-authentication?tabs=new-designer#audience-values
14+
$aud="https://service.flow.microsoft.com/"
15+
# Connect to Azure Powershell using the Managed Identity
16+
Connect-azaccount -Identity
17+
# Get a token
18+
$EntraToken=Get-AzAccessToken -ResourceUrl $aud
19+
$Token=$EntraToken.Token | ConvertTo-SecureString -AsPlainText
20+
21+
22+
$payload=[pscustomobject]@{
23+
resourceGroupName=$resourceGroupName
24+
subscriptionid=$subscriptionId
25+
} | convertto-json -Compress
26+
27+
$Url="https://eb3001acdd1eef5fa19ccc99b93eeb.01.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/5f5585a66b1f4ce0bd34c0a05769a437/triggers/manual/paths/invoke?api-version=1"
28+
29+
Invoke-RestMethod -Method Post -Authentication Bearer -Token $Token -Uri $Url -Body $payload -ContentType 'application/json' -StatusCodeVariable StatusCode -ResponseHeadersVariable RequestResponse
30+
$RequestResponse | ConvertTo-Json -depth 99
31+
$StatusCode | ConvertTo-Json -depth 99

0 commit comments

Comments
 (0)