All Articles

Post deployment scripts with Bicep, template specs and Azure DevOps

Everybody that creates Bicep modules and deploys them through Azure Pipelines, faced challenges where it was required to do some post deployment activity. Probably you have solved the issue by either running an Azure CLI task, or Azure PowerShell task to do such activity. While this has it’s benefits, in this blog you’ll learn about a different approach by leveraging the deploymentScripts resource. Later in the blog, you are going to publish it as a template spec, which can be consumed using the Service Principal in Azure Pipelines giving you the benefit to version your modules. Of course, if you want to follow along, you are going to need some pre-requisites

Pre-requisites

To follow along, have the following available to run through the tutorial:

  • An Azure DevOps account
  • An Azure account
  • Azure Bicep CLI, in this blog v0.18.4 is used
  • A code editor like Visual Studio (VSCode)

Alright, if you are set, let’s dive into the deployment script resource.

Create deployment script resource

Before diving hardcore in some code snippets, first let’s review the Microsoft.Resources/deploymentScripts@2020-10-01 resource. This resources allows you to embed scripts in Bicep files directly. You can leverage both Azure Powershell or Azure CLI. You can perform custom steps that you might not be able to do with Bicep. Think of:

  • Data plane operations by copying blobs
  • Create objects in AAD
  • Create a bulk of users in a directory
  • Transition from Azure PowerShell or Azure CLI to Bicep

Now you have a short introduction on Microsoft.Resources/deploymentScripts@2020-10-01 resource, let’s start by creating a User-Assigned Managed Identity (UAMI for short). UAMI is responsible for calling Connect-AzAccount -Identity from the script service. Replace the values accordingly to your environment.

# Create user-assigned managed identity
New-AzUserAssignedIdentity -ResourceGroupName <resourceGroupName> -Name DS1 -Location <location>

# Add owner permission on target resource group
New-AzRoleAssignment -ResourceGroupName <targetResourceGroupName> -ObjectId <objectId> -RoleDefinitionName Owner

NOTE: Owner might be overkill for your environment, make sure you are doing it in a demo environment

Now that you’ve setup the identity, you can create a Bicep file. In this example, it is called deployment-scripts.bicep. Make sure that you replace the managed identity resource id with the one that was created in previous step.

param location string = resourceGroup().location
param arguments string = ''

resource AzurePowershellScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = {
  name: 'AzurePowershellScript'
  location: location
  kind: 'AzurePowerShell'
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: {
      '<managed identity resource id>': {}
    }
  }
  properties: {
    azPowerShellVersion: '9.7'
    retentionInterval: 'PT1H'
    scriptContent: loadTextContent('../Scripts/Add-AzRoleAssignment.ps1')
    arguments: arguments
  }
}

Create Scripts folder that will contain your Azure PowerShell scripts, with Add-AzRoleAssignment.ps1. Paste the below code in there and make sure you save it.

param (
    [string]$ResourceGroupName,
    [string]$ObjectId,
    [string]$RoleDefinitionName = 'Contributor'
)

$ErrorActionPreference = 'Stop'

try {
    $Params = @{
        ResourceGroupName  = $ResourceGroupName
        ObjectId           = $ObjectId
        RoleDefinitionName = $RoleDefinitionName
    }

    New-AzRoleAssignment @Params
}
catch {
    Write-Error $_.Exception.Message
}

Alright, you may think now, why are you not using the Microsoft.Authorization roleAssignment resource type in Bicep? You are absolutely right, but it’s for demo purposes to show case. You can definitely dump in other scripts if you want. Anyway, kick off the deployment when you’ve ran Connect-AzAccount in your PowerShell Session and run the following cmdlet.

New-AzResourceGroupDeployment -ResourceGroupName <targetResourceGroupName> -TemplateFile C:\repos\deployment-scripts-bicep\source\deployment-script.bicep -arguments "-ResourceGroupName <targetResourceGroupName> -ObjectId <userObjectId>"

