Any updates to existing or new specifications for Terraform must be submitted as a draft for review by Azure Terraform PG/Engineering(@Azure/terraform-avm) and AVM core team(@Azure/avm-core-team).
AzAPI is mandatory for AVM Terraform modules
Every new AVM Terraform module — resource, pattern, or utility — MUST use the AzAPI provider for every control-plane resource and supported data-plane operation. This applies throughout the module repository, including submodules, examples, end-to-end tests, Terraform tests, fixtures, and documentation snippets.
AzureRM is permitted only for a specific data-plane/non-ARM API operation that AzAPI cannot implement, under the narrow exception in TFFR3. It is never permitted for an ARM control-plane resource or as a convenience alternative to AzAPI.
This requirement is intentional and is driven by the following factors:
Built-in retries and error handling. AzAPI exposes first-class retry and timeouts blocks, including regex-based error matching, which lets modules handle transient failures (for example, scope locks being removed or eventual-consistency errors) deterministically and without external workarounds.
Pre-flight validation. AzAPI performs ARM API pre-flight checks at plan time, surfacing many configuration errors before an apply is attempted. This produces faster feedback loops and fewer partially-deployed resources.
Day-zero access to the latest Azure features. Because AzAPI talks directly to the Azure Resource Manager REST API, modules can adopt new resource types, properties and API versions as soon as they ship in Azure — without waiting for an AzureRM provider release.
Alignment with Bicep and ARM. AzAPI uses the same resource type identifiers (e.g. Microsoft.KeyVault/vaults@2023-07-01) and the same property shape as Bicep and ARM templates. This makes it dramatically easier to translate documentation, samples and Bicep modules into Terraform, and keeps Bicep and Terraform AVM modules conceptually aligned.
Close partnership with the Azure engineering teams. AzAPI is built and maintained in close collaboration with the Azure Resource Provider engineering teams. Issues in AzAPI can be triaged directly against the underlying ARM behavior, and the AVM team works directly with the AzAPI engineering team on roadmap and breaking changes.
Consistency across the AVM ecosystem. Standardizing on AzAPI means every AVM Terraform module uses the same patterns for identity, diagnostic settings, role assignments, locks and private endpoints — primarily through the Azure/avm-utl-interfaces/azure utility module — which simplifies authoring, review and consumer experience.
What changed recently?
See what specifications changed in the last 30 days...
This chapter details the interfaces/schemas for the AVM Resource Modules features/extension resources as referenced in RMFR4 and RMFR5.
Diagnostic Settings
Important
Allowed values for logs and metric categories or category groups MUST NOT be specified to keep the module implementation evergreen for any new categories or category groups added by RPs, without module owners having to update a list of allowed values and cut a new release of their module.
variable"diagnostic_settings" {
type = map(object({
name = optional(string, null)
logs = optional(set(object({
category = optional(string, null)
category_group = optional(string, null)
enabled = optional(bool, true)
retention_policy = optional(object({
days = optional(number, 0)
enabled = optional(bool, false)
}), {})
})), [])
metrics = optional(set(object({
category = optional(string, null)
enabled = optional(bool, true)
retention_policy = optional(object({
days = optional(number, 0)
enabled = optional(bool, false)
}), {})
})), [])
log_analytics_destination_type = optional(string, "Dedicated")
workspace_resource_id = optional(string, null)
storage_account_resource_id = optional(string, null)
event_hub_authorization_rule_resource_id = optional(string, null)
event_hub_name = optional(string, null)
marketplace_partner_resource_id = optional(string, null)
}))
default = {}
nullable = falsevalidation {
condition = alltrue([for_, vin var.diagnostic_settings: contains(["Dedicated", "AzureDiagnostics"], v.log_analytics_destination_type)])
error_message = "Log analytics destination type must be one of: 'Dedicated', 'AzureDiagnostics'." }
validation {
condition = alltrue([
for_, vin var.diagnostic_settings:alltrue([
forlinv.logs: (l.category!=null) != (l.category_group!=null)
])
])
error_message = "Each log entry must set exactly one of `category` or `category_group`." }
validation {
condition = alltrue(
[
for_, vin var.diagnostic_settings:v.workspace_resource_id!=null||v.storage_account_resource_id!=null||v.event_hub_authorization_rule_resource_id!=null||v.marketplace_partner_resource_id!=null ]
)
error_message = "At least one of `workspace_resource_id`, `storage_account_resource_id`, `marketplace_partner_resource_id`, or `event_hub_authorization_rule_resource_id`, must be set." }
validation {
condition = alltrue([
for_, vin var.diagnostic_settings:v.workspace_resource_id ==null|| can(provider::azapi::parse_resource_id("Microsoft.OperationalInsights/workspaces", v.workspace_resource_id))
])
error_message = "Each `workspace_resource_id` must be a valid Log Analytics workspace resource ID, or null." }
validation {
condition = alltrue([
for_, vin var.diagnostic_settings:v.storage_account_resource_id ==null|| can(provider::azapi::parse_resource_id("Microsoft.Storage/storageAccounts", v.storage_account_resource_id))
])
error_message = "Each `storage_account_resource_id` must be a valid storage account resource ID, or null." }
validation {
condition = alltrue([
for_, vin var.diagnostic_settings:v.event_hub_authorization_rule_resource_id ==null|| can(provider::azapi::parse_resource_id("Microsoft.EventHub/namespaces/authorizationRules", v.event_hub_authorization_rule_resource_id))
])
error_message = "Each `event_hub_authorization_rule_resource_id` must be a valid Event Hub namespace authorization rule resource ID, or null." }
description = <<DESCRIPTION A map of diagnostic settings to create on the resource. The map key is deliberately arbitrary to avoid issues where map keys maybe unknown at plan time.
- `name` - (Optional) The name of the diagnostic setting. One will be generated if not set, however this will not be unique if you want to create multiple diagnostic setting resources.
- `logs` - (Optional) A set of log entries to send to the destination. Each entry has the following attributes:
- `category` - (Optional) The name of a specific log category to enable. Mutually exclusive with `category_group`.
- `category_group` - (Optional) The name of a log category group to enable (for example, `allLogs` or `audit`). Mutually exclusive with `category`.
- `enabled` - (Optional) Whether the log entry is enabled. Defaults to `true`.
- `retention_policy` - (Optional) The retention policy for the log entry.
- `days` - (Optional) The retention period in days. Defaults to `0` (retain indefinitely).
- `enabled` - (Optional) Whether the retention policy is enabled. Defaults to `false`.
- `metrics` - (Optional) A set of metric entries to send to the destination. Each entry has the following attributes:
- `category` - (Optional) The name of the metric category to enable.
- `enabled` - (Optional) Whether the metric entry is enabled. Defaults to `true`.
- `retention_policy` - (Optional) The retention policy for the metric entry, with the same `days` and `enabled` attributes as `logs.retention_policy`.
- `log_analytics_destination_type` - (Optional) The destination type for the diagnostic setting. Possible values are `Dedicated` and `AzureDiagnostics`. Defaults to `Dedicated`.
- `workspace_resource_id` - (Optional) The resource ID of the log analytics workspace to send logs and metrics to.
- `storage_account_resource_id` - (Optional) The resource ID of the storage account to send logs and metrics to.
- `event_hub_authorization_rule_resource_id` - (Optional) The resource ID of the event hub authorization rule to send logs and metrics to.
- `event_hub_name` - (Optional) The name of the event hub. If none is specified, the default event hub will be selected.
- `marketplace_partner_resource_id` - (Optional) The full ARM resource ID of the Marketplace resource to which you would like to send Diagnostic Logs.
DESCRIPTION }
module"avm_interfaces" {
source = "Azure/avm-utl-interfaces/azure"version = "0.6.0" # check latest version at the time of use
diagnostic_settings_v2 = var.diagnostic_settingsdiagnostic_settings_scope = azapi_resource.this.id } # Sample resource
resource"azapi_resource""diagnostic_settings" {
for_each = module.avm_interfaces.diagnostic_settings_azapi_v2type = each.value.typename = each.value.nameparent_id = each.value.parent_idbody = each.value.body }
In the provided example for Diagnostic Settings, both logs and metrics are enabled for the associated resource. However, it is IMPORTANT to note that certain resources may not support both diagnostic setting types/categories. In such cases, the resource configuration MUST be modified accordingly to ensure proper functionality and compliance with system requirements.
Role Assignments
variable"role_assignments" {
type = map(object({
name = optional(string, null)
role_definition_id_or_name = stringprincipal_id = stringdescription = optional(string, null)
skip_service_principal_aad_check = optional(bool, false)
condition = optional(string, null)
condition_version = optional(string, null)
delegated_managed_identity_resource_id = optional(string, null)
principal_type = optional(string, null)
}))
default = {}
nullable = falsedescription = <<DESCRIPTION A map of role assignments to create on the <RESOURCE>. The map key is deliberately arbitrary to avoid issues where map keys maybe unknown at plan time.
- `name` - (Optional) The name of the role assignment. If not set, a random UUID will be generated. Changing this forces the creation of a new resource.
- `role_definition_id_or_name` - The ID or name of the role definition to assign to the principal.
- `principal_id` - The ID of the principal to assign the role to.
- `description` - (Optional) The description of the role assignment.
- `skip_service_principal_aad_check` - (Optional) If set to true, skips the Azure Active Directory check for the service principal in the tenant. Defaults to false.
- `condition` - (Optional) The condition which will be used to scope the role assignment.
- `condition_version` - (Optional) The version of the condition syntax. Leave as `null` if you are not using a condition, if you are then valid values are '2.0'.
- `delegated_managed_identity_resource_id` - (Optional) The delegated Azure Resource Id which contains a Managed Identity. Changing this forces a new resource to be created. This field is only used in cross-tenant scenario.
- `principal_type` - (Optional) The type of the `principal_id`. Possible values are `User`, `Group` and `ServicePrincipal`. It is necessary to explicitly set this attribute when creating role assignments if the principal creating the assignment is constrained by ABAC rules that filters on the PrincipalType attribute.
> Note: only set `skip_service_principal_aad_check` to true if you are assigning a role to a service principal.
DESCRIPTIONvalidation {
condition = alltrue([
for_, vin var.role_assignments:v.delegated_managed_identity_resource_id ==null|| can(provider::azapi::parse_resource_id("Microsoft.ManagedIdentity/userAssignedIdentities", v.delegated_managed_identity_resource_id))
])
error_message = "Each `role_assignments[*].delegated_managed_identity_resource_id` must be a valid user-assigned managed identity resource ID, or null." }
}
module"avm_interfaces" {
source = "Azure/avm-utl-interfaces/azure"version = "0.6.0" # check latest version at the time of use
role_assignments = var.role_assignmentsrole_assignment_definition_scope = azapi_resource.this.id } # Example resource declaration
resource"azapi_resource""role_assignments" {
for_each = module.avm_interfaces.role_assignments_azapitype = each.value.typename = each.value.nameparent_id = each.value.parent_idbody = each.value.bodyretry = {
error_message_regex = ["ScopeLocked"] # retry if a lock is in place on the scope and has only just been removed
interval_seconds = 15max_interval_seconds = 60 }
timeouts {
delete = "5m" }
}
Details on child, extension and cross-referenced resources:
Modules MUST support Role Assignments on child, extension and cross-referenced resources as well as the primary resource via parameters/variables
The name attribute is optional in both the top-level role_assignments interface and private_endpoints[*].role_assignments. Omitting it remains valid and backward compatible; a random UUID is generated when no name is supplied.
During migration, tooling MAY temporarily accept the older exact type declaration without the name attribute. New and updated modules SHOULD use the canonical schema, including name = optional(string, null).
Resource Locks
variable"lock" {
type = object({
kind = stringname = optional(string, null)
notes = optional(string, null)
})
default = nulldescription = <<DESCRIPTION Controls the Resource Lock configuration for this resource. The following properties can be specified:
- `kind` - (Required) The type of lock. Possible values are `\"CanNotDelete\"` and `\"ReadOnly\"`.
- `name` - (Optional) The name of the lock. If not specified, a name will be generated based on the `kind` value. Changing this forces the creation of a new resource.
- `notes` - (Optional) Notes about the lock. This value maps to `Microsoft.Authorization/locks.properties.notes`.
DESCRIPTIONvalidation {
condition = var.lock!=null? contains(["CanNotDelete", "ReadOnly"], var.lock.kind) :trueerror_message = "Lock kind must be either `\"CanNotDelete\"` or `\"ReadOnly\"`." }
}
module"avm_interfaces" {
source = "Azure/avm-utl-interfaces/azure"version = "0.6.0" # check latest version at the time of use
lock = var.locklock_scope = azapi_resource.this.id } # Example resource implementation
resource"azapi_resource""lock" {
count = var.lock!=null?1:0type = module.avm_interfaces.lock_azapi.typename = module.avm_interfaces.lock_azapi.nameparent_id = module.avm_interfaces.lock_azapi.parent_idbody = module.avm_interfaces.lock_azapi.body }
Locks SHOULD be able to be set for child resources of the primary resource in resource modules
Details on cross-referenced resources:
Locks MUST be automatically applied to cross-referenced resources if the primary resource has a lock applied.
This MUST also be able to be turned off for each of the cross-referenced resources by the module consumer via a parameter/variable if they desire
An example of this is a Key Vault module that has a Private Endpoints enabled. If a lock is applied to the Key Vault via the lock parameter/variable then the lock should also be applied to the Private Endpoint automatically, unless the privateEndpointLock/private_endpoint_lock (example name) parameter/variable is set to None
Important
In Terraform, locks become part of the resource graph and suitable depends_on values should be set. Note that, during a destroy operation, Terraform will remove the locks before removing the resource itself, reducing the usefulness of the lock somewhat. Also note, due to eventual consistency in Azure, use of locks can cause destroy operations to fail as the lock may not have been fully removed by the time the destroy operation is executed.
Tags
variable"tags" {
type = map(string)
default = nulldescription = "(Optional) Tags of the resource." }
Details on child, extension and cross-referenced resources:
Tags MUST be automatically applied to child, extension and cross-referenced resources, if tags are applied to the primary resource.
By default, all tags set for the primary resource will automatically be passed down to child, extension and cross-referenced resources.
This MUST be able to be overridden by the module consumer so they can specify alternate tags for child, extension and cross-referenced resources, if they desire via a parameter/variable
If overridden by the module consumer, no merge/union of tags will take place from the primary resource and only the tags specified for the child, extension and cross-referenced resources will be applied
Managed Identities
variable"managed_identities" {
type = object({
system_assigned = optional(bool, false)
user_assigned_resource_ids = optional(set(string), [])
})
default = {}
nullable = falsedescription = <<DESCRIPTION Controls the Managed Identity configuration on this resource. The following properties can be specified:
- `system_assigned` - (Optional) Specifies if the System Assigned Managed Identity should be enabled.
- `user_assigned_resource_ids` - (Optional) Specifies a list of User Assigned Managed Identity resource IDs to be assigned to this resource.
DESCRIPTIONvalidation {
condition = alltrue([
foridin var.managed_identities.user_assigned_resource_ids: can(provider::azapi::parse_resource_id("Microsoft.ManagedIdentity/userAssignedIdentities", id))
])
error_message = "Each entry in `managed_identities.user_assigned_resource_ids` must be a valid user-assigned managed identity resource ID." }
}
module"avm_interfaces" {
source = "Azure/avm-utl-interfaces/azure"version = "0.6.0" # check latest version at the time of use
managed_identities = var.managed_identities } # Example identity block on the parent azapi_resource. The avm_interfaces
# module returns a single object with the correct `type` and `identity_ids`
# values, including the case when no identity is configured (in which case
# the for_each is empty and no identity block is rendered).
#
# Note: AzAPI accepts a single `identity` block. The dynamic block below
# renders zero or one block depending on whether a managed identity is
# configured. The same pattern works for resources that only support
# SystemAssigned or only UserAssigned identities.
resource"azapi_resource""this" { # ...other arguments...
dynamic"identity" {
for_each = module.avm_interfaces.managed_identities_azapi!=null? [module.avm_interfaces.managed_identities_azapi] : []
content {
type = identity.value.typeidentity_ids = identity.value.identity_ids }
}
}
Reason for differences in User Assigned data type in languages:
We do not forsee the Managed Identity Resource Provider team to ever add additional properties within the empty object ({}) value required on the input of a User Assigned Managed Identity.
In Bicep we therefore have removed the need for this to be declared and just converted it to a simple array of Resource IDs
However, in Terraform we have left it as a object/map as this simplifies for_each and other loop mechanisms and provides more consistency in plan, apply, destroy operations
Especially when adding, removing or changing the order of the User Assigned Managed Identities as they are declared
Private Endpoints
# In this example we only support one service, e.g. Key Vault.
# If your service has multiple private endpoint services, then expose the service name.
variable"private_endpoints_manage_dns_zone_group" {
type = booldefault = truenullable = falsedescription = "Whether to manage private DNS zone groups with this module. If set to false, you must manage private DNS zone groups externally, e.g. using Azure Policy." }
variable"private_endpoints" {
type = map(object({
name = optional(string, null)
role_assignments = optional(map(object({
name = optional(string, null)
role_definition_id_or_name = stringprincipal_id = stringdescription = optional(string, null)
skip_service_principal_aad_check = optional(bool, false)
condition = optional(string, null)
condition_version = optional(string, null)
delegated_managed_identity_resource_id = optional(string, null)
principal_type = optional(string, null)
})), {})
lock = optional(object({
kind = stringname = optional(string, null)
notes = optional(string, null)
}), null)
tags = optional(map(string), null)
subnet_resource_id = stringsubresource_name = optional(string, null) # only required if the parent resource exposes more than one private endpoint sub-resource
private_dns_zone_group_name = optional(string, "default")
private_dns_zone_resource_ids = optional(set(string), [])
application_security_group_associations = optional(map(string), {})
private_service_connection_name = optional(string, null)
network_interface_name = optional(string, null)
location = optional(string, null)
resource_group_name = optional(string, null)
ip_configurations = optional(map(object({
name = stringprivate_ip_address = stringmember_name = optional(string)
})), {})
}))
default = {}
nullable = falsedescription = <<DESCRIPTION A map of private endpoints to create on the Key Vault. The map key is deliberately arbitrary to avoid issues where map keys maybe unknown at plan time.
- `name` - (Optional) The name of the private endpoint. One will be generated if not set.
- `role_assignments` - (Optional) A map of role assignments to create on the private endpoint. The map key is deliberately arbitrary to avoid issues where map keys maybe unknown at plan time. See `var.role_assignments` for more information.
- `name` - (Optional) The name of the role assignment. If not set, a random UUID will be generated. Changing this forces the creation of a new resource.
- `role_definition_id_or_name` - The ID or name of the role definition to assign to the principal.
- `principal_id` - The ID of the principal to assign the role to.
- `description` - (Optional) The description of the role assignment.
- `skip_service_principal_aad_check` - (Optional) If set to true, skips the Azure Active Directory check for the service principal in the tenant. Defaults to false.
- `condition` - (Optional) The condition which will be used to scope the role assignment.
- `condition_version` - (Optional) The version of the condition syntax. Leave as `null` if you are not using a condition, if you are then valid values are '2.0'.
- `delegated_managed_identity_resource_id` - (Optional) The delegated Azure Resource Id which contains a Managed Identity. Changing this forces a new resource to be created. This field is only used in cross-tenant scenario.
- `principal_type` - (Optional) The type of the `principal_id`. Possible values are `User`, `Group` and `ServicePrincipal`. It is necessary to explicitly set this attribute when creating role assignments if the principal creating the assignment is constrained by ABAC rules that filters on the PrincipalType attribute.
- `lock` - (Optional) The lock level to apply to the private endpoint. Default is `None`. Possible values are `None`, `CanNotDelete`, and `ReadOnly`.
- `kind` - (Required) The type of lock. Possible values are `\"CanNotDelete\"` and `\"ReadOnly\"`.
- `name` - (Optional) The name of the lock. If not specified, a name will be generated based on the `kind` value. Changing this forces the creation of a new resource.
- `notes` - (Optional) Notes about the lock. This value maps to `Microsoft.Authorization/locks.properties.notes`.
- `tags` - (Optional) A mapping of tags to assign to the private endpoint.
- `subnet_resource_id` - The resource ID of the subnet to deploy the private endpoint in.
- `subresource_name` (Optional) - The name of the sub resource for the private endpoint.
- `private_dns_zone_group_name` - (Optional) The name of the private DNS zone group. One will be generated if not set.
- `private_dns_zone_resource_ids` - (Optional) A set of resource IDs of private DNS zones to associate with the private endpoint. If not set, no zone groups will be created and the private endpoint will not be associated with any private DNS zones. DNS records must be managed external to this module.
- `application_security_group_associations` - (Optional) A map of resource IDs of application security groups to associate with the private endpoint. The map key is deliberately arbitrary to avoid issues where map keys maybe unknown at plan time.
- `private_service_connection_name` - (Optional) The name of the private service connection. One will be generated if not set.
- `network_interface_name` - (Optional) The name of the network interface. One will be generated if not set.
- `location` - (Optional) The Azure location where the resources will be deployed. Defaults to the location of the resource group.
- `resource_group_name` - (Optional) The resource group resource ID where the private endpoint resources will be deployed. Defaults to the resource group of the parent resource.
- `ip_configurations` - (Optional) A map of IP configurations to create on the private endpoint. If not specified the platform will create one. The map key is deliberately arbitrary to avoid issues where map keys maybe unknown at plan time.
- `name` - The name of the IP configuration.
- `private_ip_address` - The private IP address of the IP configuration.
- `member_name` - (Optional) The private IP configuration member name.
DESCRIPTIONvalidation {
condition = alltrue([
for_, vin 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_, vin var.private_endpoints: [
foridinv.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." }
validation {
condition = alltrue(flatten([
for_, vin var.private_endpoints: [
for_, asginv.application_security_group_associations: can(provider::azapi::parse_resource_id("Microsoft.Network/applicationSecurityGroups", asg))
]
]))
error_message = "Each value in `private_endpoints[*].application_security_group_associations` must be a valid application security group resource ID." }
validation {
condition = alltrue(flatten([
for_, vin var.private_endpoints: [
for_, rainv.role_assignments:ra.delegated_managed_identity_resource_id ==null|| can(provider::azapi::parse_resource_id("Microsoft.ManagedIdentity/userAssignedIdentities", ra.delegated_managed_identity_resource_id))
]
]))
error_message = "Each `private_endpoints[*].role_assignments[*].delegated_managed_identity_resource_id` must be a valid user-assigned managed identity resource ID, or null." }
}
module"avm_interfaces" {
source = "Azure/avm-utl-interfaces/azure"version = "0.6.0" # check latest version at the time of use
private_endpoints = var.private_endpointsprivate_endpoints_scope = azapi_resource.this.idrole_assignment_definition_scope = azapi_resource.this.id }
resource"azapi_resource""private_endpoints" {
for_each = module.avm_interfaces.private_endpoints_azapilocation = azapi_resource.this.locationname = each.value.nameparent_id = coalesce(var.private_endpoints[each.key].resource_group_name, azapi_resource.this.parent_id)
type = each.value.typebody = each.value.bodyretry = {
error_message_regex = ["ScopeLocked"] # This will retry if a lock is in place on the resource group, and has only just been removed
}
timeouts {
delete = "5m" }
}
resource"azapi_resource""private_endpoint_locks" {
for_each = module.avm_interfaces.lock_private_endpoint_azapiname = each.value.nameparent_id = azapi_resource.private_endpoints[each.value.pe_key].idtype = each.value.typebody = each.value.bodydepends_on = [
azapi_resource.private_dns_zone_groups,
azapi_resource.private_endpoint_role_assignments ]
}
resource"azapi_resource""private_dns_zone_groups" {
for_each = module.avm_interfaces.private_dns_zone_groups_azapiname = each.value.nameparent_id = azapi_resource.private_endpoints[each.key].idtype = each.value.typebody = each.value.bodyretry = {
error_message_regex = ["ScopeLocked"] # This will retry if a lock is in place on the resource group, and has only just been removed
interval_seconds = 15max_interval_seconds = 60 }
timeouts {
delete = "5m" }
}
resource"azapi_resource""private_endpoint_role_assignments" {
for_each = module.avm_interfaces.role_assignments_private_endpoint_azapiname = each.value.nameparent_id = azapi_resource.private_endpoints[each.value.pe_key].idtype = each.value.typebody = each.value.bodyretry = {
error_message_regex = ["ScopeLocked"]
interval_seconds = 15max_interval_seconds = 60 }
timeouts {
delete = "5m" }
}
The properties defined in the schema above are the minimum amount of properties expected to be exposed for Private Endpoints in AVM Resource Modules.
A module owner MAY chose to expose additional properties of the Private Endpoint resource.
However, module owners considering this SHOULD contact the AVM core team first to consult on how the property should be exposed to avoid future breaking changes to the schema that may be enforced upon them.
Module owners MAY chose to define a list of allowed value for the ‘service’ (a.k.a. groupIds) property.
However, they should do so with caution as should a new service appear for their resource module, a new release will need to be cut to add this new service to the allowed values.
Whereas not specifying allowed values will allow flexibility from day 0 without the need for any changes and releases to be made.
Customer Managed Keys
A module MUST implement exactly one of the two variants below. Which one applies is determined by the resource provider’s API, not by module owner preference. Linting accepts either shape.
Customer Managed Keys
variable"customer_managed_key" {
type = object({
key_vault_resource_id = stringkey_name = stringkey_version = optional(string, null)
user_assigned_identity = optional(object({
resource_id = string }), null)
})
default = nullvalidation {
condition = var.customer_managed_key ==null|| can(provider::azapi::parse_resource_id("Microsoft.KeyVault/vaults", var.customer_managed_key.key_vault_resource_id))
error_message = "`customer_managed_key.key_vault_resource_id` must be a valid Azure Key Vault resource ID." }
validation {
condition = var.customer_managed_key ==null|| var.customer_managed_key.user_assigned_identity ==null|| can(provider::azapi::parse_resource_id("Microsoft.ManagedIdentity/userAssignedIdentities", var.customer_managed_key.user_assigned_identity.resource_id))
error_message = "`customer_managed_key.user_assigned_identity.resource_id` must be a valid user-assigned managed identity resource ID." }
}
customer_managed_key = {
key_vault_resource_id = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{keyVaultName}"key_name = "{keyName}" # Omit `key_version` to let the resource provider follow key rotations automatically.
key_version = "{keyVersion}"user_assigned_identity = {
resource_id = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{userAssignedIdentityName}" }
}
# The vault is read by resource ID, not by name. That is safe in both directions: when
# the vault already exists the read resolves during plan, and when the vault is created
# by the same apply its resource ID is unknown at plan time, so Terraform defers the
# read. Reading the *key* the same way is not safe, which is what Variant 2 exists for.
data"azapi_resource""customer_managed_key_vault" {
count = var.customer_managed_key ==null?0:1type = var.resource_types.keyvault_vaultsresource_id = var.customer_managed_key.key_vault_resource_idresponse_export_values = ["properties.vaultUri"]
}
locals {
customer_managed_key_vault_uri = try(
data.azapi_resource.customer_managed_key_vault[0].output.properties.vaultUri,
null )
customer_managed_key_identity_resource_id = try(
var.customer_managed_key.user_assigned_identity.resource_id,
null )
} # `Microsoft.Storage/storageAccounts` takes the vault URI, key name and key version as
# separate fields, and identifies the encryption identity by resource ID. A null
# `keyversion` leaves the account following key rotations automatically.
resource"azapi_resource""this" {
type = var.resource_types.storage_storage_accountsname = var.namelocation = var.locationparent_id = var.resource_group_resource_idbody = {
properties = { # ... other properties
encryption = var.customer_managed_key ==null?null: {
keySource = "Microsoft.Keyvault"identity = {
userAssignedIdentity = local.customer_managed_key_identity_resource_id }
keyvaultproperties = {
keyvaulturi = local.customer_managed_key_vault_urikeyname = var.customer_managed_key.key_namekeyversion = var.customer_managed_key.key_version }
}
}
}
lifecycle {
precondition {
condition = var.customer_managed_key ==null||local.customer_managed_key_identity_resource_id!=nullerror_message = "`customer_managed_key.user_assigned_identity.resource_id` must be supplied because the Storage API identifies the encryption identity by resource ID." }
precondition {
condition = local.customer_managed_key_identity_resource_id ==null|| contains(var.managed_identities.user_assigned_resource_ids, local.customer_managed_key_identity_resource_id)
error_message = "The user assigned managed identity used for customer managed key encryption must also be assigned to the Storage Account via `managed_identities.user_assigned_resource_ids`." }
}
}
variable"customer_managed_key" {
type = object({
key_vault_key_uri = stringuser_assigned_identity = optional(object({
client_id = string }), null)
})
default = nullvalidation {
condition = var.customer_managed_key ==null|| can(regex("^https://[^/]+/keys/[^/]+(/[^/]+)?$", var.customer_managed_key.key_vault_key_uri))
error_message = "`customer_managed_key.key_vault_key_uri` must be a Key Vault or Managed HSM key URI, in the form `https://{vaultHost}/keys/{keyName}` or `https://{vaultHost}/keys/{keyName}/{keyVersion}`." }
validation {
condition = var.customer_managed_key ==null|| var.customer_managed_key.user_assigned_identity ==null|| can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.customer_managed_key.user_assigned_identity.client_id))
error_message = "`customer_managed_key.user_assigned_identity.client_id` must be a valid GUID." }
}
customer_managed_key = { # Omit the trailing version segment to let the resource provider follow key
# rotations automatically. The host is supplied in full, so the same input shape
# works in sovereign clouds and against Managed HSM, for example
# `https://{managedHsmName}.managedhsm.azure.net/keys/{keyName}/{keyVersion}`.
key_vault_key_uri = "https://{keyVaultName}.vault.azure.net/keys/{keyName}"user_assigned_identity = {
client_id = "{userAssignedIdentityClientId}" }
}
# Variant 2 carries exactly the two values the API consumes, so the module performs no
# resolution at all: no data sources, no URI construction, and no cloud specific DNS
# suffix handling. The consumer builds the key URI from the key resource they own, and
# supplies the client ID of the identity that the registry uses to reach the vault.
#
# `Microsoft.ContainerRegistry/registries` takes a single combined key identifier and
# identifies the encryption identity by client ID. A client ID cannot be derived from an
# identity resource ID without a data source, which is why this variant exists.
resource"azapi_resource""this" {
type = var.resource_types.containerregistry_registriesname = var.namelocation = var.locationparent_id = var.resource_group_resource_idbody = {
properties = { # ... other properties
encryption = var.customer_managed_key ==null?null: {
status = "enabled"keyVaultProperties = {
keyIdentifier = var.customer_managed_key.key_vault_key_uriidentity = var.customer_managed_key.user_assigned_identity ==null?null: var.customer_managed_key.user_assigned_identity.client_id }
}
}
}
lifecycle {
precondition {
condition = var.customer_managed_key ==null|| var.customer_managed_key.user_assigned_identity!=nullerror_message = "`customer_managed_key.user_assigned_identity` must be supplied because the Container Registry API identifies the encryption identity by client ID." }
}
}
Notes:
Modules MUST NOT use a data source to resolve the key URI or the encryption identity’s client ID.
Terraform reads a data source during plan whenever its arguments are already known. A key or identity lookup whose arguments are known literals can therefore run before a resource created by the same terraform apply exists, causing the plan to fail.
A module MAY read the Key Vault itself by key_vault_resource_id, because that argument is unknown at plan time whenever the vault is created by the same apply, which defers the read to apply time.
Variant 1 MUST be used where the resource provider takes the vault URI, the key name and the key version as separate fields, and identifies the encryption identity by resource ID, such as Microsoft.Storage/storageAccounts.
Omitting key_versionMUST leave the resource provider following key rotations automatically.
Where the resource provider requires a versioned key, such as Microsoft.Compute/diskEncryptionSets, the module MUST validate that key_version has been supplied.
Variant 2 MUST be used where the resource provider requires the encryption identity’s client ID, such as Microsoft.ContainerRegistry/registries, because a client ID cannot be resolved from an identity resource ID without a data source.
key_vault_key_uri carries the entire key identifier, so the consumer owns the host. The same input shape therefore works unchanged in sovereign clouds and against Managed HSM, and the module MUST NOT construct a DNS suffix of its own.
Omitting the trailing version segment MUST leave the resource provider following key rotations automatically.
Consumers SHOULD build key_vault_key_uri from the key resource they own, rather than from a data source, so that the value stays known at plan time.
Variant 2 deliberately carries no vault resource ID and no identity resource ID. A module MUST NOT require either, and MUST NOT attempt to cross-check the identity against managed_identities.
Modules MUST validate that whichever identity value their resource provider requires has been supplied, and SHOULD do so with a precondition so that the error names the missing attribute.
Where the resource provider also requires the identity to be assigned to the primary resource, modules MUST document that the consumer supplies the same identity through managed_identities.user_assigned_resource_ids.
Azure Monitor Alerts
Note
This interface is a SHOULD instead of a MUST and therefore the AVM core team have not mandated a interface schema to use.
AzAPI resource types
Important
Each resource_types key MUST be the snake_case form of the ARM resource type with the Microsoft. prefix dropped (for example Microsoft.Example/widgets/parts \u2192 example_widgets_parts). Each module MUST declare one optional(string, "...") field per azapi_resource (or equivalent AzAPI resource) it owns, defaulting each field to the latest tested API version. See TFFR6.
# `resource_types` keys vs Terraform resource labels
# -----------------------------------------------------------------------------
# These are two unrelated concepts:
#
# - Keys in `var.resource_types` name the AzAPI resource TYPE (e.g.
# `example_widgets`). They are derived from the ARM resource type by
# the naming rule below.
# - The Terraform resource LABEL (e.g. `azapi_resource.this`) names the
# graph node. The primary resource label MUST be `this` per TFRMNFR2.
#
# A primary-resource declaration therefore reads:
#
# resource "azapi_resource" "this" { # label per TFRMNFR2
# type = var.resource_types.example_widgets # key per the naming rule
# }
#
# The two MUST NOT be conflated. `this` is never a valid `resource_types` key.
#
# Naming rule for `resource_types` keys
# -----------------------------------------------------------------------------
# Each key MUST be the snake_case form of the ARM resource type with the
# `Microsoft.` prefix dropped. The provider namespace is rendered as a single
# lowercase token (no internal split) and each path segment after the
# provider is converted from camelCase to snake_case. Segments are joined
# with `_`:
#
# 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
#
# Submodules in the variable
# -----------------------------------------------------------------------------
# Every submodule the module instantiates gets a nested `optional(object({...}), {})`
# slot in `resource_types`, keyed by the submodule's primary ARM resource type
# (same naming rule). The slot's shape MUST match the submodule's own
# `resource_types` variable exactly. The parent MUST NOT repeat the submodule's
# defaults: the inner string attributes are declared as `optional(string)`
# with no default, so the submodule remains the single source of truth for
# its own tested API versions. Passing `null` (or omitting the key) yields
# the submodule's default.
# Root module example: manages `Microsoft.Example/widgets`, owns one extension
# resource (a lock), and instantiates a `parts` submodule that itself
# instantiates a `component` sibling submodule (per TFRMNFR1).
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 = falsedescription = <<DESCRIPTION Override the AzAPI `<provider>/<resource>@<api-version>` strings used by this module and its submodules. Each key defaults to a tested value; supply only the keys you want to override. Useful when targeting a sovereign cloud with older API versions, or when opting into a newer preview API.
- `example_widgets` - The primary widget managed by this module.
- `authorization_locks` - Management lock applied to the widget and its private endpoints.
- `example_widgets_parts` - Override slot for the `parts` submodule. Defaults live in the submodule; supply only the keys you want to override.
- `example_widgets_parts` - The part resource managed by the `parts` submodule.
- `example_widgets_parts_components` - Override slot for the grandchild `components` submodule. Defaults live in that submodule.
- `example_widgets_parts_components` - The component resource managed by the `components` submodule.
DESCRIPTION } # `type =` of every `azapi_resource` MUST come from `var.resource_types`,
# never a hard-coded string. The resource label (`this`) and the
# `resource_types` key (`example_widgets`) are independent concerns.
resource"azapi_resource""this" {
type = var.resource_types.example_widgetsname = var.nameparent_id = var.parent_idbody = { /* ... */ }
response_export_values = []
} # Cascade the nested slot through to the submodule unchanged. The submodule's
# `resource_types` variable has exactly the shape of the slot, so no
# repacking or renaming is required.
module"part" {
source = "./modules/part"for_each = var.partsname = each.value.nameparent_id = azapi_resource.this.idresource_types = var.resource_types.example_widgets_parts }
# Consumers override only the keys they need; defaults supply the rest.
# Passing `null` for any attribute (or omitting it) yields the default
# declared on the owning module's variable.
resource_types = { # Pin the primary widget to a newer preview API version.
example_widgets = "Microsoft.Example/widgets@2025-06-01-preview" # Override an API version inside the `parts` submodule.
example_widgets_parts = {
example_widgets_parts = "Microsoft.Example/widgets/parts@2023-01-01" # Override an API version inside the grandchild `components` submodule
# of `parts` — the nested slot mirrors the submodule tree.
example_widgets_parts_components = {
example_widgets_parts_components = "Microsoft.Example/widgets/parts/components@2023-01-01" }
}
}
Notes:
resource_types keys name the AzAPI resource type and are derived deterministically from the ARM type. They are independent of the Terraform resource label (see TFRMNFR2) \u2014 this is never a valid resource_types key.
Submodules MUST declare their own resource_types variable using the same naming rule for the resources they own. The parent MUST declare one nested optional(object({...}), {}) slot per submodule it instantiates, shaped exactly like that submodule’s variable, and MUST cascade the slot through unchanged (see TFRMNFR1). The parent MUST NOT repeat the submodule’s defaults \u2014 the submodule remains the source of truth for its own tested API versions.
Defaults MUST be a stable (non-preview) API version unless the module’s primary resource only ships a preview API.
AzAPI retry
variable"retry" {
type = object({
error_message_regex = optional(list(string))
interval_seconds = optional(number)
max_interval_seconds = optional(number)
})
default = nulldescription = <<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 } # Example resource implementation. `retry` is an attribute on `azapi_resource`,
# so the variable is assigned directly. The same pattern applies to every
# `azapi_resource` declared by the module, including those in submodules.
resource"azapi_resource""this" {
type = var.resource_types.example_widgetsname = var.nameparent_id = var.parent_idbody = { /* ... */ }
retry = var.retryresponse_export_values = []
} # Cascade `retry` to every submodule the parent module instantiates so that a
# single override at the parent level propagates everywhere.
module"child" {
source = "./modules/child"retry = var.retry # ...other arguments...
}
The retry variable MUST be applied to every azapi_resource (and equivalent AzAPI resources) declared by the module.
Parent modules MUST cascade retry to each submodule they instantiate (see TFFR7 and TFRMNFR1).
Module owners MAY ship module-level defaults when the resource it manages benefits from them. To do so, set the variable’s overall default to {} (not null) and provide per-field defaults inside the optional(...) wrappers. Consumers MUST still be able to override any individual field.
# Module-level defaults example: a hypothetical module retries
# on common transient replication errors and tunes the back-off interval. The
# overall variable default is `{}` (not `null`) so the per-field defaults take
# effect, and consumers can still override any individual field.
variable"retry" {
type = object({
error_message_regex = optional(list(string), ["AnotherOperationInProgress", "TooManyRequests"])
interval_seconds = optional(number, 30)
max_interval_seconds = optional(number, 300)
})
default = {}
description = <<DESCRIPTION Retry configuration applied to every `azapi` resource managed by the module. This module ships defaults tuned for Storage Account replication; consumers **MAY** override any field.
- `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.
DESCRIPTION }
AzAPI timeouts
variable"timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = nulldescription = <<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 } # Example resource implementation. `timeouts` is a block on `azapi_resource`,
# so a `dynamic "timeouts"` block is required to honour the variable's `null`
# default. The same pattern applies to every `azapi_resource` declared by
# the module, including those in submodules.
resource"azapi_resource""this" {
type = var.resource_types.example_widgetsname = var.nameparent_id = var.parent_idbody = { /* ... */ }
dynamic"timeouts" {
for_each = var.timeouts ==null? [] : [var.timeouts]
content {
create = timeouts.value.createread = timeouts.value.readupdate = timeouts.value.updatedelete = timeouts.value.delete }
}
response_export_values = []
} # Cascade `timeouts` to every submodule the parent module instantiates so that
# a single override at the parent level propagates everywhere.
module"child" {
source = "./modules/child"timeouts = var.timeouts # ...other arguments...
}
timeouts is a block on azapi_resource (not an attribute), so a dynamic "timeouts" block is required to honor the variable’s null default.
The timeouts variable MUST be applied to every azapi_resource (and equivalent AzAPI resources) declared by the module.
Parent modules MUST cascade timeouts to each submodule they instantiate (see TFFR7 and TFRMNFR1). Submodules MAY additionally expose per-item overrides for cases where individual resources need different settings.
Module owners MAY ship module-level defaults when the resource it manages benefits from them (for example, longer create / delete timeouts for slow-provisioning resources). To do so, set the variable’s overall default to {} (not null) and provide per-field defaults inside the optional(...) wrappers. Consumers MUST still be able to override any individual field.
# Module-level defaults example: a hypothetical SQL Database module ships
# longer create / delete timeouts because provisioning and dropping large
# databases can exceed the provider defaults. The overall variable default
# is `{}` (not `null`) so the per-field defaults take effect, and consumers
# can still override any individual field.
variable"timeouts" {
type = object({
create = optional(string, "1h")
read = optional(string, "5m")
update = optional(string, "1h")
delete = optional(string, "45m")
})
default = {}
description = <<DESCRIPTION Default per-operation timeouts applied to every `azapi` resource managed by the module. This module ships defaults tuned for SQL Database provisioning latency; consumers **MAY** override any field.
- `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 }
AzAPI ignore_body_changes
Important
ignore_body_changes is a write-only argument that requires the Azure/azapi provider v2.12.0 or later, and Terraform 1.11 or later when a non-empty value is supplied. See TFFR8.
# `ignore_body_changes` keys follow exactly the same naming rule as
# `resource_types` (see TFFR6): the snake_case form of the ARM resource type
# with the `Microsoft.` prefix dropped.
#
# Microsoft.Example/widgets -> example_widgets
# Microsoft.Example/widgets/parts -> example_widgets_parts
# Microsoft.Example/widgets/parts/components -> example_widgets_parts_components
#
# Unlike `retry` and `timeouts`, the values are dot-notation paths into ONE
# specific resource's `body`, so the variable is scoped per resource and per
# submodule instead of being cascaded unchanged. Every submodule the module
# instantiates gets a nested `optional(object({...}), {})` slot whose shape
# matches that submodule's own `ignore_body_changes` variable exactly, and the
# parent cascades that slot through unchanged.
variable"ignore_body_changes" {
type = object({
example_widgets = optional(list(string), [])
example_widgets_parts = optional(object({
example_widgets_parts = optional(list(string), [])
}), {})
})
default = {}
nullable = falsedescription = <<DESCRIPTION Paths in each resource's `body` whose changes the AzAPI provider ignores. Prefer Terraform's `lifecycle.ignore_changes` when the paths are static; use this variable when the paths must be derived from variables or other non-static values.
Paths use dot notation, for example `properties.sku.name`. Individual list items cannot be targeted — ignore the whole list property instead. Configuration changes at an ignored path are **not** sent to Azure until that path is removed from the list.
Supplying a non-empty value requires Terraform 1.11 or later, because `ignore_body_changes` is a write-only argument. Changes take effect only after an apply, because the value is held in provider-private state.
- `example_widgets` - Ignored body paths for the widget managed by this module.
- `example_widgets_parts` - Override slot for the `parts` submodule. Supply only the keys you want to override.
- `example_widgets_parts` - Ignored body paths for the part resource managed by the `parts` submodule.
DESCRIPTION } # `ignore_body_changes` is a write-only attribute on `azapi_resource`, so the
# relevant field is assigned directly. Collapse an empty list to `null` so the
# argument is absent when the feature is unused, keeping the module usable on
# Terraform versions earlier than 1.11.
resource"azapi_resource""this" {
type = var.resource_types.example_widgetsname = var.nameparent_id = var.parent_idbody = { /* ... */ }
ignore_body_changes = length(var.ignore_body_changes.example_widgets) >0? var.ignore_body_changes.example_widgets:nullresponse_export_values = []
} # Cascade the nested slot to the submodule unchanged. The submodule's
# `ignore_body_changes` variable has exactly the shape of the slot, so no
# repacking or renaming is required.
module"part" {
source = "./modules/part"for_each = var.partsname = each.value.nameparent_id = azapi_resource.this.idresource_types = var.resource_types.example_widgets_partsignore_body_changes = var.ignore_body_changes.example_widgets_parts }
ignore_body_changes = { # Tags are applied to the widget by Azure Policy, so suppress the diff when
# the consumer opts in. `lifecycle.ignore_changes` cannot express this,
# because the value is derived from a variable.
example_widgets = var.ignore_policy_tags? ["tags"] : []
example_widgets_parts = {
example_widgets_parts = ["properties.retentionPolicy"]
}
}
Notes:
Unlike retry and timeouts, ignore_body_changes values are dot-notation paths into one specific resource’sbody, so the variable MUST NOT be cascaded to submodules unchanged. It uses the same per-resource, per-submodule shape and key-naming rule as resource_types (see TFFR6).
The ignore_body_changes variable MUST be applied to every azapi_resource (and equivalent AzAPI resources) declared by the module, and every submodule MUST declare its own (see TFFR8 and TFRMNFR1).
The assignment MUST collapse an empty list to null so that the write-only argument is absent when the feature is unused, keeping the module usable on Terraform versions earlier than 1.11.
A change to ignore_body_changes only takes effect after an apply, because the value is held in provider-private state.
An ignored path is not merely hidden from the plan — configuration changes at that path are not sent to Azure until the path is removed from the list.
Module owners MAY ship module-level defaults where the resource is known to be mutated outside Terraform, by supplying the default inside the optional(list(string), [...]) wrapper. Consumers MUST still be able to override any individual field.
Terraform Pattern Module Specifications
Contribution / Support
The content below is listed based on the following tags
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>
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 the avm-technical-reviewers-bicep parent 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):
Access management for Terraform repositories is governed centrally through Microsoft Entra. Module owner access is granted via an Entra access package — it is no longer managed through a per-module GitHub team or the legacy Core Identity entitlement.
All module owners MUST request access via the Azure Verified Modules (AVM) Module Contributors Entra access package:
Once approved, you are added to the azure-verified-modules-module-contributors Entra group, which is the source of truth for who is authorized to own and approve changes on AVM Terraform module repositories. Day-to-day repository access is then granted through this group together with just-in-time (JIT) elevation.
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.
```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/ to request admin access before running the script.
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 -ne0) {
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 -ne0) {
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 alreadyif ($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 labelsif ($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 alreadyif ($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 repositoryif ($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
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
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 the telemetry guidance for more details.
Telemetry Information Notice
Note
The following information notice is automatically added at the bottom of the README.md file of the module when
Terraform: Running avm pre-commit with the note and header ## Data Collection placed in the module’s _footer.md beforehand
### 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 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-
<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:
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: Terraform uses a telemetry provider, the configuration of which is the same for every module and is included in the template repo.
General: See the language specific contribution guides for detailed guidance and sample code to use in AVM modules to achieve this requirement.
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 main.telemetry.tf, which is generated and maintained by Avm.Authoring.
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.
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
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., is 2021-05-01-preview
Otherwise the latest “non-preview” version of the API SHOULD be used
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”
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:
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.
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.
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.
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.
Pattern 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.
Example: avm/ptn/compute/app-tier-vmss or avm/ptn/avd-lza/management-plane or avm/ptn/3-tier/web-app
Segments:
ptn defines this as a pattern module
<hyphenated grouping/category name> is a hierarchical grouping of pattern modules by category, with each word separated by dashes, such as:
project name, e.g., avd-lza,
primary resource provider, e.g., compute or network, or
architecture, e.g., 3-tier
<hyphenated pattern module name> is a term describing the module’s function, with each word separated by dashes, e.g., app-tier-vmss = Application Tier VMSS; management-plane = Azure Virtual Desktop Landing Zone Accelerator Management Plane
Terraform Pattern Module Naming
Naming convention:
avm-ptn-<pattern module name> (Module name for registry)
terraform-<provider>-avm-ptn-<pattern module name> (GitHub repository name to meet registry naming requirements)
Example: avm-ptn-apptiervmss or avm-ptn-avd-lza-managementplane
Segments:
<provider> is a legacy requirement of the Terraform registry. This must be set to azure
ptn defines this as a pattern module
<pattern module name> is a term describing the module’s function, e.g., apptiervmss = Application Tier VMSS; avd-lza-managementplane = Azure Virtual Desktop Landing Zone Accelerator Management Plane
ID: PMNFR2 - Category: Composition - Use Resource Modules to Build a Pattern Module
A Pattern Module SHOULD be built from AVM Resources Modules to establish a standardized code base and improve maintainability. If a valid reason exists, a pattern module MAY contain native resources (“vanilla” code) where it’s necessary. A Pattern Module MUST NOT contain references to non-AVM modules.
Valid reasons for not using a Resource Module for a resource required by a Pattern Module include but are not limited to:
When using a Resource Module would result in hitting scaling limitations and/or would reduce the capabilities of the Pattern Module due to the limitations of Azure Resource Manager.
Developing a Pattern Module under time constraint, without having all required Resource Modules readily available.
Note
In the latter case, the Pattern Module SHOULD be updated to use the Resource Module when the required Resource Module becomes available, to avoid accumulating technical debt. Ideally, all required Resource Modules SHOULD be developed first, and then leveraged by the Pattern Module.
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.,
Every new AVM Terraform module — resource, pattern, or utility — MUST use Azure/azapi for every Azure control-plane resource and every data-plane operation supported by AzAPI. The AzureRM provider is permitted only for the unsupported data-plane/non-ARM API exception defined below.
Authors MUST only use the following Azure providers, and versions, in their modules:
provider
min version
max version
permitted use
Azure/azapi
>= 2.12
< 3.0
All Azure control-plane resources and supported data-plane operations
hashicorp/azurerm
>= 4.0
< 5.0
Only a specific unsupported data-plane/non-ARM API operation under the exception below
Note
The AzAPI floor is 2.12 because TFFR8 requires every module to expose the ignore_body_changes argument, which was introduced in Azure/azapi v2.12.0. Modules pinned below that version will fail to plan because the argument is absent from the provider schema.
This prohibition applies to every Terraform configuration shipped with the module, including:
The root module and all submodules.
Every configuration under examples/, including examples executed as end-to-end tests.
Terraform tests, test fixtures, and supporting setup configurations.
Terraform snippets in _header.md, _footer.md, generated documentation, and other repository documentation.
Supporting control-plane resources needed by an example, end-to-end test, or fixture MUST use AzAPI. AzureRM MUST NOT be used for resource groups, role assignments, monitoring resources, networking, or any other ARM control-plane resource.
Exception — unsupported data-plane/non-ARM API operations
An AVM Terraform module that is otherwise built with AzAPI MAY declare the AzureRM provider only for a specific data-plane or non-ARM API operation whose functionality is genuinely unavailable through azapi_data_plane_resource, azapi_resource, azapi_resource_action, or azapi_update_resource. This exception is intended for isolated operations such as a data-plane resource whose AzureRM implementation calls a service endpoint rather than Azure Resource Manager. It is not a general fallback for a missing or inconvenient AzAPI schema. Every azurerm_* block MUST independently satisfy this exception; one permitted block does not authorize any other AzureRM use.
Where this exception applies, the module MUST:
Continue to declare and use AzAPI as its required, primary Azure provider.
Scope every azurerm_* resource or data source to the exact unsupported data-plane/non-ARM operation.
Pin the AzureRM provider to ~> 4.0 in required_providers.
Use AzAPI for every control-plane resource and every data-plane operation that AzAPI supports.
Document the exception in the module’s README.md, including each azurerm_* block, the data-plane/non-ARM API it wraps, why AzAPI cannot implement it, and the upstream AzAPI issue or pull request tracking support.
Replace the azurerm_* block with AzAPI in the next module release after the required capability ships.
Examples, end-to-end tests, Terraform tests, fixtures, and documentation snippets MAY configure or exercise AzureRM only when required by that exact permitted data-plane operation. All supporting control-plane resources in those surfaces MUST use AzAPI.
This exception MUST NOT be used to:
Implement any ARM control-plane resource.
Avoid AzAPI because its body schema is more verbose or less convenient.
Avoid raising an AzAPI capability gap for an unsupported control-plane operation.
Side-step any AzAPI-specific specification that applies to the module’s AzAPI resources.
The azurerm remote state backend and the final segment of a published Terraform Registry module address, such as /azurerm in an existing AVM module source, are names and are not provider declarations. They MAY appear where required for state storage or to reference an existing published AVM module. A dependency’s provider implementation is governed by that dependency’s own repository; its Registry address does not by itself justify a direct hashicorp/azurerm declaration or azurerm_* block in the consuming module repository. Any such direct use MUST independently satisfy the data-plane exception above.
Authors MUST use the required_providers block in their module to enforce the provider versions.
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
}
Authors MUST omit replace_triggers_refs when no body properties require replacement. When one or more body properties require replacement, authors MUST set replace_triggers_refs to a non-empty static list of JMESPath expressions that identify those paths.
Each expression MUST be valid JMESPath syntax, non-blank, and unique within the list. Do not include name or location, as AzAPI already replaces the resource when either changes. When the resource body is statically evaluable, every declared expression MUST resolve against that body.
This is to ensure that changes to properties that require replacement of the resource are handled correctly by Terraform. Authors remain responsible for identifying every property that actually requires replacement. Current Bicep-generated schemas do not reliably preserve whether a property is create-only or updateable, so the rule validates declared paths but cannot prove that the list is semantically complete.
We can use count and for_each to deploy multiple resources, but using count with an ordered collection can create an index anti-pattern where removing one item unexpectedly changes other resource addresses.
You can use count to create some kind of resources under certain conditions, for example:
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.idbody = {
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.idbody = {
properties = {
addressPrefixes = ["10.0.1.0/24"]
}
}
response_export_values = []
}
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.location
or:
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:
count
depends_on
for_each
lifecycle
provider
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:
provider
count
for_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_on
lifecycle
The parameters of lifecycle block SHOULD show up in the following order:
create_before_destroy
ignore_changes
prevent_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:
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.
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.
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:
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 descriptionSHOULD precisely describe the input parameter’s purpose and the expected data type. descriptionSHOULD 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 = stringkey_vault_network_access = optional(string)
})
default = nulldescription = <<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: 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 variableSHOULD be declared as sensitive = true, otherwise you SHOULD extract sensitive field into separated variable block with sensitive = true.
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.
MAPOTF removes redundant explicit nullable = true. That formatting cleanup does not change this requirement and does not imply that a collection is semantically safe to make nullable.
nullable = trueMUST be avoided. MAPOTF removes redundant explicit nullable = true; this cleanup is distinct from, and does not satisfy, the requirement to set nullable = false where a meaningful zero value exists.
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.retry and var.timeouts (per TFFR7) — null means “do not emit a retry/timeouts block; use the AzAPI provider defaults”.
var.lock (per the AVM lock interface) — null means “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 implicit nullable = true is permitted only for this purpose).
State explicitly in its description what null means.
Be consumed with a null-aware pattern (e.g. count = var.lock != null ? 1 : 0, or dynamic "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.
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}
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 variableMUST 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 = stringdefault = nulldescription = "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.tfSHOULD be performed during a major version release.
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 standard Terraform TFLint plugin validates the requirement; MAPOTF keeps it first.
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.
ID: TFNFR26 - Category: Code Style - Providers in required_providers
The terraform block in terraform.tfMUST contain the required_providers block.
Each provider used directly in the module MUST be specified with the source and version properties. The standard Terraform TFLint plugin validates the used-provider source and version requirements. MAPOTF sorts the required_providers entries alphabetically.
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 declare its requirements in its own terraform.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.
By rule, every published AVM module and submodule MUST NOT declare a provider block. Provider configuration belongs exclusively to the consuming root module.
When a module requires an alternate provider instance, it MUST declare that alias through configuration_aliases in terraform.required_providers and the consumer MUST pass the configured alias through the module’s providers map. A provider block containing only alias is not permitted in an AVM module.
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.
This specification applies only to existing legacy modules that still use AzureRM while they are being migrated. It does not apply to a new module that uses AzureRM solely for the narrow unsupported data-plane/non-ARM API exception in TFFR3, because that exception does not permit AzureRM resource-group management.
In a legacy AzureRM module, the prevent_deletion_if_contains_resources provider setting SHOULD be set to false until the module is migrated. Azure Policy remediation can add resources during a test run, and the provider’s default behavior can then prevent cleanup of the test resource group.
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.tfMAY 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, or locals) as the prefix, followed by a ., a short descriptive topic name, and the .tf extension — for example main.diagnostic_settings.tf, variables.diagnostic_settings.tf, outputs.diagnostic_settings.tf, locals.diagnostic_settings.tf.
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, and locals.private_endpoints.tf all relate to the same feature.
Each split file MUST contain only the block kind matching its prefix:
main.<topic>.tf — only resource, data, and module blocks.
variables.<topic>.tf — only variable blocks.
outputs.<topic>.tf — only output blocks.
locals.<topic>.tf — only locals blocks.
The terraform { … } block MUST appear exactly once per module, in terraform.tf. It MUST NOT be split.
Files that MUST NOT appear at the module root
A providers.tf file — provider requirements belong in terraform.tf; provider configurations belong only in the consumer’s root module, never in an AVM module (per SFR2).
A single monolithic module.tf or everything.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.
MAPOTF places top-level blocks in their canonical files. The terraform_tf_file rule validates the single terraform block requirement.
Notes
Submodules (per TFRMNFR1) follow the same layout in their own root directory under modules/<subresource>/. The submodule’s terraform.tfMUST declare the same set of required_providers it 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.
Structured values that are passed as JSON or YAML MUST be constructed with jsonencode or yamlencode, rather than a literal JSON or YAML heredoc. Native HCL objects, lists, conditionals, and for expressions keep the structure reviewable and let Terraform perform correct escaping.
Terraform interpolation (${...}), template directives (%{...}), unknown values, and dynamically generated lists or maps are not exceptions: construct the native HCL value and pass it to the encoder.
A heredoc MAY be used only when the value is not JSON or YAML, or when the receiving system requires opaque source text for a downstream templating engine or syntax that jsonencode or yamlencode cannot represent without changing its meaning. The heredoc must not use Terraform interpolation to assemble JSON or YAML in that case, and its reason must be clear from the surrounding configuration.
ID: TFNFR41 - Category: Code Style - Output Definition Order
output blocks in a module SHOULD be ordered alphabetically by output name. This applies to outputs.tf and every outputs.<topic>.tf file in the root module and each submodule.
output"id" {
value = azapi_resource.this.id}
output"name" {
value = azapi_resource.this.name}
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
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 = {
forkey, valueinazapi_resource.mychildresource:key => value.output.properties.foo }
}# Output of a sensitive attribute
output"bar" {
description = "MyResource bar attribute"value = azapi_resource.myresource.output.properties.barsensitive = true}
TFFR6, TFFR7, and TFFR8 apply independently to each module and submodule scope. Together they require resource_types, retry, timeouts, and ignore_body_changes only when that scope directly declares at least one managed resource block of a supported AzAPI type:
azapi_resource
azapi_data_plane_resource
azapi_resource_action
azapi_update_resource
A provider declaration alone, AzAPI data sources alone (including data "azapi_client_config" and data "azapi_resource"), or supported AzAPI resources declared only inside a child module do not trigger these requirements in the parent scope. Each submodule is evaluated independently and triggers when it directly declares a supported block. A count or for_each condition does not exempt a directly declared block.
Within an applicable scope, authors MUST NOT hard-code the type argument of a supported AzAPI 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_types name 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 the type argument.
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 be this, 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 a map(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 that directly declares a supported AzAPI resource and therefore exposes its own resource_types variable (see TFRMNFR1). The shape of the nested object MUST match that submodule’s own resource_types variable exactly. The parent MUST NOT repeat the submodule’s defaults — the inner string attributes are declared as optional(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:
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):
These requirements are enforced by retry and timeouts.
Applicability
TFFR6, TFFR7, and TFFR8 apply independently to each module and submodule scope. Together they require resource_types, retry, timeouts, and ignore_body_changes only when that scope directly declares at least one managed resource block of a supported AzAPI type:
azapi_resource
azapi_data_plane_resource
azapi_resource_action
azapi_update_resource
A provider declaration alone, AzAPI data sources alone (including data "azapi_client_config" and data "azapi_resource"), or supported AzAPI resources declared only inside a child module do not trigger these requirements in the parent scope. Each submodule is evaluated independently and triggers when it directly declares a supported block. A count or for_each condition does not exempt a directly declared block.
Within an applicable scope, the retry and timeouts blocks of every supported AzAPI resource 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 AzAPI retry block.
timeouts — an object variable controlling the AzAPI timeouts block.
Diff suppression via the AzAPI ignore_body_changes argument is covered separately by TFFR8, because its values are scoped to a single resource’s body and therefore MUST NOT be cascaded to submodules unchanged.
Both variables:
MAY define module-level defaults (e.g., a default error_message_regex such as "ScopeLocked" for resources that race with lock removal, or a default delete = "5m").
MUST allow the consumer to override the defaults — either by supplying a non-null value at the variable level, or by allowing per-field overrides through optional(...) attributes.
MUST be applied to every azapi_resource (and equivalent AzAPI resources) declared by the module.
MUST cascade to applicable submodules — the parent module’s retry and timeouts values MUST be passed through to each submodule it instantiates that directly declares a supported AzAPI resource (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 = nulldescription = <<DESCRIPTIONRetry configuration applied to every supported AzAPI resource declared by the module and its applicable 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 = nulldescription = <<DESCRIPTIONDefault per-operation timeouts applied to every supported AzAPI resource declared by the module and its applicable submodules. 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_widgetsname = var.nameparent_id = var.parent_idbody = { /* ... */ } # `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.retrydynamic"timeouts" {
for_each = var.timeouts ==null? [] : [var.timeouts]
content {
create = timeouts.value.createread = timeouts.value.readupdate = timeouts.value.updatedelete = timeouts.value.delete }
}
response_export_values = []
}
module"child" {
source = "./modules/child" # Cascade retry and timeouts to the submodule.
retry = var.retrytimeouts = var.timeouts # ...other arguments...
}
TFFR6, TFFR7, and TFFR8 apply independently to each module and submodule scope. Together they require resource_types, retry, timeouts, and ignore_body_changes only when that scope directly declares at least one managed resource block of a supported AzAPI type:
azapi_resource
azapi_data_plane_resource
azapi_resource_action
azapi_update_resource
A provider declaration alone, AzAPI data sources alone (including data "azapi_client_config" and data "azapi_resource"), or supported AzAPI resources declared only inside a child module do not trigger these requirements in the parent scope. Each submodule is evaluated independently and triggers when it directly declares a supported block. A count or for_each condition does not exempt a directly declared block.
Within an applicable scope, the ignore_body_changes argument of every supported AzAPI resource MUST be configurable by the consumer. Authors MUST NOT hard-code an inline list that the consumer cannot override, and MUST NOT omit the argument.
To meet this requirement, every applicable module or submodule (see TFRMNFR1) MUST expose a variable named ignore_body_changes.
ignore_body_changes lets a consumer suppress plan diffs for a set of body paths that are mutated outside Terraform (for example tags applied by Azure Policy, or an autoscaler adjusting a capacity property). It is the supported fallback for lifecycle.ignore_changes when the paths must be derived from variables, locals or other non-static values, which lifecycle blocks cannot accept.
Without this variable a consumer has no way to reach the argument, because lifecycle.ignore_changes cannot be applied to a resource from outside the module that declares it. This is exactly the same problem that TFFR7 solves for retry and timeouts.
The module’s Azure/azapi constraint in required_providersMUST allow v2.12.0 or later, which is the release that introduces the argument (see TFFR3).
A consumer supplying a non-empty value MUST be running Terraform 1.11 or later. Modules MUST NOT raise their required_version floor for this reason alone (see TFNFR25); instead they MUST emit null when the list is empty so that consumers on earlier Terraform versions who do not use the feature are unaffected. See Applying the variable.
Important
Because the value is held in provider-private state, a change to ignore_body_changes only takes effect after an apply. A consumer who adds a path will still see the pending diff for that path in the same plan, and a consumer who removes a path will not see the suppressed diff reappear until the next plan. Module documentation SHOULD call this out.
Variable shape
Unlike retry and timeouts, which are resource-agnostic and therefore cascade unchanged, ignore_body_changes values are dot-notation paths into one specific resource’sbody. A path such as properties.addressSpace is meaningful only for the resource that owns it, so passing a parent’s list straight through to a submodule would apply meaningless paths to a different resource.
The variable is therefore scoped per resource and per submodule, using exactly the same shape and key-naming rule as resource_types (TFFR6).
The ignore_body_changes variable MUST:
Be a single object({...}) (not a map(list(string))) so typos at call sites error at plan time and the full override surface is visible in the variable declaration.
Default the variable itself to {} and be nullable = false, per TFNFR20 and TFNFR21.
Declare one optional(list(string), []) field for every AzAPI resource the module itself declares, keyed by the snake_case form of the ARM resource type with the Microsoft. prefix dropped — the identical key used in resource_types (for example Microsoft.Example/widgets → example_widgets).
Declare one nested optional(object({...}), {}) field for every submodule the module instantiates that directly declares a supported AzAPI resource, keyed by that submodule’s primary ARM resource type. The shape of the nested object MUST match that submodule’s own ignore_body_changes variable exactly, and the parent MUST cascade the slot through unchanged.
Document every field in the variable’s description, including what ignore_body_changes does, that paths use dot notation, and that changes take effect only after an apply.
Module owners MAY ship module-level defaults where the resource is known to be mutated outside Terraform. To do so, supply the default inside the optional(list(string), [...]) wrapper. Consumers MUST still be able to override any individual field, and a module-level default MUST NOT be used to work around a bug that belongs in the module body.
Modules MAY additionally expose per-item overrides on the collection variable that drives a for_each submodule, for cases where individual instances need different paths. Where they do, the per-item value MUST take precedence over the shared slot.
Path syntax
Values are dot-notation paths relative to the resource’s body, for example tags or properties.sku.name. Each element MUST be a non-empty string.
Individual list items MUST NOT be targeted (there is no index syntax) — ignore the entire list property instead.
Authors and consumers MUST understand that an ignored path is not merely hidden from the plan: configuration changes at that path are not sent to Azure until the path is removed from the list.
Applying the variable
ignore_body_changes is an attribute (not a block) on azapi_resource, so the relevant field of the variable is assigned directly. The assignment MUST collapse an empty list to null so that the write-only argument is absent when the feature is unused:
ID: TFFR9 - Category: Inputs/Outputs - AzAPI - Tag Propagation
Applicability
This requirement applies independently to every root module and submodule that directly declares a managed AzAPI resource. The azapi_resource_tag rule determines whether a resource type supports the tags argument from its embedded AVM-generated capability snapshot.
Requirement
For every statically supported resource type, the resource MUST set the standard AVM tags input exactly as follows:
resource"azapi_resource""this" {
type = var.resource_types.example_widgetstags = var.tags}
The assignment MUST NOT merge, conditionally replace, or otherwise transform var.tags at the resource declaration. Apply any approved tag shaping before assigning the standard input.
For every statically unsupported resource type, the resource MUST NOT set a tags argument. Do not use a conditional, dynamic value, or an empty map to force tags onto an unsupported type.
The validation skips dynamic or otherwise unevaluable type expressions to avoid false positives. Authors SHOULD keep resource types statically resolvable through var.resource_types as required by TFFR6.
The tags input and propagation behavior remain governed by the standard tags interface. The embedded AVM-generated capability snapshot, rather than a hand-maintained module allowlist or an AzAPI import, is the authority for deciding whether the argument is supported.
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), or list(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 each validation block self-contained and avoids requiring cross-variable validation.
For optional / nullable variables, the validation MUST short-circuit on null (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 with alltrue([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 null for optional nested attributes.
Where a variable can legitimately hold IDs of more than one resource type (rare — e.g. marketplace_partner_resource_id in 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 = stringnullable = falsevalidation {
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 = stringdefault = nullnullable = truevalidation {
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 = falsevalidation {
condition = alltrue([
foridin 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 = stringprivate_dns_zone_resource_ids = optional(set(string), []) # ...other attributes...
}))
default = {}
nullable = falsevalidation {
condition = alltrue([
for_, vin 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_, vin var.private_endpoints: [
foridinv.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_id errors when (a) the input is not a well-formed ARM ID, or (b) the input does not parse as the supplied resource type. Wrapping in can(...) converts both failure modes into a single boolean suitable for a validation block’s condition.
This rule supersedes any older guidance suggesting startswith(var.x, "/") or hand-written regex for resource ID validation.
Testing
The content below is listed based on the following tags
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
To see a directory and file structure for a module, see the language specific contribution guide.
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.
➕ Terraform & Bicep Log Analytics Workspace examples using simple/native declarations for use in E2E tests
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.
Details for use in E2E tests
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>).
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.
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.
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.
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.
README documentation MUST be automatically/programmatically generated. MUST include the sections as defined in the language specific requirements BCPNFR2, TFNFR2.
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 = stringparam2 = 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 }
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.
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 Version
Y == Minor Version
Z == Patch Version
Module versioning before first Major version release 1.0.0
Initially modules MUST be released as version 0.1.0 and 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 version 1.0.0.
Note
Releasing as version 0.1.0 initially 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.0 is released, given a version number X.Y.Z:
X Major version MUST NOT be bumped.
Y Minor version MUST be bumped when introducing breaking changes (which would normally bump Major after 1.0.0 release) or feature updates (same as it will be after 1.0.0 release).
Z Patch version MUST be bumped when introducing non-breaking, backward compatible bug fixes (same as it will be after 1.0.0 release).
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: 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.
Terraform Resource Module Specifications
Contribution / Support
The content below is listed based on the following tags
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>
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 the avm-technical-reviewers-bicep parent 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):
Access management for Terraform repositories is governed centrally through Microsoft Entra. Module owner access is granted via an Entra access package — it is no longer managed through a per-module GitHub team or the legacy Core Identity entitlement.
All module owners MUST request access via the Azure Verified Modules (AVM) Module Contributors Entra access package:
Once approved, you are added to the azure-verified-modules-module-contributors Entra group, which is the source of truth for who is authorized to own and approve changes on AVM Terraform module repositories. Day-to-day repository access is then granted through this group together with just-in-time (JIT) elevation.
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.
```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/ to request admin access before running the script.
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 -ne0) {
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 -ne0) {
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 alreadyif ($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 labelsif ($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 alreadyif ($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 repositoryif ($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
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
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 the telemetry guidance for more details.
Telemetry Information Notice
Note
The following information notice is automatically added at the bottom of the README.md file of the module when
Terraform: Running avm pre-commit with the note and header ## Data Collection placed in the module’s _footer.md beforehand
### 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 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-
<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:
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: Terraform uses a telemetry provider, the configuration of which is the same for every module and is included in the template repo.
General: See the language specific contribution guides for detailed guidance and sample code to use in AVM modules to achieve this requirement.
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 main.telemetry.tf, which is generated and maintained by Avm.Authoring.
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.
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
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., is 2021-05-01-preview
Otherwise the latest “non-preview” version of the API SHOULD be used
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”
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:
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.
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.
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.
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.
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.
Tip
Make sure to checkout the language specific specifications for more info on this:
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:
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 Notes section of the module’s readme.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.”
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-machine or avm/res/managed-identity/user-assigned-identity
Segments:
res defines this is a resource module
<hyphenated resource provider name> is the resource provider’s name after the Microsoft part, 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 - since trafficmanagerprofiles is 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/subnet or avm/res/storage/storage-account/blob-service/container
Segments:
res defines this is a resource module
<hyphenated resource provider name> is the resource provider’s name after the Microsoft part, 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 = subnet or Microsoft.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-virtualmachine or avm-res-managedidentity-userassignedidentity
Segments:
<provider> is a legacy requirement of the Terraform registry. This must be set to azure
res defines this is a resource module
<resource provider> is the resource provider’s name after the Microsoft part, 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.
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.,
Every new AVM Terraform module — resource, pattern, or utility — MUST use Azure/azapi for every Azure control-plane resource and every data-plane operation supported by AzAPI. The AzureRM provider is permitted only for the unsupported data-plane/non-ARM API exception defined below.
Authors MUST only use the following Azure providers, and versions, in their modules:
provider
min version
max version
permitted use
Azure/azapi
>= 2.12
< 3.0
All Azure control-plane resources and supported data-plane operations
hashicorp/azurerm
>= 4.0
< 5.0
Only a specific unsupported data-plane/non-ARM API operation under the exception below
Note
The AzAPI floor is 2.12 because TFFR8 requires every module to expose the ignore_body_changes argument, which was introduced in Azure/azapi v2.12.0. Modules pinned below that version will fail to plan because the argument is absent from the provider schema.
This prohibition applies to every Terraform configuration shipped with the module, including:
The root module and all submodules.
Every configuration under examples/, including examples executed as end-to-end tests.
Terraform tests, test fixtures, and supporting setup configurations.
Terraform snippets in _header.md, _footer.md, generated documentation, and other repository documentation.
Supporting control-plane resources needed by an example, end-to-end test, or fixture MUST use AzAPI. AzureRM MUST NOT be used for resource groups, role assignments, monitoring resources, networking, or any other ARM control-plane resource.
Exception — unsupported data-plane/non-ARM API operations
An AVM Terraform module that is otherwise built with AzAPI MAY declare the AzureRM provider only for a specific data-plane or non-ARM API operation whose functionality is genuinely unavailable through azapi_data_plane_resource, azapi_resource, azapi_resource_action, or azapi_update_resource. This exception is intended for isolated operations such as a data-plane resource whose AzureRM implementation calls a service endpoint rather than Azure Resource Manager. It is not a general fallback for a missing or inconvenient AzAPI schema. Every azurerm_* block MUST independently satisfy this exception; one permitted block does not authorize any other AzureRM use.
Where this exception applies, the module MUST:
Continue to declare and use AzAPI as its required, primary Azure provider.
Scope every azurerm_* resource or data source to the exact unsupported data-plane/non-ARM operation.
Pin the AzureRM provider to ~> 4.0 in required_providers.
Use AzAPI for every control-plane resource and every data-plane operation that AzAPI supports.
Document the exception in the module’s README.md, including each azurerm_* block, the data-plane/non-ARM API it wraps, why AzAPI cannot implement it, and the upstream AzAPI issue or pull request tracking support.
Replace the azurerm_* block with AzAPI in the next module release after the required capability ships.
Examples, end-to-end tests, Terraform tests, fixtures, and documentation snippets MAY configure or exercise AzureRM only when required by that exact permitted data-plane operation. All supporting control-plane resources in those surfaces MUST use AzAPI.
This exception MUST NOT be used to:
Implement any ARM control-plane resource.
Avoid AzAPI because its body schema is more verbose or less convenient.
Avoid raising an AzAPI capability gap for an unsupported control-plane operation.
Side-step any AzAPI-specific specification that applies to the module’s AzAPI resources.
The azurerm remote state backend and the final segment of a published Terraform Registry module address, such as /azurerm in an existing AVM module source, are names and are not provider declarations. They MAY appear where required for state storage or to reference an existing published AVM module. A dependency’s provider implementation is governed by that dependency’s own repository; its Registry address does not by itself justify a direct hashicorp/azurerm declaration or azurerm_* block in the consuming module repository. Any such direct use MUST independently satisfy the data-plane exception above.
Authors MUST use the required_providers block in their module to enforce the provider versions.
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
}
Authors MUST omit replace_triggers_refs when no body properties require replacement. When one or more body properties require replacement, authors MUST set replace_triggers_refs to a non-empty static list of JMESPath expressions that identify those paths.
Each expression MUST be valid JMESPath syntax, non-blank, and unique within the list. Do not include name or location, as AzAPI already replaces the resource when either changes. When the resource body is statically evaluable, every declared expression MUST resolve against that body.
This is to ensure that changes to properties that require replacement of the resource are handled correctly by Terraform. Authors remain responsible for identifying every property that actually requires replacement. Current Bicep-generated schemas do not reliably preserve whether a property is create-only or updateable, so the rule validates declared paths but cannot prove that the list is semantically complete.
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 in a direct modules/<subresource-singular-name>/ child directory at the repository root, where <subresource-singular-name> is the singular form of the ARM subresource name as per PMNFR1. Nested Terraform module roots are prohibited: modules/<name>/modules/<name>/ is not an AVM module scope.
Terraform example roots follow the same one-layer convention: each example MUST be a direct examples/<name>/ child directory. Nested example roots are prohibited.
Avm.Authoring convention validation enforces the direct modules/* and examples/* scope structure. Consequently, directory-specific TFLint overrides apply only at those direct roots; see TFLint configuration overrides.
For example, a resource module for Microsoft.Example/widgets would have the following layout:
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), passes retry and timeouts through unchanged (see TFFR7), and cascades the matching nested slot from its own ignore_body_changes (see TFFR8):
When the ARM subresource type is more than one level deep (for example Microsoft.Example/widgets/parts/components), its Terraform module root still MUST be a direct child of modules/. Use a descriptive direct name such as modules/part-component/; do not create modules/part/modules/component/. The parent module composes all direct submodules and exposes the required nested interface values without creating nested Terraform roots.
The following pattern is NOT allowed inside a submodule, because it pushes cardinality into the submodule itself:
Submodules MAY reference a direct sibling submodule using a relative path:
# Inside modules/part/main.tf, calling the direct sibling 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, while preserving the required one-layer module-root layout.
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.mdMUST:
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 is mandatory for every control-plane resource and supported data-plane operation in every module and submodule; AzureRM is permitted only for the documented unsupported data-plane/non-ARM API exception.
TFFR6 — resource_types variable. Each submodule declares its own resource_types for the resources it owns; the parent declares a nested optional(object({...}), {}) slot per submodule that mirrors the submodule’s variable exactly, and cascades it through unchanged.
TFFR7 — retry and timeouts variables, which the parent module MUST cascade to each submodule unchanged.
TFFR8 — ignore_body_changes variable. Each submodule declares its own for the resources it owns; the parent declares a nested optional(object({...}), {}) slot per submodule that mirrors the submodule’s variable exactly, and cascades it through unchanged. The parent’s own paths MUST NOT be cascaded, because they are scoped to the parent’s body.
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.
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.
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_resource declarations 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_resource would, on its own, be a meaningful handle for downstream consumers (i.e. there is no resource whose id would be the obvious value of a single canonical resource_id output).
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.mdMUST 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 write module.<submodule>.resource_id.
The rule does not apply to data sources or to azapi_resource_list lookups; those SHOULD still be named after what they represent.
Code Style
The content below is listed based on the following tags
We can use count and for_each to deploy multiple resources, but using count with an ordered collection can create an index anti-pattern where removing one item unexpectedly changes other resource addresses.
You can use count to create some kind of resources under certain conditions, for example:
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.idbody = {
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.idbody = {
properties = {
addressPrefixes = ["10.0.1.0/24"]
}
}
response_export_values = []
}
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.location
or:
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:
count
depends_on
for_each
lifecycle
provider
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:
provider
count
for_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_on
lifecycle
The parameters of lifecycle block SHOULD show up in the following order:
create_before_destroy
ignore_changes
prevent_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:
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.
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.
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:
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 descriptionSHOULD precisely describe the input parameter’s purpose and the expected data type. descriptionSHOULD 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 = stringkey_vault_network_access = optional(string)
})
default = nulldescription = <<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: 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 variableSHOULD be declared as sensitive = true, otherwise you SHOULD extract sensitive field into separated variable block with sensitive = true.
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.
MAPOTF removes redundant explicit nullable = true. That formatting cleanup does not change this requirement and does not imply that a collection is semantically safe to make nullable.
nullable = trueMUST be avoided. MAPOTF removes redundant explicit nullable = true; this cleanup is distinct from, and does not satisfy, the requirement to set nullable = false where a meaningful zero value exists.
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.retry and var.timeouts (per TFFR7) — null means “do not emit a retry/timeouts block; use the AzAPI provider defaults”.
var.lock (per the AVM lock interface) — null means “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 implicit nullable = true is permitted only for this purpose).
State explicitly in its description what null means.
Be consumed with a null-aware pattern (e.g. count = var.lock != null ? 1 : 0, or dynamic "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.
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}
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 variableMUST 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 = stringdefault = nulldescription = "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.tfSHOULD be performed during a major version release.
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 standard Terraform TFLint plugin validates the requirement; MAPOTF keeps it first.
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.
ID: TFNFR26 - Category: Code Style - Providers in required_providers
The terraform block in terraform.tfMUST contain the required_providers block.
Each provider used directly in the module MUST be specified with the source and version properties. The standard Terraform TFLint plugin validates the used-provider source and version requirements. MAPOTF sorts the required_providers entries alphabetically.
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 declare its requirements in its own terraform.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.
By rule, every published AVM module and submodule MUST NOT declare a provider block. Provider configuration belongs exclusively to the consuming root module.
When a module requires an alternate provider instance, it MUST declare that alias through configuration_aliases in terraform.required_providers and the consumer MUST pass the configured alias through the module’s providers map. A provider block containing only alias is not permitted in an AVM module.
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.
This specification applies only to existing legacy modules that still use AzureRM while they are being migrated. It does not apply to a new module that uses AzureRM solely for the narrow unsupported data-plane/non-ARM API exception in TFFR3, because that exception does not permit AzureRM resource-group management.
In a legacy AzureRM module, the prevent_deletion_if_contains_resources provider setting SHOULD be set to false until the module is migrated. Azure Policy remediation can add resources during a test run, and the provider’s default behavior can then prevent cleanup of the test resource group.
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.tfMAY 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, or locals) as the prefix, followed by a ., a short descriptive topic name, and the .tf extension — for example main.diagnostic_settings.tf, variables.diagnostic_settings.tf, outputs.diagnostic_settings.tf, locals.diagnostic_settings.tf.
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, and locals.private_endpoints.tf all relate to the same feature.
Each split file MUST contain only the block kind matching its prefix:
main.<topic>.tf — only resource, data, and module blocks.
variables.<topic>.tf — only variable blocks.
outputs.<topic>.tf — only output blocks.
locals.<topic>.tf — only locals blocks.
The terraform { … } block MUST appear exactly once per module, in terraform.tf. It MUST NOT be split.
Files that MUST NOT appear at the module root
A providers.tf file — provider requirements belong in terraform.tf; provider configurations belong only in the consumer’s root module, never in an AVM module (per SFR2).
A single monolithic module.tf or everything.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.
MAPOTF places top-level blocks in their canonical files. The terraform_tf_file rule validates the single terraform block requirement.
Notes
Submodules (per TFRMNFR1) follow the same layout in their own root directory under modules/<subresource>/. The submodule’s terraform.tfMUST declare the same set of required_providers it 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.
Structured values that are passed as JSON or YAML MUST be constructed with jsonencode or yamlencode, rather than a literal JSON or YAML heredoc. Native HCL objects, lists, conditionals, and for expressions keep the structure reviewable and let Terraform perform correct escaping.
Terraform interpolation (${...}), template directives (%{...}), unknown values, and dynamically generated lists or maps are not exceptions: construct the native HCL value and pass it to the encoder.
A heredoc MAY be used only when the value is not JSON or YAML, or when the receiving system requires opaque source text for a downstream templating engine or syntax that jsonencode or yamlencode cannot represent without changing its meaning. The heredoc must not use Terraform interpolation to assemble JSON or YAML in that case, and its reason must be clear from the surrounding configuration.
ID: TFNFR41 - Category: Code Style - Output Definition Order
output blocks in a module SHOULD be ordered alphabetically by output name. This applies to outputs.tf and every outputs.<topic>.tf file in the root module and each submodule.
output"id" {
value = azapi_resource.this.id}
output"name" {
value = azapi_resource.this.name}
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
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.
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)
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 = {
forkey, valueinazapi_resource.mychildresource:key => value.output.properties.foo }
}# Output of a sensitive attribute
output"bar" {
description = "MyResource bar attribute"value = azapi_resource.myresource.output.properties.barsensitive = true}
TFFR6, TFFR7, and TFFR8 apply independently to each module and submodule scope. Together they require resource_types, retry, timeouts, and ignore_body_changes only when that scope directly declares at least one managed resource block of a supported AzAPI type:
azapi_resource
azapi_data_plane_resource
azapi_resource_action
azapi_update_resource
A provider declaration alone, AzAPI data sources alone (including data "azapi_client_config" and data "azapi_resource"), or supported AzAPI resources declared only inside a child module do not trigger these requirements in the parent scope. Each submodule is evaluated independently and triggers when it directly declares a supported block. A count or for_each condition does not exempt a directly declared block.
Within an applicable scope, authors MUST NOT hard-code the type argument of a supported AzAPI 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_types name 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 the type argument.
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 be this, 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 a map(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 that directly declares a supported AzAPI resource and therefore exposes its own resource_types variable (see TFRMNFR1). The shape of the nested object MUST match that submodule’s own resource_types variable exactly. The parent MUST NOT repeat the submodule’s defaults — the inner string attributes are declared as optional(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:
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):
These requirements are enforced by retry and timeouts.
Applicability
TFFR6, TFFR7, and TFFR8 apply independently to each module and submodule scope. Together they require resource_types, retry, timeouts, and ignore_body_changes only when that scope directly declares at least one managed resource block of a supported AzAPI type:
azapi_resource
azapi_data_plane_resource
azapi_resource_action
azapi_update_resource
A provider declaration alone, AzAPI data sources alone (including data "azapi_client_config" and data "azapi_resource"), or supported AzAPI resources declared only inside a child module do not trigger these requirements in the parent scope. Each submodule is evaluated independently and triggers when it directly declares a supported block. A count or for_each condition does not exempt a directly declared block.
Within an applicable scope, the retry and timeouts blocks of every supported AzAPI resource 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 AzAPI retry block.
timeouts — an object variable controlling the AzAPI timeouts block.
Diff suppression via the AzAPI ignore_body_changes argument is covered separately by TFFR8, because its values are scoped to a single resource’s body and therefore MUST NOT be cascaded to submodules unchanged.
Both variables:
MAY define module-level defaults (e.g., a default error_message_regex such as "ScopeLocked" for resources that race with lock removal, or a default delete = "5m").
MUST allow the consumer to override the defaults — either by supplying a non-null value at the variable level, or by allowing per-field overrides through optional(...) attributes.
MUST be applied to every azapi_resource (and equivalent AzAPI resources) declared by the module.
MUST cascade to applicable submodules — the parent module’s retry and timeouts values MUST be passed through to each submodule it instantiates that directly declares a supported AzAPI resource (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 = nulldescription = <<DESCRIPTIONRetry configuration applied to every supported AzAPI resource declared by the module and its applicable 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 = nulldescription = <<DESCRIPTIONDefault per-operation timeouts applied to every supported AzAPI resource declared by the module and its applicable submodules. 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_widgetsname = var.nameparent_id = var.parent_idbody = { /* ... */ } # `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.retrydynamic"timeouts" {
for_each = var.timeouts ==null? [] : [var.timeouts]
content {
create = timeouts.value.createread = timeouts.value.readupdate = timeouts.value.updatedelete = timeouts.value.delete }
}
response_export_values = []
}
module"child" {
source = "./modules/child" # Cascade retry and timeouts to the submodule.
retry = var.retrytimeouts = var.timeouts # ...other arguments...
}
TFFR6, TFFR7, and TFFR8 apply independently to each module and submodule scope. Together they require resource_types, retry, timeouts, and ignore_body_changes only when that scope directly declares at least one managed resource block of a supported AzAPI type:
azapi_resource
azapi_data_plane_resource
azapi_resource_action
azapi_update_resource
A provider declaration alone, AzAPI data sources alone (including data "azapi_client_config" and data "azapi_resource"), or supported AzAPI resources declared only inside a child module do not trigger these requirements in the parent scope. Each submodule is evaluated independently and triggers when it directly declares a supported block. A count or for_each condition does not exempt a directly declared block.
Within an applicable scope, the ignore_body_changes argument of every supported AzAPI resource MUST be configurable by the consumer. Authors MUST NOT hard-code an inline list that the consumer cannot override, and MUST NOT omit the argument.
To meet this requirement, every applicable module or submodule (see TFRMNFR1) MUST expose a variable named ignore_body_changes.
ignore_body_changes lets a consumer suppress plan diffs for a set of body paths that are mutated outside Terraform (for example tags applied by Azure Policy, or an autoscaler adjusting a capacity property). It is the supported fallback for lifecycle.ignore_changes when the paths must be derived from variables, locals or other non-static values, which lifecycle blocks cannot accept.
Without this variable a consumer has no way to reach the argument, because lifecycle.ignore_changes cannot be applied to a resource from outside the module that declares it. This is exactly the same problem that TFFR7 solves for retry and timeouts.
The module’s Azure/azapi constraint in required_providersMUST allow v2.12.0 or later, which is the release that introduces the argument (see TFFR3).
A consumer supplying a non-empty value MUST be running Terraform 1.11 or later. Modules MUST NOT raise their required_version floor for this reason alone (see TFNFR25); instead they MUST emit null when the list is empty so that consumers on earlier Terraform versions who do not use the feature are unaffected. See Applying the variable.
Important
Because the value is held in provider-private state, a change to ignore_body_changes only takes effect after an apply. A consumer who adds a path will still see the pending diff for that path in the same plan, and a consumer who removes a path will not see the suppressed diff reappear until the next plan. Module documentation SHOULD call this out.
Variable shape
Unlike retry and timeouts, which are resource-agnostic and therefore cascade unchanged, ignore_body_changes values are dot-notation paths into one specific resource’sbody. A path such as properties.addressSpace is meaningful only for the resource that owns it, so passing a parent’s list straight through to a submodule would apply meaningless paths to a different resource.
The variable is therefore scoped per resource and per submodule, using exactly the same shape and key-naming rule as resource_types (TFFR6).
The ignore_body_changes variable MUST:
Be a single object({...}) (not a map(list(string))) so typos at call sites error at plan time and the full override surface is visible in the variable declaration.
Default the variable itself to {} and be nullable = false, per TFNFR20 and TFNFR21.
Declare one optional(list(string), []) field for every AzAPI resource the module itself declares, keyed by the snake_case form of the ARM resource type with the Microsoft. prefix dropped — the identical key used in resource_types (for example Microsoft.Example/widgets → example_widgets).
Declare one nested optional(object({...}), {}) field for every submodule the module instantiates that directly declares a supported AzAPI resource, keyed by that submodule’s primary ARM resource type. The shape of the nested object MUST match that submodule’s own ignore_body_changes variable exactly, and the parent MUST cascade the slot through unchanged.
Document every field in the variable’s description, including what ignore_body_changes does, that paths use dot notation, and that changes take effect only after an apply.
Module owners MAY ship module-level defaults where the resource is known to be mutated outside Terraform. To do so, supply the default inside the optional(list(string), [...]) wrapper. Consumers MUST still be able to override any individual field, and a module-level default MUST NOT be used to work around a bug that belongs in the module body.
Modules MAY additionally expose per-item overrides on the collection variable that drives a for_each submodule, for cases where individual instances need different paths. Where they do, the per-item value MUST take precedence over the shared slot.
Path syntax
Values are dot-notation paths relative to the resource’s body, for example tags or properties.sku.name. Each element MUST be a non-empty string.
Individual list items MUST NOT be targeted (there is no index syntax) — ignore the entire list property instead.
Authors and consumers MUST understand that an ignored path is not merely hidden from the plan: configuration changes at that path are not sent to Azure until the path is removed from the list.
Applying the variable
ignore_body_changes is an attribute (not a block) on azapi_resource, so the relevant field of the variable is assigned directly. The assignment MUST collapse an empty list to null so that the write-only argument is absent when the feature is unused:
ID: TFFR9 - Category: Inputs/Outputs - AzAPI - Tag Propagation
Applicability
This requirement applies independently to every root module and submodule that directly declares a managed AzAPI resource. The azapi_resource_tag rule determines whether a resource type supports the tags argument from its embedded AVM-generated capability snapshot.
Requirement
For every statically supported resource type, the resource MUST set the standard AVM tags input exactly as follows:
resource"azapi_resource""this" {
type = var.resource_types.example_widgetstags = var.tags}
The assignment MUST NOT merge, conditionally replace, or otherwise transform var.tags at the resource declaration. Apply any approved tag shaping before assigning the standard input.
For every statically unsupported resource type, the resource MUST NOT set a tags argument. Do not use a conditional, dynamic value, or an empty map to force tags onto an unsupported type.
The validation skips dynamic or otherwise unevaluable type expressions to avoid false positives. Authors SHOULD keep resource types statically resolvable through var.resource_types as required by TFFR6.
The tags input and propagation behavior remain governed by the standard tags interface. The embedded AVM-generated capability snapshot, rather than a hand-maintained module allowlist or an AzAPI import, is the authority for deciding whether the argument is supported.
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), or list(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 each validation block self-contained and avoids requiring cross-variable validation.
For optional / nullable variables, the validation MUST short-circuit on null (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 with alltrue([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 null for optional nested attributes.
Where a variable can legitimately hold IDs of more than one resource type (rare — e.g. marketplace_partner_resource_id in 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 = stringnullable = falsevalidation {
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 = stringdefault = nullnullable = truevalidation {
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 = falsevalidation {
condition = alltrue([
foridin 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 = stringprivate_dns_zone_resource_ids = optional(set(string), []) # ...other attributes...
}))
default = {}
nullable = falsevalidation {
condition = alltrue([
for_, vin 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_, vin var.private_endpoints: [
foridinv.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_id errors when (a) the input is not a well-formed ARM ID, or (b) the input does not parse as the supplied resource type. Wrapping in can(...) converts both failure modes into a single boolean suitable for a validation block’s condition.
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_idMUST 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 = stringnullable = falsevalidation { # 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 = <<DESCRIPTIONThe 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}
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 via parent_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_id and 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 as resource_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 type string, required, and nullable = false.
Validate that parent_id is 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 description that any ARM resource ID is accepted because the module manages an extension resource.
Document the exception in the module’s README.md so reviewers immediately understand why the standard parse_resource_id validation is absent.
Testing
The content below is listed based on the following tags
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
To see a directory and file structure for a module, see the language specific contribution guide.
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.
➕ Terraform & Bicep Log Analytics Workspace examples using simple/native declarations for use in E2E tests
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.
Details for use in E2E tests
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>).
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.
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.
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.
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.
README documentation MUST be automatically/programmatically generated. MUST include the sections as defined in the language specific requirements BCPNFR2, TFNFR2.
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 = stringparam2 = 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 }
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.
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 Version
Y == Minor Version
Z == Patch Version
Module versioning before first Major version release 1.0.0
Initially modules MUST be released as version 0.1.0 and 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 version 1.0.0.
Note
Releasing as version 0.1.0 initially 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.0 is released, given a version number X.Y.Z:
X Major version MUST NOT be bumped.
Y Minor version MUST be bumped when introducing breaking changes (which would normally bump Major after 1.0.0 release) or feature updates (same as it will be after 1.0.0 release).
Z Patch version MUST be bumped when introducing non-breaking, backward compatible bug fixes (same as it will be after 1.0.0 release).
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: 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.
Terraform Utility Module Specifications
Contribution / Support
The content below is listed based on the following tags
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>
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 the avm-technical-reviewers-bicep parent 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):
Access management for Terraform repositories is governed centrally through Microsoft Entra. Module owner access is granted via an Entra access package — it is no longer managed through a per-module GitHub team or the legacy Core Identity entitlement.
All module owners MUST request access via the Azure Verified Modules (AVM) Module Contributors Entra access package:
Once approved, you are added to the azure-verified-modules-module-contributors Entra group, which is the source of truth for who is authorized to own and approve changes on AVM Terraform module repositories. Day-to-day repository access is then granted through this group together with just-in-time (JIT) elevation.
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.
```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/ to request admin access before running the script.
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 -ne0) {
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 -ne0) {
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 alreadyif ($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 labelsif ($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 alreadyif ($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 repositoryif ($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
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
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 the telemetry guidance for more details.
Telemetry Information Notice
Note
The following information notice is automatically added at the bottom of the README.md file of the module when
Terraform: Running avm pre-commit with the note and header ## Data Collection placed in the module’s _footer.md beforehand
### 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 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-
<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:
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: Terraform uses a telemetry provider, the configuration of which is the same for every module and is included in the template repo.
General: See the language specific contribution guides for detailed guidance and sample code to use in AVM modules to achieve this requirement.
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 main.telemetry.tf, which is generated and maintained by Avm.Authoring.
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.
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
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., is 2021-05-01-preview
Otherwise the latest “non-preview” version of the API SHOULD be used
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”
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:
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.
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.
Utility 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.
Example: avm/utl/general/get-environment or avm/utl/types/avm-common-types
Segments:
utl defines this as a utility module
<hyphenated grouping/category name> is a hierarchical grouping of utility modules by category, with each word separated by dashes, such as: general or types
<hyphenated utility module name> is a term describing the module’s function, with each word separated by dashes, e.g., get-environment = to get environmental details; avm-common-types = to use common types.
Terraform Utility Module Naming
Naming convention:
avm-utl-<utility module name> (Module name for registry)
terraform-<provider>-avm-utl-<utility module name> (GitHub repository name to meet registry naming requirements)
Example: avm-utl-sku-finder or avm-utl-naming
Segments:
<provider> is a legacy requirement of the Terraform registry. For AVM Terraform utility modules this MUST be set to azure (for example Azure/avm-utl-naming/azure). Older utility modules may still use the azurerm or azuread segments. These segments are names only and do not permit use of the AzureRM provider; TFFR3 still requires every module to be built with AzAPI.
utl defines this as a utility module
<utility module name> is a term describing the module’s function, e.g., sku-finder = to find available SKUs; naming = to handle naming conventions.
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.,
Every new AVM Terraform module — resource, pattern, or utility — MUST use Azure/azapi for every Azure control-plane resource and every data-plane operation supported by AzAPI. The AzureRM provider is permitted only for the unsupported data-plane/non-ARM API exception defined below.
Authors MUST only use the following Azure providers, and versions, in their modules:
provider
min version
max version
permitted use
Azure/azapi
>= 2.12
< 3.0
All Azure control-plane resources and supported data-plane operations
hashicorp/azurerm
>= 4.0
< 5.0
Only a specific unsupported data-plane/non-ARM API operation under the exception below
Note
The AzAPI floor is 2.12 because TFFR8 requires every module to expose the ignore_body_changes argument, which was introduced in Azure/azapi v2.12.0. Modules pinned below that version will fail to plan because the argument is absent from the provider schema.
This prohibition applies to every Terraform configuration shipped with the module, including:
The root module and all submodules.
Every configuration under examples/, including examples executed as end-to-end tests.
Terraform tests, test fixtures, and supporting setup configurations.
Terraform snippets in _header.md, _footer.md, generated documentation, and other repository documentation.
Supporting control-plane resources needed by an example, end-to-end test, or fixture MUST use AzAPI. AzureRM MUST NOT be used for resource groups, role assignments, monitoring resources, networking, or any other ARM control-plane resource.
Exception — unsupported data-plane/non-ARM API operations
An AVM Terraform module that is otherwise built with AzAPI MAY declare the AzureRM provider only for a specific data-plane or non-ARM API operation whose functionality is genuinely unavailable through azapi_data_plane_resource, azapi_resource, azapi_resource_action, or azapi_update_resource. This exception is intended for isolated operations such as a data-plane resource whose AzureRM implementation calls a service endpoint rather than Azure Resource Manager. It is not a general fallback for a missing or inconvenient AzAPI schema. Every azurerm_* block MUST independently satisfy this exception; one permitted block does not authorize any other AzureRM use.
Where this exception applies, the module MUST:
Continue to declare and use AzAPI as its required, primary Azure provider.
Scope every azurerm_* resource or data source to the exact unsupported data-plane/non-ARM operation.
Pin the AzureRM provider to ~> 4.0 in required_providers.
Use AzAPI for every control-plane resource and every data-plane operation that AzAPI supports.
Document the exception in the module’s README.md, including each azurerm_* block, the data-plane/non-ARM API it wraps, why AzAPI cannot implement it, and the upstream AzAPI issue or pull request tracking support.
Replace the azurerm_* block with AzAPI in the next module release after the required capability ships.
Examples, end-to-end tests, Terraform tests, fixtures, and documentation snippets MAY configure or exercise AzureRM only when required by that exact permitted data-plane operation. All supporting control-plane resources in those surfaces MUST use AzAPI.
This exception MUST NOT be used to:
Implement any ARM control-plane resource.
Avoid AzAPI because its body schema is more verbose or less convenient.
Avoid raising an AzAPI capability gap for an unsupported control-plane operation.
Side-step any AzAPI-specific specification that applies to the module’s AzAPI resources.
The azurerm remote state backend and the final segment of a published Terraform Registry module address, such as /azurerm in an existing AVM module source, are names and are not provider declarations. They MAY appear where required for state storage or to reference an existing published AVM module. A dependency’s provider implementation is governed by that dependency’s own repository; its Registry address does not by itself justify a direct hashicorp/azurerm declaration or azurerm_* block in the consuming module repository. Any such direct use MUST independently satisfy the data-plane exception above.
Authors MUST use the required_providers block in their module to enforce the provider versions.
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
}
Authors MUST omit replace_triggers_refs when no body properties require replacement. When one or more body properties require replacement, authors MUST set replace_triggers_refs to a non-empty static list of JMESPath expressions that identify those paths.
Each expression MUST be valid JMESPath syntax, non-blank, and unique within the list. Do not include name or location, as AzAPI already replaces the resource when either changes. When the resource body is statically evaluable, every declared expression MUST resolve against that body.
This is to ensure that changes to properties that require replacement of the resource are handled correctly by Terraform. Authors remain responsible for identifying every property that actually requires replacement. Current Bicep-generated schemas do not reliably preserve whether a property is create-only or updateable, so the rule validates declared paths but cannot prove that the list is semantically complete.
We can use count and for_each to deploy multiple resources, but using count with an ordered collection can create an index anti-pattern where removing one item unexpectedly changes other resource addresses.
You can use count to create some kind of resources under certain conditions, for example:
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.idbody = {
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.idbody = {
properties = {
addressPrefixes = ["10.0.1.0/24"]
}
}
response_export_values = []
}
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.location
or:
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:
count
depends_on
for_each
lifecycle
provider
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:
provider
count
for_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_on
lifecycle
The parameters of lifecycle block SHOULD show up in the following order:
create_before_destroy
ignore_changes
prevent_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:
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.
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.
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:
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 descriptionSHOULD precisely describe the input parameter’s purpose and the expected data type. descriptionSHOULD 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 = stringkey_vault_network_access = optional(string)
})
default = nulldescription = <<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: 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 variableSHOULD be declared as sensitive = true, otherwise you SHOULD extract sensitive field into separated variable block with sensitive = true.
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.
MAPOTF removes redundant explicit nullable = true. That formatting cleanup does not change this requirement and does not imply that a collection is semantically safe to make nullable.
nullable = trueMUST be avoided. MAPOTF removes redundant explicit nullable = true; this cleanup is distinct from, and does not satisfy, the requirement to set nullable = false where a meaningful zero value exists.
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.retry and var.timeouts (per TFFR7) — null means “do not emit a retry/timeouts block; use the AzAPI provider defaults”.
var.lock (per the AVM lock interface) — null means “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 implicit nullable = true is permitted only for this purpose).
State explicitly in its description what null means.
Be consumed with a null-aware pattern (e.g. count = var.lock != null ? 1 : 0, or dynamic "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.
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}
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 variableMUST 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 = stringdefault = nulldescription = "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.tfSHOULD be performed during a major version release.
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 standard Terraform TFLint plugin validates the requirement; MAPOTF keeps it first.
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.
ID: TFNFR26 - Category: Code Style - Providers in required_providers
The terraform block in terraform.tfMUST contain the required_providers block.
Each provider used directly in the module MUST be specified with the source and version properties. The standard Terraform TFLint plugin validates the used-provider source and version requirements. MAPOTF sorts the required_providers entries alphabetically.
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 declare its requirements in its own terraform.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.
By rule, every published AVM module and submodule MUST NOT declare a provider block. Provider configuration belongs exclusively to the consuming root module.
When a module requires an alternate provider instance, it MUST declare that alias through configuration_aliases in terraform.required_providers and the consumer MUST pass the configured alias through the module’s providers map. A provider block containing only alias is not permitted in an AVM module.
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.
This specification applies only to existing legacy modules that still use AzureRM while they are being migrated. It does not apply to a new module that uses AzureRM solely for the narrow unsupported data-plane/non-ARM API exception in TFFR3, because that exception does not permit AzureRM resource-group management.
In a legacy AzureRM module, the prevent_deletion_if_contains_resources provider setting SHOULD be set to false until the module is migrated. Azure Policy remediation can add resources during a test run, and the provider’s default behavior can then prevent cleanup of the test resource group.
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.tfMAY 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, or locals) as the prefix, followed by a ., a short descriptive topic name, and the .tf extension — for example main.diagnostic_settings.tf, variables.diagnostic_settings.tf, outputs.diagnostic_settings.tf, locals.diagnostic_settings.tf.
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, and locals.private_endpoints.tf all relate to the same feature.
Each split file MUST contain only the block kind matching its prefix:
main.<topic>.tf — only resource, data, and module blocks.
variables.<topic>.tf — only variable blocks.
outputs.<topic>.tf — only output blocks.
locals.<topic>.tf — only locals blocks.
The terraform { … } block MUST appear exactly once per module, in terraform.tf. It MUST NOT be split.
Files that MUST NOT appear at the module root
A providers.tf file — provider requirements belong in terraform.tf; provider configurations belong only in the consumer’s root module, never in an AVM module (per SFR2).
A single monolithic module.tf or everything.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.
MAPOTF places top-level blocks in their canonical files. The terraform_tf_file rule validates the single terraform block requirement.
Notes
Submodules (per TFRMNFR1) follow the same layout in their own root directory under modules/<subresource>/. The submodule’s terraform.tfMUST declare the same set of required_providers it 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.
Structured values that are passed as JSON or YAML MUST be constructed with jsonencode or yamlencode, rather than a literal JSON or YAML heredoc. Native HCL objects, lists, conditionals, and for expressions keep the structure reviewable and let Terraform perform correct escaping.
Terraform interpolation (${...}), template directives (%{...}), unknown values, and dynamically generated lists or maps are not exceptions: construct the native HCL value and pass it to the encoder.
A heredoc MAY be used only when the value is not JSON or YAML, or when the receiving system requires opaque source text for a downstream templating engine or syntax that jsonencode or yamlencode cannot represent without changing its meaning. The heredoc must not use Terraform interpolation to assemble JSON or YAML in that case, and its reason must be clear from the surrounding configuration.
ID: TFNFR41 - Category: Code Style - Output Definition Order
output blocks in a module SHOULD be ordered alphabetically by output name. This applies to outputs.tf and every outputs.<topic>.tf file in the root module and each submodule.
output"id" {
value = azapi_resource.this.id}
output"name" {
value = azapi_resource.this.name}
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
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 = {
forkey, valueinazapi_resource.mychildresource:key => value.output.properties.foo }
}# Output of a sensitive attribute
output"bar" {
description = "MyResource bar attribute"value = azapi_resource.myresource.output.properties.barsensitive = true}
TFFR6, TFFR7, and TFFR8 apply independently to each module and submodule scope. Together they require resource_types, retry, timeouts, and ignore_body_changes only when that scope directly declares at least one managed resource block of a supported AzAPI type:
azapi_resource
azapi_data_plane_resource
azapi_resource_action
azapi_update_resource
A provider declaration alone, AzAPI data sources alone (including data "azapi_client_config" and data "azapi_resource"), or supported AzAPI resources declared only inside a child module do not trigger these requirements in the parent scope. Each submodule is evaluated independently and triggers when it directly declares a supported block. A count or for_each condition does not exempt a directly declared block.
Within an applicable scope, authors MUST NOT hard-code the type argument of a supported AzAPI 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_types name 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 the type argument.
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 be this, 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 a map(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 that directly declares a supported AzAPI resource and therefore exposes its own resource_types variable (see TFRMNFR1). The shape of the nested object MUST match that submodule’s own resource_types variable exactly. The parent MUST NOT repeat the submodule’s defaults — the inner string attributes are declared as optional(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:
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):
These requirements are enforced by retry and timeouts.
Applicability
TFFR6, TFFR7, and TFFR8 apply independently to each module and submodule scope. Together they require resource_types, retry, timeouts, and ignore_body_changes only when that scope directly declares at least one managed resource block of a supported AzAPI type:
azapi_resource
azapi_data_plane_resource
azapi_resource_action
azapi_update_resource
A provider declaration alone, AzAPI data sources alone (including data "azapi_client_config" and data "azapi_resource"), or supported AzAPI resources declared only inside a child module do not trigger these requirements in the parent scope. Each submodule is evaluated independently and triggers when it directly declares a supported block. A count or for_each condition does not exempt a directly declared block.
Within an applicable scope, the retry and timeouts blocks of every supported AzAPI resource 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 AzAPI retry block.
timeouts — an object variable controlling the AzAPI timeouts block.
Diff suppression via the AzAPI ignore_body_changes argument is covered separately by TFFR8, because its values are scoped to a single resource’s body and therefore MUST NOT be cascaded to submodules unchanged.
Both variables:
MAY define module-level defaults (e.g., a default error_message_regex such as "ScopeLocked" for resources that race with lock removal, or a default delete = "5m").
MUST allow the consumer to override the defaults — either by supplying a non-null value at the variable level, or by allowing per-field overrides through optional(...) attributes.
MUST be applied to every azapi_resource (and equivalent AzAPI resources) declared by the module.
MUST cascade to applicable submodules — the parent module’s retry and timeouts values MUST be passed through to each submodule it instantiates that directly declares a supported AzAPI resource (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 = nulldescription = <<DESCRIPTIONRetry configuration applied to every supported AzAPI resource declared by the module and its applicable 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 = nulldescription = <<DESCRIPTIONDefault per-operation timeouts applied to every supported AzAPI resource declared by the module and its applicable submodules. 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_widgetsname = var.nameparent_id = var.parent_idbody = { /* ... */ } # `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.retrydynamic"timeouts" {
for_each = var.timeouts ==null? [] : [var.timeouts]
content {
create = timeouts.value.createread = timeouts.value.readupdate = timeouts.value.updatedelete = timeouts.value.delete }
}
response_export_values = []
}
module"child" {
source = "./modules/child" # Cascade retry and timeouts to the submodule.
retry = var.retrytimeouts = var.timeouts # ...other arguments...
}
TFFR6, TFFR7, and TFFR8 apply independently to each module and submodule scope. Together they require resource_types, retry, timeouts, and ignore_body_changes only when that scope directly declares at least one managed resource block of a supported AzAPI type:
azapi_resource
azapi_data_plane_resource
azapi_resource_action
azapi_update_resource
A provider declaration alone, AzAPI data sources alone (including data "azapi_client_config" and data "azapi_resource"), or supported AzAPI resources declared only inside a child module do not trigger these requirements in the parent scope. Each submodule is evaluated independently and triggers when it directly declares a supported block. A count or for_each condition does not exempt a directly declared block.
Within an applicable scope, the ignore_body_changes argument of every supported AzAPI resource MUST be configurable by the consumer. Authors MUST NOT hard-code an inline list that the consumer cannot override, and MUST NOT omit the argument.
To meet this requirement, every applicable module or submodule (see TFRMNFR1) MUST expose a variable named ignore_body_changes.
ignore_body_changes lets a consumer suppress plan diffs for a set of body paths that are mutated outside Terraform (for example tags applied by Azure Policy, or an autoscaler adjusting a capacity property). It is the supported fallback for lifecycle.ignore_changes when the paths must be derived from variables, locals or other non-static values, which lifecycle blocks cannot accept.
Without this variable a consumer has no way to reach the argument, because lifecycle.ignore_changes cannot be applied to a resource from outside the module that declares it. This is exactly the same problem that TFFR7 solves for retry and timeouts.
The module’s Azure/azapi constraint in required_providersMUST allow v2.12.0 or later, which is the release that introduces the argument (see TFFR3).
A consumer supplying a non-empty value MUST be running Terraform 1.11 or later. Modules MUST NOT raise their required_version floor for this reason alone (see TFNFR25); instead they MUST emit null when the list is empty so that consumers on earlier Terraform versions who do not use the feature are unaffected. See Applying the variable.
Important
Because the value is held in provider-private state, a change to ignore_body_changes only takes effect after an apply. A consumer who adds a path will still see the pending diff for that path in the same plan, and a consumer who removes a path will not see the suppressed diff reappear until the next plan. Module documentation SHOULD call this out.
Variable shape
Unlike retry and timeouts, which are resource-agnostic and therefore cascade unchanged, ignore_body_changes values are dot-notation paths into one specific resource’sbody. A path such as properties.addressSpace is meaningful only for the resource that owns it, so passing a parent’s list straight through to a submodule would apply meaningless paths to a different resource.
The variable is therefore scoped per resource and per submodule, using exactly the same shape and key-naming rule as resource_types (TFFR6).
The ignore_body_changes variable MUST:
Be a single object({...}) (not a map(list(string))) so typos at call sites error at plan time and the full override surface is visible in the variable declaration.
Default the variable itself to {} and be nullable = false, per TFNFR20 and TFNFR21.
Declare one optional(list(string), []) field for every AzAPI resource the module itself declares, keyed by the snake_case form of the ARM resource type with the Microsoft. prefix dropped — the identical key used in resource_types (for example Microsoft.Example/widgets → example_widgets).
Declare one nested optional(object({...}), {}) field for every submodule the module instantiates that directly declares a supported AzAPI resource, keyed by that submodule’s primary ARM resource type. The shape of the nested object MUST match that submodule’s own ignore_body_changes variable exactly, and the parent MUST cascade the slot through unchanged.
Document every field in the variable’s description, including what ignore_body_changes does, that paths use dot notation, and that changes take effect only after an apply.
Module owners MAY ship module-level defaults where the resource is known to be mutated outside Terraform. To do so, supply the default inside the optional(list(string), [...]) wrapper. Consumers MUST still be able to override any individual field, and a module-level default MUST NOT be used to work around a bug that belongs in the module body.
Modules MAY additionally expose per-item overrides on the collection variable that drives a for_each submodule, for cases where individual instances need different paths. Where they do, the per-item value MUST take precedence over the shared slot.
Path syntax
Values are dot-notation paths relative to the resource’s body, for example tags or properties.sku.name. Each element MUST be a non-empty string.
Individual list items MUST NOT be targeted (there is no index syntax) — ignore the entire list property instead.
Authors and consumers MUST understand that an ignored path is not merely hidden from the plan: configuration changes at that path are not sent to Azure until the path is removed from the list.
Applying the variable
ignore_body_changes is an attribute (not a block) on azapi_resource, so the relevant field of the variable is assigned directly. The assignment MUST collapse an empty list to null so that the write-only argument is absent when the feature is unused:
ID: TFFR9 - Category: Inputs/Outputs - AzAPI - Tag Propagation
Applicability
This requirement applies independently to every root module and submodule that directly declares a managed AzAPI resource. The azapi_resource_tag rule determines whether a resource type supports the tags argument from its embedded AVM-generated capability snapshot.
Requirement
For every statically supported resource type, the resource MUST set the standard AVM tags input exactly as follows:
resource"azapi_resource""this" {
type = var.resource_types.example_widgetstags = var.tags}
The assignment MUST NOT merge, conditionally replace, or otherwise transform var.tags at the resource declaration. Apply any approved tag shaping before assigning the standard input.
For every statically unsupported resource type, the resource MUST NOT set a tags argument. Do not use a conditional, dynamic value, or an empty map to force tags onto an unsupported type.
The validation skips dynamic or otherwise unevaluable type expressions to avoid false positives. Authors SHOULD keep resource types statically resolvable through var.resource_types as required by TFFR6.
The tags input and propagation behavior remain governed by the standard tags interface. The embedded AVM-generated capability snapshot, rather than a hand-maintained module allowlist or an AzAPI import, is the authority for deciding whether the argument is supported.
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), or list(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 each validation block self-contained and avoids requiring cross-variable validation.
For optional / nullable variables, the validation MUST short-circuit on null (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 with alltrue([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 null for optional nested attributes.
Where a variable can legitimately hold IDs of more than one resource type (rare — e.g. marketplace_partner_resource_id in 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 = stringnullable = falsevalidation {
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 = stringdefault = nullnullable = truevalidation {
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 = falsevalidation {
condition = alltrue([
foridin 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 = stringprivate_dns_zone_resource_ids = optional(set(string), []) # ...other attributes...
}))
default = {}
nullable = falsevalidation {
condition = alltrue([
for_, vin 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_, vin var.private_endpoints: [
foridinv.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_id errors when (a) the input is not a well-formed ARM ID, or (b) the input does not parse as the supplied resource type. Wrapping in can(...) converts both failure modes into a single boolean suitable for a validation block’s condition.
This rule supersedes any older guidance suggesting startswith(var.x, "/") or hand-written regex for resource ID validation.
Testing
The content below is listed based on the following tags
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
To see a directory and file structure for a module, see the language specific contribution guide.
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.
➕ Terraform & Bicep Log Analytics Workspace examples using simple/native declarations for use in E2E tests
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.
Details for use in E2E tests
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>).
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.
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.
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.
README documentation MUST be automatically/programmatically generated. MUST include the sections as defined in the language specific requirements BCPNFR2, TFNFR2.
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 = stringparam2 = 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 }
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.
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 Version
Y == Minor Version
Z == Patch Version
Module versioning before first Major version release 1.0.0
Initially modules MUST be released as version 0.1.0 and 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 version 1.0.0.
Note
Releasing as version 0.1.0 initially 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.0 is released, given a version number X.Y.Z:
X Major version MUST NOT be bumped.
Y Minor version MUST be bumped when introducing breaking changes (which would normally bump Major after 1.0.0 release) or feature updates (same as it will be after 1.0.0 release).
Z Patch version MUST be bumped when introducing non-breaking, backward compatible bug fixes (same as it will be after 1.0.0 release).
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: 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.