Featured image of post How to write your first AVM resource module

How to write your first AVM resource module

This is a post about writing your first Azure Verified Modules, for those interested in the background about AVM, check out this recent intro on YouTube.

This is recommended as a learning exercise to familiarise yourself with AVM. I strongly encourage contributing to the official resource modules Microsoft is in the process of building.

We’re going to focus on writing a resource module:

illustration of AVM resources and patterns

It is recommended to use a unix-based system for writing AVM modules (e.g. either WSL2, a Mac, a Linux variant, or GitHub codespaces).

I assume a familiarity with git, including how to clone and create feature branches.

The process is going to be:

  • Check if the AVM module already exists & understand resource module naming conventions.
  • Create a new GitHub repo for the module from the AVM template.
  • Update the GitHub repo to enable support for running ’end to end’ tests in GitHub actions.
  • Build the resource module, using the newres tool.

Let’s get started!

Check for an existing module

If a module already exists, we should use or contribute to the existing one. They are all listed on the AVM website.

Bear in mind that many resources are not available yet, as this initiative is new. We’re going to pick “Dev Center” for this example, as it doesn’t yet exist in AVM for Terraform.

AVM Module naming

If you can’t find the resource, lets make one! We’ll start by understanding the module naming conventions.

AVM modules are named as per the spec, to summarise it should look something like this:

1
avm-res-<resource provider>-<ARM resource type>
  • The resource provider should not include the “Microsoft.” part.
  • The resource type should be in singular form.

Here are some examples to help:

1
2
3
avm-res-containerregistry-registry
avm-res-storage-storageaccount
avm-res-keyvault-vault

The GitHub repo name is prefixed with terraform-azurerm- to allow it to be published correctly in the Hashicorp public registry.

So, for example, the KeyVault GitHub repository would be:

https://github.com/Azure/terraform-azurerm-avm-res-keyvault-vault

Raise a module proposal

If you’re planning to contribute back (I strongly encourage it!), it is a good idea to raise an issue, this will help validate the module naming & scope and start the process to find a Microsoft FTE to support the module.

You can raise a module proposal using this issue template: https://aka.ms/AVM/ModuleProposal.

A practical example

Lets get started making the Dev Centre resource module in Terraform, this is co-incidentally something I need as a pre-requisite to building a Dev Box in Azure.

It’s a very simple example with a limited number of inputs, a good place to start!

We’ll check the Hashicorp documentation to make sure there is support for it in the AzureRM Terraform provider, and here it is:

https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/dev_center

Remember, if AzureRM isn’t available, there’s always AzAPI, but that is for another day.

You may need to register “Microsoft.DevCenter” in your subscription resource providers if you are following along:

illustrating the registered Microsoft.DevCenter resource provider

Use the AVM template

There is a an AVM module template repository to help get you started.

Select “Use this template” -> “Create a new repository”

The repo name will be terraform-azurerm-avm-res-devcenter-devcenter:

name the repository

GitHub settings

(TODO link to another guide with more detail for peeps that need it)

Within your cloned repo on Github, do the following:

  1. Set up a GitHub repo environment called test.
  2. Configure environment protection rule to ensure that approval is required before deploying to this environment.
  3. Create a user-assigned managed identity in your test subscription.
  4. Create a role assignment for the managed identity on your test subscription, recommend providing this “Contributor” and “Role Based Access Control Administrator”.
  5. Configure federated identity credentials on the user assigned managed identity. Use the GitHub environment.
  6. Set the following secrets on your GitHub environment:
    1. AZURE_TENANT_ID
    2. AZURE_SUBSCRIPTION_ID - i.e. the subscription you will be using to deploy resources to test the module.
    3. AZURE_CLIENT_ID - i.e. the client id of the managed identity.

Clone & feature branch

Clone down the repository and open it in your preferred editor (I use Visual Studio Code).

Create a new feature branch for the initial version updates.

Install the “newres” command line utility

You can skip this step if you’re using the AVM template’s Codespace, as it is already installed.

