Introduction

When designing IaC modules finding the correct syntax to deploy a certain resource type is often not the hardest thing to do. What I found in 4 years of writing Bicep code, is that defining a dynamic way to name your resources which is also easy to use, seems to pose quite the challenge. This article won’t define the best way to get this but a way that seems to work for me and the customers I have worked with in the past.

Challenges

First, we have to analyse how the naming of resources happens to be able to shape our code to those requirements. A good place to start is the Cloud Adoption Framework, which contains a list of items you can incorporate in your naming ,. However, through experience I’ve noticed it’s not always the best solution to name all resources in an equal way, to give you some examples:

  • An Azure Front Door is a global resource and doesn’t need a region in its name
  • In most cases, there is only one Hub/VWAN and one Identity spoke, so an environment (DEV/TEST/UAT/PRD) might be unnecessary in the vnet name
  • An index might be unnecessary since you’ll seldom deploy 2 VWAN hubs in the same region
  • For child and extension resources the naming shows cannot be followed because they can’t exist alone (not viewable in the portal in a regular Resource Group view). - An example might be subnet, which when referenced almost always shows the vnet in which they can be found

The conclusion is that we have to account for this in our naming strategy and make it possible to overwrite our default naming strategy defined for a certain customer. Abbreviations are something which most customers will use from the CAF (I hope) but there will always be exceptions (looking at you LAW or LA instead of LOG for Log Analytics Workspace). A way to add default values and override this function would be great.

Another neat feature we should support is Bicepparam, at this time I don’t think that they are production-ready. I’m especially waiting on extendable parameters (the former modular parameters) however the first release seems to contain several bugs. So we want to implement support for this in our naming strategy.

Techniques

To solve our challenges above we need some new-ish features that have been recently introduced into Bicep. The simplest feature is user-defined types, the naming will be defined as a custom type and then, using user-defined type imports, imported into each resource module, and be a part of a type of each resource. We will form the resource names in each resource module using custom functions which are centrally stored and imported in each file where required.

Solution

On GitHub, you can find a way to structure a module library and how the naming is integrated into this. Below we will take a short look at each component of this structure.

shared: main.bicep

Below is the main focus of this article, the naming type:

  • Param 1-3 are custom parameters the customer may require (fixed customer name for example)
  • Function is the only required property, however format is also required for this to work
 1@export()
 2type naming = {
 3  @description('Use the function value as the full name of the resource')
 4  forceFunctionAsFullName: bool?
 5  @minLength(1)
 6  @description('Override the abbreviation of this resource with this parameter')
 7  abbreviation: string?
 8  @minLength(1)
 9  @description('The resource environment (for example: dev, tst, acc, prd)')
10  environment: string?
11  @minLength(1)
12  @description('The resource location (for example: weu, we, westeurope)')
13  location: string?
14  @minLength(1)
15  @description('The name of the customer')
16  customer: string?
17  @minLength(1)
18  @description('The delimiter between resources (default: -)')
19  delimiter: string?
20  @description('The order of the array defines the order of elements in the naming scheme')
21  format: ('abbreviation' | 'function' | 'environment' | 'location' | 'customer' | 'param1' | 'param2' | 'param3')[]?
22  @minLength(1)
23  @description('Extra parameter self defined')
24  param1: string?
25  @minLength(1)
26  @description('Extra parameter self defined')
27  param2: string?
28  @minLength(1)
29  @description('Extra parameter self defined')
30  param3: string?
31  @minLength(1)
32  @description('Function of the resource [can be app, db, security,...]')
33  function: string
34  @minLength(1)
35  @description('Suffix for the resource, if empty non will be appended, otherwise will be added to the end [can be index, ...]')
36  suffix: string?
37  @description('Force the CAF naming instead of default company naming')
38  forceDefaultNaming: bool?
39}

functions: main.bicep

The custom functions that are used in each resource module are centrally stored. They define the different flows based on the parameters:

  1. Check if forceFunctionAsFullName == true, use full name
  2. Check if forceDefaultNaming == true, use CAF naming
  3. Otherwise use customName, if not available, use CAF naming
 1import { naming } from '../types/main.bicep'
 2
 3// Merge naming components
 4@export()
 5func getResourceNaming(sharedNaming naming, naming naming) naming => (union(sharedNaming, naming))
 6
 7// Generate default CAF name using naming components
 8#disable-next-line prefer-interpolation
 9func getDefaultResourceName(resourceNaming naming) string => concat(join(concat(map(resourceNaming.?format!, x => resourceNaming[x])), resourceNaming.?delimiter! ?? '-'), resourceNaming.?suffix! ?? '')
