Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9dc20e2
feat: add MDP defaults e2e test bicep
awood-ops Jun 30, 2026
0ab3641
feat: add MDP defaults Pester v5 assertions
awood-ops Jun 30, 2026
3c93f03
feat: add cost estimate script for MDP
awood-ops Jun 30, 2026
483dc05
ci: add MDP e2e trigger workflow (PSRule + defaults)
awood-ops Jun 30, 2026
8f2560d
docs: update README with e2e testing and CI details
awood-ops Jun 30, 2026
99c72c7
refactor: remove per-repo cost estimate script — now inlined in platf…
awood-ops Jun 30, 2026
5fd424c
docs: remove cost estimate script reference (now centralised in platf…
awood-ops Jun 30, 2026
c83fbd1
ci: trigger CI run after platform-workflows made public
awood-ops Jun 30, 2026
896bda6
ci: gate MDP e2e to workflow_dispatch only — requires AZURE_DEVOPS_OR…
awood-ops Jun 30, 2026
2c95ab1
fix: add permissions id-token: write — required for OIDC in reusable …
awood-ops Jun 30, 2026
d2718c7
fix: download Bicep CLI to /tmp to avoid clash with bicep/ directory
awood-ops Jun 30, 2026
278edb3
fix: run bicep restore before bicep build (BCP192 registry types)
awood-ops Jun 30, 2026
00e5910
fix: restore all bicep module files to resolve BCP192 (AVM registry d…
awood-ops Jun 30, 2026
fd5a9aa
fix: correct br/public alias modulePath to 'bicep' (AVM modules are a…
awood-ops Jun 30, 2026
521b056
fix: replace unpublished avm/res/dev-center/dev-center with native AR…
awood-ops Jul 1, 2026
dbac3eb
fix: devCenterResourceId param name + bump pool module to 0.7.1 (v0.2…
awood-ops Jul 1, 2026
dc37d25
fix: use correct AVM module path dev-center/devcenter:0.1.2 (was dev-…
awood-ops Jul 1, 2026
ddafcbd
fix: use native ARM resource for MDOP (AVM pool module abstracts away…
awood-ops Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/mdp.module.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: MDP Module -- e2e Tests

on:
push:
branches: [main]
paths:
- 'bicep/**'
- 'tests/**'
- '.github/workflows/mdp.module.yml'
pull_request:
paths:
- 'bicep/**'
- 'tests/**'
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

# 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
psrule:
name: PSRule for Azure
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Bicep CLI
run: |
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: |
find bicep -name '*.bicep' | xargs -I{} bicep restore {}
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 -- 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
run_azqr: ${{ inputs.run_azqr || false }}
run_cost_estimate: ${{ inputs.run_cost_estimate || false }}
secrets: inherit
163 changes: 97 additions & 66 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,77 +1,56 @@
# 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-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
```

Expand All @@ -87,34 +66,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.
21 changes: 11 additions & 10 deletions bicep/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ param tags object = {

// ── Dev Center ───────────────────────────────────────────────────────────────

module devCenter 'br/public:avm/res/dev-center/dev-center:0.1.4' = {
module devCenter 'br/public:avm/res/dev-center/devcenter:0.1.2' = {
name: 'devCenter'
params: {
name: devCenterName
Expand All @@ -65,18 +65,20 @@ module devCenterProject 'br/public:avm/res/dev-center/project:0.1.2' = {
params: {
name: devCenterProjectName
location: location
devCenterId: devCenter.outputs.resourceId
devCenterResourceId: devCenter.outputs.resourceId
tags: tags
}
}

// ── Managed DevOps Pool ──────────────────────────────────────────────────────

module managedDevOpsPool 'br/public:avm/res/dev-ops-infrastructure/pool:0.2.0' = {
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'
Expand All @@ -103,13 +105,12 @@ module managedDevOpsPool 'br/public:avm/res/dev-ops-infrastructure/pool:0.2.0' =
]
storageProfile: { osDiskStorageAccountType: 'Standard' }
}
tags: tags
}
}

// ── Outputs ──────────────────────────────────────────────────────────────────

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
2 changes: 1 addition & 1 deletion bicepconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"br": {
"public": {
"registry": "mcr.microsoft.com",
"modulePath": "bicep/modules"
"modulePath": "bicep"
}
}
}
Expand Down
51 changes: 51 additions & 0 deletions tests/e2e/defaults/main.test.bicep
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading