feat: support count and for_each indexed resource drift in graft absorb#14
feat: support count and for_each indexed resource drift in graft absorb#14
Conversation
Add support for absorbing drift on resources created with count or for_each.
Instead of generating one override block per instance, graft absorb now groups
all instances of the same resource and uses a lookup() expression to select
the correct override per index.
Key changes:
- Add Index field to DriftChange struct to capture count/for_each index
- Add helper methods: IsIndexed, IsCountIndexed, IsForEachIndexed, indexKey,
indexRef, resourceKey
- Add IndexedChangesToBlock to generate grouped resource blocks with lookup()
- Add buildLookupTokens to emit HCL tokens for lookup({...}, index, graft.source)
- Update module_node.go to separate indexed vs non-indexed changes and render
indexed groups using lookup()
- Refactor manifest_test.go to use exact content matching with helper functions
(expectManifest, expectBlock, normalizeWhitespace, writeSchemaFile)
- Add unit tests for count-indexed, for_each-indexed, all-same value, mixed
indexed/non-indexed, partial drift, and module-scoped indexed resources
- Add acceptance tests for count-indexed and for_each-indexed drift
- Add Example 14 demonstrating count and for_each indexed drift absorption
- Update design doc with sections 5 (count) and 6 (for_each), linking to
Example 14, and add TODO section 7 for block drift on indexed resources
There was a problem hiding this comment.
Pull request overview
Adds support to graft absorb for resources created with count / for_each by grouping per-resource instances and generating lookup({...}, count.index|each.key, graft.source) expressions, plus updated tests/docs/examples to cover the new behavior.
Changes:
- Capture instance index in
DriftChangeand generate grouped, index-aware override blocks vialookup(). - Update module rendering to split indexed vs non-indexed changes and render indexed groups together.
- Add/extend unit + acceptance tests and add Example 14 + design doc updates for indexed drift.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/absorb/absorb.go | Plumbs plan JSON index into DriftChange. |
| internal/absorb/drift_change.go | Adds index helpers and grouped indexed rendering (IndexedChangesToBlock, buildLookupTokens). |
| internal/absorb/module_node.go | Groups indexed changes by resource and renders them with a single override block per resource. |
| internal/absorb/manifest_test.go | Refactors assertions to exact expected-content comparisons (whitespace-normalized) and adds indexed drift unit tests. |
| internal/absorb/absorb_test.go | Adds ParsePlanFile coverage for count/for_each indexes and helper method tests. |
| internal/acceptance/testdata/absorb--indexed-drift/ | Adds acceptance fixtures and expected manifests for count/for_each drift absorption. |
| examples/readme.md | Adds Example 14 to the examples index. |
| examples/14-absorb-indexed-drift/* | New example demonstrating count/for_each drift absorption output. |
| docs/design/absorb-support-scope.md | Documents indexed drift support and known limitations for block drift. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| tokens = append(tokens, | ||
| &hclwrite.Token{Type: hclsyntax.TokenOQuote, Bytes: []byte("\"")}, | ||
| &hclwrite.Token{Type: hclsyntax.TokenStringLit, Bytes: []byte(idx)}, | ||
| &hclwrite.Token{Type: hclsyntax.TokenCQuote, Bytes: []byte("\"")}, | ||
| ) |
There was a problem hiding this comment.
In buildLookupTokens, for for_each keys the code writes the raw key bytes directly into a quoted string literal token. If an each.key contains characters needing escaping (e.g., ", newlines, ${, etc.), this will emit invalid HCL. Consider rendering keys via cty.StringVal(key) (or an hclwrite-generated expression) so escaping is handled correctly, rather than manually assembling TokenStringLit bytes.
| tokens = append(tokens, | |
| &hclwrite.Token{Type: hclsyntax.TokenOQuote, Bytes: []byte("\"")}, | |
| &hclwrite.Token{Type: hclsyntax.TokenStringLit, Bytes: []byte(idx)}, | |
| &hclwrite.Token{Type: hclsyntax.TokenCQuote, Bytes: []byte("\"")}, | |
| ) | |
| // Use hclwrite to render the string literal so special characters are escaped correctly. | |
| keyTokens := hclwrite.TokensForValue(cty.StringVal(idx)) | |
| tokens = append(tokens, keyTokens...) |
| sort.Slice(m.Changes, func(i, j int) bool { | ||
| if m.Changes[i].ResourceType != m.Changes[j].ResourceType { | ||
| return m.Changes[i].ResourceType < m.Changes[j].ResourceType | ||
| } | ||
| return m.Changes[i].ResourceName < m.Changes[j].ResourceName | ||
| }) | ||
|
|
||
| // Separate indexed and non-indexed changes | ||
| var nonIndexed []DriftChange | ||
| indexedGroups := make(map[string][]DriftChange) // keyed by "type.name" | ||
|
|
||
| for _, change := range m.Changes { | ||
| if change.IsIndexed() { | ||
| indexedGroups[change.resourceKey()] = append(indexedGroups[change.resourceKey()], change) | ||
| } else { | ||
| nonIndexed = append(nonIndexed, change) | ||
| } | ||
| } |
There was a problem hiding this comment.
m.Changes is sorted only by ResourceType/ResourceName. For indexed resources, multiple instances share the same type+name so the comparator returns false both ways, and sort.Slice is not stable—this can lead to nondeterministic grouping order and flaky/unstable output (including the "Absorb drift for" address list). Consider extending the sort key (e.g., include Address or Index) or using a stable sort and then explicitly sorting each indexed group (and/or the addresses slice) by index/address before rendering.
Summary
Add support for absorbing drift on resources created with
countorfor_each. Instead of generating one override block per instance,graft absorbnow groups all instances of the same resource and uses alookup()expression to select the correct override per index.Example output
The
graft.sourcefallback preserves the original config value for un-drifted instances.Changes
Core logic
Indexfield toDriftChangestruct to capturecount/for_eachindex from plan JSONIsIndexed(),IsCountIndexed(),IsForEachIndexed(),indexKey(),indexRef(),resourceKey()IndexedChangesToBlock()to generate grouped resource blocks withlookup()buildLookupTokens()to emit HCL tokens forlookup({...}, index, graft.source)module_node.goto separate indexed vs non-indexed changes and render indexed groupsTests
manifest_test.goto use exact content matching with helpers (expectManifest,expectBlock,normalizeWhitespace,writeSchemaFile)absorb-count-indexed-drift,absorb-for-each-indexed-driftDocumentation
Known Limitations
Block drift (Category 2/3) on indexed resources falls back to emitting blocks from the first instance (lossy). Tracked in TODO #7 in the design doc.