Skip to content

Commit b7c2468

Browse files
feat: add TFFR8 spec for AzAPI ignore_body_changes variable (#2865)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent f1e670e commit b7c2468

11 files changed

Lines changed: 292 additions & 17 deletions

File tree

docs/content/contributing/terraform/composition.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ Every Terraform AVM resource module **MUST** implement the following AzAPI patte
9999
| [TFFR5]({{% siteparam base %}}/spec/TFFR5) | Always set `replace_triggers_refs` on every AzAPI resource. List the body paths that **MUST** force replacement when they change; `name` and `location` are already triggers, so don't repeat them. |
100100
| [TFFR6]({{% siteparam base %}}/spec/TFFR6) | Source the `type` argument of every AzAPI resource from a single `resource_types` object variable instead of hard-coding type strings. Use one optional key per resource, defaulted to the tested API version, and cascade the relevant subset to each submodule. |
101101
| [TFFR7]({{% siteparam base %}}/spec/TFFR7) | Expose `retry` and `timeouts` variables, apply them to every `azapi_resource`, and cascade them to every submodule. |
102+
| [TFFR8]({{% siteparam base %}}/spec/TFFR8) | Expose an `ignore_body_changes` object variable so consumers can suppress diffs on body paths derived from non-static values. Use one optional `list(string)` key per resource (same key naming as `resource_types`), collapse empty lists to `null`, and cascade the relevant nested slot to each submodule — never the parent's own paths. |
102103
| [TFNFR38]({{% siteparam base %}}/spec/TFNFR38) | Validate every variable (or nested attribute) that holds an Azure ARM resource ID using `can(provider::azapi::parse_resource_id("Microsoft.X/y", value))`. Hand-rolled regex / `startswith` / `length` checks **MUST NOT** be used. |
103104
| [TFNFR39]({{% siteparam base %}}/spec/TFNFR39) | Use the standard file layout (`terraform.tf`, `variables.tf`, `outputs.tf`, `main.tf`, `locals.tf`). Larger modules **MAY** split `main.tf` into `main.<topic>.tf` files. |
104105

docs/content/specs-defs/includes/terraform/resource/non-functional/TFRMNFR1.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,19 @@ Cardinality is the parent module's responsibility: the parent module **MUST** us
6363

6464
This rule applies equally when a submodule is consumed through its parent module and when the same submodule is consumed directly by another caller.
6565

66-
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]({{% siteparam base %}}/spec/TFFR6) for the naming rule and nested-slot pattern), and passes `retry` and `timeouts` through unchanged (see [TFFR7]({{% siteparam base %}}/spec/TFFR7)):
66+
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]({{% siteparam base %}}/spec/TFFR6) for the naming rule and nested-slot pattern), passes `retry` and `timeouts` through unchanged (see [TFFR7]({{% siteparam base %}}/spec/TFFR7)), and cascades the matching nested slot from its own `ignore_body_changes` (see [TFFR8]({{% siteparam base %}}/spec/TFFR8)):
6767

6868
```terraform
6969
module "part" {
7070
source = "./modules/part"
7171
for_each = var.parts
7272
73-
name = each.value.name
74-
parent_id = azapi_resource.this.id
75-
resource_types = var.resource_types.example_widgets_parts
76-
retry = var.retry
77-
timeouts = var.timeouts
73+
name = each.value.name
74+
parent_id = azapi_resource.this.id
75+
resource_types = var.resource_types.example_widgets_parts
76+
retry = var.retry
77+
timeouts = var.timeouts
78+
ignore_body_changes = var.ignore_body_changes.example_widgets_parts
7879
}
7980
```
8081

@@ -86,11 +87,12 @@ module "component" {
8687
source = "../component"
8788
for_each = var.components
8889
89-
name = each.value.name
90-
parent_id = azapi_resource.this.id
91-
resource_types = var.resource_types.example_widgets_parts_components
92-
retry = var.retry
93-
timeouts = var.timeouts
90+
name = each.value.name
91+
parent_id = azapi_resource.this.id
92+
resource_types = var.resource_types.example_widgets_parts_components
93+
retry = var.retry
94+
timeouts = var.timeouts
95+
ignore_body_changes = var.ignore_body_changes.example_widgets_parts_components
9496
}
9597
```
9698

@@ -151,6 +153,7 @@ Submodules **MUST** meet every requirement that applies to a top-level AVM Terra
151153
- [TFFR5]({{% siteparam base %}}/spec/TFFR5) — `replace_triggers_refs`.
152154
- [TFFR6]({{% siteparam base %}}/spec/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.
153155
- [TFFR7]({{% siteparam base %}}/spec/TFFR7) — `retry` and `timeouts` variables, which the parent module **MUST** cascade to each submodule unchanged.
156+
- [TFFR8]({{% siteparam base %}}/spec/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`.
154157
- All applicable [interface]({{% siteparam base %}}/specs/tf/interfaces/) 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.
155158

156159
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.

docs/content/specs-defs/includes/terraform/shared/functional/TFFR3.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ Authors **MUST** only use the following Azure providers, and versions, in their
2525

