Azure DevOps

Creating Azure DevOps Pipelines

I thought I would cover writing a Azure DevOps pipeline YAML file which deploys infrastructure.

Useful link – There are some built-in variables in ADO which you can check out here https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml.

To take you through it to give you an overview of the setup. We have a pipeline file for each environment (see below). This is so we can set the parameters/variables for each environment. This will call our main template (main.yml). Each environment file is named after the environment.

# infra.yml
name: $(BuildDefinitionName)_$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r)

trigger: none

extends:
  template: templates/main.yml
  parameters:
    location: 'uksouth'
    artifactName: 'azure-arm-templates'
    connectedServiceName: 'infra'
    Tier: 'infra'
    environment: 'Infra'
    deploymentResourceGroup: 'rg-deployments-infra'
    artifactsResourceGroup: 'rg-deployments-infra'
    variableGroup: 'infra'
# dev.yml
name: $(BuildDefinitionName)_$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r)

trigger: none

extends:
  template: templates/main.yml
  parameters:
    location: 'uksouth'
    artifactName: 'azure-arm-templates'
    connectedServiceName: 'Dev'
    Tier: 'dev'
    environment: 'Dev'
    deploymentResourceGroup: 'rg-deployments-dev'
    artifactsResourceGroup: 'rg-deployments-dev'
    variableGroup: 'dev'
# test.yml
name: $(BuildDefinitionName)_$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r)

trigger: none

extends:
  template: templates/main.yml
  parameters:
    location: 'uksouth'
    artifactName: 'azure-arm-templates'
    connectedServiceName: 'Test'
    Tier: 'test'
    environment: 'Test'
    deploymentResourceGroup: 'rg-deployments-test'
    artifactsResourceGroup: 'rg-deployments-test'
    variableGroup: 'test'
# pre-prod.yml
name: $(BuildDefinitionName)_$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r)

trigger: none

extends:
  template: templates/main.yml
  parameters:
    location: 'uksouth'
    artifactName: 'azure-arm-templates'
    connectedServiceName: 'Preprod'
    Tier: 'preprod'
    environment: 'Preprod'
    deploymentResourceGroup: 'rg-deployments-preprod'
    artifactsResourceGroup: 'rg-deployments-preprod'
    variableGroup: 'preprod'
# prod.yml
name: $(BuildDefinitionName)_$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r)

trigger: none

extends:
  template: templates/main.yml
  parameters:
    location: 'uksouth'
    artifactName: 'azure-arm-templates'
    connectedServiceName: 'Prod'
    Tier: prod'
    environment: 'Prod'
    deploymentResourceGroup: 'rg-deployments-prod'
    artifactsResourceGroup: 'rg-deployments-prod'
    variableGroup: 'prod'
# main.yml
parameters:
- name: location
  type: string
- name: artifactName
  type: string
- name: connectedServiceName
  type: string
- name: Tier
  type: string
- name: environment
  type: string
- name: deploymentResourceGroup
  type: string
- name: artifactsResourceGroup
  type: string
- name: variableGroup
  type: string

variables:
- group: ${{ parameters.variableGroup }}

