Terraform Resource Module Specifications
Contribution / Support
The content below is listed based on the following tags
| # | ID | Title | Severity | Persona | Lifecycle |
|---|---|---|---|---|---|
| 1 | SNFR8 | Module Owner(s) GitHub | MUST | Owner | Initial |
| 2 | SNFR20 | GitHub Teams Only | MUST | Owner | Initial |
| 3 | SNFR9 | AVM & PG Teams GitHub Repo Permissions | MUST | Owner | Initial |
| 4 | SNFR10 | MIT Licensing | MUST | Owner | Initial |
| 5 | SNFR11 | Issues Response Times | MUST | OwnerContributor | BAU |
| 6 | SNFR12 | Versions Supported | MUST | Owner | BAU |
| 7 | SNFR23 | GitHub Repo Labels | MUST | Owner | BAU |
| 8 | PMNFR4 | Missing Resource Module(s) | MUST | OwnerContributor | BAU |
| 9 | TFNFR3 | GitHub Repo Branch Protection | MUST | OwnerContributor | BAU |
โ See Specifications for this category
ID: SNFR8 - Category: Contribution/Support - Module Owner(s) GitHub
A module MUST have an owner that is defined and managed by a GitHub Team in the Azure GitHub organization.
Today this is only Microsoft FTEs, but everyone is welcome to contribute. The module just MUST be owned by a Microsoft FTE (today) so we can enforce and provide the long-term support required by this initiative.
Note
The names for the GitHub teams for each approved module are already defined in the respective Module Indexes. These teams MUST be created (and used) for each module.
ID: SNFR20 - Category: Contribution/Support - GitHub Teams Only
All GitHub repositories that AVM module are published from and hosted within MUST only assign GitHub repository permissions to GitHub teams only.
Each module MUST have a GitHub team assigned for module owners. This team MUST be created in the Azure organization in GitHub.
There MUST NOT be any GitHub repository permissions assigned to individual users.
Info
Non-FTE / external contributors (subject matter experts that aren’t Microsoft employees) can’t be members of the teams described in this chapter, hence, they won’t gain any extra permissions on AVM repositories, therefore, they need to work in forks.
Bicep
Important
As part of the module proposal process, the name of the GitHub team for each approved module is already defined in the respective Module Indexes (or CSV file). This team MUST be created (and used) for each module.
Module owners don’t need to construct the name of the GitHub team for their module themselves, instead they need use the name prescribed in the related CSV file, at the time of approval.
For a direct link, see the list of related index pages:
The @Azure prefix in the last column of the tables linked above represents the “Azure” GitHub organization all AVM-related repositories exist in. DO NOT include this segment in the team’s name!
Naming Convention
The naming convention for the GitHub teams MUST follow the below pattern:
<hyphenated module name>-module-owners-bicep- to grant permissions for module owners on Bicep modules
Segments:
<hyphenated module name>== the AVM Module’s name, with each segment separated by dashes, i.e.,avm-res-<resource provider>-<ARM resource type>module-owners== the role the GitHub Team is assigned to<bicep== the language the module is written in
Examples:
avm-res-compute-virtualmachine-module-owners-bicep
Note
The naming convention for Bicep modules is slightly different than the naming convention for their respective GitHub teams.
Add Team Members
All officially documented module owner(s) MUST be added to the -module-owners- team. The -module-owners- team MUST NOT have any other members.
Unless explicitly requested and agreed, members of the AVM core team or any PG teams MUST NOT be added to the -module-owners- teams as permissions for them are granted through the teams described in SNFR9.
Grant permissions through team memberships
Note
In case of Bicep modules, permissions to the BRM repository (the repo of the Bicep Registry) are granted via assigning the -module-owners- teams to parent teams that already have the required level access configured. While it is the module owner’s responsibility to initiate the addition of their team to the respective parent, only the AVM core team can approve this parent-child relationship.
Module owners MUST create their -module-owners- team and as part of the provisioning process, they MUST request the addition of this team to its respective parent team (see the table below for details).
| GitHub Team Name | Description | Permissions | Permissions granted through | Where to work? |
|---|---|---|---|---|
<hyphenated module name>-module-owners-bicep | AVM Bicep Module Owners - <module name> | Write | Assignment to the avm-technical-reviewers-bicep parent team. | Need to work in a fork. |
Example - GitHub team required for the Bicep resource module of Azure Virtual Network (avm/res/network/virtual-network):
avm-res-network-virtualnetwork-module-owners-bicep–> assign to theavm-technical-reviewers-bicepparent team.
Tip
Direct link to create a new GitHub team and assign it to its parent: Create new team
Fill in the values as follows:
- Team name: Following the naming convention described above, use the value defined in the module indexes.
- Description: Follow the guidance above (see the Description column in the table above).
- Parent team: Follow the guidance above (see the Permissions granted through column in the table above).
- Team visibility:
Visible - Team notifications:
Enabled
CODEOWNERS file
As part of the “initial Pull Request” (that publishes the first version of the module), module owners MUST add an entry to the CODEOWNERS file in the BRM repository (here).
Note
Through this approach, the AVM core team will grant review permission to module owners as part of the standard PR review process.
Every CODEOWNERS entry (line) MUST include the following segments separated by a single whitespace character:
- Path of the module, relative to the repo’s root, e.g.:
/avm/res/network/virtual-network/ - The
-module-owners-team, with the@Azure/prefix, e.g.,@Azure/avm-res-network-virtualnetwork-module-owners-bicep - The GitHub team of the AVM Bicep reviewers, with the
@Azure/prefix, i.e.,@Azure/avm-module-reviewers-bicep
Example - CODEOWNERS entry for the Bicep resource module of Azure Virtual Network (avm/res/network/virtual-network):
/avm/res/network/virtual-network/ @Azure/avm-res-network-virtualnetwork-module-owners-bicep @Azure/avm-module-reviewers-bicep
Terraform
Note
Access management for Terraform repositories now uses a single team, membership of which is managed using an internal entitlement management tool (Core Identity).
All module owners MUST request access to the avm-module-owners-terraform GitHub team via the Azure Verified Module Owners Terraform entitlement in Core Identity (Microsoft internal tool).
ID: SNFR9 - Category: Contribution/Support - AVM & PG Teams GitHub Repo Permissions
A module owner MUST make the following GitHub teams in the Azure GitHub organization admins on the GitHub repo of the module in question:
Bicep
@Azure/avm-core-team-technical-bicep= AVM Core Team@Azure/bicep-admins= Bicep PG team
Note
These required GitHub teams are already associated to the BRM repository and have the required permissions.
Terraform
@Azure/avm-core-team-technical-terraform= AVM Core Team@Azure/terraform-avm= Terraform PG
Important
Module owners MUST assign these GitHub teams as admins on the GitHub repo of the module in question.
For detailed steps, please follow this guidance.
ID: SNFR10 - Category: Contribution/Support - MIT Licensing
A module MUST be published with the MIT License in the Azure GitHub organization.
ID: SNFR11 - Category: Contribution/Support - Issues Response Times
A module owner MUST respond to logged issues as defined in the support statement. See Module Support for more information.
ID: SNFR12 - Category: Contribution/Support - Versions Supported
Only the latest released version of a module MUST be supported.
For example, if an AVM Resource Module is used in an AVM Pattern Module that was working but now is not. The first step by the AVM Pattern Module owner should be to upgrade to the latest version of the AVM Resource Module test and then if not fixed, troubleshoot and fix forward from the that latest version of the AVM Resource Module onward.
This avoids AVM Module owners from having to maintain multiple major release versions.
ID: SNFR23 - Category: Contribution/Support - GitHub Repo Labels
GitHub repositories where modules are held MUST use the below labels and SHOULD not use any additional labels:
โ AVM Standard GitHub Labels
These labels are available in a CSV file from here
| Name | Description | HEX |
|---|---|---|
| AZD ๐งโ๐ป | These modules are requested/used by the AZD team. | |
| Needs: Attention ๐ | Reply has been added to issue, maintainer to review | |
| Needs: Immediate Attention โผ๏ธ | Immediate attention of module owner / AVM team is needed | |
| Needs: Author Feedback ๐ | Awaiting feedback from the issue/PR author | |
| Needs: External Changes โ๏ธ | When an issue/PR requires changes that are outside of the control of the module. e.g. to an RP. | |
| Needs: More Evidence โ | We are looking for more evidence to make a decision on this | |
| Needs: Triage ๐ | Maintainers need to triage still | |
| Needs: Module Owner ๐ฃ | In the AVM repository: this module needs an owner to develop or maintain it. In the BRM repository: the module owner needs to review a PR. | |
| Needs: Module Contributor ๐ฃ | This module needs secondary owner(s) or contributor(s) to develop or maintain it | |
| Needs: Core Team ๐งโโ๏ธ | This item needs the AVM Core Team to review it | |
| Status: Awaiting Release To Be Cut โ๏ธ | This is fixed in the main branch but not in the latest release, will be fixed with next release cut | |
| Status: Do Not Merge โ | Do not merge PRs with this label attached as they are not ready or aligned to future direction etc. | |
| Status: External Contribution ๐ | This is being worked on by someone outside of the AVM module owners/contributors or AVM core team | |
| Status: Fixed โ | Auto label applied when issue fixed by merged PR | |
| Status: Help Wanted ๐ | Extra attention is needed | |
| Status: In Triage ๐ | Picked up for triaging by an AVM core team member | |
| Status: In PR ๐ | This is when an issue is due to be fixed in an open PR | |
| Status: Invalid โ | This doesn't seem right | |
| Status: Long Term โณ | We will do it, but will take a longer amount of time due to complexity/priorities | |
| Status: No Recent Activity ๐ค | When an issue/PR has not been modified for X amount of days | |
| Status: Won't Fix ๐ | This will not be worked on | |
| Status: Owners Identified ๐ค | This module has its owners identified | |
| Status: Module Available ๐ข | The module is published | |
| Status: Module Deprecated ๐ด | This is a request to deprecate a module | |
| Status: Module Orphaned ๐ก | The module has no owner and is therefore orphaned at this time | |
| Status: Ready For Repository Creation ๐ | This module is approved and the owner is ready for the repository to be created (Terraform) | |
| Status: Repository Created ๐ | This module has had it's repository created and configured ready for owner contribution (Terraform) | |
| Status: Response Overdue ๐ฉ | When an issue/PR has not been responded to for X amount of days | |
| Status: Looking For Assistance ๐ฆ | This item is looking for anyone to help develop the code and submit a PR for resolution | |
| Type: Bug ๐ | Something isn't working | |
| Type: CI ๐ | This issue is related to the AVM CI | |
| Type: Documentation ๐ | Improvements or additions to documentation | |
| Type: Duplicate ๐คฒ | This issue or pull request already exists | |
| Type: Feature Request โ | New feature or request | |
| Type: Hygiene ๐งน | things related to testing, issue triage etc. | |
| Type: New Module Proposal ๐ก | A new module for AVM is being proposed | |
| Type: Question/Feedback ๐โโ๏ธ | Further information is requested or just some feedback | |
| Type: Security Bug ๐ | This is a security bug | |
| Type: AVM ๐ ฐ๏ธ โ๏ธ โ๏ธ | This is an AVM related issue | |
| Language: Terraform ๐ | This is related to the Terraform IaC language | |
| Language: Bicep ๐ช | This is related to the Bicep IaC language | |
| Class: Resource Module ๐ฆ | This is a resource module | |
| Class: Pattern Module ๐ฆ | This is a pattern module | |
| Class: Utility Module ๐ฆ | This is a utility module | |
| Class: Child Module ๐ฆ | This is a child module | |
To help apply these to a module GitHub repository you can use the below PowerShell script:
โ Set-AvmGitHubLabels.ps1
For most scenario this is the command you’ll need to call the below PowerShell script with, replacing the value for RepositoryName:
Set-AvmGitHubLabels.ps1 -RepositoryName "Org/MyGitHubRepo" -CreateCsvLabelExports $false -NoUserPrompts $true```shell
# Linux / MacOs
# For Windows replace $PWD with your the local path or your repository
#
docker run -it -v $PWD:/repo -w /repo mcr.microsoft.com/powershell pwsh -Command '
#Invoke-WebRequest -Uri "https://azure.github.io/Azure-Verified-Modules/scripts/Set-AvmGitHubLabels.ps1" -OutFile "Set-AvmGitHubLabels.ps1"
$gh_version = "2.44.1"
Invoke-WebRequest -Uri "https://github.com/cli/cli/releases/download/v2.44.1/gh_2.44.1_linux_amd64.tar.gz" -OutFile "gh_$($gh_version)_linux_amd64.tar.gz"
apt-get update && apt-get install -y git
tar -xzf "gh_$($gh_version)_linux_amd64.tar.gz"
ls -lsa
mv "gh_$($gh_version)_linux_amd64/bin/gh" /usr/local/bin/
rm "gh_$($gh_version)_linux_amd64.tar.gz" && rm -rf "gh_$($gh_version)_linux_amd64"
gh --version
ls -lsa
gh auth login
$OrgProject = "Azure/terraform-azurerm-avm-res-kusto-cluster"
gh auth status
./Set-AvmGitHubLabels.ps1 -RepositoryName $OrgProject -CreateCsvLabelExports $false -NoUserPrompts $true
'
```By default this script will only update and append labels on the repository specified. However, this can be changed by setting the parameter -UpdateAndAddLabelsOnly to $false, which will remove all the labels from the repository first and then apply the AVM labels from the CSV only.
Make sure you elevate your privilege to admin level or the labels will not be applied to your repository. Go to repos.opensource.microsoft.com/orgs/Azure/repos/
Full Script:
These Set-AvmGitHubLabels.ps1 can be downloaded from here.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "", Justification = "Coloured output required in this script")]
<#
.SYNOPSIS
This script can be used to create the Azure Verified Modules (AVM) standard GitHub labels to a GitHub repository.
.DESCRIPTION
This script can be used to create the Azure Verified Modules (AVM) standard GitHub labels to a GitHub repository.
By default, the script will remove all pre-existing labels and apply the AVM labels. However, this can be changed by using the -RemoveExistingLabels parameter and setting it to $false. The tool will also output the labels that exist in the repository before and after the script has run to a CSV file in the current directory, or a directory specified by the -OutputDirectory parameter.
The AVM labels to be created are documented here: TBC
.NOTES
Please ensure you have specified the GitHub repositry correctly. The script will prompt you to confirm the repository name before proceeding.
.COMPONENT
You must have the GitHub CLI installed and be authenticated to a GitHub account with access to the repository you are applying the labels to before running this script.
.LINK
TBC
.Parameter RepositoryName
The name of the GitHub repository to apply the labels to.
.Parameter RemoveExistingLabels
If set to $true, the default value, the script will remove all pre-existing labels from the repository specified in -RepositoryName before applying the AVM labels. If set to $false, the script will not remove any pre-existing labels.
.Parameter UpdateAndAddLabelsOnly
If set to $true, the default value, the script will only update and add labels to the repository specified in -RepositoryName. If set to $false, the script will remove all pre-existing labels from the repository specified in -RepositoryName before applying the AVM labels.
.Parameter OutputDirectory
The directory to output the pre-existing and post-existing labels to in a CSV file. The default value is the current directory.
.Parameter CreateCsvLabelExports
If set to $true, the default value, the script will output the pre-existing and post-existing labels to a CSV file in the current directory, or a directory specified by the -OutputDirectory parameter. If set to $false, the script will not output the pre-existing and post-existing labels to a CSV file.
.Parameter GitHubCliLimit
The maximum number of labels to return from the GitHub CLI. The default value is 999.
.Parameter LabelsToApplyCsvUri
The URI to the CSV file containing the labels to apply to the GitHub repository. The default value is https://raw.githubusercontent.com/jtracey93/label-source/main/avm-github-labels.csv.
.Parameter NoUserPrompts
If set to $true, the default value, the script will not prompt the user to confirm they want to remove all pre-existing labels from the repository specified in -RepositoryName before applying the AVM labels. If set to $false, the script will prompt the user to confirm they want to remove all pre-existing labels from the repository specified in -RepositoryName before applying the AVM labels.
This is useful for running the script in automation workflows
.EXAMPLE
Create the AVM labels in the repository Org/MyGitHubRepo and remove all pre-existing labels.
Set-AvmGitHubLabels.ps1 -RepositoryName "Org/MyGitHubRepo"
.EXAMPLE
Create the AVM labels in the repository Org/MyGitHubRepo and do not remove any pre-existing labels, just overwrite any labels that have the same name.
Set-AvmGitHubLabels.ps1 -RepositoryName "Org/MyGitHubRepo" -RemoveExistingLabels $false
.EXAMPLE
Create the AVM labels in the repository Org/MyGitHubRepo and output the pre-existing and post-existing labels to the directory C:\GitHubLabels.
Set-AvmGitHubLabels.ps1 -RepositoryName "Org/MyGitHubRepo" -OutputDirectory "C:\GitHubLabels"
.EXAMPLE
Create the AVM labels in the repository Org/MyGitHubRepo and output the pre-existing and post-existing labels to the directory C:\GitHubLabels and do not remove any pre-existing labels, just overwrite any labels that have the same name.
Set-AvmGitHubLabels.ps1 -RepositoryName "Org/MyGitHubRepo" -OutputDirectory "C:\GitHubLabels" -RemoveExistingLabels $false
.EXAMPLE
Create the AVM labels in the repository Org/MyGitHubRepo and do not create the pre-existing and post-existing labels CSV files and do not remove any pre-existing labels, just overwrite any labels that have the same name.
Set-AvmGitHubLabels.ps1 -RepositoryName "Org/MyGitHubRepo" -RemoveExistingLabels $false -CreateCsvLabelExports $false
.EXAMPLE
Create the AVM labels in the repository Org/MyGitHubRepo and do not create the pre-existing and post-existing labels CSV files and do not remove any pre-existing labels, just overwrite any labels that have the same name. Finally, use a custom CSV file hosted on the internet to create the labels from.
Set-AvmGitHubLabels.ps1 -RepositoryName "Org/MyGitHubRepo" -OutputDirectory "C:\GitHubLabels" -RemoveExistingLabels $false -CreateCsvLabelExports $false -LabelsToApplyCsvUri "https://example.com/csv/avm-github-labels.csv"
#>
#Requires -PSEdition Core
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$RepositoryName,
[Parameter(Mandatory = $false)]
[bool]$RemoveExistingLabels = $true,
[Parameter(Mandatory = $false)]
[bool]$UpdateAndAddLabelsOnly = $true,
[Parameter(Mandatory = $false)]
[bool]$CreateCsvLabelExports = $true,
[Parameter(Mandatory = $false)]
[string]$OutputDirectory = (Get-Location),
[Parameter(Mandatory = $false)]
[int]$GitHubCliLimit = 999,
[Parameter(Mandatory = $false)]
[string]$LabelsToApplyCsvUri = "https://azure.github.io/Azure-Verified-Modules/governance/avm-standard-github-labels.csv",
[Parameter(Mandatory = $false)]
[bool]$NoUserPrompts = $false
)
# Check if the GitHub CLI is installed
$GitHubCliInstalled = Get-Command gh -ErrorAction SilentlyContinue
if ($null -eq $GitHubCliInstalled) {
throw "The GitHub CLI is not installed. Please install the GitHub CLI and try again."
}
Write-Host "The GitHub CLI is installed..." -ForegroundColor Green
# Check if GitHub CLI is authenticated
$GitHubCliAuthenticated = gh auth status
if ($LASTEXITCODE -ne 0) {
Write-Host $GitHubCliAuthenticated -ForegroundColor Red
throw "Not authenticated to GitHub. Please authenticate to GitHub using the GitHub CLI, `gh auth login`, and try again."
}
Write-Host "Authenticated to GitHub..." -ForegroundColor Green
# Check if GitHub repository name is valid
$GitHubRepositoryNameValid = $RepositoryName -match "^[a-zA-Z0-9-]+/[a-zA-Z0-9-]+$"
if ($false -eq $GitHubRepositoryNameValid) {
throw "The GitHub repository name $RepositoryName is not valid. Please check the repository name and try again. The format must be <OrgName>/<RepoName>"
}
# List GitHub repository provided and check it exists
$GitHubRepository = gh repo view $RepositoryName
if ($LASTEXITCODE -ne 0) {
Write-Host $GitHubRepository -ForegroundColor Red
throw "The GitHub repository $RepositoryName does not exist. Please check the repository name and try again."
}
Write-Host "The GitHub repository $RepositoryName exists..." -ForegroundColor Green
# PRE - Get the current GitHub repository labels and export to a CSV file in the current directory or where -OutputDirectory specifies if set to a valid directory path and the directory exists or can be created if it does not exist already
if ($RemoveExistingLabels -or $UpdateAndAddLabelsOnly) {
Write-Host "Getting the current GitHub repository (pre) labels for $RepositoryName..." -ForegroundColor Yellow
$GitHubRepositoryLabels = gh label list -R $RepositoryName -L $GitHubCliLimit --json name,description,color
if ($null -ne $GitHubRepositoryLabels -and $CreateCsvLabelExports -eq $true) {
$csvFileNamePathPre = "$OutputDirectory\$($RepositoryName.Replace('/', '_'))-Labels-Pre-$(Get-Date -Format FileDateTime).csv"
Write-Host "Exporting the current GitHub repository (pre) labels for $RepositoryName to $csvFileNamePathPre" -ForegroundColor Yellow
$GitHubRepositoryLabels | ConvertFrom-Json | Export-Csv -Path $csvFileNamePathPre -NoTypeInformation
}
}
# Remove all pre-existing labels if -RemoveExistingLabels is set to $true and user confirms they want to remove all pre-existing labels
if ($null -ne $GitHubRepositoryLabels) {
$GitHubRepositoryLabelsJson = $GitHubRepositoryLabels | ConvertFrom-Json
if ($RemoveExistingLabels -eq $true -and $NoUserPrompts -eq $false -and $UpdateAndAddLabelsOnly -eq $false) {
$RemoveExistingLabelsConfirmation = Read-Host "Are you sure you want to remove all $($GitHubRepositoryLabelsJson.Count) pre-existing labels from $($RepositoryName)? (Y/N)"
if ($RemoveExistingLabelsConfirmation -eq "Y") {
Write-Host "Removing all pre-existing labels from $RepositoryName..." -ForegroundColor Yellow
$GitHubRepositoryLabels | ConvertFrom-Json | ForEach-Object {
Write-Host "Removing label $($_.name) from $RepositoryName..." -ForegroundColor DarkRed
gh label delete -R $RepositoryName $_.name --yes
}
}
}
if ($RemoveExistingLabels -eq $true -and $NoUserPrompts -eq $true -and $UpdateAndAddLabelsOnly -eq $false) {
Write-Host "Removing all pre-existing labels from $RepositoryName..." -ForegroundColor Yellow
$GitHubRepositoryLabels | ConvertFrom-Json | ForEach-Object {
Write-Host "Removing label $($_.name) from $RepositoryName..." -ForegroundColor DarkRed
gh label delete -R $RepositoryName $_.name --yes
}
}
}
if ($null -eq $GitHubRepositoryLabels) {
Write-Host "No pre-existing labels to remove or not selected to be removed from $RepositoryName..." -ForegroundColor Magenta
}
# Check LabelsToApplyCsvUri is valid and contains a CSV content
Write-Host "Checking $LabelsToApplyCsvUri is valid..." -ForegroundColor Yellow
$LabelsToApplyCsvUriValid = $LabelsToApplyCsvUri -match "^https?://"
if ($false -eq $LabelsToApplyCsvUriValid) {
throw "The LabelsToApplyCsvUri $LabelsToApplyCsvUri is not valid. Please check the URI and try again. The format must be a valid URI."
}
Write-Host "The LabelsToApplyCsvUri $LabelsToApplyCsvUri is valid..." -ForegroundColor Green
# Create AVM lables from the AVM labels CSV file stored on the web using the convertfrom-csv cmdlet
$avmLabelsCsv = Invoke-WebRequest -Uri $LabelsToApplyCsvUri | ConvertFrom-Csv
# Check if the AVM labels CSV file contains the following columns: Name, Description, HEX
$avmLabelsCsvColumns = $avmLabelsCsv | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name
$avmLabelsCsvColumnsValid = $avmLabelsCsvColumns -contains "Name" -and $avmLabelsCsvColumns -contains "Description" -and $avmLabelsCsvColumns -contains "HEX"
if ($false -eq $avmLabelsCsvColumnsValid) {
throw "The labels CSV file does not contain the required columns: Name, Description, HEX. Please check the CSV file and try again. It contains the following columns: $avmLabelsCsvColumns"
}
Write-Host "The labels CSV file contains the required columns: Name, Description, HEX" -ForegroundColor Green
# Create the AVM labels in the GitHub repository
Write-Host "Creating/Updating the $($avmLabelsCsv.Count) AVM labels in $RepositoryName..." -ForegroundColor Yellow
$avmLabelsCsv | ForEach-Object {
if ($GitHubRepositoryLabelsJson.name -contains $_.name) {
Write-Host "The label $($_.name) already exists in $RepositoryName. Updating the label to ensure description and color are consitent..." -ForegroundColor Magenta
gh label create -R $RepositoryName "$($_.name)" -c $_.HEX -d $($_.Description) --force
}
else {
Write-Host "The label $($_.name) does not exist in $RepositoryName. Creating label $($_.name) in $RepositoryName..." -ForegroundColor Cyan
gh label create -R $RepositoryName "$($_.Name)" -c $_.HEX -d $($_.Description) --force
}
}
# POST - Get the current GitHub repository labels and export to a CSV file in the current directory or where -OutputDirectory specifies if set to a valid directory path and the directory exists or can be created if it does not exist already
if ($CreateCsvLabelExports -eq $true) {
Write-Host "Getting the current GitHub repository (post) labels for $RepositoryName..." -ForegroundColor Yellow
$GitHubRepositoryLabels = gh label list -R $RepositoryName -L $GitHubCliLimit --json name,description,color
if ($null -ne $GitHubRepositoryLabels) {
$csvFileNamePathPre = "$OutputDirectory\$($RepositoryName.Replace('/', '_'))-Labels-Post-$(Get-Date -Format FileDateTime).csv"
Write-Host "Exporting the current GitHub repository (post) labels for $RepositoryName to $csvFileNamePathPre" -ForegroundColor Yellow
$GitHubRepositoryLabels | ConvertFrom-Json | Export-Csv -Path $csvFileNamePathPre -NoTypeInformation
}
}
# If -RemoveExistingLabels is set to $true and user confirms they want to remove all pre-existing labels check that only the avm labels exist in the repository
if ($RemoveExistingLabels -eq $true -and ($RemoveExistingLabelsConfirmation -eq "Y" -or $NoUserPrompts -eq $true) -and $UpdateAndAddLabelsOnly -eq $false) {
Write-Host "Checking that only the AVM labels exist in $RepositoryName..." -ForegroundColor Yellow
$GitHubRepositoryLabels = gh label list -R $RepositoryName -L $GitHubCliLimit --json name,description,color
$GitHubRepositoryLabels | ConvertFrom-Json | ForEach-Object {
if ($avmLabelsCsv.Name -notcontains $_.name) {
throw "The label $($_.name) exists in $RepositoryName but is not in the CSV file."
}
}
Write-Host "Only the CSV labels exist in $RepositoryName..." -ForegroundColor Green
}
Write-Host "The CSV labels have been created/updated in $RepositoryName..." -ForegroundColor Green
ID: PMNFR4 - Category: Hygiene - Missing Resource Module(s)
An item MUST be logged onto as an issue on the AVM Central Repo (Azure/Azure-Verified-Modules) if a Resource Module does not exist for resources deployed by the pattern module.
Exception
If the Resource Module adds no value, see Resource Module functional requirement ID: RMFR2.
ID: TFNFR3 - Category: Contribution/Support - GitHub Repo Branch Protection
Module owners MUST set a branch protection policy on their GitHub Repositories for AVM modules against their default branch, typically main, to do the following:
- Requires a Pull Request before merging
- Require approval of the most recent reviewable push
- Dismiss stale pull request approvals when new commits are pushed
- Require linear history
- Prevents force pushes
- Not allow deletions
- Require CODEOWNERS review
- Do not allow bypassing the above settings
- Above settings MUST also be enforced to administrators
Tip
If you use the template repository as mentioned in the contribution guide, the above will automatically be set.
Telemetry
The content below is listed based on the following tags
| # | ID | Title | Severity | Persona | Lifecycle |
|---|---|---|---|---|---|
| 1 | SFR3 | Deployment/Usage Telemetry | MUST | Owner | Initial |
| 2 | SFR4 | Telemetry Enablement Flexibility | MUST | Owner | Initial |
โ See Specifications for this category
ID: SFR3 - Category: Telemetry - Deployment/Usage Telemetry
Modules MUST provide the capability to collect deployment/usage telemetry as detailed in Telemetry further.
To highlight that AVM modules use telemetry, an information notice MUST be included in the footer of each module’s README.md file with the below content. (See more details on this requirement, here.)
Telemetry Information Notice
Note
The following information notice is automatically added at the bottom of the README.md file of the module when
- Bicep: Using the
utilities/tools/Set-AVMModule.ps1utility - Terraform: Executing the
make docscommand with the note and header## Data Collectionbeing placed in the module’s_footer.mdbeforehand
### Data Collection
The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the [repository](https://aka.ms/avm/telemetry). There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft's privacy statement. Our privacy statement is located at <https://go.microsoft.com/fwlink/?LinkID=824704>. You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices.Module Class Applicability
This specification applies to all AVM module classes (resource, pattern, utility), however, in case of utility modules, telemetry collection MUST only be added when the utility module deploys any resources (e.g., a deployment script resource). If the utility module does not deploy any resources, telemetry collection MUST NOT be added.
Bicep
Important
We will maintain a set of CSV files in the AVM Central Repo (Azure/Azure-Verified-Modules) with the required TelemetryId prefixes to enable checks to utilize this list to ensure the correct IDs are used. To see the formatted content of these CSV files with additional information, please visit the AVM Module Indexes page.
The value you need to use for your module is defined in the related module index. You can look it up on the index pages for Resource Modules, Pattern Modules and Utility Modules.
The ARM deployment name used for the telemetry MUST follow the pattern and MUST be no longer than 64 characters in length: 46d3xbcp.<res/ptn>.<(short) module name>.<version>.<uniqueness>
<res/ptn>== AVM Resource or Pattern Module<(short) module name>== The AVM Module’s, possibly shortened, name including the resource provider and the resource type, without;- The prefixes:
avm-res- - The prefixes:
avm-ptn-
- The prefixes:
<version>== The AVM Module’s MAJOR.MINOR version (only) with.(periods) replaced with-(hyphens), to allow simpler splitting of the ARM deployment name<uniqueness>== This section of the ARM deployment name is to be used to ensure uniqueness of the deployment name.- This is to cater for the following scenarios:
- The module is deployed multiple times to the same:
- Location/Region
- Scope (Tenant, Management Group,Subscription, Resource Group)
- The module is deployed multiple times to the same:
- This is to cater for the following scenarios:
Note
Due to the 64-character length limit of Azure deployment names, the <(short) module name> segment has a length limit of 36 characters, so if the module name is longer than that, it MUST be truncated to 36 characters. If any of the semantic version’s segments are longer than 1 character, it further restricts the number of characters that can be used for naming the module.
An example deployment name for the AVM Virtual Machine Resource Module would be: 46d3xbcp.res.compute-virtualmachine.1-2-3.eum3
An example deployment name for a shortened module name would be: 46d3xbcp.res.desktopvirtualization-appgroup.1-2-3.eum3
Tip
Terraform
To enable telemetry data collection for Terraform modules, the modtm telemetry provider MUST be used. This lightweight telemetry provider sends telemetry data to Azure Application Insights via a HTTP POST front end service.
The modtm telemetry provider is included in all Terraform modules and is enabled by default through the main.telemetry.tf file being automatically distributed from the template repo.
The modtm provider MUST be listed under the required_providers section in the module’s terraform.tf file using the following entry. This is also validated by the linter.
terraform {
required_providers {
# .. other required providers as needed
modtm = {
source = "Azure/modtm"
version = "~> 0.3"
}
}
}ID: SFR4 - Category: Telemetry - Telemetry Enablement Flexibility
The telemetry collection MUST be on/enabled by default, however module consumers MUST be allowed to disable it by setting the below parameter/variable value to false:
- Bicep:
enableTelemetry - Terraform:
enable_telemetry
Note
Whenever a module references AVM modules that implement the telemetry parameter (e.g., a pattern module that uses AVM resource modules), the telemetry parameter value MUST be passed through to these modules. This is necessary to ensure a consumer can reliably enable & disable the telemetry feature for all used modules.
This general specification can be modified for some use-cases, that are language specific:
Bicep
For cross-references in resource modules, the spec BCPFR7 also applies.
Terraform
Currently, no further requirements apply.
Naming / Composition
The content below is listed based on the following tags
| # | ID | Title | Severity | Persona | Lifecycle |
|---|---|---|---|---|---|
| 1 | SFR1 | Preview Services | MUST | Owner | BAU |
| 2 | SFR2 | WAF Aligned | SHOULD | Owner | BAU |
| 3 | SFR5 | Availability Zones | MUST | Owner | Initial |
| 4 | SFR6 | Data Redundancy | MUST | Owner | Initial |
| 5 | SNFR25 | Resource Naming | MUST | Owner | Initial |
| 6 | RMFR1 | Single Resource Only | MUST | OwnerContributor | BAU |
| 7 | RMFR2 | No Resource Wrapper Modules | MUST | Owner | Initial |
| 8 | RMFR4 | AVM Consistent Feature & Extension Resources Value Add | MUST | OwnerContributor | BAU |
| 9 | RMFR5 | AVM Consistent Feature & Extension Resources Value Add Interfaces/Schemas | MUST | OwnerContributor | BAU |
| 10 | RMFR8 | Dependency on child and other resources | MUST | OwnerContributor | BAU |
| 11 | RMFR9 | End-of-life resource versions | SHOULD | OwnerContributor | BAU |
| 12 | RMNFR1 | Module Naming | MUST | Owner | Initial |
| 13 | RMNFR3 | RP Collaboration | SHOULD | Owner | BAU |
| 14 | TFFR1 | Cross-Referencing Modules | MUST | OwnerContributor | BAU |
| 15 | TFFR3 | Providers - Permitted Versions | MUST | OwnerContributor | BAU |
| 16 | TFFR4 | AzAPI - response_export_values | MUST | OwnerContributor | BAU |
| 17 | TFFR5 | AzAPI - replace_triggers_refs | MUST | OwnerContributor | BAU |
| 18 | TFNFR4 | Lower snake_casing | MUST | OwnerContributor | BAU |
| 19 | TFRMNFR1 | Subresources as submodules | MUST | OwnerContributor | BAU |
| 20 | TFRMNFR2 | Primary Resource Naming | MUST | OwnerContributor | BAU |
โ See Specifications for this category
ID: SFR1 - Category: Composition - Preview Services
Modules MAY create/adopt public preview services and features at their discretion.
Preview API versions MAY be used when:
- The resource/service/feature is GA but the only API version available for the GA resource/service/feature is a preview version
- For example, Diagnostic Settings (
Microsoft.Insights/diagnosticSettings) the latest version of the API available with GA features, like Category Groups etc., is2021-05-01-preview - Otherwise the latest “non-preview” version of the API SHOULD be used
- For example, Diagnostic Settings (
Preview services and features, SHOULD NOT be promoted and exposed, unless they are supported by the respective PG, and it’s documented publicly.
However, they MAY be exposed at the module owners discretion, but the following rules MUST be followed:
- The description of each of the parameters/variables used for the preview service/feature MUST start with:
- “THIS IS A <PARAMETER/VARIABLE> USED FOR A PREVIEW SERVICE/FEATURE, MICROSOFT MAY NOT PROVIDE SUPPORT FOR THIS, PLEASE CHECK THE PRODUCT DOCS FOR CLARIFICATION”
ID: SFR2 - Category: Composition - WAF Aligned
Modules SHOULD set defaults in input parameters/variables to align to high priority/impact/severity recommendations, where appropriate and applicable, in the following frameworks and resources:
- Well-Architected Framework (WAF)
- Reliability Hub
- Azure Proactive Resiliency Library (APRL)
- Only Product Group (PG) verified
- Microsoft Defender for Cloud (MDFC)
They SHOULD NOT align to these recommendations when it requires an external dependency/resource to be deployed and configured and then associated to the resources in the module.
Alignment SHOULD prioritize best-practices and security over cost optimization, but MUST allow for these to be overridden by a module consumer easily, if desired.
Tip
Read the FAQ of What does AVM mean by “WAF Aligned”? for more detailed information and examples.
ID: SFR5 - Category: Composition - Availability Zones
Modules that deploy zone-redundant resources MUST enable the spanning across as many zones as possible by default, typically all 3.
Modules that deploy zonal resources MUST provide the ability to specify a zone for the resources to be deployed/pinned to. However, they MUST NOT default to a particular zone by default, e.g. 1 in an effort to make the consumer aware of the zone they are selecting to suit their architecture requirements.
For both scenarios the modules MUST expose these configuration options via configurable parameters/variables.
Note
For information on the differences between zonal and zone-redundant services, see Availability zone service and regional support
ID: SFR6 - Category: Composition - Data Redundancy
Modules that deploy resources or patterns that support data redundancy SHOULD enable this to the highest possible value by default, e.g. RA-GZRS. When a resource or pattern doesn’t provide the ability to specify data redundancy as a simple property, e.g. GRS etc., then the modules MUST provide the ability to enable data redundancy for the resources or pattern via parameters/variables.
For example, a Storage Account module can simply set the sku.name property to Standard_RAGZRS. Whereas a SQL DB or Cosmos DB module will need to expose more properties, via parameters/variables, to allow the specification of the regions to replicate data to as per the consumers requirements.
Note
For information on the data redundancy options in Azure, see Cross-region replication in Azure
ID: SNFR25 - Category: Composition - Resource Naming
Module owners MUST set the default resource name prefix for child, extension, and interface resources to the associated abbreviation for the specific resource as documented in the following CAF article Abbreviation examples for Azure resources, if specified and documented. This reduces the amount of input values a module consumer MUST provide by default when using the module.
For example, a Private Endpoint that is being deployed as part of a resource module, via the mandatory interfaces, MUST set the Private Endpoint’s default name to begin with the prefix of pep-.
Module owners MUST also provide the ability for these default names, including the prefixes, to be overridden via a parameter/variable if the consumer wishes to.
Furthermore, as per RMNFR2, Resource Modules MUST not have a default value specified for the name of the primary resource and therefore the name MUST be provided and specified by the module consumer.
The name provided MAY be used by the module owner to generate the rest of the default name for child, extension, and interface resources if they wish to. For example, for the Private Endpoint mentioned above, the full default name that can be overridden by the consumer, MAY be pep-<primary-resource-name>.
Tip
If the resource does not have a documented abbreviation in Abbreviation examples for Azure resources, then the module owner is free to use a sensible prefix instead.
ID: RMFR1 - Category: Composition - Single Resource Only
A resource module MUST only deploy a single instance of the primary resource, e.g., one virtual machine per instance.
Multiple instances of the module MUST be used to scale out.
ID: RMFR2 - Category: Composition - No Resource Wrapper Modules
A resource module MUST add value by including additional features on top of the primary resource.
ID: RMFR4 - Category: Composition - AVM Consistent Feature & Extension Resources Value Add
Resource modules support the following optional features/extension resources, as specified, if supported by the primary resource. The top-level variable/parameter names MUST be:
| Optional Features/Extension Resources | Bicep Parameter Name | Terraform Variable Name | MUST/SHOULD |
|---|---|---|---|
| Diagnostic Settings | diagnosticSettings | diagnostic_settings | MUST |
| Role Assignments | roleAssignments | role_assignments | MUST |
| Resource Locks | lock | lock | MUST |
| Tags | tags | tags | MUST |
| Managed Identities (System / User Assigned) | managedIdentities | managed_identities | MUST |
| Private Endpoints | privateEndpoints | private_endpoints | MUST |
| Customer Managed Keys | customerManagedKey | customer_managed_key | MUST |
| Azure Monitor Alerts | alerts | alerts | SHOULD |
Resource modules MUST NOT deploy required/dependent resources for the optional features/extension resources specified above. For example, for Diagnostic Settings the resource module MUST NOT deploy the Log Analytics Workspace, this is expected to be already in existence from the perspective of the resource module deployed via another method/module etc.
Note
Please note that the implementation of Customer Managed Keys from an ARM API perspective is different across various RPs that implement Customer Managed Keys in their service. For that reason you may see differences between modules on how Customer Managed Keys are handled and implemented, but functionality will be as expected.
Module owners MAY choose to utilize cross repo dependencies for these “add-on” resources, or MAY chose to implement the code directly in their own repo/module. So long as the implementation and outputs are as per the specifications requirements, then this is acceptable.
ID: RMFR5 - Category: Composition - AVM Consistent Feature & Extension Resources Value Add Interfaces/Schemas
Resource modules MUST implement a common interface, e.g. the input’s data structures and properties within them (objects/arrays/dictionaries/maps), for the optional features/extension resources:
See:
ID: RMFR8 - Category: Composition - Dependency on child and other resources
A resource module MAY contain references to other resource modules, however MUST NOT contain references to non-AVM modules nor AVM pattern modules.
See BCPFR1 and TFFR1 for more information on this.
ID: RMFR9 - Category: Composition - End-of-life resource versions
When a given version of an Azure resource used in a resource module reaches its end-of-life (EOL) and is no longer supported by Microsoft, the module owner SHOULD ensure that:
- The module is aligned with these changes and only includes supported versions of the resource. This is typically achieved through the allowed values in the parameter that specifies the resource SKU or type.
- The following notice is shown under the
Notessection of the module’sreadme.md. (If any related public announcement is available, it can also be linked to from the Notes section.):“Certain versions of this Azure resource reached their end of life. The latest version of this module only includes supported versions of the resource. All unsupported versions have been removed from the related parameters.”
- AND the related parameter’s description:
“Certain versions of this Azure resource reached their end of life. The latest version of this module only includes supported versions of the resource. All unsupported versions have been removed from this parameter.”
ID: RMNFR1 - Category: Naming - Module Naming
Resource modules MUST follow the below naming conventions (all lower case).
Important
As part of the module proposal process, the module’s approved name is captured both in the module proposal issue AND the related module index page (backed by the corresponding CSV file).
Therefore, module owners don’t need to construct the module’s name themselves, instead they need use the name prescribed in the module proposal issue or in the related CSV file, at the time of approval.
Note
We will maintain a set of CSV files in the AVM Central Repo (Azure/Azure-Verified-Modules) with the correct singular names for all resource types to enable checks to utilize this list to ensure repos are named correctly. To see the formatted content of these CSV files with additional information, please visit the AVM Module Indexes page.
This will be updated quarterly, or ad-hoc as new RPs/ Resources are created and highlighted via a check failure.
Bicep Resource Module Naming
- Naming convention (module name for registry):
avm/res/<hyphenated resource provider name>/<hyphenated ARM resource type> - Example:
avm/res/compute/virtual-machineoravm/res/managed-identity/user-assigned-identity - Segments:
resdefines this is a resource module<hyphenated resource provider name>is the resource provider’s name after theMicrosoftpart, with each word starting with a capital letter separated by dashes, e.g.,Microsoft.Compute=compute,Microsoft.ManagedIdentity=managed-identity.<hyphenated ARM resource type>is the singular version of the word after the resource provider, with each word starting with a capital letter separated by dashes, e.g.,Microsoft.Compute/virtualMachines=virtual-machine, BUTMicrosoft.Network/trafficmanagerprofiles=trafficmanagerprofile- sincetrafficmanagerprofilesis all lower case as per the ARM API definition.
Bicep Child Module Naming
Naming convention (module name for registry):
avm/res/<hyphenated resource provider name>/<hyphenated ARM resource type>/<hyphenated child resource type/<hyphenated grandchild resource type>/<etc.>Example:
avm/res/network/virtual-network/subnetoravm/res/storage/storage-account/blob-service/containerSegments:
resdefines this is a resource module<hyphenated resource provider name>is the resource provider’s name after theMicrosoftpart, with each word starting with a capital letter separated by dashes, e.g.,Microsoft.Network=network.<hyphenated ARM resource type>is the singular version of the word after the resource provider, with each word starting with a capital letter separated by dashes, e.g.,Microsoft.Network/virtualNetworks=virtual-network.<hyphenated child resource type (to be repeated for grandchildren, etc.)>is the singular version of the word after the resource provider, with each word starting with a capital letter separated by dashes, e.g.,Microsoft.Network/virtualNetworks/subnets=subnetorMicrosoft.Storage/storageAccounts/blobServices/containers=blob-service/container.
Terraform Resource Module Naming
- Naming convention:
avm-res-<resource provider>-<ARM resource type>(module name for registry)terraform-<provider>-avm-res-<resource provider>-<ARM resource type>(GitHub repository name to meet registry naming requirements)
- Example:
avm-res-compute-virtualmachineoravm-res-managedidentity-userassignedidentity - Segments:
<provider>is a legacy requirement of the Terraform registry. This must be set toazureresdefines this is a resource module<resource provider>is the resource provider’s name after theMicrosoftpart, e.g.,Microsoft.Compute=compute.<ARM resource type>is the singular version of the word after the resource provider, e.g.,Microsoft.Compute/virtualMachines=virtualmachine
ID: RMNFR3 - Category: Composition - RP Collaboration
Module owners (Microsoft FTEs) SHOULD reach out to the respective Resource Provider teams to build a partnership and collaboration on the modules creation, existence and long term maintenance.
Review this wiki page (Microsoft Internal) for more information.
ID: TFFR1 - Category: Composition - Cross-Referencing Modules
Module owners MAY cross-references other modules to build either Resource or Pattern modules. However, they MUST be referenced only by a HashiCorp Terraform registry reference to a pinned version e.g.,
module "other-module" {
source = "Azure/xxx/azure"
version = "1.2.3"
}They MUST NOT use git reference to a module.
module "other-module" {
source = "git::https://xxx.yyy/xxx.git"
}module "other-module" {
source = "github.com/xxx/yyy"
}Modules MUST NOT contain references to non-AVM modules.
Tip
See Module Sources for more information.
ID: TFFR3 - Category: Providers - Permitted Versions
Authors MUST only use the following Azure providers, and versions, in their modules:
| provider | min version | max version |
|---|---|---|
| Azure/azapi | >= 2.0 | < 3.0 |
The AzureRM provider MUST NOT be used, except where the narrow exception below applies.
Exception โ AzureRM for resources with no AzAPI equivalent
An AVM Terraform module MAY declare the AzureRM provider only for resources whose functionality is genuinely unavailable through any AzAPI resource โ that is, where there is no equivalent in azapi_resource, azapi_data_plane_resource, azapi_resource_action, or azapi_update_resource. In practice this is limited to a small set of edge cases, most commonly data-plane operations such as Key Vault secrets and certificates, Storage blobs, and a handful of resources whose azurerm_* implementation calls non-ARM APIs.
Where this exception applies the module MUST:
Pin the AzureRM provider to
~> 4.0inrequired_providers.Use AzAPI for every resource that has an AzAPI equivalent. AzureRM MUST NOT be used as a convenience alternative to AzAPI.
Document the exception in the module’s
README.md, listing eachazurerm_*resource used, the data-plane / non-ARM API it wraps, why no AzAPI equivalent exists today, and the upstream AzAPI issue or PR tracking the eventual replacement.Replace each
azurerm_*resource with its AzAPI equivalent as soon as one becomes available, in the next module release after the AzAPI capability ships.Add the following TFLint exclusion (only required because the AzureRM provider is otherwise blocked by AVM tooling):
rule "provider_azurerm_disallowed" { enabled = false }
This exception MUST NOT be used to:
- Avoid migrating an existing AzureRM resource that does have an AzAPI equivalent.
- Reduce author effort where the AzAPI body schema is more verbose than the AzureRM resource.
- Side-step any other AzAPI-specific spec (for example TFFR4, TFFR5, TFFR6, or TFFR7) โ those rules continue to apply to every AzAPI resource the module declares, regardless of whether the module also uses AzureRM under this exception.
Authors MUST use the required_providers block in their module to enforce the provider versions.
The following is an example.
- In it we use the pessimistic version constraint operator
~>. - That is to say that
~> 2.9is equivalent to>= 2.9, < 3.0.
terraform {
required_providers {
# Include one or both providers, as needed
azapi = {
source = "Azure/azapi"
version = "~> 2.9"
}
}
}ID: TFFR4 - Category: Composition - AzAPI - response_export_values
Authors MUST specify the response_export_values argument when using the AzAPI provider:
resource "azapi_resource" "example" {
type = "Microsoft.Example/resourceType@2021-01-01"
name = "example-resource"
location = "West US"
response_export_values = [] # must be specified, even if empty
body = {
properties = {
exampleProperty = "exampleValue"
}
}
}If you require read-only properties to be returned from the resource, you SHOULD include them as follows:
resource "azapi_resource" "example" {
type = "Microsoft.Example/resourceType@2021-01-01"
name = "example-resource"
location = "West US"
# Example as a list:
response_export_values = ["properties.readOnlyProperty"]
# Example as a map:
# response_export_values = {
# read_only_property = "properties.readOnlyProperty"
# }
body = {
properties = {
exampleProperty = "exampleValue"
}
}
}
output "read_only_property" {
# Example if response_export_values is a list:
value = azapi_resource.example.output.properties.readOnlyProperty
# Example if response_export_values is a map:
# value = azapi_resource.example.output.read_only_property
}ID: TFFR5 - Category: Composition - AzAPI - replace_triggers_refs
Authors MUST specify the replace_triggers_refs argument when using the AzAPI provider.
The values should contain the body paths that would cause the resource to be replaced when they change.
You do not need to include name, or location, as these already trigger replacement.
This is to ensure that changes to properties that require replacement of the resource are handled correctly by Terraform.
resource "azapi_resource" "example" {
type = "Microsoft.Example/resourceType@2021-01-01"
name = "example-resource"
location = "West US"
replace_triggers_refs = [
"properties.exampleProperty"
] # must be specified, even if empty
body = {
properties = {
exampleProperty = "exampleValue"
}
}
}ID: TFNFR4 - Category: Composition - Code Styling - lower snake_casing
Module owners MUST use lower snake_casing for naming the following:
- Locals
- Variables
- Outputs
- Resources (symbolic names)
- Modules (symbolic names)
For example: snake_casing_example (every word in lowercase, with each word separated by an underscore _)
ID: TFRMNFR1 - Category: Composition - Subresources as submodules
Resource modules MUST implement each ARM subresource (a child resource type as defined in the API spec, for example Microsoft.Example/widgets/parts is a subresource of Microsoft.Example/widgets) as a Terraform submodule.
Submodules MUST be located under a modules/<subresource-singular-name>/ directory at the root of the module, where <subresource-singular-name> is the singular form of the ARM subresource name as per PMNFR1.
For example, a resource module for Microsoft.Example/widgets would have the following layout:
terraform-azure-avm-res-example-widget/
โโ main.tf # azapi_resource for Microsoft.Example/widgets
โโ variables.tf
โโ outputs.tf
โโ terraform.tf
โโ _header.md # required for top-level docs generation
โโ _footer.md # required for top-level docs generation
โโ modules/
โ โโ part/ # subresource: Microsoft.Example/widgets/parts
โ โ โโ main.tf
โ โ โโ variables.tf
โ โ โโ outputs.tf
โ โ โโ terraform.tf
โ โ โโ _header.md # required for submodule docs generation
โ โ โโ _footer.md # required for submodule docs generation
โ โโ gadget/ # subresource: Microsoft.Example/widgets/gadgets
โ โโ main.tf
โ โโ variables.tf
โ โโ outputs.tf
โ โโ terraform.tf
โ โโ _header.md
โ โโ _footer.md
โโ examples/The parent module MUST reference and compose its submodules so that supported subresources can be expressed through the parent module, but each submodule MUST also be independently consumable.
“Independently consumable” means a caller can source the submodule directly and use it without relying on hidden behavior in the parent module. Therefore, a submodule MUST follow the same interface and specification rules as a root AVM Terraform module (as listed below), even when the parent module also instantiates it.
Submodule cardinality
Submodules MUST deploy exactly one instance of the resource they manage. The submodule’s primary azapi_resource (or equivalent) MUST NOT declare count or for_each, and the submodule MUST NOT otherwise create multiple instances of its primary resource.
Cardinality is the parent module’s responsibility: the parent module MUST use count or for_each on its submodule call to control how many instances of the subresource are deployed. This keeps each submodule’s variables, outputs and tests focused on a single resource and pushes cardinality concerns up to the consumer.
This rule applies equally when a submodule is consumed through its parent module and when the same submodule is consumed directly by another caller.
For example, a parent module deploying multiple parts calls its part submodule using for_each, cascades the matching nested slot from its own resource_types (see TFFR6 for the naming rule and nested-slot pattern), and passes retry and timeouts through unchanged (see TFFR7):
module "part" {
source = "./modules/part"
for_each = var.parts
name = each.value.name
parent_id = azapi_resource.this.id
resource_types = var.resource_types.example_widgets_parts
retry = var.retry
timeouts = var.timeouts
}When the subresource tree is more than one level deep (for example Microsoft.Example/widgets/parts/components), the same pattern recurses: the part submodule declares its own resource_types with a nested slot for example_widgets_parts_components, and cascades that slot through to its sibling component submodule unchanged:
# Inside modules/part/main.tf
module "component" {
source = "../component"
for_each = var.components
name = each.value.name
parent_id = azapi_resource.this.id
resource_types = var.resource_types.example_widgets_parts_components
retry = var.retry
timeouts = var.timeouts
}The following pattern is NOT allowed inside a submodule, because it pushes cardinality into the submodule itself:
# modules/part/main.tf (invalid)
resource "azapi_resource" "this" {
for_each = var.parts
# ...
}Module source references
Parent modules MUST reference each submodule using a local relative path rooted at the parent module’s directory:
module "part" {
source = "./modules/part"
# ...other arguments...
}Submodules MAY reference sibling submodules using a relative path that traverses up to the shared modules/ directory and back down into the sibling:
# Inside modules/part/main.tf, calling its sibling submodule modules/sub-part/
module "sub_part" {
source = "../sub-part"
# ...other arguments...
}This pattern is useful when an ARM resource provider exposes child resources nested more than one level deep โ for example Microsoft.Example/widgets/parts/components, where the part submodule itself needs to instantiate its own component submodule.
Submodules MUST NOT reference a sibling submodule via the Terraform Registry (for example Azure/avm-res-example-widget/azure//modules/part) or via a Git URL when the sibling lives in the same repository. Using a relative path keeps the entire module tree as a single unit that can be developed, tested and released atomically.
Submodule documentation files
Each submodule directory MUST contain its own _header.md and _footer.md files at the root of the submodule (alongside main.tf). These files are consumed by the AVM terraform-docs documentation generation pipeline (see TFNFR2) to produce the submodule’s README.md. Without them, the generated submodule documentation will be missing its introduction and footer sections and the documentation pipeline will not produce a complete README.md.
The submodule _header.md and _footer.md MUST:
- Describe the subresource the submodule manages, not the parent resource.
- Be checked in to source control (they are inputs to documentation generation, not generated artifacts).
- Be present in every submodule under
modules/, even if the submodule is not intended to be consumed independently.
Submodules are full AVM modules
Submodules MUST meet every requirement that applies to a top-level AVM Terraform resource module, including (but not limited to):
- All shared specifications (SFR and SNFR prefixed specs).
- All resource module specifications (RMFR and RMNFR prefixed specs).
- All Terraform specifications (TFFR and TFNFR prefixed specs), including:
- TFFR3 โ AzAPI provider usage.
- TFFR4 โ
response_export_values. - TFFR5 โ
replace_triggers_refs. - TFFR6 โ
resource_typesvariable. Each submodule declares its ownresource_typesfor the resources it owns; the parent declares a nestedoptional(object({...}), {})slot per submodule that mirrors the submodule’s variable exactly, and cascades it through unchanged. - TFFR7 โ
retryandtimeoutsvariables, which the parent module MUST cascade to each submodule unchanged.
- All applicable interface specifications (managed identities, role assignments, locks, diagnostic settings, private endpoints, customer-managed keys, tags) โ for any interface that is supported by the underlying ARM subresource.
To avoid duplication, this specification deliberately states the requirement once: every requirement that applies to a top-level resource module applies equally to every one of its submodules. Where a requirement contradicts the submodule’s nature (for example, a submodule that is never published independently still MUST include all required documentation files but is not itself listed in the registry), the requirement is interpreted in the context of the submodule.
Rationale
Implementing subresources as submodules:
- Provides a clean, narrowly-scoped Terraform interface per ARM resource type, mirroring the ARM/AzAPI model where each resource type has its own type identifier and API version.
- Allows consumers to use only the subresources they need, without paying the cost of unused resources.
- Keeps each submodule’s variables, outputs and tests focused, which improves readability, testability and review velocity.
- Aligns with the equivalent Bicep guidance in BCPRMNFR3 so that AVM resource modules in both languages share a consistent structure.
ID: TFRMNFR2 - Category: Naming/Composition - Primary Resource Naming
The primary azapi_resource (or equivalent AzAPI resource) declared in a Terraform resource module MUST be named this. The same rule applies to the primary resource declared in any submodule (per TFRMNFR1).
The “primary resource” is the single Azure resource that the module exists to manage โ the one whose ARM resource type appears in the module’s name (per RMNFR1). Every other resource declared by the module (locks, role assignments, diagnostic settings, private endpoints, private DNS zone groups, child / extension resources required by the primary resource, etc.) is a satellite resource and MUST NOT be named this; instead, satellites MUST be named after what they represent (for example azapi_resource.lock, azapi_resource.role_assignments, azapi_resource.diagnostic_settings, azapi_resource.private_endpoints).
Standardizing on this for the primary resource lets consumers, CI checks, and the AVM interface utility module reference it predictably โ most notably as azapi_resource.this.id for downstream parent_id wiring, and azapi_resource.this.output for exported values.
Example
The resource label (this) and the var.resource_types.<key> argument supplied to type = are independent concerns: the label is governed by this spec, the key by the naming rule in TFFR6. this is therefore never a valid resource_types key โ the key names the AzAPI resource type, not the Terraform graph node.
resource "azapi_resource" "this" {
type = var.resource_types.example_widgets
name = var.name
parent_id = var.parent_id
body = { /* ... */ }
}
resource "azapi_resource" "lock" {
count = var.lock != null ? 1 : 0
type = module.avm_interfaces.lock_azapi.type
name = module.avm_interfaces.lock_azapi.name
parent_id = module.avm_interfaces.lock_azapi.parent_id
body = module.avm_interfaces.lock_azapi.body
}
resource "azapi_resource" "role_assignments" {
for_each = module.avm_interfaces.role_assignments_azapi
type = each.value.type
name = each.value.name
parent_id = each.value.parent_id
body = each.value.body
}Exceptions
The this rule MAY be relaxed only when all of the following are true:
- The module is a utility module (per Module Classifications) OR the module’s primary functionality is implemented by two or more
azapi_resourcedeclarations that are peers (no resource is the ARM parent of any other, and no resource depends on another resource’s ID for its own creation). - No single
azapi_resourcewould, on its own, be a meaningful handle for downstream consumers (i.e. there is no resource whoseidwould be the obvious value of a single canonicalresource_idoutput).
A module where one azapi_resource is the ARM parent of, or a hard dependency for, another azapi_resource is NOT exempted โ the parent resource is the primary and MUST be named this.
Where this exception applies, each resource MUST be named after what it represents, and the module’s README.md MUST document why the this convention does not apply.
Notes
- This rule applies regardless of whether the primary resource uses
azapi_resource,azapi_resource_action,azapi_update_resource, or any other AzAPI resource type. - The rule applies independently to every submodule: each submodule has its own
this(the primary resource it manages) โ that is the contract enabling the parent module to writemodule.<submodule>.resource_id. - The rule does not apply to data sources or to
azapi_resource_listlookups; those SHOULD still be named after what they represent.
Code Style
The content below is listed based on the following tags
โ See Specifications for this category
ID: TFNFR6 - Category: Code Style - Resource & Data Order
For the definition of resources in the same file, the resources be depended on SHOULD come first, after them are the resources depending on others.
Resources that have dependencies SHOULD be defined close to each other.
ID: TFNFR7 - Category: Code Style - count & for_each Use
We can use count and for_each to deploy multiple resources, but the improper use of count can lead to anti pattern.
You can use count to create some kind of resources under certain conditions, for example:
resource "azapi_resource" "network_security_group" {
count = local.create_new_security_group ? 1 : 0
type = "Microsoft.Network/networkSecurityGroups@2023-11-01"
name = coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg")
parent_id = var.parent_id
location = local.location
tags = var.new_network_security_group_tags
body = {
properties = {}
}
response_export_values = []
}The module’s owners MUST use map(xxx) or set(xxx) as resource’s for_each collection, the map’s key or set’s element MUST be static literals.
Good example:
resource "azapi_resource" "subnet_pair" {
for_each = var.subnet_map // `map(string)`, when user call this module, it could be: `{ "subnet0": "subnet0" }`, or `{ "subnet0": azapi_resource.subnet0.name }`
type = "Microsoft.Network/virtualNetworks/subnets@2023-11-01"
name = "${each.value}-pair"
parent_id = azapi_resource.virtual_network.id
body = {
properties = {
addressPrefixes = ["10.0.1.0/24"]
}
}
response_export_values = []
}Bad example:
resource "azapi_resource" "subnet_pair" {
for_each = var.subnet_name_set // `set(string)`, when user use `toset([azapi_resource.subnet0.name])`, it would cause an error.
type = "Microsoft.Network/virtualNetworks/subnets@2023-11-01"
name = "${each.value}-pair"
parent_id = azapi_resource.virtual_network.id
body = {
properties = {
addressPrefixes = ["10.0.1.0/24"]
}
}
response_export_values = []
}ID: TFNFR8 - Category: Code Style - Resource & Data Block Orders
There are 3 types of assignment statements in a resource or data block: argument, meta-argument and nested block. The argument assignment statement is a parameter followed by =:
location = azapi_resource.example.locationor:
tags = {
environment = "Production"
}Nested block is a assignment statement of parameter followed by {} block:
subnet {
name = "subnet1"
address_prefix = "10.0.1.0/24"
}Meta-arguments are assignment statements can be declared by all resource or data blocks. They are:
countdepends_onfor_eachlifecycleprovider
The order of declarations within resource or data blocks is:
All the meta-arguments SHOULD be declared on the top of resource or data blocks in the following order:
providercountfor_each
Then followed by:
- required arguments
- optional arguments
- required nested blocks
- optional nested blocks
All ranked in alphabetical order.
These meta-arguments SHOULD be declared at the bottom of a resource block with the following order:
depends_onlifecycle
The parameters of lifecycle block SHOULD show up in the following order:
create_before_destroyignore_changesprevent_destroy
parameters under depends_on and ignore_changes are ranked in alphabetical order.
Meta-arguments, arguments and nested blocked are separated by blank lines.
dynamic nested blocks are ranked by the name comes after dynamic, for example:
dynamic "linux_profile" {
for_each = var.admin_username == null ? [] : ["linux_profile"]
content {
admin_username = var.admin_username
ssh_key {
key_data = replace(coalesce(var.public_ssh_key, tls_private_key.ssh[0].public_key_openssh), "\n", "")
}
}
}This dynamic block will be ranked as a block named linux_profile.
Code within a nested block will also be ranked following the rules above.
PS: You can use avmfix tool to reformat your code automatically.
ID: TFNFR9 - Category: Code Style - Module Block Order
The meta-arguments below SHOULD be declared on the top of a module block with the following order:
sourceversioncountfor_each
blank lines will be used to separate them.
After them will be required arguments, optional arguments, all ranked in alphabetical order.
These meta-arguments below SHOULD be declared on the bottom of a resource block in the following order:
depends_onproviders
Arguments and meta-arguments SHOULD be separated by blank lines.
ID: TFNFR10 - Category: Code Style - No Double Quotes in ignore_changes
The ignore_changes attribute MUST NOT be enclosed in double quotes.
Good example:
lifecycle {
ignore_changes = [
tags,
]
}Bad example:
lifecycle {
ignore_changes = [
"tags",
]
}ID: TFNFR11 - Category: Code Style - Null Comparison Toggle
Sometimes we need to ensure that the resources created are compliant to some rules at a minimum extent, for example a subnet has to be connected to at least one network_security_group. The user SHOULD pass in a security_group_id and ask us to make a connection to an existing security_group, or want us to create a new security group.
Intuitively, we will define it like this:
variable "security_group_id" {
type: string
}
resource "azapi_resource" "network_security_group" {
count = var.security_group_id == null ? 1 : 0
type = "Microsoft.Network/networkSecurityGroups@2023-11-01"
name = coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg")
parent_id = var.parent_id
location = local.location
tags = var.new_network_security_group_tags
body = {
properties = {}
}
response_export_values = []
}The disadvantage of this approach is if the user create a security group directly in the root module and use the id as a variable of the module, the expression which determines the value of count will contain an attribute from another resource, the value of this very attribute is “known after apply” at plan stage. Terraform core will not be able to get an exact plan of deployment during the “plan” stage.
You can’t do this:
resource "azapi_resource" "foo" {
type = "Microsoft.Network/networkSecurityGroups@2023-11-01"
name = "example-nsg"
parent_id = "/subscriptions/.../resourceGroups/example-rg"
location = "eastus"
body = {
properties = {}
}
response_export_values = []
}
module "bar" {
source = "xxxx"
...
security_group_id = azapi_resource.foo.id
}For this kind of parameters, wrapping with object type is RECOMMENDED:
variable "security_group" {
type: object({
id = string
})
default = null
}The advantage of doing so is encapsulating the value which is “known after apply” in an object, and the object itself can be easily found out if it’s null or not. Since the id of a resource cannot be null, this approach can avoid the situation we are facing in the first example, like the following:
resource "azapi_resource" "foo" {
type = "Microsoft.Network/networkSecurityGroups@2023-11-01"
name = "example-nsg"
parent_id = "/subscriptions/.../resourceGroups/example-rg"
location = "eastus"
body = {
properties = {}
}
response_export_values = []
}
module "bar" {
source = "xxxx"
...
security_group = {
id = azapi_resource.foo.id
}
}This technique SHOULD be used under this use case only.
ID: TFNFR12 - Category: Code Style - Dynamic for Optional Nested Objects
An example using AzAPI:
resource "azapi_resource" "main" {
type = "Microsoft.ContainerService/managedClusters@2024-09-01"
name = var.name
parent_id = var.parent_id
location = var.location
body = {
properties = { ... }
}
dynamic "identity" {
for_each = var.client_id == "" || var.client_secret == "" ? [1] : []
content {
type = var.identity_type
identity_ids = var.user_assigned_identity_ids
}
}
response_export_values = []
}Please refer to the coding style in the example. Nested blocks under conditions, MUST be declared as:
for_each = <condition> ? [<some_item>] : []ID: TFNFR13 - Category: Code Style - Default Values with coalesce/try
The following example shows how "${var.subnet_name}-nsg" SHOULD be used when var.new_network_security_group_name is null or ""
Good examples:
coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg")try(coalesce(var.new_network_security_group.name, "${var.subnet_name}-nsg"), "${var.subnet_name}-nsg")Bad examples:
var.new_network_security_group_name == null ? "${var.subnet_name}-nsg" : var.new_network_security_group_name)ID: TFNFR16 - Category: Code Style - Variable Naming Rules
The naming of a variable SHOULD follow HashiCorp’s naming rule.
variable used as feature switches SHOULD apply a positive statement, use xxx_enabled instead of xxx_disabled. Avoid double negatives like !xxx_disabled.
Please use xxx_enabled instead of xxx_disabled as name of a variable.
ID: TFNFR17 - Category: Code Style - Variables with Descriptions
The target audience of description is the module users.
For a newly created variable (Eg. variable for switching dynamic block on-off), it’s description SHOULD precisely describe the input parameter’s purpose and the expected data type. description SHOULD NOT contain any information for module developers, this kind of information can only exist in code comments.
For object type variable, description can be composed in HEREDOC format:
variable "kubernetes_cluster_key_management_service" {
type: object({
key_vault_key_id = string
key_vault_network_access = optional(string)
})
default = null
description = <<DESCRIPTION
- `key_vault_key_id` - (Required) Identifier of Azure Key Vault key. See [key identifier format](https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#vault-name-and-object-name) for more details. When Azure Key Vault key management service is enabled, this field is required and must be a valid key identifier. When `enabled` is `false`, leave the field empty.
- `key_vault_network_access` - (Optional) Network access of the key vault Network access of key vault. The possible values are `Public` and `Private`. `Public` means the key vault allows public access from all networks. `Private` means the key vault disables public access and enables private link. Defaults to `Public`.
DESCRIPTION
}You MUST remove all trailing whitespace so that terraform-docs renders the readme properly.
ID: TFNFR18 - Category: Code Style - Variables with Types
type MUST be defined for every variable. type SHOULD be as precise as possible. Authors SHOULD NOT use any.
- Use
boolinstead ofstringornumberfortrue/false - Use
stringfor text - Use concrete
objectinstead ofmap(any)
ID: TFNFR19 - Category: Code Style - Sensitive Data Variables
If variable’s type is object and contains one or more fields that would be assigned to a sensitive argument, then this whole variable SHOULD be declared as sensitive = true, otherwise you SHOULD extract sensitive field into separated variable block with sensitive = true.
ID: TFNFR20 - Category: Code Style - Non-Nullable Defaults for collection values
Nullable SHOULD be set to false for collection values (e.g. sets, maps, lists) when using them in loops. However for scalar values like string and number, a null value MAY have a semantic meaning and as such these values are allowed.
ID: TFNFR21 - Category: Code Style - Discourage Nullability by Default
nullable = true MUST be avoided.
Variables MUST be declared with nullable = false whenever the variable’s type has a meaningful zero value ({} for objects/maps, [] for lists/sets, "" for strings where empty has the same meaning as absent, etc.). Consumers should signal “no value” by omitting the input, not by explicitly passing null.
Exception โ behavior-toggle inputs
A small, well-defined class of inputs MAY keep the implicit nullable = true (i.e. default = null) where null carries a distinct semantic meaning of “no override โ use the underlying provider/AVM defaults”, and where representing that state with the type’s zero value would be ambiguous or wrong. Examples include:
var.retryandvar.timeouts(per TFFR7) โnullmeans “do not emit aretry/timeoutsblock; use the AzAPI provider defaults”.var.lock(per the AVM lock interface) โnullmeans “do not create a management lock”.- Optional sub-objects that toggle whole feature blocks on/off, where
{}would be indistinguishable from “feature enabled with all defaults”.
Where this exception applies, the variable MUST:
- Use
default = null(the implicitnullable = trueis permitted only for this purpose). - State explicitly in its
descriptionwhatnullmeans. - Be consumed with a
null-aware pattern (e.g.count = var.lock != null ? 1 : 0, ordynamic "timeouts" { for_each = var.timeouts == null ? [] : [var.timeouts] }).
This exception does not extend to required inputs, to collection-shaped inputs (TFNFR20), or to nested attributes inside an object โ those MUST use nullable = false and the type’s zero value.
ID: TFNFR22 - Category: Code Style - Avoid sensitive = false
sensitive = false MUST be avoided.
ID: TFNFR23 - Category: Code Style - Sensitive Default Value Conditions
A default value MUST NOT be set for a sensitive input, unless it is an empty collection value.
Good example:
variable "example_map" {
type = map(string)
default = {}
description = "An example map variable with an empty default value."
sensitive = true
}Bad example:
variable "example_string" {
type = string
default = "sensitive_value"
description = "An example string variable with a sensitive default value."
sensitive = true
}ID: TFNFR24 - Category: Code Style - Handling Deprecated Variables
Sometimes we will find names for some variable are not suitable anymore, or a change SHOULD be made to the data type. We want to ensure forward compatibility within a major version, so direct changes are strictly forbidden. The right way to do this is move this variable to an independent deprecated_variables.tf file, then redefine the new parameter in variable.tf and make sure it’s compatible everywhere else.
Deprecated variable MUST be annotated as DEPRECATED at the beginning of the description, at the same time the replacement’s name SHOULD be declared. E.g.,
variable "enable_network_security_group" {
type = string
default = null
description = "DEPRECATED, use `network_security_group_enabled` instead; Whether to generate a network security group and assign it to the subnet. Changing this forces a new resource to be created."
}A cleanup of deprecated_variables.tf SHOULD be performed during a major version release.
ID: TFNFR25 - Category: Code Style - Verified Modules Requirements
The terraform.tf file MUST only contain one terraform block.
The first line of the terraform block MUST define a required_version property for the Terraform CLI.
The required_version property MUST include a constraint on the minimum version of the Terraform CLI. Previous releases of the Terraform CLI can have unexpected behavior.
The required_version property MUST include a constraint on the maximum major version of the Terraform CLI. Major version releases of the Terraform CLI can introduce breaking changes and MUST be tested.
The required_version property constraint SHOULD use the ~> #.# or the >= #.#.#, < #.#.# format.
Note: You can read more about Terraform version constraints in the documentation.
Example terraform.tf file:
terraform {
required_version = "~> 1.6"
required_providers {
azapi = {
source = "Azure/azapi"
version = "~> 2.9"
}
}
}ID: TFNFR26 - Category: Code Style - Providers in required_providers
The terraform block in terraform.tf MUST contain the required_providers block.
Each provider used directly in the module MUST be specified with the source and version properties. Providers in the required_providers block SHOULD be sorted in alphabetical order.
Do not add providers to the required_providers block that are not directly required by this module. If submodules are used then each submodule SHOULD have its own versions.tf file.
The source property MUST be in the format of namespace/name. If this is not explicitly specified, it can cause failure.
The version property MUST include a constraint on the minimum version of the provider. Older provider versions may not work as expected.
The version property MUST include a constraint on the maximum major version. A provider major version release may introduce breaking change, so updates to the major version constraint for a provider MUST be tested.
The version property constraint SHOULD use the ~> #.# or the >= #.#.#, < #.#.# format.
Note: You can read more about Terraform version constraints in the documentation.
Good examples:
terraform {
required_version = "~> 1.6"
required_providers {
azapi = {
source = "Azure/azapi"
version = "~> 2.9"
}
}
}terraform {
required_version = ">= 1.6.6, < 2.0.0"
required_providers {
azapi = {
source = "Azure/azapi"
version = ">= 2.9.0, < 3.0.0"
}
}
}terraform {
required_version = ">= 1.6, < 2.0"
required_providers {
azapi = {
source = "Azure/azapi"
version = ">= 2.9, < 3.0"
}
}
}Acceptable example (but not recommended):
terraform {
required_version = "1.6"
required_providers {
azapi = {
source = "Azure/azapi"
version = "2.9"
}
}
}Bad example:
terraform {
required_version = ">= 1.6"
required_providers {
azapi = {
source = "Azure/azapi"
version = ">= 2.9"
}
}
}ID: TFNFR27 - Category: Code Style - Provider Declarations in Modules
By rules, in the module code provider MUST NOT be declared. The only exception is when the module indeed need different instances of the same kind of provider(Eg. manipulating resources across different locations or accounts), you MUST declare configuration_aliases in terraform.required_providers. See details in this document.
provider block declared in the module MUST only be used to differentiate instances used in resource and data. Declaration of fields other than alias in provider block is strictly forbidden. It could lead to module users unable to utilize count, for_each or depends_on. Configurations of the provider instance SHOULD be passed in by the module users.
Good examples:
In verified module:
terraform {
required_providers {
azapi = {
source = "Azure/azapi"
version = "~> 2.9"
configuration_aliases = [azapi.alternate]
}
}
}In the root module where we call this verified module:
provider "azapi" {}
provider "azapi" {
alias = "alternate"
}
module "foo" {
source = "xxx"
providers = {
azapi = azapi
azapi.alternate = azapi.alternate
}
}Bad example:
In verified module:
provider "azapi" {
# Configuration options
}ID: TFNFR29 - Category: Code Style - Sensitive Data Outputs
An output block that contains confidential data MUST be declared with sensitive = true.
ID: TFNFR30 - Category: Code Style - Handling Deprecated Outputs
Sometimes we notice that the name of certain output is not appropriate anymore, however, since we have to ensure forward compatibility in the same major version, its name MUST NOT be changed directly. It MUST be moved to an independent deprecated_outputs.tf file, then redefine a new output in output.tf and make sure it’s compatible everywhere else in the module.
A cleanup SHOULD be performed to deprecated_outputs.tf and other logics related to compatibility during a major version upgrade.
ID: TFNFR31 - Category: Code Style - locals.tf for Locals Only
In locals.tf, file we could declare multiple locals blocks, but only locals blocks are allowed.
You MAY declare locals blocks next to a resource block or data block for some advanced scenarios, like making a fake module to execute some light-weight tests aimed at the expressions.
ID: TFNFR33 - Category: Code Style - Precise Local Types
Precise local types SHOULD be used.
Good example:
{
name = "John"
age = 52
}Bad example:
{
name = "John"
age = "52" # age should be number
}ID: TFNFR34 - Category: Code Style - Using Feature Toggles
A toggle variable MUST be used to allow users to avoid the creation of a new resource block by default if it is added in a minor or patch version.
E.g., our previous release was v1.2.1 and next release would be v1.3.0, now we’d like to submit a pull request which contains such new resource:
resource "azapi_resource" "route_table" {
type = "Microsoft.Network/routeTables@2023-11-01"
name = coalesce(var.new_route_table_name, "${var.subnet_name}-rt")
parent_id = var.parent_id
location = local.location
body = {
properties = {}
}
response_export_values = []
}A user who’s just upgraded the module’s version would be surprised to see a new resource to be created in a newly generated plan file.
A better approach is adding a feature toggle to be turned off by default:
variable "create_route_table" {
type = bool
default = false
nullable = false
}
resource "azapi_resource" "route_table" {
count = var.create_route_table ? 1 : 0
type = "Microsoft.Network/routeTables@2023-11-01"
name = coalesce(var.new_route_table_name, "${var.subnet_name}-rt")
parent_id = var.parent_id
location = local.location
body = {
properties = {}
}
response_export_values = []
}ID: TFNFR35 - Category: Code Style - Reviewing Potential Breaking Changes
Potential breaking(surprise) changes introduced by resource block
- Adding a new
resourcewithoutcountorfor_eachfor conditional creation, or creating by default - Adding a new argument assignment with a value other than the default value provided by the provider’s schema
- Adding a new nested block without making it
dynamicor omitting it by default - Renaming a
resourceblock without one or more correspondingmovedblocks - Change
resource’scounttofor_each, or vice versa
Terraform moved block could be your cure.
Potential breaking changes introduced by variable and output blocks
- Deleting(Renaming) a
variable - Changing
typein avariableblock - Changing the
defaultvalue in avariableblock - Changing
variable’snullabletofalse - Changing
variable’ssensitivefromfalsetotrue - Adding a new
variablewithoutdefault - Deleting an
output - Changing an
output’svalue - Changing an
output’ssensitivevalue
These changes do not necessarily trigger breaking changes, but they are very likely to, they MUST be reviewed with caution.
ID: TFNFR36 - Category: Code Style - Setting prevent_deletion_if_contains_resources (AzureRM only)
From Terraform AzureRM 3.0, the default value of prevent_deletion_if_contains_resources in provider block is true. This will lead to an unstable test because the test subscription has some policies applied, and they will add some extra resources during the run, which can cause failures during destroy of resource groups.
Since we cannot guarantee our testing environment won’t be applied some Azure Policy Remediation Tasks in the future, for a robust testing environment, prevent_deletion_if_contains_resources SHOULD be explicitly set to false.
ID: TFNFR37 - Category: Code Style - Tool Usage by Module Owner
newres is a command-line tool that generates Terraform configuration files for a specified resource type. It automates the process of creating variables.tf and main.tf files, making it easier to get started with Terraform and reducing the time spent on manual configuration.
Module owners MAY use newres when they’re trying to add new resource block, attribute, or nested block. They MAY generate the whole block along with the corresponding variable blocks in an empty folder, then copy-paste the parts they need with essential refactoring.
ID: TFNFR39 - Category: Code Style - Standard File Layout
Every Terraform AVM module (root module and every submodule) MUST organize its top-level Terraform code into the following files at the module’s root directory:
| File | Required | Contents |
|---|---|---|
terraform.tf | MUST | The single terraform { โฆ } block โ required_version, required_providers, and any backend configuration (root module only). Provider configuration blocks MUST NOT appear here. |
variables.tf | MUST | All variable blocks for the module. MAY be split into additional variables.<topic>.tf files (see below). |
outputs.tf | MUST | All output blocks for the module. MAY be split into additional outputs.<topic>.tf files (see below). |
main.tf | MUST | The module’s primary resource, data, and module blocks. MAY be split into additional main.<topic>.tf files (see below). |
locals.tf | SHOULD | All locals blocks. Required if the module declares any locals. MAY be split into additional locals.<topic>.tf files (see below). MAY be omitted only when the module has no locals at all. |
Splitting and naming additional files
For larger modules the contents of main.tf, variables.tf, outputs.tf, and locals.tf MAY each be split into multiple files along logical / topic lines. When this is done:
- Additional Terraform files MUST use the canonical filename (
main,variables,outputs, orlocals) as the prefix, followed by a., a short descriptive topic name, and the.tfextension โ for examplemain.diagnostic_settings.tf,variables.diagnostic_settings.tf,outputs.diagnostic_settings.tf,locals.diagnostic_settings.tf. - The topic name MUST be
snake_case(per TFNFR3). - The same topic name SHOULD be used across the four file types when they describe the same logical concern, so that (for example)
main.private_endpoints.tf,variables.private_endpoints.tf,outputs.private_endpoints.tf, andlocals.private_endpoints.tfall relate to the same feature. - Each split file MUST contain only the block kind matching its prefix:
main.<topic>.tfโ onlyresource,data, andmoduleblocks.variables.<topic>.tfโ onlyvariableblocks.outputs.<topic>.tfโ onlyoutputblocks.locals.<topic>.tfโ onlylocalsblocks.
- The
terraform { โฆ }block MUST appear exactly once per module, interraform.tf. It MUST NOT be split.
Files that MUST NOT appear at the module root
- A
providers.tffile โ provider requirements belong interraform.tf; provider configurations belong only in the consumer’s root module, never in an AVM module (per SFR2). - A single monolithic
module.tforeverything.tfโ the canonical filenames above MUST be used.
Rationale
Standardizing file layout means that any reviewer or consumer can find a module’s interface (variables.tf, outputs.tf), provider constraints (terraform.tf), and primary logic (main.tf / main.<topic>.tf) in the same place across every AVM Terraform module, without having to grep. It also makes the cascade rules in TFFR6, TFFR7, and TFRMNFR1 reviewable at a glance.
Notes
- Submodules (per TFRMNFR1) follow the same layout in their own root directory under
modules/<subresource>/. The submodule’sterraform.tfMUST declare the same set ofrequired_providersit actually consumes. - Auto-generated documentation files (
README.md,_header.md,_footer.md) and tooling configuration files (.terraform-docs.yml,.tflint.hcl, etc.) are out of scope of this rule and follow their own specs.
Inputs / Outputs
The content below is listed based on the following tags
| # | ID | Title | Severity | Persona | Lifecycle |
|---|---|---|---|---|---|
| 1 | SNFR14 | Data Types | SHOULD | OwnerContributor | BAU |
| 2 | SNFR22 | Parameters/Variables for Resource IDs | MUST | OwnerContributor | BAU |
| 3 | SNFR26 | Output - Parameters - Decorators | MUST | OwnerContributor | BAU |
| 4 | RMFR6 | Parameter/Variable Naming | MUST | OwnerContributor | BAU |
| 5 | RMFR7 | Minimum Required Outputs | MUST | OwnerContributor | BAU |
| 6 | RMNFR2 | Parameter/Variable Naming | MUST | OwnerContributor | BAU |
| 7 | TFFR2 | Additional Terraform Outputs | SHOULD | OwnerContributor | BAU |
| 8 | TFFR6 | AzAPI - resource_types variable | MUST | OwnerContributor | BAU |
| 9 | TFFR7 | AzAPI - retry and timeouts variables | MUST | OwnerContributor | BAU |
| 10 | TFNFR14 | Not allowed variables | MUST | OwnerContributor | BAU |
| 11 | TFNFR38 | Resource ID Variable Validation | MUST | OwnerContributor | BAU |
| 12 | TFRMFR1 | Resource Module Parent ID | MUST | OwnerContributor | BAU |
โ See Specifications for this category
ID: SNFR14 - Category: Inputs - Data Types
A module SHOULD use either: simple data types. e.g., string, int, bool.
OR
Complex data types (objects, arrays, maps) when the language-compliant schema is defined.
ID: SNFR22 - Category: Inputs - Parameters/Variables for Resource IDs
A module parameter/variable that requires a full Azure Resource ID as an input value, e.g. /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{keyVaultName}, SHOULD contain ResourceId/resource_id in its parameter/variable name when that parameter/variable is part of a user-defined type. This assists users in knowing what value to provide at a glance of the parameter/variable name.
Example for the property workspaceId for the Diagnostic Settings resource in a user-defined type: in Bicep its parameter name should be workspaceResourceId and the variable name in Terraform should be workspace_resource_id.
In that user-defined context, workspaceId is not descriptive enough and is ambiguous as to which ID is required to be input.
Special considerations for Bicep
If the property is nested in a parameter and you opt for a resource-derived type (that is, a schema defined by the resource provider), this requirement does not apply. We do however recommend to use a user-defined type whenever these cases occur to increase the module’s usability.
Example for the property subnetArmId of the Cognitive Service’s property networkInjections:
If using a user-defined type, you may define a type for the networkInjections parameter like
param networkInjections networkInjectionType?
@export()
type networkInjectionType = {
subnetResourceId: string
// (...)
}
resource cognitiveService 'Microsoft.CognitiveServices/accounts@2025-06-01' = {
// (...)
properties: {
// (...)
networkInjections: [{
subnetArmId: networkInjections.?subnetResourceId
// (...)
}]
}
}or a resource-derived type like
param networkInjections resourceInput<'Microsoft.CognitiveServices/accounts@2025-06-01'>.properties.networkInjections
resource cognitiveService 'Microsoft.CognitiveServices/accounts@2025-06-01' = {
// (...)
properties: {
// (...)
networkInjections: networkInjections
}
}ID: SNFR26 - Output-Parameters - Decorators
Output parameters MUST implement:
- Decorators in Bicep such as
description&secure(if sensitive) - Arguments in Terraform such as
description&sensitive(if sensitive)
@description('The resourceId of your resource.')
output sampleResourceId string = sampleResource.id
@description('The key of your resource.')
@secure()
output sampleResourceKey string = sampleResource.key# Resource output
output "foo" {
description = "MyResource foo attribute"
value = azapi_resource.myresource.output.properties.foo
}
# Output of a sensitive attribute
output "bar" {
description = "MyResource bar attribute"
value = azapi_resource.myresource.output.properties.bar
sensitive = true
}ID: RMFR6 - Category: Inputs - Parameter/Variable Naming
Parameters/variables that pertain to the primary resource MUST NOT use the resource type in the name.
e.g., use sku, vs. virtualMachineSku/virtualmachine_sku
Another example for where RPs contain some of their name within a property, leave the property unchanged. E.g. Key Vault has a property called keySize, it is fine to leave as this and not remove the key part from the property/parameter name.
ID: RMFR7 - Category: Outputs - Minimum Required Outputs
Module owners MUST output the following outputs as a minimum in their modules:
| Output | Bicep Output Name | Terraform Output Name |
|---|---|---|
| Resource Name | name | name |
| Resource ID | resourceId | resource_id |
| System Assigned Managed Identity Principal ID (if supported by module) | systemAssignedMIPrincipalId | system_assigned_mi_principal_id |
Tip
ID: RMNFR2 - Category: Inputs - Parameter/Variable Naming
A resource module MUST use the following standard inputs:
name(no default)location(if supported by the resource and not a global resource, then use Resource Group location, if resource supports Resource Groups, otherwise no default)
ID: TFFR2 - Category: Outputs - Additional Terraform Outputs
Authors SHOULD NOT output entire resource objects as these may contain sensitive outputs and the schema can change with API or provider versions.
Instead, authors SHOULD output the computed attributes of the resource as discreet outputs.
This kind of pattern protects against provider schema changes and is known as an anti-corruption layer.
Remember, you SHOULD NOT output values that are already inputs (other than name).
E.g.,
# Resource output, computed attribute.
output "foo" {
description = "MyResource foo attribute"
value = azapi_resource.myresource.output.properties.foo
}
# Resource output for resources that are deployed using `for_each`. Again only computed attributes.
output "childresource_foos" {
description = "MyResource children's foo attributes"
value = {
for key, value in azapi_resource.mychildresource : key => value.output.properties.foo
}
}
# Output of a sensitive attribute
output "bar" {
description = "MyResource bar attribute"
value = azapi_resource.myresource.output.properties.bar
sensitive = true
}ID: TFFR6 - Category: Inputs/Outputs - AzAPI - resource_types variable
Authors MUST NOT hard-code the type argument of an azapi_resource (or azapi_data_plane_resource, azapi_resource_action, azapi_update_resource) inline.
Instead, every AzAPI resource type string used by the module MUST be sourced from a single object variable named resource_types.
resource_types keys vs Terraform resource labels
These are two unrelated concepts and the spec treats them independently:
- Keys in
var.resource_typesname the AzAPI resource type and are derived from the ARM type by the naming rule below. They appear on the right of an assignment as the value of thetypeargument. - Terraform resource labels (e.g.
azapi_resource.this) name the graph node and govern how the resource is referenced elsewhere in HCL. The primary resource label MUST bethis, per TFRMNFR2.
A typical primary-resource declaration therefore reads:
resource "azapi_resource" "this" { # label per TFRMNFR2
type = var.resource_types.example_widgets # key per the naming rule below
# ...
}this and example_widgets describe different things and are derived by different rules. They MUST NOT be made to coincide โ this is never a valid resource_types key.
Key naming
Each resource_types key (at every level of nesting) MUST be the snake_case form of the ARM resource type, with the Microsoft. prefix dropped:
- Drop the
Microsoft.prefix. - Render the provider namespace as a single lowercase token โ do not split internal camelCase (
KeyVaultโkeyvault,DocumentDBโdocumentdb,EventHubโeventhub). - Convert each resource path segment after the provider from camelCase to snake_case (
virtualNetworksโvirtual_networks,roleAssignmentsโrole_assignments). - Join the provider token and each path segment with
_.
| ARM type | Key |
|---|---|
Microsoft.Example/widgets | example_widgets |
Microsoft.Example/widgets/parts | example_widgets_parts |
Microsoft.Example/widgets/parts/components | example_widgets_parts_components |
Microsoft.Authorization/locks | authorization_locks |
Microsoft.Authorization/roleAssignments | authorization_role_assignments |
Microsoft.Insights/diagnosticSettings | insights_diagnostic_settings |
Microsoft.KeyVault/vaults/secrets | keyvault_vaults_secrets |
Microsoft.Network/virtualNetworks/subnets | network_virtual_networks_subnets |
The rule is deterministic so consumers, lint checks and tooling can derive the expected key for any ARM type without consulting the module source. Authors MUST NOT invent shorter aliases (e.g. widgets instead of example_widgets).
Variable shape
The resource_types variable MUST:
- Be a single
object({...})(not amap(string)) so typos at call sites error at plan time and per-key defaults are visible in the variable declaration. - Default the variable itself to
{}so consumers only need to supply the keys they wish to override. - Be
nullable = false. - Declare one
optional(string, "<provider>/<resource>@<api-version>")field for every AzAPI resource the module itself declares, defaulting each to the latest API version the module has been tested against. The default MUST be a stable (non-preview) API version unless the module’s primary resource only ships a preview API. - Declare one nested
optional(object({...}), {})field for every submodule the module instantiates (see TFRMNFR1). The shape of the nested object MUST match that submodule’s ownresource_typesvariable exactly. The parent MUST NOT repeat the submodule’s defaults โ the inner string attributes are declared asoptional(string)(no default) so the submodule remains the single source of truth for its own tested API versions. - Document every field in the variable’s
description.
Cascading to submodules
Because the nested slot in the parent mirrors the submodule’s variable, the parent cascades the slot through unchanged:
module "part" {
source = "./modules/part"
resource_types = var.resource_types.example_widgets_parts
}No renaming, repacking, or null filtering is required. When the consumer omits a key or sets it explicitly to null, Terraform substitutes the default declared on the owning module’s variable (per Terraform’s optional-attribute semantics).
The rationale for the variable is to let consumers:
- Target sovereign clouds (e.g., Azure US Government, Azure China) where older API versions may be the latest available.
- Opt into a newer preview API version without waiting for a module release.
- Pin a specific API version for compliance or reproducibility reasons.
Nesting submodule slots inside the parent’s resource_types (rather than flattening every AzAPI resource into a single top-level namespace):
- Keeps each module’s defaults co-located with the resource it owns.
- Lets a submodule add or rename its own resources without forcing a breaking change on parent-module consumers who never touched those keys.
- Makes the override surface mirror the actual module tree โ a consumer looking at the parent’s variable can see, in shape, every resource managed beneath it.
Example โ root, child and grandchild
A module managing Microsoft.Example/widgets, with a parts submodule for Microsoft.Example/widgets/parts, which in turn instantiates a component sibling submodule for Microsoft.Example/widgets/parts/components (per TFRMNFR1):
# === root variables.tf ===
variable "resource_types" {
type = object({
example_widgets = optional(string, "Microsoft.Example/widgets@2024-01-01")
authorization_locks = optional(string, "Microsoft.Authorization/locks@2020-05-01")
example_widgets_parts = optional(object({
example_widgets_parts = optional(string)
example_widgets_parts_components = optional(object({
example_widgets_parts_components = optional(string)
}), {})
}), {})
})
default = {}
nullable = false
}
# === root main.tf ===
resource "azapi_resource" "this" {
type = var.resource_types.example_widgets
name = var.name
parent_id = var.parent_id
body = { /* ... */ }
}
module "part" {
source = "./modules/part"
for_each = var.parts
name = each.value.name
parent_id = azapi_resource.this.id
resource_types = var.resource_types.example_widgets_parts
}
# === modules/part/variables.tf ===
variable "resource_types" {
type = object({
example_widgets_parts = optional(string, "Microsoft.Example/widgets/parts@2024-01-01")
example_widgets_parts_components = optional(object({
example_widgets_parts_components = optional(string)
}), {})
})
default = {}
nullable = false
}
# === modules/part/main.tf ===
resource "azapi_resource" "this" {
type = var.resource_types.example_widgets_parts
name = var.name
parent_id = var.parent_id
body = { /* ... */ }
}
module "component" {
source = "../component"
for_each = var.components
name = each.value.name
parent_id = azapi_resource.this.id
resource_types = var.resource_types.example_widgets_parts_components
}
# === modules/component/variables.tf ===
variable "resource_types" {
type = object({
example_widgets_parts_components = optional(string, "Microsoft.Example/widgets/parts/components@2024-01-01")
})
default = {}
nullable = false
}
# === modules/component/main.tf ===
resource "azapi_resource" "this" {
type = var.resource_types.example_widgets_parts_components
name = var.name
parent_id = var.parent_id
body = { /* ... */ }
}A consumer overriding only the grandchild API version writes:
module "widget" {
source = "Azure/avm-res-example-widget/azure"
resource_types = {
example_widgets_parts = {
example_widgets_parts_components = {
example_widgets_parts_components = "Microsoft.Example/widgets/parts/components@2023-01-01"
}
}
}
# ...other arguments...
}ID: TFFR7 - Category: Inputs/Outputs - AzAPI - retry and timeouts variables
The retry and timeouts blocks of every azapi_resource declared by the module MUST be configurable by the consumer. Authors MUST NOT hard-code values inline that the consumer cannot override.
To meet this requirement, the module MUST expose two variables:
retryโ an object variable controlling the AzAPIretryblock.timeoutsโ an object variable controlling the AzAPItimeoutsblock.
Both variables:
- MAY define module-level defaults (e.g., a default
error_message_regexsuch as"ScopeLocked"for resources that race with lock removal, or a defaultdelete = "5m"). - MUST allow the consumer to override the defaults โ either by supplying a non-
nullvalue at the variable level, or by allowing per-field overrides throughoptional(...)attributes. - MUST be applied to every
azapi_resource(and equivalent AzAPI resources) declared by the module. - MUST cascade to submodules โ the parent module’s
retryandtimeoutsvalues MUST be passed through to each submodule it instantiates (see TFRMNFR1). Submodules MAY additionally expose per-item overrides for cases where individual resources need different settings.
variable "retry" {
type = object({
error_message_regex = optional(list(string))
interval_seconds = optional(number)
max_interval_seconds = optional(number)
})
default = null
description = <<DESCRIPTION
Retry configuration applied to every `azapi` resource managed by the module (root resource and all submodules). Defaults to `null` (no custom retry).
- `error_message_regex` - (Optional) A list of regex patterns matching error messages that trigger a retry.
- `interval_seconds` - (Optional) Initial interval between retries in seconds.
- `max_interval_seconds` - (Optional) Maximum interval between retries in seconds.
See <https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource#retry> for full semantics.
DESCRIPTION
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
description = <<DESCRIPTION
Default per-operation timeouts applied to every `azapi` resource managed by the module. Defaults to `null` (provider defaults). Each value is a Go duration string (e.g. `30m`, `1h`).
- `create` - (Optional) Timeout for create operations.
- `read` - (Optional) Timeout for read operations.
- `update` - (Optional) Timeout for update operations.
- `delete` - (Optional) Timeout for delete operations.
DESCRIPTION
}
resource "azapi_resource" "this" {
type = var.resource_types.example_widgets
name = var.name
parent_id = var.parent_id
body = { /* ... */ }
# `retry` is an attribute on `azapi_resource`, so the variable can be
# assigned directly. `timeouts` is a block, so a `dynamic "timeouts"`
# block is required to honor the variable's `null` default.
retry = var.retry
dynamic "timeouts" {
for_each = var.timeouts == null ? [] : [var.timeouts]
content {
create = timeouts.value.create
read = timeouts.value.read
update = timeouts.value.update
delete = timeouts.value.delete
}
}
response_export_values = []
}
module "child" {
source = "./modules/child"
# Cascade retry and timeouts to the submodule.
retry = var.retry
timeouts = var.timeouts
# ...other arguments...
}ID: TFNFR14 - Category: Inputs - Not allowed variables
Since Terraform 0.13, count, for_each and depends_on are introduced for modules, module development is significantly simplified. Module’s owners MUST NOT add variables like enabled or module_depends_on to control the entire module’s operation. Boolean feature toggles are acceptable however.
ID: TFNFR38 - Category: Inputs/Outputs - Resource ID Variable Validation
Every input variable (or nested attribute) that holds an Azure ARM resource ID MUST be validated using the AzAPI provider-defined function provider::azapi::parse_resource_id, called with a literal string naming the expected resource type, and wrapped in can(...).
Hand-rolled regex, startswith, length, or split checks MUST NOT be used to validate resource IDs. The provider function knows the canonical ARM ID grammar for every resource type, is fixed in lockstep with the provider, and produces a single consistent error model โ including for IDs whose grammar contains anomalies (such as classic resources, extension resources, or scope-based IDs).
This rule covers, but is not limited to:
- Top-level scope variables such as
parent_id(see TFRMFR1). - Variables that reference other Azure resources by ID (e.g.
subnet_resource_id,key_vault_resource_id,workspace_resource_id,private_dns_zone_resource_ids,user_assigned_resource_ids). - Nested attributes inside
object,map(object),set(object), orlist(object)types that hold resource IDs.
Rules
- The resource type passed to
parse_resource_idMUST be a literal string (e.g."Microsoft.Network/virtualNetworks/subnets"). It MUST NOT be a reference to another variable, local, or expression. This keeps eachvalidationblock self-contained and avoids requiring cross-variable validation. - For optional /
nullablevariables, the validation MUST short-circuit onnull(e.g.var.x == null || can(provider::azapi::parse_resource_id("...", var.x))) so that callers omitting the value do not trip validation. - For collection-valued variables (
set(string),list(string),map(string)), the validation MUST iterate the collection withalltrue([for v in ... : can(...)]). - For nested attributes within object types, the validation MUST iterate the parent collection (or reference the object directly) and validate each nested resource ID, again handling
nullfor optional nested attributes. - Where a variable can legitimately hold IDs of more than one resource type (rare โ e.g.
marketplace_partner_resource_idin the diagnostic-settings interface), this rule does not apply and the variable SHOULD be left without resource-ID validation rather than validated against a single arbitrary type.
Examples
A required, single-value resource ID:
variable "key_vault_resource_id" {
type = string
nullable = false
validation {
condition = can(provider::azapi::parse_resource_id("Microsoft.KeyVault/vaults", var.key_vault_resource_id))
error_message = "`key_vault_resource_id` must be a valid Azure Key Vault resource ID."
}
description = "The resource ID of the Key Vault that holds the customer-managed key."
}An optional, single-value resource ID:
variable "workspace_resource_id" {
type = string
default = null
nullable = true
validation {
condition = var.workspace_resource_id == null || can(provider::azapi::parse_resource_id("Microsoft.OperationalInsights/workspaces", var.workspace_resource_id))
error_message = "`workspace_resource_id` must be a valid Log Analytics workspace resource ID, or `null`."
}
description = "The resource ID of the Log Analytics workspace to send diagnostics to."
}A collection of resource IDs:
variable "user_assigned_resource_ids" {
type = set(string)
default = []
nullable = false
validation {
condition = alltrue([
for id in var.user_assigned_resource_ids :
can(provider::azapi::parse_resource_id("Microsoft.ManagedIdentity/userAssignedIdentities", id))
])
error_message = "Each entry in `user_assigned_resource_ids` must be a valid user-assigned managed identity resource ID."
}
description = "A set of user-assigned managed identity resource IDs to attach to the resource."
}A nested resource ID inside a map(object(...)):
variable "private_endpoints" {
type = map(object({
subnet_resource_id = string
private_dns_zone_resource_ids = optional(set(string), [])
# ...other attributes...
}))
default = {}
nullable = false
validation {
condition = alltrue([
for _, v in var.private_endpoints :
can(provider::azapi::parse_resource_id("Microsoft.Network/virtualNetworks/subnets", v.subnet_resource_id))
])
error_message = "Each `private_endpoints[*].subnet_resource_id` must be a valid subnet resource ID."
}
validation {
condition = alltrue(flatten([
for _, v in var.private_endpoints : [
for id in v.private_dns_zone_resource_ids :
can(provider::azapi::parse_resource_id("Microsoft.Network/privateDnsZones", id))
]
]))
error_message = "Each entry in `private_endpoints[*].private_dns_zone_resource_ids` must be a valid private DNS zone resource ID."
}
}Notes
- The rule applies regardless of whether the resource ID is required or optional, single-valued or collection-valued, top-level or nested.
parse_resource_iderrors when (a) the input is not a well-formed ARM ID, or (b) the input does not parse as the supplied resource type. Wrapping incan(...)converts both failure modes into a single boolean suitable for avalidationblock’scondition.- This rule supersedes any older guidance suggesting
startswith(var.x, "/")or hand-written regex for resource ID validation.
ID: TFRMFR1 - Category: Inputs/Outputs - Resource Module Parent ID
A Terraform resource module MUST expose its parent scope to consumers as a single string variable named parent_id, and MUST assign that variable to the parent_id argument of every primary azapi_resource (or equivalent AzAPI resource) it manages.
parent_id is the AzAPI provider’s universal way of expressing where a resource lives in the Azure Resource Manager hierarchy. Depending on the resource type, it can be:
- A subscription ID (e.g.
/subscriptions/{subscriptionId}) โ for tenant- or subscription-scoped resources. - A management group ID (e.g.
/providers/Microsoft.Management/managementGroups/{name}) โ for management-group-scoped resources. - A resource group ID (e.g.
/subscriptions/{subscriptionId}/resourceGroups/{rgName}) โ for the most common case of resources that live inside a resource group. - The resource ID of a parent ARM resource (e.g. the ID of a virtual network for subnets, the ID of a storage account for blob containers) โ for child / nested resources.
Because the same variable describes every possible parent scope, modules MUST NOT expose resource_group_name, resource_group_resource_id, or any other parent-scope-specific variable. The fully-qualified ARM ID supplied via parent_id is sufficient and works uniformly for every kind of Azure resource.
parent_id MUST be validated using the AzAPI provider’s provider-defined functions, per TFNFR38. The required function is provider::azapi::parse_resource_id, called with the expected parent resource type for the module’s primary resource (for example Microsoft.Resources/resourceGroups for resources that live inside a resource group, or Microsoft.Network/virtualNetworks for a subnet module). Hand-rolled regex, startswith, or length checks MUST NOT be used.
This rule supersedes the Terraform clause of RMFR3 (which historically required a resource_group_name variable in Terraform). RMFR3 still applies to Bicep modules; for AVM Terraform modules the rules in this spec take precedence.
Variable declaration
variable "parent_id" {
type = string
nullable = false
validation {
# Validate via the AzAPI provider's `parse_resource_id` function. The function
# errors if `parent_id` is malformed OR if it does not parse as the expected
# parent resource type (e.g. passing a subscription ID where a resource group
# is required). Replace `Microsoft.Resources/resourceGroups` with the parent
# resource type expected by this module's primary resource (for example
# `Microsoft.Network/virtualNetworks` for a subnet module).
condition = can(provider::azapi::parse_resource_id("Microsoft.Resources/resourceGroups", var.parent_id))
error_message = "`parent_id` must be a valid Azure resource group resource ID."
}
description = <<DESCRIPTION
The fully-qualified ARM resource ID of the scope into which the resource managed by this module will be deployed. Examples:
- Subscription scope: `/subscriptions/00000000-0000-0000-0000-000000000000`
- Management group scope: `/providers/Microsoft.Management/managementGroups/example-mg`
- Resource group scope: `/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example-rg`
- Parent resource scope: `/subscriptions/.../resourceGroups/example-rg/providers/Microsoft.Network/virtualNetworks/example-vnet`
This module **does not** create the parent scope. The consumer (or composing pattern module) is responsible for providing a `parent_id` for an existing scope.
DESCRIPTION
}Use in the resource block
resource "azapi_resource" "this" {
type = var.resource_types.example_widgets
name = var.name
parent_id = var.parent_id
body = { /* ... */ }
response_export_values = []
}Notes
parent_idMUST be of typestring, MUST be required (nodefault), and MUST be validated usingprovider::azapi::parse_resource_idwrapped incan(...), per TFNFR38.- The resource type passed to
parse_resource_idMUST be a literal string naming the expected parent resource type for the module’s primary resource (e.g."Microsoft.Resources/resourceGroups"for a resource that lives inside a resource group, or"Microsoft.Network/virtualNetworks"for a subnet module). It MUST NOT be a reference to another variable. This keeps the validation block self-contained. - Modules MUST NOT accept
resource_group_name,resource_group_resource_id, or any other parent-scope-specific variable. If a module needs to be told which resource group (or subscription, or management group) to deploy into, it does so exclusively viaparent_id. - Modules MUST NOT create the parent scope themselves (see RMFR3 for the resource-group case). The consumer or composing pattern module supplies an existing scope’s ARM ID.
- Submodules (per TFRMNFR1) MUST also expose
parent_idand follow the same rules. The parent module typically passes its own primary resource’s ID to each child, e.g.parent_id = azapi_resource.this.id. - Modules MAY expose additional, narrower scope variables only when a single resource genuinely needs two different parent scopes (rare). In that case the additional variable MUST still be a
parent_id-shaped string (fully-qualified ARM ID), validated with the same provider-defined function pattern, and MUST NOT be named after a specific scope kind such asresource_group_name.
Exception โ extension-resource modules
A small class of resource modules manages an Azure extension resource (a resource type that attaches to any parent ARM resource, regardless of its provider). Examples include modules whose primary resource is Microsoft.Authorization/locks, Microsoft.Authorization/roleAssignments, Microsoft.Insights/diagnosticSettings, Microsoft.Resources/tags, or similar. For these modules, the parent resource type is intentionally polymorphic and a literal parse_resource_id("Microsoft.X/y", var.parent_id) validation MUST NOT be used.
Where this exception applies, the module MUST still:
Expose the parent scope as the variable named
parent_id(no other name), of typestring, required, andnullable = false.Validate that
parent_idis a non-empty fully-qualified ARM ID using a generic check, e.g.:validation { condition = length(var.parent_id) > 0 && (startswith(var.parent_id, "/subscriptions/") || startswith(var.parent_id, "/providers/")) error_message = "`parent_id` must be a fully-qualified ARM resource ID starting with `/subscriptions/` or `/providers/`." }Document in the variable’s
descriptionthat any ARM resource ID is accepted because the module manages an extension resource.Document the exception in the module’s
README.mdso reviewers immediately understand why the standardparse_resource_idvalidation is absent.
Testing
The content below is listed based on the following tags
| # | ID | Title | Severity | Persona | Lifecycle |
|---|---|---|---|---|---|
| 1 | SNFR1 | Prescribed Tests | MUST | OwnerContributor | BAU |
| 2 | SNFR2 | E2E Testing | MUST | OwnerContributor | BAU |
| 3 | SNFR3 | AVM Compliance Tests | MUST | OwnerContributor | Initial |
| 4 | SNFR4 | Unit Tests | SHOULD | OwnerContributor | BAU |
| 5 | SNFR5 | Upgrade Tests | SHOULD | OwnerContributor | BAU |
| 6 | SNFR6 | Static Analysis/Linting Tests | MUST | OwnerContributor | BAU |
| 7 | SNFR7 | Idempotency Tests | MUST | OwnerContributor | BAU |
| 8 | SNFR24 | Testing Child, Extension & Interface Resources | MUST | OwnerContributor | BAU |
| 9 | TFNFR5 | Test Tooling | MUST | OwnerContributor | BAU |
| 10 | TFNFR15 | Variable Definition Order | SHOULD | OwnerContributor | BAU |
โ See Specifications for this category
ID: SNFR1 - Category: Testing - Prescribed Tests
Modules MUST use the prescribed tooling and testing frameworks defined in the language specific specs.
ID: SNFR2 - Category: Testing - E2E Testing
Modules MUST implement end-to-end (deployment) testing that create actual resources to validate that module deployments work. In Bicep tests are sourced from the directories in /tests/e2e. In Terraform, these are in /examples.
Each test MUST run and complete without user inputs successfully, for automation purposes.
Each test MUST also destroy/clean-up its resources and test dependencies following a run.
Tip
Resources/Dependencies Required for E2E Tests
It is likely that to complete E2E tests, a number of resources will be required as dependencies to enable the tests to pass successfully. Some examples:
- When testing the Diagnostic Settings interface for a Resource Module, you will need an existing Log Analytics Workspace to be able to send the logs to as a destination.
- When testing the Private Endpoints interface for a Resource Module, you will need an existing Virtual Network, Subnet and Private DNS Zone to be able to complete the Private Endpoint deployment and configuration.
Module owners MUST:
- Create the required resources that their module depends upon in the test file/directory
- They MUST either use:
- Simple/native resource declarations/definitions in their respective IaC language,
OR - Another already published AVM Module that MUST be pinned to a specific published version.
- They MUST NOT use any local directory path references or local copies of AVM modules in their own modules test directory.
- Simple/native resource declarations/definitions in their respective IaC language,
- They MUST either use:
โ Terraform & Bicep Log Analytics Workspace examples using simple/native declarations for use in E2E tests
Terraform
resource "azapi_resource" "resource_group" {
type = "Microsoft.Resources/resourceGroups@2024-03-01"
name = "rsg-test-001"
parent_id = "/subscriptions/${data.azapi_client_config.current.subscription_id}"
location = "West Europe"
body = {}
response_export_values = []
}
resource "azapi_resource" "log_analytics_workspace" {
type = "Microsoft.OperationalInsights/workspaces@2023-09-01"
name = "law-test-001"
parent_id = azapi_resource.resource_group.id
location = azapi_resource.resource_group.location
body = {
properties = {
sku = {
name = "PerGB2018"
}
retentionInDays = 30
}
}
response_export_values = []
}Bicep
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = {
name: 'law-test-001'
location: resourceGroup().location
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
}
}Skipping Deployments (SHOULD NOT)
Deployment tests are an important part of a module’s validation and a staple of AVM’s CI environment. However, there are situations where certain e2e-test-deployments cannot be performed against AVM’s test environment (e.g., if a special configuration/registration (such as certain AI models) is required). For these cases, the CI offers the possibility to ‘skip’ specific test cases by placing a file named .e2eignore in their test folder.
Note
A skipped test case is still added to the ‘Usage Examples’ section of the module’s readme and should be manually validated in regular intervals.
You MUST add a note to the tests metadata description, which explains the excemption.
If you require that a test is skipped and add an โ.e2eignoreโ file (e.g. \<module\>/tests/e2e/\<testname\>/.e2eignore) to a pull request, a member of the AVM Core Technical Bicep Team must approve set pull request. The content of the file is logged the module’s workflow runs and transparently communicates why the test case is skipped during the deployment validation stage. It iss hence important to specify the reason for skipping the deployment in this file.
Sample filecontent:
The test is skipped, as only one instance of this service can be deployed to a subscription.Note
For resource modules, the ‘defaults’ and ‘waf-aligned’ tests can’t be skipped.
The deployment of a test can be skipped by adding a .e2eignore file into a test folder (e.g. /examples/<testname>).
ID: SNFR3 - Category: Testing - AVM Compliance Tests
Modules MUST pass all tests that ensure compliance to AVM specifications. These tests MUST pass before a module version can be published.
Important
Please note these are still under development at this time and will be published and available soon for module owners.
Module owners MUST request a manual GitHub Pull Request review, prior to their first release of version 0.1.0 of their module, from the related GitHub Team: @Azure/avm-core-team-technical-bicep, OR @Azure/avm-core-team-technical-terraform.
ID: SNFR4 - Category: Testing - Unit Tests
Modules SHOULD implement unit testing to ensure logic and conditions within parameters/variables/locals are performing correctly. These tests MUST pass before a module version can be published.
Unit Tests test specific module functionality, without deploying resources. Used on more complex modules. In Bicep and Terraform these live in tests/unit.
ID: SNFR5 - Category: Testing - Upgrade Tests
Modules SHOULD implement upgrade testing to ensure new features are implemented in a non-breaking fashion on non-major releases.
ID: SNFR6 - Category: Testing - Static Analysis/Linting Tests
Modules MUST use static analysis, e.g., linting, security scanning (PSRule, tflint, etc.). These tests MUST pass before a module version can be published.
There may be differences between languages in linting rules standards, but the AVM core team will try to close these and bring them into alignment over time.
ID: SNFR7 - Category: Testing - Idempotency Tests
Modules MUST implement idempotency end-to-end (deployment) testing. E.g. deploying the module twice over the top of itself.
Modules SHOULD pass the idempotency test, as we are aware that there are some exceptions where they may fail as a false-positive or legitimate cases where a resource cannot be idempotent.
For example, Virtual Machine Image names must be unique on each resource creation/update.
ID: SNFR24 - Category: Testing - Testing Child, Extension & Interface Resources
Module owners MUST test that child and extension resources and those Bicep or Terreform interface resources that are supported by their modules, are validated in E2E tests as per SNFR2 to ensure they deploy and are configured correctly.
These MAY be tested in a separate E2E test and DO NOT have to be tested in each E2E test.
ID: TFNFR5 - Category: Testing - Test Tooling
Module owners MUST use the below test script for unit/linting/static/security analysis tests.
./avm pr-check
ID: TFNFR15 - Category: Code Style - Variable Definition Order
Input variables SHOULD follow this order:
- All required fields, in alphabetical order
- All optional fields, in alphabetical order
A variable without default value is a required field, otherwise it’s an optional one.
Documentation
The content below is listed based on the following tags
| # | ID | Title | Severity | Persona | Lifecycle |
|---|---|---|---|---|---|
| 1 | SNFR15 | Automatic Documentation Generation | MUST | OwnerContributor | BAU |
| 2 | SNFR16 | Examples/E2E | MUST | OwnerContributor | BAU |
| 3 | TFNFR1 | Descriptions | MUST | OwnerContributor | BAU |
| 4 | TFNFR2 | Module Documentation Generation | MUST | OwnerContributor | BAU |
โ See Specifications for this category
ID: SNFR15 - Category: Documentation - Automatic Documentation Generation
README documentation MUST be automatically/programmatically generated. MUST include the sections as defined in the language specific requirements BCPNFR2, TFNFR2.
ID: SNFR16 - Category: Documentation - Examples/E2E
An examples/e2e directory MUST exist to provide named scenarios for module deployment.
ID: TFNFR1 - Category: Documentation - Descriptions
Where descriptions for variables and outputs spans multiple lines. The description MAY provide variable input examples for each variable using the HEREDOC format and embedded markdown.
Example:
variable "my_complex_input" {
type = map(object({
param1 = string
param2 = optional(number, null)
}))
description = <<DESCRIPTION
A complex input variable that is a map of objects.
Each object has two attributes:
- `param1`: A required string parameter.
- `param2`: (Optional) An optional number parameter.
Example Input:
```terraform
my_complex_input = {
"object1" = {
param1 = "value1"
param2 = 2
}
"object2" = {
param1 = "value2"
}
}
```
DESCRIPTION
}
ID: TFNFR2 - Category: Documentation - Module Documentation Generation
Terraform modules documentation MUST be automatically generated via Terraform Docs.
A file called .terraform-docs.yml MUST be present in the root of the module and have the following content:
---
### To generate the output file to partially incorporate in the README.md,
### Execute this command in the Terraform module's code folder:
# terraform-docs -c .terraform-docs.yml .
formatter: "markdown document" # this is required
version: "0.16.0"
header-from: "_header.md"
footer-from: "_footer.md"
recursive:
enabled: false
path: modules
sections:
hide: []
show: []
content: |-
{{ .Header }}
<!-- markdownlint-disable MD033 -->
{{ .Requirements }}
{{ .Providers }}
{{ .Resources }}
<!-- markdownlint-disable MD013 -->
{{ .Inputs }}
{{ .Outputs }}
{{ .Modules }}
{{ .Footer }}
output:
file: README.md
mode: replace
template: |-
<!-- BEGIN_TF_DOCS -->
{{ .Content }}
<!-- END_TF_DOCS -->
output-values:
enabled: false
from: ""
sort:
enabled: true
by: required
settings:
anchor: true
color: true
default: true
description: false
escape: true
hide-empty: false
html: true
indent: 2
lockfile: true
read-comments: true
required: true
sensitive: true
type: true
Release / Publishing
The content below is listed based on the following tags
| # | ID | Title | Severity | Persona | Lifecycle |
|---|---|---|---|---|---|
| 1 | SNFR17 | Semantic Versioning | MUST | OwnerContributor | BAU |
| 2 | SNFR18 | Breaking Changes | SHOULD | OwnerContributor | BAU |
| 3 | SNFR19 | Registries Targeted | MUST | OwnerContributor | BAU |
| 4 | SNFR21 | Cross Language Collaboration | SHOULD | OwnerContributor | BAU |
โ See Specifications for this category
ID: SNFR17 - Category: Release - Semantic Versioning
Important
You cannot specify the patch version for Bicep modules in the public Bicep Registry, as this is automatically incremented by 1 each time a module is published. You can only set the Major and Minor versions.
See the Bicep Contribution Guide for more information.
Modules MUST use semantic versioning (aka semver) for their versions and releases in accordance with: Semantic Versioning 2.0.0
For example all modules should be released using a semantic version that matches this pattern: X.Y.Z
X== Major VersionY== Minor VersionZ== Patch Version
Module versioning before first Major version release 1.0.0
Initially modules MUST be released as version
0.1.0and incremented via Minor and Patch versions only until the AVM Core Team are confident the AVM specifications are mature enough and appropriate CI test coverage is in place, plus the module owner is happy the module has been “road tested” and is now stable enough for its first Major release of version1.0.0.Note
Releasing as version
0.1.0initially and only incrementing Minor and Patch versions allows the module owner to make breaking changes more easily and frequently as it’s still not an official Major/Stable release. ๐Until first Major version
1.0.0is released, given a version numberX.Y.Z:XMajor version MUST NOT be bumped.YMinor version MUST be bumped when introducing breaking changes (which would normally bump Major after1.0.0release) or feature updates (same as it will be after1.0.0release).ZPatch version MUST be bumped when introducing non-breaking, backward compatible bug fixes (same as it will be after1.0.0release).
ID: SNFR18 - Category: Release - Breaking Changes
A module SHOULD avoid breaking changes, e.g., deprecating inputs vs. removing. If you need to implement changes that cause a breaking change, the major version should be increased.
Info
Modules that have not been released as 1.0.0 may introduce breaking changes, as explained in the previous ID SNFR17. That means that you have to introduce non-breaking and breaking changes with a minor version jump, as long as the module has not reached version 1.0.0.
There are, however, scenarios where you want to include breaking changes into a commit and not create a new major version. If you want to introduce breaking changes as part of a minor update, you can do so. In this case, it is essential to keep the change backward compatible, so that the existing code will continue to work. At a later point, another update can increase the major version and remove the code introduced for the backward compatibility.
Tip
See the language specific examples to find out how you can deal with deprecations in AVM modules.
ID: SNFR19 - Category: Publishing - Registries Targeted
Modules MUST be published to their respective language public registries.
- Bicep = Bicep Public Module Registry
- Within the
avmdirectory
- Within the
- Terraform = HashiCorp Terraform Registry
Tip
ID: SNFR21 - Category: Publishing - Cross Language Collaboration
When the module owners of the same Resource, Pattern or Utility module are not the same individual or team for all languages, each languages team SHOULD collaborate with their sibling language team for the same module to ensure consistency where possible.