diff --git a/.changes/v1.15/ENHANCEMENTS-20260215-200907.yaml b/.changes/v1.15/ENHANCEMENTS-20260215-200907.yaml new file mode 100644 index 000000000000..85685aea60b7 --- /dev/null +++ b/.changes/v1.15/ENHANCEMENTS-20260215-200907.yaml @@ -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" diff --git a/internal/configs/module_call.go b/internal/configs/module_call.go index ff7c7417f80c..4a79d6915e7c 100644 --- a/internal/configs/module_call.go +++ b/internal/configs/module_call.go @@ -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) { @@ -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 @@ -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{ @@ -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, }) } } @@ -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"}, + }, +} diff --git a/internal/terraform/module_call_lifecycle_ignore_changes.go b/internal/terraform/module_call_lifecycle_ignore_changes.go new file mode 100644 index 000000000000..57e36c8f180e --- /dev/null +++ b/internal/terraform/module_call_lifecycle_ignore_changes.go @@ -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[] 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 .. + 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 +} diff --git a/internal/terraform/node_resource_abstract_instance.go b/internal/terraform/node_resource_abstract_instance.go index 332e61f0eb4b..fb7be8bf9b76 100644 --- a/internal/terraform/node_resource_abstract_instance.go +++ b/internal/terraform/node_resource_abstract_instance.go @@ -935,7 +935,7 @@ func (n *NodeAbstractResourceInstance) plan( // starting values. // Here we operate on the marked values, so as to revert any changes to the // marks as well as the value. - configValIgnored, ignoreChangeDiags := n.processIgnoreChanges(priorVal, origConfigVal, schema.Body) + configValIgnored, ignoreChangeDiags := n.processIgnoreChangesWithModuleCallLifecycle(ctx, priorVal, origConfigVal, schema.Body) diags = diags.Append(ignoreChangeDiags) if ignoreChangeDiags.HasErrors() { return nil, nil, deferred, keyData, diags @@ -3123,3 +3123,74 @@ func getRequiredReplaces(priorVal, plannedNewVal cty.Value, writeOnly []cty.Path return reqRep, diags } + +// rootConfigForModuleCallIgnore attempts to retrieve the root *configs.Config +// from the current EvalContext. In normal Terraform runs this is a BuiltinEvalContext. +func rootConfigForModuleCallIgnore(ctx EvalContext) *configs.Config { + switch c := ctx.(type) { + case *BuiltinEvalContext: + if c.Evaluator == nil { + return nil + } + return c.Evaluator.Config + case interface{ Evaluator() *Evaluator }: + ev := c.Evaluator() + if ev == nil { + return nil + } + return ev.Config + default: + return nil + } +} + +// processIgnoreChangesWithModuleCallLifecycle is a thin wrapper around the existing +// processIgnoreChanges, but it augments the resource's ignore_changes rules with +// any matching ignore_changes rules coming from module-call lifecycle blocks. +// +// This must run at resource-instance time (not during graph config attachment), +// because only resource instances have the module instance keys needed for self["k"] filtering. +func (n *NodeAbstractResourceInstance) processIgnoreChangesWithModuleCallLifecycle( + ctx EvalContext, + priorVal, configVal cty.Value, + schema *configschema.Block, +) (cty.Value, tfdiags.Diagnostics) { + + // Preserve existing behavior if we can't evaluate module-call lifecycle ignores. + root := rootConfigForModuleCallIgnore(ctx) + if root == nil { + return n.processIgnoreChanges(priorVal, configVal, schema) + } + if n.Config == nil || n.Config.Mode != addrs.ManagedResourceMode || n.Config.Managed == nil { + return n.processIgnoreChanges(priorVal, configVal, schema) + } + + // Use the *resource instance* address which includes module instance keys. + resInst := n.ResourceInstanceAddr() + + extra, ignoreAll := moduleCallExtraIgnoreTraversals(root, resInst) + if !ignoreAll && len(extra) == 0 { + return n.processIgnoreChanges(priorVal, configVal, schema) + } + + // Temporarily swap n.Config for this one call so we can reuse the existing + // processIgnoreChanges implementation without duplicating it. + saved := n.Config + attach := copyResourceForIgnoreAppend(saved) + + // Should already be non-nil for managed resources. + if attach.Managed == nil { + attach.Managed = &configs.ManagedResource{} + } + + if ignoreAll { + attach.Managed.IgnoreAllChanges = true + } + attach.Managed.IgnoreChanges = append(attach.Managed.IgnoreChanges, extra...) + + n.Config = attach + ignored, diags := n.processIgnoreChanges(priorVal, configVal, schema) + n.Config = saved + + return ignored, diags +}