stages:
  - stage: Testing
    displayName: Test ARM Templates
    jobs:
      - job: TestJob
        displayName: Test ARM Templates
        pool:
          vmImage: 'windows-latest'
        steps:
          - task: RunARMTTKTests@1
            displayName: Run ARM TTK Tests
            inputs:
              templatelocation: '$(System.DefaultWorkingDirectory)\ARM-templates'
              resultLocation: '$(System.DefaultWorkingDirectory)\results'
              mainTemplates: 'main.json'
              skipTests: 'DependsOn-Best-Practices'
              cliOutputResults: true

          - task: PublishTestResults@2
            displayName: Publish ARM TTK Test Results
            condition: succeededOrFailed()
            inputs:
              testResultsFormat: NUnit
              testResultsFiles: '**/*-armttk.xml'
              searchFolder: '$(System.DefaultWorkingDirectory)\results'

  - stage: PublishArtifacts
    condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
    displayName: Publish Artifacts
    jobs:
      - job: PublishJob
        displayName: Copy and Publish Artifacts
        pool:
          vmImage: "windows-latest"
        steps:
          - task: CopyFiles@2
            displayName: Copy files
            inputs:
              SourceFolder: "$(Build.SourcesDirectory)"
              Contents: "**"
              TargetFolder: "$(Build.ArtifactStagingDirectory)"

          - task: PublishBuildArtifacts@1
            displayName: Publish build artifacts
            inputs:
              PathtoPublish: "$(Build.ArtifactStagingDirectory)"
              ArtifactName: ${{ parameters.artifactName }}

  - stage: DeployResources
    condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
    displayName: Deployment to ${{ parameters.Tier }} tier
    jobs:
      - deployment: InfraDeploy
        displayName: "Deploy infrastrucure for ${{ parameters.Tier }} tier"
        pool:
          vmImage: "windows-latest"
        environment: ${{ parameters.environment }}
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureResourceManagerTemplateDeployment@3
                  displayName: Create resource group for deployment
                  inputs:
                    deploymentScope: "Subscription"
                    ConnectedServiceName: ${{ parameters.connectedServiceName }}
                    subscriptionName: $(SubscriptionId)
                    location: ${{ parameters.location }}
                    templateLocation: "Linked artifact"
                    csmFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\resource-group\artifactsResourceGroup.json'
                    csmParametersFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\resource-group\artifactsResourceGroup-${{ parameters.Tier }}.parameter.json'
                    deploymentMode: "Incremental"
                    deploymentOutputs: "armOutputs"

                - task: AzurePowerShell@5
                  displayName: Get the resource group name
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: "InlineScript"
                    Inline: |
                      $var=ConvertFrom-Json '$(armOutputs)'
                      $value=$var.artifactsGroupName.value
                      Write-Host "##vso[task.setvariable variable=artefactsGroupName;]$value"
                    azurePowerShellVersion: "latestVersion"
                    pwsh: true

                - task: AzureResourceManagerTemplateDeployment@3
                  displayName: Create a storage account for deployment artifacts
                  inputs:
                    deploymentScope: "Resource Group"
                    ConnectedServiceName: ${{ parameters.connectedServiceName }}
                    subscriptionName: $(SubscriptionId)
                    action: "Create Or Update Resource Group"
                    resourceGroupName: "$(artefactsGroupName)"
                    location: ${{ parameters.location }}
                    templateLocation: "Linked artifact"
                    csmFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\storage-account-for-artifacts\azuredeploy.json'
                    csmParametersFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\storage-account-for-artifacts\azuredeploy.parameters.json'
                    deploymentMode: "Incremental"
                    deploymentOutputs: "armOutputs"

                - task: AzurePowerShell@5
                  displayName: Get the storage account and its container names
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: "InlineScript"
                    Inline: |
                      $var=ConvertFrom-Json '$(armOutputs)'
                      $value=$var.storageAccountName.value
                      Write-Host "##vso[task.setvariable variable=storageAccountName;]$value"
                      $value=$var.storageContainerName.value
                      Write-Host "##vso[task.setvariable variable=storageContainerName;]$value"
                    azurePowerShellVersion: "latestVersion"
                    pwsh: true

                - task: AzurePowerShell@5
                  displayName: Copy deployment artifacts to the storage account
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: 'FilePath'
                    azurePowerShellVersion: 'latestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\Copy-DeploymentArtifacts.ps1'
                    scriptArguments:
                      -StorageAccountName $(storageAccountName)
                      -BlobContainerName $(storageContainerName)
                      -TemplateFolderPath '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\Arm-templates'
                      -ResourceGroupName $(artefactsGroupName)

                - task: AzurePowerShell@5
                  displayName: Run Management Groups Script
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: 'FilePath'
                    azurePowerShellVersion: 'latestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\CreateManagementStructure.ps1'
                    scriptArguments:
                      -environment ${{ parameters.environment }}

                - task: AzureResourceManagerTemplateDeployment@3
                  displayName: Deploy Platform Resources
                  inputs:
                    deploymentScope: "Resource Group"
                    ConnectedServiceName: ${{ parameters.connectedServiceName }}
                    subscriptionName: $(SubscriptionId)
                    action: "Create Or Update Resource Group"
                    resourceGroupName: ${{ parameters.deploymentResourceGroup }}
                    location: ${{ parameters.location }}
                    templateLocation: "URL of the file"
                    csmFileLink: '$(artifactsLocation)linked-templates/main.json$(artifactsLocationSasToken)'
                    csmParametersFileLink: '$(artifactsLocation)linked-templates/${{ parameters.Tier }}.parameters.json$(artifactsLocationSasToken)'
                    overrideParameters: "-_Tier ${{ parameters.Tier }} -_artifactsLocation $(artifactsLocation) -_artifactsLocationSasToken $(artifactsLocationSasToken) -_SubscriptionId $(SubscriptionId) -_aSubscriptionId $(SubscriptionId) -_lSubscriptionId $(lSubscriptionId)"
                    deploymentMode: "Incremental"
                    deploymentOutputs: "armOutputs"
                
                - task: AzurePowerShell@5
                  displayName: Pester tests
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    ScriptType: 'FilePath'
                    azurePowerShellVersion: 'LatestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\Execute-PesterTests.ps1'
                    scriptArguments:
                      -TestFile '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tests\main.tests.ps1'
                      -TemplateParameterFile '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\ARM-templates\${{ parameters.Tier }}.parameters.json'
                      -AgentBuildDirectory $(Agent.BuildDirectory)
                      -ArtifactsName ${{ parameters.artifactName }}
                      -SubscriptionId $(SubscriptionId)
                      -ASubscriptionId $(aSubscriptionId)
                      -LSubscriptionId $(lSubscriptionId)

                - task: PublishTestResults@2
                  displayName: Publish Test Results
                  inputs:
                    testResultsFormat: 'NUnit'
                    testResultsFiles: '**/TestResults.xml'
                    searchFolder: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tests\'

                - task: AzurePowerShell@5
                  displayName: Set Resource Locks on Resource Groups
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    ScriptType: 'FilePath'
                    azurePowerShellVersion: 'LatestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\Set-ResourceLocks.ps1'
                    scriptArguments:
                      -SubscriptionId $(SubscriptionId)
                      -ASubscriptionId $(aSubscriptionId)
                      -LSubscriptionId $(lSubscriptionId)
                      -ArtifactsResourceGroup $(artefactsGroupName)

                - task: AzureCLI@2
                  displayName: Remove Artifacts from Storage Account
                  condition: always()
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: ps
                    scriptLocation: inlineScript
                    inlineScript: az storage account delete --yes --name $(storageAccountName) --resource-group $(artefactsGroupName)

