Sharing Bicep Modules with Azure Container Registry
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:
- Visual Studio Code
- Azure Subscription with Owner privileges
- Azure CLI with Azure Bicep installed
- GitHub Account
- GitHub CLI
- Git CLI
- Bash terminal (if you are not on Linux or macOS, try WSL or Azure Cloud Shell)
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 itbicep/modules/azure-container-registry/main.bicep
is where we’ll place the Bicep codebicep/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!