Azure, Azure Pipelines, Bicep, DevOps, IaC

Dynamic Multistage Azure Pipelines Part 2

In Part 1 I went through the idea of a base template to handle dynamic multistage pipelines and ended with a template to deal with multiple subscriptions and environments. In this part I am going to show real use by deploying some IaC (Infrastructure as Code) to create an Web App in Azure and then deploying an application to the Web App using that base template.

To recap, the base template looked liked this with one difference, I’ve added a QA environment:

parameters:
- name: environments
  type: object
  default:
  - name: 'dev'
    subscriptions:
      - subscription: 'Dev Subscription'
        regions:
          - location: 'eastus'
            locationShort: 'eus'
  - name: 'qa'
    subscriptions:
      - subscription: 'QA Subscription'
        regions:
          - location: 'eastus'
            locationShort: 'eus'
          - location: 'westus'
            locationShort: 'wus'
  - name: 'prod'
    subscriptions:
      - subscription: 'Prod Subscription'
        regions:
          - location: 'eastus'
            locationShort: 'eus'
          - location: 'westus'
            locationShort: 'wus'
- name: buildSteps
  type: stepList
  default: []
- name: releaseSteps
  type: stepList
  default: []
- name: customReleaseTemplate
  type: string
  default: ''
- name: variableGroupPrefix
  type: string
  default: ''
- name: variableFilePrefix
  type: string
  default: ''
 
stages:
- stage: build
  displayName: 'Build/Package Code or IaC'
  jobs:
  - job: build
    displayName: 'Build/Package Code'
    steps: ${{ parameters.buildSteps }}
 
- ${{ each env in parameters.environments }}:
  - stage: ${{ env.name }}
    displayName: 'Deploy to ${{ env.name }}'
    condition: succeeded()
    variables:
      - ${{ if ne(parameters.variableFilePrefix, '') }}:
        - template: ${{ parameters.variableFilePrefix }}_${{ env.name }}.yml@self
      - ${{ if ne(parameters.variableGroupPrefix, '') }}:
        - group: ${{ parameters.variableGroupPrefix }}_${{ env.name }}
    jobs:
    - ${{ each sub in env.subscriptions }}:
      - ${{ each region in sub.regions }}:
        - ${{ if ne(parameters.customReleaseTemplate, '') }}:
          - template: ${{ parameters.customReleaseTemplate }}
            parameters:
               env: ${{ env.name }}
               location: ${{ region.location }}
               locationShort: ${{ region.locationShort }}
               subscription: ${{ sub.subscription }}
        - ${{ else }}:
          - deployment: deploy_${{ region.locationShort }}
            displayName: 'Deploy app to ${{ env.name }} in ${{ region.location }}'
            environment: ${{ env.name }}_${{ region.locationShort }}
            strategy:
              runOnce:
                deploy:
                  steps:
                  - ${{ parameters.releaseSteps }}

My repository for this contains several folders, one called infrastructure which contains my IaC code (in this case Bicep) and one called src which holds a very basic .NET 6 web app.

Deploy IaC

As most of the configuration is in the base template this pipeline is going to extend the environments template and then add steps to package up the infrastructure and run the IaC via a custom release template deploy-infrastructure.yml.

trigger: none
pr: none

pool: 
  vmImage: 'ubuntu-latest'

resources:
  repositories:
    - repository: templates
      type: git
      name: shared-templates
      ref: main