The Name

name: $(BuildDefinitionName)_$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r)

This is the name of the deployment. The breakdown is as follows:-

$(BuildDefinitionName) which is the name of the pipeline, then an underscore(_), then the $(SourceBranchName) which is the name of the branch you are running (main), then an underscore(_), then today’s date in the format of 20211201 and finally the $(Rev:.r) which is the run number for the day so 1, 2, 3 etc… if you want it to start 01 then you can use $(Rev:.rr). remember there is a . in the $(Rev:.r) so it will produce .1, .2 etc…If the pipeline was called ITO, it would be ITO_main_20211201.1. A couple of other useful variables are $(TeamProject) which gives the name of the project as the output and $(Build.BuildNumber) would output the whole of what we just put together.

name: $(Versioning.MajorMinor).$(Versioning.Patch)

The name can be whatever makes sense to you and your team. The format above of MajorMinor, Patch is from https://semver.org/ To use these you would change the Patch number as you make bug fixes. You would change the Minor number as you add some functionality and you would change the Major number if you were making some significant changes for example a new interface.

If you don’t add a name: property to your pipeline, each run is given a unique number as its name. So it might be better to use a different name to the default. Check out the doc below to see where I got this info from.

https://docs.microsoft.com/en-us/azure/devops/pipelines/process/run-number?view=azure-devops&tabs=yaml

Trigger

trigger: none

The trigger specifies what will trigger this pipeline to run. In this case, I’ve set the trigger to none, so nothing will set it to run, I have to run it manually. I could set it like this