2626
| provider | min version | max version |
2727
|-----------------------|-------------|-------------|
28-
| Azure/azapi | >= 2.0 | < 3.0 |
28+
| Azure/azapi | >= 2.12 | < 3.0 |
29+
30+
{{% notice style="note" %}}
31+
32+
The AzAPI floor is `2.12` because [TFFR8]({{% siteparam base %}}/spec/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.
33+
34+
{{% /notice %}}
2935

3036
The AzureRM provider **MUST NOT** be used, except where the narrow exception below applies.
3137

@@ -58,15 +64,15 @@ Authors **MUST** use the `required_providers` block in their module to enforce t
5864
The following is an example.
5965

6066
- In it we use the [pessimistic version constraint operator](https://developer.hashicorp.com/terraform/language/expressions/version-constraints#operators) `~>`.
61-
- That is to say that `~> 2.9` is equivalent to `>= 2.9, < 3.0`.
67+
- That is to say that `~> 2.12` is equivalent to `>= 2.12, < 3.0`.
6268

6369
```terraform
6470
terraform {
6571
required_providers {
6672
# Include one or both providers, as needed
6773
azapi = {
6874
source = "Azure/azapi"
69-
version = "~> 2.9"
75+
version = "~> 2.12"
7076
}
7177
}
7278
}

docs/content/specs-defs/includes/terraform/shared/functional/TFFR7.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ To meet this requirement, the module **MUST** expose two variables:
2828
- `retry` — an object variable controlling the AzAPI `retry` block.
2929
- `timeouts` — an object variable controlling the AzAPI `timeouts` block.
3030

31+
Diff suppression via the AzAPI `ignore_body_changes` argument is covered separately by [TFFR8]({{% siteparam base %}}/spec/TFFR8), because its values are scoped to a single resource's `body` and therefore **MUST NOT** be cascaded to submodules unchanged.
32+
3133
Both variables:
3234

3335
- **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"`).
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
---
2+
title: TFFR8 - AzAPI - ignore_body_changes variable
3+
description: Module Specification for the Azure Verified Modules (AVM) program
4+
url: /spec/TFFR8
5+
type: default
6+
tags: [
7+
Class-Resource, # MULTIPLE VALUES: this can be "Class-Resource" AND/OR "Class-Pattern" AND/OR "Class-Utility"
8+
Class-Pattern, # MULTIPLE VALUES: this can be "Class-Resource" AND/OR "Class-Pattern" AND/OR "Class-Utility"
9+
Class-Utility, # MULTIPLE VALUES: this can be "Class-Resource" AND/OR "Class-Pattern" AND/OR "Class-Utility"
10+
Type-Functional, # SINGLE VALUE: this can be "Type-Functional" OR "Type-NonFunctional"
11+
Category-Inputs/Outputs, # SINGLE VALUE: this can be "Category-Testing" OR "Category-Telemetry" OR "Category-Contribution/Support" OR "Category-Documentation" OR "Category-CodeStyle" OR "Category-Naming/Composition" OR "Category-Inputs/Outputs" OR "Category-Release/Publishing"
12+
Language-Terraform, # MULTIPLE VALUES: this can be "Language-Bicep" AND/OR "Language-Terraform"
13+
Severity-MUST, # SINGLE VALUE: this can be "Severity-MUST" OR "Severity-SHOULD" OR "Severity-MAY"
14+
Persona-Owner, # MULTIPLE VALUES: this can be "Persona-Owner" AND/OR "Persona-Contributor"
15+
Persona-Contributor, # MULTIPLE VALUES: this can be "Persona-Owner" AND/OR "Persona-Contributor"
16+
Lifecycle-BAU, # SINGLE VALUE: this can be "Lifecycle-Initial" OR "Lifecycle-BAU" OR "Lifecycle-EOL"
17+
Validation-TBD # SINGLE VALUE: this can be "Validation-TF/Manual" OR "Validation-TF/CI/Informational" OR "Validation-TF/CI/Enforced"
18+
]
19+
priority: 20080
20+
---
21+
22+
## ID: TFFR8 - Category: Inputs/Outputs - AzAPI - ignore_body_changes variable
23+
24+
The `ignore_body_changes` argument of every `azapi_resource` declared by the module **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.
25+
26+
To meet this requirement, every module — including every submodule (see [TFRMNFR1]({{% siteparam base %}}/spec/TFRMNFR1)) — **MUST** expose a variable named `ignore_body_changes`.
27+
28+
`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.
29+
30+
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]({{% siteparam base %}}/spec/TFFR7) solves for `retry` and `timeouts`.
31+
32+
### Prerequisites
33+
34+
`ignore_body_changes` is a [write-only argument](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments). As a result:
35+
36+
- The module's `Azure/azapi` constraint in `required_providers` **MUST** allow v2.12.0 or later, which is the release that introduces the argument (see [TFFR3]({{% siteparam base %}}/spec/TFFR3)).
37+
- 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]({{% siteparam base %}}/spec/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](#applying-the-variable).
38+
39+
{{% notice style="important" %}}
40+
41+
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.
42+
43+
{{% /notice %}}
44+
45+
### Variable shape
46+
47+
Unlike `retry` and `timeouts`, which are resource-agnostic and therefore cascade unchanged, `ignore_body_changes` values are dot-notation paths into **one specific resource's** `body`. 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.
48+
49+
The variable is therefore scoped per resource and per submodule, using exactly the same shape and key-naming rule as `resource_types` ([TFFR6]({{% siteparam base %}}/spec/TFFR6)).
50+
51+
The `ignore_body_changes` variable **MUST**:
52+
53+
- 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.
54+
- Default the variable itself to `{}` and be `nullable = false`, per [TFNFR20]({{% siteparam base %}}/spec/TFNFR20) and [TFNFR21]({{% siteparam base %}}/spec/TFNFR21).
55+
- 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`).
56+
- Declare one nested `optional(object({...}), {})` field for every submodule the module instantiates, 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.
57+
- 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.
58+
59+
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.
60+
61+
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.
62+
63+
### Path syntax
64+
65+
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.
66+
67+
Individual list items **MUST NOT** be targeted (there is no index syntax) — ignore the entire list property instead.
68+
69+
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.
70+
71+
### Applying the variable
72+
73+
`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:
74+
75+
```terraform
76+
ignore_body_changes = length(var.ignore_body_changes.example_widgets) > 0 ? var.ignore_body_changes.example_widgets : null
77+
```
78+
79+
The variable **MUST** be applied to every `azapi_resource` (and equivalent AzAPI resources) declared by the module.
80+
81+
### Example — root and child
82+
83+
```terraform
84+
# === root variables.tf ===
85+
variable "ignore_body_changes" {
86+
type = object({
87+
example_widgets = optional(list(string), [])
88+
89+
example_widgets_parts = optional(object({
90+
example_widgets_parts = optional(list(string), [])
91+
}), {})
92+
})
93+
default = {}
94+
nullable = false
95+
}
96+
97+
# === root main.tf ===
98+
resource "azapi_resource" "this" {
99+
type = var.resource_types.example_widgets
100+
name = var.name
101+
parent_id = var.parent_id
102+
body = { /* ... */ }
103+
104+
ignore_body_changes = length(var.ignore_body_changes.example_widgets) > 0 ? var.ignore_body_changes.example_widgets : null
105+
106+
response_export_values = []
107+
}
108+
109+
module "part" {
110+
source = "./modules/part"
111+
for_each = var.parts
112+
113+
name = each.value.name
114+
parent_id = azapi_resource.this.id
115+
resource_types = var.resource_types.example_widgets_parts
116+
retry = var.retry
117+
timeouts = var.timeouts
118+
119+
# Cascade the nested slot through unchanged.
120+
ignore_body_changes = var.ignore_body_changes.example_widgets_parts
121+
}
122+
123+
# === modules/part/variables.tf ===
124+
variable "ignore_body_changes" {
125+
type = object({
126+
example_widgets_parts = optional(list(string), [])
127+
})
128+
default = {}
129+
nullable = false
130+
}
131+
132+
# === modules/part/main.tf ===
133+
resource "azapi_resource" "this" {
134+
type = var.resource_types.example_widgets_parts
135+
name = var.name
136+
parent_id = var.parent_id
137+
body = { /* ... */ }
138+
139+
ignore_body_changes = length(var.ignore_body_changes.example_widgets_parts) > 0 ? var.ignore_body_changes.example_widgets_parts : null
140+
141+
response_export_values = []
142+
}
143+
```
144+
145+
A consumer ignoring tags on the widget, and a policy-managed property on every part, writes:
146+
147+
```terraform
148+
module "widget" {
149+
source = "Azure/avm-res-example-widget/azure"
150+
151+
ignore_body_changes = {
152+
example_widgets = var.ignore_policy_tags ? ["tags"] : []
153+
154+
example_widgets_parts = {
155+
example_widgets_parts = ["properties.retentionPolicy"]
156+
}
157+
}
158+
159+
# ...other arguments...
160+
}
161+
```
162+
163+
See <https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource#ignore_body_changes> for full semantics.

docs/content/specs-defs/includes/terraform/shared/non-functional/TFNFR25.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ terraform {
4141
required_providers {
4242
azapi = {
4343
source = "Azure/azapi"
44-
version = "~> 2.9"
44+
version = "~> 2.12"
4545
}
4646
}
4747
}

docs/content/specs-defs/includes/terraform/shared/non-functional/TFNFR26.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ terraform {
4545
required_providers {
4646
azapi = {
4747
source = "Azure/azapi"
48-
version = "~> 2.9"
48+
version = "~> 2.12"
4949
}
5050
}
5151
}

docs/content/specs-defs/includes/terraform/shared/non-functional/TFNFR27.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ terraform {
3434
required_providers {
3535
azapi = {
3636
source = "Azure/azapi"
37-
version = "~> 2.9"
37+
version = "~> 2.12"
3838
configuration_aliases = [azapi.alternate]
3939
}
4040
}

0 commit comments

Comments
 (0)