extends:
  template: environments.yml@templates
  parameters:
    buildSteps:
    - task: CopyFiles@2
      displayName: 'Copy Files to output'
      inputs:
        contents: |
          **/infrastructure/**
        targetFolder: $(Build.ArtifactStagingDirectory)
    - task: ArchiveFiles@2
      displayName: 'Archive Infrastructure'
      inputs:
        rootFolderOrFile: $(Build.ArtifactStagingDirectory)
        includeRootFolder: false
        archiveType: 'zip'
        archiveFile: '$(Build.ArtifactStagingDirectory)/infrastructure$(Build.BuildId).zip'
    - publish: $(Build.ArtifactStagingDirectory)/infrastructure$(Build.BuildId).zip
      displayName: 'Upload Infrastructure package'
      artifact: infrastructure
    customReleaseTemplate: deploy-infrastructure.yml@self

The custom release template is mainly used so that I can dynamically load in some parameters files that will build the parameters JSON file for Bicep to use (if you want to know how I create the parameters JSON see my post on Passing Parameters to Bicep Technique #3).

deploy-infrastructure.yml

parameters:
- name: env
  type: string
- name: location
  type: string
- name: locationShort
  type: string
- name: subscription
  type: string

jobs:
- deployment: deploy_${{ parameters.locationShort }}
  displayName: 'Deploy app to ${{ parameters.env }} in ${{ parameters.location }}'
  environment: ${{ parameters.env }}_${{ parameters.locationShort }}
  variables:
    resourceGroupName: 'myapp-${{ parameters.env }}-${{ parameters.locationShort }}-rg'
    templateFileName: 'infrastructure/main.bicep'
    templateParametersFileName: 'infrastructure/main.parameters.json'
  strategy: 
    runOnce:
      deploy:
        steps:
        - task: ExtractFiles@1
          displayName: 'Extract Infrastructure'
          inputs:
            archiveFilePatterns: '$(Pipeline.Workspace)/**/*.zip'
            destinationFolder: $(Build.SourcesDirectory)
            cleanDestinationFolder: false
        - template: params_${{ parameters.env }}.yml
          parameters:
            linuxFxVersion: 'DOTNETCORE|6.0'
            env: ${{ parameters.env }}
            appname: 'myapp'
        - task: AzureCLI@2
          displayName: 'Deploy Infrastructure'
          inputs:
            azureSubscription: ${{ parameters.subscription }}
            scriptType: 'pscore'
            scriptLocation: inlineScript
            inlineScript: |
              az group create --name $(resourceGroupName) --location ${{ parameters.location }}
              az deployment group create --name "infrastructure-deploy" --resource-group $(resourceGroupName) --template-file $(templateFileName) --parameters $(templateParametersFileName)

The parameters template will create the parameters JSON file, each file is very similar but the main difference is the sku being used, dev has ‘F1’, qa has ‘B1’ and prod has ‘S1’. This is what the prod parameters file looks like:

parameters:
- name: sku
  type: string
  default: 'S1'
- name: linuxFxVersion
  type: string 
- name: env 
  type: string
- name: appname
  type: string
- name: tags
  type: object
  default:
    environment: 'Production'
    project: 'myproj'

steps:
- template: createParametersFile.yml@templates
  parameters:
    paramsJson: '${{ convertToJson(parameters) }}'
    parameterFilePath: $(templateParametersFileName)

Deploy Application

As with the deployment of IaC most of the configuration is in the base template and so again this pipeline is quite small.

trigger: none
pr: none

pool: 
  vmImage: 'ubuntu-latest'

resources:
  repositories:
    - repository: templates
      type: git
      name: shared-templates
      ref: main

extends:
  template: environments.yml@templates
  parameters:    
    buildSteps:
    - script: | 
        cd src
        dotnet build --configuration Release
      displayName: 'dotnet build Release'
    - task: DotNetCoreCLI@2
      displayName: 'dotnet publish'
      inputs:
        arguments: --configuration Release -o $(Build.ArtifactStagingDirectory)
        command: 'publish'
        publishWebProjects: true
    - publish: $(Build.ArtifactStagingDirectory)
      artifact: code
      displayName: 'Publish Code'
    customReleaseTemplate: deploy-app.yml@self

deploy-app.yml

parameters:
- name: env
  type: string
- name: location
  type: string
- name: locationShort
  type: string
- name: subscription
  type: string