newres will usually greatly speed up writing the resource module where there are a lot of parameters. In this example, there are only a few and isn’t really worth it, but we’ll continue so you see the method.

If you repeat this process with something with lots of parameters, like App Gateway, you’ll quickly see the benefit!

Assuming you already have golang installed, run:

1
go install github.com/lonegunmanb/newres/v3@latest

Run the following to make a temporary folder for newres output and run the tool:

1
2
3
mkdir newres
cd newres
newres -dir ./ -r azurerm_dev_center

If successful, you’ll see:

1
Successfully generated variables.tf and main.tf

The newres naming convention needs an adjustment for AVM, in the generated main.tf, observe the variables all start dev_centre_ - we need to remove this prefix, leaving you with this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
resource "azurerm_dev_center" "this" {
  location            = var.location
  name                = var.name
  resource_group_name = var.resource_group_name
  tags                = var.tags

  dynamic "identity" {
    for_each = var.identity == null ? [] : [var.identity]
    content {
      type         = identity.value.type
      identity_ids = identity.value.identity_ids
    }
  }
  dynamic "timeouts" {
    for_each = var.timeouts == null ? [] : [var.timeouts]
    content {
      create = timeouts.value.create
      delete = timeouts.value.delete
      read   = timeouts.value.read
      update = timeouts.value.update
    }
  }
}

This will also need to be fixed in the variables.tf file.

newres updates for main.tf

Copy the contents of newres\main.tf into main.tf, replacing this block:

1
2
resource "azurerm_TODO_the_resource_for_this_module" "this" {
}

Remove the TODO from the top of the file & update the location as follows:

1
  location            = coalesce(var.location, local.resource_group_location)

Update the identity block in main.tf

Update the dynamic “identity” block (assuming it exists), following the reference examples in the AVM interface specification for managed identities:

managed identity implementation - for resources supporting both a SystemAssigned & UserAssigned id

Make sure to check the Hashicorp documentation to confirm the identities supported by the resource (SystemAssigned, UserAssigned, or both).

The identity block for the Dev Center resource shows that both types of identities are supported.

1
2
3
4
5
6
7
  dynamic "identity" {
    for_each = (var.managed_identities.system_assigned || length(var.managed_identities.user_assigned_resource_ids) > 0) ? { this = var.managed_identities } : {}
    content {
      type         = identity.value.system_assigned && length(identity.value.user_assigned_resource_ids) > 0 ? "SystemAssigned, UserAssigned" : length(identity.value.user_assigned_resource_ids) > 0 ? "UserAssigned" : "SystemAssigned"
      identity_ids = identity.value.user_assigned_resource_ids
    }
  }

The Terraform variable declaration in the specification has examples for other scenarios (e.g. SystemAssigned only, or UserAssigned only).

newres updates for variables.tf

Edit the contents of newres\variables.tf to remove the following variables:

  • location
  • name
  • resource_group_name
  • identity
  • tags

(we’re going to use the ones in the template, instead).

Copy the remaining variables to the top of variables.tf file in the module root folder. (in our case, there is only a ’timeouts’ variable remaining).

newres tidy-up

Remove the newres folder. By this point, any intellisense errors should be fixed in main.tf, and the top of the main.tf should look something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
data "azurerm_resource_group" "parent" {
  count = var.location == null ? 1 : 0
  name  = var.resource_group_name
}

resource "azurerm_dev_center" "this" {
  location            = coalesce(var.location, local.resource_group_location)
  name                = var.name
  resource_group_name = var.resource_group_name
  tags                = var.tags

  dynamic "identity" {
    for_each = (var.managed_identities.system_assigned || length(var.managed_identities.user_assigned_resource_ids) > 0) ? { this = var.managed_identities } : {}
    content {
      type         = identity.value.system_assigned && length(identity.value.user_assigned_resource_ids) > 0 ? "SystemAssigned, UserAssigned" : length(identity.value.user_assigned_resource_ids) > 0 ? "UserAssigned" : "SystemAssigned"
      identity_ids = identity.value.user_assigned_resource_ids
    }
  }

  dynamic "timeouts" {
    for_each = var.timeouts == null ? [] : [var.timeouts]
    content {
      create = timeouts.value.create
      delete = timeouts.value.delete
      read   = timeouts.value.read
      update = timeouts.value.update
    }
  }
}