trigger:
  branches:
    include:
      - develop
      - main
      - hotfix/*
  paths:
    include:
      - AzureVM/*

      - /solutions/azureVM/*
    exclude:
      - '/solutions/azureVM/README.md'
      - '/solutions/azureVM/bicep/*'
      

From this, you can see that it’s saying run this pipeline when any changes happen to the develop, main branches or any branches under the hotfix folder. It will only look for changes in the AzureVM and solutions/azureVM folders, but it will not run off any changes that happen to the README.md file or in the solutions/azureVM/bicep folder.

Extends

extends:
  template: templates/main.yml
  parameters:
    location: 'uksouth'
    artifactName: 'azure-arm-templates'
    connectedServiceName: 'infra'
    Tier: 'infra'
    environment: 'Infra'
    deploymentResourceGroup: 'rg-deployments-infra'
    artifactsResourceGroup: 'rg-deployments-infra'
    variableGroup: 'infra'

The extends is useful as it allows you to extend from a template, so in our case, the main.yml file is our template and then we extend from it and add the parameters for each environment. In the extends you tell it which template to extend(main.yml) which exists in the templates folder. Then looking at the main.yml file (see extract below) we need to fill in the parameters it specifies (you can set some defaults for these if you wish)

# main.yml
parameters:
- name: location
  type: string
- name: artifactName
  type: string
- name: connectedServiceName
  type: string
- name: Tier
  type: string
- name: environment
  type: string
- name: deploymentResourceGroup
  type: string
- name: artifactsResourceGroup
  type: string
- name: variableGroup
  type: string

so what are these parameters,

  • location, which is specifying the location of where we are deploying the resource in this case we are dropping them in UKSouth.
  • artifactName, this is the name of the “package” that we create which we use to store the ARM templates.
  • connectedServiceName, this is the Service Connection, in the Azure DevOps project, where we have set up an SPN and saved the credentials in ADO so we can connect to our subscription and deploy the resources. This needs to be set up beforehand and there will be one for each subscription.
  • Tier, This is similar to the environment variable. It’s just another name for the same thing.
  • environment, this is a short name for Infra, Dev, Test, Preprod, Prod.
  • deploymentResourceGroup, the name of the deployment resourcegroup. We use a different resource group to keep track of the deployments.
  • artifactsResourceGroup, this is the name of the resource group to where we are going to upload the arm templates to. They sit in a storage account.
  • variableGroup, in ADO you can create variable groups, we have created a variable group for each environment. We could have created the same group and specified different variable values for each environment and when the pipeline runs it will select the correct value for each environment. However, this can get messy. So we specify the name of the variable group we want to include.

Variables

variables:
- group: ${{ parameters.variableGroup }}

So we are properly into the main template now, imagine it has taken those parameters from the dev.yml file and filled in the main.yml file with them. Next, it hits the variables block. Here we declare that we want to use a variable group called whatever we set in the parameter called variableGroup. You can add individual variable hear as well if you want to.

variables:
- group: ${{ parameters.variableGroup }}  
- name: location
  value: uksouth
- name: ProjectName
  value: 'AzureVM'

Stages

stages:
  - stage: Testing
    displayName: Test ARM Templates

So you can break down the template into stages, jobs, steps, tasks etc.. Check out this doc for more information on it. https://docs.microsoft.com/en-us/azure/devops/pipelines/process/stages?view=azure-devops&tabs=yaml

But in short, Stages are a division in your pipeline so build this app, run this test are good examples. In this template, we have 4 stages.

  • Stage: testing
  • Stage: PublishArtifacts
  • Stage: DeployResources

Jobs

stages:
  - stage: Testing
    displayName: Test ARM Templates
    jobs:
      - job: TestJob

You can organize your pipeline into jobs. Every pipeline has at least one job. A job is a series of steps that run sequentially as a unit. In other words, a job is the smallest unit of work that can be scheduled to run. For more information see https://docs.microsoft.com/en-us/azure/devops/pipelines/process/phases?view=azure-devops&tabs=yaml

stages:
  - stage: Testing
    displayName: Test ARM Templates
    jobs:
      - job: TestJob
  - stage: PublishArtifacts

    displayName: Publish Artifacts
    jobs:
      - job: PublishJob
 - stage: DeployResources
    displayName: Deployment to ${{ parameters.Tier }} tier
    jobs:
      - deployment: InfraDeploy
        displayName: "Deploy infrastrucure for ${{ parameters.Tier }} tier"
 

I’ve just copied all the jobs and stages to show what the process is. It’s a good idea to work this out before you start your pipeline so you can break each section down and have a clear picture of what you are going to do. I thought it’s a good time to show that you can use dependsOn previous Stages etc.. and set the conditions of stages, jobs etc.. to say when they run. So if something fails normally it would stop the pipeline, but in this case, you can see we are always going to clean up after ourselves.

Also, you will notice that one of the Jobs doesn’t start with – Job, it starts with – deployment this is a special type of job. It’s recommended to put your deployment steps in a deployment job. The reason to use a deployment job rather than a traditional job is that you get the deployment history across pipelines, down to a specific resource and status of the deployments for auditing.

Note: a deployment job doesn’t automatically clone the source repo. You have to check out the repo within your job using the checkout: self.

Pool

 pool:
   vmImage: 'windows-latest'

Here you specify the type of server you want to run all code on.

Steps and tasks

 steps:
   - task: RunARMTTKTests@1
     displayName: Run ARM TTK Tests
     inputs:
       templatelocation: '$(System.DefaultWorkingDirectory)\ARM-templates'
       resultLocation: '$(System.DefaultWorkingDirectory)\results'
       mainTemplates: 'main.json'
       skipTests: 'DependsOn-Best-Practices'
       cliOutputResults: true

  - task: PublishTestResults@2
    displayName: Publish ARM TTK Test Results
    condition: succeededOrFailed()
    inputs:
      testResultsFormat: NUnit
      testResultsFiles: '**/*-armttk.xml'
      searchFolder: '$(System.DefaultWorkingDirectory)\results'

A step is the smallest building block of a pipeline. It can be a script or a task and looking at the code above you can see we have two tasks. A task is a packaged script or procedure with a set of inputs, which you can define.

The first task we are going to run is the ARM TTK or the longer name for it Azure Resource Manager Template Toolkit. This will test your ARM Templates for a number of tests and it’s good practice to test your templates against it. To read more about it – https://github.com/Azure/arm-ttk

We specify where the templates are, in this case in the ARM-templates folder, in our repo. Then we tell it where to put the results of the tests in the resultsLocation. We specify the main templates, here we just have one main template. Then we can tell it to skip some tests, here I have said don’t run the test called DependsOn-Best-Practices. These tests I’m talking about are in the ARM-TTK and you can read about them from the link above. The link cliOutputResults is set to true, which means output the results to the logs.

