diff --git a/internal/addrs/count_attr.go b/internal/addrs/count_attr.go index 90a5faf0e..0be5c0264 100644 --- a/internal/addrs/count_attr.go +++ b/internal/addrs/count_attr.go @@ -10,3 +10,9 @@ type CountAttr struct { func (ca CountAttr) String() string { return "count." + ca.Name } + +func (ca CountAttr) UniqueKey() UniqueKey { + return ca // A CountAttr is its own UniqueKey +} + +func (ca CountAttr) uniqueKeySigil() {} diff --git a/internal/addrs/for_each_attr.go b/internal/addrs/for_each_attr.go index 7a6385035..6b0c06096 100644 --- a/internal/addrs/for_each_attr.go +++ b/internal/addrs/for_each_attr.go @@ -10,3 +10,9 @@ type ForEachAttr struct { func (f ForEachAttr) String() string { return "each." + f.Name } + +func (f ForEachAttr) UniqueKey() UniqueKey { + return f // A ForEachAttr is its own UniqueKey +} + +func (f ForEachAttr) uniqueKeySigil() {} diff --git a/internal/addrs/input_variable.go b/internal/addrs/input_variable.go index 975c72f1e..e85743bcd 100644 --- a/internal/addrs/input_variable.go +++ b/internal/addrs/input_variable.go @@ -14,6 +14,12 @@ func (v InputVariable) String() string { return "var." + v.Name } +func (v InputVariable) UniqueKey() UniqueKey { + return v // A InputVariable is its own UniqueKey +} + +func (v InputVariable) uniqueKeySigil() {} + // Absolute converts the receiver into an absolute address within the given // module instance. func (v InputVariable) Absolute(m ModuleInstance) AbsInputVariableInstance { diff --git a/internal/addrs/local_value.go b/internal/addrs/local_value.go index 61a07b9c7..601765006 100644 --- a/internal/addrs/local_value.go +++ b/internal/addrs/local_value.go @@ -14,6 +14,12 @@ func (v LocalValue) String() string { return "local." + v.Name } +func (v LocalValue) UniqueKey() UniqueKey { + return v // A LocalValue is its own UniqueKey +} + +func (v LocalValue) uniqueKeySigil() {} + // Absolute converts the receiver into an absolute address within the given // module instance. func (v LocalValue) Absolute(m ModuleInstance) AbsLocalValue { diff --git a/internal/addrs/module_call.go b/internal/addrs/module_call.go index c55433ac6..d80b35a58 100644 --- a/internal/addrs/module_call.go +++ b/internal/addrs/module_call.go @@ -15,6 +15,12 @@ func (c ModuleCall) String() string { return "module." + c.Name } +func (c ModuleCall) UniqueKey() UniqueKey { + return c // A ModuleCall is its own UniqueKey +} + +func (c ModuleCall) uniqueKeySigil() {} + // Instance returns the address of an instance of the receiver identified by // the given key. func (c ModuleCall) Instance(key InstanceKey) ModuleCallInstance { @@ -47,7 +53,11 @@ func (c AbsModuleCall) absMoveableSigil() { } func (c AbsModuleCall) String() string { - return fmt.Sprintf("%s.%s", c.Module, c.Call.Name) + if len(c.Module) == 0 { + return "module." + c.Call.Name + + } + return fmt.Sprintf("%s.module.%s", c.Module, c.Call.Name) } func (c AbsModuleCall) Instance(key InstanceKey) ModuleInstance { @@ -79,6 +89,12 @@ func (c ModuleCallInstance) String() string { return fmt.Sprintf("module.%s%s", c.Call.Name, c.Key) } +func (c ModuleCallInstance) UniqueKey() UniqueKey { + return c // A ModuleCallInstance is its own UniqueKey +} + +func (c ModuleCallInstance) uniqueKeySigil() {} + func (c ModuleCallInstance) Absolute(moduleAddr ModuleInstance) ModuleInstance { ret := make(ModuleInstance, len(moduleAddr), len(moduleAddr)+1) copy(ret, moduleAddr) @@ -118,6 +134,12 @@ func (m ModuleCallOutput) String() string { return fmt.Sprintf("%s.%s", m.Call.String(), m.Name) } +func (m ModuleCallOutput) UniqueKey() UniqueKey { + return m // A ModuleCallOutput is its own UniqueKey +} + +func (m ModuleCallOutput) uniqueKeySigil() {} + // ModuleCallInstanceOutput is the address of a particular named output produced by // an instance of a module call. type ModuleCallInstanceOutput struct { @@ -139,6 +161,12 @@ func (co ModuleCallInstanceOutput) String() string { return fmt.Sprintf("%s.%s", co.Call.String(), co.Name) } +func (co ModuleCallInstanceOutput) UniqueKey() UniqueKey { + return co // A ModuleCallInstanceOutput is its own UniqueKey +} + +func (co ModuleCallInstanceOutput) uniqueKeySigil() {} + // AbsOutputValue returns the absolute output value address that corresponds // to the receving module call output address, once resolved in the given // calling module. diff --git a/internal/addrs/module_instance.go b/internal/addrs/module_instance.go index dfaacc418..21afcb077 100644 --- a/internal/addrs/module_instance.go +++ b/internal/addrs/module_instance.go @@ -275,6 +275,14 @@ func (m ModuleInstance) String() string { return buf.String() } +type moduleInstanceKey string + +func (m ModuleInstance) UniqueKey() UniqueKey { + return moduleInstanceKey(m.String()) +} + +func (mk moduleInstanceKey) uniqueKeySigil() {} + // Equal returns true if the receiver and the given other value // contains the exact same parts. func (m ModuleInstance) Equal(o ModuleInstance) bool { @@ -496,6 +504,28 @@ func (m ModuleInstance) absMoveableSigil() { // ModuleInstance is moveable } +// IsDeclaredByCall returns true if the receiver is an instance of the given +// AbsModuleCall. +func (m ModuleInstance) IsDeclaredByCall(other AbsModuleCall) bool { + // Compare len(m) to len(other.Module+1) because the final module instance + // step in other is stored in the AbsModuleCall.Call + if len(m) > len(other.Module)+1 || len(m) == 0 && len(other.Module) == 0 { + return false + } + + // Verify that the other's ModuleInstance matches the receiver. + inst, lastStep := other.Module, other.Call + for i := range inst { + if inst[i] != m[i] { + return false + } + } + + // Now compare the final step of the received with the other Call, where + // only the name needs to match. + return lastStep.Name == m[len(m)-1].Name +} + func (s ModuleInstanceStep) String() string { if s.InstanceKey != NoKey { return s.Name + s.InstanceKey.String() diff --git a/internal/addrs/module_instance_test.go b/internal/addrs/module_instance_test.go index 2334f28ff..4ad096cfc 100644 --- a/internal/addrs/module_instance_test.go +++ b/internal/addrs/module_instance_test.go @@ -91,3 +91,80 @@ func BenchmarkStringLong(b *testing.B) { addr.String() } } + +func TestModuleInstance_IsDeclaredByCall(t *testing.T) { + tests := []struct { + instance ModuleInstance + call AbsModuleCall + want bool + }{ + { + ModuleInstance{}, + AbsModuleCall{}, + false, + }, + { + mustParseModuleInstanceStr("module.child"), + AbsModuleCall{}, + false, + }, + { + ModuleInstance{}, + AbsModuleCall{ + RootModuleInstance, + ModuleCall{Name: "child"}, + }, + false, + }, + { + mustParseModuleInstanceStr("module.child"), + AbsModuleCall{ // module.child + RootModuleInstance, + ModuleCall{Name: "child"}, + }, + true, + }, + { + mustParseModuleInstanceStr(`module.child`), + AbsModuleCall{ // module.kinder.module.child + mustParseModuleInstanceStr("module.kinder"), + ModuleCall{Name: "child"}, + }, + false, + }, + { + mustParseModuleInstanceStr("module.kinder"), + // module.kinder.module.child contains module.kinder, but is not itself an instance of module.kinder + AbsModuleCall{ + mustParseModuleInstanceStr("module.kinder"), + ModuleCall{Name: "child"}, + }, + false, + }, + { + mustParseModuleInstanceStr("module.child"), + AbsModuleCall{ + mustParseModuleInstanceStr(`module.kinder["a"]`), + ModuleCall{Name: "kinder"}, + }, + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%q.IsCallInstance(%q)", test.instance, test.call.String()), func(t *testing.T) { + got := test.instance.IsDeclaredByCall(test.call) + if got != test.want { + t.Fatal("wrong result") + } + }) + } +} + +func mustParseModuleInstanceStr(str string) ModuleInstance { + mi, err := ParseModuleInstanceStr(str) + if err != nil { + panic(err) + } + return mi +} diff --git a/internal/addrs/move_endpoint.go b/internal/addrs/move_endpoint.go index b7c529dd5..2b44c5cd1 100644 --- a/internal/addrs/move_endpoint.go +++ b/internal/addrs/move_endpoint.go @@ -39,6 +39,10 @@ type MoveEndpoint struct { relSubject AbsMoveable } +func (e *MoveEndpoint) ObjectKind() MoveEndpointKind { + return absMoveableEndpointKind(e.relSubject) +} + func (e *MoveEndpoint) String() string { // Our internal pseudo-AbsMovable representing the relative // address (either ModuleInstance or AbsResourceInstance) is @@ -73,7 +77,7 @@ func (e *MoveEndpoint) MightUnifyWith(other *MoveEndpoint) bool { // address, because the rules for whether unify can succeed depend // only on the relative part of the addresses, not on which module // they were declared in. - from, to := UnifyMoveEndpoints(RootModuleInstance, e, other) + from, to := UnifyMoveEndpoints(RootModule, e, other) return from != nil && to != nil } @@ -147,11 +151,10 @@ func ParseMoveEndpoint(traversal hcl.Traversal) (*MoveEndpoint, tfdiags.Diagnost // UnifyMoveEndpoints takes a pair of MoveEndpoint objects representing the // "from" and "to" addresses in a moved block, and returns a pair of -// AbsMoveable addresses guaranteed to be of the same dynamic type +// MoveEndpointInModule addresses guaranteed to be of the same dynamic type // that represent what the two MoveEndpoint addresses refer to. // -// moduleAddr must be the address of the module instance where the move -// was declared. +// moduleAddr must be the address of the module where the move was declared. // // This function deals both with the conversion from relative to absolute // addresses and with resolving the ambiguity between no-key instance @@ -163,7 +166,7 @@ func ParseMoveEndpoint(traversal hcl.Traversal) (*MoveEndpoint, tfdiags.Diagnost // given addresses are incompatible then UnifyMoveEndpoints returns (nil, nil), // in which case the caller should typically report an error to the user // stating the unification constraints. -func UnifyMoveEndpoints(moduleAddr ModuleInstance, relFrom, relTo *MoveEndpoint) (absFrom, absTo AbsMoveable) { +func UnifyMoveEndpoints(moduleAddr Module, relFrom, relTo *MoveEndpoint) (modFrom, modTo *MoveEndpointInModule) { // First we'll make a decision about which address type we're // ultimately trying to unify to. For our internal purposes @@ -197,17 +200,17 @@ func UnifyMoveEndpoints(moduleAddr ModuleInstance, relFrom, relTo *MoveEndpoint) panic("unhandled move address types") } - absFrom = relFrom.prepareAbsMoveable(moduleAddr, wantType) - absTo = relTo.prepareAbsMoveable(moduleAddr, wantType) - if absFrom == nil || absTo == nil { + modFrom = relFrom.prepareMoveEndpointInModule(moduleAddr, wantType) + modTo = relTo.prepareMoveEndpointInModule(moduleAddr, wantType) + if modFrom == nil || modTo == nil { // if either of them failed then they both failed, to make the // caller's life a little easier. return nil, nil } - return absFrom, absTo + return modFrom, modTo } -func (e *MoveEndpoint) prepareAbsMoveable(moduleAddr ModuleInstance, wantType TargetableAddrType) AbsMoveable { +func (e *MoveEndpoint) prepareMoveEndpointInModule(moduleAddr Module, wantType TargetableAddrType) *MoveEndpointInModule { // relAddr can only be either AbsResourceInstance or ModuleInstance, the // internal intermediate representation produced by ParseMoveEndpoint. relAddr := e.relSubject @@ -216,40 +219,43 @@ func (e *MoveEndpoint) prepareAbsMoveable(moduleAddr ModuleInstance, wantType Ta case ModuleInstance: switch wantType { case ModuleInstanceAddrType: - ret := make(ModuleInstance, 0, len(moduleAddr)+len(relAddr)) - ret = append(ret, moduleAddr...) - ret = append(ret, relAddr...) - return ret + // Since our internal representation is already a module instance, + // we can just rewrap this one. + return &MoveEndpointInModule{ + SourceRange: e.SourceRange, + module: moduleAddr, + relSubject: relAddr, + } case ModuleAddrType: // NOTE: We're fudging a little here and using // ModuleAddrType to represent AbsModuleCall rather // than Module. - callerAddr := make(ModuleInstance, 0, len(moduleAddr)+len(relAddr)-1) - callerAddr = append(callerAddr, moduleAddr...) - callerAddr = append(callerAddr, relAddr[:len(relAddr)-1]...) - return AbsModuleCall{ + callerAddr, callAddr := relAddr.Call() + absCallAddr := AbsModuleCall{ Module: callerAddr, - Call: ModuleCall{ - Name: relAddr[len(relAddr)-1].Name, - }, + Call: callAddr, + } + return &MoveEndpointInModule{ + SourceRange: e.SourceRange, + module: moduleAddr, + relSubject: absCallAddr, } default: return nil // can't make any other types from a ModuleInstance } case AbsResourceInstance: - callerAddr := make(ModuleInstance, 0, len(moduleAddr)+len(relAddr.Module)) - callerAddr = append(callerAddr, moduleAddr...) - callerAddr = append(callerAddr, relAddr.Module...) switch wantType { case AbsResourceInstanceAddrType: - return AbsResourceInstance{ - Module: callerAddr, - Resource: relAddr.Resource, + return &MoveEndpointInModule{ + SourceRange: e.SourceRange, + module: moduleAddr, + relSubject: relAddr, } case AbsResourceAddrType: - return AbsResource{ - Module: callerAddr, - Resource: relAddr.Resource.Resource, + return &MoveEndpointInModule{ + SourceRange: e.SourceRange, + module: moduleAddr, + relSubject: relAddr.ContainingResource(), } default: return nil // can't make any other types from an AbsResourceInstance diff --git a/internal/addrs/move_endpoint_kind.go b/internal/addrs/move_endpoint_kind.go new file mode 100644 index 000000000..cd8adab8f --- /dev/null +++ b/internal/addrs/move_endpoint_kind.go @@ -0,0 +1,33 @@ +package addrs + +import "fmt" + +// MoveEndpointKind represents the different kinds of object that a movable +// address can refer to. +type MoveEndpointKind rune + +//go:generate go run golang.org/x/tools/cmd/stringer -type MoveEndpointKind + +const ( + // MoveEndpointModule indicates that a move endpoint either refers to + // an individual module instance or to all instances of a particular + // module call. + MoveEndpointModule MoveEndpointKind = 'M' + + // MoveEndpointResource indicates that a move endpoint either refers to + // an individual resource instance or to all instances of a particular + // resource. + MoveEndpointResource MoveEndpointKind = 'R' +) + +func absMoveableEndpointKind(addr AbsMoveable) MoveEndpointKind { + switch addr := addr.(type) { + case ModuleInstance, AbsModuleCall: + return MoveEndpointModule + case AbsResourceInstance, AbsResource: + return MoveEndpointResource + default: + // The above should be exhaustive for all AbsMoveable types. + panic(fmt.Sprintf("unsupported address type %T", addr)) + } +} diff --git a/internal/addrs/move_endpoint_module.go b/internal/addrs/move_endpoint_module.go new file mode 100644 index 000000000..0b6461bd9 --- /dev/null +++ b/internal/addrs/move_endpoint_module.go @@ -0,0 +1,191 @@ +package addrs + +import ( + "fmt" + "strings" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// MoveEndpointInModule annotates a MoveEndpoint with the address of the +// module where it was declared, which is the form we use for resolving +// whether move statements chain from or are nested within other move +// statements. +type MoveEndpointInModule struct { + // SourceRange is the location of the physical endpoint address + // in configuration, if this MoveEndpoint was decoded from a + // configuration expresson. + SourceRange tfdiags.SourceRange + + // The internals are unexported here because, as with MoveEndpoint, + // we're somewhat abusing AbsMoveable here to represent an address + // relative to the module, rather than as an absolute address. + // Conceptually, the following two fields represent a matching pattern + // for AbsMoveables where the elements of "module" behave as + // ModuleInstanceStep values with a wildcard instance key, because + // a moved block in a module affects all instances of that module. + // Unlike MoveEndpoint, relSubject in this case can be any of the + // address types that implement AbsMoveable. + module Module + relSubject AbsMoveable +} + +func (e *MoveEndpointInModule) ObjectKind() MoveEndpointKind { + return absMoveableEndpointKind(e.relSubject) +} + +// String produces a string representation of the object matching pattern +// represented by the reciever. +// +// Since there is no direct syntax for representing such an object matching +// pattern, this function uses a splat-operator-like representation to stand +// in for the wildcard instance keys. +func (e *MoveEndpointInModule) String() string { + if e == nil { + return "" + } + var buf strings.Builder + for _, name := range e.module { + buf.WriteString("module.") + buf.WriteString(name) + buf.WriteString("[*].") + } + buf.WriteString(e.relSubject.String()) + + // For consistency we'll also use the splat-like wildcard syntax to + // represent the final step being either a resource or module call + // rather than an instance, so we can more easily distinguish the two + // in the string representation. + switch e.relSubject.(type) { + case AbsModuleCall, AbsResource: + buf.WriteString("[*]") + } + + return buf.String() +} + +// SelectsModule returns true if the reciever directly selects either +// the given module or a resource nested directly inside that module. +// +// This is a good function to use to decide which modules in a state +// to consider when processing a particular move statement. For a +// module move the given module itself is what will move, while a +// resource move indicates that we should search each of the resources in +// the given module to see if they match. +func (e *MoveEndpointInModule) SelectsModule(addr ModuleInstance) bool { + // In order to match the given module path should be at least as + // long as the path to the module where the move endpoint was defined. + if len(addr) < len(e.module) { + return false + } + + containerPart := addr[:len(e.module)] + relPart := addr[len(e.module):] + + // The names of all of the steps that align with e.module must match, + // though the instance keys are wildcards for this part. + for i := range e.module { + if containerPart[i].Name != e.module[i] { + return false + } + } + + // The remaining module address steps must match both name and key. + // The logic for all of these is similar but we will retrieve the + // module address differently for each type. + var relMatch ModuleInstance + switch relAddr := e.relSubject.(type) { + case ModuleInstance: + relMatch = relAddr + case AbsModuleCall: + // This one requires a little more fuss because the call effectively + // slices in two the final step of the module address. + if len(relPart) != len(relAddr.Module)+1 { + return false + } + callPart := relPart[len(relPart)-1] + if callPart.Name != relAddr.Call.Name { + return false + } + case AbsResource: + relMatch = relAddr.Module + case AbsResourceInstance: + relMatch = relAddr.Module + default: + panic(fmt.Sprintf("unhandled relative address type %T", relAddr)) + } + + if len(relPart) != len(relMatch) { + return false + } + for i := range relMatch { + if relPart[i] != relMatch[i] { + return false + } + } + return true +} + +// CanChainFrom returns true if the reciever describes an address that could +// potentially select an object that the other given address could select. +// +// In other words, this decides whether the move chaining rule applies, if +// the reciever is the "to" from one statement and the other given address +// is the "from" of another statement. +func (e *MoveEndpointInModule) CanChainFrom(other *MoveEndpointInModule) bool { + // TODO: implement + return false +} + +// NestedWithin returns true if the reciever describes an address that is +// contained within one of the objects that the given other address could +// select. +func (e *MoveEndpointInModule) NestedWithin(other *MoveEndpointInModule) bool { + // TODO: implement + return false +} + +// MoveDestination considers a an address representing a module +// instance in the context of source and destination move endpoints and then, +// if the module address matches the from endpoint, returns the corresponding +// new module address that the object should move to. +// +// MoveDestination will return false in its second return value if the receiver +// doesn't match fromMatch, indicating that the given move statement doesn't +// apply to this object. +// +// Both of the given endpoints must be from the same move statement and thus +// must have matching object types. If not, MoveDestination will panic. +func (m ModuleInstance) MoveDestination(fromMatch, toMatch *MoveEndpointInModule) (ModuleInstance, bool) { + return nil, false +} + +// MoveDestination considers a an address representing a resource +// in the context of source and destination move endpoints and then, +// if the resource address matches the from endpoint, returns the corresponding +// new resource address that the object should move to. +// +// MoveDestination will return false in its second return value if the receiver +// doesn't match fromMatch, indicating that the given move statement doesn't +// apply to this object. +// +// Both of the given endpoints must be from the same move statement and thus +// must have matching object types. If not, MoveDestination will panic. +func (r AbsResource) MoveDestination(fromMatch, toMatch *MoveEndpointInModule) (AbsResource, bool) { + return AbsResource{}, false +} + +// MoveDestination considers a an address representing a resource +// instance in the context of source and destination move endpoints and then, +// if the instance address matches the from endpoint, returns the corresponding +// new instance address that the object should move to. +// +// MoveDestination will return false in its second return value if the receiver +// doesn't match fromMatch, indicating that the given move statement doesn't +// apply to this object. +// +// Both of the given endpoints must be from the same move statement and thus +// must have matching object types. If not, MoveDestination will panic. +func (r AbsResourceInstance) MoveDestination(fromMatch, toMatch *MoveEndpointInModule) (AbsResourceInstance, bool) { + return AbsResourceInstance{}, false +} diff --git a/internal/addrs/move_endpoint_test.go b/internal/addrs/move_endpoint_test.go index a54b79c85..6e117139b 100644 --- a/internal/addrs/move_endpoint_test.go +++ b/internal/addrs/move_endpoint_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" ) @@ -339,245 +338,127 @@ func TestParseMoveEndpoint(t *testing.T) { func TestUnifyMoveEndpoints(t *testing.T) { tests := []struct { InputFrom, InputTo string - Module ModuleInstance - WantFrom, WantTo AbsMoveable + Module Module + WantFrom, WantTo string }{ { InputFrom: `foo.bar`, InputTo: `foo.baz`, - Module: RootModuleInstance, - WantFrom: AbsResource{ - Module: RootModuleInstance, - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "bar", - }, - }, - WantTo: AbsResource{ - Module: RootModuleInstance, - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "baz", - }, - }, + Module: RootModule, + WantFrom: `foo.bar[*]`, + WantTo: `foo.baz[*]`, }, { InputFrom: `foo.bar`, InputTo: `foo.baz`, - Module: RootModuleInstance.Child("a", NoKey), - WantFrom: AbsResource{ - Module: RootModuleInstance.Child("a", NoKey), - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "bar", - }, - }, - WantTo: AbsResource{ - Module: RootModuleInstance.Child("a", NoKey), - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "baz", - }, - }, + Module: RootModule.Child("a"), + WantFrom: `module.a[*].foo.bar[*]`, + WantTo: `module.a[*].foo.baz[*]`, }, { InputFrom: `foo.bar`, InputTo: `module.b[0].foo.baz`, - Module: RootModuleInstance.Child("a", NoKey), - WantFrom: AbsResource{ - Module: RootModuleInstance.Child("a", NoKey), - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "bar", - }, - }, - WantTo: AbsResource{ - Module: RootModuleInstance.Child("a", NoKey).Child("b", IntKey(0)), - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "baz", - }, - }, + Module: RootModule.Child("a"), + WantFrom: `module.a[*].foo.bar[*]`, + WantTo: `module.a[*].module.b[0].foo.baz[*]`, }, { InputFrom: `foo.bar`, InputTo: `foo.bar["thing"]`, - Module: RootModuleInstance, - WantFrom: AbsResourceInstance{ - Module: RootModuleInstance, - Resource: ResourceInstance{ - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "bar", - }, - }, - }, - WantTo: AbsResourceInstance{ - Module: RootModuleInstance, - Resource: ResourceInstance{ - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "bar", - }, - Key: StringKey("thing"), - }, - }, + Module: RootModule, + WantFrom: `foo.bar`, + WantTo: `foo.bar["thing"]`, }, { InputFrom: `foo.bar["thing"]`, InputTo: `foo.bar`, - Module: RootModuleInstance, - WantFrom: AbsResourceInstance{ - Module: RootModuleInstance, - Resource: ResourceInstance{ - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "bar", - }, - Key: StringKey("thing"), - }, - }, - WantTo: AbsResourceInstance{ - Module: RootModuleInstance, - Resource: ResourceInstance{ - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "bar", - }, - }, - }, + Module: RootModule, + WantFrom: `foo.bar["thing"]`, + WantTo: `foo.bar`, }, { InputFrom: `foo.bar["a"]`, InputTo: `foo.bar["b"]`, - Module: RootModuleInstance, - WantFrom: AbsResourceInstance{ - Module: RootModuleInstance, - Resource: ResourceInstance{ - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "bar", - }, - Key: StringKey("a"), - }, - }, - WantTo: AbsResourceInstance{ - Module: RootModuleInstance, - Resource: ResourceInstance{ - Resource: Resource{ - Mode: ManagedResourceMode, - Type: "foo", - Name: "bar", - }, - Key: StringKey("b"), - }, - }, + Module: RootModule, + WantFrom: `foo.bar["a"]`, + WantTo: `foo.bar["b"]`, }, { InputFrom: `module.foo`, InputTo: `module.bar`, - Module: RootModuleInstance, - WantFrom: AbsModuleCall{ - Module: RootModuleInstance, - Call: ModuleCall{Name: "foo"}, - }, - WantTo: AbsModuleCall{ - Module: RootModuleInstance, - Call: ModuleCall{Name: "bar"}, - }, + Module: RootModule, + WantFrom: `module.foo[*]`, + WantTo: `module.bar[*]`, }, { InputFrom: `module.foo`, InputTo: `module.bar.module.baz`, - Module: RootModuleInstance, - WantFrom: AbsModuleCall{ - Module: RootModuleInstance, - Call: ModuleCall{Name: "foo"}, - }, - WantTo: AbsModuleCall{ - Module: RootModuleInstance.Child("bar", NoKey), - Call: ModuleCall{Name: "baz"}, - }, + Module: RootModule, + WantFrom: `module.foo[*]`, + WantTo: `module.bar.module.baz[*]`, }, { InputFrom: `module.foo`, InputTo: `module.bar.module.baz`, - Module: RootModuleInstance.Child("bloop", StringKey("hi")), - WantFrom: AbsModuleCall{ - Module: RootModuleInstance.Child("bloop", StringKey("hi")), - Call: ModuleCall{Name: "foo"}, - }, - WantTo: AbsModuleCall{ - Module: RootModuleInstance.Child("bloop", StringKey("hi")).Child("bar", NoKey), - Call: ModuleCall{Name: "baz"}, - }, + Module: RootModule.Child("bloop"), + WantFrom: `module.bloop[*].module.foo[*]`, + WantTo: `module.bloop[*].module.bar.module.baz[*]`, }, { InputFrom: `module.foo[0]`, InputTo: `module.foo["a"]`, - Module: RootModuleInstance, - WantFrom: RootModuleInstance.Child("foo", IntKey(0)), - WantTo: RootModuleInstance.Child("foo", StringKey("a")), + Module: RootModule, + WantFrom: `module.foo[0]`, + WantTo: `module.foo["a"]`, }, { InputFrom: `module.foo`, InputTo: `module.foo["a"]`, - Module: RootModuleInstance, - WantFrom: RootModuleInstance.Child("foo", NoKey), - WantTo: RootModuleInstance.Child("foo", StringKey("a")), + Module: RootModule, + WantFrom: `module.foo`, + WantTo: `module.foo["a"]`, }, { InputFrom: `module.foo[0]`, InputTo: `module.foo`, - Module: RootModuleInstance, - WantFrom: RootModuleInstance.Child("foo", IntKey(0)), - WantTo: RootModuleInstance.Child("foo", NoKey), + Module: RootModule, + WantFrom: `module.foo[0]`, + WantTo: `module.foo`, }, { InputFrom: `module.foo[0]`, InputTo: `module.foo`, - Module: RootModuleInstance.Child("bloop", NoKey), - WantFrom: RootModuleInstance.Child("bloop", NoKey).Child("foo", IntKey(0)), - WantTo: RootModuleInstance.Child("bloop", NoKey).Child("foo", NoKey), + Module: RootModule.Child("bloop"), + WantFrom: `module.bloop[*].module.foo[0]`, + WantTo: `module.bloop[*].module.foo`, }, { InputFrom: `module.foo`, InputTo: `foo.bar`, - Module: RootModuleInstance, - WantFrom: nil, // Can't unify module call with resource - WantTo: nil, + Module: RootModule, + WantFrom: ``, // Can't unify module call with resource + WantTo: ``, }, { InputFrom: `module.foo[0]`, InputTo: `foo.bar`, - Module: RootModuleInstance, - WantFrom: nil, // Can't unify module instance with resource - WantTo: nil, + Module: RootModule, + WantFrom: ``, // Can't unify module instance with resource + WantTo: ``, }, { InputFrom: `module.foo`, InputTo: `foo.bar[0]`, - Module: RootModuleInstance, - WantFrom: nil, // Can't unify module call with resource instance - WantTo: nil, + Module: RootModule, + WantFrom: ``, // Can't unify module call with resource instance + WantTo: ``, }, { InputFrom: `module.foo[0]`, InputTo: `foo.bar[0]`, - Module: RootModuleInstance, - WantFrom: nil, // Can't unify module instance with resource instance - WantTo: nil, + Module: RootModule, + WantFrom: ``, // Can't unify module instance with resource instance + WantTo: ``, }, } @@ -604,13 +485,12 @@ func TestUnifyMoveEndpoints(t *testing.T) { fromEp := parseInput(test.InputFrom) toEp := parseInput(test.InputTo) - diffOpts := cmpopts.IgnoreUnexported(ModuleCall{}) gotFrom, gotTo := UnifyMoveEndpoints(test.Module, fromEp, toEp) - if diff := cmp.Diff(test.WantFrom, gotFrom, diffOpts); diff != "" { - t.Errorf("wrong 'from' address\n%s", diff) + if got, want := gotFrom.String(), test.WantFrom; got != want { + t.Errorf("wrong 'from' result\ngot: %s\nwant: %s", got, want) } - if diff := cmp.Diff(test.WantTo, gotTo, diffOpts); diff != "" { - t.Errorf("wrong 'to' address\n%s", diff) + if got, want := gotTo.String(), test.WantTo; got != want { + t.Errorf("wrong 'to' result\ngot: %s\nwant: %s", got, want) } }) } diff --git a/internal/addrs/moveable.go b/internal/addrs/moveable.go index cac6eceb8..fd93b58dc 100644 --- a/internal/addrs/moveable.go +++ b/internal/addrs/moveable.go @@ -9,7 +9,6 @@ package addrs // of the configuration, which is different than the direct representation // of these in configuration where the author gives an address relative to // the current module where the address is defined. The type MoveEndpoint - type AbsMoveable interface { absMoveableSigil() diff --git a/internal/addrs/moveendpointkind_string.go b/internal/addrs/moveendpointkind_string.go new file mode 100644 index 000000000..f706fb9ca --- /dev/null +++ b/internal/addrs/moveendpointkind_string.go @@ -0,0 +1,29 @@ +// Code generated by "stringer -type MoveEndpointKind"; DO NOT EDIT. + +package addrs + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[MoveEndpointModule-77] + _ = x[MoveEndpointResource-82] +} + +const ( + _MoveEndpointKind_name_0 = "MoveEndpointModule" + _MoveEndpointKind_name_1 = "MoveEndpointResource" +) + +func (i MoveEndpointKind) String() string { + switch { + case i == 77: + return _MoveEndpointKind_name_0 + case i == 82: + return _MoveEndpointKind_name_1 + default: + return "MoveEndpointKind(" + strconv.FormatInt(int64(i), 10) + ")" + } +} diff --git a/internal/addrs/path_attr.go b/internal/addrs/path_attr.go index cfc13f4bc..9de5e134d 100644 --- a/internal/addrs/path_attr.go +++ b/internal/addrs/path_attr.go @@ -10,3 +10,9 @@ type PathAttr struct { func (pa PathAttr) String() string { return "path." + pa.Name } + +func (pa PathAttr) UniqueKey() UniqueKey { + return pa // A PathAttr is its own UniqueKey +} + +func (pa PathAttr) uniqueKeySigil() {} diff --git a/internal/addrs/referenceable.go b/internal/addrs/referenceable.go index 211083a5f..fbbc753d4 100644 --- a/internal/addrs/referenceable.go +++ b/internal/addrs/referenceable.go @@ -7,6 +7,9 @@ type Referenceable interface { // in lang.Scope.buildEvalContext. referenceableSigil() + // All Referenceable address types must have unique keys. + UniqueKeyer + // String produces a string representation of the address that could be // parsed as a HCL traversal and passed to ParseRef to produce an identical // result. diff --git a/internal/addrs/resource.go b/internal/addrs/resource.go index 97b7f5dd9..a7b7ee308 100644 --- a/internal/addrs/resource.go +++ b/internal/addrs/resource.go @@ -32,6 +32,12 @@ func (r Resource) Equal(o Resource) bool { return r.Mode == o.Mode && r.Name == o.Name && r.Type == o.Type } +func (r Resource) UniqueKey() UniqueKey { + return r // A Resource is its own UniqueKey +} + +func (r Resource) uniqueKeySigil() {} + // Instance produces the address for a specific instance of the receiver // that is idenfied by the given key. func (r Resource) Instance(key InstanceKey) ResourceInstance { @@ -94,6 +100,12 @@ func (r ResourceInstance) Equal(o ResourceInstance) bool { return r.Key == o.Key && r.Resource.Equal(o.Resource) } +func (r ResourceInstance) UniqueKey() UniqueKey { + return r // A ResourceInstance is its own UniqueKey +} + +func (r ResourceInstance) uniqueKeySigil() {} + // Absolute returns an AbsResourceInstance from the receiver and the given module // instance address. func (r ResourceInstance) Absolute(module ModuleInstance) AbsResourceInstance { @@ -280,6 +292,14 @@ func (r AbsResourceInstance) Less(o AbsResourceInstance) bool { } } +type absResourceInstanceKey string + +func (r AbsResourceInstance) UniqueKey() UniqueKey { + return absResourceInstanceKey(r.String()) +} + +func (r absResourceInstanceKey) uniqueKeySigil() {} + func (r AbsResourceInstance) absMoveableSigil() { // AbsResourceInstance is moveable } diff --git a/internal/addrs/resource_phase.go b/internal/addrs/resource_phase.go index 9bdbdc421..c62a7fc83 100644 --- a/internal/addrs/resource_phase.go +++ b/internal/addrs/resource_phase.go @@ -44,6 +44,12 @@ func (rp ResourceInstancePhase) String() string { return fmt.Sprintf("%s#%s", rp.ResourceInstance, rp.Phase) } +func (rp ResourceInstancePhase) UniqueKey() UniqueKey { + return rp // A ResourceInstancePhase is its own UniqueKey +} + +func (rp ResourceInstancePhase) uniqueKeySigil() {} + // ResourceInstancePhaseType is an enumeration used with ResourceInstancePhase. type ResourceInstancePhaseType string @@ -103,3 +109,9 @@ func (rp ResourcePhase) String() string { // because this special address type should never be exposed in the UI. return fmt.Sprintf("%s#%s", rp.Resource, rp.Phase) } + +func (rp ResourcePhase) UniqueKey() UniqueKey { + return rp // A ResourcePhase is its own UniqueKey +} + +func (rp ResourcePhase) uniqueKeySigil() {} diff --git a/internal/addrs/self.go b/internal/addrs/self.go index 7f24eaf08..64c8f6ecf 100644 --- a/internal/addrs/self.go +++ b/internal/addrs/self.go @@ -12,3 +12,9 @@ func (s selfT) referenceableSigil() { func (s selfT) String() string { return "self" } + +func (s selfT) UniqueKey() UniqueKey { + return Self // Self is its own UniqueKey +} + +func (s selfT) uniqueKeySigil() {} diff --git a/internal/addrs/set.go b/internal/addrs/set.go new file mode 100644 index 000000000..ef82c5915 --- /dev/null +++ b/internal/addrs/set.go @@ -0,0 +1,43 @@ +package addrs + +// Set represents a set of addresses of types that implement UniqueKeyer. +type Set map[UniqueKey]UniqueKeyer + +func (s Set) Has(addr UniqueKeyer) bool { + _, exists := s[addr.UniqueKey()] + return exists +} + +func (s Set) Add(addr UniqueKeyer) { + s[addr.UniqueKey()] = addr +} + +func (s Set) Remove(addr UniqueKeyer) { + delete(s, addr.UniqueKey()) +} + +func (s Set) Union(other Set) Set { + ret := make(Set) + for k, addr := range s { + ret[k] = addr + } + for k, addr := range other { + ret[k] = addr + } + return ret +} + +func (s Set) Intersection(other Set) Set { + ret := make(Set) + for k, addr := range s { + if _, exists := other[k]; exists { + ret[k] = addr + } + } + for k, addr := range other { + if _, exists := s[k]; exists { + ret[k] = addr + } + } + return ret +} diff --git a/internal/addrs/terraform_attr.go b/internal/addrs/terraform_attr.go index a880182ae..d3d11677c 100644 --- a/internal/addrs/terraform_attr.go +++ b/internal/addrs/terraform_attr.go @@ -10,3 +10,9 @@ type TerraformAttr struct { func (ta TerraformAttr) String() string { return "terraform." + ta.Name } + +func (ta TerraformAttr) UniqueKey() UniqueKey { + return ta // A TerraformAttr is its own UniqueKey +} + +func (ta TerraformAttr) uniqueKeySigil() {} diff --git a/internal/addrs/unique_key.go b/internal/addrs/unique_key.go new file mode 100644 index 000000000..c3321a298 --- /dev/null +++ b/internal/addrs/unique_key.go @@ -0,0 +1,23 @@ +package addrs + +// UniqueKey is an interface implemented by values that serve as unique map +// keys for particular addresses. +// +// All implementations of UniqueKey are comparable and can thus be used as +// map keys. Unique keys generated from different address types are always +// distinct. All functionally-equivalent keys for the same address type +// always compare equal, and likewise functionally-different values do not. +type UniqueKey interface { + uniqueKeySigil() +} + +// UniqueKeyer is an interface implemented by types that can be represented +// by a unique key. +// +// Some address types naturally comply with the expectations of a UniqueKey +// and may thus be their own unique key type. However, address types that +// are not naturally comparable can implement this interface by returning +// proxy values. +type UniqueKeyer interface { + UniqueKey() UniqueKey +} diff --git a/internal/addrs/unique_key_test.go b/internal/addrs/unique_key_test.go new file mode 100644 index 000000000..0926a0c37 --- /dev/null +++ b/internal/addrs/unique_key_test.go @@ -0,0 +1,72 @@ +package addrs + +import ( + "fmt" + "testing" +) + +// TestUniqueKeyer aims to ensure that all of the types that have unique keys +// will continue to meet the UniqueKeyer contract under future changes. +// +// If you add a new implementation of UniqueKey, consider adding a test case +// for it here. +func TestUniqueKeyer(t *testing.T) { + tests := []UniqueKeyer{ + CountAttr{Name: "index"}, + ForEachAttr{Name: "key"}, + TerraformAttr{Name: "workspace"}, + PathAttr{Name: "module"}, + InputVariable{Name: "foo"}, + ModuleCall{Name: "foo"}, + ModuleCallInstance{ + Call: ModuleCall{Name: "foo"}, + Key: StringKey("a"), + }, + ModuleCallOutput{ + Call: ModuleCall{Name: "foo"}, + Name: "bar", + }, + ModuleCallInstanceOutput{ + Call: ModuleCallInstance{ + Call: ModuleCall{Name: "foo"}, + Key: StringKey("a"), + }, + Name: "bar", + }, + Resource{ + Mode: ManagedResourceMode, + Type: "foo", + Name: "bar", + }, + ResourceInstance{ + Resource: Resource{ + Mode: ManagedResourceMode, + Type: "foo", + Name: "bar", + }, + Key: IntKey(1), + }, + RootModuleInstance, + RootModuleInstance.Child("foo", NoKey), + RootModuleInstance.ResourceInstance( + DataResourceMode, + "boop", + "beep", + NoKey, + ), + Self, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%s", test), func(t *testing.T) { + a := test.UniqueKey() + b := test.UniqueKey() + + // The following comparison will panic if the unique key is not + // of a comparable type. + if a != b { + t.Fatalf("the two unique keys are not equal\na: %#v\b: %#v", a, b) + } + }) + } +} diff --git a/internal/command/format/diff.go b/internal/command/format/diff.go index 6421f6ee6..da47b8f7c 100644 --- a/internal/command/format/diff.go +++ b/internal/command/format/diff.go @@ -1814,7 +1814,27 @@ func ctySequenceDiff(old, new []cty.Value) []*plans.Change { lcs := objchange.LongestCommonSubsequence(old, new) var oldI, newI, lcsI int for oldI < len(old) || newI < len(new) || lcsI < len(lcs) { + // We first process items in the old and new sequences which are not + // equal to the current common sequence item. Old items are marked as + // deletions, and new items are marked as additions. + // + // There is an exception for deleted & created object items, which we + // try to render as updates where that makes sense. for oldI < len(old) && (lcsI >= len(lcs) || !old[oldI].RawEquals(lcs[lcsI])) { + // Render this as an object update if all of these are true: + // + // - the current old item is an object; + // - there's a current new item which is also an object; + // - either there are no common items left, or the current new item + // doesn't equal the current common item. + // + // Why do we need the the last clause? If we have current items in all + // three sequences, and the current new item is equal to a common item, + // then we should just need to advance the old item list and we'll + // eventually find a common item matching both old and new. + // + // This combination of conditions allows us to render an object update + // diff instead of a combination of delete old & create new. isObjectDiff := old[oldI].Type().IsObjectType() && newI < len(new) && new[newI].Type().IsObjectType() && (lcsI >= len(lcs) || !new[newI].RawEquals(lcs[lcsI])) if isObjectDiff { ret = append(ret, &plans.Change{ @@ -1827,6 +1847,8 @@ func ctySequenceDiff(old, new []cty.Value) []*plans.Change { continue } + // Otherwise, this item is not part of the common sequence, so + // render as a deletion. ret = append(ret, &plans.Change{ Action: plans.Delete, Before: old[oldI], @@ -1842,6 +1864,9 @@ func ctySequenceDiff(old, new []cty.Value) []*plans.Change { }) newI++ } + + // When we've exhausted the old & new sequences of items which are not + // in the common subsequence, we render a common item and continue. if lcsI < len(lcs) { ret = append(ret, &plans.Change{ Action: plans.NoOp, diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index eb10c7b1e..5d19705d0 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -9,6 +9,7 @@ const ( MessageDiagnostic MessageType = "diagnostic" // Operation results + MessageResourceDrift MessageType = "resource_drift" MessagePlannedChange MessageType = "planned_change" MessageChangeSummary MessageType = "change_summary" MessageOutputs MessageType = "outputs" diff --git a/internal/command/views/json_view.go b/internal/command/views/json_view.go index 8f2b166f7..e1c3db6d7 100644 --- a/internal/command/views/json_view.go +++ b/internal/command/views/json_view.go @@ -95,6 +95,14 @@ func (v *JSONView) PlannedChange(c *json.ResourceInstanceChange) { ) } +func (v *JSONView) ResourceDrift(c *json.ResourceInstanceChange) { + v.log.Info( + fmt.Sprintf("%s: Drift detected (%s)", c.Resource.Addr, c.Action), + "type", json.MessageResourceDrift, + "change", c, + ) +} + func (v *JSONView) ChangeSummary(cs *json.ChangeSummary) { v.log.Info( cs.String(), diff --git a/internal/command/views/json_view_test.go b/internal/command/views/json_view_test.go index 698f2952e..c755cf0b7 100644 --- a/internal/command/views/json_view_test.go +++ b/internal/command/views/json_view_test.go @@ -141,6 +141,46 @@ func TestJSONView_PlannedChange(t *testing.T) { testJSONViewOutputEquals(t, done(t).Stdout(), want) } +func TestJSONView_ResourceDrift(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + jv := NewJSONView(NewView(streams)) + + foo, diags := addrs.ParseModuleInstanceStr("module.foo") + if len(diags) > 0 { + t.Fatal(diags.Err()) + } + managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} + cs := &plans.ResourceInstanceChangeSrc{ + Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), + ChangeSrc: plans.ChangeSrc{ + Action: plans.Update, + }, + } + jv.ResourceDrift(viewsjson.NewResourceInstanceChange(cs)) + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": `module.foo.test_instance.bar["boop"]: Drift detected (update)`, + "@module": "terraform.ui", + "type": "resource_drift", + "change": map[string]interface{}{ + "action": "update", + "resource": map[string]interface{}{ + "addr": `module.foo.test_instance.bar["boop"]`, + "implied_provider": "test", + "module": "module.foo", + "resource": `test_instance.bar["boop"]`, + "resource_key": "boop", + "resource_name": "bar", + "resource_type": "test_instance", + }, + }, + }, + } + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + func TestJSONView_ChangeSummary(t *testing.T) { streams, done := terminal.StreamsForTesting(t) jv := NewJSONView(NewView(streams)) @@ -263,12 +303,16 @@ func testJSONViewOutputEqualsFull(t *testing.T, output string, want []map[string gotLines := strings.Split(output, "\n") if len(gotLines) != len(want) { - t.Fatalf("unexpected number of messages. got %d, want %d", len(gotLines), len(want)) + t.Errorf("unexpected number of messages. got %d, want %d", len(gotLines), len(want)) } // Unmarshal each line and compare to the expected value for i := range gotLines { var gotStruct map[string]interface{} + if i >= len(want) { + t.Error("reached end of want messages too soon") + break + } wantStruct := want[i] if err := json.Unmarshal([]byte(gotLines[i]), &gotStruct); err != nil { @@ -283,12 +327,12 @@ func testJSONViewOutputEqualsFull(t *testing.T, output string, want []map[string // Verify the timestamp format if _, err := time.Parse("2006-01-02T15:04:05.000000Z07:00", timestamp.(string)); err != nil { - t.Fatalf("error parsing timestamp: %s", err) + t.Errorf("error parsing timestamp on line %d: %s", i, err) } } if !cmp.Equal(wantStruct, gotStruct) { - t.Fatalf("unexpected output on line %d:\n%s", i, cmp.Diff(wantStruct, gotStruct)) + t.Errorf("unexpected output on line %d:\n%s", i, cmp.Diff(wantStruct, gotStruct)) } } } diff --git a/internal/command/views/operation.go b/internal/command/views/operation.go index 486455324..c617dd187 100644 --- a/internal/command/views/operation.go +++ b/internal/command/views/operation.go @@ -3,6 +3,7 @@ package views import ( "bytes" "fmt" + "sort" "strings" "github.com/hashicorp/terraform/internal/addrs" @@ -10,9 +11,11 @@ import ( "github.com/hashicorp/terraform/internal/command/format" "github.com/hashicorp/terraform/internal/command/views/json" "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" ) type Operation interface { @@ -160,6 +163,12 @@ func (v *OperationJSON) EmergencyDumpState(stateFile *statefile.File) error { // Log a change summary and a series of "planned" messages for the changes in // the plan. func (v *OperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) { + if err := v.resourceDrift(plan.PrevRunState, plan.PriorState, schemas); err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(err) + v.Diagnostics(diags) + } + cs := &json.ChangeSummary{ Operation: json.OperationPlanned, } @@ -188,6 +197,92 @@ func (v *OperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) { v.view.ChangeSummary(cs) } +func (v *OperationJSON) resourceDrift(oldState, newState *states.State, schemas *terraform.Schemas) error { + if newState.ManagedResourcesEqual(oldState) { + // Nothing to do, because we only detect and report drift for managed + // resource instances. + return nil + } + var changes []*json.ResourceInstanceChange + for _, ms := range oldState.Modules { + for _, rs := range ms.Resources { + if rs.Addr.Resource.Mode != addrs.ManagedResourceMode { + // Drift reporting is only for managed resources + continue + } + + provider := rs.ProviderConfig.Provider + for key, oldIS := range rs.Instances { + if oldIS.Current == nil { + // Not interested in instances that only have deposed objects + continue + } + addr := rs.Addr.Instance(key) + newIS := newState.ResourceInstance(addr) + + schema, _ := schemas.ResourceTypeConfig( + provider, + addr.Resource.Resource.Mode, + addr.Resource.Resource.Type, + ) + if schema == nil { + return fmt.Errorf("no schema found for %s (in provider %s)", addr, provider) + } + ty := schema.ImpliedType() + + oldObj, err := oldIS.Current.Decode(ty) + if err != nil { + return fmt.Errorf("failed to decode previous run data for %s: %s", addr, err) + } + + var newObj *states.ResourceInstanceObject + if newIS != nil && newIS.Current != nil { + newObj, err = newIS.Current.Decode(ty) + if err != nil { + return fmt.Errorf("failed to decode refreshed data for %s: %s", addr, err) + } + } + + var oldVal, newVal cty.Value + oldVal = oldObj.Value + if newObj != nil { + newVal = newObj.Value + } else { + newVal = cty.NullVal(ty) + } + + if oldVal.RawEquals(newVal) { + // No drift if the two values are semantically equivalent + continue + } + + // We can only detect updates and deletes as drift. + action := plans.Update + if newVal.IsNull() { + action = plans.Delete + } + + change := &plans.ResourceInstanceChangeSrc{ + Addr: addr, + ChangeSrc: plans.ChangeSrc{ + Action: action, + }, + } + changes = append(changes, json.NewResourceInstanceChange(change)) + } + } + } + + // Sort the change structs lexically by address to give stable output + sort.Slice(changes, func(i, j int) bool { return changes[i].Resource.Addr < changes[j].Resource.Addr }) + + for _, change := range changes { + v.view.ResourceDrift(change) + } + + return nil +} + func (v *OperationJSON) PlannedChange(change *plans.ResourceInstanceChangeSrc) { if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode { // Avoid rendering data sources on deletion diff --git a/internal/command/views/operation_test.go b/internal/command/views/operation_test.go index a3b3392ea..e642dbfab 100644 --- a/internal/command/views/operation_test.go +++ b/internal/command/views/operation_test.go @@ -483,8 +483,8 @@ func TestOperationJSON_plan(t *testing.T) { if len(diags) > 0 { t.Fatal(diags.Err()) } - boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "boop"} - beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "beep"} + boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "boop"} + beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "beep"} derp := addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_source", Name: "derp"} plan := &plans.Plan{ @@ -518,101 +518,101 @@ func TestOperationJSON_plan(t *testing.T) { }, }, } - v.Plan(plan, nil) + v.Plan(plan, testSchemas()) want := []map[string]interface{}{ // Create-then-delete should result in replace { "@level": "info", - "@message": "test_instance.boop[0]: Plan to replace", + "@message": "test_resource.boop[0]: Plan to replace", "@module": "terraform.ui", "type": "planned_change", "change": map[string]interface{}{ "action": "replace", "resource": map[string]interface{}{ - "addr": `test_instance.boop[0]`, + "addr": `test_resource.boop[0]`, "implied_provider": "test", "module": "", - "resource": `test_instance.boop[0]`, + "resource": `test_resource.boop[0]`, "resource_key": float64(0), "resource_name": "boop", - "resource_type": "test_instance", + "resource_type": "test_resource", }, }, }, // Simple create { "@level": "info", - "@message": "test_instance.boop[1]: Plan to create", + "@message": "test_resource.boop[1]: Plan to create", "@module": "terraform.ui", "type": "planned_change", "change": map[string]interface{}{ "action": "create", "resource": map[string]interface{}{ - "addr": `test_instance.boop[1]`, + "addr": `test_resource.boop[1]`, "implied_provider": "test", "module": "", - "resource": `test_instance.boop[1]`, + "resource": `test_resource.boop[1]`, "resource_key": float64(1), "resource_name": "boop", - "resource_type": "test_instance", + "resource_type": "test_resource", }, }, }, // Simple delete { "@level": "info", - "@message": "module.vpc.test_instance.boop[0]: Plan to delete", + "@message": "module.vpc.test_resource.boop[0]: Plan to delete", "@module": "terraform.ui", "type": "planned_change", "change": map[string]interface{}{ "action": "delete", "resource": map[string]interface{}{ - "addr": `module.vpc.test_instance.boop[0]`, + "addr": `module.vpc.test_resource.boop[0]`, "implied_provider": "test", "module": "module.vpc", - "resource": `test_instance.boop[0]`, + "resource": `test_resource.boop[0]`, "resource_key": float64(0), "resource_name": "boop", - "resource_type": "test_instance", + "resource_type": "test_resource", }, }, }, // Delete-then-create is also a replace { "@level": "info", - "@message": "test_instance.beep: Plan to replace", + "@message": "test_resource.beep: Plan to replace", "@module": "terraform.ui", "type": "planned_change", "change": map[string]interface{}{ "action": "replace", "resource": map[string]interface{}{ - "addr": `test_instance.beep`, + "addr": `test_resource.beep`, "implied_provider": "test", "module": "", - "resource": `test_instance.beep`, + "resource": `test_resource.beep`, "resource_key": nil, "resource_name": "beep", - "resource_type": "test_instance", + "resource_type": "test_resource", }, }, }, // Simple update { "@level": "info", - "@message": "module.vpc.test_instance.beep: Plan to update", + "@message": "module.vpc.test_resource.beep: Plan to update", "@module": "terraform.ui", "type": "planned_change", "change": map[string]interface{}{ "action": "update", "resource": map[string]interface{}{ - "addr": `module.vpc.test_instance.beep`, + "addr": `module.vpc.test_resource.beep`, "implied_provider": "test", "module": "module.vpc", - "resource": `test_instance.beep`, + "resource": `test_resource.beep`, "resource_key": nil, "resource_name": "beep", - "resource_type": "test_instance", + "resource_type": "test_resource", }, }, }, @@ -635,6 +635,134 @@ func TestOperationJSON_plan(t *testing.T) { testJSONViewOutputEquals(t, done(t).Stdout(), want) } +func TestOperationJSON_planDrift(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := &OperationJSON{view: NewJSONView(NewView(streams))} + + root := addrs.RootModuleInstance + boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "boop"} + beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "beep"} + derp := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "derp"} + + plan := &plans.Plan{ + Changes: &plans.Changes{ + Resources: []*plans.ResourceInstanceChangeSrc{}, + }, + PrevRunState: states.BuildState(func(state *states.SyncState) { + // Update + state.SetResourceInstanceCurrent( + boop.Instance(addrs.NoKey).Absolute(root), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"foo":"bar"}`), + }, + root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), + ) + // Delete + state.SetResourceInstanceCurrent( + beep.Instance(addrs.NoKey).Absolute(root), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"foo":"boop"}`), + }, + root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), + ) + // No-op + state.SetResourceInstanceCurrent( + derp.Instance(addrs.NoKey).Absolute(root), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"foo":"boop"}`), + }, + root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), + ) + }), + PriorState: states.BuildState(func(state *states.SyncState) { + // Update + state.SetResourceInstanceCurrent( + boop.Instance(addrs.NoKey).Absolute(root), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"foo":"baz"}`), + }, + root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), + ) + // Delete + state.SetResourceInstanceCurrent( + beep.Instance(addrs.NoKey).Absolute(root), + nil, + root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), + ) + // No-op + state.SetResourceInstanceCurrent( + derp.Instance(addrs.NoKey).Absolute(root), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"foo":"boop"}`), + }, + root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), + ) + }), + } + v.Plan(plan, testSchemas()) + + want := []map[string]interface{}{ + // Drift detected: delete + { + "@level": "info", + "@message": "test_resource.beep: Drift detected (delete)", + "@module": "terraform.ui", + "type": "resource_drift", + "change": map[string]interface{}{ + "action": "delete", + "resource": map[string]interface{}{ + "addr": "test_resource.beep", + "implied_provider": "test", + "module": "", + "resource": "test_resource.beep", + "resource_key": nil, + "resource_name": "beep", + "resource_type": "test_resource", + }, + }, + }, + // Drift detected: update + { + "@level": "info", + "@message": "test_resource.boop: Drift detected (update)", + "@module": "terraform.ui", + "type": "resource_drift", + "change": map[string]interface{}{ + "action": "update", + "resource": map[string]interface{}{ + "addr": "test_resource.boop", + "implied_provider": "test", + "module": "", + "resource": "test_resource.boop", + "resource_key": nil, + "resource_name": "boop", + "resource_type": "test_resource", + }, + }, + }, + // No changes + { + "@level": "info", + "@message": "Plan: 0 to add, 0 to change, 0 to destroy.", + "@module": "terraform.ui", + "type": "change_summary", + "changes": map[string]interface{}{ + "operation": "plan", + "add": float64(0), + "change": float64(0), + "remove": float64(0), + }, + }, + } + + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + func TestOperationJSON_plannedChange(t *testing.T) { streams, done := terminal.StreamsForTesting(t) v := &OperationJSON{view: NewJSONView(NewView(streams))} diff --git a/internal/configs/configschema/decoder_spec.go b/internal/configs/configschema/decoder_spec.go index 333274503..19bea4917 100644 --- a/internal/configs/configschema/decoder_spec.go +++ b/internal/configs/configschema/decoder_spec.go @@ -211,9 +211,15 @@ func (a *Attribute) decoderSpec(name string) hcldec.Spec { // belong to their own cty.Object definitions. It is used in other functions // which themselves handle that recursion. func listOptionalAttrsFromObject(o *Object) []string { - var ret []string + ret := make([]string, 0) + + // This is unlikely to happen outside of tests. + if o == nil { + return ret + } + for name, attr := range o.Attributes { - if attr.Optional == true { + if attr.Optional || attr.Computed { ret = append(ret, name) } } diff --git a/internal/configs/configschema/decoder_spec_test.go b/internal/configs/configschema/decoder_spec_test.go index a6571eaa6..12fdee761 100644 --- a/internal/configs/configschema/decoder_spec_test.go +++ b/internal/configs/configschema/decoder_spec_test.go @@ -1,10 +1,12 @@ package configschema import ( + "sort" "testing" "github.com/apparentlymart/go-dump/dump" "github.com/davecgh/go-spew/spew" + "github.com/google/go-cmp/cmp" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hcldec" @@ -885,3 +887,43 @@ func TestAttributeDecoderSpec_panic(t *testing.T) { attrS.decoderSpec("attr") t.Errorf("expected panic") } + +func TestListOptionalAttrsFromObject(t *testing.T) { + tests := []struct { + input *Object + want []string + }{ + { + nil, + []string{}, + }, + { + &Object{}, + []string{}, + }, + { + &Object{ + Nesting: NestingSingle, + Attributes: map[string]*Attribute{ + "optional": {Type: cty.String, Optional: true}, + "required": {Type: cty.Number, Required: true}, + "computed": {Type: cty.List(cty.Bool), Computed: true}, + "optional_computed": {Type: cty.Map(cty.Bool), Optional: true, Computed: true}, + }, + }, + []string{"optional", "computed", "optional_computed"}, + }, + } + + for _, test := range tests { + got := listOptionalAttrsFromObject(test.input) + + // order is irrelevant + sort.Strings(got) + sort.Strings(test.want) + + if diff := cmp.Diff(got, test.want); diff != "" { + t.Fatalf("wrong result: %s\n", diff) + } + } +} diff --git a/internal/configs/configschema/implied_type.go b/internal/configs/configschema/implied_type.go index 4d4a042c3..58b995110 100644 --- a/internal/configs/configschema/implied_type.go +++ b/internal/configs/configschema/implied_type.go @@ -79,7 +79,7 @@ func (o *Object) ImpliedType() cty.Type { case NestingSet: return cty.Set(ret) default: // Should never happen - panic("invalid Nesting") + return cty.EmptyObject } } diff --git a/internal/configs/configschema/implied_type_test.go b/internal/configs/configschema/implied_type_test.go index 7cd0f7309..d36239615 100644 --- a/internal/configs/configschema/implied_type_test.go +++ b/internal/configs/configschema/implied_type_test.go @@ -37,6 +37,7 @@ func TestBlockImpliedType(t *testing.T) { "optional_computed": { Type: cty.Map(cty.Bool), Optional: true, + Computed: true, }, }, }, @@ -132,26 +133,18 @@ func TestObjectImpliedType(t *testing.T) { nil, cty.EmptyObject, }, + "empty": { + &Object{}, + cty.EmptyObject, + }, "attributes": { &Object{ Nesting: NestingSingle, Attributes: map[string]*Attribute{ - "optional": { - Type: cty.String, - Optional: true, - }, - "required": { - Type: cty.Number, - Required: true, - }, - "computed": { - Type: cty.List(cty.Bool), - Computed: true, - }, - "optional_computed": { - Type: cty.Map(cty.Bool), - Optional: true, - }, + "optional": {Type: cty.String, Optional: true}, + "required": {Type: cty.Number, Required: true}, + "computed": {Type: cty.List(cty.Bool), Computed: true}, + "optional_computed": {Type: cty.Map(cty.Bool), Optional: true, Computed: true}, }, }, cty.ObjectWithOptionalAttrs( @@ -161,7 +154,7 @@ func TestObjectImpliedType(t *testing.T) { "computed": cty.List(cty.Bool), "optional_computed": cty.Map(cty.Bool), }, - []string{"optional", "optional_computed"}, + []string{"optional", "computed", "optional_computed"}, ), }, "nested attributes": { @@ -172,21 +165,42 @@ func TestObjectImpliedType(t *testing.T) { NestedType: &Object{ Nesting: NestingSingle, Attributes: map[string]*Attribute{ - "optional": { - Type: cty.String, - Optional: true, - }, - "required": { - Type: cty.Number, - Required: true, - }, - "computed": { - Type: cty.List(cty.Bool), - Computed: true, - }, - "optional_computed": { - Type: cty.Map(cty.Bool), - Optional: true, + "optional": {Type: cty.String, Optional: true}, + "required": {Type: cty.Number, Required: true}, + "computed": {Type: cty.List(cty.Bool), Computed: true}, + "optional_computed": {Type: cty.Map(cty.Bool), Optional: true, Computed: true}, + }, + }, + Optional: true, + }, + }, + }, + cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "nested_type": cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "optional": cty.String, + "required": cty.Number, + "computed": cty.List(cty.Bool), + "optional_computed": cty.Map(cty.Bool), + }, []string{"optional", "computed", "optional_computed"}), + }, []string{"nested_type"}), + }, + "nested object-type attributes": { + &Object{ + Nesting: NestingSingle, + Attributes: map[string]*Attribute{ + "nested_type": { + NestedType: &Object{ + Nesting: NestingSingle, + Attributes: map[string]*Attribute{ + "optional": {Type: cty.String, Optional: true}, + "required": {Type: cty.Number, Required: true}, + "computed": {Type: cty.List(cty.Bool), Computed: true}, + "optional_computed": {Type: cty.Map(cty.Bool), Optional: true, Computed: true}, + "object": { + Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "optional": cty.String, + "required": cty.Number, + }, []string{"optional"}), }, }, }, @@ -200,7 +214,8 @@ func TestObjectImpliedType(t *testing.T) { "required": cty.Number, "computed": cty.List(cty.Bool), "optional_computed": cty.Map(cty.Bool), - }, []string{"optional", "optional_computed"}), + "object": cty.ObjectWithOptionalAttrs(map[string]cty.Type{"optional": cty.String, "required": cty.Number}, []string{"optional"}), + }, []string{"optional", "computed", "optional_computed"}), }, []string{"nested_type"}), }, "NestingList": { diff --git a/internal/getproviders/registry_client.go b/internal/getproviders/registry_client.go index ac0abd4c6..0a9c5e80a 100644 --- a/internal/getproviders/registry_client.go +++ b/internal/getproviders/registry_client.go @@ -55,7 +55,7 @@ func init() { configureRequestTimeout() } -var SupportedPluginProtocols = MustParseVersionConstraints("~> 5") +var SupportedPluginProtocols = MustParseVersionConstraints(">= 5, <7") // registryClient is a client for the provider registry protocol that is // specialized only for the needs of this package. It's not intended as a diff --git a/internal/getproviders/registry_client_test.go b/internal/getproviders/registry_client_test.go index 252d4dd00..85fe00aa8 100644 --- a/internal/getproviders/registry_client_test.go +++ b/internal/getproviders/registry_client_test.go @@ -218,6 +218,10 @@ func fakeRegistryHandler(resp http.ResponseWriter, req *http.Request) { resp.Header().Set("Content-Type", "application/json") resp.WriteHeader(200) resp.Write([]byte(`{"versions":[{"version":"1.0.0","protocols":["0.1"]}]}`)) + case "weaksauce/protocol-six": + resp.Header().Set("Content-Type", "application/json") + resp.WriteHeader(200) + resp.Write([]byte(`{"versions":[{"version":"1.0.0","protocols":["6.0"]}]}`)) case "weaksauce/no-versions": resp.Header().Set("Content-Type", "application/json") resp.WriteHeader(200) @@ -412,6 +416,12 @@ func TestFindClosestProtocolCompatibleVersion(t *testing.T) { versions.Unspecified, ``, }, + "provider protocol six": { + addrs.MustParseProviderSourceString("example.com/weaksauce/protocol-six"), + MustParseVersion("1.0.0"), + MustParseVersion("1.0.0"), + ``, + }, } for name, test := range tests { t.Run(name, func(t *testing.T) { diff --git a/internal/plans/objchange/objchange_test.go b/internal/plans/objchange/objchange_test.go index 3e9cc2055..8221f99bd 100644 --- a/internal/plans/objchange/objchange_test.go +++ b/internal/plans/objchange/objchange_test.go @@ -1457,7 +1457,7 @@ func TestProposedNew(t *testing.T) { "computed": cty.String, "optional_computed": cty.String, "required": cty.String, - }, []string{"optional", "optional_computed"}), + }, []string{"computed", "optional", "optional_computed"}), }))), }), }, diff --git a/internal/refactoring/move_execute.go b/internal/refactoring/move_execute.go new file mode 100644 index 000000000..1fc60f85f --- /dev/null +++ b/internal/refactoring/move_execute.go @@ -0,0 +1,171 @@ +package refactoring + +import ( + "fmt" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/dag" + "github.com/hashicorp/terraform/internal/states" +) + +type MoveResult struct { + From, To addrs.AbsResourceInstance +} + +// ApplyMoves modifies in-place the given state object so that any existing +// objects that are matched by a "from" argument of one of the move statements +// will be moved to instead appear at the "to" argument of that statement. +// +// The result is a map from the unique key of each absolute address that was +// either the source or destination of a move to a MoveResult describing +// what happened at that address. +// +// ApplyMoves does not have any error situations itself, and will instead just +// ignore any unresolvable move statements. Validation of a set of moves is +// a separate concern applied to the configuration, because validity of +// moves is always dependent only on the configuration, not on the state. +// +// ApplyMoves expects exclusive access to the given state while it's running. +// Don't read or write any part of the state structure until ApplyMoves returns. +func ApplyMoves(stmts []MoveStatement, state *states.State) map[addrs.UniqueKey]MoveResult { + // The methodology here is to construct a small graph of all of the move + // statements where the edges represent where a particular statement + // is either chained from or nested inside the effect of another statement. + // That then means we can traverse the graph in topological sort order + // to gradually move objects through potentially multiple moves each. + + g := buildMoveStatementGraph(stmts) + + // If there are any cycles in the graph then we'll not take any action + // at all. The separate validation step should detect this and return + // an error. + if len(g.Cycles()) != 0 { + return nil + } + + // The starting nodes are the ones that don't depend on any other nodes. + startNodes := make(dag.Set, len(stmts)) + for _, v := range g.Vertices() { + if len(g.UpEdges(v)) == 0 { + startNodes.Add(v) + } + } + + results := make(map[addrs.UniqueKey]MoveResult) + g.DepthFirstWalk(startNodes, func(v dag.Vertex, depth int) error { + stmt := v.(*MoveStatement) + + for _, ms := range state.Modules { + modAddr := ms.Addr + if !stmt.From.SelectsModule(modAddr) { + continue + } + + // We now know that the current module is relevant but what + // we'll do with it depends on the object kind. + switch kind := stmt.ObjectKind(); kind { + case addrs.MoveEndpointModule: + // For a module endpoint we just try the module address + // directly. + if newAddr, matches := modAddr.MoveDestination(stmt.From, stmt.To); matches { + // We need to visit all of the resource instances in the + // module and record them individually as results. + for _, rs := range ms.Resources { + relAddr := rs.Addr.Resource + for key := range rs.Instances { + oldInst := relAddr.Instance(key).Absolute(modAddr) + newInst := relAddr.Instance(key).Absolute(newAddr) + result := MoveResult{ + From: oldInst, + To: newInst, + } + results[oldInst.UniqueKey()] = result + results[newInst.UniqueKey()] = result + } + } + + state.MoveModuleInstance(modAddr, newAddr) + continue + } + case addrs.MoveEndpointResource: + // For a resource endpoint we need to search each of the + // resources and resource instances in the module. + for _, rs := range ms.Resources { + rAddr := rs.Addr + if newAddr, matches := rAddr.MoveDestination(stmt.From, stmt.To); matches { + for key := range rs.Instances { + oldInst := rAddr.Instance(key) + newInst := newAddr.Instance(key) + result := MoveResult{ + From: oldInst, + To: newInst, + } + results[oldInst.UniqueKey()] = result + results[newInst.UniqueKey()] = result + } + state.MoveAbsResource(rAddr, newAddr) + continue + } + for key := range rs.Instances { + iAddr := rAddr.Instance(key) + if newAddr, matches := iAddr.MoveDestination(stmt.From, stmt.To); matches { + result := MoveResult{From: iAddr, To: newAddr} + results[iAddr.UniqueKey()] = result + results[newAddr.UniqueKey()] = result + + state.MoveAbsResourceInstance(iAddr, newAddr) + continue + } + } + } + default: + panic(fmt.Sprintf("unhandled move object kind %s", kind)) + } + } + + return nil + }) + + // FIXME: In the case of either chained or nested moves, "results" will + // be left in a pretty interesting shape where the "old" address will + // refer to a result that describes only the first step, while the "new" + // address will refer to a result that describes only the last step. + // To make that actually useful we'll need a different strategy where + // the result describes the _effective_ source and destination, skipping + // over any intermediate steps we took to get there, so that ultimately + // we'll have enough information to annotate items in the plan with the + // addresses the originally moved from. + + return results +} + +// buildMoveStatementGraph constructs a dependency graph of the given move +// statements, where the nodes are all pointers to statements in the given +// slice and the edges represent either chaining or nesting relationships. +// +// buildMoveStatementGraph doesn't do any validation of the graph, so it +// may contain cycles and other sorts of invalidity. +func buildMoveStatementGraph(stmts []MoveStatement) *dag.AcyclicGraph { + g := &dag.AcyclicGraph{} + for _, stmt := range stmts { + // The graph nodes are pointers to the actual statements directly. + g.Add(&stmt) + } + + // Now we'll add the edges representing chaining and nesting relationships. + // We assume that a reasonable configuration will have at most tens of + // move statements and thus this N*M algorithm is acceptable. + for dependerI := range stmts { + depender := &stmts[dependerI] + for dependeeI := range stmts { + dependee := &stmts[dependeeI] + dependeeTo := dependee.To + dependerFrom := depender.From + if dependerFrom.CanChainFrom(dependeeTo) || dependerFrom.NestedWithin(dependeeTo) { + g.Connect(dag.BasicEdge(depender, dependee)) + } + } + } + + return g +} diff --git a/internal/refactoring/move_execute_test.go b/internal/refactoring/move_execute_test.go new file mode 100644 index 000000000..eed56e911 --- /dev/null +++ b/internal/refactoring/move_execute_test.go @@ -0,0 +1,213 @@ +package refactoring + +import ( + "fmt" + "sort" + "strings" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/states" +) + +func TestApplyMoves(t *testing.T) { + // TODO: Renable this once we're ready to implement the intended behaviors + // it is describing. + t.Skip("ApplyMoves is not yet fully implemented") + + providerAddr := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.MustParseProviderSourceString("example.com/foo/bar"), + } + rootNoKeyResourceAddr := [...]addrs.AbsResourceInstance{ + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "foo", + Name: "from", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "foo", + Name: "to", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + } + rootIntKeyResourceAddr := [...]addrs.AbsResourceInstance{ + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "foo", + Name: "from", + }.Instance(addrs.IntKey(0)).Absolute(addrs.RootModuleInstance), + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "foo", + Name: "to", + }.Instance(addrs.IntKey(0)).Absolute(addrs.RootModuleInstance), + } + + tests := map[string]struct { + Stmts []MoveStatement + State *states.State + + WantResults map[addrs.UniqueKey]MoveResult + WantInstanceAddrs []string + }{ + "no moves and empty state": { + []MoveStatement{}, + states.NewState(), + nil, + nil, + }, + "no moves": { + []MoveStatement{}, + states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + rootNoKeyResourceAddr[0], + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{}`), + }, + providerAddr, + ) + }), + nil, + []string{ + `foo.from`, + }, + }, + "single move of whole singleton resource": { + []MoveStatement{ + testMoveStatement(t, "", "foo.from", "foo.to"), + }, + states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + rootNoKeyResourceAddr[0], + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{}`), + }, + providerAddr, + ) + }), + map[addrs.UniqueKey]MoveResult{ + rootNoKeyResourceAddr[0].UniqueKey(): { + From: rootNoKeyResourceAddr[0], + To: rootNoKeyResourceAddr[1], + }, + rootNoKeyResourceAddr[1].UniqueKey(): { + From: rootNoKeyResourceAddr[1], + To: rootNoKeyResourceAddr[1], + }, + }, + []string{ + `foo.to`, + }, + }, + "single move of whole 'count' resource": { + []MoveStatement{ + testMoveStatement(t, "", "foo.from", "foo.to"), + }, + states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + rootIntKeyResourceAddr[0], + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{}`), + }, + providerAddr, + ) + }), + map[addrs.UniqueKey]MoveResult{ + rootNoKeyResourceAddr[0].UniqueKey(): { + From: rootIntKeyResourceAddr[0], + To: rootIntKeyResourceAddr[1], + }, + rootNoKeyResourceAddr[1].UniqueKey(): { + From: rootIntKeyResourceAddr[0], + To: rootIntKeyResourceAddr[1], + }, + }, + []string{ + `foo.to[0]`, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var stmtsBuf strings.Builder + for _, stmt := range test.Stmts { + fmt.Fprintf(&stmtsBuf, "- from: %s\n to: %s", stmt.From, stmt.To) + } + t.Logf("move statements:\n%s", stmtsBuf.String()) + + t.Logf("resource instances in prior state:\n%s", spew.Sdump(allResourceInstanceAddrsInState(test.State))) + + state := test.State.DeepCopy() // don't modify the test case in-place + gotResults := ApplyMoves(test.Stmts, state) + + if diff := cmp.Diff(test.WantResults, gotResults); diff != "" { + t.Errorf("wrong results\n%s", diff) + } + + gotInstAddrs := allResourceInstanceAddrsInState(state) + if diff := cmp.Diff(test.WantInstanceAddrs, gotInstAddrs); diff != "" { + t.Errorf("wrong resource instances in final state\n%s", diff) + } + }) + } +} + +func testMoveStatement(t *testing.T, module string, from string, to string) MoveStatement { + t.Helper() + + moduleAddr := addrs.RootModule + if len(module) != 0 { + moduleAddr = addrs.Module(strings.Split(module, ".")) + } + + fromTraversal, hclDiags := hclsyntax.ParseTraversalAbs([]byte(from), "from", hcl.InitialPos) + if hclDiags.HasErrors() { + t.Fatalf("invalid 'from' argument: %s", hclDiags.Error()) + } + fromAddr, diags := addrs.ParseMoveEndpoint(fromTraversal) + if diags.HasErrors() { + t.Fatalf("invalid 'from' argument: %s", diags.Err().Error()) + } + toTraversal, hclDiags := hclsyntax.ParseTraversalAbs([]byte(to), "to", hcl.InitialPos) + if diags.HasErrors() { + t.Fatalf("invalid 'to' argument: %s", hclDiags.Error()) + } + toAddr, diags := addrs.ParseMoveEndpoint(toTraversal) + if diags.HasErrors() { + t.Fatalf("invalid 'from' argument: %s", diags.Err().Error()) + } + + fromInModule, toInModule := addrs.UnifyMoveEndpoints(moduleAddr, fromAddr, toAddr) + if fromInModule == nil || toInModule == nil { + t.Fatalf("incompatible endpoints") + } + + return MoveStatement{ + From: fromInModule, + To: toInModule, + + // DeclRange not populated because it's unimportant for our tests + } +} + +func allResourceInstanceAddrsInState(state *states.State) []string { + var ret []string + for _, ms := range state.Modules { + for _, rs := range ms.Resources { + for key := range rs.Instances { + ret = append(ret, rs.Addr.Instance(key).String()) + } + } + } + sort.Strings(ret) + return ret +} diff --git a/internal/refactoring/move_statement.go b/internal/refactoring/move_statement.go new file mode 100644 index 000000000..59f7f55de --- /dev/null +++ b/internal/refactoring/move_statement.go @@ -0,0 +1,52 @@ +package refactoring + +import ( + "fmt" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +type MoveStatement struct { + From, To *addrs.MoveEndpointInModule + DeclRange tfdiags.SourceRange +} + +// FindMoveStatements recurses through the modules of the given configuration +// and returns a flat set of all "moved" blocks defined within, in a +// deterministic but undefined order. +func FindMoveStatements(rootCfg *configs.Config) []MoveStatement { + return findMoveStatements(rootCfg, nil) +} + +func findMoveStatements(cfg *configs.Config, into []MoveStatement) []MoveStatement { + modAddr := cfg.Path + for _, mc := range cfg.Module.Moved { + fromAddr, toAddr := addrs.UnifyMoveEndpoints(modAddr, mc.From, mc.To) + if fromAddr == nil || toAddr == nil { + // Invalid combination should've been caught during original + // configuration decoding, in the configs package. + panic(fmt.Sprintf("incompatible move endpoints in %s", mc.DeclRange)) + } + + into = append(into, MoveStatement{ + From: fromAddr, + To: toAddr, + DeclRange: tfdiags.SourceRangeFromHCL(mc.DeclRange), + }) + } + + for _, childCfg := range cfg.Children { + into = findMoveStatements(childCfg, into) + } + + return into +} + +func (s *MoveStatement) ObjectKind() addrs.MoveEndpointKind { + // addrs.UnifyMoveEndpoints guarantees that both of our addresses have + // the same kind, so we can just arbitrary use From and assume To will + // match it. + return s.From.ObjectKind() +} diff --git a/internal/refactoring/move_validate.go b/internal/refactoring/move_validate.go new file mode 100644 index 000000000..40ee5f654 --- /dev/null +++ b/internal/refactoring/move_validate.go @@ -0,0 +1,40 @@ +package refactoring + +import ( + "fmt" + + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/instances" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// ValidateMoves tests whether all of the given move statements comply with +// both the single-statement validation rules and the "big picture" rules +// that constrain statements in relation to one another. +// +// The validation rules are primarily in terms of the configuration, but +// ValidateMoves also takes the expander that resulted from creating a plan +// so that it can see which instances are defined for each module and resource, +// to precisely validate move statements involving specific-instance addresses. +// +// Because validation depends on the planning result but move execution must +// happen _before_ planning, we have the unusual situation where sibling +// function ApplyMoves must run before ValidateMoves and must therefore +// tolerate and ignore any invalid statements. The plan walk will then +// construct in incorrect plan (because it'll be starting from the wrong +// prior state) but ValidateMoves will block actually showing that invalid +// plan to the user. +func ValidateMoves(stmts []MoveStatement, rootCfg *configs.Config, expander *instances.Expander) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + g := buildMoveStatementGraph(stmts) + + if len(g.Cycles()) != 0 { + // TODO: proper error messages for this + diags = diags.Append(fmt.Errorf("move statement cycles")) + } + + // TODO: Various other validation rules + + return diags +} diff --git a/internal/states/state.go b/internal/states/state.go index 0b3e207ee..1c972d662 100644 --- a/internal/states/state.go +++ b/internal/states/state.go @@ -1,6 +1,7 @@ package states import ( + "fmt" "sort" "github.com/zclconf/go-cty/cty" @@ -296,3 +297,245 @@ func (s *State) SyncWrapper() *SyncState { state: s, } } + +// MoveAbsResource moves the given src AbsResource's current state to the new +// dst address. This will panic if the src AbsResource does not exist in state, +// or if there is already a resource at the dst address. It is the caller's +// responsibility to verify the validity of the move (for example, that the src +// and dst are compatible types). +func (s *State) MoveAbsResource(src, dst addrs.AbsResource) { + // verify that the src address exists and the dst address does not + rs := s.Resource(src) + if rs == nil { + panic(fmt.Sprintf("no state for src address %s", src.String())) + } + + ds := s.Resource(dst) + if ds != nil { + panic(fmt.Sprintf("dst resource %s already exists", dst.String())) + } + + ms := s.Module(src.Module) + ms.RemoveResource(src.Resource) + + // Remove the module if it is empty (and not root) after removing the + // resource. + if !ms.Addr.IsRoot() && ms.empty() { + s.RemoveModule(src.Module) + } + + // Update the address before adding it to the state + rs.Addr = dst + s.EnsureModule(dst.Module).Resources[dst.Resource.String()] = rs +} + +// MaybeMoveAbsResource moves the given src AbsResource's current state to the +// new dst address. This function will succeed if both the src address does not +// exist in state and the dst address does; the return value indicates whether +// or not the move occured. This function will panic if either the src does not +// exist or the dst does exist (but not both). +func (s *State) MaybeMoveAbsResource(src, dst addrs.AbsResource) bool { + // Get the source and destinatation addresses from state. + rs := s.Resource(src) + ds := s.Resource(dst) + + // Normal case: the src exists in state, dst does not + if rs != nil && ds == nil { + s.MoveAbsResource(src, dst) + return true + } + + if rs == nil && ds != nil { + // The source is not in state, the destination is. This is not + // guaranteed to be idempotent since we aren't tracking exact moves, but + // it's useful information for the caller. + return false + } else { + panic("invalid move") + } +} + +// MoveAbsResourceInstance moves the given src AbsResourceInstance's current state to +// the new dst address. This will panic if the src AbsResourceInstance does not +// exist in state, or if there is already a resource at the dst address. It is +// the caller's responsibility to verify the validity of the move (for example, +// that the src and dst are compatible types). +func (s *State) MoveAbsResourceInstance(src, dst addrs.AbsResourceInstance) { + srcInstanceState := s.ResourceInstance(src) + if srcInstanceState == nil { + panic(fmt.Sprintf("no state for src address %s", src.String())) + } + + dstInstanceState := s.ResourceInstance(dst) + if dstInstanceState != nil { + panic(fmt.Sprintf("dst resource %s already exists", dst.String())) + } + + srcResourceState := s.Resource(src.ContainingResource()) + srcProviderAddr := srcResourceState.ProviderConfig + dstResourceAddr := dst.ContainingResource() + + // Remove the source resource instance from the module's state, and then the + // module if empty. + ms := s.Module(src.Module) + ms.ForgetResourceInstanceAll(src.Resource) + if !ms.Addr.IsRoot() && ms.empty() { + s.RemoveModule(src.Module) + } + + dstModule := s.EnsureModule(dst.Module) + + // See if there is already a resource we can add this instance to. + dstResourceState := s.Resource(dstResourceAddr) + if dstResourceState == nil { + // If we're moving to an address without an index then that + // suggests the user's intent is to establish both the + // resource and the instance at the same time (since the + // address covers both). If there's an index in the + // target then allow creating the new instance here. + dstModule.SetResourceProvider( + dstResourceAddr.Resource, + srcProviderAddr, // in this case, we bring the provider along as if we were moving the whole resource + ) + dstResourceState = dstModule.Resource(dstResourceAddr.Resource) + } + + dstResourceState.Instances[dst.Resource.Key] = srcInstanceState +} + +// MaybeMoveAbsResourceInstance moves the given src AbsResourceInstance's +// current state to the new dst address. This function will succeed if both the +// src address does not exist in state and the dst address does; the return +// value indicates whether or not the move occured. This function will panic if +// either the src does not exist or the dst does exist (but not both). +func (s *State) MaybeMoveAbsResourceInstance(src, dst addrs.AbsResourceInstance) bool { + // get the src and dst resource instances from state + rs := s.ResourceInstance(src) + ds := s.ResourceInstance(dst) + + // Normal case: the src exists in state, dst does not + if rs != nil && ds == nil { + s.MoveAbsResourceInstance(src, dst) + return true + } + + if rs == nil && ds != nil { + // The source is not in state, the destination is. This is not + // guaranteed to be idempotent since we aren't tracking exact moves, but + // it's useful information. + return false + } else { + panic("invalid move") + } +} + +// MoveModuleInstance moves the given src ModuleInstance's current state to the +// new dst address. This will panic if the src ModuleInstance does not +// exist in state, or if there is already a resource at the dst address. It is +// the caller's responsibility to verify the validity of the move. +func (s *State) MoveModuleInstance(src, dst addrs.ModuleInstance) { + if src.IsRoot() || dst.IsRoot() { + panic("cannot move to or from root module") + } + + srcMod := s.Module(src) + if srcMod == nil { + panic(fmt.Sprintf("no state for src module %s", src.String())) + } + + dstMod := s.Module(dst) + if dstMod != nil { + panic(fmt.Sprintf("dst module %s already exists in state", dst.String())) + } + + s.RemoveModule(src) + + srcMod.Addr = dst + s.EnsureModule(dst) + s.Modules[dst.String()] = srcMod + + // Update any Resource's addresses. + if srcMod.Resources != nil { + for _, r := range srcMod.Resources { + r.Addr.Module = dst + } + } + + // Update any OutputValues's addresses. + if srcMod.OutputValues != nil { + for _, ov := range srcMod.OutputValues { + ov.Addr.Module = dst + } + } +} + +// MaybeMoveModuleInstance moves the given src ModuleInstance's current state to +// the new dst address. This function will succeed if both the src address does +// not exist in state and the dst address does; the return value indicates +// whether or not the move occured. This function will panic if either the src +// does not exist or the dst does exist (but not both). +func (s *State) MaybeMoveModuleInstance(src, dst addrs.ModuleInstance) bool { + if src.IsRoot() || dst.IsRoot() { + panic("cannot move to or from root module") + } + + srcMod := s.Module(src) + dstMod := s.Module(dst) + + // Normal case: the src exists in state, dst does not + if srcMod != nil && dstMod == nil { + s.MoveModuleInstance(src, dst) + return true + } + + if srcMod == nil || src.IsRoot() && dstMod != nil { + // The source is not in state, the destination is. This is not + // guaranteed to be idempotent since we aren't tracking exact moves, but + // it's useful information. + return false + } else { + panic("invalid move") + } +} + +// MoveModule takes a source and destination addrs.Module address, and moves all +// state Modules which are contained by the src address to the new address. +func (s *State) MoveModule(src, dst addrs.AbsModuleCall) { + if src.Module.IsRoot() || dst.Module.IsRoot() { + panic("cannot move to or from root module") + } + + // Modules only exist as ModuleInstances in state, so we need to check each + // state Module and see if it is contained by the src address to get a full + // list of modules to move. + var srcMIs []*Module + for _, module := range s.Modules { + if !module.Addr.IsRoot() { + if src.Module.TargetContains(module.Addr) { + srcMIs = append(srcMIs, module) + } + } + } + + if len(srcMIs) == 0 { + panic(fmt.Sprintf("no matching module instances found for src module %s", src.String())) + } + + for _, ms := range srcMIs { + newInst := make(addrs.ModuleInstance, len(ms.Addr)) + copy(newInst, ms.Addr) + if ms.Addr.IsDeclaredByCall(src) { + // Easy case: we just need to update the last step with the new name + newInst[len(newInst)-1].Name = dst.Call.Name + } else { + // Trickier: this Module is a submodule. we need to find and update + // only that appropriate step + for s := range newInst { + if newInst[s].Name == src.Call.Name { + newInst[s].Name = dst.Call.Name + } + } + } + s.MoveModuleInstance(ms.Addr, newInst) + } +} diff --git a/internal/states/state_test.go b/internal/states/state_test.go index 90746d5f8..b4495c0cd 100644 --- a/internal/states/state_test.go +++ b/internal/states/state_test.go @@ -1,6 +1,7 @@ package states import ( + "fmt" "reflect" "testing" @@ -292,3 +293,597 @@ func TestStateDeepCopy(t *testing.T) { t.Fatalf("\nexpected:\n%q\ngot:\n%q\n", state, stateCopy) } } + +func TestState_MoveAbsResource(t *testing.T) { + // Set up a starter state for the embedded tests, which should start from a copy of this state. + state := NewState() + rootModule := state.RootModule() + rootModule.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "foo", + }.Instance(addrs.IntKey(0)), + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + src := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "foo"}.Absolute(addrs.RootModuleInstance) + + t.Run("basic move", func(t *testing.T) { + s := state.DeepCopy() + dst := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "bar"}.Absolute(addrs.RootModuleInstance) + + s.MoveAbsResource(src, dst) + + if s.Empty() { + t.Fatal("unexpected empty state") + } + + if len(s.RootModule().Resources) != 1 { + t.Fatalf("wrong number of resources in state; expected 1, found %d", len(state.RootModule().Resources)) + } + + got := s.Resource(dst) + if got.Addr.Resource != dst.Resource { + t.Fatalf("dst resource not in state") + } + }) + + t.Run("move to new module", func(t *testing.T) { + s := state.DeepCopy() + dstModule := addrs.RootModuleInstance.Child("kinder", addrs.StringKey("one")) + dst := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "bar"}.Absolute(dstModule) + + s.MoveAbsResource(src, dst) + + if s.Empty() { + t.Fatal("unexpected empty state") + } + + if s.Module(dstModule) == nil { + t.Fatalf("child module %s not in state", dstModule.String()) + } + + if len(s.Module(dstModule).Resources) != 1 { + t.Fatalf("wrong number of resources in state; expected 1, found %d", len(s.Module(dstModule).Resources)) + } + + got := s.Resource(dst) + if got.Addr.Resource != dst.Resource { + t.Fatalf("dst resource not in state") + } + }) + + t.Run("from a child module to root", func(t *testing.T) { + s := state.DeepCopy() + srcModule := addrs.RootModuleInstance.Child("kinder", addrs.NoKey) + cm := s.EnsureModule(srcModule) + cm.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "child", + }.Instance(addrs.IntKey(0)), // Moving the AbsResouce moves all instances + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + cm.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "child", + }.Instance(addrs.IntKey(1)), // Moving the AbsResouce moves all instances + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + + src := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "child"}.Absolute(srcModule) + dst := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "child"}.Absolute(addrs.RootModuleInstance) + s.MoveAbsResource(src, dst) + + if s.Empty() { + t.Fatal("unexpected empty state") + } + + // The child module should have been removed after removing its only resource + if s.Module(srcModule) != nil { + t.Fatalf("child module %s was not removed from state after mv", srcModule.String()) + } + + if len(s.RootModule().Resources) != 2 { + t.Fatalf("wrong number of resources in state; expected 2, found %d", len(s.RootModule().Resources)) + } + + if len(s.Resource(dst).Instances) != 2 { + t.Fatalf("wrong number of resource instances for dst, got %d expected 2", len(s.Resource(dst).Instances)) + } + + got := s.Resource(dst) + if got.Addr.Resource != dst.Resource { + t.Fatalf("dst resource not in state") + } + }) + + t.Run("module to new module", func(t *testing.T) { + s := NewState() + srcModule := addrs.RootModuleInstance.Child("kinder", addrs.StringKey("exists")) + dstModule := addrs.RootModuleInstance.Child("kinder", addrs.StringKey("new")) + cm := s.EnsureModule(srcModule) + cm.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "child", + }.Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + + src := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "child"}.Absolute(srcModule) + dst := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "child"}.Absolute(dstModule) + s.MoveAbsResource(src, dst) + + if s.Empty() { + t.Fatal("unexpected empty state") + } + + // The child module should have been removed after removing its only resource + if s.Module(srcModule) != nil { + t.Fatalf("child module %s was not removed from state after mv", srcModule.String()) + } + + gotMod := s.Module(dstModule) + if len(gotMod.Resources) != 1 { + t.Fatalf("wrong number of resources in state; expected 1, found %d", len(gotMod.Resources)) + } + + got := s.Resource(dst) + if got.Addr.Resource != dst.Resource { + t.Fatalf("dst resource not in state") + } + }) + + t.Run("module to new module", func(t *testing.T) { + s := NewState() + srcModule := addrs.RootModuleInstance.Child("kinder", addrs.StringKey("exists")) + dstModule := addrs.RootModuleInstance.Child("kinder", addrs.StringKey("new")) + cm := s.EnsureModule(srcModule) + cm.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "child", + }.Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + + src := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "child"}.Absolute(srcModule) + dst := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "child"}.Absolute(dstModule) + s.MoveAbsResource(src, dst) + + if s.Empty() { + t.Fatal("unexpected empty state") + } + + // The child module should have been removed after removing its only resource + if s.Module(srcModule) != nil { + t.Fatalf("child module %s was not removed from state after mv", srcModule.String()) + } + + gotMod := s.Module(dstModule) + if len(gotMod.Resources) != 1 { + t.Fatalf("wrong number of resources in state; expected 1, found %d", len(gotMod.Resources)) + } + + got := s.Resource(dst) + if got.Addr.Resource != dst.Resource { + t.Fatalf("dst resource not in state") + } + }) +} + +func TestState_MaybeMoveAbsResource(t *testing.T) { + state := NewState() + rootModule := state.RootModule() + rootModule.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "foo", + }.Instance(addrs.IntKey(0)), + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + + src := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "foo"}.Absolute(addrs.RootModuleInstance) + dst := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "bar"}.Absolute(addrs.RootModuleInstance) + + // First move, success + t.Run("first move", func(t *testing.T) { + moved := state.MaybeMoveAbsResource(src, dst) + if !moved { + t.Fatal("wrong result") + } + }) + + // Trying to move a resource that doesn't exist in state to a resource which does exist should be a noop. + t.Run("noop", func(t *testing.T) { + moved := state.MaybeMoveAbsResource(src, dst) + if moved { + t.Fatal("wrong result") + } + }) +} + +func TestState_MoveAbsResourceInstance(t *testing.T) { + state := NewState() + rootModule := state.RootModule() + rootModule.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "foo", + }.Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + // src resource from the state above + src := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "foo"}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) + + t.Run("resource to resource instance", func(t *testing.T) { + s := state.DeepCopy() + // For a little extra fun, move a resource to a resource instance: test_thing.foo to test_thing.foo[1] + dst := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "foo"}.Instance(addrs.IntKey(1)).Absolute(addrs.RootModuleInstance) + + s.MoveAbsResourceInstance(src, dst) + + if s.Empty() { + t.Fatal("unexpected empty state") + } + + if len(s.RootModule().Resources) != 1 { + t.Fatalf("wrong number of resources in state; expected 1, found %d", len(state.RootModule().Resources)) + } + + got := s.ResourceInstance(dst) + if got == nil { + t.Fatalf("dst resource not in state") + } + }) + + t.Run("move to new module", func(t *testing.T) { + s := state.DeepCopy() + // test_thing.foo to module.kinder.test_thing.foo["baz"] + dstModule := addrs.RootModuleInstance.Child("kinder", addrs.NoKey) + dst := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "foo"}.Instance(addrs.IntKey(1)).Absolute(dstModule) + + s.MoveAbsResourceInstance(src, dst) + + if s.Empty() { + t.Fatal("unexpected empty state") + } + + if s.Module(dstModule) == nil { + t.Fatalf("child module %s not in state", dstModule.String()) + } + + if len(s.Module(dstModule).Resources) != 1 { + t.Fatalf("wrong number of resources in state; expected 1, found %d", len(s.Module(dstModule).Resources)) + } + + got := s.ResourceInstance(dst) + if got == nil { + t.Fatalf("dst resource not in state") + } + }) +} + +func TestState_MaybeMoveAbsResourceInstance(t *testing.T) { + state := NewState() + rootModule := state.RootModule() + rootModule.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "foo", + }.Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + + // For a little extra fun, let's go from a resource to a resource instance: test_thing.foo to test_thing.bar[1] + src := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "foo"}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) + dst := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_thing", Name: "foo"}.Instance(addrs.IntKey(1)).Absolute(addrs.RootModuleInstance) + + // First move, success + t.Run("first move", func(t *testing.T) { + moved := state.MaybeMoveAbsResourceInstance(src, dst) + if !moved { + t.Fatal("wrong result") + } + got := state.ResourceInstance(dst) + if got == nil { + t.Fatal("destination resource instance not in state") + } + }) + + // Moving a resource instance that doesn't exist in state to a resource which does exist should be a noop. + t.Run("noop", func(t *testing.T) { + moved := state.MaybeMoveAbsResourceInstance(src, dst) + if moved { + t.Fatal("wrong result") + } + }) +} + +func TestState_MoveModuleInstance(t *testing.T) { + state := NewState() + srcModule := addrs.RootModuleInstance.Child("kinder", addrs.NoKey) + m := state.EnsureModule(srcModule) + m.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "foo", + }.Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + + dstModule := addrs.RootModuleInstance.Child("child", addrs.IntKey(3)) + state.MoveModuleInstance(srcModule, dstModule) + + // srcModule should have been removed, dstModule should exist and have one resource + if len(state.Modules) != 2 { // kinder[3] and root + t.Fatalf("wrong number of modules in state. Expected 2, got %d", len(state.Modules)) + } + + got := state.Module(dstModule) + if got == nil { + t.Fatal("dstModule not found") + } + + gone := state.Module(srcModule) + if gone != nil { + t.Fatal("srcModule not removed from state") + } + + r := got.Resource(mustAbsResourceAddr("test_thing.foo").Resource) + if r.Addr.Module.String() != dstModule.String() { + fmt.Println(r.Addr.Module.String()) + t.Fatal("resource address was not updated") + } + +} + +func TestState_MaybeMoveModuleInstance(t *testing.T) { + state := NewState() + src := addrs.RootModuleInstance.Child("child", addrs.StringKey("a")) + cm := state.EnsureModule(src) + cm.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "foo", + }.Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + + dst := addrs.RootModuleInstance.Child("kinder", addrs.StringKey("b")) + + // First move, success + t.Run("first move", func(t *testing.T) { + moved := state.MaybeMoveModuleInstance(src, dst) + if !moved { + t.Fatal("wrong result") + } + }) + + // Second move, should be a noop + t.Run("noop", func(t *testing.T) { + moved := state.MaybeMoveModuleInstance(src, dst) + if moved { + t.Fatal("wrong result") + } + }) +} + +func TestState_MoveModule(t *testing.T) { + // For this test, add two module instances (kinder and kinder["a"]). + // MoveModule(kinder) should move both instances. + state := NewState() // starter state, should be copied by the subtests. + srcModule := addrs.RootModule.Child("kinder") + m := state.EnsureModule(srcModule.UnkeyedInstanceShim()) + m.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "foo", + }.Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + + moduleInstance := addrs.RootModuleInstance.Child("kinder", addrs.StringKey("a")) + mi := state.EnsureModule(moduleInstance) + mi.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "foo", + }.Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + + _, mc := srcModule.Call() + src := mc.Absolute(addrs.RootModuleInstance.Child("kinder", addrs.NoKey)) + + t.Run("basic", func(t *testing.T) { + s := state.DeepCopy() + _, dstMC := addrs.RootModule.Child("child").Call() + dst := dstMC.Absolute(addrs.RootModuleInstance.Child("child", addrs.NoKey)) + s.MoveModule(src, dst) + + // srcModule should have been removed, dstModule should exist and have one resource + if len(s.Modules) != 3 { // child, child["a"] and root + t.Fatalf("wrong number of modules in state. Expected 3, got %d", len(s.Modules)) + } + + got := s.Module(dst.Module) + if got == nil { + t.Fatal("dstModule not found") + } + + got = s.Module(addrs.RootModuleInstance.Child("child", addrs.StringKey("a"))) + if got == nil { + t.Fatal("dstModule instance \"a\" not found") + } + + gone := s.Module(srcModule.UnkeyedInstanceShim()) + if gone != nil { + t.Fatal("srcModule not removed from state") + } + }) + + t.Run("nested modules", func(t *testing.T) { + s := state.DeepCopy() + + // add a child module to module.kinder + mi := mustParseModuleInstanceStr(`module.kinder.module.grand[1]`) + m := s.EnsureModule(mi) + m.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "foo", + }.Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + Status: ObjectReady, + SchemaVersion: 1, + AttrsJSON: []byte(`{"woozles":"confuzles"}`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + + _, dstMC := addrs.RootModule.Child("child").Call() + dst := dstMC.Absolute(addrs.RootModuleInstance.Child("child", addrs.NoKey)) + s.MoveModule(src, dst) + + moved := s.Module(addrs.RootModuleInstance.Child("child", addrs.StringKey("a"))) + if moved == nil { + t.Fatal("dstModule not found") + } + + // The nested module's relative address should also have been updated + nested := s.Module(mustParseModuleInstanceStr(`module.child.module.grand[1]`)) + if nested == nil { + t.Fatal("nested child module of src wasn't moved") + } + }) +} + +func mustParseModuleInstanceStr(str string) addrs.ModuleInstance { + addr, diags := addrs.ParseModuleInstanceStr(str) + if diags.HasErrors() { + panic(diags.Err()) + } + return addr +} + +func mustAbsResourceAddr(s string) addrs.AbsResource { + addr, diags := addrs.ParseAbsResourceStr(s) + if diags.HasErrors() { + panic(diags.Err()) + } + return addr +} diff --git a/internal/states/sync.go b/internal/states/sync.go index defa02cc4..286eae965 100644 --- a/internal/states/sync.go +++ b/internal/states/sync.go @@ -554,3 +554,45 @@ func (s *SyncState) maybePruneModule(addr addrs.ModuleInstance) { s.state.RemoveModule(addr) } } + +func (s *SyncState) MoveAbsResource(src, dst addrs.AbsResource) { + s.lock.Lock() + defer s.lock.Unlock() + + s.state.MoveAbsResource(src, dst) +} + +func (s *SyncState) MaybeMoveAbsResource(src, dst addrs.AbsResource) bool { + s.lock.Lock() + defer s.lock.Unlock() + + return s.state.MaybeMoveAbsResource(src, dst) +} + +func (s *SyncState) MoveResourceInstance(src, dst addrs.AbsResourceInstance) { + s.lock.Lock() + defer s.lock.Unlock() + + s.state.MoveAbsResourceInstance(src, dst) +} + +func (s *SyncState) MaybeMoveResourceInstance(src, dst addrs.AbsResourceInstance) bool { + s.lock.Lock() + defer s.lock.Unlock() + + return s.state.MaybeMoveAbsResourceInstance(src, dst) +} + +func (s *SyncState) MoveModuleInstance(src, dst addrs.ModuleInstance) { + s.lock.Lock() + defer s.lock.Unlock() + + s.state.MoveModuleInstance(src, dst) +} + +func (s *SyncState) MaybeMoveModuleInstance(src, dst addrs.ModuleInstance) bool { + s.lock.Lock() + defer s.lock.Unlock() + + return s.state.MaybeMoveModuleInstance(src, dst) +} diff --git a/internal/terraform/context.go b/internal/terraform/context.go index c0934d778..3ae479eed 100644 --- a/internal/terraform/context.go +++ b/internal/terraform/context.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/provisioners" + "github.com/hashicorp/terraform/internal/refactoring" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/zclconf/go-cty/cty" @@ -608,6 +609,33 @@ The -target option is not for routine use, and is provided only for exceptional )) } + moveStmts := refactoring.FindMoveStatements(c.config) + if len(moveStmts) != 0 { + // TEMP: we haven't fully implemented moving yet, so we'll just + // reject it outright for now to reduce confusion. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Moves are not yet supported", + "There is currently no handling of \"moved\" blocks in the configuration.", + )) + } + moveResults := refactoring.ApplyMoves(moveStmts, c.prevRunState) + if len(c.targets) > 0 { + for _, result := range moveResults { + matchesTarget := false + for _, targetAddr := range c.targets { + if targetAddr.TargetContains(result.From) { + matchesTarget = true + break + } + } + if !matchesTarget { + // TODO: Return an error stating that a targeted plan is + // only valid if it includes this address that was moved. + } + } + } + var plan *plans.Plan var planDiags tfdiags.Diagnostics switch c.planMode { @@ -625,6 +653,10 @@ The -target option is not for routine use, and is provided only for exceptional return nil, diags } + // TODO: Call refactoring.ValidateMoves, but need to figure out how to + // get hold of the plan's expander here, or somehow otherwise export + // the necessary subset of its data for ValidateMoves to do its work. + // convert the variables into the format expected for the plan varVals := make(map[string]plans.DynamicValue, len(c.variables)) for k, iv := range c.variables { diff --git a/website/README.md b/website/README.md index 0fb9be102..53a5d9be5 100644 --- a/website/README.md +++ b/website/README.md @@ -28,3 +28,19 @@ You should preview all of your changes locally before creating a pull request. T 1. Navigate into your local `terraform` top-level directory and run `make website`. 2. Open `http://localhost:4567` in your web browser. While the preview is running, you can edit pages and Middleman will automatically rebuild them. 3. When you're done with the preview, press `ctrl-C` in your terminal to stop the server. + +## Deploying Changes + +Merge the PR to main. The changes will appear in the next major Terraform release. + +If you need your changes to be deployed sooner, cherry-pick them to: +- the current release branch (e.g. `v1.0`) and push. They will be deployed in the next minor version release (once every two weeks). +- the `stable-website` branch and push. They will be included in the next site deploy (see below). Note that the release process resets `stable-website` to match the release tag, removing any additional commits. So, we recommend always cherry-picking to the version branch first and then to `stable-website` when needed. + +### Deployment +Currently, HashiCorp uses a CircleCI job to deploy the [terraform.io](terraform.io) site. This job can be run manually by many people within HashiCorp, and also runs automatically whenever a user in the HashiCorp GitHub org merges changes to master in the `terraform-website` repository. + +New commits in this repository don't automatically deploy the [terraform.io][] site, but an unrelated site deploy will usually happen within a day. If you can't wait that long, you can do a manual CircleCI build or ask someone in the #proj-terraform-docs channel to do so: +- Log in to circleci.com, and make sure you're viewing the HashiCorp organization. +- Go to the terraform-website project's list of workflows. +- Find the most recent "website-deploy" workflow, and click the "Rerun workflow from start" button (which looks like a refresh button with a numeral "1" inside). diff --git a/website/docs/cli/commands/index.html.md b/website/docs/cli/commands/index.html.md index 8b5f02291..513825066 100644 --- a/website/docs/cli/commands/index.html.md +++ b/website/docs/cli/commands/index.html.md @@ -19,8 +19,8 @@ We refer to the `terraform` command line tool as "Terraform CLI" elsewhere in the documentation. This terminology is often used to distinguish it from other components you might use in the Terraform product family, such as [Terraform Cloud](/docs/cloud/) or -the various [Terraform providers](/docs/providers/), which are developed and -released separately from Terraform CLI. +the various [Terraform providers](/docs/language/providers/index.html), which +are developed and released separately from Terraform CLI. To view a list of the commands available in your current Terraform version, run `terraform` with no additional arguments: diff --git a/website/docs/cli/commands/output.html.md b/website/docs/cli/commands/output.html.md index 1a828612d..a09725e21 100644 --- a/website/docs/cli/commands/output.html.md +++ b/website/docs/cli/commands/output.html.md @@ -33,6 +33,10 @@ The command-line flags are all optional. The list of available flags are: * `-state=path` - Path to the state file. Defaults to "terraform.tfstate". Ignored when [remote state](/docs/language/state/remote.html) is used. +-> **Note:** When using the `-json` or `-raw` command-line flag, any sensitive +values in Terraform state will be displayed in plain text. For more information, +see [Sensitive Data in State](/docs/language/state/sensitive-data.html). + ## Examples These examples assume the following Terraform output snippet. diff --git a/website/docs/cli/config/config-file.html.md b/website/docs/cli/config/config-file.html.md index cc4a1763a..2df6203f3 100644 --- a/website/docs/cli/config/config-file.html.md +++ b/website/docs/cli/config/config-file.html.md @@ -358,6 +358,10 @@ Terraform will never itself delete a plugin from the plugin cache once it has been placed there. Over time, as plugins are upgraded, the cache directory may grow to contain several unused versions which you must delete manually. +-> **Note:** The plugin cache directory is not guaranteed to be concurrency +safe. The provider installer's behavior in environments with multiple `terraform +init` calls is undefined. + ### Development Overrides for Provider Developers -> **Note:** Development overrides work only in Terraform v0.14 and later. diff --git a/website/docs/cli/config/environment-variables.html.md b/website/docs/cli/config/environment-variables.html.md index 891f8d231..fb33a1a1f 100644 --- a/website/docs/cli/config/environment-variables.html.md +++ b/website/docs/cli/config/environment-variables.html.md @@ -62,6 +62,7 @@ export TF_VAR_amap='{ foo = "bar", baz = "qux" }' For more on how to use `TF_VAR_name` in context, check out the section on [Variable Configuration](/docs/language/values/variables.html). ## TF_CLI_ARGS and TF_CLI_ARGS_name + The value of `TF_CLI_ARGS` will specify additional arguments to the command-line. This allows easier automation in CI environments as well as diff --git a/website/docs/configuration-0-11/data-sources.html.md b/website/docs/configuration-0-11/data-sources.html.md index 90f3463e3..abd435e46 100644 --- a/website/docs/configuration-0-11/data-sources.html.md +++ b/website/docs/configuration-0-11/data-sources.html.md @@ -64,8 +64,8 @@ parameter) and `NAME` (second parameter). The combination of the type and name must be unique. Within the block (the `{ }`) is configuration for the data instance. The -configuration is dependent on the type, and is documented for each -data source in the [providers section](/docs/providers/index.html). +configuration is dependent on the type; consult the [provider's documentation] (https://registry.terraform.io/browse/providers) for +details. Each data instance will export one or more attributes, which can be interpolated into other resources using variables of the form diff --git a/website/docs/configuration-0-11/providers.html.md b/website/docs/configuration-0-11/providers.html.md index 2450f38e6..5380ff69e 100644 --- a/website/docs/configuration-0-11/providers.html.md +++ b/website/docs/configuration-0-11/providers.html.md @@ -48,8 +48,8 @@ header. For example, `provider "aws"` above is a configuration for the `aws` provider. Within the block body (between `{ }`) is configuration for the provider. -The configuration is dependent on the type, and is documented -[for each provider](/docs/providers/index.html). +The configuration is dependent on the type. Consult the [provider's documentation](https://registry.terraform.io/browse/providers) for details. +in each provider's documentation. The arguments `alias` and `version`, if present, are special arguments handled by Terraform Core for their respective features described above. All diff --git a/website/docs/configuration-0-11/resources.html.md b/website/docs/configuration-0-11/resources.html.md index 8b31603f4..cbf5cd699 100644 --- a/website/docs/configuration-0-11/resources.html.md +++ b/website/docs/configuration-0-11/resources.html.md @@ -41,9 +41,9 @@ parameter) and `NAME` (second parameter). The combination of the type and name must be unique. Within the block (the `{ }`) is configuration for the resource. The -configuration is dependent on the type, and is documented for each -resource type in the -[providers section](/docs/providers/index.html). +configuration is dependent on the type. Consult the [provider's documentation](https://registry.terraform.io/browse/providers) for details. + +details. ### Meta-parameters diff --git a/website/docs/internals/json-format.html.md b/website/docs/internals/json-format.html.md index d2c9cff69..9a3efeff5 100644 --- a/website/docs/internals/json-format.html.md +++ b/website/docs/internals/json-format.html.md @@ -451,6 +451,8 @@ Each unevaluated expression in the configuration is represented with an ` **Note:** Expressions in `dynamic` blocks are not included in the configuration representation. + ### Block Expressions Representation In some cases, it is the entire content of a block (possibly after certain special arguments have already been handled and removed) that must be represented. For that, we have an `` structure: diff --git a/website/docs/internals/machine-readable-ui.html.md b/website/docs/internals/machine-readable-ui.html.md index 9f234fb24..742e4e9dc 100644 --- a/website/docs/internals/machine-readable-ui.html.md +++ b/website/docs/internals/machine-readable-ui.html.md @@ -54,6 +54,7 @@ The following message types are supported: ### Operation Results +- `resource_drift`: describes a detected change to a single resource made outside of Terraform - `planned_change`: describes a planned change to a single resource - `change_summary`: summary of all planned or applied changes - `outputs`: list of all root module outputs @@ -85,6 +86,39 @@ A machine-readable UI command output will always begin with a `version` message. } ``` +## Resource Drift + +If drift is detected during planning, Terraform will emit a `resource_drift` message for each resource which has changed outside of Terraform. This message has an embedded `change` object with the following keys: + +- `resource`: object describing the address of the resource to be changed; see [resource object](#resource-object) below for details +- `action`: the action planned to be taken for the resource. Values: `update`, `delete`. + +This message does not include details about the exact changes which caused the change to be planned. That information is available in [the JSON plan output](./json-format.html). + +### Example + +```json +{ + "@level": "info", + "@message": "random_pet.animal: Drift detected (update)", + "@module": "terraform.ui", + "@timestamp": "2021-05-25T13:32:41.705503-04:00", + "change": { + "resource": { + "addr": "random_pet.animal", + "module": "", + "resource": "random_pet.animal", + "implied_provider": "random", + "resource_type": "random_pet", + "resource_name": "animal", + "resource_key": null + }, + "action": "update" + }, + "type": "resource_drift" +} +``` + ## Planned Change At the end of a plan or before an apply, Terraform will emit a `planned_change` message for each resource which has changes to apply. This message has an embedded `change` object with the following keys: diff --git a/website/docs/language/data-sources/index.html.md b/website/docs/language/data-sources/index.html.md index 1666efbd2..0da043a1a 100644 --- a/website/docs/language/data-sources/index.html.md +++ b/website/docs/language/data-sources/index.html.md @@ -172,8 +172,10 @@ block label) and _name_ (second block label). The combination of the type and name must be unique. Within the block (the `{ }`) is configuration for the data instance. The -configuration is dependent on the type, and is documented for each -data source in the [providers section](/docs/providers/index.html). +configuration is dependent on the type; as with +[resources](/docs/language/resources/index.html), each provider on the +[Terraform Registry](https://registry.terraform.io/browse/providers) has its own +documentation for configuring and using the data types it provides. Each data instance will export one or more attributes, which can be used in other resources as reference expressions of the form diff --git a/website/docs/language/functions/fileset.html.md b/website/docs/language/functions/fileset.html.md index 147ac08ba..820f42d43 100644 --- a/website/docs/language/functions/fileset.html.md +++ b/website/docs/language/functions/fileset.html.md @@ -26,6 +26,10 @@ Supported pattern matches: - `[CLASS]` - matches any single non-separator character inside a class of characters (see below) - `[^CLASS]` - matches any single non-separator character outside a class of characters (see below) +Note that the doublestar (`**`) must appear as a path component by itself. A +pattern such as /path** is invalid and will be treated the same as /path*, but +/path*/** should achieve the desired result. + Character classes support the following: - `[abc]` - matches any single character within the set diff --git a/website/docs/language/providers/index.html.md b/website/docs/language/providers/index.html.md index 5b7117a66..890889fab 100644 --- a/website/docs/language/providers/index.html.md +++ b/website/docs/language/providers/index.html.md @@ -37,6 +37,21 @@ The [Terraform Registry](https://registry.terraform.io/browse/providers) is the main directory of publicly available Terraform providers, and hosts providers for most major infrastructure platforms. +## Provider Documentation + +Each provider has its own documentation, describing its resource +types and their arguments. + +The [Terraform Registry](https://registry.terraform.io/browse/providers) +includes documentation for a wide range of providers developed by HashiCorp, third-party vendors, and our Terraform community. Use the +"Documentation" link in a provider's header to browse its documentation. + +Provider documentation in the Registry is versioned; you can use the version +menu in the header to change which version you're viewing. + +For details about writing, generating, and previewing provider documentation, +see the [provider publishing documentation](/docs/registry/providers/docs.html). + ## How to Use Providers To use resources from a given provider, you need to include some information diff --git a/website/docs/providers/index.html.markdown b/website/docs/providers/index.html.markdown deleted file mode 100644 index 07762206a..000000000 --- a/website/docs/providers/index.html.markdown +++ /dev/null @@ -1,15 +0,0 @@ ---- -layout: "language" -page_title: "Provider Documentation" -sidebar_current: "docs-providers" -description: "Find provider documentation on the Terraform Registry and information about creating documentation for your provider." ---- - -# Provider Documentation - -## Find Provider Docs -Every Terraform provider has its own documentation on the [Terraform Registry](https://registry.terraform.io/browse/providers) that describes its resource types and their arguments. - -## Write Provider Docs -Learn more about [writing, generating, and rendering provider documentation](/docs/registry/providers/docs.html). - diff --git a/website/layouts/language.erb b/website/layouts/language.erb index e129c088f..a5610bbd9 100644 --- a/website/layouts/language.erb +++ b/website/layouts/language.erb @@ -167,11 +167,6 @@
  • Dependency Lock File
  • - -
  • - Provider Documentation - -