Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/v1.15/ENHANCEMENTS-20260215-200907.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: ENHANCEMENTS
body: add entry for module lifecycle ignore_changes
time: 2026-02-15T20:09:07.395154651+01:00
custom:
Issue: "27360"
136 changes: 125 additions & 11 deletions internal/configs/module_call.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,97 @@ type ModuleCall struct {
DeclRange hcl.Range

IgnoreNestedDeprecations bool

Managed *ManagedModule
}

// ManagedModule represents a "resource" block in a module or file.
type ManagedModule struct {
Connection *Connection
Provisioners []*Provisioner
ActionTriggers []*ActionTrigger

CreateBeforeDestroy bool
PreventDestroy bool
IgnoreChanges []hcl.Traversal
IgnoreAllChanges bool

CreateBeforeDestroySet bool
PreventDestroySet bool
}

func decodeModuleCallLifecycleBlock(block *hcl.Block, mc *ModuleCall) hcl.Diagnostics {
var diags hcl.Diagnostics

// Allocate only when lifecycle is present (tests expect nil otherwise).
if mc.Managed == nil {
mc.Managed = &ManagedModule{}
}

lcContent, lcDiags := block.Body.Content(moduleCallLifecycleBlockSchema)
diags = append(diags, lcDiags...)

if attr, exists := lcContent.Attributes["create_before_destroy"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &mc.Managed.CreateBeforeDestroy)
diags = append(diags, valDiags...)
mc.Managed.CreateBeforeDestroySet = true
}

if attr, exists := lcContent.Attributes["prevent_destroy"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &mc.Managed.PreventDestroy)
diags = append(diags, valDiags...)
mc.Managed.PreventDestroySet = true
}

if attr, exists := lcContent.Attributes["ignore_changes"]; exists {
kw := hcl.ExprAsKeyword(attr.Expr)

switch {
case kw == "all":
mc.Managed.IgnoreAllChanges = true

default:
exprs, listDiags := hcl.ExprList(attr.Expr)
diags = append(diags, listDiags...)

var ignoreAllRange hcl.Range

for _, expr := range exprs {
if shimIsIgnoreChangesStar(expr) {
mc.Managed.IgnoreAllChanges = true
ignoreAllRange = expr.Range()
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid ignore_changes wildcard",
Detail: "The [\"*\"] form of ignore_changes wildcard is was deprecated and is now invalid. Use \"ignore_changes = all\" to ignore changes to all attributes.",
Subject: attr.Expr.Range().Ptr(),
})
continue
}

expr, shimDiags := shimTraversalInString(expr, false)
diags = append(diags, shimDiags...)

traversal, travDiags := hcl.RelTraversalForExpr(expr)
diags = append(diags, travDiags...)
if len(traversal) != 0 {
mc.Managed.IgnoreChanges = append(mc.Managed.IgnoreChanges, traversal)
}
}

if mc.Managed.IgnoreAllChanges && len(mc.Managed.IgnoreChanges) != 0 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid ignore_changes ruleset",
Detail: "Cannot mix wildcard string \"*\" with non-wildcard references.",
Subject: &ignoreAllRange,
Context: attr.Expr.Range().Ptr(),
})
}
}
}

return diags
}

func decodeModuleBlock(block *hcl.Block, override bool) (*ModuleCall, hcl.Diagnostics) {
Expand All @@ -46,6 +137,8 @@ func decodeModuleBlock(block *hcl.Block, override bool) (*ModuleCall, hcl.Diagno
mc := &ModuleCall{
Name: block.Labels[0],
DeclRange: block.DefRange,
// IMPORTANT: Managed must stay nil unless we actually decode a lifecycle
// (or other managed-module features in future). Tests depend on this.
}

schema := moduleBlockSchema
Expand Down Expand Up @@ -191,8 +284,10 @@ func decodeModuleBlock(block *hcl.Block, override bool) (*ModuleCall, hcl.Diagno
}

var seenEscapeBlock *hcl.Block
for _, block := range content.Blocks {
switch block.Type {
var seenLifecycle *hcl.Block

for _, inner := range content.Blocks {
switch inner.Type {
case "_":
if seenEscapeBlock != nil {
diags = append(diags, &hcl.Diagnostic{
Expand All @@ -202,24 +297,34 @@ func decodeModuleBlock(block *hcl.Block, override bool) (*ModuleCall, hcl.Diagno
"The special block type \"_\" can be used to force particular arguments to be interpreted as module input variables rather than as meta-arguments, but each module block can have only one such block. The first escaping block was at %s.",
seenEscapeBlock.DefRange,
),
Subject: &block.DefRange,
Subject: &inner.DefRange,
})
continue
}
seenEscapeBlock = inner
mc.Config = hcl.MergeBodies([]hcl.Body{mc.Config, inner.Body})

case "lifecycle":
if seenLifecycle != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate lifecycle block",
Detail: fmt.Sprintf("This module call already has a lifecycle block at %s.", seenLifecycle.DefRange),
Subject: &inner.DefRange,
})
continue
}
seenEscapeBlock = block
seenLifecycle = inner

// When there's an escaping block its content merges with the
// existing config we extracted earlier, so later decoding
// will see a blend of both.
mc.Config = hcl.MergeBodies([]hcl.Body{mc.Config, block.Body})
diags = append(diags, decodeModuleCallLifecycleBlock(inner, mc)...)

default:
// All of the other block types in our schema are reserved.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Reserved block type name in module block",
Detail: fmt.Sprintf("The block type name %q is reserved for use by Terraform in a future version.", block.Type),
Subject: &block.TypeRange,
Detail: fmt.Sprintf("The block type name %q is reserved for use by Terraform in a future version.", inner.Type),
Subject: &inner.TypeRange,
})
}
}
Expand Down Expand Up @@ -299,9 +404,18 @@ var moduleBlockSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{Type: "_"}, // meta-argument escaping block

