Azure, Azure Pipelines, DevOps, IaC, Terraform

Using Containers to Share Terraform Modules and Deploy with Azure Pipelines

I’ve been using a container for running Terraform for a while but just for local development. More recently though the need to share modules has become more prevalent.

One solution for this is to use a container to not only share modules for development but for deployment as well. This also allows the containers to be versioned, limiting breaking changes affecting multiple pipelines at once.

In this post I am going to cover:

  • Building a container with shared terraform modules
  • Pushing the built container to Azure Container Registry
  • Configuring the dev environment to use the built container
  • Deploy infrastructure using the built container

NOTE: All of the code used here can be found on my GitHub including the shared modules.

Prerequisites

For this post I will running on Windows and using the following programs:

Building the Container

The container needs to not only have what is needed for development but what is needed to run as a container job in Azure Pipelines e.g. Node. The Microsoft Docs provide more detail about this.

The container is an Alpine Linux base with Node, PowerShell Core, Azure CLI and Terraform installed.

Dockerfile

ARG IMAGE_REPO=alpine
ARG IMAGE_VERSION=3
ARG TERRAFORM_VERSION
ARG POWERSHELL_VERSION
ARG NODE_VERSION=lts-alpine3.14

FROM node:${NODE_VERSION} AS node_base
RUN echo "NODE Version:" && node --version
RUN echo "NPM Version:" && npm --version

FROM ${IMAGE_REPO}:${IMAGE_VERSION} AS installer-env
ARG TERRAFORM_VERSION
ARG POWERSHELL_VERSION
ARG POWERSHELL_PACKAGE=powershell-${POWERSHELL_VERSION}-linux-alpine-x64.tar.gz
ARG POWERSHELL_DOWNLOAD_PACKAGE=powershell.tar.gz
ARG POWERSHELL_URL=https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/${POWERSHELL_PACKAGE}
RUN apk upgrade --update && \
    apk add --no-cache bash wget curl

# Terraform
RUN wget --quiet https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip && \
    unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip && \
    mv terraform /usr/bin
    
# PowerShell Core
RUN curl -s -L ${POWERSHELL_URL} -o /tmp/${POWERSHELL_DOWNLOAD_PACKAGE}&& \
    mkdir -p /opt/microsoft/powershell/7 && \
    tar zxf /tmp/${POWERSHELL_DOWNLOAD_PACKAGE} -C /opt/microsoft/powershell/7 && \
    chmod +x /opt/microsoft/powershell/7/pwsh 

FROM ${IMAGE_REPO}:${IMAGE_VERSION} 
ENV NODE_HOME /usr/local/bin/node
# Copy only the files we need from the previous stages
COPY --from=installer-env ["/usr/bin/terraform", "/usr/bin/terraform"]
COPY --from=installer-env ["/opt/microsoft/powershell/7", "/opt/microsoft/powershell/7"]
RUN ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh
COPY --from=node_base ["${NODE_HOME}", "${NODE_HOME}"]

# Copy over Modules
RUN mkdir modules
COPY modules modules

LABEL maintainer="Coding With Taz"
LABEL "com.azure.dev.pipelines.agent.handler.node.path"="${NODE_HOME}"

ENV APK_DEV "gcc libffi-dev musl-dev openssl-dev python3-dev make"
ENV APK_ADD "bash sudo shadow curl py3-pip graphviz git"
ENV APK_POWERSHELL="ca-certificates less ncurses-terminfo-base krb5-libs libgcc libintl libssl1.1 libstdc++ tzdata userspace-rcu zlib icu-libs"
# Install additional packages
RUN apk upgrade --update && \
    apk add --no-cache --virtual .pipeline-deps readline linux-pam && \
    apk add --no-cache --virtual .build ${APK_DEV} && \
    apk add --no-cache ${APK_ADD} ${APK_POWERSHELL} && \
    # Install Azure CLI
    pip --no-cache-dir install --upgrade pip && \
    pip --no-cache-dir install wheel && \
    pip --no-cache-dir install azure-cli && \
    apk del .build && \
    apk del .pipeline-deps 

RUN echo "PS1='\n\[\033[01;35m\][\[\033[0m\]Terraform\[\033[01;35m\]]\[\033[0m\]\n\[\033[01;35m\][\[\033[0m\]\[\033[01;32m\]\w\[\033[0m\]\[\033[01;35m\]]\[\033[0m\]\n \[\033[01;33m\]->\[\033[0m\] '" >> ~/.bashrc 
CMD tail -f /dev/null

The container can be built locally using the docker build command and providing the PowerShell and Terraform versions e.g.