jobs:
- deployment: deploy_${{ parameters.locationShort }}
  displayName: 'Deploy app to ${{ parameters.env }} in ${{ parameters.location }}'
  environment: ${{ parameters.env }}_${{ parameters.locationShort }}
  strategy: 
    runOnce:
      deploy:
        steps:
        - task: AzureWebApp@1
          inputs:
            azureSubscription: '${{ parameters.subscription }}'
            appType: 'webAppLinux'
            appName: 'app-myapp-${{ parameters.env }}-${{ parameters.locationShort }}'
            package: '$(Pipeline.Workspace)/**/*.zip'

Conclusion

The infrastructure and application both run the same steps and so have the same stages output

Resource Groups and Web Apps created by the IaC

Using this type of template provides a very powerful base template and allows the pipelines to focus on building and deploying removing the need to worry too much about the environments that are needed.

I hope showing this has been helpful and who knows it may spark some other ideas and ways of using base templates. Happy deployments 🙂

Azure, Azure Pipelines, Bicep, DevOps, IaC

Automate Maintaining a Private Bicep Module Registry with Azure Pipelines

After using IaC (Infrastructure as Code) on multiple projects it soon becomes apparent that you are performing the same actions over and over and you might want to start sharing those actions or even standardising them. IaC frameworks like Terraform, Pulumi, etc. provide a registry for sharing modules and I wondered if Bicep had something similar, it turns out there is using ACR (Azure Container Registry).

In this post we are going to use an ACR to create a private Bicep registry for sharing modules and a build pipeline to publish modules into the ACR when new modules are added or existing ones are changed.

Objectives:

  • Trigger on changes to Bicep files in the main branch
  • Add modules to the registry only if they do not already exist
  • Publish a new version of each changed module to the registry

Automate Publishing Files

First thing that we need is a Azure Container Registry, if you don’t already have one provisioned you can create one using the Azure CLI e.g.

az group create --name devops-rg --location "UK South"
az acr create --resource-group devops-rg --name devcrdevopsuks --sku Basic

Next we will need a repository that contains the Bicep modules we want to share. This could be an existing repository or a new repository. For the purpose of this, we can create a repository with a simple structure, a single folder called modules that contain the Bicep files we want to share e.g.

Now we have an ACR and a repository we can start going through those objectives.

Trigger on changes to Bicep files in the main branch

Azure Pipeline triggers can be defined to handle this objective by adding the branch name and including the paths e.g.

trigger:
  branches:
    include:
    - main
  paths:
    include:
    - '*.bicep'

Add modules to the registry only if they do not already exist

To achieve this objective we will first need a list of what is in the registry and compare that against the modules in our repository.

Initially there will be no modules in the registry but as ones are added we will want to only return the Bicep modules. It is a good idea to prefix the modules e.g. with ‘bicep/’ when adding them as you may use the ACR for other things not just Bicep modules.

We can use the Azure CLI again to get the list from the registry and filter on the prefix e.g.

az acr repository list --name $(registryName) --query "[?contains(@, '$(modulePrefix)')]" -o tsv

Combine that with some PowerShell to compare the entries, we can then publish the modules not in the registry e.g.

