How to deploy Azure policy: The DevOps way

In this first part of this series of blogs, you will delve into deploying Azure Policy the “DevOps way”. You’ll start by exploring the Enterprise Azure Policy as Code (also known as EPAC) tool and set up the project structure for future growth. Later, you’ll create an Azure Policy that follows the naming convention on Resource Groups. The result can then be exported to your project and managed by EPAC. The last section will cover basic validation of the files produced by EPAC.

Hang on tight, you’re going to cover a lot of ground and see a ton of coding examples. Let’s get it started.

Azure Policy, a feature in Azure that enforces organizational standards and assesses compliance. Many large organizations implement Azure Policy from top-level management groups.

Every large enterprise has an organizational chart. Wether they call it a division, domain, department, or anything else on the matter, it will probably reflect back in the management groups created in Azure. For example, take a look at the following image when management groups became generally available:

Figure 1: Hierarchy example using Management Groups

Rabobank follows the same principle, but of course with a different organizational hierarchy in Azure. Each domain gets one or two subscriptions to deploy its Azure resources. Subscriptions are provided by a large team of Platform Engineers, which also enforce the Azure Policies from the top-level Management Groups.

The question then becomes, can teams within the domain still leverage Azure Policy to enforce their domain standards? Glad you asked! Azure Policy is not limited to Management Groups. You can scope Azure Policy on Subscription-level.

Getting started

Before diving into the nitty-gritty things of Azure Policy, you need to make sure you have the following in-place:

  • An Azure DevOps Services account
  • At least one (preferable two) Azure Subscription available
  • PowerShell v7 or above, in this series, v7.3.8 is used
  • The Az PowerShell module, preferably 9.3+ or above.
  • Pester PowerShell module, in this series v5.5.0 is used

Enterprise Azure Policy as Code

You might already have seen the term being flipped, Enterprise (Azure) Policy as Code (also known as EPAC). Policy as Code is used to define and manage your Azure Policies as Code. Azure Policies are written in JSON format and can be extracted also in such way. Still, you need a tool that can integrate CI/CD capabilities to deploy your Policies, Initiatives (or Policy Sets), Assignments (Policy or Role), and Exemptions. That’s where Microsoft has developed in partnership with S&C4CI, a PowerShell module called EnterprisePolicyAsCode.

To install the PowerShell module, use the following cmdlet:

Install-PSResource EnterPrisePolicyAsCode -Repository PSGallery -Scope CurrentUser 

If you’re using still using PowerShellGet v2, use the Install-Module cmdlet

Following the installation of the PowerShell module, set up the basic project layout structure that will hold your policy objects.

$RootDir = 'C:\internalRepo' 
$ProjectName = 'EnterprisePolicyAsCode' 
 
$ProjectDir = Join-Path $RootDir $ProjectName 
$SourceDir = Join-Path $ProjectDir 'src' 
 
New-Item -Path $ProjectDir -ItemType Directory 
New-Item -Path $SourceDir -ItemType Directory 
 
# Create EPAC definition folder to hold your policy objects, in this example naming 
New-EPACDefinitionFolder -DefinitionsRootFolder (Join-Path $SourceDir 'Naming') 

When it’s finished, some subfolders were created, including a configuration file.

Figure 2: Project structure layout including EPAC subfolders

The subfolder structure speaks quite for itself. The configuration file that has been created needs to be modified according to your environment. Replace the sections in the example below.

{ 
    "$schema": "https://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/global-settings-schema.json", 
    "pacOwnerId": "<guid>", // Generate a guid using (New-Guid).Guid | Clip and place it here 
    "telemetryOptOut": false, 
    "managedIdentityLocations": { 
        "*": "<location>" // Update the default location for managed identities when used in remediation tasks 
    }, 
    "globalNotScopes": { 
        "*": [ 
            "/resourceGroupPatterns/excluded-rg*" // defines scopes not subjected to your Policy Assignments 
        ] 
    }, 
    "pacEnvironments": [ 
        { 
            "pacSelector": "epac-sandbox", 
            "cloud": "AzureCloud", 
            "tenantId": "<tenantId>", // Replace this with your tenant Id 
            "deploymentRootScope": "/subscriptions/<subscriptionId>", // Replace this with a valid subscription id 
            "desiredState": { 
                "strategy": "ownedOnly" 
            } 
        } 
    ] 
} 