When the command has finished, you will notice the Deployment Script resource created in your Resource Group.

deployment-script-resource Figure 1: Deployment Script resource in Resource Group

If you inspect the content, the script was successfully loaded into context including the dynamic arguments passed in.

script-content Figure 2: Content and inputs for AzurePowerShellScript

The challenge comes, as you might already have noticed, that the content has to be constant during compile-time. If you see the documentation, there are enough properties that you can use. Unfortunately, most of these properties point to a public address or you have to implement Storage Account keys, which might not be desirable in a secure environment. Therefore, you can implement a trick by dynamically loading in the script content and pass it to the scriptContent property. In the next section, you are going to prepare the Bicep code to do so, and include Service Principal authentication.

Prepare deployment scripts resource for dynamic content

To load content directly in the deployment script resource, you can easily get the raw content of a PowerShell script that you want to execute. This content can be parsed against the scriptContent property. It’s more easier if you see some code.

  1. Create a new file called deployment-script-spn.bicep in the source folder
  2. Add the content below and save the file
param scriptContent string
param arguments string
param location string = resourceGroup().location
param servicePrincipalId string
@secure()
param servicePrincipalKey string
param tenantId string = ''
param subscriptionId string

resource deploymentScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = {
  name: 'deploymentScript'
  location: location
  kind: 'AzurePowerShell'
  properties: {
    azPowerShellVersion: '9.7'
    retentionInterval: 'PT1H'
    environmentVariables: [
      {
        name: 'servicePrincipalId'
        value: servicePrincipalId
      }
      {
        name: 'servicePrincipalKey'
        secureValue: servicePrincipalKey
      }
      {
        name: 'tenantId'
        value: tenantId
      }
      {
        name: 'subscriptionId'
        value: subscriptionId
      }
    ]
    arguments: arguments
    scriptContent: scriptContent
    timeout: 'PT4H'
    cleanupPreference: 'Always'
  }
}

There is some interesting stuff with the above code. First, you can see the scriptContent parameter added to the property. 4 new parameters are added where you can specify a Service Principal. By now, you should already see a bit where this is going. You might already have a Service Connection setup in Azure DevOps. A Service Connection is mostly configured with the Service Principal Id, key, Subscription and Tenant Id. Before you are going to dive into the Azure DevOps part, let’s see if you can run the Bicep code already with a Service Principal. The deployment scripts resource doesn’t automatically connect when it runs, so you have to add this yourself.

  1. In Scripts folder, add a new file called Add-AzRoleAssignmentSpn.ps1
  2. Add the following code snippet from below
param (
    [string]$ObjectId,
    [string]$ResourceGroupName,
    [string]$RoleDefinition = 'Contributor'
)

try {
    # Region connect Service Principal
    Write-Host -Object ("Connection service principal id - {0}" -f $env:servicePrincipalId)
    $ServicePrincipalKey = ConvertTo-SecureString -String $env:servicePrincipalKey -AsPlainText -Force
    $AzureADCred = New-Object System.Management.Automation.PSCredential($env:servicePrincipalId, $ServicePrincipalKey)
    Connect-AzAccount -ServicePrincipal -Credential $AzureADCred -TenantId $env:tenantId -Subscription $env:subscriptionId
    # End region connect Service Principal

    Write-Host -Object ("Assigning role permission - {0}" -f $RoleDefinition)
    New-AzRoleAssignment -ObjectId $ObjectId -ResourceGroupName $ResourceGroupName -RoleDefinitionName $RoleDefinition
} catch {
    Write-Error $_.Exception.Message
}

In the above snippet, you noticed that the environment variables can be fetched and used to connect to Azure. Try running the same code, but this time dynamically retrieving the content and connecting with the Service Principal.