The second task publishes the results of the tests. we have a condition called succeededOrFailed() this will run even if the job before has failed. Which is what we want so we can see the errors to work out what we need to fix. Then we give it the format of the results, what the name of the results should be called ‘**/*-armttk.xml’ we are saying look for any files which end with -armttk.xml. and then the searchfolder is where to look for them.

Next Stage

- stage: PublishArtifacts
    condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
    displayName: Publish Artifacts
    jobs:
      - job: PublishJob
        displayName: Copy and Publish Artifacts
        pool:
          vmImage: "windows-latest"
        steps:
          - task: CopyFiles@2
            displayName: Copy files
            inputs:
              SourceFolder: "$(Build.SourcesDirectory)"
              Contents: "**"
              TargetFolder: "$(Build.ArtifactStagingDirectory)"

          - task: PublishBuildArtifacts@1
            displayName: Publish build artifacts
            inputs:
              PathtoPublish: "$(Build.ArtifactStagingDirectory)"
              ArtifactName: ${{ parameters.artifactName }}

I’ve covered most new bits and pieces, so I will now just go over each stage. The next stage is the publishArtifacts stage. There is a condition here and(succeeded(), ne(variables['Build.Reason'],'PullRequest')). It starts with an and() so these things need to both be true. then a succeeded() as there is nothing in the brackets () then it means it will evaluate to true only if all previous jobs succeeded or partially succeeded. ne starts for not equals and it refers to the built-in variable Build.Reason. This is the event that caused the build to run. This could be evaluated to any of the following :

  • Manual: a user manually queued the build.
  • IndividualCI: CI triggered the event by push.
  • BatchedCI: CI triggered the event by a push, and with the batch changes selected.
  • Schedule: a Schedule triggered the event.
  • ValidateShelveset: A user manually queued the build of a specific TFVC shelveset.
  • CheckInShelveset: Gated check-in trigger.
  • PullRequest: The build was triggered by a branch policy that requires a build.
  • ResourceTrigger: The build was triggered by a resource trigger or triggered by another build.

As you can see we are specifying the Build.Reason to NOT equal PullRequest. so putting this together if all previous jobs ran successfully and the build reason isn’t Pull Request then run the PublishArtifacts stage.

The steps in this are Copy Files and Publish build artefacts. the first step copies from the source folder to the target folder. In this case, the source is the built-in variable Build.SourceDirectory which translates to the local path on the build agent, where the source code files are. c:\agent_work\1\s.

The Targetfolder is set to the built-in variable Build.ArtifactStagingDirectory. This is also a location on the build agent. C:\agent_work\1\a. This variable is interchangeable with Build.StagingDirectory. It is typical to use this folder for publishing your build artefacts. This folder is purged before every new build so you don’t have to worry about cleaning it up.

The next step PublishBuildArtifacts@1 publish your artefacts. This creates a zip file with all your files in. This is actually old now and you should use the newer task PublishPipelineArtifact@1 This isn’t supported in release pipelines. This can only be used in multi-stage pipelines, build pipelines and YAML pipelines. If you did want to use the newer one it would look like this.

steps:
- task: PublishPipelineArtifact@1
  inputs:
    targetPath: "$(Build.ArtifactStagingDirectory)"
    artifactName:  ${{ parameters.artifactName }}

Whichever one you use, they will allow you to access the artefact to use in deployments. One of the differences in the newer one will publish the artefact to a container.

The Deployment stage

  - stage: DeployResources
    condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
    displayName: Deployment to ${{ parameters.Tier }} tier
    jobs:
      - deployment: InfraDeploy
        displayName: "Deploy infrastrucure for ${{ parameters.Tier }} tier"
        pool:
          vmImage: "windows-latest"
        environment: ${{ parameters.environment }}
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureResourceManagerTemplateDeployment@3
                  displayName: Create resource group for deployment
                  inputs:
                    deploymentScope: "Subscription"
                    ConnectedServiceName: ${{ parameters.connectedServiceName }}
                    subscriptionName: $(SubscriptionId)
                    location: ${{ parameters.location }}
                    templateLocation: "Linked artifact"
                    csmFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\resource-group\artifactsResourceGroup.json'
                    csmParametersFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\resource-group\artifactsResourceGroup-${{ parameters.Tier }}.parameter.json'
                    deploymentMode: "Incremental"
                    deploymentOutputs: "armOutputs"

                - task: AzurePowerShell@5
                  displayName: Get the resource group name
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: "InlineScript"
                    Inline: |
                      $var=ConvertFrom-Json '$(armOutputs)'
                      $value=$var.artifactsGroupName.value
                      Write-Host "##vso[task.setvariable variable=artefactsGroupName;]$value"
                    azurePowerShellVersion: "latestVersion"
                    pwsh: true

                - task: AzureResourceManagerTemplateDeployment@3
                  displayName: Create a storage account for deployment artifacts
                  inputs:
                    deploymentScope: "Resource Group"
                    ConnectedServiceName: ${{ parameters.connectedServiceName }}
                    subscriptionName: $(SubscriptionId)
                    action: "Create Or Update Resource Group"
                    resourceGroupName: "$(artefactsGroupName)"
                    location: ${{ parameters.location }}
                    templateLocation: "Linked artifact"
                    csmFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\storage-account-for-artifacts\azuredeploy.json'
                    csmParametersFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\storage-account-for-artifacts\azuredeploy.parameters.json'
                    deploymentMode: "Incremental"
                    deploymentOutputs: "armOutputs"

                - task: AzurePowerShell@5
                  displayName: Get the storage account and its container names
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: "InlineScript"
                    Inline: |
                      $var=ConvertFrom-Json '$(armOutputs)'
                      $value=$var.storageAccountName.value
                      Write-Host "##vso[task.setvariable variable=storageAccountName;]$value"
                      $value=$var.storageContainerName.value
                      Write-Host "##vso[task.setvariable variable=storageContainerName;]$value"
                    azurePowerShellVersion: "latestVersion"
                    pwsh: true

                - task: AzurePowerShell@5
                  displayName: Copy deployment artifacts to the storage account
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: 'FilePath'
                    azurePowerShellVersion: 'latestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\Copy-DeploymentArtifacts.ps1'
                    scriptArguments:
                      -StorageAccountName $(storageAccountName)
                      -BlobContainerName $(storageContainerName)
                      -TemplateFolderPath '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\Arm-templates'
                      -ResourceGroupName $(artefactsGroupName)

                - task: AzurePowerShell@5
                  displayName: Run Management Groups Script
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: 'FilePath'
                    azurePowerShellVersion: 'latestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\CreateManagementStructure.ps1'
                    scriptArguments:
                      -environment ${{ parameters.environment }}

                - task: AzureResourceManagerTemplateDeployment@3
                  displayName: Deploy Platform Resources
                  inputs:
                    deploymentScope: "Resource Group"
                    ConnectedServiceName: ${{ parameters.connectedServiceName }}
                    subscriptionName: $(SubscriptionId)
                    action: "Create Or Update Resource Group"
                    resourceGroupName: ${{ parameters.deploymentResourceGroup }}
                    location: ${{ parameters.location }}
                    templateLocation: "URL of the file"
                    csmFileLink: '$(artifactsLocation)linked-templates/main.json$(artifactsLocationSasToken)'
                    csmParametersFileLink: '$(artifactsLocation)linked-templates/${{ parameters.Tier }}.parameters.json$(artifactsLocationSasToken)'
                    overrideParameters: "-_Tier ${{ parameters.Tier }} -_artifactsLocation $(artifactsLocation) -_artifactsLocationSasToken $(artifactsLocationSasToken) -_SubscriptionId $(SubscriptionId) -_aSubscriptionId $(aSubscriptionId) -_lSubscriptionId $(lSubscriptionId)"
                    deploymentMode: "Incremental"
                    deploymentOutputs: "armOutputs"
                
                - task: AzurePowerShell@5
                  displayName: Pester tests
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    ScriptType: 'FilePath'
                    azurePowerShellVersion: 'LatestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\Execute-PesterTests.ps1'
                    scriptArguments:
                      -TestFile '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tests\main.tests.ps1'
                      -TemplateParameterFile '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\ARM-templates\main\${{ parameters.Tier }}.parameters.json'
                      -AgentBuildDirectory $(Agent.BuildDirectory)
                      -ArtifactsName ${{ parameters.artifactName }}
                      -SubscriptionId $(SubscriptionId)
                      -ASubscriptionId $(aSubscriptionId)
                      -LSubscriptionId $(lSubscriptionId)

                - task: PublishTestResults@2
                  displayName: Publish Test Results
                  inputs:
                    testResultsFormat: 'NUnit'
                    testResultsFiles: '**/TestResults.xml'
                    searchFolder: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tests\'

                - task: AzurePowerShell@5
                  displayName: Set Resource Locks on Resource Groups
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    ScriptType: 'FilePath'
                    azurePowerShellVersion: 'LatestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\Set-ResourceLocks.ps1'
                    scriptArguments:
                      -SubscriptionId $(SubscriptionId)
                      -ASubscriptionId $(aSubscriptionId)
                      -LSubscriptionId $(lSubscriptionId)
                      -ArtifactsResourceGroup $(artefactsGroupName)

                - task: AzureCLI@2
                  displayName: Remove Artifacts from Storage Account
                  condition: always()
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: ps
                    scriptLocation: inlineScript
                    inlineScript: az storage account delete --yes --name $(storageAccountName) --resource-group $(artefactsGroupName)

