PAUL'S BLOG

Learn. Build. Share. Repeat.

Sharing Bicep Modules with Azure Container Registry

2022-10-11 14 min read Tutorial

One of the things I do as a Cloud Native Advocate at Microsoft is build end-to-end lab scenarios in the https://aka.ms/oss-labs repo. Most of the demo scenarios we aim to cover is in and around the container space and a majority of the labs uses Azure Bicep to declaratively provision Azure infrastructure. As more labs get spun up, there is a potential for redundant Bicep code. You might have already guessed, there’s a need for re-usable code to spin up AKS clusters.

The easy solution here is to place Bicep code into local modules, but referencing local modules presents a few challenges… what happens if someone updates a local module and doesn’t update all the code that uses it? Without proper versioning in place, there is the potential to break things. If only there was a Bicep registry where we can publish and share Bicep modules… 🤔

A quick internet search helped me discover that about a year ago, the @BicepLang team added capability to share Bicep modules using Azure Container Registry (ACR).

This was exactly the solution I was looking for!

There’s even a Bicep public module registry with a handful of published modules and if you’d like to contribute to this registry, please check out the contributing guide to get started (I might publish a few there myself in the near future).

But my goal here is to demonstrate you can build your own private registry and share Bicep modules within your team/organization.

So in this article, we’ll walk through the following:

  • Creating a Bicep module
  • Manually publish a Bicep module to ACR
  • Automating Bicep module publishing with GitHub Actions

… and as a bonus I’ll cover how GitHub Actions can use the newer OpenID Connect (OIDC) mechanism for Azure authentication

Let’s go!

Pre-requisites

Before you get started, make sure you have the following tools:

Build a Bicep module

Since we need an ACR resource to publish modules into, let’s use that as a first module to publish.

Start by creating a new repo on GitHub, cloning it on your machine, and opening it with VSCode. We’ll use the GitHub CLI for this.

# random name to use as a name for the repo and azure resources
name=bicep$RANDOM

# make sure you are logged into your github account
gh auth login

# create a private repo
gh repo create $name --private

# clone the repo
gh repo clone $name

# drop into the repo directory
cd $name

# open with vscode
code .

I like how the files and folders are structured within the Bicep public module registry. Let’s follow a similar pattern.

# make sure you are in the root of the project directory
mkdir -p bicep/modules/azure-container-registry
cd bicep/modules/azure-container-registry

Add a few files in the directory that will help others consume the module.

echo "# azure-container-registry" >> README.md
touch main.bicep
touch metadata.json

Here is the directory structure so far:

.
└── bicep
    └── modules
        └── azure-container-registry
            ├── README.md
            ├── main.bicep
            └── metadata.json

Here is how each of the files will be used:

  • bicep/modules/azure-container-registry/README.md will be used to document the intent of the module and how to use it
  • bicep/modules/azure-container-registry/main.bicep is where we’ll place the Bicep code
  • bicep/modules/azure-container-registry/metadata.json is used to maintain versioning of the Bicep modules

Versioning modules is a best practice as it helps with regressions and allows you to introduce breaking changes as modules evolve over time.

Open the bicep/modules/azure-container-registry/metadata.json file and add the following JSON code.

{
  "version": {
    "major": 0,
    "minor": 1
  }
}

If you’d like to follow the same schema for versioning as in the Bicep public module registry, you can take a look at this resource. I’ll use the JSON above to make things a bit simpler.

In VSCode, open the bicep/modules/azure-container-registry/main.bicep file and add in the following Bicep code.

param name string
param location string
param tags object

@allowed([
  'Basic'
  'Standard'
  'Premium'
])
@description('Defaults to Standard')
param sku string = 'Standard'

@allowed([
  'SystemAssigned'
  'UserAssigned'
])
@description('Two options are available: SystemAssigned or UserAssigned')
param managedIdentityType string

@description('Required when managed identity type is set to UserAssigned')
param userAssignedIdentities object = {}

param adminUserEnabled bool = false

param anonymousPullEnabled bool = false

param publicNetworkAccess bool = false

resource registry 'Microsoft.ContainerRegistry/registries@2022-02-01-preview' = {
  name: name
  location: location
  tags: tags
  sku: {
    name: sku
  }
  identity: {
    type: managedIdentityType
    userAssignedIdentities: managedIdentityType != 'SystemAssigned' ? userAssignedIdentities : null
  }
  properties: {
    adminUserEnabled: adminUserEnabled
    anonymousPullEnabled: anonymousPullEnabled
    publicNetworkAccess: publicNetworkAccess ? 'Enabled' : 'Disabled'
  }
}

