Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Configurable instance replacement via lifecycle replace_triggered_by #30900

Merged
merged 15 commits into from
Apr 22, 2022
Merged
Show file tree
Hide file tree
Changes from 14 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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ require (
github.com/hashicorp/go-uuid v1.0.2
github.com/hashicorp/go-version v1.3.0
github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f
github.com/hashicorp/hcl/v2 v2.11.1
github.com/hashicorp/hcl/v2 v2.11.2-0.20220408161043-2ef09d129d96
github.com/hashicorp/terraform-config-inspect v0.0.0-20210209133302-4fd17a0faac2
github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734
github.com/jmespath/go-jmespath v0.4.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -434,8 +434,8 @@ github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f h1:UdxlrJz4JOnY8W+Db
github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w=
github.com/hashicorp/hcl/v2 v2.0.0/go.mod h1:oVVDG71tEinNGYCxinCYadcmKU9bglqW9pV3txagJ90=
github.com/hashicorp/hcl/v2 v2.3.0/go.mod h1:d+FwDBbOLvpAM3Z6J7gPj/VoAGkNe/gm352ZhjJ/Zv8=
github.com/hashicorp/hcl/v2 v2.11.1 h1:yTyWcXcm9XB0TEkyU/JCRU6rYy4K+mgLtzn2wlrJbcc=
github.com/hashicorp/hcl/v2 v2.11.1/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg=
github.com/hashicorp/hcl/v2 v2.11.2-0.20220408161043-2ef09d129d96 h1:RO/o1b/ZxMUCIgQiKF7qdk0YRwkILQF4KwO39mm9itA=
github.com/hashicorp/hcl/v2 v2.11.2-0.20220408161043-2ef09d129d96/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg=
github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d h1:9ARUJJ1VVynB176G1HCwleORqCaXm/Vx0uUi0dL26I0=
github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d/go.mod h1:Yog5+CPEM3c99L1CL2CFCYoSzgWm5vTU58idbRUaLik=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
Expand Down
2 changes: 2 additions & 0 deletions internal/command/format/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ func ResourceChange(
buf.WriteString(fmt.Sprintf(color.Color("[bold] # %s[reset] is tainted, so must be [bold][red]replaced"), dispAddr))
case plans.ResourceInstanceReplaceByRequest:
buf.WriteString(fmt.Sprintf(color.Color("[bold] # %s[reset] will be [bold][red]replaced[reset], as requested"), dispAddr))
case plans.ResourceInstanceReplaceByTriggers:
buf.WriteString(fmt.Sprintf(color.Color("[bold] # %s[reset] will be [bold][red]replaced[reset] due to changes in replace_triggered_by"), dispAddr))
default:
buf.WriteString(fmt.Sprintf(color.Color("[bold] # %s[reset] must be [bold][red]replaced"), dispAddr))
}
Expand Down
2 changes: 2 additions & 0 deletions internal/command/jsonplan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,8 @@ func (p *plan) marshalResourceChanges(resources []*plans.ResourceInstanceChangeS
r.ActionReason = "replace_because_tainted"
case plans.ResourceInstanceReplaceByRequest:
r.ActionReason = "replace_by_request"
case plans.ResourceInstanceReplaceByTriggers:
r.ActionReason = "replace_by_triggers"
case plans.ResourceInstanceDeleteBecauseNoResourceConfig:
r.ActionReason = "delete_because_no_resource_config"
case plans.ResourceInstanceDeleteBecauseWrongRepetition:
Expand Down
13 changes: 8 additions & 5 deletions internal/command/views/json/change.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,12 @@ func changeAction(action plans.Action) ChangeAction {
type ChangeReason string

const (
ReasonNone ChangeReason = ""
ReasonTainted ChangeReason = "tainted"
ReasonRequested ChangeReason = "requested"
ReasonCannotUpdate ChangeReason = "cannot_update"
ReasonUnknown ChangeReason = "unknown"
ReasonNone ChangeReason = ""
ReasonTainted ChangeReason = "tainted"
ReasonRequested ChangeReason = "requested"
ReasonReplaceTriggeredBy ChangeReason = "replace_triggered_by"
ReasonCannotUpdate ChangeReason = "cannot_update"
ReasonUnknown ChangeReason = "unknown"

ReasonDeleteBecauseNoResourceConfig ChangeReason = "delete_because_no_resource_config"
ReasonDeleteBecauseWrongRepetition ChangeReason = "delete_because_wrong_repetition"
Expand All @@ -91,6 +92,8 @@ func changeReason(reason plans.ResourceInstanceChangeActionReason) ChangeReason
return ReasonRequested
case plans.ResourceInstanceReplaceBecauseCannotUpdate:
return ReasonCannotUpdate
case plans.ResourceInstanceReplaceByTriggers:
return ReasonReplaceTriggeredBy
case plans.ResourceInstanceDeleteBecauseNoResourceConfig:
return ReasonDeleteBecauseNoResourceConfig
case plans.ResourceInstanceDeleteBecauseWrongRepetition:
Expand Down
125 changes: 124 additions & 1 deletion internal/configs/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclsyntax"
hcljson "github.com/hashicorp/hcl/v2/json"

"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/lang"
"github.com/hashicorp/terraform/internal/tfdiags"
)

// Resource represents a "resource" or "data" block in a module or file.
Expand All @@ -27,6 +30,8 @@ type Resource struct {

DependsOn []hcl.Traversal

TriggersReplacement []hcl.Expression

// Managed is populated only for Mode = addrs.ManagedResourceMode,
// containing the additional fields that apply to managed resources.
// For all other resource modes, this field is nil.
Expand Down Expand Up @@ -177,6 +182,13 @@ func decodeResourceBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagno
r.Managed.PreventDestroySet = true
}

if attr, exists := lcContent.Attributes["replace_triggered_by"]; exists {
exprs, hclDiags := decodeReplaceTriggeredBy(attr.Expr)
diags = diags.Extend(hclDiags)

r.TriggersReplacement = append(r.TriggersReplacement, exprs...)
}

if attr, exists := lcContent.Attributes["ignore_changes"]; exists {

// ignore_changes can either be a list of relative traversals
Expand Down Expand Up @@ -237,7 +249,6 @@ func decodeResourceBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagno
}

}

}

for _, block := range lcContent.Blocks {
Expand Down Expand Up @@ -481,6 +492,115 @@ func decodeDataBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagnostic
return r, diags
}

// decodeReplaceTriggeredBy decodes and does basic validation of the
// replace_triggered_by expressions, ensuring they only contains references to
// a single resource, and the only extra variables are count.index or each.key.
func decodeReplaceTriggeredBy(expr hcl.Expression) ([]hcl.Expression, hcl.Diagnostics) {
// Since we are manually parsing the replace_triggered_by argument, we
// need to specially handle json configs, in which case the values will
// be json strings rather than hcl. To simplify parsing however we will
// decode the individual list elements, rather than the entire expression.
isJSON := hcljson.IsJSONExpression(expr)

exprs, diags := hcl.ExprList(expr)

for i, expr := range exprs {
if isJSON {
// We can abuse the hcl json api and rely on the fact that calling
// Value on a json expression with no EvalContext will return the
// raw string. We can then parse that as normal hcl syntax, and
// continue with the decoding.
v, ds := expr.Value(nil)
diags = diags.Extend(ds)
if diags.HasErrors() {
continue
}

expr, ds = hclsyntax.ParseExpression([]byte(v.AsString()), "", expr.Range().Start)
diags = diags.Extend(ds)
if diags.HasErrors() {
continue
}
// make sure to swap out the expression we're returning too
exprs[i] = expr
}

refs, refDiags := lang.ReferencesInExpr(expr)
for _, diag := range refDiags {
severity := hcl.DiagError
if diag.Severity() == tfdiags.Warning {
severity = hcl.DiagWarning
}

desc := diag.Description()

diags = append(diags, &hcl.Diagnostic{
Severity: severity,
Summary: desc.Summary,
Detail: desc.Detail,
Subject: expr.Range().Ptr(),
})
}

if refDiags.HasErrors() {
continue
}

resourceCount := 0
for _, ref := range refs {
switch sub := ref.Subject.(type) {
case addrs.Resource, addrs.ResourceInstance:
resourceCount++

case addrs.ForEachAttr:
if sub.Name != "key" {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid each reference in replace_triggered_by expression",
Detail: "Only each.key may be used in replace_triggered_by.",
Subject: expr.Range().Ptr(),
})
}
case addrs.CountAttr:
if sub.Name != "index" {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid count reference in replace_triggered_by expression",
Detail: "Only count.index may be used in replace_triggered_by.",
Subject: expr.Range().Ptr(),
})
}
default:
// everything else should be simple traversals
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid reference in replace_triggered_by expression",
Detail: "Only resources, count.index, and each.key may be used in replace_triggered_by.",
Subject: expr.Range().Ptr(),
})
}
}