// These are all reserved for future use.
// "lifecycle" is interpreted as Terraform meta-configuration for this module call.
// Other block types are reserved for future use.
{Type: "lifecycle"},
{Type: "locals"},
{Type: "provider", LabelNames: []string{"type"}},
},
}

var moduleCallLifecycleBlockSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "create_before_destroy"},
{Name: "prevent_destroy"},
{Name: "ignore_changes"},
},
}
162 changes: 162 additions & 0 deletions internal/terraform/module_call_lifecycle_ignore_changes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package terraform

import (
"github.com/hashicorp/hcl/v2"

"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
)

func moduleCallExtraIgnoreTraversals(root *configs.Config, resInst addrs.AbsResourceInstance) ([]hcl.Traversal, bool) {
var extra []hcl.Traversal
ignoreAll := false

mi := resInst.Module // full module *instance* path (includes keys)
if len(mi) == 0 {
return nil, false
}

// For each step in the module instance path, look at the corresponding
// module call in the parent module config.
for depth := 1; depth <= len(mi); depth++ {
parentMI := mi[:depth-1]
step := mi[depth-1] // ModuleInstanceStep{Name, InstanceKey}

parentCfg := root.Descendant(parentMI.Module()) // config tree is keyed by static module path
if parentCfg == nil || parentCfg.Module == nil {
continue
}

mc := parentCfg.Module.ModuleCalls[step.Name]
if mc == nil || mc.Managed == nil {
continue
}

// "ignore_changes = all" on the module call lifecycle:
// treat as ignore-all for every managed resource under it.
if mc.Managed.IgnoreAllChanges {
ignoreAll = true
}

for _, t := range mc.Managed.IgnoreChanges {
if rem, ok := matchModuleIgnoreTraversal(t, step.InstanceKey, resInst.Resource); ok {
extra = append(extra, rem)
}
}
}

return extra, ignoreAll
}

// matchModuleIgnoreTraversal checks whether a module-call ignore traversal
// (like self[0].google_storage_bucket_iam_binding.authoritative["k"].members)
// applies to this resource instance. If so it returns the attribute traversal
// relative to the resource instance (e.g. members, labels["x"], etc).
func matchModuleIgnoreTraversal(
t hcl.Traversal,
moduleKey addrs.InstanceKey,
target addrs.ResourceInstance,
) (hcl.Traversal, bool) {

if len(t) < 2 {
return nil, false
}

// module_call.go stores ignore_changes items using hcl.RelTraversalForExpr,
// which can yield TraverseAttr for the first step.
var firstName string
switch s := t[0].(type) {
case hcl.TraverseRoot:
firstName = s.Name
case hcl.TraverseAttr:
firstName = s.Name
default:
return nil, false
}
if firstName != "self" {
return nil, false
}

i := 1

// Optional self[<key>] filter on module instance key.
if i < len(t) {
if idx, ok := t[i].(hcl.TraverseIndex); ok {
k, err := addrs.ParseInstanceKey(idx.Key)
if err != nil {
return nil, false
}
if moduleKey == addrs.NoKey || k != moduleKey {
return nil, false
}
i++
}
}

rest := t[i:]
if len(rest) < 3 {
// Need at least <type>.<name>.<attr...>
return nil, false
}

// Normalize first step to TraverseRoot so ParseRef is happy in all cases.
if a, ok := rest[0].(hcl.TraverseAttr); ok {
rest = append(hcl.Traversal{
hcl.TraverseRoot{Name: a.Name, SrcRange: a.SrcRange},
}, rest[1:]...)
}

ref, diags := addrs.ParseRef(rest)
if diags.HasErrors() || ref == nil {
return nil, false
}

subj, ok := ref.Subject.(addrs.ResourceInstance)
if !ok {
return nil, false
}

// Only managed resources.
if subj.Resource.Mode != addrs.ManagedResourceMode {
return nil, false
}

// Match resource type+name.
if subj.Resource != target.Resource {
return nil, false
}

// If the ignore rule specifies a resource instance key, require it to match
// only when the target instance key is known. If target.Key is NoKey, we
// can't reliably filter to one instance at this point, so we treat it as
// applying to the resource block (and thus all instances).
if subj.Key != addrs.NoKey && target.Key != addrs.NoKey && subj.Key != target.Key {
return nil, false
}

if len(ref.Remaining) == 0 {
// No attribute path => nothing to ignore
return nil, false
}

return ref.Remaining, true
}

func copyResourceForIgnoreAppend(r *configs.Resource) *configs.Resource {
rc := *r

if r.Managed != nil {
mc := *r.Managed
if len(mc.IgnoreChanges) != 0 {
cp := make([]hcl.Traversal, len(mc.IgnoreChanges))
copy(cp, mc.IgnoreChanges)
mc.IgnoreChanges = cp
}
rc.Managed = &mc
}

return &rc
}
Loading