output name string = registry.name

Deploy Bicep template

With the module code added, let’s deploy it so that we have an ACR in place to publish modules into.

Head back to the root of the project directory create a new main.bicep file. This is where we will consume the module.

cd ../../../
touch main.bicep

Your directory structure should now look like this:

.
├── bicep
│   └── modules
│       └── azure-container-registry
│           ├── README.md
│           ├── main.bicep
│           └── metadata.json
└── main.bicep

Open the main.bicep file from the root of the project directory and add the following Bicep code.

targetScope = 'subscription'

param name string
param location string
param tags object = {}

// Set up the resource group
resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = {
  name: 'rg-${name}'
  location: location
  tags: tags
}

// Set up the container registry
module acr 'bicep/modules/azure-container-registry/main.bicep' = {
  scope: rg
  name: 'acrDeploy'
  params: {
    name: 'acr${toLower(name)}'
    location: location
    tags: tags
    sku: 'Basic'
  }
}

The code above will deploy a resource group into your subscription then use the local module to deploy an ACR resource into the resource group.

The ACR SKU we are deploying here is Basic. This is fine for demo purposes but for production you will want to use either the Standard or Premium SKUs. By default, ACR is private. If you’d like to share your Bicep modules to anonymous users, you will need to deploy a Premium SKU.

Notice how we referenced the source for the ACR module. We used the file path bicep/modules/azure-container-registry/main.bicep to locally reference the module. This will change once we publish the module to ACR.

Run the following command to deploy.

# make sure you are logged into azure
az login

# location to deploy resources into (change this value if needed)
location=eastus

# run the deployment
az deployment sub create \
  --name "$name-deploy" \
  --location $location \
  --template-file ./main.bicep \
  --parameters name=$name location=$location

Validate the deployment and store the ACR loginServer property in a variable.

loginServer=$(az acr show -n acr${name} --query loginServer -o tsv)

Publish your module (manually)

Now we are ready to publish the module. You can only publish one module at a time, so you need to pass in the module’s main.bicep file as the value for the --file parameter. We’ll need to use the ACR login server URL to build the value for the --target parameter. This value must be formatted as br:<artifact-uri>:<tag>.

Run this command to publish.

az bicep publish \
  --file bicep/modules/azure-container-registry/main.bicep \
  --target "br:${loginServer}/bicep/modules/azure-container-registry:v0.1"

Validate the module has been published to ACR.

az acr repository show-tags \
  --name acr$name \
  --repository bicep/modules/azure-container-registry

Use your published module

With the module published, we can update the main.bicep deployment file in the root of your project directory to reference the module from ACR instead of locally.

Update the module reference to resemble this instead of using the local module path.

module acr 'br:<REPLACE_THIS_WITH_YOUR_ACR_NAME>.azurecr.io/bicep/modules/azure-container-registry:v0.1' = {
  scope: rg
  name: 'acrDeploy'
  params: {
    name: 'acr${toLower(name)}'
    location: location
    tags: tags
    sku: 'Basic'
  }
}

Run the deployment again to ensure Bicep is able to pull down the module from ACR.

az deployment sub create \
  --name "$name-deploy" \
  --location $location \
  --template-file ./main.bicep \
  --parameters name=$name location=$location

Note the format when referencing the Bicep module as an OCI artifact in ACR is br:<artifact-uri>:<tag>. Optionally you can configure a Bicep to use an alias instead of referencing the entire URL of your ACR server.

Add a new file called bicepconfig.json in the root of the project directory.

touch bicepconfig.json

Your project directory should now look like this:

.
├── bicep
│   └── modules
│       └── azure-container-registry
│           ├── README.md
│           ├── main.bicep
│           └── metadata.json
├── bicepconfig.json
└── main.bicep

Add the following JSON code to the bicepconfig.json file.

{
  "moduleAliases": {
    "ts": {},
    "br": {
      "my-acr": {
        "registry": "<REPLACE_THIS_WITH_YOUR_ACR_NAME>.azurecr.io"
      }
    }
  }
}

Here, I’ve aliased my ACR login server URL as my-acr, so I can reference the module using this format: br/<module-alias>:<module-name-or-path>:<tag>.

Note when using a module alias br is followed by a slash / not a colon :.

Let’s update main.bicep again to use an alias instead.