docker build --build-arg TERRAFORM_VERSION="1.0.10" --build-arg POWERSHELL_VERSION="7.1.5" -t my-terraform .

Pushing the container to Azure Container Registry

Next thing to do is to build the container and push it to the Azure Container Registry (if you need to know how to set that up in Azure DevOps see my previous post on Configuring ACR). In this pipeline I have also added a Snyk scan to check for vulnerabilities in my container (happy to report there wasn’t any at the time of writing). If you are not familiar with Snyk I recommend you check out their website.

For the build number I have used the version of Terraform and then the date and revision but you can use whatever makes sense for example you could use Semver.

I also setup some pipeline variables for the container registry connection and the container registry name e.g. <your registry>.azurecr.io

trigger: 
    - main

pr: none

name: $(terraformVersion)_$(Date:yyyyMMdd)$(Rev:.r)

variables:
 dockerFilePath: dockerfile
 imageRepository: iac/terraform
 terraformVersion: 1.0.10
 powershellVersion: 7.1.5

pool:
  vmImage: "ubuntu-latest"

steps:
  - task: Docker@2
    displayName: "Build Terraform Image"
    inputs:
      containerRegistry: '$(containerRegistryConnection)'
      repository: '$(imageRepository)'
      command: 'build'
      Dockerfile: '$(dockerfilePath)'
      arguments: '--build-arg TERRAFORM_VERSION="$(terraformVersion)" --build-arg POWERSHELL_VERSION="$(powershellVersion)"'
      tags: | 
        $(Build.BuildNumber)
  - task: SnykSecurityScan@1
    inputs:
      serviceConnectionEndpoint: 'Snyk'
      testType: 'container'
      dockerImageName: '$(containerRegistry)/$(imageRepository):$(Build.BuildNumber)'
      dockerfilePath: '$(dockerfilePath)'
      monitorWhen: 'always'
      severityThreshold: 'high'
      failOnIssues: true
  - task: Docker@2
    displayName: "Build and Push Terraform Image"
    inputs:
      containerRegistry: '$(containerRegistryConnection)'
      repository: '$(imageRepository)'
      command: 'Push'
      Dockerfile: '$(dockerfilePath)'
      tags: | 
        $(Build.BuildNumber)

Once the container is built it can be viewed in the Azure Portal inside your Azure Container Registry.

Configuring the Dev Environment

Now the container has been created and pushed to the Azure Container Registry the next job is to configure Visual Studio Code.

To start with we need to make sure the extension Remote Containers is installed in Visual Studio Code

In the project where you want to use the container, create a folder called .devcontainer and then a file inside the folder called devcontainer.json and add the following (updating the container registry and container details e.g. name, version, etc.)

// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.205.1/containers/docker-existing-dockerfile
{
	"name": "Terraform Dev",

	// Sets the run context to one level up instead of the .devcontainer folder.
	"context": "..",

	// Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
	"image": "<your container registry>.azurecr.io/iac/terraform:1.0.10_20211108.1",

	// Set *default* container specific settings.json values on container create.
	"settings": {},
	
	// Add the IDs of extensions you want installed when the container is created.
	"extensions": [
		"ms-vscode.azure-account",
		"ms-azuretools.vscode-azureterraform",
		"hashicorp.terraform",
		"ms-azure-devops.azure-pipelines"
	]
}

NOTE: You may notice that there is a number of extensions in the above config. I use these extensions in Visual Studio Code for Terraform, Azure Pipelines, etc. and therefore they would also need installing in order to make use of them in the container environment.

TIP: If you right-click on an extension in Visual Studio Code and select ‘Copy Extension ID’ you can easily get the extension information you need to add other extensions to the list.

Now, make sure to login to the Azure Container Registry (either in another window or the terminal in Visual Studio Code) with the Azure CLI for authentication e.g.

az acr login -n <your container registry name>

This needs to be done to be able to pull down the container. Once the login is successful, select the icon in the bottom left of Visual Studio Code to ‘Open a Remote Window’

Then select ‘Reopen in Container’ this will download the container from the Azure Container Registry and load up the project in the container (this can take a minute or so first time).

Once the project is loaded you can create Terraform files as normal and take advantage of the shared modules inside the container.

So lets create a small example. I work a lot in Azure so I am using a shared module to create an Azure Function and another module to format the naming convention for the resources.

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 2.83"
    }
  }
  backend "local" {}
  required_version = ">= 1.0.10"
}

provider "azurerm" {
  features {}
}


module "rgname" {
    source        = "/modules/naming"
    name          = "myapp"
    env           = "rg-${var.env}"
    resource_type = ""
    location      = var.location
    separator     = "-"
}

