Skip to content

Commit f7fd271

Browse files
authored
Merge pull request #15 from ms-henglu/acceptance-test-absorb-indexed-block-drift
feat: support block drift on indexed resources in absorb
2 parents f5d9f08 + 3ce0ac6 commit f7fd271

File tree

10 files changed

+2029
-41
lines changed

10 files changed

+2029
-41
lines changed

docs/design/absorb-support-scope.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,14 @@ Resources using `for_each` (e.g., `azurerm_resource_group.main["web"]`,
129129

130130
---
131131

132-
## TODO
133-
134132
### 7. Block Drift for Indexed Resources (Category 2/3)
135133

136134
Category 2 (single nested block) and Category 3 (multiple sibling blocks)
137-
drift within `count`/`for_each` resources is not yet supported with
138-
per-instance differentiation.
139-
140-
**Proposed approach — `dynamic` blocks with `lookup()`:**
135+
drift within `count`/`for_each` resources uses `dynamic` blocks with
136+
`lookup()` for per-instance differentiation.
141137

142-
For block-type attributes that differ across indexed instances, replace static
143-
blocks with `dynamic` blocks that select content per-instance:
138+
For block-type attributes that differ across indexed instances, static
139+
blocks are replaced with `dynamic` blocks that select content per-instance:
144140

145141
```hcl
146142
dynamic "security_rule" {
@@ -155,11 +151,22 @@ dynamic "security_rule" {
155151
}
156152
```
157153

158-
**Open problem:** The `graft.source` fallback cannot be used with `dynamic`
159-
block `for_each` because the original source has static blocks (not a list
160-
expression). A different fallback mechanism is needed.
161-
162-
**Current behavior:** When block drift occurs on indexed resources, blocks from
163-
the first instance are emitted (lossy). This is a known limitation.
154+
- A `_graft { remove = ["block_type"] }` directive is added to remove the
155+
original static blocks before the dynamic block generates replacements.
156+
- The fallback value is `[]` (empty list). Instances without block drift
157+
for a given block type will produce no dynamic iterations.
158+
- For single nested blocks (Category 2), the value is wrapped in a
159+
single-element array: `[{ ... }]`.
160+
- For nested blocks within the content, nested `dynamic` blocks with
161+
`try(parent.value.nested, [])` are generated recursively.
162+
- Full (pre-deep-diff) block values are used in the lookup map since
163+
dynamic blocks replace the entire block, not just changed attributes.
164+
165+
**Known limitation:** The `graft.source` fallback cannot be used with
166+
`dynamic` block `for_each` because the original source has static blocks
167+
(not a list expression). Instances without block drift in the lookup map
168+
will have their original static blocks removed by `_graft remove`.
169+
170+
✅ Supported. See [Example 15](../../examples/15-absorb-indexed-block-drift).
164171

165172
---
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Generated by graft absorb
2+
# This manifest contains overrides to match the current remote state
3+
4+
override {
5+
# Absorb drift for: azurerm_linux_virtual_machine.vm["web"], azurerm_linux_virtual_machine.vm["api"]
6+
resource "azurerm_linux_virtual_machine" "vm" {
7+
dynamic "os_disk" {
8+
for_each = lookup({
9+
"api" = [{
10+
caching = "ReadOnly"
11+
disk_size_gb = 64
12+
storage_account_type = "StandardSSD_LRS"
13+
}]
14+
"web" = [{
15+
caching = "ReadWrite"
16+
disk_size_gb = 50
17+
storage_account_type = "Premium_LRS"
18+
}]
19+
}, each.key, [])
20+
content {
21+
caching = os_disk.value.caching
22+
disk_size_gb = os_disk.value.disk_size_gb
23+
storage_account_type = os_disk.value.storage_account_type
24+
}
25+
}
26+
_graft {
27+
remove = ["os_disk"]
28+
}
29+
}
30+
31+
# Absorb drift for: azurerm_network_security_group.nsg[0], azurerm_network_security_group.nsg[1]
32+
resource "azurerm_network_security_group" "nsg" {
33+
tags = lookup({
34+
0 = {
35+
environment = "production"
36+
project = "graft"
37+
}
38+
1 = {
39+
environment = "staging"
40+
project = "graft"
41+
}
42+
}, count.index, graft.source)
43+
dynamic "security_rule" {
44+
for_each = lookup({
45+
0 = [{
46+
access = "Allow"
47+
destination_address_prefix = "*"
48+
destination_port_range = "22"
49+
direction = "Inbound"
50+
name = "allow-ssh"
51+
priority = 100
52+
protocol = "Tcp"
53+
source_address_prefix = "10.0.0.0/8"
54+
source_port_range = "*"
55+
}, {
56+
access = "Allow"
57+
destination_address_prefix = "*"
58+
destination_port_range = "443"
59+
direction = "Inbound"
60+
name = "allow-https"
61+
priority = 200
62+
protocol = "Tcp"
63+
source_address_prefix = "10.0.0.0/8"
64+
source_port_range = "*"
65+
}]
66+
1 = [{
67+
access = "Allow"
68+
destination_address_prefix = "*"
69+
destination_port_range = "80"
70+
direction = "Inbound"
71+
name = "allow-http"
72+
priority = 100
73+
protocol = "Tcp"
74+
source_address_prefix = "10.0.0.0/8"
75+
source_port_range = "*"
76+
}, {
77+
access = "Allow"
78+
destination_address_prefix = "*"
79+
destination_port_range = "443"
80+
direction = "Inbound"
81+
name = "allow-https"
82+
priority = 200
83+
protocol = "Tcp"
84+
source_address_prefix = "10.0.0.0/8"
85+
source_port_range = "*"
86+
}, {
87+
access = "Deny"
88+
destination_address_prefix = "*"
89+
destination_port_range = "*"
90+
direction = "Inbound"
91+
name = "deny-all"
92+
priority = 4096
93+
protocol = "*"
94+
source_address_prefix = "*"
95+
source_port_range = "*"
96+
}]
97+
}, count.index, [])
98+
content {
99+
access = security_rule.value.access
100+
destination_address_prefix = security_rule.value.destination_address_prefix
101+
destination_port_range = security_rule.value.destination_port_range
102+
direction = security_rule.value.direction
103+
name = security_rule.value.name
104+
priority = security_rule.value.priority
105+
protocol = security_rule.value.protocol
106+
source_address_prefix = security_rule.value.source_address_prefix
107+
source_port_range = security_rule.value.source_port_range
108+
}
109+
}
110+
_graft {
111+
remove = ["security_rule"]
112+
}
113+
}
114+
115+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
terraform {
2+
required_providers {
3+
azurerm = {
4+
source = "hashicorp/azurerm"
5+
version = "~> 3.0"
6+
}
7+
}
8+
}
9+
10+
provider "azurerm" {
11+
features {}
12+
}
13+
14+
# count-indexed NSG with security rules (Category 3 — multiple sibling blocks)
15+
resource "azurerm_network_security_group" "nsg" {
16+
count = 2
17+
name = "graft-absorb-${count.index}-nsg"
18+
location = "eastus"
19+
resource_group_name = "graft-test-rg"
20+
21+
security_rule {
22+
name = "allow-ssh"
23+
priority = 100
24+
direction = "Inbound"
25+
access = "Allow"
26+
protocol = "Tcp"
27+
source_port_range = "*"
28+
destination_port_range = "22"
29+
source_address_prefix = "*"
30+
destination_address_prefix = "*"
31+
}
32+
33+
tags = {
34+
environment = "test"
35+
project = "graft"
36+
}
37+
}
38+
39+
# for_each-indexed VM with os_disk (Category 2 — single nested block)
40+
resource "azurerm_linux_virtual_machine" "vm" {
41+
for_each = toset(["web", "api"])
42+
name = "graft-${each.key}-vm"
43+
location = "eastus"
44+
resource_group_name = "graft-test-rg"
45+
size = "Standard_DS1_v2"
46+
47+
admin_username = "adminuser"
48+
admin_password = "P@ssw0rd1234!"
49+
50+
network_interface_ids = []
51+
52+
os_disk {
53+
caching = "ReadOnly"
54+
storage_account_type = "Standard_LRS"
55+
disk_size_gb = 30
56+
}
57+
58+
source_image_reference {
59+
publisher = "Canonical"
60+
offer = "UbuntuServer"
61+
sku = "18.04-LTS"
62+
version = "latest"
63+
}
64+
}

0 commit comments

Comments
 (0)