$version = Get-Date -f 'yyyy-MM-dd'
$publishedModules = $(az acr repository list --name $(registryName) --query "[?contains(@, '$(modulePrefix)')]" -o tsv)
Get-ChildItem -Recurse -Path ./*.bicep | Foreach-Object {
    $filename = ($_ | Resolve-Path -Relative) -replace "^./" -replace '\..*'
    # Check if module already exists in the registry
    If (-not ($publishedModules ?? @()).Contains(("$(modulePrefix)" + $filename))) {
        Write-Host "Adding new module $filename with version $version"
        az bicep publish --file $_ --target br:$(registryName).azurecr.io/bicep/${filename}:${version}
    }
}

Note: For the version using the date provides Bicep/ARM style version numbering e.g. 2022-01-23

Publish a new version of each changed module to the registry

For this objective we need to get the list of changed files from the repository. We can use the Git command diff-tree to provide a list of changes since the last commit e.g.

git diff-tree --no-commit-id --name-only --diff-filter=ad -r $(Build.SourceVersion)

This command shows the name of the files changed without the commit id and using lowercase a and d filters instruct this to not include Add or Delete changes (for further information on diff-tree see the docs).

Following this we need to filter to just Bicep file changes and then publish those changes like before e.g.

git diff-tree --no-commit-id --name-only --diff-filter=ad -r $(Build.SourceVersion) | Where-Object {$_.EndsWith('.bicep')} | Foreach-Object {
    $moduleName = ($_ | Resolve-Path -Relative) -replace "^./" -replace '\..*'
    Write-Host "Updating module $moduleName with version $version"
    az bicep publish --file $_ --target br:$(registryName).azurecr.io/$(modulePrefix)${moduleName}:${version}
}

Now we have the full PowerShell script we can add that to the pipeline using the AzureCLI task, include an install step for Bicep and then the complete pipeline looks like this:

trigger:
  branches:
    include:
    - main
  paths:
    include:
    - '*.bicep'

pr: none

variables:
  isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]
  modulePrefix: 'bicep/'

jobs:
- job: modules
  displayName: 'Publish Bicep Modules'
  condition: eq(variables.isMain, 'true')
  pool:
    vmImage: ubuntu-latest
  steps:
  - task: AzureCLI@2
    displayName: 'Publish/Update Modules to Registry'
    inputs:
      azureSubscription: $(azureSubscription)
      scriptType: 'pscore'
      scriptLocation: inlineScript
      inlineScript: |
        az bicep install
        $version = Get-Date -f 'yyyy-MM-dd'
        $publishedModules = $(az acr repository list --name $(registryName) --query "[?contains(@, '$(modulePrefix)')]" -o tsv)
        Get-ChildItem -Recurse -Path ./*.bicep | Foreach-Object {
          $filename = ($_ | Resolve-Path -Relative) -replace "^./" -replace '\..*'
          # Check if module already exists in the registry
          If (-not ($publishedModules ?? @()).Contains(("$(modulePrefix)" + $filename))) {
            Write-Host "Adding new module $filename with version $version"
            az bicep publish --file $_ --target br:$(registryName).azurecr.io/bicep/${filename}:${version}
          }
        }

        git diff-tree --no-commit-id --name-only --diff-filter=ad -r $(Build.SourceVersion) | Where-Object {$_.EndsWith('.bicep')} | Foreach-Object {
          $moduleName = ($_ | Resolve-Path -Relative) -replace "^./" -replace '\..*'
          Write-Host "Updating module $moduleName with version $version"
          az bicep publish --file $_ --target br:$(registryName).azurecr.io/$(modulePrefix)${moduleName}:${version}
        }

Consume Registry Modules

Now we have the shared modules in the registry, how do we use them? As it turns out it’s quite simple as shown in the Microsoft docs e.g.

module storageModule 'br:devcrdevopsuks.azurecr.io/bicep/modules/storage:2022-01-23' = {

Using the registry name and the module path can make this quite long and a lot to type in every time. We can however use a Bicep config file to create an alias and include the registry and module path (see the Microsoft docs for more detail) e.g.

{
  "moduleAliases": {
    "br": {
      "DevOps": {
        "registry": "devcrdevopsuks.azurecr.io",
        "modulePath": "bicep/modules"
      }
    }
  }
}

Now the name is more concise e.g.

module storageModule 'br/DevOps:storage:2022-01-23' = {

Conclusion

I have to say I like the option of using an ACR as a Bicep registry and by automating the maintenance of adding/updating the modules it makes sharing changes very easy.

The only thing that bothered me was that (at the time of writing) Visual Studio Code does not provide intellisense on which modules are available in the registry. Hopefully this will change in the future but in the meantime this handy PowerShell script will output the information about the registry modules and available versions

$items = @()
$(az acr repository list --name devcrdevopsuks --query "[?contains(@, 'bicep/')]" -o tsv) | ForEach-Object {
    $items += [PSCustomObject]@{
        Moddule = $_
        Tags = $(az acr repository show-tags --name devcrdevopsuks --repository $_ -o tsv)
        }    
}
Write-Output $items