It’s a fair question to ask why not store all policies objects in the src folder, especially if you’re aware of how the public azure-policy repository looks like? Both approaches have their pros and limitations. When managing all your policy objects in big chunks, it’s difficult to integrate it into a DevOps pipeline. It also doesn’t allow you to test each Policy Set definition separately, as you’ll learn later.

You now have set up your project structure. Now you need a policy to work with. Let’s start by creating your own naming convention policy in the sandbox environment.

Create naming convention policy for Azure resources

Microsoft has already provided a ton of details on naming recommendations. Unthinkingly you can adopt these naming recommendations, but you can also throw in your own sauce over it. Take for example the naming pattern for a Resource Group:

rg-<department>-<application>-<environment>

This will end up in something like:

  • rg-marketing-application-dev
  • rg-marketing-application-prd

Knowing this information at hand allows you to create the required policy for it.

  1. Open the Azure Portal
  2. Search for Policy in the search box
  3. Click on Definitions in the Authoring section -> + Policy definition
  4. Select Subscription in Definition location
  5. Create a meaningful name, in this example [Naming-Convension-001] Resource Group is used
  6. Add the description and use the existing category Naming
  7. In the editor field, add the following content
{
  "mode": "All",
  "policyRule": {
      "if": {
        "allOf": [
          {
            "field": "type",
          "equals": "Microsoft.Resources/subscriptions/resourceGroups"
        },
        {
            "anyOf": [
              {
                "field": "name",
              "notLike": "rg-marketing-*"
            },
            {
                "allOf": [
                  {
                    "field": "name",
                  "notLike": "*-dev"
                },
                {
                    "field": "name",
                  "notLike": "*-prd"
                }
              ]
            }
          ]
        },
        {
            "anyOf": [
              {
                "field": "name",
              "notLike": "databricks-rg-marketing-*"
            },
            {
                "allOf": [
                  {
                    "field": "name",
                  "notLike": "*-dev"
                },
                {
                    "field": "name",
                  "notLike": "*-prd"
                }
              ]
            }
          ]
        },
        {
            "anyOf": [
              {
                "field": "name",
              "notLike": "synapseworkspace-rg-marketing-*"
            },
            {
                "allOf": [
                  {
                    "field": "name",
                  "notLike": "*-dev"
                },
                {
                    "field": "name",
                  "notLike": "*-prd"
                }
              ]
            }
          ]
        }
      ]
    },
    "then": {
        "effect": "[parameters('effect')]"
    }
  },
  "parameters": {
      "effect": {
        "type": "String",
      "metadata": {
          "displayName": "Effect of Policy",
        "description": "Effect of Policy, such as 'deny'"
      },
      "defaultValue": "deny"
    }
  }
}
Figure 3: Create Policy Definition in Azure Portal
Figure 3: Create Policy Definition in Azure Portal

8. Press Save to save the Policy Definition in Azure

You can now create an initiative definition that holds the collection of Policy Definitions. For the sake of the series, you’ll be working with only one Policy Definition. If you already have many Definitions that are tailored around in achieving the same goal, in this case naming conventions, you can add them to the Initiative definition.

  1. Back in the Authoring > Definitions > Click Initiative definition
  2. Select the same Subscription in previous step
  3. Add the following content
Figure 4: Create Policy Set Definition (also known as Initiative)
Figure 4: Create Policy Set Definition (also known as Initiative)
  1. In the Policies section, make sure you add the Policy Definition
Figure 5: Add Policy Definition in Initiative
Figure 5: Add Policy Definition in Initiative
  1. Add the following initiative parameter to map to Policy Definition parameter
Figure 6: Create initiative parameter
Figure 6: Create initiative parameter
  1. Map the initiative parameter in Policy parameters
Figure 7: Map initiative parameter to Policy parameter
Figure 7: Map initiative parameter to Policy parameter

When you map parameters in your Initiative definition, it provides you with the ability to set different values across your subscriptions. For example, on your development subscription, it might not be needed to follow a strict naming convention, whereas for production it does.

You can now create and assign the Initiative definition to apply your Azure Policy.

Managing Azure Policy through EPAC

It’s now time to extract the policy and utilize EPAC to handle Azure Policy deployments through your environments. To extract the policy that you just created, use the following cmdlet.

Connect-AzAccount

Set-Location C:\internalRepo\EnterprisePolicyAsCode

