From e9fe0f1118e77c0ba8a78dad264fcafbccebb1b0 Mon Sep 17 00:00:00 2001 From: Ronny Orot Date: Wed, 21 Feb 2024 10:31:44 +0200 Subject: [PATCH] Add support for removed block (#1158) Signed-off-by: Ronny Orot --- CHANGELOG.md | 1 + internal/addrs/module.go | 125 +++ internal/addrs/module_instance.go | 57 +- internal/addrs/parse_target.go | 155 ++-- internal/addrs/removable.go | 26 + internal/addrs/remove_endpoint.go | 78 ++ internal/addrs/remove_endpoint_test.go | 213 +++++ internal/addrs/resource.go | 4 + internal/command/format/format.go | 2 + internal/command/jsonformat/plan.go | 26 +- internal/command/jsonformat/plan_test.go | 198 +++++ internal/command/jsonplan/plan.go | 14 +- internal/command/views/json/change.go | 3 + internal/command/views/json/hook.go | 6 + internal/configs/module.go | 21 +- internal/configs/parser_config.go | 10 + internal/configs/removed.go | 49 ++ internal/configs/removed_test.go | 198 +++++ .../testdata/valid-files/refactoring.tf | 13 + .../removed-blocks/removed-blocks-1.tf | 19 + .../removed-blocks/removed-blocks-2.tf | 5 + internal/plans/action.go | 1 + internal/plans/action_string.go | 12 +- .../plans/internal/planproto/planfile.pb.go | 76 +- .../plans/internal/planproto/planfile.proto | 3 +- internal/plans/planfile/tfplan.go | 6 + internal/plans/planfile/tfplan_test.go | 22 + internal/refactoring/remove_statement.go | 124 +++ internal/refactoring/remove_statement_test.go | 85 ++ .../child/main.tf | 2 + .../main.tf | 7 + .../child/main.tf | 2 + .../main.tf | 7 + .../main.tf | 6 + .../child/grandchild/main.tf | 7 + .../valid-remove-statements/child/main.tf | 11 + .../valid-remove-statements/main.tf | 15 + internal/tofu/context_apply2_test.go | 61 ++ internal/tofu/context_plan.go | 14 + internal/tofu/context_plan2_test.go | 758 ++++++++++++++++++ internal/tofu/graph_builder_plan.go | 10 +- .../tofu/node_resource_abstract_instance.go | 25 + ...oy_deposed.go => node_resource_deposed.go} | 85 +- ..._test.go => node_resource_deposed_test.go} | 277 ++++--- internal/tofu/node_resource_forget.go | 75 ++ internal/tofu/node_resource_plan_orphan.go | 25 +- .../tofu/node_resource_plan_orphan_test.go | 184 +++-- internal/tofu/transform_diff.go | 34 +- website/docs/internals/json-format.mdx | 12 +- 49 files changed, 2864 insertions(+), 305 deletions(-) create mode 100644 internal/addrs/removable.go create mode 100644 internal/addrs/remove_endpoint.go create mode 100644 internal/addrs/remove_endpoint_test.go create mode 100644 internal/configs/removed.go create mode 100644 internal/configs/removed_test.go create mode 100644 internal/configs/testdata/valid-files/refactoring.tf create mode 100644 internal/configs/testdata/valid-modules/removed-blocks/removed-blocks-1.tf create mode 100644 internal/configs/testdata/valid-modules/removed-blocks/removed-blocks-2.tf create mode 100644 internal/refactoring/remove_statement.go create mode 100644 internal/refactoring/remove_statement_test.go create mode 100644 internal/refactoring/testdata/remove-statement/not-valid-module-block-still-exist/child/main.tf create mode 100644 internal/refactoring/testdata/remove-statement/not-valid-module-block-still-exist/main.tf create mode 100644 internal/refactoring/testdata/remove-statement/not-valid-nested-resource-block-still-exist/child/main.tf create mode 100644 internal/refactoring/testdata/remove-statement/not-valid-nested-resource-block-still-exist/main.tf create mode 100644 internal/refactoring/testdata/remove-statement/not-valid-resource-block-still-exist/main.tf create mode 100644 internal/refactoring/testdata/remove-statement/valid-remove-statements/child/grandchild/main.tf create mode 100644 internal/refactoring/testdata/remove-statement/valid-remove-statements/child/main.tf create mode 100644 internal/refactoring/testdata/remove-statement/valid-remove-statements/main.tf rename internal/tofu/{node_resource_destroy_deposed.go => node_resource_deposed.go} (80%) rename internal/tofu/{node_resource_destroy_deposed_test.go => node_resource_deposed_test.go} (52%) create mode 100644 internal/tofu/node_resource_forget.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c4833db724..9b4355c201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ENHANCEMENTS: * Allow test run blocks to reference previous run block's module outputs ([#1129](https://github.com/opentofu/opentofu/pull/1129)) * Support the XDG Base Directory Specification ([#1200](https://github.com/opentofu/opentofu/pull/1200)) * Allow referencing the output from a test run in the local variables block of another run (tofu test). ([#1254](https://github.com/opentofu/opentofu/pull/1254)) +* Add support for a `removed` block that allows users to remove resources or modules from the state without destroying them. ([#1158](https://github.com/opentofu/opentofu/pull/1158)) BUG FIXES: * `tofu test` resources cleanup at the end of tests changed to use simple reverse run block order. ([#1043](https://github.com/opentofu/opentofu/pull/1043)) diff --git a/internal/addrs/module.go b/internal/addrs/module.go index 90fe014e4a..81becd077a 100644 --- a/internal/addrs/module.go +++ b/internal/addrs/module.go @@ -7,6 +7,9 @@ package addrs import ( "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/opentofu/opentofu/internal/tfdiags" ) // Module is an address for a module call within configuration. This is @@ -170,3 +173,125 @@ func (m Module) Ancestors() []Module { func (m Module) configMoveableSigil() { // ModuleInstance is moveable } +func (m Module) configRemovableSigil() { + // Empty function so Module will fulfill the requirements of the removable interface +} + +// parseModulePrefix parses a module address from the given traversal, +// returning the module address and the remaining traversal. +// For example, if the input traversal is ["module","a","module","b", +// "null_resource", example_resource"], the output module will be ["a", "b"] +// and the output remaining traversal will be ["null_resource", +// "example_resource"]. +// This function only supports module addresses without instance keys (as the +// returned Module struct doesn't support instance keys) and will return an +// error if it encounters one. +func parseModulePrefix(traversal hcl.Traversal) (Module, hcl.Traversal, tfdiags.Diagnostics) { + remain := traversal + var module Module + var diags tfdiags.Diagnostics + + for len(remain) > 0 { + moduleName, isModule, moduleNameDiags := getModuleName(remain) + diags = diags.Append(moduleNameDiags) + + if !isModule || diags.HasErrors() { + break + } + + // Because this is a valid module address, we can safely assume that + // the first two elements are "module" and the module name + remain = remain[2:] + + if len(remain) > 0 { + // We don't allow module instances as part of the module address + if _, ok := remain[0].(hcl.TraverseIndex); ok { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Module instance address with keys is not allowed", + Detail: "Module address cannot be a module instance (e.g. \"module.a[0]\"), it must be a module instead (e.g. \"module.a\").", + Subject: remain[0].SourceRange().Ptr(), + }) + + return module, remain, diags + } + } + + module = append(module, moduleName) + } + + var retRemain hcl.Traversal + if len(remain) > 0 { + retRemain = make(hcl.Traversal, len(remain)) + copy(retRemain, remain) + // The first element here might be either a TraverseRoot or a + // TraverseAttr, depending on whether we had a module address on the + // front. To make life easier for callers, we'll normalize to always + // start with a TraverseRoot. + if tt, ok := retRemain[0].(hcl.TraverseAttr); ok { + retRemain[0] = hcl.TraverseRoot{ + Name: tt.Name, + SrcRange: tt.SrcRange, + } + } + } + + return module, retRemain, diags +} + +func getModuleName(remain hcl.Traversal) (moduleName string, isModule bool, diags tfdiags.Diagnostics) { + if len(remain) == 0 { + // If the address is empty, then we can't possibly have a module address + return moduleName, false, diags + } + + var next string + switch tt := remain[0].(type) { + case hcl.TraverseRoot: + next = tt.Name + case hcl.TraverseAttr: + next = tt.Name + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address operator", + Detail: "Module address prefix must be followed by dot and then a name.", + Subject: remain[0].SourceRange().Ptr(), + }) + + return moduleName, false, diags + } + + if next != "module" { + return moduleName, false, diags + } + + kwRange := remain[0].SourceRange() + remain = remain[1:] + // If we have the prefix "module" then we should be followed by a + // module call name, as an attribute + if len(remain) == 0 { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address operator", + Detail: "Prefix \"module.\" must be followed by a module name.", + Subject: &kwRange, + }) + + return moduleName, false, diags + } + + switch tt := remain[0].(type) { + case hcl.TraverseAttr: + moduleName = tt.Name + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address operator", + Detail: "Prefix \"module.\" must be followed by a module name.", + Subject: remain[0].SourceRange().Ptr(), + }) + return moduleName, false, diags + } + return moduleName, true, diags +} diff --git a/internal/addrs/module_instance.go b/internal/addrs/module_instance.go index 2a724edc31..85d012ba33 100644 --- a/internal/addrs/module_instance.go +++ b/internal/addrs/module_instance.go @@ -82,66 +82,31 @@ func ParseModuleInstanceStr(str string) (ModuleInstance, tfdiags.Diagnostics) { return addr, diags } +// parseModuleInstancePrefix parses a module instance address from the given +// traversal, returning the module instance address and the remaining +// traversal. +// This function supports module addresses with and without instance keys. func parseModuleInstancePrefix(traversal hcl.Traversal) (ModuleInstance, hcl.Traversal, tfdiags.Diagnostics) { remain := traversal var mi ModuleInstance var diags tfdiags.Diagnostics -LOOP: for len(remain) > 0 { - var next string - switch tt := remain[0].(type) { - case hcl.TraverseRoot: - next = tt.Name - case hcl.TraverseAttr: - next = tt.Name - default: - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid address operator", - Detail: "Module address prefix must be followed by dot and then a name.", - Subject: remain[0].SourceRange().Ptr(), - }) - break LOOP - } + moduleName, isModule, moduleNameDiags := getModuleName(remain) + diags = diags.Append(moduleNameDiags) - if next != "module" { + if !isModule || diags.HasErrors() { break } - kwRange := remain[0].SourceRange() - remain = remain[1:] - // If we have the prefix "module" then we should be followed by an - // module call name, as an attribute, and then optionally an index step - // giving the instance key. - if len(remain) == 0 { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid address operator", - Detail: "Prefix \"module.\" must be followed by a module name.", - Subject: &kwRange, - }) - break - } - - var moduleName string - switch tt := remain[0].(type) { - case hcl.TraverseAttr: - moduleName = tt.Name - default: - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid address operator", - Detail: "Prefix \"module.\" must be followed by a module name.", - Subject: remain[0].SourceRange().Ptr(), - }) - break LOOP - } - remain = remain[1:] + // Because this is a valid module address, we can safely assume that + // the first two elements are "module" and the module name + remain = remain[2:] step := ModuleInstanceStep{ Name: moduleName, } + // Check for optional module instance key if len(remain) > 0 { if idx, ok := remain[0].(hcl.TraverseIndex); ok { remain = remain[1:] diff --git a/internal/addrs/parse_target.go b/internal/addrs/parse_target.go index 42e2ff586f..98997689fc 100644 --- a/internal/addrs/parse_target.go +++ b/internal/addrs/parse_target.go @@ -80,54 +80,8 @@ func parseResourceInstanceUnderModule(moduleAddr ModuleInstance, remain hcl.Trav remain = remain[1:] } - if len(remain) < 2 { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid address", - Detail: "Resource specification must include a resource type and name.", - Subject: remain.SourceRange().Ptr(), - }) - return AbsResourceInstance{}, diags - } - - var typeName, name string - switch tt := remain[0].(type) { - case hcl.TraverseRoot: - typeName = tt.Name - case hcl.TraverseAttr: - typeName = tt.Name - default: - switch mode { - case ManagedResourceMode: - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid address", - Detail: "A resource type name is required.", - Subject: remain[0].SourceRange().Ptr(), - }) - case DataResourceMode: - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid address", - Detail: "A data source name is required.", - Subject: remain[0].SourceRange().Ptr(), - }) - default: - panic("unknown mode") - } - return AbsResourceInstance{}, diags - } - - switch tt := remain[1].(type) { - case hcl.TraverseAttr: - name = tt.Name - default: - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid address", - Detail: "A resource name is required.", - Subject: remain[1].SourceRange().Ptr(), - }) + typeName, name, diags := parseResourceTypeAndName(remain, mode) + if diags.HasErrors() { return AbsResourceInstance{}, diags } @@ -169,6 +123,111 @@ func parseResourceInstanceUnderModule(moduleAddr ModuleInstance, remain hcl.Trav } } +// parseResourceUnderModule is a helper function that parses a traversal, which +// is an address (or a part of an address) that describes a resource (e.g. +// ["null_resource," "boop"] or ["data", "null_data_source," "bip"]), under a +// module. It returns the ConfigResource that represents the resource address. +// It does not support addresses of resources with instance keys, and will +// return an error if it encounters one (unlike +// parseResourceInstanceUnderModule). +// This function does not expect to encounter a module prefix in the traversal, +// as it should be processed by parseModulePrefix first. +func parseResourceUnderModule(moduleAddr Module, remain hcl.Traversal) (ConfigResource, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + mode := ManagedResourceMode + if remain.RootName() == "data" { + mode = DataResourceMode + remain = remain[1:] + } + + typeName, name, diags := parseResourceTypeAndName(remain, mode) + if diags.HasErrors() { + return ConfigResource{}, diags + } + + remain = remain[2:] + switch len(remain) { + case 0: + return moduleAddr.Resource(mode, typeName, name), diags + case 1: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Resource instance address with keys is not allowed", + Detail: "Resource address cannot be a resource instance (e.g. \"null_resource.a[0]\"), it must be a resource instead (e.g. \"null_resource.a\").", + Subject: remain[0].SourceRange().Ptr(), + }) + return ConfigResource{}, diags + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "Unexpected extra operators after address.", + Subject: remain[1].SourceRange().Ptr(), + }) + return ConfigResource{}, diags + } +} + +// parseResourceTypeAndName is a helper function that parses a traversal, which +// is an address (or a part of an address) that describes a resource (e.g. +// ["null_resource," "boop"]) and returns its type and name. +// It is used in parseResourceUnderModule and parseResourceInstanceUnderModule, +// and does not expect to encounter a module prefix in the traversal. +func parseResourceTypeAndName(remain hcl.Traversal, mode ResourceMode) (typeName, name string, diags tfdiags.Diagnostics) { + if len(remain) < 2 { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "Resource specification must include a resource type and name.", + Subject: remain.SourceRange().Ptr(), + }) + return typeName, name, diags + } + + switch tt := remain[0].(type) { + case hcl.TraverseRoot: + typeName = tt.Name + case hcl.TraverseAttr: + typeName = tt.Name + default: + switch mode { + case ManagedResourceMode: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "A resource type name is required.", + Subject: remain[0].SourceRange().Ptr(), + }) + case DataResourceMode: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "A data source name is required.", + Subject: remain[0].SourceRange().Ptr(), + }) + default: + panic("unknown mode") + } + return typeName, name, diags + } + + switch tt := remain[1].(type) { + case hcl.TraverseAttr: + name = tt.Name + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "A resource name is required.", + Subject: remain[1].SourceRange().Ptr(), + }) + return typeName, name, diags + } + + return typeName, name, diags +} + // ParseTargetStr is a helper wrapper around ParseTarget that takes a string // and parses it with the HCL native syntax traversal parser before // interpreting it. diff --git a/internal/addrs/removable.go b/internal/addrs/removable.go new file mode 100644 index 0000000000..7ab0d9bfe0 --- /dev/null +++ b/internal/addrs/removable.go @@ -0,0 +1,26 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package addrs + +// ConfigRemovable is an interface implemented by address types that represents +// the destination of a "removed" statement in configuration. +// +// Note that ConfigRemovable might represent: +// 1. An absolute address relative to the root of the configuration. +// 2. A direct representation of these in configuration where the author gives an +// address relative to the current module where the address is defined. +type ConfigRemovable interface { + Targetable + configRemovableSigil() + + String() string +} + +// The following are all the possible ConfigRemovable address types: +var ( + _ ConfigRemovable = ConfigResource{} + _ ConfigRemovable = Module(nil) +) diff --git a/internal/addrs/remove_endpoint.go b/internal/addrs/remove_endpoint.go new file mode 100644 index 0000000000..fff457d94d --- /dev/null +++ b/internal/addrs/remove_endpoint.go @@ -0,0 +1,78 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package addrs + +import ( + "github.com/hashicorp/hcl/v2" + "github.com/opentofu/opentofu/internal/tfdiags" +) + +// RemoveEndpoint is to ConfigRemovable what Target is to Targetable: +// a wrapping struct that captures the result of decoding an HCL +// traversal representing a relative path from the current module to +// a removable object. It is very similar to MoveEndpoint. +// +// Its purpose is to represent the "from" address in a "removed" block +// in the configuration. +// +// To obtain a full address from a RemoveEndpoint we need to combine it +// with any ancestor modules in the configuration +type RemoveEndpoint struct { + // SourceRange is the location of the physical endpoint address + // in configuration, if this RemoveEndpoint was decoded from a + // configuration expression. + SourceRange tfdiags.SourceRange + + // the representation of our relative address as a ConfigRemovable + RelSubject ConfigRemovable +} + +// ParseRemoveEndpoint attempts to interpret the given traversal as a +// "remove endpoint" address, which is a relative path from the module containing +// the traversal to a removable object in either the same module or in some +// child module. +// +// This deals only with the syntactic element of a remove endpoint expression +// in configuration. Before the result will be useful you'll need to combine +// it with the address of the module where it was declared in order to get +// an absolute address relative to the root module. +func ParseRemoveEndpoint(traversal hcl.Traversal) (*RemoveEndpoint, tfdiags.Diagnostics) { + path, remain, diags := parseModulePrefix(traversal) + if diags.HasErrors() { + return nil, diags + } + + rng := tfdiags.SourceRangeFromHCL(traversal.SourceRange()) + + if len(remain) == 0 { + return &RemoveEndpoint{ + RelSubject: path, + SourceRange: rng, + }, diags + } + + riAddr, moreDiags := parseResourceUnderModule(path, remain) + diags = diags.Append(moreDiags) + if diags.HasErrors() { + return nil, diags + } + + if riAddr.Resource.Mode == DataResourceMode { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Data source address is not allowed", + Detail: "Data sources cannot be destroyed, and therefore, 'removed' blocks are not allowed to target them. To remove data sources from the state, you should remove the data source block from the configuration.", + Subject: traversal.SourceRange().Ptr(), + }) + + return nil, diags + } + + return &RemoveEndpoint{ + RelSubject: riAddr, + SourceRange: rng, + }, diags +} diff --git a/internal/addrs/remove_endpoint_test.go b/internal/addrs/remove_endpoint_test.go new file mode 100644 index 0000000000..f2f68bd31c --- /dev/null +++ b/internal/addrs/remove_endpoint_test.go @@ -0,0 +1,213 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package addrs + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +func TestParseRemoveEndpoint(t *testing.T) { + tests := []struct { + Input string + WantRel ConfigRemovable + WantErr string + }{ + { + `foo.bar`, + ConfigResource{ + Module: RootModule, + Resource: Resource{ + + Mode: ManagedResourceMode, + Type: "foo", + Name: "bar", + }, + }, + ``, + }, + { + `module.boop`, + Module{"boop"}, + ``, + }, + { + `module.boop.foo.bar`, + ConfigResource{ + Module: Module{"boop"}, + Resource: Resource{ + Mode: ManagedResourceMode, + Type: "foo", + Name: "bar", + }, + }, + ``, + }, + { + `module.foo.module.bar`, + Module{"foo", "bar"}, + ``, + }, + { + `module.boop.module.bip.foo.bar`, + ConfigResource{ + Module: Module{"boop", "bip"}, + Resource: Resource{ + Mode: ManagedResourceMode, + Type: "foo", + Name: "bar", + }, + }, + ``, + }, + { + `foo.bar[0]`, + nil, + `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`, + }, + { + `foo.bar["a"]`, + nil, + `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`, + }, + { + `module.boop.foo.bar[0]`, + nil, + `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`, + }, + { + `module.boop.foo.bar["a"]`, + nil, + + `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`, + }, + { + `data.foo.bar`, + nil, + `Data source address is not allowed: Data sources cannot be destroyed, and therefore, 'removed' blocks are not allowed to target them. To remove data sources from the state, you should remove the data source block from the configuration.`, + }, + { + `data.foo.bar[0]`, + nil, + `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`, + }, + { + `data.foo.bar["a"]`, + nil, + `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`, + }, + { + `module.boop.data.foo.bar[0]`, + nil, + `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`, + }, + { + `module.boop.data.foo.bar["a"]`, + nil, + `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`, + }, + { + `module.foo[0]`, + nil, + `Module instance address with keys is not allowed: Module address cannot be a module instance (e.g. "module.a[0]"), it must be a module instead (e.g. "module.a").`, + }, + { + `module.foo["a"]`, + nil, + `Module instance address with keys is not allowed: Module address cannot be a module instance (e.g. "module.a[0]"), it must be a module instead (e.g. "module.a").`, + }, + { + `module.foo[1].module.bar`, + nil, + `Module instance address with keys is not allowed: Module address cannot be a module instance (e.g. "module.a[0]"), it must be a module instead (e.g. "module.a").`, + }, + { + `module.foo.module.bar[1]`, + nil, + `Module instance address with keys is not allowed: Module address cannot be a module instance (e.g. "module.a[0]"), it must be a module instead (e.g. "module.a").`, + }, + { + `module.foo[0].module.bar[1]`, + nil, + `Module instance address with keys is not allowed: Module address cannot be a module instance (e.g. "module.a[0]"), it must be a module instead (e.g. "module.a").`, + }, + { + `module`, + nil, + `Invalid address operator: Prefix "module." must be followed by a module name.`, + }, + { + `module[0]`, + nil, + `Invalid address operator: Prefix "module." must be followed by a module name.`, + }, + { + `module.foo.data`, + nil, + `Invalid address: Resource specification must include a resource type and name.`, + }, + { + `module.foo.data.bar`, + nil, + `Invalid address: Resource specification must include a resource type and name.`, + }, + { + `module.foo.data[0]`, + nil, + `Invalid address: Resource specification must include a resource type and name.`, + }, + { + `module.foo.data.bar[0]`, + nil, + `Invalid address: A resource name is required.`, + }, + { + `module.foo.bar`, + nil, + `Invalid address: Resource specification must include a resource type and name.`, + }, + { + `module.foo.bar[0]`, + nil, + `Invalid address: A resource name is required.`, + }, + } + + for _, test := range tests { + t.Run(test.Input, func(t *testing.T) { + traversal, hclDiags := hclsyntax.ParseTraversalAbs([]byte(test.Input), "", hcl.InitialPos) + if hclDiags.HasErrors() { + // We're not trying to test the HCL parser here, so any + // failures at this point are likely to be bugs in the + // test case itself. + t.Fatalf("syntax error: %s", hclDiags.Error()) + } + + moveEp, diags := ParseRemoveEndpoint(traversal) + + switch { + case test.WantErr != "": + if !diags.HasErrors() { + t.Fatalf("unexpected success\nwant error: %s", test.WantErr) + } + gotErr := diags.Err().Error() + if gotErr != test.WantErr { + t.Fatalf("wrong error\ngot: %s\nwant: %s", gotErr, test.WantErr) + } + default: + if diags.HasErrors() { + t.Fatalf("unexpected error: %s", diags.Err().Error()) + } + if diff := cmp.Diff(test.WantRel, moveEp.RelSubject); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + } + }) + } +} diff --git a/internal/addrs/resource.go b/internal/addrs/resource.go index fdf0eb71fd..fd11007062 100644 --- a/internal/addrs/resource.go +++ b/internal/addrs/resource.go @@ -459,6 +459,10 @@ func (v ConfigResource) CheckableKind() CheckableKind { return CheckableResource } +func (r ConfigResource) configRemovableSigil() { + // Empty function so ConfigResource will fulfill the requirements of the removable interface +} + type configResourceKey string func (k configResourceKey) uniqueKeySigil() {} diff --git a/internal/command/format/format.go b/internal/command/format/format.go index aaa3917c73..f0fe995f53 100644 --- a/internal/command/format/format.go +++ b/internal/command/format/format.go @@ -34,6 +34,8 @@ func DiffActionSymbol(action plans.Action) string { return " [yellow]~[reset]" case plans.NoOp: return " " + case plans.Forget: + return " [red].[reset]" default: return " ?" } diff --git a/internal/command/jsonformat/plan.go b/internal/command/jsonformat/plan.go index 729c1e0253..a21d5e3ab4 100644 --- a/internal/command/jsonformat/plan.go +++ b/internal/command/jsonformat/plan.go @@ -203,6 +203,9 @@ func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...plans.Q if counts[plans.Read] > 0 { renderer.Streams.Println(renderer.Colorize.Color(actionDescription(plans.Read))) } + if counts[plans.Forget] > 0 { + renderer.Streams.Println(renderer.Colorize.Color(actionDescription(plans.Forget))) + } } if len(changes) > 0 { @@ -354,6 +357,11 @@ func renderHumanDiff(renderer Renderer, diff diff, cause string) (string, bool) buf.WriteString(renderer.Colorize.Color(resourceChangeComment(diff.change, action, cause))) opts := computed.NewRenderHumanOpts(renderer.Colorize) + + if action == plans.Forget { + opts.HideDiffActionSymbols = true + opts.OverrideNullSuffix = true + } opts.ShowUnchangedChildren = diff.Importing() buf.WriteString(fmt.Sprintf("%s %s %s", renderer.Colorize.Color(format.DiffActionSymbol(action)), resourceChangeHeader(diff.change), diff.diff.RenderHuman(0, opts))) @@ -453,7 +461,20 @@ func resourceChangeComment(resource jsonplan.ResourceChange, action plans.Action buf.WriteString(fmt.Sprintf("\n # (because key [%s] is not in for_each map)", resource.Index)) } if len(resource.Deposed) != 0 { - // Some extra context about this unusual situation. + // In the case where we partially failed to replace a resource + // configured with 'create_before_destroy' in a previous apply and + // the deposed instance is still in the state, we give some extra + // context about this unusual situation. + buf.WriteString("\n # (left over from a partially-failed replacement of this instance)") + } + case plans.Forget: + buf.WriteString(fmt.Sprintf("[bold] # %s[reset] will be removed from the OpenTofu state [bold][red]but will not be destroyed[reset]", dispAddr)) + + if len(resource.Deposed) != 0 { + // In the case where we partially failed to replace a resource + // configured with 'create_before_destroy' in a previous apply and + // the deposed instance is still in the state, we give some extra + // context about this unusual situation. buf.WriteString("\n # (left over from a partially-failed replacement of this instance)") } case plans.NoOp: @@ -524,6 +545,9 @@ func actionDescription(action plans.Action) string { return "[red]-[reset]/[green]+[reset] destroy and then create replacement" case plans.Read: return " [cyan]<=[reset] read (data resources)" + case plans.Forget: + return " [red].[reset] forget" + default: panic(fmt.Sprintf("unrecognized change type: %s", action.String())) } diff --git a/internal/command/jsonformat/plan_test.go b/internal/command/jsonformat/plan_test.go index 8e08d3acbc..dee2408c36 100644 --- a/internal/command/jsonformat/plan_test.go +++ b/internal/command/jsonformat/plan_test.go @@ -569,6 +569,44 @@ func TestResourceChange_primitiveTypes(t *testing.T) { - resource "test_instance" "example" { - id = "i-02ae66f368e8518a9" -> null }`, + }, + "forget": { + Action: plans.Forget, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + }), + After: cty.NullVal(cty.EmptyObject), + Schema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + }, + }, + RequiredReplace: cty.NewPathSet(), + ExpectedOutput: ` # test_instance.example will be removed from the OpenTofu state but will not be destroyed + . resource "test_instance" "example" { + id = "i-02ae66f368e8518a9" +}`, + }, + "forget a deposed object": { + Action: plans.Forget, + Mode: addrs.ManagedResourceMode, + DeposedKey: states.DeposedKey("byebye"), + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + }), + After: cty.NullVal(cty.EmptyObject), + Schema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + }, + }, + RequiredReplace: cty.NewPathSet(), + ExpectedOutput: ` # test_instance.example (deposed object byebye) will be removed from the OpenTofu state but will not be destroyed + # (left over from a partially-failed replacement of this instance) + . resource "test_instance" "example" { + id = "i-02ae66f368e8518a9" +}`, }, "string in-place update": { Action: plans.Update, @@ -5888,6 +5926,41 @@ func TestResourceChange_actionReason(t *testing.T) { ExpectedOutput: ` # test_instance.example must be replaced +/- resource "test_instance" "example" {}`, }, + "forget for no particular reason": { + Action: plans.Forget, + ActionReason: plans.ResourceInstanceChangeNoReason, + Mode: addrs.ManagedResourceMode, + Before: emptyVal, + After: nullVal, + Schema: emptySchema, + RequiredReplace: cty.NewPathSet(), + ExpectedOutput: ` # test_instance.example will be removed from the OpenTofu state but will not be destroyed + . resource "test_instance" "example" {}`, + }, + "forget because no resource configuration": { + Action: plans.Forget, + ActionReason: plans.ResourceInstanceDeleteBecauseNoResourceConfig, + ModuleInst: addrs.RootModuleInstance.Child("foo", addrs.NoKey), + Mode: addrs.ManagedResourceMode, + Before: emptyVal, + After: nullVal, + Schema: emptySchema, + RequiredReplace: cty.NewPathSet(), + ExpectedOutput: ` # module.foo.test_instance.example will be removed from the OpenTofu state but will not be destroyed + . resource "test_instance" "example" {}`, + }, + "forget because no module": { + Action: plans.Forget, + ActionReason: plans.ResourceInstanceDeleteBecauseNoModule, + ModuleInst: addrs.RootModuleInstance.Child("foo", addrs.IntKey(1)), + Mode: addrs.ManagedResourceMode, + Before: emptyVal, + After: nullVal, + Schema: emptySchema, + RequiredReplace: cty.NewPathSet(), + ExpectedOutput: ` # module.foo[1].test_instance.example will be removed from the OpenTofu state but will not be destroyed + . resource "test_instance" "example" {}`, + }, } runTestCases(t, testCases) @@ -6683,6 +6756,105 @@ func TestResourceChange_sensitiveVariable(t *testing.T) { # so its contents will not be displayed. } }`, + }, + "forget": { + Action: plans.Forget, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-BEFORE"), + "list_field": cty.ListVal([]cty.Value{ + cty.StringVal("hello"), + cty.StringVal("friends"), + }), + "map_key": cty.MapVal(map[string]cty.Value{ + "breakfast": cty.NumberIntVal(800), + "dinner": cty.NumberIntVal(2000), // sensitive key + }), + "map_whole": cty.MapVal(map[string]cty.Value{ + "breakfast": cty.StringVal("pizza"), + "dinner": cty.StringVal("pizza"), + }), + "nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "an_attr": cty.StringVal("secret"), + "another": cty.StringVal("not secret"), + }), + }), + "nested_block_set": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "an_attr": cty.StringVal("secret"), + "another": cty.StringVal("not secret"), + }), + }), + }), + After: cty.NullVal(cty.EmptyObject), + BeforeValMarks: []cty.PathValueMarks{ + { + Path: cty.Path{cty.GetAttrStep{Name: "ami"}}, + Marks: cty.NewValueMarks(marks.Sensitive), + }, + { + Path: cty.Path{cty.GetAttrStep{Name: "list_field"}, cty.IndexStep{Key: cty.NumberIntVal(1)}}, + Marks: cty.NewValueMarks(marks.Sensitive), + }, + { + Path: cty.Path{cty.GetAttrStep{Name: "map_key"}, cty.IndexStep{Key: cty.StringVal("dinner")}}, + Marks: cty.NewValueMarks(marks.Sensitive), + }, + { + Path: cty.Path{cty.GetAttrStep{Name: "map_whole"}}, + Marks: cty.NewValueMarks(marks.Sensitive), + }, + { + Path: cty.Path{cty.GetAttrStep{Name: "nested_block"}}, + Marks: cty.NewValueMarks(marks.Sensitive), + }, + { + Path: cty.Path{cty.GetAttrStep{Name: "nested_block_set"}}, + Marks: cty.NewValueMarks(marks.Sensitive), + }, + }, + RequiredReplace: cty.NewPathSet(), + Schema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Optional: true, Computed: true}, + "ami": {Type: cty.String, Optional: true}, + "list_field": {Type: cty.List(cty.String), Optional: true}, + "map_key": {Type: cty.Map(cty.Number), Optional: true}, + "map_whole": {Type: cty.Map(cty.String), Optional: true}, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "nested_block_set": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "an_attr": {Type: cty.String, Optional: true}, + "another": {Type: cty.String, Optional: true}, + }, + }, + Nesting: configschema.NestingSet, + }, + }, + }, + ExpectedOutput: ` # test_instance.example will be removed from the OpenTofu state but will not be destroyed + . resource "test_instance" "example" { + ami = (sensitive value) + id = "i-02ae66f368e8518a9" + list_field = [ + "hello", + (sensitive value), + ] + map_key = { + "breakfast" = 800 + "dinner" = (sensitive value) + } + map_whole = (sensitive value) + + nested_block_set { + # At least one attribute in this block is (or was) sensitive, + # so its contents will not be displayed. + } +}`, }, "update with sensitive value forcing replacement": { Action: plans.DeleteThenCreate, @@ -6900,6 +7072,32 @@ func TestResourceChange_moved(t *testing.T) { # (2 unchanged attributes hidden) }`, }, + "moved and forgotten": { + PrevRunAddr: prevRunAddr, + Action: plans.Forget, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("12345"), + "foo": cty.StringVal("hello"), + "bar": cty.StringVal("baz"), + }), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("12345"), + "foo": cty.StringVal("hello"), + "bar": cty.StringVal("boop"), + }), + Schema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + }, + }, + RequiredReplace: cty.NewPathSet(), + ExpectedOutput: ` # test_instance.example will be removed from the OpenTofu state but will not be destroyed + # (moved from test_instance.previous) + . resource "test_instance" "example" { + id = "12345" +}`, + }, } runTestCases(t, testCases) diff --git a/internal/command/jsonplan/plan.go b/internal/command/jsonplan/plan.go index 51dbfbd058..ad8fb022d4 100644 --- a/internal/command/jsonplan/plan.go +++ b/internal/command/jsonplan/plan.go @@ -92,6 +92,7 @@ type Change struct { // ["delete", "create"] // ["create", "delete"] // ["delete"] + // ["forget"] // The two "replace" actions are represented in this way to allow callers to // e.g. just scan the list for "delete" to recognize all three situations // where the object will be deleted, allowing for any new deletion @@ -99,10 +100,11 @@ type Change struct { Actions []string `json:"actions,omitempty"` // Before and After are representations of the object value both before and - // after the action. For ["create"] and ["delete"] actions, either "before" - // or "after" is unset (respectively). For ["no-op"], the before and after - // values are identical. The "after" value will be incomplete if there are - // values within it that won't be known until after apply. + // after the action. For ["delete"] and ["forget"] actions, the "after" + // value is unset. For ["create"] the "before" is unset. For ["no-op"], the + // before and after values are identical. The "after" value will be + // incomplete if there are values within it that won't be known until after + // apply. Before json.RawMessage `json:"before,omitempty"` After json.RawMessage `json:"after,omitempty"` @@ -841,6 +843,8 @@ func actionString(action string) []string { return []string{"read"} case action == "DeleteThenCreate": return []string{"delete", "create"} + case action == "Forget": + return []string{"forget"} default: return []string{action} } @@ -870,6 +874,8 @@ func UnmarshalActions(actions []string) plans.Action { return plans.Read case "no-op": return plans.NoOp + case "forget": + return plans.Forget } } diff --git a/internal/command/views/json/change.go b/internal/command/views/json/change.go index cef9e62961..6c88d7ae81 100644 --- a/internal/command/views/json/change.go +++ b/internal/command/views/json/change.go @@ -72,6 +72,7 @@ const ( ActionReplace ChangeAction = "replace" ActionDelete ChangeAction = "delete" ActionImport ChangeAction = "import" + ActionForget ChangeAction = "remove" ) func changeAction(action plans.Action) ChangeAction { @@ -88,6 +89,8 @@ func changeAction(action plans.Action) ChangeAction { return ActionReplace case plans.Delete: return ActionDelete + case plans.Forget: + return ActionForget default: return ActionNoOp } diff --git a/internal/command/views/json/hook.go b/internal/command/views/json/hook.go index d08c0a8cd7..6b0db623fe 100644 --- a/internal/command/views/json/hook.go +++ b/internal/command/views/json/hook.go @@ -319,6 +319,8 @@ func startActionVerb(action plans.Action) string { // This is not currently possible to reach, as we receive separate // passes for create and delete return "Replacing" + case plans.Forget: + return "Removing" case plans.NoOp: // This should never be possible: a no-op planned change should not // be applied. We'll fall back to "Applying". @@ -345,6 +347,8 @@ func progressActionVerb(action plans.Action) string { // This is not currently possible to reach, as we receive separate // passes for create and delete return "replacing" + case plans.Forget: + return "removing" case plans.NoOp: // This should never be possible: a no-op planned change should not // be applied. We'll fall back to "applying". @@ -371,6 +375,8 @@ func actionNoun(action plans.Action) string { // This is not currently possible to reach, as we receive separate // passes for create and delete return "Replacement" + case plans.Forget: + return "Removal" case plans.NoOp: // This should never be possible: a no-op planned change should not // be applied. We'll fall back to "Apply". diff --git a/internal/configs/module.go b/internal/configs/module.go index ee18d25b7f..823e51f155 100644 --- a/internal/configs/module.go +++ b/internal/configs/module.go @@ -51,8 +51,9 @@ type Module struct { ManagedResources map[string]*Resource DataResources map[string]*Resource - Moved []*Moved - Import []*Import + Moved []*Moved + Import []*Import + Removed []*Removed Checks map[string]*Check @@ -90,8 +91,9 @@ type File struct { ManagedResources []*Resource DataResources []*Resource - Moved []*Moved - Import []*Import + Moved []*Moved + Import []*Import + Removed []*Removed Checks []*Check } @@ -468,6 +470,8 @@ func (m *Module) appendFile(file *File) hcl.Diagnostics { m.Import = append(m.Import, i) } + m.Removed = append(m.Removed, file.Removed...) + return diags } @@ -658,6 +662,15 @@ func (m *Module) mergeFile(file *File) hcl.Diagnostics { }) } + for _, m := range file.Removed { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Cannot override 'Removed' blocks", + Detail: "Removed blocks can appear only in normal files, not in override files.", + Subject: m.DeclRange.Ptr(), + }) + } + return diags } diff --git a/internal/configs/parser_config.go b/internal/configs/parser_config.go index 9c808989e2..72c08ebd29 100644 --- a/internal/configs/parser_config.go +++ b/internal/configs/parser_config.go @@ -196,6 +196,13 @@ func (p *Parser) loadConfigFile(path string, override bool) (*File, hcl.Diagnost file.Checks = append(file.Checks, cfg) } + case "removed": + cfg, cfgDiags := decodeRemovedBlock(block) + diags = append(diags, cfgDiags...) + if cfg != nil { + file.Removed = append(file.Removed, cfg) + } + default: // Should never happen because the above cases should be exhaustive // for all block type names in our schema. @@ -293,6 +300,9 @@ var configFileSchema = &hcl.BodySchema{ Type: "check", LabelNames: []string{"name"}, }, + { + Type: "removed", + }, }, } diff --git a/internal/configs/removed.go b/internal/configs/removed.go new file mode 100644 index 0000000000..c7a5e37693 --- /dev/null +++ b/internal/configs/removed.go @@ -0,0 +1,49 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package configs + +import ( + "github.com/hashicorp/hcl/v2" + "github.com/opentofu/opentofu/internal/addrs" +) + +// Removed represents a removed block in the configuration. +type Removed struct { + From *addrs.RemoveEndpoint + + DeclRange hcl.Range +} + +func decodeRemovedBlock(block *hcl.Block) (*Removed, hcl.Diagnostics) { + var diags hcl.Diagnostics + removed := &Removed{ + DeclRange: block.DefRange, + } + + content, moreDiags := block.Body.Content(removedBlockSchema) + diags = append(diags, moreDiags...) + + if attr, exists := content.Attributes["from"]; exists { + from, traversalDiags := hcl.AbsTraversalForExpr(attr.Expr) + diags = append(diags, traversalDiags...) + if !traversalDiags.HasErrors() { + from, fromDiags := addrs.ParseRemoveEndpoint(from) + diags = append(diags, fromDiags.ToHCL()...) + removed.From = from + } + } + + return removed, diags +} + +var removedBlockSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "from", + Required: true, + }, + }, +} diff --git a/internal/configs/removed_test.go b/internal/configs/removed_test.go new file mode 100644 index 0000000000..45d6d39d45 --- /dev/null +++ b/internal/configs/removed_test.go @@ -0,0 +1,198 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package configs + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hcltest" + "github.com/opentofu/opentofu/internal/addrs" +) + +func TestRemovedBlock_decode(t *testing.T) { + blockRange := hcl.Range{ + Filename: "mock.tf", + Start: hcl.Pos{Line: 3, Column: 12, Byte: 27}, + End: hcl.Pos{Line: 3, Column: 19, Byte: 34}, + } + + foo_expr := hcltest.MockExprTraversalSrc("test_instance.foo") + mod_foo_expr := hcltest.MockExprTraversalSrc("module.foo") + foo_index_expr := hcltest.MockExprTraversalSrc("test_instance.foo[1]") + mod_boop_index_foo_expr := hcltest.MockExprTraversalSrc("module.boop[1].test_instance.foo") + data_foo_expr := hcltest.MockExprTraversalSrc("data.test_instance.foo") + + tests := map[string]struct { + input *hcl.Block + want *Removed + err string + }{ + "success": { + &hcl.Block{ + Type: "removed", + Body: hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcl.Attributes{ + "from": { + Name: "from", + Expr: foo_expr, + }, + }, + }), + DefRange: blockRange, + }, + &Removed{ + From: mustRemoveEndpointFromExpr(foo_expr), + DeclRange: blockRange, + }, + ``, + }, + "modules": { + &hcl.Block{ + Type: "removed", + Body: hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcl.Attributes{ + "from": { + Name: "from", + Expr: mod_foo_expr, + }, + }, + }), + DefRange: blockRange, + }, + &Removed{ + From: mustRemoveEndpointFromExpr(mod_foo_expr), + DeclRange: blockRange, + }, + ``, + }, + "error: missing argument": { + &hcl.Block{ + Type: "removed", + Body: hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcl.Attributes{}, + }), + DefRange: blockRange, + }, + &Removed{ + DeclRange: blockRange, + }, + "Missing required argument", + }, + "error: indexed resources": { + &hcl.Block{ + Type: "removed", + Body: hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcl.Attributes{ + "from": { + Name: "from", + Expr: foo_index_expr, + }, + }, + }), + DefRange: blockRange, + }, + &Removed{ + DeclRange: blockRange, + }, + "Resource instance address with keys is not allowed", + }, + "error: indexed modules": { + &hcl.Block{ + Type: "removed", + Body: hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcl.Attributes{ + "from": { + Name: "from", + Expr: mod_boop_index_foo_expr, + }, + }, + }), + DefRange: blockRange, + }, + &Removed{ + DeclRange: blockRange, + }, + "Module instance address with keys is not allowed", + }, + "error: data address": { + &hcl.Block{ + Type: "moved", + Body: hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcl.Attributes{ + "from": { + Name: "from", + Expr: data_foo_expr, + }, + }, + }), + DefRange: blockRange, + }, + &Removed{ + DeclRange: blockRange, + }, + "Data source address is not allowed", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + got, diags := decodeRemovedBlock(test.input) + + if diags.HasErrors() { + if test.err == "" { + t.Fatalf("unexpected error: %s", diags.Errs()) + } + if gotErr := diags[0].Summary; gotErr != test.err { + t.Errorf("wrong error, got %q, want %q", gotErr, test.err) + } + } else if test.err != "" { + t.Fatal("expected error") + } + + if !cmp.Equal(got, test.want, cmp.AllowUnexported(addrs.MoveEndpoint{})) { + t.Fatalf("wrong result: %s", cmp.Diff(got, test.want)) + } + }) + } +} + +func TestRemovedBlock_inModule(t *testing.T) { + parser := NewParser(nil) + mod, diags := parser.LoadConfigDir("testdata/valid-modules/removed-blocks") + if diags.HasErrors() { + t.Errorf("unexpected error: %s", diags.Error()) + } + + var got []string + for _, mc := range mod.Removed { + got = append(got, mc.From.RelSubject.String()) + } + want := []string{ + `test.foo`, + `test.foo`, + `module.a`, + `module.a`, + `test.foo`, + `test.boop`, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("wrong addresses\n%s", diff) + } +} + +func mustRemoveEndpointFromExpr(expr hcl.Expression) *addrs.RemoveEndpoint { + traversal, hcldiags := hcl.AbsTraversalForExpr(expr) + if hcldiags.HasErrors() { + panic(hcldiags.Errs()) + } + + ep, diags := addrs.ParseRemoveEndpoint(traversal) + if diags.HasErrors() { + panic(diags.Err()) + } + + return ep +} diff --git a/internal/configs/testdata/valid-files/refactoring.tf b/internal/configs/testdata/valid-files/refactoring.tf new file mode 100644 index 0000000000..ccba1188f0 --- /dev/null +++ b/internal/configs/testdata/valid-files/refactoring.tf @@ -0,0 +1,13 @@ +import { + to = aws_instance.import + id = 1 +} + +moved { + from = aws_instance.moved_from + to = aws_instance.moved_to +} + +removed { + from = aws_instance.removed +} \ No newline at end of file diff --git a/internal/configs/testdata/valid-modules/removed-blocks/removed-blocks-1.tf b/internal/configs/testdata/valid-modules/removed-blocks/removed-blocks-1.tf new file mode 100644 index 0000000000..2a35a03320 --- /dev/null +++ b/internal/configs/testdata/valid-modules/removed-blocks/removed-blocks-1.tf @@ -0,0 +1,19 @@ +removed { + from = test.foo +} + +removed { + from = test.foo +} + +removed { + from = module.a +} + +removed { + from = module.a +} + +removed { + from = test.foo +} diff --git a/internal/configs/testdata/valid-modules/removed-blocks/removed-blocks-2.tf b/internal/configs/testdata/valid-modules/removed-blocks/removed-blocks-2.tf new file mode 100644 index 0000000000..6a9f6b1569 --- /dev/null +++ b/internal/configs/testdata/valid-modules/removed-blocks/removed-blocks-2.tf @@ -0,0 +1,5 @@ +# One more removed block in a separate file just to make sure the +# appending of multiple files works properly. +removed { + from = test.boop +} diff --git a/internal/plans/action.go b/internal/plans/action.go index c93cca2720..aef4f8a351 100644 --- a/internal/plans/action.go +++ b/internal/plans/action.go @@ -15,6 +15,7 @@ const ( DeleteThenCreate Action = '∓' CreateThenDelete Action = '±' Delete Action = '-' + Forget Action = '.' ) //go:generate go run golang.org/x/tools/cmd/stringer -type Action diff --git a/internal/plans/action_string.go b/internal/plans/action_string.go index be43ab1757..82f68e3863 100644 --- a/internal/plans/action_string.go +++ b/internal/plans/action_string.go @@ -15,26 +15,32 @@ func _() { _ = x[DeleteThenCreate-8723] _ = x[CreateThenDelete-177] _ = x[Delete-45] + _ = x[Forget-46] } const ( _Action_name_0 = "NoOp" _Action_name_1 = "Create" - _Action_name_2 = "Delete" + _Action_name_2 = "DeleteForget" _Action_name_3 = "Update" _Action_name_4 = "CreateThenDelete" _Action_name_5 = "Read" _Action_name_6 = "DeleteThenCreate" ) +var ( + _Action_index_2 = [...]uint8{0, 6, 12} +) + func (i Action) String() string { switch { case i == 0: return _Action_name_0 case i == 43: return _Action_name_1 - case i == 45: - return _Action_name_2 + case 45 <= i && i <= 46: + i -= 45 + return _Action_name_2[_Action_index_2[i]:_Action_index_2[i+1]] case i == 126: return _Action_name_3 case i == 177: diff --git a/internal/plans/internal/planproto/planfile.pb.go b/internal/plans/internal/planproto/planfile.pb.go index 0176270df3..b00e3939d2 100644 --- a/internal/plans/internal/planproto/planfile.pb.go +++ b/internal/plans/internal/planproto/planfile.pb.go @@ -87,6 +87,7 @@ const ( Action_DELETE Action = 5 Action_DELETE_THEN_CREATE Action = 6 Action_CREATE_THEN_DELETE Action = 7 + Action_FORGET Action = 8 ) // Enum value maps for Action. @@ -99,6 +100,7 @@ var ( 5: "DELETE", 6: "DELETE_THEN_CREATE", 7: "CREATE_THEN_DELETE", + 8: "FORGET", } Action_value = map[string]int32{ "NOOP": 0, @@ -108,6 +110,7 @@ var ( "DELETE": 5, "DELETE_THEN_CREATE": 6, "CREATE_THEN_DELETE": 7, + "FORGET": 8, } ) @@ -729,7 +732,7 @@ type ResourceInstanceChange struct { // apply it. Provider string `protobuf:"bytes,8,opt,name=provider,proto3" json:"provider,omitempty"` // Description of the proposed change. May use "create", "read", "update", - // "replace", "delete" and "no-op" actions. + // "replace", "delete", "forget" and "no-op" actions. Change *Change `protobuf:"bytes,9,opt,name=change,proto3" json:"change,omitempty"` // raw blob value provided by the provider as additional context for the // change. Must be considered an opaque value for any consumer other than @@ -1504,47 +1507,48 @@ var file_planfile_proto_rawDesc = []byte{ 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x2a, 0x31, 0x0a, 0x04, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x0a, 0x0a, 0x06, 0x4e, 0x4f, 0x52, 0x4d, 0x41, 0x4c, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x53, 0x54, 0x52, 0x4f, 0x59, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x52, 0x45, 0x46, 0x52, 0x45, - 0x53, 0x48, 0x5f, 0x4f, 0x4e, 0x4c, 0x59, 0x10, 0x02, 0x2a, 0x70, 0x0a, 0x06, 0x41, 0x63, 0x74, + 0x53, 0x48, 0x5f, 0x4f, 0x4e, 0x4c, 0x59, 0x10, 0x02, 0x2a, 0x7c, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x08, 0x0a, 0x04, 0x4e, 0x4f, 0x4f, 0x50, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x52, 0x45, 0x41, 0x44, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x03, 0x12, 0x0a, 0x0a, 0x06, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x05, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x54, 0x48, 0x45, 0x4e, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x06, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x48, - 0x45, 0x4e, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x07, 0x2a, 0xc8, 0x03, 0x0a, 0x1c, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, - 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x08, 0x0a, 0x04, - 0x4e, 0x4f, 0x4e, 0x45, 0x10, 0x00, 0x12, 0x1b, 0x0a, 0x17, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, - 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x54, 0x41, 0x49, 0x4e, 0x54, 0x45, - 0x44, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, - 0x59, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x02, 0x12, 0x21, 0x0a, 0x1d, 0x52, - 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, - 0x41, 0x4e, 0x4e, 0x4f, 0x54, 0x5f, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x03, 0x12, 0x25, - 0x0a, 0x21, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, - 0x5f, 0x4e, 0x4f, 0x5f, 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, - 0x46, 0x49, 0x47, 0x10, 0x04, 0x12, 0x23, 0x0a, 0x1f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, - 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x57, 0x52, 0x4f, 0x4e, 0x47, 0x5f, 0x52, 0x45, - 0x50, 0x45, 0x54, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x05, 0x12, 0x1e, 0x0a, 0x1a, 0x44, 0x45, - 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x4f, 0x55, - 0x4e, 0x54, 0x5f, 0x49, 0x4e, 0x44, 0x45, 0x58, 0x10, 0x06, 0x12, 0x1b, 0x0a, 0x17, 0x44, 0x45, - 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x45, 0x41, 0x43, - 0x48, 0x5f, 0x4b, 0x45, 0x59, 0x10, 0x07, 0x12, 0x1c, 0x0a, 0x18, 0x44, 0x45, 0x4c, 0x45, 0x54, - 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f, 0x44, - 0x55, 0x4c, 0x45, 0x10, 0x08, 0x12, 0x17, 0x0a, 0x13, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, - 0x5f, 0x42, 0x59, 0x5f, 0x54, 0x52, 0x49, 0x47, 0x47, 0x45, 0x52, 0x53, 0x10, 0x09, 0x12, 0x1f, - 0x0a, 0x1b, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, - 0x4f, 0x4e, 0x46, 0x49, 0x47, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x0a, 0x12, - 0x23, 0x0a, 0x1f, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, - 0x44, 0x45, 0x50, 0x45, 0x4e, 0x44, 0x45, 0x4e, 0x43, 0x59, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, - 0x4e, 0x47, 0x10, 0x0b, 0x12, 0x1d, 0x0a, 0x19, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, - 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x5f, 0x4e, 0x45, 0x53, 0x54, 0x45, - 0x44, 0x10, 0x0d, 0x12, 0x21, 0x0a, 0x1d, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, - 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f, 0x56, 0x45, 0x5f, 0x54, 0x41, - 0x52, 0x47, 0x45, 0x54, 0x10, 0x0c, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x74, 0x6f, 0x66, 0x75, 0x2f, 0x6f, 0x70, - 0x65, 0x6e, 0x74, 0x6f, 0x66, 0x75, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, - 0x70, 0x6c, 0x61, 0x6e, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, - 0x6c, 0x61, 0x6e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x45, 0x4e, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x07, 0x12, 0x0a, 0x0a, 0x06, 0x46, + 0x4f, 0x52, 0x47, 0x45, 0x54, 0x10, 0x08, 0x2a, 0xc8, 0x03, 0x0a, 0x1c, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x41, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x08, 0x0a, 0x04, 0x4e, 0x4f, 0x4e, 0x45, + 0x10, 0x00, 0x12, 0x1b, 0x0a, 0x17, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x45, + 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x54, 0x41, 0x49, 0x4e, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, + 0x16, 0x0a, 0x12, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x59, 0x5f, 0x52, 0x45, + 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x02, 0x12, 0x21, 0x0a, 0x1d, 0x52, 0x45, 0x50, 0x4c, 0x41, + 0x43, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x41, 0x4e, 0x4e, 0x4f, + 0x54, 0x5f, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x03, 0x12, 0x25, 0x0a, 0x21, 0x44, 0x45, + 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f, + 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49, 0x47, 0x10, + 0x04, 0x12, 0x23, 0x0a, 0x1f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, + 0x55, 0x53, 0x45, 0x5f, 0x57, 0x52, 0x4f, 0x4e, 0x47, 0x5f, 0x52, 0x45, 0x50, 0x45, 0x54, 0x49, + 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x05, 0x12, 0x1e, 0x0a, 0x1a, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, + 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x4f, 0x55, 0x4e, 0x54, 0x5f, 0x49, + 0x4e, 0x44, 0x45, 0x58, 0x10, 0x06, 0x12, 0x1b, 0x0a, 0x17, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, + 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x45, 0x41, 0x43, 0x48, 0x5f, 0x4b, 0x45, + 0x59, 0x10, 0x07, 0x12, 0x1c, 0x0a, 0x18, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, + 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f, 0x44, 0x55, 0x4c, 0x45, 0x10, + 0x08, 0x12, 0x17, 0x0a, 0x13, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x59, 0x5f, + 0x54, 0x52, 0x49, 0x47, 0x47, 0x45, 0x52, 0x53, 0x10, 0x09, 0x12, 0x1f, 0x0a, 0x1b, 0x52, 0x45, + 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49, + 0x47, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x0a, 0x12, 0x23, 0x0a, 0x1f, 0x52, + 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x44, 0x45, 0x50, 0x45, + 0x4e, 0x44, 0x45, 0x4e, 0x43, 0x59, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x0b, + 0x12, 0x1d, 0x0a, 0x19, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, + 0x5f, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x5f, 0x4e, 0x45, 0x53, 0x54, 0x45, 0x44, 0x10, 0x0d, 0x12, + 0x21, 0x0a, 0x1d, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, + 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f, 0x56, 0x45, 0x5f, 0x54, 0x41, 0x52, 0x47, 0x45, 0x54, + 0x10, 0x0c, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x74, 0x6f, 0x66, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x74, 0x6f, + 0x66, 0x75, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x6c, 0x61, 0x6e, + 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x6c, 0x61, 0x6e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/internal/plans/internal/planproto/planfile.proto b/internal/plans/internal/planproto/planfile.proto index cff2e0b31b..4a93aa6a19 100644 --- a/internal/plans/internal/planproto/planfile.proto +++ b/internal/plans/internal/planproto/planfile.proto @@ -116,6 +116,7 @@ enum Action { DELETE = 5; DELETE_THEN_CREATE = 6; CREATE_THEN_DELETE = 7; + FORGET = 8; } // Change represents a change made to some object, transforming it from an old @@ -206,7 +207,7 @@ message ResourceInstanceChange { string provider = 8; // Description of the proposed change. May use "create", "read", "update", - // "replace", "delete" and "no-op" actions. + // "replace", "delete", "forget" and "no-op" actions. Change change = 9; // raw blob value provided by the provider as additional context for the diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index 21b11576ee..e0469ae226 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -391,6 +391,9 @@ func changeFromTfplan(rawChange *planproto.Change) (*plans.ChangeSrc, error) { case planproto.Action_DELETE: ret.Action = plans.Delete beforeIdx = 0 + case planproto.Action_FORGET: + ret.Action = plans.Forget + beforeIdx = 0 case planproto.Action_CREATE_THEN_DELETE: ret.Action = plans.CreateThenDelete beforeIdx = 0 @@ -795,6 +798,9 @@ func changeToTfplan(change *plans.ChangeSrc) (*planproto.Change, error) { case plans.Delete: ret.Action = planproto.Action_DELETE ret.Values = []*planproto.DynamicValue{before} + case plans.Forget: + ret.Action = planproto.Action_FORGET + ret.Values = []*planproto.DynamicValue{before} case plans.DeleteThenCreate: ret.Action = planproto.Action_DELETE_THEN_CREATE ret.Values = []*planproto.DynamicValue{before, after} diff --git a/internal/plans/planfile/tfplan_test.go b/internal/plans/planfile/tfplan_test.go index 996de27241..9ef1c04af4 100644 --- a/internal/plans/planfile/tfplan_test.go +++ b/internal/plans/planfile/tfplan_test.go @@ -124,6 +124,28 @@ func TestTFPlanRoundTrip(t *testing.T) { }), objTy), }, }, + { + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "forget", + }.Instance(addrs.IntKey(1)).Absolute(addrs.RootModuleInstance), + PrevRunAddr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "forget", + }.Instance(addrs.IntKey(1)).Absolute(addrs.RootModuleInstance), + ProviderAddr: addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Forget, + Before: mustNewDynamicValue(cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("bar-baz-forget"), + }), objTy), + }, + }, { Addr: addrs.Resource{ Mode: addrs.ManagedResourceMode, diff --git a/internal/refactoring/remove_statement.go b/internal/refactoring/remove_statement.go new file mode 100644 index 0000000000..b77663ff39 --- /dev/null +++ b/internal/refactoring/remove_statement.go @@ -0,0 +1,124 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refactoring + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/opentofu/opentofu/internal/addrs" + "github.com/opentofu/opentofu/internal/configs" + "github.com/opentofu/opentofu/internal/tfdiags" +) + +type RemoveStatement struct { + From addrs.ConfigRemovable + DeclRange tfdiags.SourceRange +} + +// GetEndpointsToRemove recurses through the modules of the given configuration +// and returns an array of all "removed" addresses within, in a +// deterministic but undefined order. +// We also validate that the removed modules/resources configuration blocks were removed. +func GetEndpointsToRemove(rootCfg *configs.Config) ([]addrs.ConfigRemovable, tfdiags.Diagnostics) { + rm := findRemoveStatements(rootCfg, nil) + diags := validateRemoveStatements(rootCfg, rm) + removedAddresses := make([]addrs.ConfigRemovable, len(rm)) + for i, rs := range rm { + removedAddresses[i] = rs.From + } + return removedAddresses, diags +} + +func findRemoveStatements(cfg *configs.Config, into []*RemoveStatement) []*RemoveStatement { + modAddr := cfg.Path + + for _, rc := range cfg.Module.Removed { + var removedEndpoint *RemoveStatement + switch FromAddress := rc.From.RelSubject.(type) { + case addrs.ConfigResource: + // Get the absolute address of the resource by appending the module config address + // to the resource's relative address + absModule := make(addrs.Module, 0, len(modAddr)+len(FromAddress.Module)) + absModule = append(absModule, modAddr...) + absModule = append(absModule, FromAddress.Module...) + + var absConfigResource addrs.ConfigRemovable = addrs.ConfigResource{ + Resource: FromAddress.Resource, + Module: absModule, + } + + removedEndpoint = &RemoveStatement{From: absConfigResource, DeclRange: tfdiags.SourceRangeFromHCL(rc.DeclRange)} + + case addrs.Module: + // Get the absolute address of the module by appending the module config address + // to the module itself + var absModule = make(addrs.Module, 0, len(modAddr)+len(FromAddress)) + absModule = append(absModule, modAddr...) + absModule = append(absModule, FromAddress...) + removedEndpoint = &RemoveStatement{From: absModule, DeclRange: tfdiags.SourceRangeFromHCL(rc.DeclRange)} + + default: + panic(fmt.Sprintf("unhandled address type %T", FromAddress)) + } + + into = append(into, removedEndpoint) + + } + + for _, childCfg := range cfg.Children { + into = findRemoveStatements(childCfg, into) + } + + return into +} + +// validateRemoveStatements validates that the removed modules/resources configuration blocks were removed. +func validateRemoveStatements(cfg *configs.Config, removeStatements []*RemoveStatement) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + for _, rs := range removeStatements { + fromAddr := rs.From + if fromAddr == nil { + // Invalid value should've been caught during original + // configuration decoding, in the configs package. + panic(fmt.Sprintf("incompatible Remove endpoint in %s", rs.DeclRange.ToHCL())) + } + + // validate that a resource/module with this address doesn't exist in the config + switch fromAddr := fromAddr.(type) { + case addrs.ConfigResource: + moduleConfig := cfg.Descendent(fromAddr.Module) + if moduleConfig != nil && moduleConfig.Module.ResourceByAddr(fromAddr.Resource) != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Removed resource block still exists", + Detail: fmt.Sprintf( + "This statement declares a removal of the resource %s, but this resource block still exists in the configuration. Please remove the resource block.", + fromAddr, + ), + Subject: rs.DeclRange.ToHCL().Ptr(), + }) + } + case addrs.Module: + if cfg.Descendent(fromAddr) != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Removed module block still exists", + Detail: fmt.Sprintf( + "This statement declares a removal of the module %s, but this module block still exists in the configuration. Please remove the module block.", + fromAddr, + ), + Subject: rs.DeclRange.ToHCL().Ptr(), + }) + } + default: + panic(fmt.Sprintf("incompatible Remove endpoint address type in %s", rs.DeclRange.ToHCL())) + } + } + + return diags +} diff --git a/internal/refactoring/remove_statement_test.go b/internal/refactoring/remove_statement_test.go new file mode 100644 index 0000000000..24e99ff09b --- /dev/null +++ b/internal/refactoring/remove_statement_test.go @@ -0,0 +1,85 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refactoring + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/opentofu/opentofu/internal/addrs" +) + +func TestGetEndpointsToRemove(t *testing.T) { + tests := []struct { + name string + fixtureName string + want []addrs.ConfigRemovable + wantError string + }{ + { + name: "Valid cases", + fixtureName: "testdata/remove-statement/valid-remove-statements", + want: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("foo.basic_resource")).(addrs.ConfigRemovable), + interface{}(addrs.Module{"basic_module"}).(addrs.ConfigRemovable), + interface{}(mustConfigResourceAddr("module.child.foo.removed_resource_from_root_module")).(addrs.ConfigRemovable), + interface{}(mustConfigResourceAddr("module.child.foo.removed_resource_from_child_module")).(addrs.ConfigRemovable), + interface{}(addrs.Module{"child", "removed_module_from_child_module"}).(addrs.ConfigRemovable), + interface{}(mustConfigResourceAddr("module.child.module.grandchild.foo.removed_resource_from_grandchild_module")).(addrs.ConfigRemovable), + interface{}(addrs.Module{"child", "grandchild", "removed_module_from_grandchild_module"}).(addrs.ConfigRemovable), + }, + wantError: ``, + }, + { + name: "Error - resource block still exist", + fixtureName: "testdata/remove-statement/not-valid-resource-block-still-exist", + want: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("foo.basic_resource")).(addrs.ConfigRemovable), + }, + wantError: `Removed resource block still exists: This statement declares a removal of the resource foo.basic_resource, but this resource block still exists in the configuration. Please remove the resource block.`, + }, + { + name: "Error - module block still exist", + fixtureName: "testdata/remove-statement/not-valid-module-block-still-exist", + want: []addrs.ConfigRemovable{}, + wantError: `Removed module block still exists: This statement declares a removal of the module module.child, but this module block still exists in the configuration. Please remove the module block.`, + }, + { + name: "Error - nested resource block still exist", + fixtureName: "testdata/remove-statement/not-valid-nested-resource-block-still-exist", + want: []addrs.ConfigRemovable{}, + wantError: `Removed resource block still exists: This statement declares a removal of the resource module.child.foo.basic_resource, but this resource block still exists in the configuration. Please remove the resource block.`, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootCfg, _ := loadRefactoringFixture(t, tt.fixtureName) + got, diags := GetEndpointsToRemove(rootCfg) + + if tt.wantError != "" { + if !diags.HasErrors() { + t.Fatalf("missing expected error\ngot: \nwant: %s", tt.wantError) + } + errStr := diags.Err().Error() + if errStr != tt.wantError { + t.Fatalf("wrong error\ngot: %s\nwant: %s", errStr, tt.wantError) + } + } else { + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + } + }) + } +} + +func mustConfigResourceAddr(s string) addrs.ConfigResource { + addr, diags := addrs.ParseAbsResourceStr(s) + if diags.HasErrors() { + panic(diags.Err()) + } + return addr.Config() +} diff --git a/internal/refactoring/testdata/remove-statement/not-valid-module-block-still-exist/child/main.tf b/internal/refactoring/testdata/remove-statement/not-valid-module-block-still-exist/child/main.tf new file mode 100644 index 0000000000..6a5b9e8678 --- /dev/null +++ b/internal/refactoring/testdata/remove-statement/not-valid-module-block-still-exist/child/main.tf @@ -0,0 +1,2 @@ +resource "foo" "basic_resource" { +} diff --git a/internal/refactoring/testdata/remove-statement/not-valid-module-block-still-exist/main.tf b/internal/refactoring/testdata/remove-statement/not-valid-module-block-still-exist/main.tf new file mode 100644 index 0000000000..03346e0208 --- /dev/null +++ b/internal/refactoring/testdata/remove-statement/not-valid-module-block-still-exist/main.tf @@ -0,0 +1,7 @@ +module "child" { + source = "./child" +} + +removed { + from = module.child +} \ No newline at end of file diff --git a/internal/refactoring/testdata/remove-statement/not-valid-nested-resource-block-still-exist/child/main.tf b/internal/refactoring/testdata/remove-statement/not-valid-nested-resource-block-still-exist/child/main.tf new file mode 100644 index 0000000000..6a5b9e8678 --- /dev/null +++ b/internal/refactoring/testdata/remove-statement/not-valid-nested-resource-block-still-exist/child/main.tf @@ -0,0 +1,2 @@ +resource "foo" "basic_resource" { +} diff --git a/internal/refactoring/testdata/remove-statement/not-valid-nested-resource-block-still-exist/main.tf b/internal/refactoring/testdata/remove-statement/not-valid-nested-resource-block-still-exist/main.tf new file mode 100644 index 0000000000..8de3142043 --- /dev/null +++ b/internal/refactoring/testdata/remove-statement/not-valid-nested-resource-block-still-exist/main.tf @@ -0,0 +1,7 @@ +removed { + from = module.child.foo.basic_resource +} + +module "child" { + source = "./child" +} diff --git a/internal/refactoring/testdata/remove-statement/not-valid-resource-block-still-exist/main.tf b/internal/refactoring/testdata/remove-statement/not-valid-resource-block-still-exist/main.tf new file mode 100644 index 0000000000..28b32b3a2a --- /dev/null +++ b/internal/refactoring/testdata/remove-statement/not-valid-resource-block-still-exist/main.tf @@ -0,0 +1,6 @@ +resource "foo" "basic_resource" { +} + +removed { + from = foo.basic_resource +} \ No newline at end of file diff --git a/internal/refactoring/testdata/remove-statement/valid-remove-statements/child/grandchild/main.tf b/internal/refactoring/testdata/remove-statement/valid-remove-statements/child/grandchild/main.tf new file mode 100644 index 0000000000..40c599c639 --- /dev/null +++ b/internal/refactoring/testdata/remove-statement/valid-remove-statements/child/grandchild/main.tf @@ -0,0 +1,7 @@ +removed { + from = foo.removed_resource_from_grandchild_module +} + +removed { + from = module.removed_module_from_grandchild_module +} \ No newline at end of file diff --git a/internal/refactoring/testdata/remove-statement/valid-remove-statements/child/main.tf b/internal/refactoring/testdata/remove-statement/valid-remove-statements/child/main.tf new file mode 100644 index 0000000000..250c8298df --- /dev/null +++ b/internal/refactoring/testdata/remove-statement/valid-remove-statements/child/main.tf @@ -0,0 +1,11 @@ +removed { + from = foo.removed_resource_from_child_module +} + +module "grandchild" { + source = "./grandchild" +} + +removed { + from = module.removed_module_from_child_module +} \ No newline at end of file diff --git a/internal/refactoring/testdata/remove-statement/valid-remove-statements/main.tf b/internal/refactoring/testdata/remove-statement/valid-remove-statements/main.tf new file mode 100644 index 0000000000..a078fde5ce --- /dev/null +++ b/internal/refactoring/testdata/remove-statement/valid-remove-statements/main.tf @@ -0,0 +1,15 @@ +removed { + from = foo.basic_resource +} + +removed { + from = module.basic_module +} + +removed { + from = module.child.foo.removed_resource_from_root_module +} + +module "child" { + source = "./child" +} diff --git a/internal/tofu/context_apply2_test.go b/internal/tofu/context_apply2_test.go index a6025c0579..e5b9302c7e 100644 --- a/internal/tofu/context_apply2_test.go +++ b/internal/tofu/context_apply2_test.go @@ -2246,3 +2246,64 @@ locals { t.Errorf("expected local value to be \"foo\" but was \"%s\"", module.LocalValues["local_value"].AsString()) } } + +func TestContext2Apply_forgetOrphanAndDeposed(t *testing.T) { + desposedKey := states.DeposedKey("deposed") + addr := "aws_instance.baz" + m := testModuleInline(t, map[string]string{ + "main.tf": ` + removed { + from = aws_instance.baz + } + `, + }) + hook := new(MockHook) + p := testProvider("aws") + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr(addr).Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"bar"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceDeposed( + mustResourceInstanceAddr(addr).Resource, + desposedKey, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectTainted, + AttrsJSON: []byte(`{"id":"bar"}`), + Dependencies: []addrs.ConfigResource{}, + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + p.PlanResourceChangeFn = testDiffFn + + plan, diags := ctx.Plan(m, state, DefaultPlanOpts) + assertNoErrors(t, diags) + + s, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + if !s.Empty() { + t.Fatalf("State should be empty") + } + + if p.ApplyResourceChangeCalled { + t.Fatalf("When we forget we don't call the provider's ApplyResourceChange unlike in destroy") + } + + if hook.PostApplyCalled { + t.Fatalf("PostApply hook should not be called as part of forget") + } +} diff --git a/internal/tofu/context_plan.go b/internal/tofu/context_plan.go index 8e3b380b65..fb01eaf83d 100644 --- a/internal/tofu/context_plan.go +++ b/internal/tofu/context_plan.go @@ -87,6 +87,10 @@ type PlanOpts struct { // will be added to the plan graph. ImportTargets []*ImportTarget + // EndpointsToRemove are the list of resources and modules to forget from + // the state. + EndpointsToRemove []addrs.ConfigRemovable + // GenerateConfig tells OpenTofu where to write any generated configuration // for any ImportTargets that do not have configuration already. // @@ -310,6 +314,15 @@ func (c *Context) plan(config *configs.Config, prevRunState *states.State, opts opts.ImportTargets = c.findImportTargets(config, prevRunState) importTargetDiags := c.validateImportTargets(config, opts.ImportTargets) diags = diags.Append(importTargetDiags) + + var endpointsToRemoveDiags tfdiags.Diagnostics + opts.EndpointsToRemove, endpointsToRemoveDiags = refactoring.GetEndpointsToRemove(config) + diags = diags.Append(endpointsToRemoveDiags) + + if diags.HasErrors() { + return nil, diags + } + plan, walkDiags := c.planWalk(config, prevRunState, opts) diags = diags.Append(walkDiags) @@ -694,6 +707,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State, ExternalReferences: opts.ExternalReferences, ImportTargets: opts.ImportTargets, GenerateConfigPath: opts.GenerateConfigPath, + EndpointsToRemove: opts.EndpointsToRemove, }).Build(addrs.RootModuleInstance) return graph, walkPlan, diags case plans.RefreshOnlyMode: diff --git a/internal/tofu/context_plan2_test.go b/internal/tofu/context_plan2_test.go index 5a5ff3c002..d55480ce38 100644 --- a/internal/tofu/context_plan2_test.go +++ b/internal/tofu/context_plan2_test.go @@ -5410,3 +5410,761 @@ locals { t.Errorf("expected resource to be in planned state") } } + +func TestContext2Plan_removedResourceBasic(t *testing.T) { + desposedKey := states.DeposedKey("deposed") + addr := mustResourceInstanceAddr("test_object.a") + m := testModuleInline(t, map[string]string{ + "main.tf": ` + removed { + from = test_object.a + } + `, + }) + + state := states.BuildState(func(s *states.SyncState) { + // The prior state tracks test_object.a, which we should be + // removed from the state by the "removed" block in the config. + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + s.SetResourceInstanceDeposed( + mustResourceInstanceAddr(addr.String()), + desposedKey, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectTainted, + AttrsJSON: []byte(`{"test_string":"old"}`), + Dependencies: []addrs.ConfigResource{}, + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), + ) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addr, + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + for _, test := range []struct { + deposedKey states.DeposedKey + wantReason plans.ResourceInstanceChangeActionReason + }{{desposedKey, plans.ResourceInstanceChangeNoReason}, {states.NotDeposed, plans.ResourceInstanceDeleteBecauseNoResourceConfig}} { + t.Run(addr.String(), func(t *testing.T) { + var instPlan *plans.ResourceInstanceChangeSrc + + if test.deposedKey == states.NotDeposed { + instPlan = plan.Changes.ResourceInstance(addr) + } else { + instPlan = plan.Changes.ResourceInstanceDeposed(addr, test.deposedKey) + } + + if instPlan == nil { + t.Fatalf("no plan for %s at all", addr) + } + + if got, want := instPlan.Addr, addr; !got.Equal(want) { + t.Errorf("wrong current address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.PrevRunAddr, addr; !got.Equal(want) { + t.Errorf("wrong previous run address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.Action, plans.Forget; got != want { + t.Errorf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.ActionReason, test.wantReason; got != want { + t.Errorf("wrong action reason\ngot: %s\nwant: %s", got, want) + } + }) + } +} + +func TestContext2Plan_removedModuleBasic(t *testing.T) { + desposedKey := states.DeposedKey("deposed") + addr := mustResourceInstanceAddr("module.mod.test_object.a") + m := testModuleInline(t, map[string]string{ + "main.tf": ` + removed { + from = module.mod + } + `, + }) + + state := states.BuildState(func(s *states.SyncState) { + // The prior state tracks module.mod.test_object.a, which should be + // removed from the state by the module's "removed" block in the root module config. + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + s.SetResourceInstanceDeposed( + mustResourceInstanceAddr(addr.String()), + desposedKey, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectTainted, + AttrsJSON: []byte(`{"test_string":"old"}`), + Dependencies: []addrs.ConfigResource{}, + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), + ) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addr, + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + for _, test := range []struct { + deposedKey states.DeposedKey + wantReason plans.ResourceInstanceChangeActionReason + }{{desposedKey, plans.ResourceInstanceChangeNoReason}, {states.NotDeposed, plans.ResourceInstanceDeleteBecauseNoResourceConfig}} { + t.Run(addr.String(), func(t *testing.T) { + var instPlan *plans.ResourceInstanceChangeSrc + + if test.deposedKey == states.NotDeposed { + instPlan = plan.Changes.ResourceInstance(addr) + } else { + instPlan = plan.Changes.ResourceInstanceDeposed(addr, test.deposedKey) + } + + if instPlan == nil { + t.Fatalf("no plan for %s at all", addr) + } + + if got, want := instPlan.Addr, addr; !got.Equal(want) { + t.Errorf("wrong current address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.PrevRunAddr, addr; !got.Equal(want) { + t.Errorf("wrong previous run address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.Action, plans.Forget; got != want { + t.Errorf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.ActionReason, test.wantReason; got != want { + t.Errorf("wrong action reason\ngot: %s\nwant: %s", got, want) + } + }) + } +} + +func TestContext2Plan_removedModuleForgetsAllInstances(t *testing.T) { + addrFirst := mustResourceInstanceAddr("module.mod[0].test_object.a") + addrSecond := mustResourceInstanceAddr("module.mod[1].test_object.a") + + m := testModuleInline(t, map[string]string{ + "main.tf": ` + removed { + from = module.mod + } + `, + }) + + state := states.BuildState(func(s *states.SyncState) { + // The prior state tracks module.mod[0].test_object.a and + // module.mod[1].test_object.a, which we should be removed + // from the state by the "removed" block in the config. + s.SetResourceInstanceCurrent(addrFirst, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + s.SetResourceInstanceCurrent(addrSecond, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addrFirst, addrSecond, + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + for _, resourceInstance := range []addrs.AbsResourceInstance{addrFirst, addrSecond} { + t.Run(resourceInstance.String(), func(t *testing.T) { + instPlan := plan.Changes.ResourceInstance(resourceInstance) + if instPlan == nil { + t.Fatalf("no plan for %s at all", resourceInstance) + } + + if got, want := instPlan.Addr, resourceInstance; !got.Equal(want) { + t.Errorf("wrong current address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.PrevRunAddr, resourceInstance; !got.Equal(want) { + t.Errorf("wrong previous run address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.Action, plans.Forget; got != want { + t.Errorf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.ActionReason, plans.ResourceInstanceDeleteBecauseNoResourceConfig; got != want { + t.Errorf("wrong action reason\ngot: %s\nwant: %s", got, want) + } + }) + } +} + +func TestContext2Plan_removedResourceForgetsAllInstances(t *testing.T) { + addrFirst := mustResourceInstanceAddr("test_object.a[0]") + addrSecond := mustResourceInstanceAddr("test_object.a[1]") + + m := testModuleInline(t, map[string]string{ + "main.tf": ` + removed { + from = test_object.a + } + `, + }) + + state := states.BuildState(func(s *states.SyncState) { + // The prior state tracks test_object.a[0] and + // test_object.a[1], which we should be removed from + // the state by the "removed" block in the config. + s.SetResourceInstanceCurrent(addrFirst, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + s.SetResourceInstanceCurrent(addrSecond, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addrFirst, addrSecond, + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + for _, resourceInstance := range []addrs.AbsResourceInstance{addrFirst, addrSecond} { + t.Run(resourceInstance.String(), func(t *testing.T) { + instPlan := plan.Changes.ResourceInstance(resourceInstance) + if instPlan == nil { + t.Fatalf("no plan for %s at all", resourceInstance) + } + + if got, want := instPlan.Addr, resourceInstance; !got.Equal(want) { + t.Errorf("wrong current address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.PrevRunAddr, resourceInstance; !got.Equal(want) { + t.Errorf("wrong previous run address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.Action, plans.Forget; got != want { + t.Errorf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.ActionReason, plans.ResourceInstanceDeleteBecauseNoResourceConfig; got != want { + t.Errorf("wrong action reason\ngot: %s\nwant: %s", got, want) + } + }) + } +} + +func TestContext2Plan_removedResourceInChildModuleFromParentModule(t *testing.T) { + addr := mustResourceInstanceAddr("module.mod.test_object.a") + m := testModuleInline(t, map[string]string{ + "main.tf": ` + module "mod" { + source = "./mod" + } + + removed { + from = module.mod.test_object.a + } + `, + "mod/main.tf": ``, + }) + + state := states.BuildState(func(s *states.SyncState) { + // The prior state tracks module.mod.test_object.a.a, which we should be + // removed from the state by the "removed" block in the root config. + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addr, + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + t.Run(addr.String(), func(t *testing.T) { + instPlan := plan.Changes.ResourceInstance(addr) + if instPlan == nil { + t.Fatalf("no plan for %s at all", addr) + } + + if got, want := instPlan.Addr, addr; !got.Equal(want) { + t.Errorf("wrong current address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.PrevRunAddr, addr; !got.Equal(want) { + t.Errorf("wrong previous run address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.Action, plans.Forget; got != want { + t.Errorf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.ActionReason, plans.ResourceInstanceDeleteBecauseNoResourceConfig; got != want { + t.Errorf("wrong action reason\ngot: %s\nwant: %s", got, want) + } + }) +} + +func TestContext2Plan_removedResourceInChildModuleFromChildModule(t *testing.T) { + addr := mustResourceInstanceAddr("module.mod.test_object.a") + m := testModuleInline(t, map[string]string{ + "main.tf": ` + module "mod" { + source = "./mod" + } + `, + "mod/main.tf": ` + removed { + from = test_object.a + } + `, + }) + + state := states.BuildState(func(s *states.SyncState) { + // The prior state tracks module.mod.test_object.a.a, which we should be + // removed from the state by the "removed" block in the child mofule config. + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addr, + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + t.Run(addr.String(), func(t *testing.T) { + instPlan := plan.Changes.ResourceInstance(addr) + if instPlan == nil { + t.Fatalf("no plan for %s at all", addr) + } + + if got, want := instPlan.Addr, addr; !got.Equal(want) { + t.Errorf("wrong current address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.PrevRunAddr, addr; !got.Equal(want) { + t.Errorf("wrong previous run address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.Action, plans.Forget; got != want { + t.Errorf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.ActionReason, plans.ResourceInstanceDeleteBecauseNoResourceConfig; got != want { + t.Errorf("wrong action reason\ngot: %s\nwant: %s", got, want) + } + }) +} + +func TestContext2Plan_removedResourceInGrandchildModuleFromRootModule(t *testing.T) { + addr := mustResourceInstanceAddr("module.child.module.grandchild.test_object.a") + m := testModuleInline(t, map[string]string{ + "main.tf": ` + module "child" { + source = "./child" + } + + removed { + from = module.child.module.grandchild.test_object.a + } + `, + "child/main.tf": ``, + }) + + state := states.BuildState(func(s *states.SyncState) { + // The prior state tracks module.child.module.grandchild.test_object.a, + // which we should be removed from the state by the "removed" block in + // the root config. + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addr, + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + t.Run(addr.String(), func(t *testing.T) { + instPlan := plan.Changes.ResourceInstance(addr) + if instPlan == nil { + t.Fatalf("no plan for %s at all", addr) + } + + if got, want := instPlan.Addr, addr; !got.Equal(want) { + t.Errorf("wrong current address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.PrevRunAddr, addr; !got.Equal(want) { + t.Errorf("wrong previous run address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.Action, plans.Forget; got != want { + t.Errorf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.ActionReason, plans.ResourceInstanceDeleteBecauseNoResourceConfig; got != want { + t.Errorf("wrong action reason\ngot: %s\nwant: %s", got, want) + } + }) +} + +func TestContext2Plan_removedChildModuleForgetsResourceInGrandchildModule(t *testing.T) { + addr := mustResourceInstanceAddr("module.child.module.grandchild.test_object.a") + m := testModuleInline(t, map[string]string{ + "main.tf": ` + module "child" { + source = "./child" + } + + removed { + from = module.child.module.grandchild + } + `, + "child/main.tf": ``, + }) + + state := states.BuildState(func(s *states.SyncState) { + // The prior state tracks module.child.module.grandchild.test_object.a, + // which we should be removed from the state by the "removed" block + // in the root config. + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addr, + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + t.Run(addr.String(), func(t *testing.T) { + instPlan := plan.Changes.ResourceInstance(addr) + if instPlan == nil { + t.Fatalf("no plan for %s at all", addr) + } + + if got, want := instPlan.Addr, addr; !got.Equal(want) { + t.Errorf("wrong current address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.PrevRunAddr, addr; !got.Equal(want) { + t.Errorf("wrong previous run address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.Action, plans.Forget; got != want { + t.Errorf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.ActionReason, plans.ResourceInstanceDeleteBecauseNoResourceConfig; got != want { + t.Errorf("wrong action reason\ngot: %s\nwant: %s", got, want) + } + }) +} + +func TestContext2Plan_movedAndRemovedResourceAtTheSameTime(t *testing.T) { + // This is the only scenario where the "moved" and "removed" blocks can + // coexist while referencing the same resource. In this case, the "moved" logic + // will run first, trying to move the resource to a non-existing target. + // Usually ,it will cause the resource to be destroyed, but because the + // "removed" block is also present, it will be removed from the state instead. + addrA := mustResourceInstanceAddr("test_object.a") + addrB := mustResourceInstanceAddr("test_object.b") + m := testModuleInline(t, map[string]string{ + "main.tf": ` + removed { + from = test_object.b + } + + moved { + from = test_object.a + to = test_object.b + } + `, + }) + + state := states.BuildState(func(s *states.SyncState) { + // The prior state tracks test_object.a, which we should treat as + // test_object.b because of the "moved" block in the config. + s.SetResourceInstanceCurrent(addrA, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addrA, + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + t.Run(addrA.String(), func(t *testing.T) { + instPlan := plan.Changes.ResourceInstance(addrA) + if instPlan != nil { + t.Fatalf("unexpected plan for %s; should've moved to %s", addrA, addrB) + } + }) + t.Run(addrB.String(), func(t *testing.T) { + instPlan := plan.Changes.ResourceInstance(addrB) + if instPlan == nil { + t.Fatalf("no plan for %s at all", addrB) + } + + if got, want := instPlan.Addr, addrB; !got.Equal(want) { + t.Errorf("wrong current address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.PrevRunAddr, addrA; !got.Equal(want) { + t.Errorf("wrong previous run address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.Action, plans.Forget; got != want { + t.Errorf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.ActionReason, plans.ResourceInstanceDeleteBecauseNoMoveTarget; got != want { + t.Errorf("wrong action reason\ngot: %s\nwant: %s", got, want) + } + }) +} + +func TestContext2Plan_removedResourceButResourceBlockStillExists(t *testing.T) { + addr := mustResourceInstanceAddr("test_object.a") + m := testModuleInline(t, map[string]string{ + "main.tf": ` + resource "test_object" "a" { + test_string = "foo" + } + + removed { + from = test_object.a + } + `, + }) + + state := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + _, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addr, + }, + }) + + if !diags.HasErrors() { + t.Fatal("succeeded; want errors") + } + + if got, want := diags.Err().Error(), "Removed resource block still exists"; !strings.Contains(got, want) { + t.Fatalf("wrong error:\ngot: %s\nwant: message containing %q", got, want) + } +} + +func TestContext2Plan_removedResourceButResourceBlockStillExistsInChildModule(t *testing.T) { + addr := mustResourceInstanceAddr("module.mod.test_object.a") + m := testModuleInline(t, map[string]string{ + "main.tf": ` + module "mod" { + source = "./mod" + } + + removed { + from = module.mod.test_object.a + } + `, + "mod/main.tf": ` + resource "test_object" "a" { + test_string = "foo" + } + `, + }) + + state := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + _, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addr, + }, + }) + + if !diags.HasErrors() { + t.Fatal("succeeded; want errors") + } + + if got, want := diags.Err().Error(), "Removed resource block still exists"; !strings.Contains(got, want) { + t.Fatalf("wrong error:\ngot: %s\nwant: message containing %q", got, want) + } +} + +func TestContext2Plan_removedModuleButModuleBlockStillExists(t *testing.T) { + addr := mustResourceInstanceAddr("module.mod.test_object.a") + m := testModuleInline(t, map[string]string{ + "main.tf": ` + module "mod" { + source = "./mod" + } + + removed { + from = module.mod + } + `, + "mod/main.tf": ` + resource "test_object" "a" { + test_string = "foo" + } + `, + }) + + state := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + _, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + ForceReplace: []addrs.AbsResourceInstance{ + addr, + }, + }) + + if !diags.HasErrors() { + t.Fatal("succeeded; want errors") + } + + if got, want := diags.Err().Error(), "Removed module block still exists"; !strings.Contains(got, want) { + t.Fatalf("wrong error:\ngot: %s\nwant: message containing %q", got, want) + } +} diff --git a/internal/tofu/graph_builder_plan.go b/internal/tofu/graph_builder_plan.go index 827574b614..905450780c 100644 --- a/internal/tofu/graph_builder_plan.go +++ b/internal/tofu/graph_builder_plan.go @@ -83,6 +83,10 @@ type PlanGraphBuilder struct { // ImportTargets are the list of resources to import. ImportTargets []*ImportTarget + // EndpointsToRemove are the list of resources and modules to forget from + // the state. + EndpointsToRemove []addrs.ConfigRemovable + // GenerateConfig tells OpenTofu where to write and generated config for // any import targets that do not already have configuration. // @@ -266,6 +270,7 @@ func (b *PlanGraphBuilder) initPlan() { NodeAbstractResourceInstance: a, skipRefresh: b.skipRefresh, skipPlanChanges: b.skipPlanChanges, + EndpointsToRemove: b.EndpointsToRemove, } } @@ -274,8 +279,9 @@ func (b *PlanGraphBuilder) initPlan() { NodeAbstractResourceInstance: a, DeposedKey: key, - skipRefresh: b.skipRefresh, - skipPlanChanges: b.skipPlanChanges, + skipRefresh: b.skipRefresh, + skipPlanChanges: b.skipPlanChanges, + EndpointsToRemove: b.EndpointsToRemove, } } } diff --git a/internal/tofu/node_resource_abstract_instance.go b/internal/tofu/node_resource_abstract_instance.go index 9089e215ce..f8f87d4b96 100644 --- a/internal/tofu/node_resource_abstract_instance.go +++ b/internal/tofu/node_resource_abstract_instance.go @@ -348,6 +348,31 @@ func (n *NodeAbstractResourceInstance) writeResourceInstanceStateImpl(ctx EvalCo return nil } +// planForget returns a removed from state diff. +func (n *NodeAbstractResourceInstance) planForget(ctx EvalContext, currentState *states.ResourceInstanceObject, deposedKey states.DeposedKey) *plans.ResourceInstanceChange { + var plan *plans.ResourceInstanceChange + + unmarkedPriorVal, _ := currentState.Value.UnmarkDeep() + + // The config and new value are null to signify that this is a forget + // operation. + nullVal := cty.NullVal(unmarkedPriorVal.Type()) + + plan = &plans.ResourceInstanceChange{ + Addr: n.Addr, + PrevRunAddr: n.prevRunAddr(ctx), + DeposedKey: deposedKey, + Change: plans.Change{ + Action: plans.Forget, + Before: currentState.Value, + After: nullVal, + }, + ProviderAddr: n.ResolvedProvider, + } + + return plan +} + // planDestroy returns a plain destroy diff. func (n *NodeAbstractResourceInstance) planDestroy(ctx EvalContext, currentState *states.ResourceInstanceObject, deposedKey states.DeposedKey) (*plans.ResourceInstanceChange, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics diff --git a/internal/tofu/node_resource_destroy_deposed.go b/internal/tofu/node_resource_deposed.go similarity index 80% rename from internal/tofu/node_resource_destroy_deposed.go rename to internal/tofu/node_resource_deposed.go index e77e008906..91e09c9b42 100644 --- a/internal/tofu/node_resource_destroy_deposed.go +++ b/internal/tofu/node_resource_deposed.go @@ -43,6 +43,12 @@ type NodePlanDeposedResourceInstanceObject struct { // skipPlanChanges indicates we should skip trying to plan change actions // for any instances. skipPlanChanges bool + + // EndpointsToRemove are resource instance addresses where the user wants to + // forget from the state. This set isn't pre-filtered, so + // it might contain addresses that have nothing to do with the resource + // that this node represents, which the node itself must therefore ignore. + EndpointsToRemove []addrs.ConfigRemovable } var ( @@ -132,8 +138,23 @@ func (n *NodePlanDeposedResourceInstanceObject) Execute(ctx EvalContext, op walk if !n.skipPlanChanges { var change *plans.ResourceInstanceChange - change, destroyPlanDiags := n.planDestroy(ctx, state, n.DeposedKey) - diags = diags.Append(destroyPlanDiags) + var planDiags tfdiags.Diagnostics + + shouldForget := false + + for _, etf := range n.EndpointsToRemove { + if etf.TargetContains(n.Addr) { + shouldForget = true + } + } + + if shouldForget { + change = n.planForget(ctx, state, n.DeposedKey) + } else { + change, planDiags = n.planDestroy(ctx, state, n.DeposedKey) + } + + diags = diags.Append(planDiags) if diags.HasErrors() { return diags } @@ -333,3 +354,63 @@ func (n *NodeDestroyDeposedResourceInstanceObject) writeResourceInstanceState(ct state.SetResourceInstanceDeposed(absAddr, key, src, n.ResolvedProvider) return nil } + +// NodeForgetDeposedResourceInstanceObject represents deposed resource +// instance objects during apply. Nodes of this type are inserted by +// DiffTransformer when the planned changeset contains "forget" changes for +// deposed instance objects, and its only supported operation is to forget +// the associated object from the state. +type NodeForgetDeposedResourceInstanceObject struct { + *NodeAbstractResourceInstance + DeposedKey states.DeposedKey +} + +var ( + _ GraphNodeDeposedResourceInstanceObject = (*NodeForgetDeposedResourceInstanceObject)(nil) + _ GraphNodeConfigResource = (*NodeForgetDeposedResourceInstanceObject)(nil) + _ GraphNodeResourceInstance = (*NodeForgetDeposedResourceInstanceObject)(nil) + _ GraphNodeReferenceable = (*NodeForgetDeposedResourceInstanceObject)(nil) + _ GraphNodeReferencer = (*NodeForgetDeposedResourceInstanceObject)(nil) + _ GraphNodeExecutable = (*NodeForgetDeposedResourceInstanceObject)(nil) + _ GraphNodeProviderConsumer = (*NodeForgetDeposedResourceInstanceObject)(nil) + _ GraphNodeProvisionerConsumer = (*NodeForgetDeposedResourceInstanceObject)(nil) +) + +func (n *NodeForgetDeposedResourceInstanceObject) Name() string { + return fmt.Sprintf("%s (forget deposed %s)", n.ResourceInstanceAddr(), n.DeposedKey) +} + +func (n *NodeForgetDeposedResourceInstanceObject) DeposedInstanceObjectKey() states.DeposedKey { + return n.DeposedKey +} + +// GraphNodeReferenceable implementation, overriding the one from NodeAbstractResourceInstance +func (n *NodeForgetDeposedResourceInstanceObject) ReferenceableAddrs() []addrs.Referenceable { + // Deposed objects don't participate in references. + return nil +} + +// GraphNodeReferencer implementation, overriding the one from NodeAbstractResourceInstance +func (n *NodeForgetDeposedResourceInstanceObject) References() []*addrs.Reference { + // We don't evaluate configuration for deposed objects, so they effectively + // make no references. + return nil +} + +// GraphNodeExecutable impl. +func (n *NodeForgetDeposedResourceInstanceObject) Execute(ctx EvalContext, op walkOperation) (diags tfdiags.Diagnostics) { + // Read the state for the deposed resource instance + state, err := n.readResourceInstanceStateDeposed(ctx, n.Addr, n.DeposedKey) + if err != nil { + return diags.Append(err) + } + + if state == nil { + log.Printf("[WARN] NodeForgetDeposedResourceInstanceObject for %s (%s) with no state", n.Addr, n.DeposedKey) + } + + contextState := ctx.State() + contextState.ForgetResourceInstanceDeposed(n.Addr, n.DeposedKey) + + return diags.Append(updateStateHook(ctx)) +} diff --git a/internal/tofu/node_resource_destroy_deposed_test.go b/internal/tofu/node_resource_deposed_test.go similarity index 52% rename from internal/tofu/node_resource_destroy_deposed_test.go rename to internal/tofu/node_resource_deposed_test.go index 598736b625..6eff88f5e4 100644 --- a/internal/tofu/node_resource_destroy_deposed_test.go +++ b/internal/tofu/node_resource_deposed_test.go @@ -17,120 +17,118 @@ import ( ) func TestNodePlanDeposedResourceInstanceObject_Execute(t *testing.T) { - deposedKey := states.NewDeposedKey() - state := states.NewState() - absResource := mustResourceInstanceAddr("test_instance.foo") - state.Module(addrs.RootModuleInstance).SetResourceInstanceDeposed( - absResource.Resource, - deposedKey, - &states.ResourceInstanceObjectSrc{ - Status: states.ObjectTainted, - AttrsJSON: []byte(`{"id":"bar"}`), + tests := []struct { + description string + nodeAddress string + nodeEndpointsToRemove []addrs.ConfigRemovable + wantAction plans.Action + }{ + { + nodeAddress: "test_instance.foo", + nodeEndpointsToRemove: make([]addrs.ConfigRemovable, 0), + wantAction: plans.Delete, + }, + { + nodeAddress: "test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("test_instance.bar")).(addrs.ConfigRemovable), + }, + wantAction: plans.Delete, + }, + { + nodeAddress: "test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(addrs.Module{"boop"}).(addrs.ConfigRemovable), + }, + wantAction: plans.Delete, + }, + { + nodeAddress: "test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("test_instance.foo")).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, + }, + { + nodeAddress: "test_instance.foo[1]", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("test_instance.foo")).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, + }, + { + nodeAddress: "module.boop.test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("module.boop.test_instance.foo")).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, + }, + { + nodeAddress: "module.boop[1].test_instance.foo[1]", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("module.boop.test_instance.foo")).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, + }, + { + nodeAddress: "module.boop.test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(addrs.Module{"boop"}).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, + }, + { + nodeAddress: "module.boop[1].test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(addrs.Module{"boop"}).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, }, - mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), - ) - - p := testProvider("test") - p.ConfigureProvider(providers.ConfigureProviderRequest{}) - p.UpgradeResourceStateResponse = &providers.UpgradeResourceStateResponse{ - UpgradedState: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("bar"), - }), } - ctx := &MockEvalContext{ - StateState: state.SyncWrapper(), - PrevRunStateState: state.DeepCopy().SyncWrapper(), - RefreshStateState: state.DeepCopy().SyncWrapper(), - ProviderProvider: p, - ProviderSchemaSchema: providers.ProviderSchema{ - ResourceTypes: map[string]providers.Schema{ - "test_instance": { - Block: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": { - Type: cty.String, - Computed: true, - }, - }, - }, + + for _, test := range tests { + deposedKey := states.NewDeposedKey() + absResource := mustResourceInstanceAddr(test.nodeAddress) + + ctx, p := initMockEvalContext(test.nodeAddress, deposedKey) + + node := NodePlanDeposedResourceInstanceObject{ + NodeAbstractResourceInstance: &NodeAbstractResourceInstance{ + Addr: absResource, + NodeAbstractResource: NodeAbstractResource{ + ResolvedProvider: mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), }, }, - }, - ChangesChanges: plans.NewChanges().SyncWrapper(), - } + DeposedKey: deposedKey, + EndpointsToRemove: test.nodeEndpointsToRemove, + } - node := NodePlanDeposedResourceInstanceObject{ - NodeAbstractResourceInstance: &NodeAbstractResourceInstance{ - Addr: absResource, - NodeAbstractResource: NodeAbstractResource{ - ResolvedProvider: mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), - }, - }, - DeposedKey: deposedKey, - } - err := node.Execute(ctx, walkPlan) - if err != nil { - t.Fatalf("unexpected error: %s", err) - } + err := node.Execute(ctx, walkPlan) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } - if !p.UpgradeResourceStateCalled { - t.Errorf("UpgradeResourceState wasn't called; should've been called to upgrade the previous run's object") - } - if !p.ReadResourceCalled { - t.Errorf("ReadResource wasn't called; should've been called to refresh the deposed object") - } + if !p.UpgradeResourceStateCalled { + t.Errorf("UpgradeResourceState wasn't called; should've been called to upgrade the previous run's object") + } + if !p.ReadResourceCalled { + t.Errorf("ReadResource wasn't called; should've been called to refresh the deposed object") + } - change := ctx.Changes().GetResourceInstanceChange(absResource, deposedKey) - if got, want := change.ChangeSrc.Action, plans.Delete; got != want { - t.Fatalf("wrong planned action\ngot: %s\nwant: %s", got, want) + change := ctx.Changes().GetResourceInstanceChange(absResource, deposedKey) + if got, want := change.ChangeSrc.Action, test.wantAction; got != want { + t.Fatalf("wrong planned action\ngot: %s\nwant: %s", got, want) + } } } func TestNodeDestroyDeposedResourceInstanceObject_Execute(t *testing.T) { deposedKey := states.NewDeposedKey() state := states.NewState() - absResource := mustResourceInstanceAddr("test_instance.foo") - state.Module(addrs.RootModuleInstance).SetResourceInstanceDeposed( - absResource.Resource, - deposedKey, - &states.ResourceInstanceObjectSrc{ - Status: states.ObjectTainted, - AttrsJSON: []byte(`{"id":"bar"}`), - }, - mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), - ) - - schema := providers.ProviderSchema{ - ResourceTypes: map[string]providers.Schema{ - "test_instance": { - Block: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": { - Type: cty.String, - Computed: true, - }, - }, - }, - }, - }, - } - - p := testProvider("test") - p.ConfigureProvider(providers.ConfigureProviderRequest{}) - p.GetProviderSchemaResponse = &schema - - p.UpgradeResourceStateResponse = &providers.UpgradeResourceStateResponse{ - UpgradedState: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("bar"), - }), - } - ctx := &MockEvalContext{ - StateState: state.SyncWrapper(), - ProviderProvider: p, - ProviderSchemaSchema: schema, - ChangesChanges: plans.NewChanges().SyncWrapper(), - } + absResourceAddr := "test_instance.foo" + ctx, _ := initMockEvalContext(absResourceAddr, deposedKey) + absResource := mustResourceInstanceAddr(absResourceAddr) node := NodeDestroyDeposedResourceInstanceObject{ NodeAbstractResourceInstance: &NodeAbstractResourceInstance{ Addr: absResource, @@ -219,3 +217,82 @@ func TestNodeDestroyDeposedResourceInstanceObject_ExecuteMissingState(t *testing t.Fatal("expected error") } } + +func TestNodeForgetDeposedResourceInstanceObject_Execute(t *testing.T) { + deposedKey := states.NewDeposedKey() + state := states.NewState() + absResourceAddr := "test_instance.foo" + ctx, _ := initMockEvalContext(absResourceAddr, deposedKey) + + absResource := mustResourceInstanceAddr(absResourceAddr) + node := NodeForgetDeposedResourceInstanceObject{ + NodeAbstractResourceInstance: &NodeAbstractResourceInstance{ + Addr: absResource, + NodeAbstractResource: NodeAbstractResource{ + ResolvedProvider: mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), + }, + }, + DeposedKey: deposedKey, + } + err := node.Execute(ctx, walkApply) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !state.Empty() { + t.Fatalf("resources left in state after forget") + } +} + +func initMockEvalContext(resourceAddrs string, deposedKey states.DeposedKey) (*MockEvalContext, *MockProvider) { + state := states.NewState() + absResource := mustResourceInstanceAddr(resourceAddrs) + + if !absResource.Module.Module().Equal(addrs.RootModule) { + state.EnsureModule(addrs.RootModuleInstance.Child(absResource.Module[0].Name, absResource.Module[0].InstanceKey)) + } + + state.Module(absResource.Module).SetResourceInstanceDeposed( + absResource.Resource, + deposedKey, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectTainted, + AttrsJSON: []byte(`{"id":"bar"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), + ) + + schema := providers.ProviderSchema{ + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + }, + } + + p := testProvider("test") + p.ConfigureProvider(providers.ConfigureProviderRequest{}) + p.GetProviderSchemaResponse = &schema + + p.UpgradeResourceStateResponse = &providers.UpgradeResourceStateResponse{ + UpgradedState: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("bar"), + }), + } + return &MockEvalContext{ + PrevRunStateState: state.DeepCopy().SyncWrapper(), + RefreshStateState: state.DeepCopy().SyncWrapper(), + StateState: state.SyncWrapper(), + ProviderProvider: p, + ProviderSchemaSchema: schema, + ChangesChanges: plans.NewChanges().SyncWrapper(), + }, p +} diff --git a/internal/tofu/node_resource_forget.go b/internal/tofu/node_resource_forget.go new file mode 100644 index 0000000000..e4767ff3a3 --- /dev/null +++ b/internal/tofu/node_resource_forget.go @@ -0,0 +1,75 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tofu + +import ( + "fmt" + "log" + + "github.com/opentofu/opentofu/internal/tfdiags" + + "github.com/opentofu/opentofu/internal/states" +) + +// NodeForgetResourceInstance represents a resource instance that is to be +// forgotten from the state. +type NodeForgetResourceInstance struct { + *NodeAbstractResourceInstance + + // If DeposedKey is set to anything other than states.NotDeposed then + // this node forgets a deposed object of the associated instance + // rather than its current object. + DeposedKey states.DeposedKey +} + +var ( + _ GraphNodeModuleInstance = (*NodeForgetResourceInstance)(nil) + _ GraphNodeConfigResource = (*NodeForgetResourceInstance)(nil) + _ GraphNodeResourceInstance = (*NodeForgetResourceInstance)(nil) + _ GraphNodeReferenceable = (*NodeForgetResourceInstance)(nil) + _ GraphNodeReferencer = (*NodeForgetResourceInstance)(nil) + _ GraphNodeExecutable = (*NodeForgetResourceInstance)(nil) + _ GraphNodeProviderConsumer = (*NodeForgetResourceInstance)(nil) + _ GraphNodeProvisionerConsumer = (*NodeForgetResourceInstance)(nil) +) + +func (n *NodeForgetResourceInstance) Name() string { + if n.DeposedKey != states.NotDeposed { + return fmt.Sprintf("%s (forget deposed %s)", n.ResourceInstanceAddr(), n.DeposedKey) + } + return n.ResourceInstanceAddr().String() + " (forget)" +} + +// GraphNodeExecutable +func (n *NodeForgetResourceInstance) Execute(ctx EvalContext, op walkOperation) (diags tfdiags.Diagnostics) { + addr := n.ResourceInstanceAddr() + + // Get our state + is := n.instanceState + if is == nil { + log.Printf("[WARN] NodeForgetResourceInstance for %s with no state", addr) + } + + var state *states.ResourceInstanceObject + + state, readDiags := n.readResourceInstanceState(ctx, addr) + diags = diags.Append(readDiags) + if diags.HasErrors() { + return diags + } + + // Exit early if the state object is null after reading the state + if state == nil || state.Value.IsNull() { + return diags + } + + contextState := ctx.State() + contextState.ForgetResourceInstanceAll(n.Addr) + + diags = diags.Append(updateStateHook(ctx)) + + return diags +} diff --git a/internal/tofu/node_resource_plan_orphan.go b/internal/tofu/node_resource_plan_orphan.go index d8470454ad..5ea6b5a0c9 100644 --- a/internal/tofu/node_resource_plan_orphan.go +++ b/internal/tofu/node_resource_plan_orphan.go @@ -26,6 +26,12 @@ type NodePlannableResourceInstanceOrphan struct { // skipPlanChanges indicates we should skip trying to plan change actions // for any instances. skipPlanChanges bool + + // EndpointsToRemove are resource instance addresses where the user wants to + // forget from the state. This set isn't pre-filtered, so + // it might contain addresses that have nothing to do with the resource + // that this node represents, which the node itself must therefore ignore. + EndpointsToRemove []addrs.ConfigRemovable } var ( @@ -135,8 +141,23 @@ func (n *NodePlannableResourceInstanceOrphan) managedResourceExecute(ctx EvalCon } var change *plans.ResourceInstanceChange - change, destroyPlanDiags := n.planDestroy(ctx, oldState, "") - diags = diags.Append(destroyPlanDiags) + var planDiags tfdiags.Diagnostics + + shouldForget := false + + for _, etf := range n.EndpointsToRemove { + if etf.TargetContains(n.Addr) { + shouldForget = true + } + } + + if shouldForget { + change = n.planForget(ctx, oldState, "") + } else { + change, planDiags = n.planDestroy(ctx, oldState, "") + } + + diags = diags.Append(planDiags) if diags.HasErrors() { return diags } diff --git a/internal/tofu/node_resource_plan_orphan_test.go b/internal/tofu/node_resource_plan_orphan_test.go index 3c451e0299..47324ca034 100644 --- a/internal/tofu/node_resource_plan_orphan_test.go +++ b/internal/tofu/node_resource_plan_orphan_test.go @@ -8,6 +8,8 @@ package tofu import ( "testing" + "github.com/opentofu/opentofu/internal/configs/configschema" + "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/instances" "github.com/opentofu/opentofu/internal/plans" @@ -16,61 +18,153 @@ import ( "github.com/zclconf/go-cty/cty" ) -func TestNodeResourcePlanOrphanExecute(t *testing.T) { - state := states.NewState() - state.Module(addrs.RootModuleInstance).SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_object", - Name: "foo", - }.Instance(addrs.NoKey), - &states.ResourceInstanceObjectSrc{ - AttrsFlat: map[string]string{ - "test_string": "foo", +func TestNodeResourcePlanOrphan_Execute(t *testing.T) { + tests := []struct { + description string + nodeAddress string + nodeEndpointsToRemove []addrs.ConfigRemovable + wantAction plans.Action + }{ + { + nodeAddress: "test_instance.foo", + nodeEndpointsToRemove: make([]addrs.ConfigRemovable, 0), + wantAction: plans.Delete, + }, + { + nodeAddress: "test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("test_instance.bar")).(addrs.ConfigRemovable), }, - Status: states.ObjectReady, + wantAction: plans.Delete, }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, + { + nodeAddress: "test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(addrs.Module{"boop"}).(addrs.ConfigRemovable), + }, + wantAction: plans.Delete, }, - ) + { + nodeAddress: "test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("test_instance.foo")).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, + }, + { + nodeAddress: "test_instance.foo[1]", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("test_instance.foo")).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, + }, + { + nodeAddress: "module.boop.test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("module.boop.test_instance.foo")).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, + }, + { + nodeAddress: "module.boop[1].test_instance.foo[1]", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(mustConfigResourceAddr("module.boop.test_instance.foo")).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, + }, + { + nodeAddress: "module.boop.test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(addrs.Module{"boop"}).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, + }, + { + nodeAddress: "module.boop[1].test_instance.foo", + nodeEndpointsToRemove: []addrs.ConfigRemovable{ + interface{}(addrs.Module{"boop"}).(addrs.ConfigRemovable), + }, + wantAction: plans.Forget, + }, + } - p := simpleMockProvider() - p.ConfigureProvider(providers.ConfigureProviderRequest{}) - ctx := &MockEvalContext{ - StateState: state.SyncWrapper(), - RefreshStateState: state.DeepCopy().SyncWrapper(), - PrevRunStateState: state.DeepCopy().SyncWrapper(), - InstanceExpanderExpander: instances.NewExpander(), - ProviderProvider: p, - ProviderSchemaSchema: providers.ProviderSchema{ + for _, test := range tests { + state := states.NewState() + absResource := mustResourceInstanceAddr(test.nodeAddress) + + if !absResource.Module.Module().Equal(addrs.RootModule) { + state.EnsureModule(addrs.RootModuleInstance.Child(absResource.Module[0].Name, absResource.Module[0].InstanceKey)) + } + + state.Module(absResource.Module).SetResourceInstanceCurrent( + absResource.Resource, + &states.ResourceInstanceObjectSrc{ + AttrsFlat: map[string]string{ + "test_string": "foo", + }, + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + + schema := providers.ProviderSchema{ ResourceTypes: map[string]providers.Schema{ - "test_object": { - Block: simpleTestSchema(), + "test_instance": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + }, + }, }, }, - }, - ChangesChanges: plans.NewChanges().SyncWrapper(), - } + } - node := NodePlannableResourceInstanceOrphan{ - NodeAbstractResourceInstance: &NodeAbstractResourceInstance{ - NodeAbstractResource: NodeAbstractResource{ - ResolvedProvider: addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, + p := simpleMockProvider() + p.ConfigureProvider(providers.ConfigureProviderRequest{}) + p.GetProviderSchemaResponse = &schema + + ctx := &MockEvalContext{ + StateState: state.SyncWrapper(), + RefreshStateState: state.DeepCopy().SyncWrapper(), + PrevRunStateState: state.DeepCopy().SyncWrapper(), + InstanceExpanderExpander: instances.NewExpander(), + ProviderProvider: p, + ProviderSchemaSchema: schema, + ChangesChanges: plans.NewChanges().SyncWrapper(), + } + + node := NodePlannableResourceInstanceOrphan{ + NodeAbstractResourceInstance: &NodeAbstractResourceInstance{ + NodeAbstractResource: NodeAbstractResource{ + ResolvedProvider: addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, }, + Addr: absResource, }, - Addr: mustResourceInstanceAddr("test_object.foo"), - }, - } - diags := node.Execute(ctx, walkApply) - if diags.HasErrors() { - t.Fatalf("unexpected error: %s", diags.Err()) - } - if !state.Empty() { - t.Fatalf("expected empty state, got %s", state.String()) + EndpointsToRemove: test.nodeEndpointsToRemove, + } + + err := node.Execute(ctx, walkPlan) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + change := ctx.Changes().GetResourceInstanceChange(absResource, states.NotDeposed) + if got, want := change.ChangeSrc.Action, test.wantAction; got != want { + t.Fatalf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + + if !state.Empty() { + t.Fatalf("expected empty state, got %s", state.String()) + } } } diff --git a/internal/tofu/transform_diff.go b/internal/tofu/transform_diff.go index 759e827e47..8f10bb6c07 100644 --- a/internal/tofu/transform_diff.go +++ b/internal/tofu/transform_diff.go @@ -91,7 +91,7 @@ func (t *DiffTransformer) Transform(g *Graph) error { // Depending on the action we'll need some different combinations of // nodes, because destroying uses a special node type separate from // other actions. - var update, delete, createBeforeDestroy bool + var update, delete, forget, createBeforeDestroy bool switch rc.Action { case plans.NoOp: // For a no-op change we don't take any action but we still @@ -101,6 +101,8 @@ func (t *DiffTransformer) Transform(g *Graph) error { update = t.hasConfigConditions(addr) case plans.Delete: delete = true + case plans.Forget: + forget = true case plans.DeleteThenCreate, plans.CreateThenDelete: update = true delete = true @@ -109,14 +111,14 @@ func (t *DiffTransformer) Transform(g *Graph) error { update = true } - // A deposed instance may only have a change of Delete or NoOp. A NoOp - // can happen if the provider shows it no longer exists during the most - // recent ReadResource operation. - if dk != states.NotDeposed && !(rc.Action == plans.Delete || rc.Action == plans.NoOp) { + // A deposed instance may only have a change of Delete, Forget or NoOp. + // A NoOp can happen if the provider shows it no longer exists during + // the most recent ReadResource operation. + if dk != states.NotDeposed && !(rc.Action == plans.Delete || rc.Action == plans.NoOp || rc.Action == plans.Forget) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Invalid planned change for deposed object", - fmt.Sprintf("The plan contains a non-delete change for %s deposed object %s. The only valid action for a deposed object is to destroy it, so this is a bug in OpenTofu.", addr, dk), + fmt.Sprintf("The plan contains a non-removal change for %s deposed object %s. The only valid actions for a deposed object is to destroy it or forget it, so this is a bug in OpenTofu.", addr, dk), )) continue } @@ -211,6 +213,26 @@ func (t *DiffTransformer) Transform(g *Graph) error { g.Add(node) } + if forget { + var node GraphNodeResourceInstance + abstract := NewNodeAbstractResourceInstance(addr) + if dk == states.NotDeposed { + node = &NodeForgetResourceInstance{ + NodeAbstractResourceInstance: abstract, + DeposedKey: dk, + } + log.Printf("[TRACE] DiffTransformer: %s will be represented for removal from the state by %s", addr, dag.VertexName(node)) + } else { + node = &NodeForgetDeposedResourceInstanceObject{ + NodeAbstractResourceInstance: abstract, + DeposedKey: dk, + } + log.Printf("[TRACE] DiffTransformer: %s deposed object %s will be represented for removal from the state by %s", addr, dk, dag.VertexName(node)) + } + + g.Add(node) + } + } log.Printf("[TRACE] DiffTransformer complete") diff --git a/website/docs/internals/json-format.mdx b/website/docs/internals/json-format.mdx index ba49ad57c2..98e07ba2ba 100644 --- a/website/docs/internals/json-format.mdx +++ b/website/docs/internals/json-format.mdx @@ -569,17 +569,19 @@ A `` describes the change to the indicated object. // ["delete", "create"] // ["create", "delete"] // ["delete"] + // ["forget"] // The two "replace" actions are represented in this way to allow callers to // e.g. just scan the list for "delete" to recognize all three situations // where the object will be deleted, allowing for any new deletion // combinations that might be added in future. "actions": ["update"], - // "before" and "after" are representations of the object value both before - // and after the action. For ["create"] and ["delete"] actions, either - // "before" or "after" is unset (respectively). For ["no-op"], the before and - // after values are identical. The "after" value will be incomplete if there - // are values within it that won't be known until after apply. + // Before and After are representations of the object value both before and + // after the action. For ["delete"] and ["forget"] actions, the "after" + // value is unset. For ["create"] the "before" is unset. For ["no-op"], the + // before and after values are identical. The "after" value will be + // incomplete if there are values within it that won't be known until after + // apply. "before": , "after": ,