Deploying Azure Policy Definitions via Azure DevOps (Part 3)
This is the 3rd and final installment of the 3-part blog series. You can find the other parts here:
Part 1: Custom deployment scripts for policy and initiative definitions
Part 2: Pester-test policy and initiative definitions in the build pipeline
Part 3: Configuring build (CI) and release (CD) pipelines in Azure DevOps
In this part, I will walk through how I configured the build and release pipelines for deploying policy and initiative definitions at scale.
Pre-requisites
The following pre-requisistes are required before start creating the pipelines:
1. Creating Azure AD Service Principals
We need to create service principals in each Azure AD tenant that you are deploying the definitions to. The Service Principals need to have permission to deploy policy and initiative definitions to the target management groups or subscriptions. the minimum Azure RBAC role required is Resource Policy Contributor. You can assign the RBAC role to the management group or subscription that you are deploying the definitions to, or to a parent management group. For me, since I will also use the same service principal in other pipelines, I have assigned Owner role on the Tenant Root management group.
2. Publish AzPolicyTest and Pester modules to an Azure Artifacts feed
The AzPolicyTest PowerShell module is required to perform pester tests in the build pipeline. both AzPolicyTest and its dependency Pester need to be publish to an Azure Artifacts feed. this is explained in details in part 2.
Storing the definition and deployment scripts in Azure Repo
Before creating the pipelines, firstly, create an Azure repo in your DevOps project and store the definition files and deployment scripts in the repo.
Again, everything I used in this blog post is stored in my public GitHub repo: https://github.com/tyconsulting/azurepolicy, although the folder structure is slightly different in the Azure Repo I created. This is how I structured the files in the Azure Repo (as explained in part 1):
The reason I’m placing everything under the “tenant-root-mg” folder is that all these definitions are going to the tenant root management group in each tenant, I am planning to add additional definitions targeting child management groups later. by placing them in different folders, I can set triggers in build-pipeline to filter on the file path.
Creating Service Connections
you will need to create a service connection for each of the stage (environment) in the DevOps project. depending on the target, you may create the connection to a management group, or to a subscription. Use the service principal created earlier, and make sure you verify the connection before continuing to the next step.
Creating Variable Groups
I normally create a variable group that stores common variables that are consistent among all stages, and individual groups for values that are different in each stage.
I created a variable group called “common”, stored a variable called ArtifactsFeedName in it. As the name suggests, the value is the name of your Azure Artifacts feed name.
I also created a separate variable group for each stage, storing the target management group name in a variable called “MGName”:
Creating Build (CI) Pipeline
Here’s what the build pipeline looks like:
Steps:
1. Select an agent pool for the pipeline, I’m using Hosted Windows 2019 with VS2019
2. Link the common variable group to the pipeline.
3. Get sources – select the source from the Azure Repo
4. Create an agent job called “Test Policy and Initiative Definitions”
Make sure “Allow scripts to access the OAuth token” is ticked
This job contains a number of steps executing PowerShell scripts, and the final step publishes test results.
Yaml definition:
pool:
name: Hosted Windows 2019 with VS2019
steps:
- powershell: |
$colURI = [uri]::New("$(System.TeamFoundationCollectionUri)")
if ("$(System.TeamFoundationCollectionUri)" -match "visualstudio.com")
{
$org = $colURI.Authority.split('.')[0]
$feedURI = "https://pkgs.dev.azure.com/$org/_packaging/" + "$(ArtifactsFeedName)" + "/nuget/v2"
} else {
$pkgAuth = "pkgs.$($colURI.Authority)"
$feedURI = "https://$pkgAuth" + "$($colURI.AbsolutePath)" + "_packaging/" + "$(ArtifactsFeedName)" + "/nuget/v2"
}
Write-output "Azure Artifacts Feed URI: $feedURI"
Write-Output ("##vso[task.setvariable variable=feedURI]$($feedURI)")
displayName: 'Get Azure Artifact Feed URI'
- powershell: 'Register-PSRepository -Name "$(ArtifactsFeedName)" -SourceLocation "$(feedURI)" -PublishLocation "$(feedURI)" -InstallationPolicy Trusted'
displayName: 'Register PS repository for Azure Artifacts Feed'
- powershell: |
$pw = ConvertTo-SecureString '$(System.AccessToken)' -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential 'abc', $pw
Install-Module Pester -Repository $(ArtifactsFeedName) -Credential $cred -force -scope CurrentUser
Install-Module AzPolicyTest -Repository $(ArtifactsFeedName) -Credential $cred -force -scope CurrentUser
displayName: 'Install required PowerShell modules'
- powershell: |
Import-Module AzPolicyTest
Test-JSONContent -path $(Build.SourcesDirectory)\tenant-root-mg\policy-definitions -OutputFile $(Build.SourcesDirectory)\TEST-tenant-root-mg-Policy.JSCONContent.XML
Test-AzPolicyDefinition -Path $(Build.SourcesDirectory)\tenant-root-mg\policy-definitions -OutputFile $(Build.SourcesDirectory)\TEST-tenant-root-mg-PolicyDefinition.XML
errorActionPreference: continue
displayName: 'Pester Test Azure Policy Definitions'
- powershell: |
Import-Module AzPolicyTest
Test-JSONContent -path $(Build.SourcesDirectory)\tenant-root-mg\initiative-definitions -OutputFile $(Build.SourcesDirectory)\TEST-tenant-root-mg-Initiative.JSCONContent.XML
Test-AzPolicySetDefinition -Path $(Build.SourcesDirectory)\tenant-root-mg\initiative-definitions -OutputFile $(Build.SourcesDirectory)\TEST-tenant-root-mg-InitiativeDefinition.XML
errorActionPreference: continue
displayName: 'Pester Test Azure Policy Initiative Definitions'
- task: PublishTestResults@2
displayName: 'Publish Test Results **\TEST-*.xml'
inputs:
testResultsFormat: NUnit
testResultsFiles: '**\TEST-*.xml'
failTaskOnFailedTests: true
5. Create Another agent Job called Publish Artifacts
Yaml Definition:
dependsOn: Job_1
pool:
name: Hosted Windows 2019 with VS2019
steps:
- task: CopyFiles@2
displayName: 'Copy Files to: $(Build.ArtifactStagingDirectory)'
inputs:
SourceFolder: '$(Build.SourcesDirectory)'
TargetFolder: '$(Build.ArtifactStagingDirectory)'
CleanTargetFolder: true
OverWrite: true
6. Setup triggers:
I enabled continuous integration, and filtered on the path:
Create Release (CD) Pipeline
In my demo environment, I have created 2 stages, for 2 separate Azure AD tenants:
Steps:
1. Select Artifacts (from the build pipeline):
2. Create a stage:
3. Link the stage-specific variable group that contains the MGName to this stage:
4. In the stage, create an agent job called “Deploy Policy Definition”
It contains a single step that uses Azure PowerShell task to bulk deploy policy definitions to the target management group (or subscription). Here’s the Yaml definition for the “Deploy Policy Definitions to MG” task:
steps:
- task: AzurePowerShell@4
displayName: 'Deploy Policy Definitions to MG'
inputs:
azureSubscription: 'mg-tenant-root-ty-lab'
ScriptPath: '$(System.DefaultWorkingDirectory)/_Azure.Policy-CI/drop/scripts/deploy-policyDef.ps1'
ScriptArguments: '-folderpath "$(System.DefaultWorkingDirectory)/_Azure.Policy-CI/drop/tenant-root-mg/policy-definitions" -recurse -managementGroupName "$(MGName)" -silent'
FailOnStandardError: true
azurePowerShellVersion: LatestVersion
Note: You must use version 4 of this task because previous versions don’t support the new Azure PowerShell Az modules, and make sure you select the correct Service Connection (that you created earlier)
5. Create another agent job to deploy initiative definitions.
Make sure it’s configured to only run when all previous jobs have succeeded. This ensures all custom policy definitions are deployed before you group them into initiatives (potentially):
For reach Initiative definition, create an Azure PowerShell task (version 4). The Yaml definition for the step is similar to this:
steps:
- task: AzurePowerShell@4
displayName: 'Initiative: resource-diag-settings-LA'
inputs:
azureSubscription: 'mg-tenant-root-ty-lab'
ScriptPath: '$(System.DefaultWorkingDirectory)/_Azure.Policy-CI/drop/scripts/deploy-policySetDef.ps1'
ScriptArguments: '-definitionFile "$(System.DefaultWorkingDirectory)/_Azure.Policy-CI/drop/tenant-root-mg/initiative-definitions/resource-diagnostics-settings/log-analytics/azurepolicyset-la.json" -managementGroupName "$(MGName)" -PolicyLocations @{policyLocationResourceId1 = ''/providers/Microsoft.Management/managementGroups/$(MGName)'} -silent'
FailOnStandardError: true
azurePowerShellVersion: LatestVersion
6. Repeat Step 5 for each initiative definition that you wish to deploy
7. Repeat step 2-6 for each stage, and chain the stages together
8. You might want to setup pre-approval for certain stages (i.e. production stage)
Executing the pipelines:
Build:
Release:
Conclusion
Please only use the above steps as a reference since each environment is different. But the pipelines are pretty easy to setup. For me, the most time consuming part is to develop the deployment scripts (part 1), and the Pester test module (part 2).
In this demo, I have deployed 100+ custom policy and 3 initiative definitions to 2 tenant root management groups in 2 separate Azure AD tenants.
In the build pipeline, I was able to use a single command to pester test syntax of all policy definitions and another one-liner for all initiative syntax. It can’t get easier than that.
By using the custom deployment scripts, I managed to deploy all the policy and initiatives definitions to a management group in around 2 and half minutes. Previously, when I was using ARM template to deploy around 50 definitions (for resource diagnostic settings), it took a lot longer than that.
I have spent couple of weeks working on this solution, I though I’m obligated to share my experience with the greater community. I hope you’ll find this blog series helpful. I am certainly going to re-use the code I shared here in the future.
Leave a comment