|
| 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. |
0 commit comments