# required AVM interfaces
# (etc)

TODO hunting

There are a few “TODO” references in the files that need to be fixed and removed, ignore any that are in “README.md” because we’ll fix those when we automatically update the docs.

main.tf

  • update azurerm_TODO_resource appropriately, e.g. azurerm_dev_center.

locals.telemetry.tf

  • set the module name and leave the module_type as “res”, e.g.:
1
2
3
  telem_puid = "UNOFFICIAL"
  module_name = "res-devcenter-devcenter"
  module_type = "res"

I suggest setting telem_puid to “UNOFFICIAL” in resources created for learning.

main.privateendpoint.tf

  • You would typically update private_service_connection with the correct resource id and subresource name, something like this:
1
2
3
4
5
6
private_service_connection {
    name                           = each.value.private_service_connection_name != null ? each.value.private_service_connection_name : "pse-${var.name}"
    private_connection_resource_id = azurerm_TODO.this.id
    is_manual_connection           = false
    subresource_names              = ["TODO subresource name"]
  }

However, in this case, Dev Center doesn’t support private endpoints, so we’re going to remove this file altogether.

There is a handy link in the template that helps find the right subresource name, should you need it for your resource.

locals.tf

  • We don’t have any locals to add at the moment, so lets remove the reminder to “insert locals here”
  • In our example, lets also remove the private endpoint local var, as we don’t need private endpoints.

terraform.tf

  • remove the reminder to add required providers
  • providers should really be versioned like this (a correction to template is needed to align to AVM):
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 3.71.0, < 4.0"
    }
    random = {
      source  = "hashicorp/random"
      version = ">= 3.5.0, < 4.0"
    }
  }
}

variables.tf

  • In the name variable, update the regex in the validation condition. If you’re not sure how to do this, remove the validation block for now and we will return to it later.

  • Since our resource doesn’t support private endpoints, remove the “private_endpoints” variable.

  • For an unofficial module, you might want to consider setting the default value of the enable_telemetry variable to false.

outputs.tf

  • In this case, we don’t need the output private_endpoints.
  • Remove the reminder TODO
  • Update the resource output value & add a couple mandatory outputs missing from the template:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
output "resource" {
  value       = azurerm_dev_center.this
  description = "This is the full output for the resource."
}

output "id" {
  description = "The ID of the resource."
  value       = azurerm_dev_center.this.id
}

output "name" {
  description = "The name of the resource"
  value       = azurerm_dev_center.this.id
}

_header.md

  • If you’re using this for learning, remove the contents of this file and replace with something like this:
1
2
3
4
5
6
# terraform-azurerm-res-devcenter-devcenter

This is a Terraform module for Azure Dev Center in the style of Azure Verified Modules.  For official modules please see <https://aka.ms/AVM>.

> [!WARNING]
> Major version Zero (0.y.z) is for initial development. Anything MAY change at any time. A module SHOULD NOT be considered stable till at least it is major version one (1.0.0) or greater. Changes will always be via new versions being published and no changes will be made to existing published versions. For more details please go to <https://semver.org/>

If this is not an official module, then I suggest removing the following files:

  • SUPPORT.MD
  • SECURITY.MD
  • .github\CODEOWNERS

Creating the default example

Each of the subfolders inside .\examples contain a test that is run as part of the end-to-end tests during the the pull request workflow.

AVM modules start with a “default” example - the purpose of this is to test the module in its simplest form with the minimum number of parameters.

Update the default example

In the default example, you may need to modify the resource group logic if you have policy that restricts where you can deploy resources:

1
2
3
4
resource "azurerm_resource_group" "this" {
  name     = module.naming.resource_group.name_unique
  location = "AustraliaEast"
}