resource "azurerm_resource_group" "rg" {
  name     = module.rgname.result
  location = "uksouth"
}

module "funcApp" {
  source                    = "/modules/linux_azure_function"
  resource_group            = azurerm_resource_group.rg.name
  resource_group_location   = azurerm_resource_group.rg.location
  env                       = var.env
  appName                   = var.appName
  funcWorkerRuntime         = "dotnet-isolated"
  dotnetVersion             = "v5.0"
  additionalFuncAppSettings = {
    mysetting = "somevalue"
  }
  tags                      = var.tags
}

From the terminal window I can now authenticate to Azure by logging in via the CLI

az login

Then I can run the terraform commands

terraform init
terraform plan

This produces the terraform plan for the resources that would be created.

Deploy Infrastructure Using the Container

So now I have created a new terraform configuration its time to deploy the changes using the same container.

To do this I am using Azure Pipelines YAML. There are several parts to the pipeline, firstly, in order to store the state for the pipeline there needs to be an Azure Storage Account to store the state file. I like to add this to the pipeline using Azure CLI so that the account is created if it doesn’t exist but also updates it if there are changes.

 - task: AzureCLI@2
    displayName: 'Create/Update State File Storage'
    inputs:
        azureSubscription: '$(subscription)'
        scriptType: bash
        scriptLocation: inlineScript
        inlineScript: |
          az group create --location $(location) --name $(terraformGroup)
          az storage account create --name $(terraformStorageName) --resource-group $(terraformGroup) --location $(location) --sku $(terraformStorageSku) --min-tls-version TLS1_2 --https-only true --allow-blob-public-access false
          az storage container create --name $(terraformContainerName) --account-name $(terraformStorageName)
        addSpnToEnvironment: false

The terraform backend configuration is set to local for development and so I need a step in the pipeline to update it to use backend “azurerm”.

  - bash: |
      sed -i 's/backend "local" {}/backend "azurerm" {}/g' main.tf
    displayName: 'Update Backend in terraform file'

For the Terraform commands I tend to use the Microsoft Terraform Tasks with additional command options for the plan file

- task: TerraformTaskV2@2
    displayName: 'Terraform Init'
    inputs:
      backendServiceArm: '$(subscription)'
      backendAzureRmResourceGroupName: '$(terraformGroup)'
      backendAzureRmStorageAccountName: '$(terraformStorageName)'
      backendAzureRmContainerName: '$(terraformContainerName)'
      backendAzureRmKey: '$(terraformStateFilename)'
  - task: TerraformTaskV2@2
    displayName: 'Terraform Plan'
    inputs:
      command: plan
      commandOptions: '-out=tfplan'
      environmentServiceNameAzureRM: '$(subscription)'
  - task: TerraformTaskV2@2
    displayName: 'Terraform Apply'
    inputs:
      command: apply
      commandOptions: '-auto-approve tfplan'
      environmentServiceNameAzureRM: '$(subscription)'

So, putting it all together the whole pipeline looks likes this:

trigger:
   - main
 
pr: none
parameters:
  - name: env
    displayName: 'Environment'
    type: string
    default: 'dev'
    values:
      - dev
      - test
      - prod
  - name: location
    displayName: 'Resource Location'
    type: string
    default: 'uksouth'
  - name: appName
    displayName: 'Application Name'
    type: string
    default: 'myapp'
  - name: tags 
    displayName: 'Tags'
    type: object 
    default: 
     Environment: "dev"
     Project: "Demo"
variables:
  isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]
  location: 'uksouth'
  terraformGroup: 'rg-dev-terraform-uksouth'
  terraformStorageName: 'devterraformuksouth2329'
  terraformStorageSku: 'Standard_LRS'
  terraformContainerName: 'infrastructure'
  terraformStateFilename: 'deploy.tfstate'
  