New-AzResourceGroupDeployment -ResourceGroupName <targetResourceGroupName> -TemplateFile C:\repos\deployment-scripts-bicep\source\deployment-script-spn.bicep -scriptContent (Get-Content "C:\repos\deployment-scripts-bicep\Scripts\Add-AzRoleAssignmentSpn.ps1" -Raw) -arguments "-ResourceGroupName <targetResourceGroupName> -ObjectId <userObjectId>" -servicePrincipalId <servicePrincipalId> -servicePrincipalKey (ConvertTo-SecureString -String "<key>" -AsPlainText -Force) -tenantId <tenantId> -subscriptionId <subscriptionId>

If you don’t have an SPN with the appropriate permissions, you can run az ad sp create-for-rbac --name "<spnName>" --role "Owner" --scopes "/subscriptions/<subscriptionId>"

If you inspect the Content and Inputs tab from the deployment script, you noticed that the Environment variables are populated. Also the script content is parsed correctly.

deployment-script-spn Figure 3: Deployment Script running with Service Principal authentication

You are getting the hang of it. As you already have all the details now available, it’s easy to integrate it in Azure DevOps by using the Service Connection.

Integrate deployment scripts in Azure DevOps with template spec

To integrate deployment scripts in your Azure Pipeline, you of course need a proper Service connection setup. You also want to make use of re-usable components based on template specs. A template spec is a kind of resource type that allows you to store an Azure Resource Manager template in Azure for later usages.

If you are ready, grab your editor once more and follow the steps below

  1. Create a new file called deployment-script-module.bicep
  2. Add the following code snippet from below
@description('Specify the content to run.')
param scriptContent string

@description('Specify the argument to pass to script if it contains parameters.')
param arguments string = ''

@description('Location of the deploymentScript resource.')
param location string = resourceGroup().location

@description('The service principal ID')
param servicePrincipalId string

@description('The service principal key to authenticate the service principal')
@secure()
param servicePrincipalKey string

@description('The tenant ID of Azure')
param tenantId string

@description('The subscription to connect to of Azure')
param subscriptionId string

module deploymentScript 'deployment-script-spn.bicep' = {
  name: 'deploymentscript-az-ps'
  params: {
    arguments: arguments
    scriptContent: scriptContent
    location: location
    servicePrincipalId: servicePrincipalId
    servicePrincipalKey: servicePrincipalKey
    tenantId: tenantId
    subscriptionId: subscriptionId

  }
}
  1. Run az bicep build --file C:\repos\deployment-scripts-bicep\source\deployment-script-module.bicep to build the Bicep file
  2. Upload the module as template spec to Azure by running