This is the largest stage and starts off with

 - stage: DeployResources
    condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
    displayName: Deployment to ${{ parameters.Tier }} tier
    jobs:
      - deployment: InfraDeploy
        displayName: "Deploy infrastrucure for ${{ parameters.Tier }} tier"
        pool:
          vmImage: "windows-latest"
        environment: ${{ parameters.environment }}
        strategy:
          runOnce:
            deploy:

a condition, this one is the same as the other one if everything has run ok and the Build Reason is not PullRequest then run the stage. As you can see this is where we use the special type of Job called a deployment job. This is a collection of steps that are run sequentially against the environment. Deployment jobs provide deployment history which is useful for auditing and you can define how your application is rolled out using deployment strategy (there are three different strategies, runOnce, rolling, and canary)

To read about the different strategies have a look at this link https://docs.microsoft.com/en-us/azure/devops/pipelines/process/dephttps://docs.microsoft.com/en-us/azure/devops/pipelines/process/deployment-jobs?view=azure-devopsloyment-jobs?view=azure-devops

we specify the pool we are going to use for the deployment and set the environment we are going to deploy to. This is the environment in Azure DevOps, pipelines, environments.

We are using the simplest deployment called runOnce. This will execute each of the lifecycle hooks once. The lifecycle hooks being preDeploy, deploy, RouteTraffic and postRouteTraffic finishing with on: success or on: failure. We are only using the deploy lifecycle in this simple deployment.

