Add support for removed block (#1158)

Signed-off-by: Ronny Orot <ronny.orot@gmail.com>
This commit is contained in:
Ronny Orot 2024-02-21 10:31:44 +02:00 committed by GitHub
parent 851391f2e6
commit e9fe0f1118
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 2864 additions and 305 deletions

View File

@ -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))

View File

@ -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
}

View File

@ -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:]

View File

@ -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.

View File

@ -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)
)

View File

@ -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
}

View File

@ -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)
}
}
})
}
}

View File

@ -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() {}

View File

@ -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 " ?"
}

View File

@ -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()))
}

View File

@ -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)

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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".

View File

@ -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
}

View File

@ -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",
},
},
}

View File

@ -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,
},
},
}

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,19 @@
removed {
from = test.foo
}
removed {
from = test.foo
}
removed {
from = module.a
}
removed {
from = module.a
}
removed {
from = test.foo
}

View File

@ -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
}

View File

@ -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

View File

@ -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:

View File

@ -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 (

View File

@ -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

View File

@ -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}

View File

@ -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,

View File

@ -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
}

View File

@ -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: <no error>\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()
}

View File

@ -0,0 +1,2 @@
resource "foo" "basic_resource" {
}

View File

@ -0,0 +1,7 @@
module "child" {
source = "./child"
}
removed {
from = module.child
}

View File

@ -0,0 +1,2 @@
resource "foo" "basic_resource" {
}

View File

@ -0,0 +1,7 @@
removed {
from = module.child.foo.basic_resource
}
module "child" {
source = "./child"
}

View File

@ -0,0 +1,6 @@
resource "foo" "basic_resource" {
}
removed {
from = foo.basic_resource
}

View File

@ -0,0 +1,7 @@
removed {
from = foo.removed_resource_from_grandchild_module
}
removed {
from = module.removed_module_from_grandchild_module
}

View File

@ -0,0 +1,11 @@
removed {
from = foo.removed_resource_from_child_module
}
module "grandchild" {
source = "./grandchild"
}
removed {
from = module.removed_module_from_child_module
}

View File

@ -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"
}

View File

@ -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")
}
}

View File

@ -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:

View File

@ -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)
}
}

View File

@ -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,
}
}
}

View File

@ -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

View File

@ -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))
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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())
}
}
}

View File

@ -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")

View File

@ -569,17 +569,19 @@ A `<change-representation>` 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": <value-representation>,
"after": <value-representation>,