From 9dc20e25075e82292450f6667b719815a64e8a04 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:08:17 +0100 Subject: [PATCH 01/18] feat: add MDP defaults e2e test bicep --- tests/e2e/defaults/main.test.bicep | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/e2e/defaults/main.test.bicep diff --git a/tests/e2e/defaults/main.test.bicep b/tests/e2e/defaults/main.test.bicep new file mode 100644 index 0000000..c154c5c --- /dev/null +++ b/tests/e2e/defaults/main.test.bicep @@ -0,0 +1,51 @@ +// Test bicep for the managed-devops-pools defaults scenario. +// Creates a dedicated resource group, then deploys the module with minimum concurrency. +// namePrefix derives all resource names — keeps parallel scenario runs isolated. + +targetScope = 'subscription' + +@description('Short prefix for all test resources') +param namePrefix string + +@description('Azure region for test resources') +param location string = 'uksouth' + +@description('Azure DevOps organisation URL') +param organizationUrl string + +var rgName = 'dep-${namePrefix}-mdp' +var dcName = '${namePrefix}-dc' +var projName = '${namePrefix}-proj' +var poolName = '${namePrefix}-pool' + +resource testRg 'Microsoft.Resources/resourceGroups@2024-07-01' = { + name: rgName + location: location +} + +module mdp '../../../bicep/main.bicep' = { + name: 'mdp-test-defaults' + scope: testRg + params: { + devCenterName: dcName + devCenterProjectName: projName + poolName: poolName + organizationUrl: organizationUrl + maximumConcurrency: 1 + vmSize: 'Standard_D2s_v5' + osType: 'Windows' + agentLifecycle: 'Stateless' + environment: 'test' + tags: { + environment: 'test' + managedBy: 'bicep' + repo: 'managed-devops-pools' + } + } +} + +output resourceGroupName string = testRg.name +output moduleNamePrefix string = namePrefix +output devCenterName string = dcName +output devCenterProjectName string = projName +output poolName string = poolName From 0ab364106035f38cede5d0b563af90aaaeb50439 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:08:18 +0100 Subject: [PATCH 02/18] feat: add MDP defaults Pester v5 assertions --- tests/e2e/defaults/mdp.defaults.test.ps1 | 80 ++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/e2e/defaults/mdp.defaults.test.ps1 diff --git a/tests/e2e/defaults/mdp.defaults.test.ps1 b/tests/e2e/defaults/mdp.defaults.test.ps1 new file mode 100644 index 0000000..a84a324 --- /dev/null +++ b/tests/e2e/defaults/mdp.defaults.test.ps1 @@ -0,0 +1,80 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Pester v5 assertions for the managed-devops-pools defaults e2e scenario. + Uses az CLI for all assertions — no Az PowerShell modules required. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] +param() + +BeforeAll { + $script:rg = $env:TEST_RG + $p = $env:TEST_NAME_PREFIX + + # Derive names from namePrefix (must match main.test.bicep) + $script:dc = "$p-dc" + $script:proj = "$p-proj" + $script:pool = "$p-pool" +} + +Describe 'Resource Group' { + It 'should exist and be provisioned' { + $state = az group show --name $script:rg ` + --query 'properties.provisioningState' -o tsv 2>$null + $state | Should -Be 'Succeeded' + } +} + +Describe 'Dev Center' { + It 'should exist' { + $state = az resource show ` + --resource-group $script:rg ` + --name $script:dc ` + --resource-type 'Microsoft.DevCenter/devcenters' ` + --query 'properties.provisioningState' -o tsv 2>$null + $state | Should -Be 'Succeeded' + } +} + +Describe 'Dev Center Project' { + It 'should exist' { + $state = az resource show ` + --resource-group $script:rg ` + --name $script:proj ` + --resource-type 'Microsoft.DevCenter/projects' ` + --query 'properties.provisioningState' -o tsv 2>$null + $state | Should -Be 'Succeeded' + } +} + +Describe 'Managed DevOps Pool' { + BeforeAll { + $script:poolProps = az resource show ` + --resource-group $script:rg ` + --name $script:pool ` + --resource-type 'Microsoft.DevOpsInfrastructure/pools' ` + --query 'properties' -o json 2>$null | ConvertFrom-Json + } + + It 'should exist and be provisioned' { + $script:poolProps | Should -Not -BeNullOrEmpty + $script:poolProps.provisioningState | Should -Be 'Succeeded' + } + + It 'organisation URL should be set' { + $url = $script:poolProps.organizationProfile.organizations[0].url + $url | Should -Not -BeNullOrEmpty + } + + It 'maximum concurrency should be at least 1' { + $script:poolProps.maximumConcurrency | Should -BeGreaterOrEqual 1 + } + + It 'agent lifecycle should be Stateless' { + $script:poolProps.agentProfile.kind | Should -Be 'Stateless' + } + + It 'fabric profile should be Managed' { + $script:poolProps.fabricProfile.kind | Should -Be 'Managed' + } +} From 3c93f0331d1a2257f0f571bf3d3b1036390b9c16 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:08:19 +0100 Subject: [PATCH 03/18] feat: add cost estimate script for MDP --- scripts/Get-CostEstimate.ps1 | 155 +++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 scripts/Get-CostEstimate.ps1 diff --git a/scripts/Get-CostEstimate.ps1 b/scripts/Get-CostEstimate.ps1 new file mode 100644 index 0000000..6107695 --- /dev/null +++ b/scripts/Get-CostEstimate.ps1 @@ -0,0 +1,155 @@ +#Requires -Modules Az.Accounts, Az.Resources +<# +.SYNOPSIS + Estimates monthly cost for a Managed DevOps Pool resource group using + the Azure Retail Prices API. Outputs a table to the console and saves + CSV, JSON, and Markdown artifacts. + +.PARAMETER ResourceGroupName + Resource group to analyse. + +.PARAMETER Location + Azure region (ARM name, e.g. 'uksouth'). + +.PARAMETER Currency + ISO currency code (default GBP). + +.PARAMETER OutputPath + Directory to write cost-estimate.csv / .json / .md into. +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [string] $ResourceGroupName, + + [string] $Location = 'uksouth', + [string] $Currency = 'GBP', + [string] $OutputPath = '.' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-RetailPrices { + param([string] $Filter) + $uri = 'https://prices.azure.com/api/retail/prices?api-version=2023-01-01-preview' + + "&`$filter=$([Uri]::EscapeDataString($Filter))" + try { + $r = Invoke-RestMethod -Uri $uri -TimeoutSec 20 + return $r.Items | Where-Object priceType -eq 'Consumption' + } + catch { + Write-Warning "Retail Prices API lookup failed: $_" + return @() + } +} + +$estimates = [System.Collections.Generic.List[PSCustomObject]]::new() +$resources = Get-AzResource -ResourceGroupName $ResourceGroupName + +foreach ($r in $resources) { + switch -Wildcard ($r.ResourceType.ToLower()) { + + 'microsoft.devcenter/devcenters' { + $prices = Get-RetailPrices -Filter ( + "armRegionName eq '$Location' and currencyCode eq '$Currency' " + + "and serviceName eq 'Dev Center' and meterName eq 'Dev Center'" + ) + $price = $prices | Select-Object -First 1 + $estimates.Add([PSCustomObject]@{ + Resource = $r.Name + Type = 'Dev Center' + SKU = 'Standard' + UnitPrice = if ($price) { "$Currency $($price.retailPrice)/hr" } else { 'lookup failed' } + EstMonthly = if ($price) { [math]::Round($price.retailPrice * 730, 2) } else { 'N/A' } + Notes = 'Billed per hour while active' + }) + } + + 'microsoft.devcenter/projects' { + $estimates.Add([PSCustomObject]@{ + Resource = $r.Name + Type = 'Dev Center Project' + SKU = 'N/A' + UnitPrice = 'Free' + EstMonthly = 0 + Notes = 'No charge for DC projects' + }) + } + + 'microsoft.devopsinfrastructure/pools' { + $poolJson = az resource show ` + --resource-group $ResourceGroupName ` + --name $r.Name ` + --resource-type 'Microsoft.DevOpsInfrastructure/pools' ` + --query 'properties' -o json 2>$null | ConvertFrom-Json + + $vmSize = $poolJson.fabricProfile.sku.name + $maxConc = $poolJson.maximumConcurrency + + if ($vmSize) { + $sku = $vmSize -replace '^(Standard|Basic)_', '' -replace '_', ' ' + $prices = Get-RetailPrices -Filter ( + "armRegionName eq '$Location' and currencyCode eq '$Currency' " + + "and serviceName eq 'Virtual Machines' and skuName eq '$sku'" + ) + $price = $prices | + Where-Object { $_.meterName -notmatch 'Spot|Low Priority|Windows' } | + Select-Object -First 1 + $perVm = if ($price) { $price.retailPrice } else { $null } + $monthly = if ($perVm) { [math]::Round($perVm * 730 * $maxConc, 2) } else { 'N/A' } + $estimates.Add([PSCustomObject]@{ + Resource = $r.Name + Type = 'Managed DevOps Pool' + SKU = $vmSize + UnitPrice = if ($price) { "$Currency $perVm/hr per agent" } else { 'lookup failed' } + EstMonthly = $monthly + Notes = "Max concurrency $maxConc agents × 730 hrs (all agents running 24/7)" + }) + } + } + } +} + +Write-Host "`n=== Cost Estimate: $ResourceGroupName ===" -ForegroundColor Cyan +Write-Host "Region: $Location | Currency: $Currency`n" -ForegroundColor Gray + +if ($estimates.Count -eq 0) { + Write-Warning 'No priceable resources found.' +} +else { + $estimates | Format-Table Resource, Type, SKU, UnitPrice, EstMonthly, Notes -AutoSize +} + +$numericTotal = ($estimates | + Where-Object { $_.EstMonthly -is [double] -or $_.EstMonthly -is [int] } | + Measure-Object -Property EstMonthly -Sum).Sum + +Write-Host "Subtotal (all agents 24/7): $Currency $([math]::Round($numericTotal, 2))/month" -ForegroundColor Green +Write-Host "MDP agents are stateless — actual cost = (jobs run × avg duration × hourly rate)`n" + +$null = New-Item -ItemType Directory -Force -Path $OutputPath +$estimates | Export-Csv -Path "$OutputPath/cost-estimate.csv" -NoTypeInformation -Force +$estimates | ConvertTo-Json -Depth 3 | Out-File -FilePath "$OutputPath/cost-estimate.json" -Force + +$rows = $estimates | ForEach-Object { + "| $($_.Resource) | $($_.Type) | $($_.SKU) | $($_.UnitPrice) | $($_.EstMonthly) | $($_.Notes) |" +} +$md = @" +## Cost Estimate — $ResourceGroupName + +> **Region:** `$Location` | **Currency:** `$Currency` | Max agents 24/7 assumption + +| Resource | Type | SKU | Unit Price | Est. Monthly | Notes | +|---|---|---|---|---|---| +$($rows -join "`n") + +**Subtotal (all agents 24/7):** $Currency $([math]::Round($numericTotal, 2))/month +> MDP pools are on-demand — actual cost scales with job frequency and duration, not max concurrency. +"@ + +$md | Out-File -FilePath "$OutputPath/cost-estimate.md" -Force +if ($env:GITHUB_STEP_SUMMARY) { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $md +} +Write-Host "Artifacts saved to: $OutputPath (csv / json / md)" From 483dc054efd557e9c8fbbb11645f096aee99d558 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:08:20 +0100 Subject: [PATCH 04/18] ci: add MDP e2e trigger workflow (PSRule + defaults) --- .github/workflows/mdp.module.yml | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/mdp.module.yml diff --git a/.github/workflows/mdp.module.yml b/.github/workflows/mdp.module.yml new file mode 100644 index 0000000..7144b01 --- /dev/null +++ b/.github/workflows/mdp.module.yml @@ -0,0 +1,67 @@ +name: MDP Module — e2e Tests + +on: + push: + branches: [main] + paths: + - 'bicep/**' + - 'tests/**' + - 'scripts/**' + - '.github/workflows/mdp.module.yml' + pull_request: + paths: + - 'bicep/**' + - 'tests/**' + - 'scripts/**' + workflow_dispatch: + inputs: + run_azqr: + description: 'Run AzQR best-practice scan (uploads report artifact)' + type: boolean + default: false + run_cost_estimate: + description: 'Run cost estimate (uploads pricing report artifact)' + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + # ── Static analysis — no Azure credentials needed ────────────────────────── + psrule: + name: PSRule for Azure + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Bicep CLI + run: | + curl -Lo bicep https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64 + chmod +x ./bicep && sudo mv ./bicep /usr/local/bin/bicep + + - name: Compile Bicep to ARM + run: bicep build bicep/main.bicep --outdir /tmp/arm + + - name: Run PSRule for Azure + shell: pwsh + run: | + Install-Module PSRule.Rules.Azure -Force -Scope CurrentUser + $results = Invoke-PSRule -InputPath /tmp/arm -Module PSRule.Rules.Azure -Outcome Fail -As Summary + if ($results) { + $results | Format-Table -AutoSize + Write-Error "PSRule found $($results.Count) issue(s)" + } + + # ── e2e: defaults scenario ───────────────────────────────────────────────── + defaults: + name: e2e / defaults + needs: psrule + uses: awood-ops/platform-workflows/.github/workflows/e2e.reusable.yml@main + with: + scenario: defaults + run_azqr: ${{ inputs.run_azqr || false }} + run_cost_estimate: ${{ inputs.run_cost_estimate || false }} + secrets: inherit From 8f2560d827c918973b2b24d2c8c019109f688e5a Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:08:22 +0100 Subject: [PATCH 05/18] docs: update README with e2e testing and CI details --- README.md | 164 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 98 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index da5cbea..5213770 100755 --- a/README.md +++ b/README.md @@ -1,77 +1,57 @@ -# Azure Managed DevOps Pools +# Managed DevOps Pools -Bicep templates and Azure DevOps pipelines to deploy and manage [Managed DevOps Pools](https://learn.microsoft.com/en-us/azure/devops/managed-devops-pools/overview) — Microsoft's replacement for VMSS-based scale set agents. - -Managed DevOps Pools provisions ephemeral Azure VMs on-demand when pipeline jobs are queued, and deallocates them when jobs complete. No agent infrastructure to maintain. +[Azure Managed DevOps Pools](https://learn.microsoft.com/en-us/azure/devops/managed-devops-pools/overview) — Bicep (AVM) templates and Azure DevOps pipelines for on-demand ephemeral build agents. ## Architecture ```mermaid -graph LR - subgraph ADO["Azure DevOps"] - Pipeline["Pipeline Job\n(queued)"] - Pool["Agent Pool\nmdp-windows-prd"] - end - - subgraph Azure["Azure Subscription"] - subgraph DC["Dev Center"] - DevCenter["Dev Center"] - Project["Dev Center Project"] - end - subgraph MDP["Managed DevOps Pool"] - PoolRes["Pool Resource\nmax 4 agents"] - VM["Ephemeral VM\nStandard_D4s_v5\nWindows 2022"] - end +graph TB + ADO(["🔧 Azure DevOps\nPipeline Job"]) + + subgraph Azure["Azure Subscription — rg-devops-agents-prd"] + DC["Dev Center\ndc-devops-prd"] + Proj["DC Project\ndc-project-devops-prd"] + Pool["Managed DevOps Pool\nmdp-windows-prd"] + Agents["Ephemeral Agents\n(Windows Server 2022)"] end - Pipeline -->|"job queued"| PoolRes - PoolRes -->|"provision"| VM - VM -->|"registers as agent"| Pool - Pool -->|"runs job"| Pipeline - VM -->|"job complete → deallocate"| PoolRes - DevCenter --> Project --> PoolRes + ADO -->|triggers job| Pool + Pool -->|provisions| Agents + Agents -->|registered in| ADO + Proj --> Pool + DC --> Proj ``` -## Agent lifecycle - -```mermaid -sequenceDiagram - participant P as Pipeline - participant MDP as Managed DevOps Pool - participant VM as Ephemeral VM - - P->>MDP: Job queued - MDP->>VM: Provision VM (cold start ~2 min) - VM->>MDP: Register as agent - MDP->>P: Agent available - P->>VM: Run job - VM->>P: Job complete - VM->>MDP: Deregister - MDP->>VM: Deallocate - Note over VM: No charge while idle -``` +Agents are **stateless** — a fresh VM is provisioned per job and deallocated when complete. No persistent agent infrastructure; cost is purely on-demand. ## Structure ``` .azuredevops/ -├── Deploy-ManagedDevOpsPools.yaml # CD pipeline — deploys on push to main -└── PR-Validation.yaml # CI pipeline — validates Bicep on PRs +└── Deploy-ManagedDevOpsPools.yaml # CD pipeline — deploys on push to main .github/workflows/ -└── check-avm-versions.yml # Weekly AVM version check — auto-raises PRs +├── mdp.module.yml # e2e test trigger (PSRule + defaults scenario) +└── check-avm-versions.yml # Weekly AVM version check bicep/ -├── main.bicep # Entry point — calls AVM modules -└── main.bicepparam # Parameters (copy and customise per environment) +├── main.bicep # Dev Center + DC Project + Managed DevOps Pool +└── main.bicepparam # Parameters (customise per environment) docs/ -└── Getting-Started.md # Prerequisites and first-time setup +└── Getting-Started.md # Prerequisites and initial setup scripts/ -├── Get-PoolAgentStatus.ps1 # Check active agents on a pool +├── Get-CostEstimate.ps1 # Retail Prices API cost estimate — used by e2e workflow +├── Get-PoolAgentStatus.ps1 # Show current agent status in the pool +├── Test-ManagedDevOpsPool.ps1 # Validate a deployment is healthy └── Update-AvmVersions.py # AVM version checker (used by GitHub Actions) +tests/e2e/ +└── defaults/ + ├── main.test.bicep # Subscription-scoped test bicep — DC + project + 1-agent pool + └── mdp.defaults.test.ps1 # Pester v5 assertions for defaults scenario + bicepconfig.json # AVM public registry alias ``` @@ -87,34 +67,86 @@ Check latest versions: [AVM Bicep Resource Modules](https://azure.github.io/Azur ## Quick start -See [docs/Getting-Started.md](docs/Getting-Started.md) for prerequisites and service connection setup. +See [docs/Getting-Started.md](docs/Getting-Started.md) for prerequisites (service principal, ADO permissions). ```bash -# Restore AVM modules locally az bicep restore --file bicep/main.bicep -# Validate -az deployment group validate \ - --resource-group rg-devops-agents-prd \ - --template-file bicep/main.bicep \ - --parameters bicep/main.bicepparam - -# Deploy az deployment group create \ --resource-group rg-devops-agents-prd \ --template-file bicep/main.bicep \ - --parameters bicep/main.bicepparam + --parameters bicep/main.bicepparam \ + --parameters organizationUrl='https://dev.azure.com/YOUR-ORG' +``` + +## CI / Testing + +Testing follows the [Azure Verified Modules e2e pattern](https://azure.github.io/Azure-Verified-Modules/contributing/bicep/bicep-contribution-flow/validate-bicep-module-locally/) via the shared [`awood-ops/.github`](https://github.com/awood-ops/.github) reusable workflow. + +### Pipeline overview + ``` +On push / PR (bicep/** or tests/**) +│ +├── PSRule for Azure Static analysis — compiles Bicep → ARM, runs best-practice rules. +│ No Azure credentials needed. Blocks e2e if it fails. +│ +└── e2e / defaults Deploys Dev Center + Project + 1-agent pool; runs Pester assertions; + deletes RG on completion. +``` + +The e2e lifecycle is managed by [`e2e.reusable.yml`](https://github.com/awood-ops/.github/blob/main/.github/workflows/e2e.reusable.yml): + +``` +OIDC login → what-if preflight → deploy → Pester assertions → [AzQR] → [cost estimate] → cleanup +``` + +### Test scenario + +| Scenario | VM size | Max concurrency | Approx. duration | +|---|---|---|---| +| `defaults` | Standard_D2s_v5 | 1 | ~15–20 min (Dev Center provisioning) | + +> Dev Center provisioning takes significantly longer than most Azure resources. The e2e run is triggered on PRs but is not a merge gate — failing runs should be investigated before merging. -## Use the pool in a pipeline +### Optional analysis (workflow_dispatch only) -Reference the pool by name in your Azure DevOps pipeline YAML: +When triggering via **Actions → MDP Module — e2e Tests → Run workflow**: -```yaml -pool: - name: mdp-windows-prd +| Input | Description | Artifact | +|---|---|---| +| `run_azqr` | [AzQR](https://github.com/Azure/azqr) best-practice scan | `azqr-defaults/` | +| `run_cost_estimate` | Per-resource monthly estimate via [Azure Retail Prices API](https://learn.microsoft.com/en-us/rest/api/cost-management/retail-prices/azure-retail-prices) | `cost-estimate-defaults/` | + +### Running tests locally + +```bash +# Deploy the defaults scenario +az deployment sub create \ + --location uksouth \ + --template-file tests/e2e/defaults/main.test.bicep \ + --parameters namePrefix=mdplocal organizationUrl='https://dev.azure.com/YOUR-ORG' + +# Run Pester assertions (Pester v5 + PowerShell 7 required) +$env:TEST_RG = 'dep-mdplocal-mdp' +$env:TEST_NAME_PREFIX = 'mdplocal' +Invoke-Pester ./tests/e2e/defaults -Output Detailed + +# Cleanup +az group delete --name dep-mdplocal-mdp --yes ``` +### Required secrets + +| Secret | Description | +|---|---| +| `AZURE_CLIENT_ID` | App registration client ID for OIDC federation | +| `AZURE_TENANT_ID` | Entra tenant ID | +| `AZURE_SUBSCRIPTION_ID` | Target subscription for test deployments | +| `AZURE_DEVOPS_ORG_URL` | Azure DevOps org URL (e.g. `https://dev.azure.com/my-org`) | + +The OIDC service principal requires `Contributor` on the test subscription and `DevOps Infrastructure Pool Admin` in the Azure DevOps organisation. + ## Contributing -Changes go through a pull request — the PR validation pipeline runs `az bicep build` and a preflight `validate` against the target resource group before merge. AVM module versions are checked weekly and updated automatically via pull request. +Changes go through a pull request. PSRule for Azure runs on every PR as a pre-gate. AVM module versions are checked weekly and updated automatically via pull request. From 99c72c7ff3a3597e35c7eb72386b9f407efa2b93 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:12:40 +0100 Subject: [PATCH 06/18] =?UTF-8?q?refactor:=20remove=20per-repo=20cost=20es?= =?UTF-8?q?timate=20script=20=E2=80=94=20now=20inlined=20in=20platform-wor?= =?UTF-8?q?kflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/Get-CostEstimate.ps1 | 155 ----------------------------------- 1 file changed, 155 deletions(-) delete mode 100644 scripts/Get-CostEstimate.ps1 diff --git a/scripts/Get-CostEstimate.ps1 b/scripts/Get-CostEstimate.ps1 deleted file mode 100644 index 6107695..0000000 --- a/scripts/Get-CostEstimate.ps1 +++ /dev/null @@ -1,155 +0,0 @@ -#Requires -Modules Az.Accounts, Az.Resources -<# -.SYNOPSIS - Estimates monthly cost for a Managed DevOps Pool resource group using - the Azure Retail Prices API. Outputs a table to the console and saves - CSV, JSON, and Markdown artifacts. - -.PARAMETER ResourceGroupName - Resource group to analyse. - -.PARAMETER Location - Azure region (ARM name, e.g. 'uksouth'). - -.PARAMETER Currency - ISO currency code (default GBP). - -.PARAMETER OutputPath - Directory to write cost-estimate.csv / .json / .md into. -#> -[CmdletBinding()] -param ( - [Parameter(Mandatory)] - [string] $ResourceGroupName, - - [string] $Location = 'uksouth', - [string] $Currency = 'GBP', - [string] $OutputPath = '.' -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -function Get-RetailPrices { - param([string] $Filter) - $uri = 'https://prices.azure.com/api/retail/prices?api-version=2023-01-01-preview' + - "&`$filter=$([Uri]::EscapeDataString($Filter))" - try { - $r = Invoke-RestMethod -Uri $uri -TimeoutSec 20 - return $r.Items | Where-Object priceType -eq 'Consumption' - } - catch { - Write-Warning "Retail Prices API lookup failed: $_" - return @() - } -} - -$estimates = [System.Collections.Generic.List[PSCustomObject]]::new() -$resources = Get-AzResource -ResourceGroupName $ResourceGroupName - -foreach ($r in $resources) { - switch -Wildcard ($r.ResourceType.ToLower()) { - - 'microsoft.devcenter/devcenters' { - $prices = Get-RetailPrices -Filter ( - "armRegionName eq '$Location' and currencyCode eq '$Currency' " + - "and serviceName eq 'Dev Center' and meterName eq 'Dev Center'" - ) - $price = $prices | Select-Object -First 1 - $estimates.Add([PSCustomObject]@{ - Resource = $r.Name - Type = 'Dev Center' - SKU = 'Standard' - UnitPrice = if ($price) { "$Currency $($price.retailPrice)/hr" } else { 'lookup failed' } - EstMonthly = if ($price) { [math]::Round($price.retailPrice * 730, 2) } else { 'N/A' } - Notes = 'Billed per hour while active' - }) - } - - 'microsoft.devcenter/projects' { - $estimates.Add([PSCustomObject]@{ - Resource = $r.Name - Type = 'Dev Center Project' - SKU = 'N/A' - UnitPrice = 'Free' - EstMonthly = 0 - Notes = 'No charge for DC projects' - }) - } - - 'microsoft.devopsinfrastructure/pools' { - $poolJson = az resource show ` - --resource-group $ResourceGroupName ` - --name $r.Name ` - --resource-type 'Microsoft.DevOpsInfrastructure/pools' ` - --query 'properties' -o json 2>$null | ConvertFrom-Json - - $vmSize = $poolJson.fabricProfile.sku.name - $maxConc = $poolJson.maximumConcurrency - - if ($vmSize) { - $sku = $vmSize -replace '^(Standard|Basic)_', '' -replace '_', ' ' - $prices = Get-RetailPrices -Filter ( - "armRegionName eq '$Location' and currencyCode eq '$Currency' " + - "and serviceName eq 'Virtual Machines' and skuName eq '$sku'" - ) - $price = $prices | - Where-Object { $_.meterName -notmatch 'Spot|Low Priority|Windows' } | - Select-Object -First 1 - $perVm = if ($price) { $price.retailPrice } else { $null } - $monthly = if ($perVm) { [math]::Round($perVm * 730 * $maxConc, 2) } else { 'N/A' } - $estimates.Add([PSCustomObject]@{ - Resource = $r.Name - Type = 'Managed DevOps Pool' - SKU = $vmSize - UnitPrice = if ($price) { "$Currency $perVm/hr per agent" } else { 'lookup failed' } - EstMonthly = $monthly - Notes = "Max concurrency $maxConc agents × 730 hrs (all agents running 24/7)" - }) - } - } - } -} - -Write-Host "`n=== Cost Estimate: $ResourceGroupName ===" -ForegroundColor Cyan -Write-Host "Region: $Location | Currency: $Currency`n" -ForegroundColor Gray - -if ($estimates.Count -eq 0) { - Write-Warning 'No priceable resources found.' -} -else { - $estimates | Format-Table Resource, Type, SKU, UnitPrice, EstMonthly, Notes -AutoSize -} - -$numericTotal = ($estimates | - Where-Object { $_.EstMonthly -is [double] -or $_.EstMonthly -is [int] } | - Measure-Object -Property EstMonthly -Sum).Sum - -Write-Host "Subtotal (all agents 24/7): $Currency $([math]::Round($numericTotal, 2))/month" -ForegroundColor Green -Write-Host "MDP agents are stateless — actual cost = (jobs run × avg duration × hourly rate)`n" - -$null = New-Item -ItemType Directory -Force -Path $OutputPath -$estimates | Export-Csv -Path "$OutputPath/cost-estimate.csv" -NoTypeInformation -Force -$estimates | ConvertTo-Json -Depth 3 | Out-File -FilePath "$OutputPath/cost-estimate.json" -Force - -$rows = $estimates | ForEach-Object { - "| $($_.Resource) | $($_.Type) | $($_.SKU) | $($_.UnitPrice) | $($_.EstMonthly) | $($_.Notes) |" -} -$md = @" -## Cost Estimate — $ResourceGroupName - -> **Region:** `$Location` | **Currency:** `$Currency` | Max agents 24/7 assumption - -| Resource | Type | SKU | Unit Price | Est. Monthly | Notes | -|---|---|---|---|---|---| -$($rows -join "`n") - -**Subtotal (all agents 24/7):** $Currency $([math]::Round($numericTotal, 2))/month -> MDP pools are on-demand — actual cost scales with job frequency and duration, not max concurrency. -"@ - -$md | Out-File -FilePath "$OutputPath/cost-estimate.md" -Force -if ($env:GITHUB_STEP_SUMMARY) { - Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $md -} -Write-Host "Artifacts saved to: $OutputPath (csv / json / md)" From 5fd424c16f6fcc81134538e5c84b90990e2500da Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:12:43 +0100 Subject: [PATCH 07/18] docs: remove cost estimate script reference (now centralised in platform-workflows) --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 5213770..4fe60d9 100755 --- a/README.md +++ b/README.md @@ -42,7 +42,6 @@ docs/ └── Getting-Started.md # Prerequisites and initial setup scripts/ -├── Get-CostEstimate.ps1 # Retail Prices API cost estimate — used by e2e workflow ├── Get-PoolAgentStatus.ps1 # Show current agent status in the pool ├── Test-ManagedDevOpsPool.ps1 # Validate a deployment is healthy └── Update-AvmVersions.py # AVM version checker (used by GitHub Actions) From c83fbd178c5cb6d2f77efa831abcd7182c538858 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:19:10 +0100 Subject: [PATCH 08/18] ci: trigger CI run after platform-workflows made public From 896bda6d4a3a7f38580510737e38fd1a6fd9bf30 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:22:02 +0100 Subject: [PATCH 09/18] =?UTF-8?q?ci:=20gate=20MDP=20e2e=20to=20workflow=5F?= =?UTF-8?q?dispatch=20only=20=E2=80=94=20requires=20AZURE=5FDEVOPS=5FORG?= =?UTF-8?q?=5FURL=20secret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/mdp.module.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/mdp.module.yml b/.github/workflows/mdp.module.yml index 7144b01..96e59f9 100644 --- a/.github/workflows/mdp.module.yml +++ b/.github/workflows/mdp.module.yml @@ -6,13 +6,11 @@ on: paths: - 'bicep/**' - 'tests/**' - - 'scripts/**' - '.github/workflows/mdp.module.yml' pull_request: paths: - 'bicep/**' - 'tests/**' - - 'scripts/**' workflow_dispatch: inputs: run_azqr: @@ -55,10 +53,11 @@ jobs: Write-Error "PSRule found $($results.Count) issue(s)" } - # ── e2e: defaults scenario ───────────────────────────────────────────────── + # ── e2e: defaults — manual dispatch only (requires AZURE_DEVOPS_ORG_URL secret) ─ defaults: name: e2e / defaults needs: psrule + if: ${{ github.event_name == 'workflow_dispatch' }} uses: awood-ops/platform-workflows/.github/workflows/e2e.reusable.yml@main with: scenario: defaults From 2c95ab14d4b99f58e196211cf40a0ceca7fd2e2b Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:53:49 +0100 Subject: [PATCH 10/18] =?UTF-8?q?fix:=20add=20permissions=20id-token:=20wr?= =?UTF-8?q?ite=20=E2=80=94=20required=20for=20OIDC=20in=20reusable=20workf?= =?UTF-8?q?low?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/mdp.module.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/mdp.module.yml b/.github/workflows/mdp.module.yml index 96e59f9..dffb673 100644 --- a/.github/workflows/mdp.module.yml +++ b/.github/workflows/mdp.module.yml @@ -1,4 +1,4 @@ -name: MDP Module — e2e Tests +name: MDP Module -- e2e Tests on: push: @@ -22,13 +22,18 @@ on: type: boolean default: false +# Required so reusable workflow can use OIDC (azure/login@v2) +permissions: + id-token: write + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - # ── Static analysis — no Azure credentials needed ────────────────────────── + # Static analysis -- no Azure credentials needed psrule: name: PSRule for Azure runs-on: ubuntu-latest @@ -53,7 +58,7 @@ jobs: Write-Error "PSRule found $($results.Count) issue(s)" } - # ── e2e: defaults — manual dispatch only (requires AZURE_DEVOPS_ORG_URL secret) ─ + # e2e: defaults -- manual dispatch only (requires AZURE_DEVOPS_ORG_URL secret) defaults: name: e2e / defaults needs: psrule From d2718c70425d36454fef6e5ba0a3a7bdb6086f55 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:56:26 +0100 Subject: [PATCH 11/18] fix: download Bicep CLI to /tmp to avoid clash with bicep/ directory --- .github/workflows/mdp.module.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mdp.module.yml b/.github/workflows/mdp.module.yml index dffb673..986d38a 100644 --- a/.github/workflows/mdp.module.yml +++ b/.github/workflows/mdp.module.yml @@ -42,8 +42,8 @@ jobs: - name: Install Bicep CLI run: | - curl -Lo bicep https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64 - chmod +x ./bicep && sudo mv ./bicep /usr/local/bin/bicep + curl -Lo /tmp/bicep-cli https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64 + chmod +x /tmp/bicep-cli && sudo mv /tmp/bicep-cli /usr/local/bin/bicep - name: Compile Bicep to ARM run: bicep build bicep/main.bicep --outdir /tmp/arm From 278edb36e60878e54297497c19d835616b6152f9 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:58:32 +0100 Subject: [PATCH 12/18] fix: run bicep restore before bicep build (BCP192 registry types) --- .github/workflows/mdp.module.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mdp.module.yml b/.github/workflows/mdp.module.yml index 986d38a..becfd5e 100644 --- a/.github/workflows/mdp.module.yml +++ b/.github/workflows/mdp.module.yml @@ -46,7 +46,9 @@ jobs: chmod +x /tmp/bicep-cli && sudo mv /tmp/bicep-cli /usr/local/bin/bicep - name: Compile Bicep to ARM - run: bicep build bicep/main.bicep --outdir /tmp/arm + run: | + bicep restore bicep/main.bicep + bicep build bicep/main.bicep --outdir /tmp/arm - name: Run PSRule for Azure shell: pwsh From 00e5910b8eea439d28e7ce78fc7caeb3bc85bbc2 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:05:36 +0100 Subject: [PATCH 13/18] fix: restore all bicep module files to resolve BCP192 (AVM registry deps) --- .github/workflows/mdp.module.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mdp.module.yml b/.github/workflows/mdp.module.yml index becfd5e..a8fcb72 100644 --- a/.github/workflows/mdp.module.yml +++ b/.github/workflows/mdp.module.yml @@ -47,7 +47,7 @@ jobs: - name: Compile Bicep to ARM run: | - bicep restore bicep/main.bicep + find bicep -name '*.bicep' | xargs -I{} bicep restore {} bicep build bicep/main.bicep --outdir /tmp/arm - name: Run PSRule for Azure From fd5a9aa47611e07631e0557349a757815b3761e2 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:11:25 +0100 Subject: [PATCH 14/18] fix: correct br/public alias modulePath to 'bicep' (AVM modules are at mcr.microsoft.com/bicep/avm, not /bicep/modules/avm) --- bicepconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bicepconfig.json b/bicepconfig.json index de4d762..e3cdb78 100755 --- a/bicepconfig.json +++ b/bicepconfig.json @@ -3,7 +3,7 @@ "br": { "public": { "registry": "mcr.microsoft.com", - "modulePath": "bicep/modules" + "modulePath": "bicep" } } } From 521b0565b5aa597c26a59f42495d714e0d8b3f16 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:10:15 +0100 Subject: [PATCH 15/18] fix: replace unpublished avm/res/dev-center/dev-center with native ARM resource (module not in MCR registry) --- bicep/main.bicep | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bicep/main.bicep b/bicep/main.bicep index 05be635..d336b3f 100755 --- a/bicep/main.bicep +++ b/bicep/main.bicep @@ -51,13 +51,11 @@ param tags object = { // ── Dev Center ─────────────────────────────────────────────────────────────── -module devCenter 'br/public:avm/res/dev-center/dev-center:0.1.4' = { - name: 'devCenter' - params: { - name: devCenterName - location: location - tags: tags - } +// avm/res/dev-center/dev-center is not published to MCR -- using native resource +resource devCenter 'Microsoft.DevCenter/devCenters@2024-02-01' = { + name: devCenterName + location: location + tags: tags } module devCenterProject 'br/public:avm/res/dev-center/project:0.1.2' = { @@ -65,7 +63,7 @@ module devCenterProject 'br/public:avm/res/dev-center/project:0.1.2' = { params: { name: devCenterProjectName location: location - devCenterId: devCenter.outputs.resourceId + devCenterId: devCenter.id tags: tags } } @@ -109,7 +107,7 @@ module managedDevOpsPool 'br/public:avm/res/dev-ops-infrastructure/pool:0.2.0' = // ── Outputs ────────────────────────────────────────────────────────────────── -output devCenterId string = devCenter.outputs.resourceId +output devCenterId string = devCenter.id output devCenterProjectId string = devCenterProject.outputs.resourceId output poolId string = managedDevOpsPool.outputs.resourceId output poolName string = managedDevOpsPool.outputs.name From dbac3ebbb9fd2fdfb76fbf489abcb0506e85ac16 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:12:20 +0100 Subject: [PATCH 16/18] fix: devCenterResourceId param name + bump pool module to 0.7.1 (v0.2.0 lacked agentProfile/organizationProfile) --- bicep/main.bicep | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bicep/main.bicep b/bicep/main.bicep index d336b3f..cb6471c 100755 --- a/bicep/main.bicep +++ b/bicep/main.bicep @@ -63,14 +63,14 @@ module devCenterProject 'br/public:avm/res/dev-center/project:0.1.2' = { params: { name: devCenterProjectName location: location - devCenterId: devCenter.id + devCenterResourceId: devCenter.id tags: tags } } // ── Managed DevOps Pool ────────────────────────────────────────────────────── -module managedDevOpsPool 'br/public:avm/res/dev-ops-infrastructure/pool:0.2.0' = { +module managedDevOpsPool 'br/public:avm/res/dev-ops-infrastructure/pool:0.7.1' = { name: 'managedDevOpsPool' params: { name: poolName From dc37d25fe114ed8d29239349784b9567ed253ed2 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:13:06 +0100 Subject: [PATCH 17/18] fix: use correct AVM module path dev-center/devcenter:0.1.2 (was dev-center which doesn't exist in MCR) --- bicep/main.bicep | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/bicep/main.bicep b/bicep/main.bicep index cb6471c..27bbe50 100755 --- a/bicep/main.bicep +++ b/bicep/main.bicep @@ -51,11 +51,13 @@ param tags object = { // ── Dev Center ─────────────────────────────────────────────────────────────── -// avm/res/dev-center/dev-center is not published to MCR -- using native resource -resource devCenter 'Microsoft.DevCenter/devCenters@2024-02-01' = { - name: devCenterName - location: location - tags: tags +module devCenter 'br/public:avm/res/dev-center/devcenter:0.1.2' = { + name: 'devCenter' + params: { + name: devCenterName + location: location + tags: tags + } } module devCenterProject 'br/public:avm/res/dev-center/project:0.1.2' = { @@ -63,7 +65,7 @@ module devCenterProject 'br/public:avm/res/dev-center/project:0.1.2' = { params: { name: devCenterProjectName location: location - devCenterResourceId: devCenter.id + devCenterResourceId: devCenter.outputs.resourceId tags: tags } } @@ -107,7 +109,7 @@ module managedDevOpsPool 'br/public:avm/res/dev-ops-infrastructure/pool:0.7.1' = // ── Outputs ────────────────────────────────────────────────────────────────── -output devCenterId string = devCenter.id +output devCenterId string = devCenter.outputs.resourceId output devCenterProjectId string = devCenterProject.outputs.resourceId output poolId string = managedDevOpsPool.outputs.resourceId output poolName string = managedDevOpsPool.outputs.name From ddafcbde6fbc61ec30954cf330b67fb3f213e6f1 Mon Sep 17 00:00:00 2001 From: Andy Wood <135889941+awood-ops@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:15:46 +0100 Subject: [PATCH 18/18] fix: use native ARM resource for MDOP (AVM pool module abstracts away organizationProfile/agentProfile) --- bicep/main.bicep | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/bicep/main.bicep b/bicep/main.bicep index 27bbe50..412f3bd 100755 --- a/bicep/main.bicep +++ b/bicep/main.bicep @@ -72,11 +72,13 @@ module devCenterProject 'br/public:avm/res/dev-center/project:0.1.2' = { // ── Managed DevOps Pool ────────────────────────────────────────────────────── -module managedDevOpsPool 'br/public:avm/res/dev-ops-infrastructure/pool:0.7.1' = { - name: 'managedDevOpsPool' - params: { - name: poolName - location: location +// avm/res/dev-ops-infrastructure/pool abstracts away organizationProfile/agentProfile/fabricProfile +// Use native resource to preserve full MDOP configuration +resource managedDevOpsPool 'Microsoft.DevOpsInfrastructure/pools@2024-10-19' = { + name: poolName + location: location + tags: tags + properties: { devCenterProjectResourceId: devCenterProject.outputs.resourceId maximumConcurrency: maximumConcurrency agentProfile: agentLifecycle == 'Stateless' @@ -103,7 +105,6 @@ module managedDevOpsPool 'br/public:avm/res/dev-ops-infrastructure/pool:0.7.1' = ] storageProfile: { osDiskStorageAccountType: 'Standard' } } - tags: tags } } @@ -111,5 +112,5 @@ module managedDevOpsPool 'br/public:avm/res/dev-ops-infrastructure/pool:0.7.1' = output devCenterId string = devCenter.outputs.resourceId output devCenterProjectId string = devCenterProject.outputs.resourceId -output poolId string = managedDevOpsPool.outputs.resourceId -output poolName string = managedDevOpsPool.outputs.name +output poolId string = managedDevOpsPool.id +output poolName string = managedDevOpsPool.name