Export-AzPolicyResources -DefinitionsRootFolder C:\internalRepo\EnterprisePolicyAsCode\src\Naming -OutputFolder Output

This will extract the policy that was created through the Portal, and output everything in JSON format.

Figure 8: Extracted Policy Definition, Set Definition, and Initiative assignment
Figure 8: Extracted Policy Definition, Set Definition, and Initiative assignment

From here onwards, you can copy the output files into the appropriate folders and let EPAC build a plan for policy deployment. This plan can be deployed for each environment it gets created from. That’s where the pacEnvironment value comes into play in the global-settings.jsonc file.

 

$Files = Get-ChildItem output -Filter *.jsonc -Recurse
$SourceDirectory = Join-Path 'src' 'naming'
foreach ($File in $Files)
{
      $Folder = Split-Path (Split-Path $File -Parent) -Leaf
    if ($Folder -notlike "*policy*") {
          # Triple split because of category
        $Folder = Split-Path (Split-Path (Split-Path $file -Parent) -Parent) -Leaf
    }
    $Destination = Join-Path $SourceDirectory $Folder 
    Write-Host -Object ("Moving file {0} to destination {1}" -f $File, $Destination)
    Move-Item -Path $File -Destination $Destination -Force
}
Remove-Item -Path Output -Force -Recurse

To test out the process, remove the Policy Definition, Initiative Definition, and Initiative Assignment.

$SourceDirectory = Join-Path 'src' 'naming'

$AssignmentName = (Get-ChildItem -Path (Join-Path $SourceDirectory 'policyAssignments') | Get-Content -Raw | ConvertFrom-Json -AsHashtable).assignment.name

Remove-AzPolicyAssignment -Name $AssignmentName

$PolicySetDefinitionName = (Get-ChildItem -Path (Join-Path $SourceDirectory 'policySetDefinitions') | Get-Content -Raw | ConvertFrom-Json -AsHashtable).name

Remove-AzPolicySetDefinition -Name $PolicySetDefinitionName 

$PolicyDefinitionName = (Get-ChildItem -Path (Join-Path $SourceDirectory 'policyDefinitions') | Get-Content -Raw | ConvertFrom-Json -AsHashtable).name

Remove-AzPolicyDefinition -Name $PolicyDefinitionName 

Give it a minute to finish cleaning up. If everything has been removed, run the following to generate a plan for deployment

Build-DeploymentPlans -PacEnvironmentSelector epac-sandbox
-DefinitionsRootFolder $SourceDirectory -OutputFolder
$env:PAC_OUTPUT_FOLDER

As you’ll notice in the output, EPAC has detected 3 new changes in your environment.

Figure 9: New changes detected by EPAC
Figure 9: New changes detected by EPAC

Fire up the Deploy-PolicyPlan cmdlet to let EPAC manage your policy objects.

Deploy-PolicyPlan -PacEnvironmentSelector epac-sandbox
-DefinitionsRootFolder $SourceDirectory
Figure 10: Deploy changes using EPAC
Figure 10: Deploy changes using EPAC

If you check in the Azure Portal, the policy objects have been deployed by EPAC. Fantastic!

Basic validation testing on policy objects

Before closing the first part of this series, it’s important to implement some basic validation testing on policy objects. As you’ve learned to extract existing policies from Azure, it’s also possible to manually create them. EPAC supports a $schema tag on JSON files introduced in your repository.

{
  "$schema": "https://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/policy-definition-schema.json"
}

This allows the flexibility to develop either through your favorite editor, or extract existing policies created through the Azure Portal. Nevertheless, the method you choose, validating the JSON files that have been created, including their structure, is useful. It’s useful to validate the JSON files that you create and their structure. To do so, you can leverage Pester, the testing framework for PowerShell.

  1. Open the PowerShell terminal
  2. Run the following cmdlets in sequence
Set-Location C:\internalRepo\EnterprisePolicyAsCode

# Test folder structure
New-Item tests -ItemType Directory
New-Item tests\Unit -ItemType Directory 

# Create test files
New-Item -Path tests\unit -Name FileContent.Tests.ps1 -ItemType File
New-Item -Path tests\unit -Name PolicyAssignmentStructure.Tests.ps1 -ItemType File
New-Item -Path tests\unit -Name PolicyDefinitionStructure.Tests.ps1 -ItemType File
New-Item -Path tests\unit -Name PolicySetDefinitionStructure.Tests.ps1 -ItemType File