jobs:
- job: infrastructure
  displayName: 'Build Infrastructure'
  pool:
    vmImage: ubuntu-latest
  container:
    image: $(containerRegistry)/iac/terraform:1.0.10_20211108.1
    endpoint: 'ACR Connection'
  steps:
  - task: AzureCLI@2
    displayName: 'Create/Update State File Storage'
    inputs:
        azureSubscription: '$(subscription)'
        scriptType: bash
        scriptLocation: inlineScript
        inlineScript: |
          az group create --location $(location) --name $(terraformGroup)
          az storage account create --name $(terraformStorageName) --resource-group $(terraformGroup) --location $(location) --sku $(terraformStorageSku) --min-tls-version TLS1_2 --https-only true --allow-blob-public-access false
          az storage container create --name $(terraformContainerName) --account-name $(terraformStorageName)
        addSpnToEnvironment: false
  - bash: |
      sed -i 's/backend "local" {}/backend "azurerm" {}/g' main.tf
    displayName: 'Update Backend in terraform file'
  - template: 'autovars.yml'
    parameters:
      env: ${{ parameters.env }}
      location: ${{ parameters.location }}
      appName: ${{ parameters.appName }}
      tags: ${{ parameters.tags }}
  - task: TerraformTaskV2@2
    displayName: 'Terraform Init'
    inputs:
      backendServiceArm: '$(subscription)'
      backendAzureRmResourceGroupName: '$(terraformGroup)'
      backendAzureRmStorageAccountName: '$(terraformStorageName)'
      backendAzureRmContainerName: '$(terraformContainerName)'
      backendAzureRmKey: '$(terraformStateFilename)'
  - task: TerraformTaskV2@2
    displayName: 'Terraform Plan'
    inputs:
      command: plan
      commandOptions: '-out=tfplan'
      environmentServiceNameAzureRM: '$(subscription)'
  - task: TerraformTaskV2@2
    displayName: 'Terraform Apply'
    inputs:
      command: apply
      commandOptions: '-auto-approve tfplan'
      environmentServiceNameAzureRM: '$(subscription)'

As with the container build pipeline I used some pipeline variables here for the subscription connection and the container registry e.g. <your registry>.azurecr.io

After the pipeline ran, a quick check in the Azure Portal shows the resources were created as expected

Final Thoughts

I really like using containers for local development and with the remote containers extension for Visual Studio Code its great to be able to run from within a container and share code in this way. I am sure that other things could be shared using this method too.

Being able to version the containers and isolate breaking changes across multiple pipelines is also a bonus. I expect this process could be better, maybe even include pinning of provider versions in Terraform, etc. but its a good start.

Azure Pipelines, Bicep, DevOps, IaC

Azure Pipelines – Deploy AKS with Bicep

In this post we are going to look at deploying an AKS cluster using Azure Pipelines YAML and Bicep.

If you are new to AKS then take a look at the video series AKS Zero to Hero from Richard Hooper (aka PixelRobots) and Gregor Suttie as well as the learning path from Brendan Burns.

If you are new to Pipelines and Bicep then checkout this Microsoft Learn course to give an introduction.

So, on to creating the AKS cluster using Bicep.

The resources we are going to deploy are:

  • Virtual Network
  • Log Analytics Workspace
  • AKS Cluster
  • Container Registry

We are also going to add Azure AD groups to lockdown the cluster administration and connect the container registry to allow AKS to pull containers from the registry.

Bicep

So let’s start with creating a module for the Virtual network, we need a name for the network and subnet as well as some address prefixes and tags.

@description('The virtual network name')
param vnetName string
@description('The name of the subnet')
param subnetName string
@description('The virtual network address prefixes')
param vnetAddressPrefixes array
@description('The subnet address prefix')
param subnetAddressPrefix string
@description('Tags for the resources')
param tags object

resource vnet 'Microsoft.Network/virtualNetworks@2019-11-01' = {
  name: vnetName
  location: resourceGroup().location
  properties: {
    addressSpace: {
      addressPrefixes: vnetAddressPrefixes
    }
    subnets: [
      {
        name: subnetName
        properties: {
          addressPrefix: subnetAddressPrefix
        }
      }      
    ]
  }
  tags: tags
}

output subnetId string = '${vnet.id}/subnets/${subnetName}'

The next module then is the AKS cluster itself, there is a lot of settings that you might want to control but I’ve added defaults for some of them. This module also includes creation of an Log Analytics workspace and the renaming of the AKS resource group that normally is prefixed with MC_ to something inline with the used naming convention.