What does the deploy do? well, it used to run steps to deploy your application. It will download the artefacts and run the steps you specify.

Steps of the deployment job

   - task: AzureResourceManagerTemplateDeployment@3
                  displayName: Create resource group for deployment
                  inputs:
                    deploymentScope: "Subscription"
                    ConnectedServiceName: ${{ parameters.connectedServiceName }}
                    subscriptionName: $(SubscriptionId)
                    location: ${{ parameters.location }}
                    templateLocation: "Linked artifact"
                    csmFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\resource-group\artifactsResourceGroup.json'
                    csmParametersFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\resource-group\artifactsResourceGroup-${{ parameters.Tier }}.parameter.json'
                    deploymentMode: "Incremental"
                    deploymentOutputs: "armOutputs"

The first step creates the resource group for the deployment, it sets the type of deployment with the deploymentScope. In other words it’s going to do the equivalent to new-azdeploy or if it was a resource group level it would be new-azresourcegroupdeployment. Some of the details are obvious what to put in so I will skip them. The csmFile is the main template file you want to run and the csmParametersFile is the parameters file.

        - task: AzurePowerShell@5
                  displayName: Get the resource group name
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: "InlineScript"
                    Inline: |
                      $var=ConvertFrom-Json '$(armOutputs)'
                      $value=$var.artifactsGroupName.value
                      Write-Host "##vso[task.setvariable variable=artefactsGroupName;]$value"
                    azurePowerShellVersion: "latestVersion"
                    pwsh: true

This task will run PowerShell and get the name of the resource group. This is picked up from the previous step. it gets the outputs from the previous step and creates a variable for ADO to use.

   - task: AzureResourceManagerTemplateDeployment@3
                  displayName: Create a storage account for deployment artifacts
                  inputs:
                    deploymentScope: "Resource Group"
                    ConnectedServiceName: ${{ parameters.connectedServiceName }}
                    subscriptionName: $(SubscriptionId)
                    action: "Create Or Update Resource Group"
                    resourceGroupName: "$(artefactsGroupName)"
                    location: ${{ parameters.location }}
                    templateLocation: "Linked artifact"
                    csmFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\storage-account-for-artifacts\azuredeploy.json'
                    csmParametersFile: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\storage-account-for-artifacts\azuredeploy.parameters.json'
                    deploymentMode: "Incremental"
                    deploymentOutputs: "armOutputs"

Using the ADO that was created in the previous step, it’s used in this step to create a storage account. The storage account will be used to store the ARM templates that we call from our main template file. The task will run the arm template which creates a storage account in the resource group we created and then outputted the outputs ready to use in the next tasks.

   - task: AzurePowerShell@5
                  displayName: Get the storage account and its container names
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: "InlineScript"
                    Inline: |
                      $var=ConvertFrom-Json '$(armOutputs)'
                      $value=$var.storageAccountName.value
                      Write-Host "##vso[task.setvariable variable=storageAccountName;]$value"
                      $value=$var.storageContainerName.value
                      Write-Host "##vso[task.setvariable variable=storageContainerName;]$value"
                    azurePowerShellVersion: "latestVersion"
                    pwsh: true

Again same as a couple of tasks ago we get the outputs and add them to ADO’s variables to use in the next tasks.

  - task: AzurePowerShell@5
                  displayName: Copy deployment artifacts to the storage account
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: 'FilePath'
                    azurePowerShellVersion: 'latestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\Copy-DeploymentArtifacts.ps1'
                    scriptArguments:
                      -StorageAccountName $(storageAccountName)
                      -BlobContainerName $(storageContainerName)
                      -TemplateFolderPath '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\Arm-templates'
                      -ResourceGroupName $(artefactsGroupName)

Now we can copy the templates, for each of our deploys to this storage account. We simply run a PowerShell script that copies the files from the build agent they are on up to the storage account container. The script also gets the SASToken and set’s it as an ADO variable to use later. We used the variables we created in ADO from the previous steps/tasks in this step.

        - task: AzurePowerShell@5
                  displayName: Run Management Groups Script
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: 'FilePath'
                    azurePowerShellVersion: 'latestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\CreateManagementStructure.ps1'
                    scriptArguments:
                      -environment ${{ parameters.environment }}