module acr 'br/my-acr:bicep/modules/azure-container-registry:v0.1' = {
  scope: rg
  name: 'acrDeploy'
  params: {
    name: 'acr${toLower(name)}'
    location: location
    tags: tags
    sku: 'Basic'
  }
}

Before we re-run the deployment, let’s clear any local Bicep cache to ensure we are getting a fresh copy from the remote ACR server. The following command will force Bicep to reload any modules that are referenced in the main.bicep file.

az bicep restore --file main.bicep --force

Now run the deployment one more time to ensure it still works.

az deployment sub create \
  --name "$name-deploy" \
  --location $location \
  --template-file ./main.bicep \
  --parameters name=$name location=$location

Publish your module with GitHub Actions

Great! We now have a module published to ACR and have verified the push and pull works 🎉

What if we need to make changes to a module? What happens if we have many more modules in the bicep/modules directory? Publishing each module individually will become unwieldy over time. So we’ll implement a GitHub Action workflow to automate this process.

With GitHub Actions, we can use the Azure Login Action to authenticate against and make changes to Azure resources, but historically the authentication mechanism relied on client secrets (e.g., passwords). This required users to store client secrets as a GitHub encrypted secret stored in the GitHub repo. This introduces problems such as potentially leaking credentials, forgetting to renew the client secrets, etc.

Now, there is a better way. We can now use the Azure Login action with OIDC by adding a federated credential to an Azure AD Application to enable trust between Azure and tokens issued by GitHub Actions.

Let’s use Azure CLI and Microsoft Graph REST APIs to configure this.

⚠️ WARNING

You must have the ability to create Azure AD App Registrations to proceed with the steps below.

Create an Azure AD application

appId=$(az ad app create --display-name $name --query appId -o tsv)

Azure AD apps on their own cannot be granted roles to do things in your Azure subscription. You need a service principal for that. So let’s add a service principal to the Azure AD application.

assigneeObjectId=$(az ad sp create --id $appId --query id -o tsv)

We need to grant the service principal permissions to work with resources in your resource group. Put your subscription ID, tenant ID, and resource group name into variables and grant permission to it.

subscriptionId=$(az account show --query id -o tsv)
tenantId=$(az account show --query tenantId -o tsv)
resourceGroupName=rg-$name

# grant the contributor role to the service principal at the resource group scope
az role assignment create \
  --role contributor \
  --subscription $subscriptionId \
  --assignee-object-id $assigneeObjectId \
  --assignee-principal-type ServicePrincipal \
  --scope /subscriptions/$subscriptionId/resourceGroups/$resourceGroupName

Add the federated credential to the Azure AD app registration.

appObjectId=$(az ad app show --id $appId --query id -o tsv)
repo=<YOUR_GITHUB_ACCOUNT_NAME>/$name
credentialName=main
description=Demo

az rest \
  --method POST \
  --uri "https://graph.microsoft.com/beta/applications/${appObjectId}/federatedIdentityCredentials" \
  --body "{'name':'${credentialName}','issuer':'https://token.actions.githubusercontent.com','subject':'repo:${repo}:ref:refs/heads/main','description':'${description}','audiences':['api://AzureADTokenExchange']}"

Now, add some secrets to your GitHub repo so that your Action workflow can use these as variables.

gh secret set AZURE_CLIENT_ID --body "${appId}" --repos $repo
gh secret set AZURE_TENANT_ID --body "${tenantId}" --repos $repo
gh secret set AZURE_SUBSCRIPTION_ID --body "${subscriptionId}" --repos $repo
gh secret set ACR_SERVER --body "${loginServer}" --repos $repo

Now we’re ready to code up our Action. Create a new directory and GitHub Action workflow file.

mkdir -p .github/workflows
touch .github/workflows/publish_bicep_modules.yml

Our directory structure should now look like this:

.
├── .github
│   └── workflows
│       └── publish_bicep_modules.yml
├── bicep
│   └── modules
│       └── azure-container-registry
│           ├── README.md
│           ├── main.bicep
│           └── metadata.json
├── bicepconfig.json
└── main.bicep

Add the following YAML code to the publish_bicep_modules.yml file.

name: Publish Bicep module