@description('The environment prefix of the Managed Cluster resource e.g. dev, prod, etc.')
param prefix string
@description('The name of the Managed Cluster resource')
param clusterName string
@description('Resource location')
param location string = resourceGroup().location
@description('Kubernetes version to use')
param kubernetesVersion string = '1.20.7'
@description('The VM Size to use for each node')
param nodeVmSize string
@minValue(1)
@maxValue(50)
@description('The number of nodes for the cluster.')
param nodeCount int
@maxValue(100)
@description('Max number of nodes to scale up to')
param maxNodeCount int
@description('The node pool name')
param nodePoolName string = 'linux1'
@minValue(0)
@maxValue(1023)
@description('Disk size (in GB) to provision for each of the agent pool nodes. This value ranges from 0 to 1023. Specifying 0 will apply the default disk size for that agentVMSize')
param osDiskSizeGB int
param nodeAdminUsername string
@description('Availability zones to use for the cluster nodes')
param availabilityZones array = [
  '1'
  '2'
  '3'
]
@description('Allow the cluster to auto scale to the max node count')
param enableAutoScaling bool = true
@description('SSH RSA public key for all the nodes')
@secure()
param sshPublicKey string
@description('Tags for the resources')
param tags object
@description('Log Analytics Workspace Tier')
@allowed([
  'Free'
  'Standalone'
  'PerNode'
  'PerGB2018'
  'Premium'
])
param workspaceTier string
@allowed([
  'azure'  
])
@description('Network plugin used for building Kubernetes network')
param networkPlugin string = 'azure'
@description('Subnet id to use for the cluster')
param subnetId string
@description('Cluster services IP range')
param serviceCidr string = '10.0.0.0/16'
@description('DNS Service IP address')
param dnsServiceIP string = '10.0.0.10'
@description('Docker Bridge IP range')
param dockerBridgeCidr string = '172.17.0.1/16'
@description('An array of AAD group object ids for administration')
param adminGroupObjectIDs array = []

resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-10-01' = {
  name: '${prefix}-oms-${clusterName}-${resourceGroup().location}'
  location: location
  properties: {
    sku: {
      name: workspaceTier
    }
  }
  tags: tags
}

resource aksCluster 'Microsoft.ContainerService/managedClusters@2021-03-01' = {
  name: '${prefix}-aks-${clusterName}-${location}'
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  tags: tags  
  properties: {
    nodeResourceGroup: 'rg-${prefix}-aks-nodes-${clusterName}-${location}'
    kubernetesVersion: kubernetesVersion
    dnsPrefix: '${clusterName}-dns'
    enableRBAC: true    
    agentPoolProfiles: [
      {        
        name: nodePoolName
        osDiskSizeGB: osDiskSizeGB
        osDiskType: 'Ephemeral'        
        count: nodeCount
        enableAutoScaling: enableAutoScaling
        minCount: nodeCount
        maxCount: maxNodeCount
        vmSize: nodeVmSize        
        osType: 'Linux'
        type: 'VirtualMachineScaleSets'
        mode: 'System'
        availabilityZones: availabilityZones
        enableEncryptionAtHost: true
        vnetSubnetID: subnetId
      }
    ]
    networkProfile: {      
      loadBalancerSku: 'standard'
      networkPlugin: networkPlugin
      serviceCidr: serviceCidr
      dnsServiceIP: dnsServiceIP
      dockerBridgeCidr: dockerBridgeCidr
    }
    aadProfile: !empty(adminGroupObjectIDs) ? {
      managed: true
      adminGroupObjectIDs: adminGroupObjectIDs
    } : null
    addonProfiles: {
      azurepolicy: {
        enabled: false
      }
      omsAgent: {
        enabled: true
        config: {
          logAnalyticsWorkspaceResourceID: logAnalyticsWorkspace.id
        }
      }   
    }
    linuxProfile: {      
      adminUsername: nodeAdminUsername
      ssh: {
        publicKeys: [
          {
            keyData: sshPublicKey
          }
        ]
      }      
    }
  }

  dependsOn: [
    logAnalyticsWorkspace    
  ]
}

output controlPlaneFQDN string = reference('${prefix}-aks-${clusterName}-${location}').fqdn
output clusterPrincipalID string = aksCluster.properties.identityProfile.kubeletidentity.objectId

The final module is building an Azure Container Registry and assigning the ACR Pull role for the cluster

@description('The name of the container registry')
param registryName string
@description('The principal ID of the AKS cluster')
param aksPrincipalId string
@description('Tags for the resources')
param tags object

@allowed([
  'b24988ac-6180-42a0-ab88-20f7382dd24c' // Contributor
  'acdd72a7-3385-48ef-bd42-f606fba81ae7' // Reader
])
param roleAcrPull string = 'b24988ac-6180-42a0-ab88-20f7382dd24c'

resource containerRegistry 'Microsoft.ContainerRegistry/registries@2019-05-01' = {
  name: registryName
  location: resourceGroup().location
  sku: {
    name: 'Standard'
  }
  properties: {
    adminUserEnabled: true
  }
  tags: tags
}

resource assignAcrPullToAks 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
  name: guid(resourceGroup().id, registryName, aksPrincipalId, 'AssignAcrPullToAks')
  scope: containerRegistry
  properties: {
    description: 'Assign AcrPull role to AKS'
    principalId: aksPrincipalId
    principalType: 'ServicePrincipal'
    roleDefinitionId: '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/${roleAcrPull}'
  }
}

output name string = containerRegistry.name

