Azure Pipelines, DevOps

Dynamic Multistage Azure Pipelines Part 1

In a previous post I looked at multistage YAML pipelines. In this post I am going to look at dynamic multistage YAML pipelines.

What do I mean by dynamic multistage? What I mean is running multiple stages but all of the configuration is loaded dynamically from one or more sources e.g. parameters, variable templates, variable groups, etc..

Why?

What problem am I trying to solve with this? Firstly, reduce duplication, in a lot of cases the difference between dev and prod is just the configuration. Secondly, provide the ground work to get a base setup so that I can concentrate on what steps are needed in the pipeline and not worry about the environments.

Anything else? Well, I often have multiple projects that all need to deploy to the same set of environments, it would be good to share the configuration for that as well between projects.

Next Steps

Ok, I need a pipeline, lets start with something simple, a pipeline with an initial build stage and then multiple deployment stages defined by a parameter:

trigger: none 
pr: none 

pool:  
  vmImage: 'ubuntu-latest' 

parameters:
- name: stages
  type: object
  default:
    - 'dev'
    - 'prod'

stages:
- stage: build
  displayName: 'Build/Package Code or IaC'  
  jobs:  
  - job: build
    displayName: 'Build/Package Code'
    steps:
    # Steps to perform the build and/or package of code or IaC

- ${{ each stage in parameters.stages }}:
  - stage: ${{ stage }}
    displayName: 'Deploy to ${{ stage }}'
    jobs:
    - deployment: deploy_${{ stage }}
      displayName: 'Deploy app to ${{ stage }}'
      environment: ${{ stage }}
      strategy:
        runOnce:
          deploy:
            steps:
            # Steps to perform the deployment

This very small example achieves configuring multiple deployment stages, adding another stage to this would be very easy to do, just update the parameter to include a new stage name.

Now we have the basic configuration lets add loading of a variable group. This could be done by using dynamic naming or by changing the stages parameter.

I have a variable group for each environment, groupvars_dev, groupvars_prod with a single variable mygroupvar.

Dynamic Naming

I’ll add the variable group to the variables at the Stage level (this could also be done at the job level) and include the stage name dynamically.

- ${{ each stage in parameters.stages }}:
  - stage: ${{ stage }}
    displayName: 'Deploy to ${{ stage }}'
    variables:
      - group: groupvars_${{ stage }}
    jobs:
    - deployment: deploy_${{ stage }}
      displayName: 'Deploy app to ${{ stage }}'
      environment: ${{ stage }}
      strategy:
        runOnce:
          deploy:
            steps:
            - bash: |
                echo '$(mygroupvar)'
              displayName: 'Deploy Steps'

Parameter Change

Another way to define the dynamic group is to update the parameter object to provide additional configuration e.g.

parameters:
- name: stages
  type: object
  default:
    - name: 'dev'
      group: 'groupvars_dev'
    - name: 'prod'
      group: 'groupvars_prod'

   ...

- ${{ each stage in parameters.stages }}:
  - stage: ${{ stage.name }}
    displayName: 'Deploy to ${{ stage.name }}'
    variables:
      - group: ${{ stage.group }}
    jobs:
    - deployment: deploy_${{ stage.name }}
      displayName: 'Deploy app to ${{ stage.name }}'
      environment: ${{ stage.name }}
      strategy:
        runOnce:
          deploy:
            steps:
            - bash: |
                echo '$(mygroupvar)'
              displayName: 'Deploy Steps'

Both ways of adding the variable group dynamically achieved the same goal and loaded in the expected group when each stage ran.

Variable Templates

Variable groups are not the only way to dynamically load variables, you could also use variable templates, lets say I have variable templates for each environment, vars_dev.yml and vars_prod.yml

Using dynamic naming you can load the variables like this:

- ${{ each stage in parameters.stages }}:
  - stage: ${{ stage }}
    displayName: 'Deploy to ${{ stage }}'
    variables:
      - template: vars_${{ stage }}.yml
    jobs:
    - deployment: deploy_${{ stage }}
      displayName: 'Deploy app to ${{ stage }}'
      environment: ${{ stage }}
      strategy:
        runOnce:
          deploy:
            steps:
            - bash: |
                echo '$(myfilevar)'
              displayName: 'Deploy Steps'

Now with variable files and groups being added, updating to add a new stage becomes a little more complex as I would need to add those as well.

Shared Template

Now I have a dynamic multistage pipeline, how can I create a template to share with other projects?