# Utility folder structure
New-Item -Path utilities\tools -ItemType Directory
New-Item -Path utilities\tools -Name "Invoke-PesterWrapper.ps1" -ItemType File
  1. Open the FileContent.Tests.ps1 file and add the content below to test if the JSON is valid
param(
    [Parameter(Mandatory = $false)]
    [string]
    $ResourceType,

    [Parameter(Mandatory = $false)]
    [string]
      $RepoRootPath = (Get - Item $PSScriptRoot).Parent.Parent.FullName

)

Write - Verbose("Root: $repoRootPath") - Verbose

BeforeDiscovery {
    $Path = Join - Path $RepoRootPath 'src' $ResourceType

    $Files = (Get - ChildItem - Path $Path - Recurse - File - Exclude "global-settings.jsonc").FullName
}


Describe "File exist" {
      BeforeAll {
        $Path = Join - Path $RepoRootPath 'src' $ResourceType
        $Files = (Get - ChildItem - Path $Path - Recurse - File - Exclude "global-settings.jsonc").FullName
    }

    Context "Json file should exist" {
              It "File count should be greater than 0" {
            $Files.Count | Should - Not - Be 0
        }
    }
}

Describe "File - <file>" - ForEach $Files {
      BeforeAll {
        $File = $_
    }
    Context "'<file>' JSON syntax test" {
          It "Should be a valid JSON file" {
            $FileContent = Get - Content - Path $File - Raw
            ConvertFrom - Json - InputObject $FileContent - ErrorVariable ParseError
            $ParseError | Should - Be $Null
        }
    }
}
  1. Open PolicyAssignmentStructure.tests.ps1 and add the following to check if the scope count has been set
[Parameter(Mandatory = $false)]
[string]
$ResourceType,
    [Parameter(Mandatory = $false)]
    [string]
$RepoRootPath = (Get - Item $PSScriptRoot).Parent.Parent.FullName
    )
Write - Verbose("Root: $repoRootPath") - Verbose
    BeforeDiscovery {
    $Path = Join - Path $RepoRootPath 'src' $ResourceType
    $GlobalSettingFile = Join - Path $Path 'global-settings.jsonc'
    $Files = ((Get - ChildItem - Path $Path - Recurse - File - Exclude "global-settings.jsonc") | Where - Object { $_.DirectoryName - like "*policyAssignments" }).FullName
}
    Describe "File - <file>" - ForEach $Files {
          BeforeAll {
        $File = $_
        if (-not $SettingsJson) {
            $GlobalSettingFile = Join - Path(Split - Path(Split - Path - Path $File - Parent) - Parent) 'global-settings.jsonc'
            $SettingsJson = ConvertFrom - Json(Get - Content $GlobalSettingFile - Raw) - ErrorAction SilentlyContinue
        }
        $Json = Get - Content - Path $File - Raw | ConvertFrom - Json - ErrorAction SilentlyContinue
    }
                Context "'<file>' Required scope attributes" {
                    It "Should match the amount of scope attributes" {
            $SettingsCount = $SettingsJson.pacEnvironments.Count
            $JsonScopeCount = ($Json.scope | Get - Member | Where - Object { $_.MemberType - eq 'NoteProperty' }).Count
            $JsonScopeCount | Should - Be $SettingsCount
        }
    }
}
  1. Open _PolicyDefinitionStructure.tests.ps1 and add the following to check if the property elements are correctly set
param(
    [Parameter(Mandatory = $false)]
    [string]
    $ResourceType,

    [Parameter(Mandatory = $false)]
    [string]
    $RepoRootPath = (Get - Item $PSScriptRoot).Parent.Parent.FullName

)

Write - Verbose("repoRootPath: $repoRootPath") - Verbose

BeforeDiscovery {
    $Path = Join - Path $RepoRootPath 'src' $ResourceType

    $Files = ((Get - ChildItem - Path $Path - Recurse - File - Exclude "global-settings.jsonc") | Where - Object { $_.DirectoryName - like "*policyDefinitions" }).FullName
}