In this case, the resource is only supported in the following regions, so pick one of these:

  • ‘australiaeast,canadacentral,westeurope,japaneast,uksouth,eastus,eastus2,southcentralus,westus3,centralindia,eastasia,northeurope,koreacentral’

This also means we can remove the logic that randomly selects a region:

lines to be removed

The module call is at the end of main.tf, this would typically need adjustment to add the required variables, however in the case of Dev Center, just the name and resource group is enough:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# This is the module call
# Do not specify location here due to the randomization above.
# Leaving location as `null` will cause the module to use the resource group location
# with a data source.
module "dev_center" {
  source = "../../"
  # source             = "Azure/avm-res-devcenter-devcenter/azurerm"
  # ...
  enable_telemetry    = var.enable_telemetry # see variables.tf
  name                = module.naming.dev_test_lab.name_unique
  resource_group_name = azurerm_resource_group.this.name
}

Updating docs and running PR checks

This requires Docker to be installed as the checks run in a container (The Codespace is set up with Docker support).

The AVM template includes a script to help running checks. There is a bash version (./avm) and a batch file version (./avm.bat). I recommend using the bash version.

Don’t forget to check the line-encoding is set to “LF”, else you will get /usr/bin/env: ‘sh\r’: No such file or directory.

Mark the file as executable the first time you use the script:

1
chmod +x ./avm

To update the docs:

1
./avm docs

Once the container has been pulled, you’ll see output like this:

1
2
3
4
5
6
curl -H 'Cache-Control: no-cache, no-store' -sSL ""https://raw.githubusercontent.com/Azure/tfmod-scaffold/main/avm_scripts"/docs-gen.sh" | sh -s
==> Generating module documentation...
README.md updated successfully
==> Generating examples documentation...
===> Generating examples documentation in ./default
default/README.md updated successfully

To confirm readiness for a Pull Request, run the following:

1
./avm pr-check

At the time of writing, this will perform the following:

  • ensure each of the examples terraform validate
  • run formatting and lint checks for go and terraform
  • run any unit tests (not the end to end examples - they run during PR)

GitHub runners

Microsoft use internal runners for AVM end to end tests. If you’re not a Microsoft FTE, you’ll need to amend your E2E tests to use your own subscription

To do this, replace .github\workflows\e2e.yml with my version here:

https://gist.github.com/kewalaka/93ec3da3c6a39610da3eef4b04c37365

Note, running end to end tests deploys and destroys resources, thus does incur cost.

Raise a PR

If you’ve made it this far, and resolved any PR checks, you are ready to raise the initial pull request.

If you’ve been following along, hopefully success will smile down on you:

all checks have passed in the PR

… at which your point can merge to main and congratulate yourself on getting this far!

Find this on Github

You can find the result of this blog post at this location:

https://github.com/kewalaka/terraform-azurerm-avm-res-devcenter-devcenter

Where next

There’s more to follow in future blog posts, here’s a taster:

Add child resources

The scope of an AVM module must include “child” resources within the same resource provider scope, which for Dev Centre is here.

In the case of Dev Center, this means the module needs to be extended to include dev center projects. Other child resource such as DevBox definitions and Gallery support will require use of the AzAPI provider, as they are not yet covered by AzureRM.

Add more examples (end to end tests)

Tests need to be added to provide coverage for resource functionality. See .\examples\README.md for more info on how to add new examples.

Where to look for inspiration

Matt White (@matt-FFFFFF on Github) is the Microsoft lead for Azure Verified Modules, if you’re looking for inspiration then I recommend checking out his Key Vault module here:

https://github.com/Azure/terraform-azurerm-avm-res-keyvault-vault

You can also check out some of my own AVM-style modules, e.g. these ones in the Azure org that I have helped co-author:

& you can find more on my GitHub account, that I’m in the progress of contributing:

Below is an “AVM-style” Container Apps that uses an AzureRM-like interface, whilst using AzAPI internally:

Licensed under CC BY-NC-SA 4.0, unless noted by credits.
Built with Hugo
Theme Stack designed by Jimmy