Before I answer that I should say that I usually use a separate repository for shared templates that way I can version them. I covered this is a previous post if you want some more information.

Ok, on to the how, based on the above scenario wouldn’t it be great to have a really simple pipeline that concentrated on just the steps, like this?

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:
    variableFilePrefix: 'vars'
    buildSteps:
        # Steps to perform the build and/or package of code or IaC
    releaseSteps:
       # Steps to perform the deployment

This could be your boilerplate code for multiple projects extending from a base template. You might be asking but how do I create such a template?

Lets convert what we started with into a template a bit at a time.

Firstly create a new file e.g. environments.yml to be the base template and add the parameters that make up the stage configuration

parameters:
- name: stages
  type: object
  default:
    - 'dev'
    - 'prod'

Next, add the build stage up to the steps

stages:
- stage: build
  displayName: 'Build/Package Code or IaC'  
  jobs:  
  - job: build
    displayName: 'Build/Package Code'
    steps:

At this point we need to be able to pass in the build steps, using the Azure Pipeline built-in type stepList we can add a parameter ‘buildSteps’:

parameters:
- name: stages
  type: object
  default:
    - 'dev'
    - 'prod'
- name: buildSteps  
  type: stepList  
  default: []

stages:
- stage: build
  displayName: 'Build/Package Code or IaC'  
  jobs:  
  - job: build
    displayName: 'Build/Package Code'
    steps: ${{ parameters.buildSteps }}

Next, add the dynamic stages up to the steps

- ${{ each stage in parameters.stages }}:
  - stage: ${{ stage }}
    displayName: 'Deploy to ${{ stage }}'
    jobs:
    - deployment: deploy_${{ stage }}
      displayName: 'Deploy app to ${{ stage }}'
      environment: ${{ stage }}
      strategy:
        runOnce:
          deploy:
            steps:

And then as before, add a stepList for the release steps

parameters:
- name: stages
  type: object
  default:
    - 'dev'
    - 'prod'
- name: buildSteps  
  type: stepList  
  default: []
- name: releaseSteps  
  type: stepList  
  default: []

stages:
- stage: build
  displayName: 'Build/Package Code or IaC'  
  jobs:  
  - job: build
    displayName: 'Build/Package Code'
    # Steps to perform the build and/or package of code or IaC
    steps: ${{ parameters.buildSteps }}

- ${{ each stage in parameters.stages }}:
  - stage: ${{ stage }}
    displayName: 'Deploy to ${{ stage }}'
    variables:
      - template: vars_${{ stage }}.yml
    jobs:
    - deployment: deploy_${{ stage }}
      displayName: 'Deploy app to ${{ stage }}'
      environment: ${{ stage }}
      strategy:
        runOnce:
          deploy:
            steps: ${{ parameters.releaseSteps }}

The next part is adding support for variable groups and/or templates. This can be achieved by the addition of 2 parameters for the name prefixes e.g.

- name: variableGroupPrefix  
  type: string  
  default: ''  
- name: variableFilePrefix  
  type: string  
  default: ''  

There will also need to a be check to only load the group and/or file if the parameter is not empty ”.

parameters:
- name: stages
  type: object
  default:
    - 'dev'
    - 'prod'
- name: buildSteps  
  type: stepList  
  default: []
- name: releaseSteps  
  type: stepList  
  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 to perform the build and/or package of code or IaC
    steps: ${{ parameters.buildSteps }}

- ${{ each stage in parameters.stages }}:
  - stage: ${{ stage }}
    displayName: 'Deploy to ${{ stage }}'
    variables:
      - ${{ if ne(parameters.variableGroupPrefix, '') }}:
        - group: ${{ parameters.variableGroupPrefix }}_${{ stage }}
      - ${{ if ne(parameters.variableFilePrefix, '') }}:
        - template: ${{ parameters.variableFilePrefix }}_${{ stage }}.yml
    jobs:
    - deployment: deploy_${{ stage }}
      displayName: 'Deploy app to ${{ stage }}'
      environment: ${{ stage }}
      strategy:
        runOnce:
          deploy:
            steps: ${{ parameters.releaseSteps }}

Note: If I was running this template from the same repository, loading of the variable file would be fine but when it’s in a separate repository there needs to be a slight adjustment to add @self on the end so it will load from the calling repository instead of the remote repository.

- template: ${{ parameters.variableFilePrefix }}_${{ stage }}.yml@self