Describe "File - <file>" - ForEach $Files {
      BeforeAll {
        $File = $_

        $Json = Get - Content - Path $File - Raw | ConvertFrom - Json - ErrorAction SilentlyContinue
    }
    Context "'<file>' Required top-level elements test" {
          It "Should contain top-level element - 'name'" {
            $Json.psobject.properties.name - match 'name' | Should - Not - Be $Null
        }
        It "Should contain top-level element - 'properties'" {
            $json.PSobject.Properties.name - match 'properties' | Should - Not - Be $Null
        }
    }

    Context "Policy Definition Properties Value Test" {
          It "Properties must contain 'displayName' element" {
            $json.properties.PSobject.Properties.name - match 'displayName' | Should - Not - Be $Null
        }
        It "Properties must contain 'description' element" {
            $json.properties.PSobject.Properties.name - match 'description' | Should - Not - Be $Null
        }
        It "Properties must contain 'metadata' element" {
            $json.properties.PSobject.Properties.name - match 'metadata' | Should - Not - Be $Null
        }
        It "Properties must contain 'parameters' element" {
            $json.properties.PSobject.Properties.name - match 'parameters' | Should - Not - Be $Null
        }
        It "Properties must contain 'policyRule' element" {
            $json.properties.PSobject.Properties.name - match 'policyRule' | Should - Not - Be $Null
        }
        It "'DisplayName' value must not be blank" {
            $json.properties.displayName.length | Should - BeGreaterThan 0
        }
        It "'Description' value must not be blank" {
            $json.properties.description.length | Should - BeGreaterThan 0
        }
        It "Must contain 'Category' metadata" {
            $json.properties.metadata.category.length | Should - BeGreaterThan 0
        }
    }

    Context "Policy Rule Test" {
          It "Policy Rule must contain 'if' element" {
            $json.properties.policyRule.PSobject.Properties.name - match 'if' | Should - Not - Be $Null
        }
        It "Policy Rule must contain 'then' element" {
            $json.properties.policyRule.PSobject.Properties.name - match 'then' | Should - Not - Be $Null
        }
        It "Policy Rule must use a valid effect" {
            'Modify', 'Deny', 'Audit', 'Append', 'AuditIfNotExists', 'DeployIfNotExists', 'Disabled' - match $json.properties.policyRule.then.effect | Should - Not - Be $Null
        }
    }

    if ($json.properties.policyRule.then.effect - ieq 'DeployIfNotExists') {
          Context "DeployIfNotExists Configuration Test" {
              It "Policy rule 'then' element Must contain a 'details' element" {
                $json.properties.policyRule.then.PSobject.Properties.name - match 'details' | Should - Not - Be $Null
            }
            It "DeployIfNotExists' Policy rule must contain a embedded 'deployment' element" {
                $json.properties.policyRule.then.details.PSobject.Properties.name - match 'deployment' | Should - Not - Be $Null
            }
            It "Deployment mode for 'DeployIfNotExists' effect must be 'incremental'" {
                $json.properties.policyRule.then.details.deployment.properties.mode - match 'incremental' | Should - Not - Be $Null
            }
            It "DeployIfNotExists' Policy rule must contain a 'roleDefinitionIds' element" {
                $json.properties.policyRule.then.details.PSobject.Properties.name - match 'roleDefinitionIds' | Should - Not - Be $Null
            }
            It "'roleDefinitionIds' element must contain at least one item" {
                $json.properties.policyRule.then.details.roleDefinitionIds.count | Should BeGreaterThan 0
            }
        }
        Context "DeployIfNotExists Embedded ARM Template Test" {
              It 'Embedded template Must have a valid schema' {
                $json.properties.policyRule.then.details.deployment.properties.template."`$schema" | Should - BeLike 'http://schema.management.azure.com/schemas/*'
            }
            It 'Embedded template Must contain a valid contentVersion' {
                $json.properties.policyRule.then.details.deployment.properties.template.contentVersion | Should - BeGreaterThan([version]'0.0.0.1')
            }
            It "Embedded template Must contain a 'parameters' element" {
                $json.properties.policyRule.then.details.deployment.properties.template.PSobject.Properties.name - match 'parameters' | Should - Not - Be $Null
            }
            It "Embedded template Must contain a 'variables' element" {
                $json.properties.policyRule.then.details.deployment.properties.template.PSobject.Properties.name - match 'variables' | Should - Not - Be $Null
            }
            It "Embedded template Must contain a 'resources' element" {
                $json.properties.policyRule.then.details.deployment.properties.template.PSobject.Properties.name - match 'resources' | Should - Not - Be $Null
            }
            It "Embedded template Must contain a 'outputs' element" {
                $json.properties.policyRule.then.details.deployment.properties.template.PSobject.Properties.name - match 'outputs' | Should - Not - Be $Null
            }
        }
    }
}
  1. Lastly, do the same for PolicySetDefinitionStructure.tests.ps1
