diff --git a/.github/workflows/mdp.module.yml b/.github/workflows/mdp.module.yml new file mode 100644 index 0000000..a8fcb72 --- /dev/null +++ b/.github/workflows/mdp.module.yml @@ -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 diff --git a/README.md b/README.md index da5cbea..4fe60d9 100755 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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. diff --git a/bicep/main.bicep b/bicep/main.bicep index 05be635..412f3bd 100755 --- a/bicep/main.bicep +++ b/bicep/main.bicep @@ -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 @@ -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' @@ -103,7 +105,6 @@ module managedDevOpsPool 'br/public:avm/res/dev-ops-infrastructure/pool:0.2.0' = ] storageProfile: { osDiskStorageAccountType: 'Standard' } } - tags: tags } } @@ -111,5 +112,5 @@ module managedDevOpsPool 'br/public:avm/res/dev-ops-infrastructure/pool:0.2.0' = 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 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" } } } 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 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' + } +}