Introduction
With the update of the Azure Developer CLI to version 0.2.0-beta.2 (2022-09-21) a change was introduced that affects the structuring of the bicep
templates i.e., structuring them via modules (see also pull request Rearrange Bicep Modules #548).
📝 Remark: The setup presented here is also valid with the CLI version 0.3.0-beta.1.
What has changed
Up to the version 0.2.0-beta.1 the infra
folder contained the bicep
files in a structured but "flat" manner. It was well defined, but all files have been gathered in one directory:
Although this setup is easy to understand and might be a good fit for small projects, it will face some limitations:
- The more complex the setup the bigger the
resources.bicep
file will become. This will decrease the maintainability and the code will be hard to understand. The mitigation would be to split the files. This can again become messy, and governance needs to be put in place to assure a uniform structuring across projects. - Some copy and paste must happen in between projects, so even after introducing
azd
as best practice for development teams each team must take care individually to keep central resource definitions for a unified infrastructure up to date.
The creators of the azd
seem to be well aware of this and their solution proposal is available with version 0.2.0-beta.2 of the Azure Developer CLI. The main change is that the bicep files are refactored into modules. The new infra
folder has the following structure:
While the foundational files like main.bicep
and resources.bicep
remain, we see two new folder namely app
and core
. Let us take a closer look into them.
The core
folder
The core
folder can be interpreted as the central reuse folder comprising a repository of resources that are used in the different (sample) projects. The structure is based on the semantics of the resources contained in the folders, like storage
, database
or security
:
We find the corresponding *.bicep
files of the resources in each folder. Instead of explicitly coding the different resources in the resource.bicep
file they are referenced via modules. This we way we reduce redundant code and have one source of truth for the building blocks of the infrastructure setup.
The app
folder
The resources that define your app are placed in a dedicated folder called app
. Reuse is again established via bicep modules from the core
folder. This leads to a clean setup of the infrastructure declaration compared to the one in prior azd
versions.
Refactoring the Azure Functions sample
To get a hands-on impression on these changes I decided to give it a try for an existing azd-compatible project. The starting point is the azd-compatible Azure Functions project that I described in the blog post The Azure Developer CLI - Compatibility journey for an Azure Functions Project.
The focus of this blog post lies on restructuring the infra
folder to get it compliant with the new azd setup.
Step 1 - Cleaning up the folders
First, I renamed the existing infra
folder to infra020beta1
to have my existing and working setup still available and to cross check in case of issues. We can even use this folder as source for the infrastructure provisioning in the project by pointing the azure.yaml
file to the folder path:
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
name: azure-functions-blob
metadata:
template: azure-functions-blob@0.0.1-beta
services:
blob-output-binding:
project: src
language: ts
host: function
infra:
path: infra020beta1
For this blog post we leave the infra
section out of the azure.yaml
file, so that azd
will use the default which is the infra
folder. Consequently, we create a new folder and call it infra
. We copy the following files from infra020beta1
to infra
:
abbreviations.json
main.bicep
main.parameters.bicep
resources.bicep
In order to have the new core
folder I created an azd
project from a template and copied the core
folder as is into the infra
folder of my Azure Functions project. The content of the core
folder is independent of the template you used; it always contains all reusable .bicep
files.
In addition, I created a new folder called app
which will become the home of my app-specific .bicep
files. I put two empty files into this folder namely:
-
function.bicep
: this file will contain the modules that need to be called to create the Azure Function -
storage-output.bicep
: this file will contain the modules that need to be called in order create the dedicated storage for the output binding of the Azure Function
With that the basic folder structure is in place and we can move on to change the content.
Step 2 - The main*.bicep
files
Let us first take a look at the main.bicep
and the main.parameters.bicep
files:
- The
main.parameters.bicep
can be left as is. No changes required even in the new setup. - The
main.bicep
gets a small change as a consequence to the change of the parameters of theresources.bicep
file. The parametertags
has gone and the parameterenvironment
is new member of the parameters:
module resources 'resources.bicep' = {
name: 'resources'
scope: rg
params: {
location: location
principalId: principalId
environmentName: name
}
}
With that, let us move on to the resources.bicep
file.
Step 3 - The resources.bicep
file
This file gets a completely new setup based on the modules provided in the core
folder. As we already saw the parameters changed:
param environmentName string
param location string = resourceGroup().location
param principalId string = ''
For our project we define the secret name for the Blob Storage access as variable here:
var blobStorageSecretName = 'BLOB-CONNECTION-STRING'
And now ... drum roll ... we can reuse the modules from the core
folder to create the usual suspects of resources like App Service Plan or monitoring:
// Create an App Service Plan to group applications under the same payment plan and SKU
module appServicePlan './core/host/appserviceplan-functions.bicep' = {
name: 'appserviceplan'
params: {
environmentName: environmentName
location: location
}
}
// Monitor application with Azure Monitor
module monitoring './core/monitor/monitoring.bicep' = {
name: 'monitoring'
params: {
environmentName: environmentName
location: location
}
}
You already see the advantage of the new setup: no more "spaghetti code" for declaring the resources. In case of changes on the basic setup, those can be managed in one central place. The downside is that you need to take a dive into the files as they might be stacked (one module calling another one) with some magic like defaulting and merging going on along the way.
I added the basic module-based setup also for the following resources:
- Storage Account for the Azure Function via
./core/storage/storage-account.bicep
- Key Vault via
./core/security/keyvault.bicep
.
In accordance with the new setup, I added the application specific setups (Azure Function per se and the Azure Storage Account for the output binding) via:
// Second Storage Account for Output Binding
module outputstorage './app/storage-output.bicep' = {
name: 'outputstorage'
params: {
environmentName: environmentName
location: location
}
}
// The function app
module function './app/function.bicep' = {
name: 'function'
params: {
environmentName: environmentName
location: location
applicationInsightsName: monitoring.outputs.applicationInsightsName
appServicePlanId: appServicePlan.outputs.appServicePlanId
storageAccountName: storage.outputs.name
keyVaultName: keyVault.outputs.keyVaultName
appSettings: {
BLOB_STORAGE_CONNECTION_STRING: '@Microsoft.KeyVault(SecretUri=${keyVault.outputs.keyVaultEndpoint}secrets/${blobStorageSecretName})'
}
}
}
📝 Remark - Be aware that we define the
BLOB_STORAGE_CONNECTION_STRING
via a reference to an Azure Key Vault secret. We will create the prerequisites for this to work in the next sections.
With the very basic setup in place, we now focus on our project specifics.
Step 4 - Our storage setup for output binding
For the output binding we create an additional Azure Storage Account. Taking a closer look at the file storage-account.bicep
in the core/storage
folder we see that we have no option to influence the name of the storage account e.g., via a postfix. It is defined by:
var resourceToken = toLower(uniqueString(subscription().id, environmentName, location))
resource storage 'Microsoft.Storage/storageAccounts@2021-09-01' = {
name: '${abbrs.storageStorageAccounts}${resourceToken}'
In addition, we need to create a container inside of the Storage Account which is not foreseen in the current content of the core
modules.
To stay in line with the setup I created a new folder called corelocal
where I centralized my own reusable .bicep
files. I also mimicked the sub-folder structure, so I added a folder storage
. Now I add my own Storage Account file enhanced-storage-account.bicep
:
param environmentName string
param location string = resourceGroup().location
param blobStorageNamePostfix string = ''
param allowBlobPublicAccess bool = false
param kind string = 'StorageV2'
param minimumTlsVersion string = 'TLS1_2'
param sku object = { name: 'Standard_LRS' }
var abbrs = loadJsonContent('../../abbreviations.json')
var resourceToken = toLower(uniqueString(subscription().id, environmentName, location))
var tags = { 'azd-env-name': environmentName }
var storageName = blobStorageNamePostfix != '' ? '${abbrs.storageStorageAccounts}${resourceToken}${blobStorageNamePostfix}' : '${abbrs.storageStorageAccounts}${resourceToken}dev'
resource enhancedStorage 'Microsoft.Storage/storageAccounts@2021-09-01' = {
name: storageName
location: location
tags: tags
kind: kind
sku: sku
properties: {
minimumTlsVersion: minimumTlsVersion
allowBlobPublicAccess: allowBlobPublicAccess
networkAcls: {
bypass: 'AzureServices'
defaultAction: 'Allow'
}
}
}
output name string = enhancedStorage.name
Basically, it is a copy & paste from the original one, with some additional logic to add a postfix, that is handed in via a parameter. I created the resource in a way that one could use this template also for the original storage account setup by leveraging a ternary expression for deriving the variable storageName
.
In addition, we need a container created in this storage account. For that I created an additional .bicep
file called storage-container.bicep
:
param blobStorageName string = ''
param blobContainerName string = 'dev'
resource storageContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2022-05-01' = {
name: '${blobStorageName}/default/${blobContainerName}'
properties: {
publicAccess: 'None'
metadata: {}
}
}
I referenced the parent Storage Account in the name
parameter of my container using it as part of the relative container path. You can also use the parent
parameter, but then you must adjust the name
parameter accordingly as providing the parent information is only allowed in one place for this resource.
Having this in place we are good to go to create the infra\app\storage-output.bicep
file using the new building blocks as modules:
param environmentName string
param location string = resourceGroup().location
var blobStorageNamePostfix = 'blobfunc'
var blobContainerName = 'players'
// Storage for Azure functions output binding
module blobStorageAccount '../corelocal/storage/enhanced-storage-account.bicep' = {
name: 'outputStorageAccount'
params: {
blobStorageNamePostfix: blobStorageNamePostfix
environmentName: environmentName
location: location
}
}
// Container in the storage account
module blobStorageContainer '../corelocal/storage/storage-container.bicep' = {
name: 'storageContainer'
params: {
blobStorageName: blobStorageAccount.outputs.name
blobContainerName: blobContainerName
}
}
output blobStorageName string = blobStorageAccount.outputs.name
That was not too complicated, so let us move on to storing the connection string to this Blob Storage in the Azure Key Vault.
Step 5 - Storing the secret
We want to store the connection string of the Blob Storage in Azure Key Vault and access it as a reference from the Azure Function app configuration.
First things first, let us create the secret. As in the previous section there is no pre-defined .bicep
file available, so let us create one. To do so I created a security
folder underneath the corelocal
folder. Here we place the bicep
file for the secret:
param environmentName string
param keyVaultName string = ''
param secretName string = ''
param blobStorageName string = ''
var tags = { 'azd-env-name': environmentName }
resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = {
name: keyVaultName
}
resource enhancedStorage 'Microsoft.Storage/storageAccounts@2021-09-01' existing = {
name: blobStorageName
}
resource keyVaultSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = {
name: secretName
tags: tags
parent: keyVault
properties: {
contentType: 'string'
value: 'DefaultEndpointsProtocol=https;AccountName=${enhancedStorage.name};AccountKey=${enhancedStorage.listKeys().keys[0].value};EndpointSuffix=core.windows.net'
}
}
As we need to attach the secret to the storage and construct the value of our secret using information from the Storage Account, we must integrate the existing resources. To do so we use the existing
keyword as described in the official documentation.
This new module is referenced from the resources.bicep
file with the parameters filled via the output of the corresponding module calls:
// attach output storage to keyvault
module outputStorageSecret './corelocal/security/keyvault-blobaccess-secret.bicep' = {
name: 'keyVaultSecretForBlob'
params: {
environmentName: environmentName
blobStorageName: outputstorage.outputs.blobStorageName
keyVaultName: keyVault.outputs.keyVaultName
secretName: blobStorageSecretName
}
}
This was a bit tricky (at least for a bicep
newbie), but well documented and fits into the new infrastructure setup.
Last thing we need is to bring the pieces together in the Azure Function App configuration and the Azure Key Vault to get access to the secret.
Step 6 - Mind the access
What do we need to do to grant the Azure Function App access to the Azure Key Vault? There are two things needed:
- Create a system assigned managed identity for the Azure Functions App
- Create the access policy in Key Vault for this identity via its principal ID
Can we achieve that with the existing modules from core
? Yes, we can. To understand why and how we must take a dive into the hierarchy for Azure Functions and the Azure Functions App. Let us start from our part, namely the function.bicep
file that we create in the app
folder that reuses the functions-node.bicep
file from the core
folder:
param location string = resourceGroup().location
param environmentName string
param applicationInsightsName string
param appServicePlanId string
param appSettings object = {}
param serviceName string = 'blob-output-binding'
param storageAccountName string
param keyVaultName string = ''
module function '../core/host/functions-node.bicep' = {
name: '${serviceName}-functions-node-module'
params: {
environmentName: environmentName
location: location
appSettings: appSettings
applicationInsightsName: applicationInsightsName
appServicePlanId: appServicePlanId
serviceName: serviceName
storageAccountName: storageAccountName
keyVaultName: keyVaultName
}
}
output FUNCTION_IDENTITY_PRINCIPAL_ID string = function.outputs.identityPrincipalId
output FUNCTION_NAME string = function.outputs.name
output FUNCTION_URI string = function.outputs.uri
📝 Remark - We implemented the call in the
resources.bicep
in a prior step. Be aware that we provided the parameterkeyVaultName
from there. This is important for wiring things up.
We need to make sure that a system assigned identity is created in our Azure Functions App and that the access policy in Azure Key Vault is set accordingly. What needs to be done to achieve this?
We have no choice, we need to dive into the code of the predefined .bicep
files, to answer this question:
- Starting from our file the next stop is the
functions-node.bicep
file. There is nothing relevant for our investigation in there, however it is interesting to see the defaulting of some parameters. - The next stop is the
functions.bicep
file. And here we get our first answer about the managed identity:
param managedIdentity bool = !(empty(keyVaultName))
...
module functions 'appservice.bicep' = {
name: '${serviceName}-functions'
params: {
...
kind: kind
linuxFxVersion: linuxFxVersion
managedIdentity: managedIdentity
minimumElasticInstanceCount: minimumElasticInstanceCount
...
}
}
The creation of a managed identity is based on the fact if the keyVaultName
is provided or not. We provide so the identity will be created. So, the first prerequisite is fulfilled for our setup.
What about the second one namely the Key Vault Access policies? Let's dive a bit deeper going into the referenced appservice.bicep
file that contains the last missing puzzle pieces. In this file the access policy is created via:
module keyVaultAccess '../security/keyvault-access.bicep' = if (!(empty(keyVaultName))) {
name: '${serviceName}-appservice-keyvault-access'
params: {
principalId: appService.identity.principalId
environmentName: environmentName
location: location
}
}
This closes the loop and allows the Azure Function app to access the Azure Key Vault via the managed identity. The magic ingredient was to set the name of our Azure Key Vault when creatin the Azure Functions resources, all consequent steps are then executed automagically.
And this is also the end of the infrastructure restructuring journey: we have everything in place now to deploy the infrastructure (azd provision
) or to deploy the app as a whole (azd up
).
Summary and conclusion
The progress and improvements of the Azure Developer CLI are coming fast. One of the major improvements of the 0.2.0-beta2 release was a complete overhaul of the infrastructure provisioning setup.
I followed this refactoring in my sample project to get an idea what it means. In general, I can state that this new setup makes perfect sense, pushes the maturity of the Azure Developer CLI one step further and makes it even more appealing for platform and development teams. The modularization of the .bicep
files with a central reuse repository is a good move and will support the maintainability on the long term. However, there is no free lunch and there is a price you have to pay:
- To make use of the reusable
.bicep
files a more decent understanding of the concepts is needed. This is something to keep in mind if you have members in your team that are not familiar withbicep
. - I think reusable bits and pieces are hard to define up front. However, I have the impression that the Azure Developer CLI team did a good job, guided by the sample code, but do not fall into the trap to expect everything to be in place in the
core
folder that you need. Embrace the structure and fill in the gaps accordingly as I did for some missing parts in my project. - Take your time and go down the rabbit hole of the chain of
.bicep
files to get an understand what happens where like how the configurations are merged, how default values are set etc. You get an idea of what I mean when looking at the section Step 6 - Mind the access.
I think the provided infrastructure setup should be seen and used as a pattern for the setup in your team/company not necessarily as a library carved in stone. There are also some open questions like where and how to keep the central repository of the bicep
files and how to propagate changes. Curious to see how things evolve and what further support the Azure Developer CLI will come up with.
Long story short: I like the new infrastructure setup and I am looking forward to the upcoming improvements of the Azure Developer CLI. What about you?
Where to find the code
You find the code and the description of the project here (mind the branch):
https://github.com/lechnerc77/azd-compatibility-azure-function/tree/azd-020-beta2
The latest and greatest project code is in the main branch which might deviate from the code described in this blog post.
Useful references
Useful references if you want to try things out on your own:
- azd documentation
- azd on GitHub
- bicep documentation
- bicep playground
- Azure Developer CLI (azd) – September 2022 Release - information and links for Terraform
- QuickGlance - Azure Developer CLI
Top comments (0)