diff --git a/array_attribute.go b/array_attribute.go index 9751a0c..71359bc 100644 --- a/array_attribute.go +++ b/array_attribute.go @@ -18,10 +18,11 @@ var _ attributeChange = &ArrayAttributeChange{} // IsArrayAttributeChangeLine returns true if the line is a valid attribute change // This requires the line to start with "+", "-" or "~", not be followed with "resource" or "data", and ends with "[". +// Terraform may append ForcesReplacementComment after the opening bracket. func IsArrayAttributeChangeLine(line string) bool { line = strings.TrimSpace(line) - // validPrefix := strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") || strings.HasPrefix(line, "~") - validSuffix := strings.HasSuffix(line, "[") || IsOneLineEmptyArrayAttribute(line) + base := strings.TrimSpace(strings.TrimSuffix(line, ForcesReplacementComment)) + validSuffix := strings.HasSuffix(base, "[") || IsOneLineEmptyArrayAttribute(line) return validSuffix && !IsResourceChangeLine(line) } @@ -32,7 +33,9 @@ func IsArrayAttributeTerminator(line string) bool { // IsOneLineEmptyArrayAttribute returns true if the line ends with a "[]" func IsOneLineEmptyArrayAttribute(line string) bool { - return strings.HasSuffix(line, "[]") + line = strings.TrimSpace(line) + line = strings.TrimSuffix(line, ForcesReplacementComment) + return strings.HasSuffix(strings.TrimSpace(line), "[]") } // NewArrayAttributeChangeFromLine initializes an ArrayAttributeChange from a line containing an array attribute change @@ -57,10 +60,13 @@ func NewArrayAttributeChangeFromLine(line string) (*ArrayAttributeChange, error) UpdateType: DestroyResource, }, nil } else if strings.HasPrefix(line, "~") { - // replace + updateType := UpdateInPlaceResource + if strings.HasSuffix(strings.TrimSpace(line), ForcesReplacementComment) { + updateType = ForceReplaceResource + } return &ArrayAttributeChange{ Name: attributeName, - UpdateType: UpdateInPlaceResource, + UpdateType: updateType, }, nil } else { return &ArrayAttributeChange{ diff --git a/array_attribute_test.go b/array_attribute_test.go index bff3ea3..1d20abd 100644 --- a/array_attribute_test.go +++ b/array_attribute_test.go @@ -64,6 +64,14 @@ func TestNewArrayAttributeChangeFromLine(t *testing.T) { UpdateType: UpdateInPlaceResource, }, }, + "attribute changed forces replacement": { + line: `~ array_test = [` + ForcesReplacementComment, + shouldError: false, + expected: &ArrayAttributeChange{ + Name: "array_test", + UpdateType: ForceReplaceResource, + }, + }, "attribute is unchanged": { line: `attribute [`, shouldError: false, diff --git a/attribute.go b/attribute.go index 78f287c..7aa9038 100644 --- a/attribute.go +++ b/attribute.go @@ -11,6 +11,8 @@ const ( ATTRIBUTE_DEFINITON_DELIMITER = " = " SENSITIVE_VALUE = "(sensitive value)" COMPUTED_VALUE = "(known after apply)" + // ForcesReplacementComment is the suffix Terraform appends to attributes that trigger replace. + ForcesReplacementComment = " # forces replacement" ) type attributeChange interface { @@ -92,9 +94,9 @@ func NewAttributeChangeFromLine(line string) (*AttributeChange, error) { // replace updateType := UpdateInPlaceResource - if strings.HasSuffix(attribute[1], " # forces replacement") { + if strings.HasSuffix(attribute[1], ForcesReplacementComment) { updateType = ForceReplaceResource - attribute[1] = strings.TrimSuffix(attribute[1], " # forces replacement") + attribute[1] = strings.TrimSuffix(attribute[1], ForcesReplacementComment) } values := strings.Split(attribute[1], ATTRIBUTE_CHANGE_DELIMITER) @@ -112,11 +114,18 @@ func NewAttributeChangeFromLine(line string) (*AttributeChange, error) { UpdateType: updateType, }, nil } else { + valStr := strings.TrimSpace(attribute[1]) + updateType := NoOpResource + if strings.HasSuffix(valStr, ForcesReplacementComment) { + valStr = strings.TrimSpace(strings.TrimSuffix(valStr, ForcesReplacementComment)) + updateType = ForceReplaceResource + } + conv := doTypeConversion(valStr) return &AttributeChange{ Name: dequote(strings.TrimSpace(attribute[0])), - OldValue: doTypeConversion(attribute[1]), - NewValue: doTypeConversion(attribute[1]), - UpdateType: NoOpResource, + OldValue: conv, + NewValue: conv, + UpdateType: updateType, }, nil } } diff --git a/attribute_test.go b/attribute_test.go index f016e83..62f1fd1 100644 --- a/attribute_test.go +++ b/attribute_test.go @@ -171,6 +171,16 @@ func TestNewAttributeChangeFromLine(t *testing.T) { UpdateType: NoOpResource, }, }, + "unchanged attribute forces replacement": { + line: `id = "namespace-id"` + ForcesReplacementComment, + shouldError: false, + expected: &AttributeChange{ + Name: "id", + OldValue: "namespace-id", + NewValue: "namespace-id", + UpdateType: ForceReplaceResource, + }, + }, "resource line": { line: `+ resource "type" "name" {`, shouldError: true, diff --git a/heredoc_attribute.go b/heredoc_attribute.go index 045cfdc..0f49b32 100644 --- a/heredoc_attribute.go +++ b/heredoc_attribute.go @@ -65,7 +65,7 @@ func NewHeredocAttributeChangeFromLine(line string) (*HeredocAttributeChange, er } else if strings.HasPrefix(line, "~") { // replace updateType := UpdateInPlaceResource - if strings.HasSuffix(attribute[1], " # forces replacement") { + if strings.HasSuffix(attribute[1], ForcesReplacementComment) { updateType = ForceReplaceResource } diff --git a/jsonencode.go b/jsonencode.go index d8fea4c..f84ea01 100644 --- a/jsonencode.go +++ b/jsonencode.go @@ -57,7 +57,7 @@ func NewJSONEncodeAttributeChangeFromLine(line string) (*JSONEncodeAttributeChan } else if strings.HasPrefix(line, "~") { // replace updateType := UpdateInPlaceResource - if strings.HasSuffix(attribute[1], " # forces replacement") { + if strings.HasSuffix(attribute[1], ForcesReplacementComment) { updateType = ForceReplaceResource } diff --git a/map_attribute.go b/map_attribute.go index 7adc817..6e00648 100644 --- a/map_attribute.go +++ b/map_attribute.go @@ -15,10 +15,11 @@ var _ attributeChange = &MapAttributeChange{} // IsMapAttributeChangeLine returns true if the line is a valid attribute change // This requires the line to start with "+", "-" or "~", not be followed with "resource" or "data", and ends with "{". +// Terraform may append ForcesReplacementComment after the opening brace. func IsMapAttributeChangeLine(line string) bool { line = strings.TrimSpace(line) - // validPrefix := strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") || strings.HasPrefix(line, "~") - validSuffix := strings.HasSuffix(line, "{") || IsOneLineEmptyMapAttribute(line) + base := strings.TrimSpace(strings.TrimSuffix(line, ForcesReplacementComment)) + validSuffix := strings.HasSuffix(base, "{") || IsOneLineEmptyMapAttribute(line) return validSuffix && !IsResourceChangeLine(line) } @@ -29,7 +30,9 @@ func IsMapAttributeTerminator(line string) bool { // IsOneLineEmptyMapAttribute returns true if the line ends with a "{}" func IsOneLineEmptyMapAttribute(line string) bool { - return strings.HasSuffix(line, "{}") + line = strings.TrimSpace(line) + line = strings.TrimSuffix(line, ForcesReplacementComment) + return strings.HasSuffix(strings.TrimSpace(line), "{}") } // NewMapAttributeChangeFromLine initializes an AttributeChange from a line containing an attribute change @@ -54,10 +57,13 @@ func NewMapAttributeChangeFromLine(line string) (*MapAttributeChange, error) { UpdateType: DestroyResource, }, nil } else if strings.HasPrefix(line, "~") { - // replace + updateType := UpdateInPlaceResource + if strings.HasSuffix(strings.TrimSpace(line), ForcesReplacementComment) { + updateType = ForceReplaceResource + } return &MapAttributeChange{ Name: attributeName, - UpdateType: UpdateInPlaceResource, + UpdateType: updateType, }, nil } else { return &MapAttributeChange{ diff --git a/map_attribute_test.go b/map_attribute_test.go index d388cff..0e1d20d 100644 --- a/map_attribute_test.go +++ b/map_attribute_test.go @@ -64,6 +64,14 @@ func TestNewMapAttributeChangeFromLine(t *testing.T) { UpdateType: UpdateInPlaceResource, }, }, + "attribute changed forces replacement": { + line: `~ triggers = {` + ForcesReplacementComment, + shouldError: false, + expected: &MapAttributeChange{ + Name: "triggers", + UpdateType: ForceReplaceResource, + }, + }, "attribute is unchanged": { line: `attribute {`, shouldError: false, diff --git a/parse_test.go b/parse_test.go index f42b1e1..e008eda 100644 --- a/parse_test.go +++ b/parse_test.go @@ -575,6 +575,115 @@ func TestParse(t *testing.T) { }, }, }, + "forcereplace": { + file: "test/forcereplace.stdout", + expected: []*ResourceChange{ + &ResourceChange{ + Address: "module.mymodule.kubernetes_namespace.mynamespace", + ModuleAddress: "module.mymodule", + Type: "kubernetes_namespace", + Name: "mynamespace", + UpdateType: ForceReplaceResource, + AttributeChanges: []attributeChange{ + &AttributeChange{ + Name: "id", + OldValue: "namespace-id", + NewValue: "namespace-id", + UpdateType: ForceReplaceResource, + }, + &MapAttributeChange{ + Name: "metadata", + AttributeChanges: []attributeChange{ + &MapAttributeChange{ + Name: "annotations", + UpdateType: NoOpResource, + }, + &AttributeChange{ + Name: "generation", + OldValue: 0, + NewValue: 0, + UpdateType: NoOpResource, + }, + &MapAttributeChange{ + Name: "labels", + AttributeChanges: []attributeChange{ + &AttributeChange{ + Name: "label", + OldValue: "value", + NewValue: "value", + UpdateType: NoOpResource, + }, + &AttributeChange{ + Name: "other", + OldValue: "label", + NewValue: "label", + UpdateType: NoOpResource, + }, + &AttributeChange{ + Name: "newLabel", + OldValue: nil, + NewValue: "newLabel", + UpdateType: NewResource, + }, + }, + UpdateType: UpdateInPlaceResource, + }, + &AttributeChange{ + Name: "name", + OldValue: "my-namespace", + NewValue: "my-namespace", + UpdateType: NoOpResource, + }, + &AttributeChange{ + Name: "resource_version", + OldValue: "123", + NewValue: "123", + UpdateType: NoOpResource, + }, + &AttributeChange{ + Name: "self_link", + OldValue: "/api/v1/namespaces/my-namespace", + NewValue: "/api/v1/namespaces/my-namespace", + UpdateType: NoOpResource, + }, + &AttributeChange{ + Name: "uid", + OldValue: "some-uid-123", + NewValue: "some-uid-123", + UpdateType: NoOpResource, + }, + }, + UpdateType: ForceReplaceResource, + }, + &ArrayAttributeChange{ + Name: "array_test", + AttributeChanges: []attributeChange{ + &AttributeChange{ + OldValue: nil, + NewValue: "entry1", + UpdateType: NewResource, + }, + &AttributeChange{ + OldValue: "entry2", + NewValue: nil, + UpdateType: DestroyResource, + }, + }, + UpdateType: ForceReplaceResource, + }, + &ArrayAttributeChange{ + Name: "array_test2", + AttributeChanges: nil, + UpdateType: ForceReplaceResource, + }, + &MapAttributeChange{ + Name: "timeouts", + UpdateType: NoOpResource, + }, + }, + }, + }, + }, "jsonencode": { file: "test/jsonencode.stdout", expected: []*ResourceChange{ diff --git a/test/forcereplace.stdout b/test/forcereplace.stdout new file mode 100644 index 0000000..ff10ace --- /dev/null +++ b/test/forcereplace.stdout @@ -0,0 +1,38 @@ +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + ~ update in-place + +Terraform will perform the following actions: + + # module.mymodule.kubernetes_namespace.mynamespace must be replaced +-/+ resource "kubernetes_namespace" "mynamespace" { + id = "namespace-id" # forces replacement + + ~ metadata { # forces replacement + annotations = {} + generation = 0 + ~ labels = { + "label" = "value" + "other" = "label" + + "newLabel" = "newLabel" + # (5 unchanged elements hidden) + } + name = "my-namespace" + resource_version = "123" + self_link = "/api/v1/namespaces/my-namespace" + uid = "some-uid-123" + # (8 unchanged attributes hidden) + } + ~ array_test = [ # forces replacement + + "entry1", + - "entry2", + ] + ~ array_test2 = null -> [] # forces replacement + + timeouts {} + # (1 unchanged block hidden) + } + +Plan: 0 to add, 1 to change, 0 to destroy.