DEV Community

Cover image for Azure Developer CLI - The new infrastructure setup
Christian Lechner
Christian Lechner

Posted on

Azure Developer CLI - The new infrastructure setup

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:

azd infra directory structure beta1

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:

azd infra directory structure beta 2

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:

azd infra directory structure beta 2 - Core folder

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   
Enter fullscreen mode Exit fullscreen mode

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 the resources.bicep file. The parameter tags has gone and the parameter environment is new member of the parameters:
   module resources 'resources.bicep' = {
   name: 'resources'
   scope: rg
   params: {
     location: location
     principalId: principalId
     environmentName: name
    }
   }  
Enter fullscreen mode Exit fullscreen mode

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 = ''
Enter fullscreen mode Exit fullscreen mode

For our project we define the secret name for the Blob Storage access as variable here:

var blobStorageSecretName = 'BLOB-CONNECTION-STRING'
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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})'
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

📝 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}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: {}
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Create a system assigned managed identity for the Azure Functions App
  2. 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
Enter fullscreen mode Exit fullscreen mode

📝 Remark - We implemented the call in the resources.bicep in a prior step. Be aware that we provided the parameter keyVaultName 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
       ...
       }
   }
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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 with bicep.
  • 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:

Top comments (0)