So now we have the all the modules lets setup the main bicep file to put it all together

@description('Naming prefix for the resources e.g. dev, test, prod')
param prefix string
@description('The public SSH key')
@secure()
param publicsshKey string
@description('The name of the cluster')
param clusterName string
@description('The location of the resources')
param location string = resourceGroup().location
@description('The admin username for the nodes in the cluster')
param nodeAdminUsername string
@description('An array of AAD group object ids to give administrative access.')
param adminGroupObjectIDs array = []
@description('The VM size to use in the cluster')
param nodeVmSize string
@minValue(1)
@maxValue(50)
@description('The number of nodes for the cluster.')
param nodeCount int = 1
@maxValue(100)
@description('Max number of nodes to scale up to')
param maxNodeCount int = 3
@description('Disk size (in GB) to provision for each of the agent pool nodes. This value ranges from 0 to 1023. Specifying 0 will apply the default disk size for that agentVMSize')
param osDiskSizeGB int
@description('Log Analytics Workspace Tier')
@allowed([
  'Free'
  'Standalone'
  'PerNode'
  'PerGB2018'
  'Premium'
])
param workspaceTier string
@description('The virtual network address prefixes')
param vnetAddressPrefixes array
@description('The subnet address prefix')
param subnetAddressPrefix string
@description('Tags for the resources')
param tags object

module vnet 'vnet.bicep' = {
  name: 'vnetDeploy'
  params: {
    vnetName: '${prefix}-vnet-${clusterName}-${location}'
    subnetName: '${prefix}-snet-${clusterName}-${location}'
    vnetAddressPrefixes: vnetAddressPrefixes
    subnetAddressPrefix: subnetAddressPrefix
    tags: tags
  }
}

module aks 'aks.bicep' = {
  name: 'aksDeploy'
  params: {
    prefix: prefix
    clusterName: clusterName    
    subnetId: vnet.outputs.subnetId
    nodeAdminUsername: nodeAdminUsername
    adminGroupObjectIDs: adminGroupObjectIDs
    nodeVmSize: nodeVmSize
    nodeCount: nodeCount
    maxNodeCount: maxNodeCount
    osDiskSizeGB: osDiskSizeGB
    sshPublicKey: publicsshKey
    workspaceTier: workspaceTier
    tags: tags
  }

  dependsOn: [
    vnet
  ]
}

module registry 'registry.bicep' = {
  name: 'registryDeploy'
  params: {
    registryName: 'acr${clusterName}'
    aksPrincipalId: aks.outputs.clusterPrincipalID
    tags: tags
  }

  dependsOn: [
    aks
  ]
}

As with ARM templates you can use a Json File to configure the parameters in bicep and so I’ve added one for this

{
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "tags": {
            "value": {
                "project": "mjdemo",
                "resource": "AKS"
            }
        },
        "prefix": {
            "value": "dev"
        },
        "clusterName": {
            "value": "mjdemo"
        },
        "nodeVmSize": {
            "value": "Standard_D2s_V3"
        },
        "osDiskSizeGB": {
            "value": 50
        },
        "nodeCount": {
            "value": 1
        },
        "maxNodeCount": {
            "value": 3
        },
        "nodeAdminUsername": {
            "value": "aksAdminUser"
        },
        "adminGroupObjectIDs": {
            "value": []
        },
        "publicsshKey": {
            "value": ""
        },
        "workspaceTier": {
            "value": "PerGB2018"
        },
        "vnetAddressPrefixes": {
            "value": []
        },
        "subnetAddressPrefix": {
            "value": ""
        }
    }
}

Pipeline

Now we have all the Bicep files and a parameters file, we can create an Azure Pipeline but first we are going to need an SSH Key and upload it to Azure Pipelines, one way to generate an SSH key is to use the ssh-keygen command in Bash (I used Ubuntu in WSL) e.g.

ssh-keygen -q -t rsa -b 4096 -N '' -f aksKey

This will generate a private and public key pair, you can then upload the public key file e.g. aksKey.pub to Secure Files in Azure DevOps Pipelines (Pipelines->Library->Secure files)

We are going to add Azure AD Groups in this deployment and will need to assign the role ‘Azure Kubernetes Service Cluster User Role’ to each group, the Microsoft Docs detail how to do this.

Now we have the SSH key uploaded we can configure the parameters we want to set for our AKS cluster and network.

trigger: none
pr: none

pool:
  vmImage: ubuntu-latest

