Skip to content

feat: support count and for_each indexed resource drift in graft absorb#14

Merged
ms-henglu merged 1 commit intomainfrom
feature-absorb-indexed-drift
Feb 11, 2026
Merged

feat: support count and for_each indexed resource drift in graft absorb#14
ms-henglu merged 1 commit intomainfrom
feature-absorb-indexed-drift

Conversation

@ms-henglu
Copy link
Owner

Summary

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.

Example output

# count-indexed
tags = lookup({
  0 = { environment = "production" }
  1 = { environment = "staging" }
}, count.index, graft.source)

# for_each-indexed
location = lookup({
  "api" = "westus"
  "web" = "centralus"
}, each.key, graft.source)

The graft.source fallback preserves the original config value for un-drifted instances.

Changes

Core logic

  • Add Index field to DriftChange struct to capture count/for_each index from plan JSON
  • 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

Tests

  • Refactor manifest_test.go to use exact content matching with helpers (expectManifest, expectBlock, normalizeWhitespace, writeSchemaFile)
  • Add unit tests for: count-indexed, for_each-indexed, all-same value, mixed indexed/non-indexed, partial drift, module-scoped indexed resources
  • Add acceptance tests: absorb-count-indexed-drift, absorb-for-each-indexed-drift

Documentation

  • Add Example 14: Absorb Indexed Resource Drift
  • Update design doc with sections 5 (count) and 6 (for_each)
  • Add TODO section 7 for block drift on indexed resources (future work)

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.

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
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DriftChange and generate grouped, index-aware override blocks via lookup().
  • 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.

Comment on lines +507 to +511
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("\"")},
)
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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...)

Copilot uses AI. Check for mistakes.
Comment on lines 59 to +76
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)
}
}
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@ms-henglu ms-henglu merged commit f5d9f08 into main Feb 11, 2026
8 checks passed
@ms-henglu ms-henglu deleted the feature-absorb-indexed-drift branch February 11, 2026 05:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants