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 Pipelines, Bicep, DevOps, IaC

Passing Parameters to Bicep from Azure Pipelines

In previous posts I’ve used various techniques to supply parameters to Bicep from Azure Pipelines YAML and overriding values in a parameters JSON file so I thought I would collate them in one post.

Technique #1

Using multiple template files to handle complex types and standard types separately and create a variable for each parameter that can then be used for File Transform of a parameters JSON file.

parameters:
- name: tags
  displayName: 'Tags'
  type: object
  default:
     Environment: "prod"
     Resource: "AKS"
     Project: "Demo"
 - name: clusterName
    displayName: 'Name of the AKS Cluster'
    type: string
    default: 'demo'
  - name: nodeVmSize
    displayName: 'VM Size for the Nodes'
    type: string
    default: 'Standard_D2s_V3'
    values:
      - 'Standard_D2s_V3'
      - 'Standard_DS2_v2'
      - 'Standard_D4s_V3'
      - 'Standard_DS3_v2'
      - 'Standard_DS4_v2'
      - 'Standard_D8s_v3'
  - name: nodeCount
    displayName: 'The number of nodes'
    type: number
    default: 3

- template: objectparameters.yml
  parameters:
    tags: ${{ parameters.tags }}
- template: parameters.yml
  parameters:
    clusterName: ${{ parameters.clusterName }}
    nodeVmSize: ${{ parameters.nodeVmSize }}
    nodeCount: ${{ parameters.nodeCount }}
- task: FileTransform@2
  displayName: "Transform Parameters"
  inputs:
    folderPath: '$(System.DefaultWorkingDirectory)'
    xmlTransformationRules: ''
    jsonTargetFiles: 'deploy.parameters.json'

objectParameters.yml

parameters:
  - name: tags
    type: object 
steps:
- ${{ each item in parameters }}:
  - bash: |
      value='${{ convertToJson(item.value) }}'
      echo '##vso[task.setvariable variable=parameters.${{ item.key }}.value]'$value
    displayName: "Create Variable ${{ item.key }}"

parameters.yml

parameters:
  - name: clusterName
    type: string
  - name: nodeVmSize
    type: string
  - name: nodeCount
    type: number
 
steps:
- ${{ each item in parameters }}: 
    - bash: |
        echo '##vso[task.setvariable variable=parameters.${{ item.key }}.value]${{ item.value }}'
      displayName: "Create Variable ${{ item.key }}"

Technique #2

Using a PowerShell step to read all parameters and create a variable for each parameter that can then be used for File Transform of a parameters JSON file.

parameters:
- name: tags
  displayName: 'Tags'
  type: object
  default:
     Environment: "prod"
     Resource: "AKS"
     Project: "Demo"
 - name: clusterName
    displayName: 'Name of the AKS Cluster'
    type: string
    default: 'demo'
  - name: nodeVmSize
    displayName: 'VM Size for the Nodes'
    type: string
    default: 'Standard_D2s_V3'
    values:
      - 'Standard_D2s_V3'
      - 'Standard_DS2_v2'
      - 'Standard_D4s_V3'
      - 'Standard_DS3_v2'
      - 'Standard_DS4_v2'
      - 'Standard_D8s_v3'
  - name: nodeCount
    displayName: 'The number of nodes'
    type: number
    default: 3

- ${{ each item in parameters }}:  
   - pwsh: | 
       $obj = '${{ convertToJson(item.value) }}' | ConvertFrom-Json 
       $value = ($obj | ConvertTo-Json -Compress) 
       if($obj.GetType().Name -eq "String") { 
         $value = $obj 
       }  
       Write-Host "##vso[task.setvariable variable=parameters.${{ item.key }}.value;]$value"
     displayName: "Create Variable ${{ item.key }}"

- task: FileTransform@2
  displayName: "Transform Parameters"
  inputs:
    folderPath: '$(System.DefaultWorkingDirectory)'
    xmlTransformationRules: ''
    jsonTargetFiles: 'deploy.parameters.json'

example of the PowerShell step with optional part to ignore unwanted parameters using notIn.

- ${{ each item in parameters }}:  
  - ${{ if notIn(item.key, 'myunwantedparameter') }}:
    - pwsh: | 
        $obj = '${{ convertToJson(item.value) }}' | ConvertFrom-Json 
        $value = ($obj | ConvertTo-Json -Compress) 
        if($obj.GetType().Name -eq "String") { 
          $value = $obj 
        }  
        Write-Host "##vso[task.setvariable variable=parameters.${{ item.key }}.value;]$value"
      displayName: "Create Variable ${{ item.key }}"

Technique #3

Use a template file and PowerShell to create the parameters JSON file as a whole with all the parameters.

Note: Probably best used from a template file with the required parameters, however excludeParameters allows you to ignore ones you don’t need

createParametersFile.yml

parameters: 
- name: paramsJson 
  type: string 
- name: excludeParameters
  type: object
  default: []
- name: parameterFilePath
  type: string
  default: main.parameters.json
steps: 
- pwsh: | 
    $obj = '${{ parameters.paramsJson }}' | ConvertFrom-Json -AsHashtable
    $excludeList = '${{ convertToJson(parameters.excludeParameters) }}' | ConvertFrom-Json -Depth 10
    $header = [ordered]@{ 
      schema = "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#" 
      contentVersion = "1.0.0.0" 
      parameters = @{} 
    } 
    $valueObject = New-Object -TypeName PsObject 
    foreach ($item in $obj.GetEnumerator()) 
    {
       if ($item.Name -notin $excludeList) {
           $value = @{ 
             value = $item.Value 
           }
           Add-Member -InputObject $valueObject -MemberType NoteProperty -Name $item.Name -Value $value
       }
    } 
    $header.parameters = $valueObject 
    Set-Content ${{ parameters.parameterFilePath }} ($header | ConvertTo-Json -Depth 10)   

Call the template:

parameters:
- name: tags
  displayName: 'Tags'
  type: object
  default:
     Environment: "prod"
     Resource: "AKS"
     Project: "Demo"
 - name: clusterName
    displayName: 'Name of the AKS Cluster'
    type: string
    default: 'demo'
  - name: nodeVmSize
    displayName: 'VM Size for the Nodes'
    type: string
    default: 'Standard_D2s_V3'
    values:
      - 'Standard_D2s_V3'
      - 'Standard_DS2_v2'
      - 'Standard_D4s_V3'
      - 'Standard_DS3_v2'
      - 'Standard_DS4_v2'
      - 'Standard_D8s_v3'
  - name: nodeCount
    displayName: 'The number of nodes'
    type: number
    default: 3

- template: createParametersFile.yml
  parameters:
     paramsJson: '${{ convertToJson(parameters) }}'