And that is it, one base template that handles the desired configuration and ready for reuse.

Expanding the Concept

Lets say you had a requirement to deploy multiple projects IaC (Infrastructure as Code) and applications to multiple subscriptions and multiple regions in your Azure Estate. How nice would it be to be able to define that in a central configuration. Here is one possible configuration for such a requirement

parameters:
- name: environments
  type: object
  default:
  - name: 'dev'
    subscriptions:
      - subscription: 'Dev Subscription'
        regions:
          - 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 }}

You may notice with this configuration there is an option for a custom release template where you could override the job(s) required, you would just need to make sure the template included the parameters supplied from the base template:

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

Then you can add the custom jobs for a given project.

Final Thoughts

Shared templates are so powerful to use and combined with the often forgotten about built-in types step, stepList, job, jobList, deployment, deploymentList, stage and stageList, really allows for some interesting templates to be created.

For additional information see the Azure Pipelines Parameters docs.

You are no doubt thinking, this all sounds very good but what about real application of such a template? In the next post I will use this last template to deploy some Infrastructure as Code to Azure and then deploy an application into that infrastructure to show real usage.

Azure Pipelines, DevOps

Optional Object/Array Checks in Azure Pipelines

Sometimes when building Azure Pipelines in YAML you want to create a template with optional parameters and then test those parameters have a value.

For a string value this is straight forward e.g.

parameters:
- name: project
   type: string
   default: ''
${{ if eq(parameters.project, '') }}: 
or 
${{ if ne(parameters.project, '') }}:

When it comes to objects or arrays, creating the optional parameter is still straight forward e.g.

parameters:
- name: projects
   type: object
   default: []
- name: envs
   type: object
   default: {}

However, testing if they are empty is not quite so simple as you cannot just do an equality test like with string parameters e.g.

${{ if eq(parameters.envs, {}) }}: 
or
${{ if ne(parameters.envs, {}) }}:

${{ if eq(parameters.projects, []) }}: 
or
${{ if ne(parameters.projects, []) }}:

To get around this we can do a bit of trickery using convertToJson to provide a string representation of the object or array e.g.

${{ if eq(convertToJson(parameters.envs), '{}') }}: 
or 
${{ if ne(convertToJson(parameters.envs), '{}') }}:

${{ if eq(convertToJson(parameters.projects), '[]') }}:
or
${{ if ne(convertToJson(parameters.projects), '[]') }}:

Now this technique all sounds good but what about a real world example where it might be useful

So, I have a shared .NET build template (build-code.yml) and for some projects the dotnet test run requires some environment variables. We can pass the required environment variables in using the normal YAML object syntax e.g.

- template: build-code.yml
  parameters:
    version: '6.0.x'
    envs:
      appEndpoint: $(appEndpoint)
      databaseEndpoint: $(databaseEndpoint)
      apiEndpoint: $(apiEndpoint)

These are then used in build-code.yml template e.g.

parameters:
- name: version
  type: string
  default: ''
- name: artifactName
  type: string
  default: 'code'
- name: publishWebProjects
  type: boolean
  default: true
- name: zipAfterPublish
  type: boolean
  default: false
- name: envs
  type: object
  default: {}

variables: 
  buildConfiguration: 'Release'

steps:
- task: UseDotNet@2
  displayName: 'Use .NET version'
  inputs:
    packageType: 'sdk'
    ${{ if ne(parameters.version, '') }}:
      version: '${{ parameters.version }}'
    ${{ if eq(parameters.version, '') }}:
      useGlobalJson: true
- task: DotNetCoreCLI@2
  displayName: 'Restore Packages'
  inputs:
    command: restore
    projects: '**/*.csproj'
- task: DotNetCoreCLI@2
  displayName: 'Build Projects'
  inputs:
    command: 'build'
    projects: '**/*.csproj'
    arguments: '--no-restore --configuration $(buildConfiguration)'
- task: DotNetCoreCLI@2
  displayName: 'Run Tests'
  inputs:
    command: test
    projects: '**/*Tests.csproj'
    arguments: '--no-restore --no-build --configuration $(buildConfiguration)'
  ${{ if ne(convertToJson(parameters.envs), '{}') }}:
    env:
      ${{ parameters.envs }}

If the environment variables are not needed for a project then they can be simply omitted e.g.

- template: build-code.yml
  parameters:
    version: '6.0.x'

I don’t like to use too many optional parts in a template as it can get very complex but for this usage it works quite well