on:
  push:
    branches: [ "main" ]

  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: 'Az CLI login'
        uses: azure/login@v1
        with:
            client-id: ${{ secrets.AZURE_CLIENT_ID }}
            tenant-id: ${{ secrets.AZURE_TENANT_ID }}
            subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      
      - name: Install Bicep
        run: |
          az version
          az bicep install          
      - name: Publish Bicep modules to Azure Container Registry
        run: |
          # loop through each directory and publish
          for d in */
          do
            cd $d
            version="v$(cat metadata.json | jq -r '.version.major').$(cat metadata.json | jq -r '.version.minor')"
            if [ -z "$(az acr repository show -n ${{ secrets.ACR_SERVER }} --image bicep/modules/${d::-1}:$version 2>/dev/null)" ]; 
            then 
              echo "Publishing br:${{ secrets.ACR_SERVER }}/bicep/modules/${d::-1}:$version";
              az bicep publish --file main.bicep --target "br:${{ secrets.ACR_SERVER }}/bicep/modules/${d::-1}:$version";
            else 
              echo "Skipping br:${{ secrets.ACR_SERVER }}/bicep/modules/${d::-1}:$version as it already exists";
            fi
            cd - > /dev/null
          done          
        working-directory: bicep/modules

Here are some important elements of this workflow.

The bit of code below allows the GitHub Action to write ID tokens which will be presented to Azure AD upon authentication request.

permissions:
  id-token: write
  contents: read

The bit of code below uses the azure/login@v1 action to log into Azure. Notice how the with block here does not include a password (only the client-id, tenant-id and subscription-id). With the permissions element set above, the Azure Login Action knows to use an OAuth 2.0 authentication flow instead.

- name: 'Az CLI login'
  uses: azure/login@v1
  with:
      client-id: ${{ secrets.AZURE_CLIENT_ID }}
      tenant-id: ${{ secrets.AZURE_TENANT_ID }}
      subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

The bit of code below will loop through any child directory in bicep/modules, build a version number from metadata.json (for OCI artifact tagging), and publish the module if the tag does not already exist.

- name: Publish Bicep modules to Azure Container Registry
  run: |
    # loop through each directory and publish
    for d in */
    do
      cd $d
      version="v$(cat metadata.json | jq -r '.version.major').$(cat metadata.json | jq -r '.version.minor')"
      if [ -z "$(az acr repository show -n ${{ secrets.ACR_SERVER }} --image bicep/modules/${d::-1}:$version 2>/dev/null)" ]; 
      then 
        echo "Publishing br:${{ secrets.ACR_SERVER }}/bicep/modules/${d::-1}:$version";
        az bicep publish --file main.bicep --target "br:${{ secrets.ACR_SERVER }}/bicep/modules/${d::-1}:$version";
      else 
        echo "Skipping br:${{ secrets.ACR_SERVER }}/bicep/modules/${d::-1}:$version as it already exists";
      fi
      cd - > /dev/null
    done    
  working-directory: bicep/modules

If there is a better way to parse through the files using Bash, I’d love to know 😊

With all our code in place, we can finally commit and push to the remote repo, and watch the GitHub Action do its thing.

git add .
git commit -m 'Initial commit'
git push

You can view the output of the workflow using the following commands

# get a list of runs
gh run view

# from the list of runs, view the log of a particular job
gh run view --log --job=<YOUR_JOB_ID>

You should see a message that resembles the following near the bottom of the log output.

Publishing br:***/bicep/modules/azure-container-registry:v0.1

You can also manually trigger the Action using the workflow_dispatch

gh workflow run publish_bicep_modules.yml
gh run watch

If you view the run log again, you’ll notice a different message. This time you will see the following because the version already exists in ACR.

Skipping br:***/bicep/modules/azure-container-registry:v0.1 as it already exists

To upload a new version all you need to do is update the version number in the metadata.json file, commit and push the code again and the GitHub Action will take care of the rest. Easy, right? 😉

Summary

As a recap, we created new GitHub repo and added a Bicep module to provision an ACR resource. Then we deployed the ACR resource using a local module reference. With the ACR in place, we published the module to it, updated the main.bicep file and verified its functionality. From there we configured an Azure AD app registration and established a federated credential with GitHub Actions so that the Actions workflow can leverage OIDC to authenticate and work within your Azure subscription. Finally, we committed the code and pushed it to the remote GitHub repo and watched the Action publish modules.

Hopefully, this walk through will help you organize your Bicep modules and share them with others on your team, organization, or publicly (HINT: you will need the Premium SKU to allow anonymous pulls).

If you found this sort of workflow helpful, or found issues with it, please let me know in the comments below or via Twitter @pauldotyu. Also, be sure to check out the linked in the Resources section below.

Cheers!

Resources