This is a Powershell script that will create the management structure if it hasn’t already been created.

     - task: AzureResourceManagerTemplateDeployment@3
                  displayName: Deploy Platform Resources
                  inputs:
                    deploymentScope: "Resource Group"
                    ConnectedServiceName: ${{ parameters.connectedServiceName }}
                    subscriptionName: $(SubscriptionId)
                    action: "Create Or Update Resource Group"
                    resourceGroupName: ${{ parameters.deploymentResourceGroup }}
                    location: ${{ parameters.location }}
                    templateLocation: "URL of the file"
                    csmFileLink: '$(artifactsLocation)linked-templates/main.json$(artifactsLocationSasToken)'
                    csmParametersFileLink: '$(artifactsLocation)linked-templates/${{ parameters.Tier }}.parameters.json$(artifactsLocationSasToken)'
                    overrideParameters: "-_Tier ${{ parameters.Tier }} -_artifactsLocation $(artifactsLocation) -_artifactsLocationSasToken $(artifactsLocationSasToken) -_SubscriptionId $(SubscriptionId) -_aSubscriptionId $(aSubscriptionId) -_lSubscriptionId $(lSubscriptionId)"
                    deploymentMode: "Incremental"
                    deploymentOutputs: "armOutputs"

This is the main event this task deploys the infrastructure. As you can see it’s repeating the same task with some different parameters, in this case, we are specifying the SASToken to access the storage account. This will take some time to deploy all of our resources.

    - task: AzurePowerShell@5
                  displayName: Pester tests
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    ScriptType: 'FilePath'
                    azurePowerShellVersion: 'LatestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\Execute-PesterTests.ps1'
                    scriptArguments:
                      -TestFile '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tests\main.tests.ps1'
                      -TemplateParameterFile '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\ARM-templates\${{ parameters.Tier }}.parameters.json'
                      -AgentBuildDirectory $(Agent.BuildDirectory)
                      -ArtifactsName ${{ parameters.artifactName }}
                      -SubscriptionId $(SubscriptionId)
                      -ASubscriptionId $(aSubscriptionId)
                      -LSubscriptionId $(lSubscriptionId)

now that the infrastructure has been deployed, we need to test it, to make sure it’s as we expected it to be. So can do this by running pester tests. We have created a number of tests that check certain things so we can check it’s deployed as we think it should be.

        - task: PublishTestResults@2
                  displayName: Publish Test Results
                  inputs:
                    testResultsFormat: 'NUnit'
                    testResultsFiles: '**/TestResults.xml'
                    searchFolder: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tests\'

now that we have completed the pester tests we need to publish them to see whats happened.

       - task: AzurePowerShell@5
                  displayName: Set Resource Locks on Resource Groups
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    ScriptType: 'FilePath'
                    azurePowerShellVersion: 'LatestVersion'
                    pwsh: true
                    scriptPath: '$(Agent.BuildDirectory)\${{ parameters.artifactName }}\tools\Set-ResourceLocks.ps1'
                    scriptArguments:
                      -SubscriptionId $(SubscriptionId)
                      -ASubscriptionId $(aSubscriptionId)
                      -LSubscriptionId $(lSubscriptionId)
                      -ArtifactsResourceGroup $(artefactsGroupName)

Once we know the infrastructure is deployed we set the resource locks in place.

         - task: AzureCLI@2
                  displayName: Remove Artifacts from Storage Account
                  condition: always()
                  inputs:
                    azureSubscription: ${{ parameters.connectedServiceName }}
                    scriptType: ps
                    scriptLocation: inlineScript
                    inlineScript: az storage account delete --yes --name $(storageAccountName) --resource-group $(artefactsGroupName)

The final step/task is to clean up. This task has a condition on it that is set to always(), so it will always run no matter what happens on the other steps. This uses the Azure CLI and removed the storage account which contains the templates.

The structure of the files/folders are below.

repo
|
|-ARM-templates
|     - Modules
|          - virtualmachine.json
|          - aks.json
|          - etc.....
|     - infra.parameters.json
|     - dev.parameters.json
|     - test.parameters.json
|     - pre-production.parameters.json
|     - production.parameters.json
|     - main.json
|-Pipelines
|     - Templates
|          - main.yml
|     - main-infra.yml
|     - main-dev.yml
|     - main-test.yml
|     - main-pre-production.yml
|     - main-production.yml
|     - pr.yml
|-Resource-Group
|     - artifactsResourceGroup-infra.parameters.json
|     - artifactsResourceGroup-dev.parameters.json
|     - artifactsResourceGroup-test.parameters.json
|     - artifactsResourceGroup-pre-production.parameters.json
|     - artifactsResourceGroup-production.parameters.json
|     - artifactsResourceGroup.json
|-Storage-Account-for-artifacts
|     - azuredeploy.json
|     - azuredeploy.parameters.json
|-tests
|     - main.tests.ps1
|-tools
|     - Copy-DeploymentArtifacts.ps1
|     - CreateManagementStructure.ps1
|     - Execute-PesterTests.ps1