core: evaluate locals and return them for interpolation

We stash the locals in the module state in a map that is ignored for JSON
serialization. We don't include locals in the persisted state because they
can be trivially recomputed and this allows us to assume that they will
pass through verbatim, without any normalization or other transforms
caused by the JSON serialization.

From a user standpoint a local is just a named alias for an expression,
so it's desirable that the result passes through here in as raw a form
as possible, so it behaves as closely as possible to simply using the
given expression directly.
This commit is contained in:
Martin Atkins 2017-07-01 11:08:15 -07:00
parent 5b66953d1d
commit 3a30bfe845
11 changed files with 295 additions and 4 deletions

View File

@ -8761,3 +8761,40 @@ module.child.subchild:
type = aws_instance
`)
}
func TestContext2Apply_localVal(t *testing.T) {
m := testModule(t, "apply-local-val")
ctx := testContext2(t, &ContextOpts{
Module: m,
ProviderResolver: ResourceProviderResolverFixed(
map[string]ResourceProviderFactory{},
),
})
if _, err := ctx.Plan(); err != nil {
t.Fatalf("error during plan: %s", err)
}
state, err := ctx.Apply()
if err != nil {
t.Fatalf("error during apply: %s", err)
}
got := strings.TrimSpace(state.String())
want := strings.TrimSpace(`
<no state>
Outputs:
result_1 = hello
result_3 = hello world
module.child:
<no state>
Outputs:
result = hello
`)
if got != want {
t.Fatalf("wrong final state\ngot:\n%s\nwant:\n%s", got, want)
}
}

58
terraform/eval_local.go Normal file
View File

@ -0,0 +1,58 @@
package terraform
import (
"fmt"
"github.com/hashicorp/terraform/config"
)
// EvalLocal is an EvalNode implementation that evaluates the
// expression for a local value and writes it into a transient part of
// the state.
type EvalLocal struct {
Name string
Value *config.RawConfig
}
func (n *EvalLocal) Eval(ctx EvalContext) (interface{}, error) {
cfg, err := ctx.Interpolate(n.Value, nil)
if err != nil {
return nil, fmt.Errorf("local.%s: %s", n.Name, err)
}
state, lock := ctx.State()
if state == nil {
return nil, fmt.Errorf("cannot write local value to nil state")
}
// Get a write lock so we can access the state
lock.Lock()
defer lock.Unlock()
// Look for the module state. If we don't have one, create it.
mod := state.ModuleByPath(ctx.Path())
if mod == nil {
mod = state.AddModule(ctx.Path())
}
// Get the value from the config
var valueRaw interface{} = config.UnknownVariableValue
if cfg != nil {
var ok bool
valueRaw, ok = cfg.Get("value")
if !ok {
valueRaw = ""
}
if cfg.IsComputed("value") {
valueRaw = config.UnknownVariableValue
}
}
if mod.Locals == nil {
// initialize
mod.Locals = map[string]interface{}{}
}
mod.Locals[n.Name] = valueRaw
return nil, nil
}

View File

@ -0,0 +1,80 @@
package terraform
import (
"reflect"
"sync"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/hashicorp/terraform/config"
)
func TestEvalLocal_impl(t *testing.T) {
var _ EvalNode = new(EvalLocal)
}
func TestEvalLocal(t *testing.T) {
tests := []struct {
Value string
Want interface{}
Err bool
}{
{
"hello!",
"hello!",
false,
},
{
"",
"",
false,
},
}
for _, test := range tests {
t.Run(test.Value, func(t *testing.T) {
rawConfig, err := config.NewRawConfig(map[string]interface{}{
"value": test.Value,
})
if err != nil {
t.Fatal(err)
}
n := &EvalLocal{
Name: "foo",
Value: rawConfig,
}
ctx := &MockEvalContext{
StateState: &State{},
StateLock: &sync.RWMutex{},
InterpolateConfigResult: testResourceConfig(t, map[string]interface{}{
"value": test.Want,
}),
}
_, err = n.Eval(ctx)
if (err != nil) != test.Err {
if err != nil {
t.Errorf("unexpected error: %s", err)
} else {
t.Errorf("successful Eval; want error")
}
}
ms := ctx.StateState.ModuleByPath([]string{})
gotLocals := ms.Locals
wantLocals := map[string]interface{}{
"foo": test.Want,
}
if !reflect.DeepEqual(gotLocals, wantLocals) {
t.Errorf(
"wrong locals after Eval\ngot: %swant: %s",
spew.Sdump(gotLocals), spew.Sdump(wantLocals),
)
}
})
}
}

View File

@ -108,6 +108,9 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer {
// Add root variables
&RootVariableTransformer{Module: b.Module},
// Add the local values
&LocalTransformer{Module: b.Module},
// Add the outputs
&OutputTransformer{Module: b.Module},

View File

@ -90,6 +90,8 @@ func (i *Interpolater) Values(
err = i.valueSimpleVar(scope, n, v, result)
case *config.TerraformVariable:
err = i.valueTerraformVar(scope, n, v, result)
case *config.LocalVariable:
err = i.valueLocalVar(scope, n, v, result)
case *config.UserVariable:
err = i.valueUserVar(scope, n, v, result)
default:
@ -335,6 +337,59 @@ func (i *Interpolater) valueTerraformVar(
return nil
}
func (i *Interpolater) valueLocalVar(
scope *InterpolationScope,
n string,
v *config.LocalVariable,
result map[string]ast.Variable,
) error {
i.StateLock.RLock()
defer i.StateLock.RUnlock()
modTree := i.Module
if len(scope.Path) > 1 {
modTree = i.Module.Child(scope.Path[1:])
}
// Get the resource from the configuration so we can verify
// that the resource is in the configuration and so we can access
// the configuration if we need to.
var cl *config.Local
for _, l := range modTree.Config().Locals {
if l.Name == v.Name {
cl = l
break
}
}
if cl == nil {
return fmt.Errorf("%s: no local value of this name has been declared", n)
}
// Get the relevant module
module := i.State.ModuleByPath(scope.Path)
if module == nil {
result[n] = unknownVariable()
return nil
}
rawV, exists := module.Locals[v.Name]
if !exists {
result[n] = unknownVariable()
return nil
}
varV, err := hil.InterfaceToVariable(rawV)
if err != nil {
// Should never happen, since interpolation should always produce
// something we can feed back in to interpolation.
return fmt.Errorf("%s: %s", n, err)
}
result[n] = varV
return nil
}
func (i *Interpolater) valueUserVar(
scope *InterpolationScope,
n string,

View File

@ -100,6 +100,35 @@ func TestInterpolater_moduleVariable(t *testing.T) {
})
}
func TestInterpolater_localVal(t *testing.T) {
lock := new(sync.RWMutex)
state := &State{
Modules: []*ModuleState{
&ModuleState{
Path: rootModulePath,
Locals: map[string]interface{}{
"foo": "hello!",
},
},
},
}
i := &Interpolater{
Module: testModule(t, "interpolate-local"),
State: state,
StateLock: lock,
}
scope := &InterpolationScope{
Path: rootModulePath,
}
testInterpolate(t, i, scope, "local.foo", ast.Variable{
Value: "hello!",
Type: ast.TypeString,
})
}
func TestInterpolater_pathCwd(t *testing.T) {
i := &Interpolater{}
scope := &InterpolationScope{}

View File

@ -70,10 +70,10 @@ func (n *NodeLocal) EvalTree() EvalNode {
},
Node: &EvalSequence{
Nodes: []EvalNode{
/*&EvalWriteLocal{
Name: n.Config.Name,
Value: n.Config.RawConfig,
},*/
&EvalLocal{
Name: n.Config.Name,
Value: n.Config.RawConfig,
},
},
},
}

View File

@ -977,6 +977,10 @@ type ModuleState struct {
// always disjoint, so the path represents amodule tree
Path []string `json:"path"`
// Locals are kept only transiently in-memory, because we can always
// re-compute them.
Locals map[string]interface{} `json:"-"`
// Outputs declared by the module and maintained for each module
// even though only the root module technically needs to be kept.
// This allows operators to inspect values at the boundaries.

View File

@ -0,0 +1,4 @@
output "result" {
value = "hello"
}

View File

@ -0,0 +1,18 @@
module "child" {
source = "./child"
}
locals {
result_1 = "${module.child.result}"
result_2 = "${local.result_1}"
result_3 = "${local.result_2} world"
}
output "result_1" {
value = "${local.result_1}"
}
output "result_3" {
value = "${local.result_3}"
}

View File

@ -0,0 +1,3 @@
locals {
foo = "..."
}