parameters:
  - name: azureSubscription
    type: string
    default: 'Sandbox'
  - name: location
    displayName: 'Resource Location'
    type: string
    default: 'uksouth'
  - name: prefix
    displayName: 'Environment Prefix'
    type: string
    default: 'prod'
  - 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: osDiskSizeGB
    displayName: 'Size of OS disk (0 means use vm size)'
    type: number
    default: 50
  - name: nodeCount
    displayName: 'The number of nodes'
    type: number
    default: 3
  - name: maxNodeCount
    displayName: 'Max node to scale out to'
    type: number
    default: 10
  - name: workspaceTier
    displayName: Log Analytics Workspace Tier
    type: string
    default: 'PerGB2018'
    values:
      - 'Free'
      - 'Standalone'
      - 'PerNode'
      - 'PerGB2018'
      - 'Premium'
  - name: tags
    displayName: 'Tags'
    type: object
    default:
     Environment: "prod"
     Resource: "AKS"
     Project: "Demo"
  - name: nodeAdminUsername
    displayName: 'Admin username for the nodes'
    type: string
    default: 'adminUserName'
  - name: vnetAddressPrefixes
    displayName: 'Virtual Network Address Prefixes'
    type: object
    default: 
      - '10.240.0.0/16'
  - name: subnetAddressPrefix
    displayName: 'Subnet Address Prefix'
    type: string
    default: '10.240.0.0/20'
  - name: adGroupNames
    type: object
    default: 
      - 'demo-group'

variables:
  resourceGroupName: 'rg-${{ parameters.prefix }}-${{ parameters.clusterName }}-${{ parameters.location }}'

With the parameters set the next part is to build up the steps, starting with downloading the SSH key from the secure files using the DownloadSecureFile task

steps:
- task: DownloadSecureFile@1
  displayName: 'Download Public SSH Key'
  name: SSHfile
  inputs:
    secureFile: 'aksKey.pub'
- bash: |
    value=`cat $(SSHfile.secureFilePath)`
    echo '##vso[task.setvariable variable=publicsshKey;issecret=true]'$value
  displayName: Obtain SSH key value

Next we can get the object IDs for the groups

- task: AzureCLI@2
  displayName: 'Get AD Group Object Ids'
  inputs:
    azureSubscription: ${{ parameters.azureSubscription }}
    scriptType: pscore
    scriptLocation: inlineScript
    inlineScript: |    
      $objectIds = '${{ join(',',parameters.adGroupNames) }}'.Split(',') | ForEach { 
        "$(az ad group list --query "[?displayName == '$_'].{objectId:objectId}" -o tsv)" 
      }

      $output = ConvertTo-Json -Compress @($objectIds)
      Write-Host '##vso[task.setvariable variable=groupIds]'$output

This next section is taking those parameters and turning them into variables to then substitute the values in parameters json file

- template: objectparameters.yml
  parameters:
    tags: ${{ parameters.tags }}
    vnetAddressPrefixes: ${{ parameters.vnetAddressPrefixes }}
- template: parameters.yml
  parameters:
    prefix: ${{ parameters.prefix }}
    clusterName: ${{ parameters.clusterName }}
    nodeVmSize: ${{ parameters.nodeVmSize }}
    osDiskSizeGB: ${{ parameters.osDiskSizeGB }}
    nodeCount: ${{ parameters.nodeCount }}
    maxNodeCount: ${{ parameters.maxNodeCount }}
    nodeAdminUsername: ${{ parameters.nodeAdminUsername }}
    publicsshKey: $(publicsshKey)
    workspaceTier: ${{ parameters.workspaceTier }}    
    subnetAddressPrefix: ${{ parameters.subnetAddressPrefix }}
    adminGroupObjectIDs: $(groupIds)
- task: FileTransform@2
  displayName: "Transform Parameters"
  inputs:
    folderPath: '$(System.DefaultWorkingDirectory)'
    xmlTransformationRules: ''
    jsonTargetFiles: 'deploy.parameters.json'

If you need to debug the transform then you can add another step to output the file contents, I find this a useful technique to make sure the transform worked as expected

- bash: |
    cat deploy.parameters.json
  displayName: "Debug show parameters file"

If we put all that together then the final pipeline looks like this:

trigger: none
pr: none

pool:
  vmImage: ubuntu-latest