param(
    [Parameter(Mandatory = $false)]
    [string]
    $ResourceType,

    [Parameter(Mandatory = $false)]
    [string]
    $RepoRootPath = (Get - Item $PSScriptRoot).Parent.Parent.FullName

)

Write - Verbose("repoRootPath: $repoRootPath") - Verbose

BeforeDiscovery {
    $Path = Join - Path $RepoRootPath 'src' $ResourceType

    $Files = ((Get - ChildItem - Path $Path - Recurse - File - Exclude "global-settings.jsonc") | Where - Object { $_.DirectoryName - like "*policySetDefinitions" }).FullName
}

Describe "File - <file>" - ForEach $Files {
      BeforeAll {
        $File = $_

        $Json = Get - Content - Path $File - Raw | ConvertFrom - Json - ErrorAction SilentlyContinue
    }
    Context "Required Top-Level Elements Test" {
          It "Should contain top-level element - name" {
            $Json.PSobject.Properties.name - match 'name' | Should - Not - Be $Null
        }
        It "Should contain top-level element - properties" {
            $Json.PSobject.Properties.name - match 'properties' | Should - Not - Be $Null
        }
    }

    Context "Policy Set Definition Elements Value Test" {
          It "Name value must not be null" {
            $Json.name.length | Should - BeGreaterThan 0
        }
        It "Name value must not be longer than 64 characters" {
            $Json.name.length | Should - BeLessOrEqual 64
        }
        It "Name value must not contain spaces" {
            $Json.name - match ' ' | Should - Be $false
        }
    }

    Context "Policy Definition Properties Value Test" {
          It "Properties must contain 'displayName' element" {
            $Json.properties.PSobject.Properties.name - match 'displayName' | Should - Not - Be $Null
        }
        It "Properties must contain 'description' element" {
            $Json.properties.PSobject.Properties.name - match 'description' | Should - Not - Be $Null
        }
        It "Properties must contain 'metadata' element" {
            $Json.properties.PSobject.Properties.name - match 'metadata' | Should - Not - Be $Null
        }
        It "Properties must contain 'parameters' element" {
            $Json.properties.PSobject.Properties.name - match 'parameters' | Should - Not - Be $Null
        }
        It "Properties must contain 'policyDefinitions' element" {
            $Json.properties.PSobject.Properties.name - match 'policyDefinitions' | Should - Not - Be $Null
        }
        It "'policyDefinitions' element must contain at least one item" {
            $Json.properties.policyDefinitions.count | Should - BeGreaterThan 0
        }
        It "'DisplayName' value must not be blank" {
            $Json.properties.displayName.length | Should - BeGreaterThan 0
        }
        It "'Description' value must not be blank" {
            $Json.properties.description.length | Should - BeGreaterThan 0
        }
        It "Must contain 'Category' metadata" {
            $Json.properties.metadata.category.length | Should - BeGreaterThan 0
        }
    }

    Context "policy Definitions Test" {
        $i = 0
        Foreach($policyDefinition in $Json.properties.policyDefinitions) {
            $i++
            It "Policy Definition #$i must contain 'policyDefinitionId' element" {
                $policyDefinition.PSobject.properties.name - match 'policyDefinitionId' | Should - Not - Be $null
            }
            It "'policyDefinitionId' in Policy Definition #$i must contain value" {
                $policyDefinition.policyDefinitionId.length | Should - BeGreaterThan 0
            }
        }
    }
}

To execute the unit tests, you would run the Invoke-Pester cmdlet. As you’re building the project to contain multiple resource types, it would be useful to test each type separately. That’s why in the above snippets, the -ResourceType parameter can be passed in.

  1. Open the Invoke-PesterWrapper.ps1 and add the content shown below to wrap Pester around configuration that can be parsed when calling the function