New-AzTemplateSpec -ResourceGroupName <targetResourceGroupName -Name deployment-script-azps -Version 1.0 -Location westeurope -TemplateFile C:\repos\deployment-scripts-bicep\source\deployment-script-module.json -Description "Deployment script that can run Azure PowerShell"`

You have now organized your deployment into modules, which in turn created a Template Spec in Azure that can be consumed by Azure PowerShell, Azure CLI or DevOps pipeline. You can now consume the Template Spec from a DevOps pipeline. Proceed further, as it is just easier to see with snippets.

  1. Create a new folder called pipelines and underneath a subfolder templates
  2. Create a file called get-service-principal-details-task.yml which is responsible to retrieve the current details from the Service connection
  3. Paste the following content in and save it
steps: 
  - task: AzureCLI@2
    displayName: 'Get service principal details'
    inputs:
      azureSubscription: 'sc-spn-dev-eu-restricted-access'
      scriptType: 'pscore'
      scriptLocation: 'inlineScript'
      inlineScript: |
        Write-Host "##vso[task.setvariable variable=spId]$env:servicePrincipalId"
        Write-Host "##vso[task.setvariable variable=spKey;isSecret=true]$env:servicePrincipalKey"
        Write-Host "##vso[task.setvariable variable=tid]$env:tenantId"
      addSpnToEnvironment: true
  1. Create a file called template-spec-deployment-scripts-task.yml, which will retrieve the Template Spec from Azure and deploy it
  2. Paste the content from below
parameters: 
  - name: azureSubscription
    type: string
    displayName: 'The Azure subscription / service connection to target'
  - name: templateResourceGroupName
    type: string
    displayName: 'The template specification resource group name to retrieve template'
  - name: templateSpecVersion
    type: string
    displayName: 'The template specification version to retrieve'
    default: ''
  - name: templateSpecFileName
    type: string
    default: 'deploymentscript-azps'
    displayName: 'The template specification file name to retrieve'
  - name: templateDeploymentName
    type: string
    displayName: 'The template deployment name'
    default: ''
  - name: resourceGroupName 
    type: string
    displayName: 'The resource group name to deploy against'
  - name: mode
    type: string
    displayName: 'The mode to run against, defaults to Incremental'
    default: Incremental
    values: 
      - Incremental
      - Complete
  - name: scriptPath
    type: string
    displayName: 'The path to the script to run'
  - name: servicePrincipalId 
    type: string
    default: '$(spId)'
    displayName: 'The service principal id, defaults to $(spId)'
  - name: servicePrincipalKey
    type: string
    default: '$(spKey)'
    displayName: 'The service principal key, defaults to $(spKey)'
  - name: tenantId 
    type: string
    default: '$(tid)'
    displayName: 'The Azure Tenant Id, defaults to $(tid)'
  - name: argument
    type: string
    default: ''
    displayName: 'The arguments to pass through the deployment scripts'

steps:
  - task: AzurePowerShell@5  
    displayName: 'Deploy script ${{ parameters.scriptPath }}'
    inputs:
      azureSubscription: ${{ parameters.azureSubscription }}
      ScriptType: InlineScript
      Inline: |
        $Params = @{
            ResourceGroupName = "${{ parameters.templateResourceGroupName }}"
            Name = "${{ parameters.templateSpecFileName }}"
            Version = "${{ parameters.templateSpecVersion }}"
            ErrorAction = 'Stop'
        }

        $templateCheck = [string]::IsNullOrEmpty("${{ parameters.templateSpecVersion }}")

        if ($templateCheck) {
            Write-Host -Object "Removing 'version' from parameters as none was specified"
            $Params.Remove('Version')
        }

        $Params.GetEnumerator() | ForEach-Object { $result = 'Parameter {0} with value {1}' -f $_.key, $_.value; Write-Host $result }

        try {
          $TemplateFile = (Get-AzTemplateSpec @Params).Versions
        } catch {
          Throw "Exception occurred when retrieving template specification: $_"
        }

        if ($templateCheck) {
          $TemplateFile = ($TemplateFile | Select-Object -Last 1)
        }

        Write-Host -Object ("Template - '{0}' found with '{1}' version" -f "${{ parameters.templateSpecFileName }}", $TemplateFile.Id) 

        if (-not (Test-Path "${{ parameters.scriptPath }}")) {
          Write-Error -Message "Script path not found. Cannot locate - ${{ parameters.scriptPath}}."
          return;
        }

        $ScriptPath = (Get-Content -Path "${{ parameters.scriptPath }}" -Raw)

        $DeploymentParameters = @{
          TemplateSpecId = $TemplateFile.Id
          ResourceGroupName = "${{ parameters.resourceGroupName }}"
          Mode = "${{ parameters.mode }}"
          ScriptPath = $ScriptPath
          ServicePrincipalId = "${{ parameters.servicePrincipalId }}"
          ServicePrincipalKey = (ConvertTo-SecureString -String "${{ parameters.servicePrincipalKey }}" -AsPlainText -Force)
          TenantId = "${{ parameters.tenantId }}"
          SubscriptionId = (Get-AzContext).Subscription.Id
        }

        if ("${{ parameters.argument }}" -ne '') {
          Write-Host -Object ("Adding arguments - '{0}' to deployment parameters" -f "${{ parameters.argument }}")
          $DeploymentParameters.Add('Argument', "${{ parameters.argument }}")
        }

        # if deploymentName is not specified, set it to buildnumber (without spaces)
        $deploymentName = '${{ parameters.templateDeploymentName }}'
        if ($deploymentName -eq '') {
          $deploymentName = '$(Build.BuildNumber)'.Replace(' ', '')
        }
        $DeploymentParameters.Add('DeploymentName', $DeploymentName)

        $DeploymentParameters.GetEnumerator() | ForEach-Object { $result = 'Parameter {0} with value {1}' -f $_.key, $_.value; Write-Host $result }

        # AST validation
        $Ast = [Management.Automation.Language.Parser]::ParseInput($ScriptPath, [ref]$null, [ref]$null)
        $Ast.paramblock.parameters.name.variablepath | Foreach-Object { Write-Host -Object ("Expecting the following parameter to be passed to script - '{0}'" -f $_.UserPath)}
        $ParamBlock = $Ast.ParamBlock.Parameters 
        if ($ParamBlock -and "${{ parameters.argument}}" -eq '') {
          Write-Host "##[warning]Parameter block exists in script, but no arguments specified. Please add the appropriate arguments if required."
        }

        # Deployment 
        New-AzResourceGroupDeployment @DeploymentParameters
      FailOnStandardError: true
      azurePowerShellVersion: LatestVersion
      pwsh: true

You might be sweating with all of that code, so a quick summary is in it’s place.

  • get-service-principal-details-task.yml task: responsible to retrieve the current details of the Service Principal in the current context
  • template-spec-deployment-scripts-task.yml task: retrieves the context details from previous step, locates the Template spec and starts a deployment using the Template Spec Id. It has an additional check to see whether all required parameters have been added for the script to run based on the Abstract Syntax Tree

Now that you have re-usable YAML templates, you can create the DevOps pipeline.

  1. In the pipelines folder, create a file called azure-pipelines.yml
  2. Add the following content to add the templates to the pipeline and call the deployment script
parameters: 
 - name: azureSubscription 
   type: string 
 - name: resourceGroupName 
   type: string 
 - name: objectId 
   type: string 
 - name: targetResourceGroupName 
   type: string

stages:   
  - stage: PostDeployment
    jobs:
      - job: PostDeployment
        pool:
          vmImage: windows-latest 
        steps:
          - template: /pipelines/templates/get-service-principal-details-task.yml
            parameters: 
              azureSubscription: ${{ parameters.azureSubscription }}
          - template: /pipelines/templates/template-spec-deployment-scripts-task.yml
            parameters:
              azureSubscription: ${{ parameters.azureSubscription }}
              templateResourceGroupName: ${{ parameters.resourceGroupName }}
              resourceGroupName: ${{ parameters.targetResourceGroupName }}
              scriptContent: '$(System.DefaultWorkingDirectory)\Scripts\Add-AzRoleAssignmentSpn.ps1'
              argument: -ObjectId '${{ parameters.objectId }}' -ResourceGroupName ${{ parameters.targetResourceGroupName }}

You can load in the DevOps pipeline into Azure DevOps, specify the required parameters and notice that the deployment script is executed against Azure.

azure-pipelines Figure 4: Run deployment script through DevOps pipeline

Conclusion

That was something wasn’t it? You started out by simply creating a deployment script resource that used the User-Assigned Managed Identity. You’ve tested the result to see what happens. Later, you went hardcore and transformed the deployment script to retrieve dynamic content with the required arguments. The Service Principal was used and the DevOps pipeline can now run Azure PowerShell scripts from everywhere, as long as you have a centralized location for your templates and scripts.

Deployment scripts opens up a new world on executing Azure PowerShell or Azure CLI, as it is only currently supported for these types. Whether you are transitioning all your PowerShell code to Bicep, or you want to stick scripting in your Bicep, it does it all. If you want to see more, check out the documentation on deployment scripts.

To make it easy for you, all the code in the blog can be found on my GitHub page.