10
11// Generate a resource name
12// 1. Check if forceFunctionAsFullName == true, use full name
13// 2. Check if forceDefaultNaming == true, use CAF naming
14// 3. Otherwise use customName, if not available, use CAF naming
15@export()
16func getResourceName(resourceNaming naming, abbreviation string, customName string?) string => (resourceNaming.?forceFunctionAsFullName! == true) ? resourceNaming.function : (resourceNaming.?forceDefaultNaming! == true ? getDefaultResourceName(union({ abbreviation: abbreviation }, resourceNaming)) : customName! ?? getDefaultResourceName(union({ abbreviation: abbreviation }, resourceNaming)))

resource-group: types.bicep

Below are the resource-group specific types, in this case only resourceGroup which contains:

  • sharedNaming: for the whole customer
  • naming: specific to the resource
  • location: resource deployment location
 1import { naming } from '../types/main.bicep'
 2
 3@export()
 4type resourceGroup = {
 5  @description('Reference to the naming')
 6  naming: naming
 7  @description('Reference to the default naming')
 8  sharedNaming: naming
 9  @description('Location of the resource')
10  location: string
11}

resource-group: main.bicep

The file that deploys the resource group itself and uses the functions and types imported from the other files. You can see we right-merge the sharedNaming and naming, meaning naming will have priority over sharedNaming properties (overwrite). Abbreviation is retrieved from a central file, but can be overwritten using naming parameter.

 1targetScope = 'subscription'
 2
 3import { naming } from '../types/main.bicep'
 4import { resourceGroup } from 'types.bicep'
 5import { getResourceName } from '../functions/main.bicep'
 6
 7// Located in a JSON file centralized
 8var abbreviations = {
 9  'Microsoft.Resources/resourceGroups': 'rg'
10}
11
12param resourceObject resourceGroup
13param resourceNaming naming = union(resourceObject.sharedNaming, resourceObject.naming)
14
15var abbreviation = abbreviations['Microsoft.Resources/resourceGroups']
16var resourceName = getResourceName(resourceNaming, abbreviation, null)
17
18resource resourceItem 'Microsoft.Resources/resourceGroups@2024-07-01' = {
19  name: resourceName
20  location: resourceObject.location
21}

customer: main.bicep

Last but not least is the customer side, here we define the sharedNaming constructed specifically for the customer. Next we define the overloaded properties.

 1targetScope = 'subscription'
 2
 3import { naming } from 'types/main.bicep'
 4import { resourceGroup } from 'resource-group/types.bicep'
 5
 6// ------------------------------------------------------------------------------------------------------
 7// Located in the shared.bicep / shared.bicepparam on the customer size
 8// ------------------------------------------------------------------------------------------------------
 9param sharedNaming naming = {
10  format: [
11    'abbreviation'
12    'function'
13    'environment'
14    'location'
15  ]
16  function: 'placeholder'
17  environment: 'prd'
18  location: 'weu'
19}
20
21// ------------------------------------------------------------------------------------------------------
22// Located in the main.bicep / main.bicepparam on the customer size
23// ------------------------------------------------------------------------------------------------------
24module resourceGroupDeployment 'resource-group/main.bicep' = {
25  name: 'resourceGroupDeployment'
26  params: {
27    resourceObject: {
28      location: deployment().location
29      naming: {
30        // Required value
31        function: 'network'
32        // Overload the default values define in sharedNaming
33        environment: 'dev'
34        location: 'neu'
35        delimiter: '_'
36        suffix: '-01'
37      }
38      sharedNaming: sharedNaming
39    }
40  }
41}

Conclusion

The file structure shown above is how we organize our modules in a single repository. We then deploy all the main files to a Template Specs across all customer tenants. The deployment process is a topic for another article, but this naming strategy will help you customize your code for your customer while still keeping it DRY (Don’t Repeat Yourself).