function Invoke-PesterWrapper {
    <#
        .SYNOPSIS
        Invoke Pester test in repository

    .DESCRIPTION
        The function Invoke-PesterWrapper invokes the available Pester test(s)

            .PARAMETER FilePath
    Optional.The file path to search for test(s)

        .PARAMETER ResourceType
        Optional.The resource type found in src folder e.g.naming

        .EXAMPLE
        PS C: \> Invoke - PesterWrapper - ResourceType naming

        Invokes the unit test under tests\Unit with naming as Resource Type

        .EXAMPLE
        PS C: \> Invoke - PesterWrapper - ResourceType naming - CI

        CI switch enabled to set environment variable for Azure DevOps

        .NOTES
        Tags: Pester, Testing
    Author: Gijs Reijn
    # >
        [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $false)]
        [string]
        $FilePath = "tests\Unit\*.tests.ps1",

        [Parameter(Mandatory = $false)]
        [string]
        $ResourceType,

        [Parameter(Mandatory = $false)]
        [string]
        $PesterOutputPrefix = 'UnitTest',

        [Parameter(Mandatory = $false)]
        [switch]
        $CI
    )

    begin {
        Write - Debug('{0} entered' - f $MyInvocation.MyCommand)
    }

    process {
        $RootPath = (Get - Item $PSScriptRoot).Parent.Parent
        $TestFiles = Join - Path - Path $RootPath - ChildPath $FilePath
        $OutputFile = Join - Path $RootPath 'testResults' "NUnit_$PesterOutputPrefix.xml"

        Write - Verbose "Running local test on '$TestFiles'"
        try {
            $ContainerData = @{
                Path = $TestFiles
            }

            if ($ResourceType) {
                $ContainerData.Add('Data', @{ ResourceType = $ResourceType })

                $OutputFile = Join - Path $RootPath 'testResults' "NUnit_$ResourceType_$PesterOutputPrefix.xml"
            }

            Write - Verbose 'Invoke test(s) with'
            Write - Verbose($ContainerData | ConvertTo - Json - Depth 5 | Out - String)

            $Container = New - PesterContainer @ContainerData
            # Pester run
            $Res = Invoke - Pester - Configuration @{
                Run        = @{
                    Container = $Container
                    PassThru  = $true
                }
                TestResult = @{
                    TestSuiteName = 'Unit tests'
                    OutputPath    = $OutputFile
                    OutputFormat  = 'NUnitXml'
                    Enabled       = $true
                }
                Output     = @{
                    Verbosity = 'Detailed'
                }
            } -ErrorAction Stop
        } catch {
            $PSItem.Exception.Message
        }

        Write - Verbose("Output results to '{0}'" - f $OutputFile)

        if ($CI) {
            Write - Host "##vso[task.setvariable variable=testResultsPath]$OutputFile"
            Write - Host "##vso[task.setvariable variable=testRunTitle]$PesterOutputPrefix"
        }

        return $Res
    }

    end {
        Write - Debug('{0} exited' - f $MyInvocation.MyCommand)
    }
}

To invoke the tests, you can dot-source the script. When it’s loaded, call Invoke-PesterWrapper -ResourceType Naming from the root of your repository.

Figure 11: Run basic validation test against JSON files
Figure 11: Run basic validation test against JSON files

Conclusion

You’ve now managed to set up the basic project structure for your Azure Policies. You’ve had your first glimpse at Enterprise Azure Policy as Code and used it to extract policy objects. These policy objects can now be managed by EPAC. EPAC creates a plan and deploys it throughout your environment. As everything is driven by cmdlets and scripts, you will be able to integrate it in a CICD system later. In the last section, you were introduced to the first basic validation on the policy objects by using Pester.

That’s it for the first part. There’s more to come in the next part!

References

About the author

Gijs Reijn
Cloud Engineer

Gijs Reijn is the DevOps Engineer at Rabobank’s ALM IT department. He primarily focusses on Azure DevOps, Azure and loves to automate processes including standardization around it. Outside working hours, he can be found in the early morning working out in the gym nearly every day, writes his own blog to share knowledge with the community and reading upon new ideas. He is also a writer on Medium.

Related articles

5 best practices for using Azure Bicep

  • 17 August 2022
  • 12 min
By Gijs Reijn

Juicing it up: testing best practices for Azure Bicep

  • 21 September 2022
  • 7 min
By Gijs Reijn

Stitching it together: pipeline best practices for Azure Bicep

  • 24 October 2022
  • 8 min
By Gijs Reijn