parameters:
  - name: azureSubscription
    type: string
    default: 'Sandbox'
  - name: location
    displayName: 'Resource Location'
    type: string
    default: 'uksouth'
  - name: prefix
    displayName: 'Environment Prefix'
    type: string
    default: 'prod'
  - 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: osDiskSizeGB
    displayName: 'Size of OS disk (0 means use vm cache size)'
    type: number
    default: 50
  - name: nodeCount
    displayName: 'The number of nodes'
    type: number
    default: 3
  - name: maxNodeCount
    displayName: 'Max node to scale out to'
    type: number
    default: 10
  - name: workspaceTier
    displayName: Log Analytics Workspace Tier
    type: string
    default: 'PerGB2018'
    values:
      - 'Free'
      - 'Standalone'
      - 'PerNode'
      - 'PerGB2018'
      - 'Premium'
  - name: tags
    displayName: 'Tags'
    type: object
    default:
     Environment: "prod"
     Resource: "AKS"
     Project: "Demo"
  - name: nodeAdminUsername
    displayName: 'Admin username for the nodes'
    type: string
    default: 'adminUserName'
  - name: vnetAddressPrefixes
    displayName: 'Virtual Network Address Prefixes'
    type: object
    default: 
      - '10.240.0.0/16'
  - name: subnetAddressPrefix
    displayName: 'Subnet Address Prefix'
    type: string
    default: '10.240.0.0/20'
  - name: adGroupNames
    type: object
    default: 
      - 'demo-group'

variables:
  resourceGroupName: 'rg-${{ parameters.prefix }}-${{ parameters.clusterName }}-${{ parameters.location }}'

steps:
- task: DownloadSecureFile@1
  displayName: 'Download Public SSH Key'
  name: SSHfile
  inputs:
    secureFile: 'aksKey.pub'
- bash: |
    value=`cat $(SSHfile.secureFilePath)`
    echo '##vso[task.setvariable variable=publicsshKey;issecret=true]'$value
  displayName: Obtain SSH key value  
- task: AzureCLI@2
  displayName: 'Get AD Group Object Ids'
  inputs:
    azureSubscription: ${{ parameters.azureSubscription }}
    scriptType: pscore
    scriptLocation: inlineScript
    inlineScript: |    
      $objectIds = '${{ join(',',parameters.adGroupNames) }}'.Split(',') | ForEach { 
        "$(az ad group list --query "[?displayName == '$_'].{objectId:objectId}" -o tsv)" 
      }

      $output = ConvertTo-Json -Compress @($objectIds)
      Write-Host '##vso[task.setvariable variable=groupIds]'$output
- template: objectparameters.yml
  parameters:
    tags: ${{ parameters.tags }}
    vnetAddressPrefixes: ${{ parameters.vnetAddressPrefixes }}
- template: parameters.yml
  parameters:
    prefix: ${{ parameters.prefix }}
    clusterName: ${{ parameters.clusterName }}
    nodeVmSize: ${{ parameters.nodeVmSize }}
    osDiskSizeGB: ${{ parameters.osDiskSizeGB }}
    nodeCount: ${{ parameters.nodeCount }}
    maxNodeCount: ${{ parameters.maxNodeCount }}
    nodeAdminUsername: ${{ parameters.nodeAdminUsername }}
    publicsshKey: $(publicsshKey)
    workspaceTier: ${{ parameters.workspaceTier }}    
    subnetAddressPrefix: ${{ parameters.subnetAddressPrefix }}
    adminGroupObjectIDs: $(groupIds)
- task: FileTransform@2
  displayName: "Transform Parameters"
  inputs:
    folderPath: '$(System.DefaultWorkingDirectory)'
    xmlTransformationRules: ''
    jsonTargetFiles: 'deploy.parameters.json'
- task: AzureCLI@2
  displayName: 'Deploy AKS Cluster'
  inputs:
    azureSubscription: ${{ parameters.azureSubscription }}
    scriptType: bash
    scriptLocation: inlineScript
    inlineScript: |
      az group create --name "$(resourceGroupName)" --location ${{ parameters.location }} 
      az deployment group create --name "${{ parameters.clusterName }}-deploy" --resource-group "$(resourceGroupName)" --template-file deploy.bicep --parameters deploy.parameters.json

The template file objectparameters.yml looks like this:

parameters: 
  - name: tags
    type: object
  - name: vnetAddressPrefixes
    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 }}"

And the template file parameters.yml looks like this:

parameters: 
  - name: prefix
    type: string
  - name: clusterName
    type: string
  - name: nodeVmSize
    type: string
  - name: osDiskSizeGB
    type: number
  - name: nodeCount
    type: number
  - name: maxNodeCount
    type: number
  - name: nodeAdminUsername
    type: string
  - name: publicsshKey
    type: string
  - name: workspaceTier
    type: string
  - name: subnetAddressPrefix
    type: string
  - name: adminGroupObjectIDs
    type: string

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

Now we have an AKS cluster setup we might want to deploy some applications to the cluster. CoderDave has a great video tutorial to do this with Azure Pipelines.

All the files shown above can be found on my GitHub