switch {
case resourceCount == 0:
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid replace_triggered_by expression",
Detail: "Missing resource reference in replace_triggered_by_expression.",
Subject: expr.Range().Ptr(),
})
case resourceCount > 1:
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid replace_triggered_by expression",
Detail: "Multiple resource references in replace_triggered_by_expression.",
jbardin marked this conversation as resolved.
Show resolved Hide resolved
Subject: expr.Range().Ptr(),
})
}
}
return exprs, diags
}

type ProviderConfigRef struct {
Name string
NameRange hcl.Range
Expand Down Expand Up @@ -640,6 +760,9 @@ var resourceLifecycleBlockSchema = &hcl.BodySchema{
{
Name: "ignore_changes",
},
{
Name: "replace_triggered_by",
},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "precondition"},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
resource "test_resource" "a" {
for_each = var.input
lifecycle {
// cannot use each.val
replace_triggered_by = [ test_resource.b[each.val] ]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
resource "test_resource" "a" {
count = 1
lifecycle {
replace_triggered_by = [ not_a_reference ]
}
}
7 changes: 7 additions & 0 deletions internal/configs/testdata/valid-files/resources.tf
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ resource "aws_security_group" "firewall" {
}

resource "aws_instance" "web" {
count = 2
ami = "ami-1234"
security_groups = [
"foo",
Expand All @@ -40,3 +41,9 @@ resource "aws_instance" "web" {
aws_security_group.firewall,
]
}

resource "aws_instance" "depends" {
lifecycle {
replace_triggered_by = [ aws_instance.web[1], aws_security_group.firewall.id ]
}
}
18 changes: 18 additions & 0 deletions internal/configs/testdata/valid-files/resources.tf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"resource": {
"test_object": {
"a": {
"count": 1,
"test_string": "new"
},
"b": {
"count": 1,
"lifecycle": {
"replace_triggered_by": [
"test_object.a[count.index].test_string"
]
}
}
}
}
}
20 changes: 20 additions & 0 deletions internal/plans/changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ func (c *Changes) ResourceInstance(addr addrs.AbsResourceInstance) *ResourceInst

}

// InstancesForAbsResource returns the planned change for the current objects
// of the resource instances of the given address, if any. Returns nil if no
// changes are planned.
func (c *Changes) InstancesForAbsResource(addr addrs.AbsResource) []*ResourceInstanceChangeSrc {
var changes []*ResourceInstanceChangeSrc
for _, rc := range c.Resources {
resAddr := rc.Addr.ContainingResource()
if resAddr.Equal(addr) && rc.DeposedKey == states.NotDeposed {
changes = append(changes, rc)
}
}

return changes
}

// InstancesForConfigResource returns the planned change for the current objects
// of the resource instances of the given address, if any. Returns nil if no
// changes are planned.
Expand Down Expand Up @@ -345,6 +360,11 @@ const (
// planning option.)
ResourceInstanceReplaceByRequest ResourceInstanceChangeActionReason = 'R'

// ResourceInstanceReplaceByTriggers indicates that the resource instance
// is planned to be replaced because of a corresponding change in a
// replace_triggered_by reference.
ResourceInstanceReplaceByTriggers ResourceInstanceChangeActionReason = 'D'

// ResourceInstanceReplaceBecauseCannotUpdate indicates that the resource
// instance is planned to be replaced because the provider has indicated
// that a requested change cannot be applied as an update.
Expand Down
23 changes: 22 additions & 1 deletion internal/plans/changes_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func (cs *ChangesSync) GetResourceInstanceChange(addr addrs.AbsResourceInstance,
panic(fmt.Sprintf("unsupported generation value %#v", gen))
}

// GetChangesForConfigResource searched the set of resource instance
// GetChangesForConfigResource searches the set of resource instance
// changes and returns all changes related to a given configuration address.
// This is be used to find possible changes related to a configuration
// reference.
Expand All @@ -103,6 +103,27 @@ func (cs *ChangesSync) GetChangesForConfigResource(addr addrs.ConfigResource) []
return changes
}

// GetChangesForAbsResource searches the set of resource instance
// changes and returns all changes related to a given configuration address.
//
// If no such changes exist, nil is returned.
//
// The returned objects are a deep copy of the change recorded in the plan, so
// callers may mutate them although it's generally better (less confusing) to
// treat planned changes as immutable after they've been initially constructed.
func (cs *ChangesSync) GetChangesForAbsResource(addr addrs.AbsResource) []*ResourceInstanceChangeSrc {
if cs == nil {
panic("GetChangesForAbsResource on nil ChangesSync")
}
cs.lock.Lock()
defer cs.lock.Unlock()
var changes []*ResourceInstanceChangeSrc
for _, c := range cs.changes.InstancesForAbsResource(addr) {
changes = append(changes, c.DeepCopy())
}
return changes
}

// RemoveResourceInstanceChange searches the set of resource instance changes
// for one matching the given address and generation, and removes it from the
// set if it exists.
Expand Down
Loading