From 5825d1f5811fd603b5767a122d080f7631636b16 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Sat, 1 Jul 2017 09:12:31 -0700 Subject: [PATCH 1/5] config: parsing of "locals" blocks in configuration --- config/config.go | 32 ++++++++++++++- config/config_string.go | 36 +++++++++++++++++ config/loader_hcl.go | 63 ++++++++++++++++++++++++++++++ config/loader_test.go | 26 ++++++++++-- config/test-fixtures/basic.tf | 11 ++++++ config/test-fixtures/basic.tf.json | 8 ++++ 6 files changed, 172 insertions(+), 4 deletions(-) diff --git a/config/config.go b/config/config.go index 3f756dcf4d85..9549d0a50745 100644 --- a/config/config.go +++ b/config/config.go @@ -34,6 +34,7 @@ type Config struct { ProviderConfigs []*ProviderConfig Resources []*Resource Variables []*Variable + Locals []*Local Outputs []*Output // The fields below can be filled in by loaders for validation @@ -147,7 +148,7 @@ func (p *Provisioner) Copy() *Provisioner { } } -// Variable is a variable defined within the configuration. +// Variable is a module argument defined within the configuration. type Variable struct { Name string DeclaredType string `mapstructure:"type"` @@ -155,6 +156,12 @@ type Variable struct { Description string } +// Local is a local value defined within the configuration. +type Local struct { + Name string + RawConfig *RawConfig +} + // Output is an output defined within the configuration. An output is // resulting data that is highlighted by Terraform when finished. An // output marked Sensitive will be output in a masked form following @@ -680,6 +687,29 @@ func (c *Config) Validate() error { } } + // Check that all locals are valid + { + found := make(map[string]struct{}) + for _, l := range c.Locals { + if _, ok := found[l.Name]; ok { + errs = append(errs, fmt.Errorf( + "%s: duplicate local. local value names must be unique", + l.Name, + )) + continue + } + found[l.Name] = struct{}{} + + for _, v := range l.RawConfig.Variables { + if _, ok := v.(*CountVariable); ok { + errs = append(errs, fmt.Errorf( + "local %s: count variables are only valid within resources", l.Name, + )) + } + } + } + } + // Check that all outputs are valid { found := make(map[string]struct{}) diff --git a/config/config_string.go b/config/config_string.go index 0b3abbcd53a8..b57a09fb5834 100644 --- a/config/config_string.go +++ b/config/config_string.go @@ -148,6 +148,42 @@ func outputsStr(os []*Output) string { return strings.TrimSpace(result) } +func localsStr(ls []*Local) string { + ns := make([]string, 0, len(ls)) + m := make(map[string]*Local) + for _, l := range ls { + ns = append(ns, l.Name) + m[l.Name] = l + } + sort.Strings(ns) + + result := "" + for _, n := range ns { + l := m[n] + + result += fmt.Sprintf("%s\n", n) + + if len(l.RawConfig.Variables) > 0 { + result += fmt.Sprintf(" vars\n") + for _, rawV := range l.RawConfig.Variables { + kind := "unknown" + str := rawV.FullKey() + + switch rawV.(type) { + case *ResourceVariable: + kind = "resource" + case *UserVariable: + kind = "user" + } + + result += fmt.Sprintf(" %s: %s\n", kind, str) + } + } + } + + return strings.TrimSpace(result) +} + // This helper turns a provider configs field into a deterministic // string value for comparison in tests. func providerConfigsStr(pcs []*ProviderConfig) string { diff --git a/config/loader_hcl.go b/config/loader_hcl.go index bcd4d43a4da6..311083744586 100644 --- a/config/loader_hcl.go +++ b/config/loader_hcl.go @@ -37,6 +37,7 @@ func (t *hclConfigurable) Config() (*Config, error) { validKeys := map[string]struct{}{ "atlas": struct{}{}, "data": struct{}{}, + "locals": struct{}{}, "module": struct{}{}, "output": struct{}{}, "provider": struct{}{}, @@ -72,6 +73,15 @@ func (t *hclConfigurable) Config() (*Config, error) { } } + // Build local values + if locals := list.Filter("locals"); len(locals.Items) > 0 { + var err error + config.Locals, err = loadLocalsHcl(locals) + if err != nil { + return nil, err + } + } + // Get Atlas configuration if atlas := list.Filter("atlas"); len(atlas.Items) > 0 { var err error @@ -408,6 +418,59 @@ func loadModulesHcl(list *ast.ObjectList) ([]*Module, error) { return result, nil } +// loadLocalsHcl recurses into the given HCL object turns it into +// a list of locals. +func loadLocalsHcl(list *ast.ObjectList) ([]*Local, error) { + + result := make([]*Local, 0, len(list.Items)) + + for _, block := range list.Items { + if len(block.Keys) > 0 { + return nil, fmt.Errorf( + "locals block at %s should not have label %q", + block.Pos(), block.Keys[0].Token.Value(), + ) + } + + blockObj, ok := block.Val.(*ast.ObjectType) + if !ok { + return nil, fmt.Errorf("locals value at %s should be a block", block.Val.Pos()) + } + + // blockObj now contains directly our local decls + for _, item := range blockObj.List.Items { + if len(item.Keys) != 1 { + return nil, fmt.Errorf("local declaration at %s may not be a block", item.Val.Pos()) + } + + // By the time we get here there can only be one item left, but + // we'll decode into a map anyway because it's a convenient way + // to extract both the key and the value robustly. + kv := map[string]interface{}{} + hcl.DecodeObject(&kv, item) + for k, v := range kv { + rawConfig, err := NewRawConfig(map[string]interface{}{ + "value": v, + }) + + if err != nil { + return nil, fmt.Errorf( + "error parsing local value %q at %s: %s", + k, item.Val.Pos(), err, + ) + } + + result = append(result, &Local{ + Name: k, + RawConfig: rawConfig, + }) + } + } + } + + return result, nil +} + // LoadOutputsHcl recurses into the given HCL object and turns // it into a mapping of outputs. func loadOutputsHcl(list *ast.ObjectList) ([]*Output, error) { diff --git a/config/loader_test.go b/config/loader_test.go index a3aeb7321ebd..30a3f24446be 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -180,17 +180,17 @@ func TestLoadFileBasic(t *testing.T) { } if c.Dir != "" { - t.Fatalf("bad: %#v", c.Dir) + t.Fatalf("wrong dir %#v; want %#v", c.Dir, "") } expectedTF := &Terraform{RequiredVersion: "foo"} if !reflect.DeepEqual(c.Terraform, expectedTF) { - t.Fatalf("bad: %#v", c.Terraform) + t.Fatalf("wrong terraform block %#v; want %#v", c.Terraform, expectedTF) } expectedAtlas := &AtlasConfig{Name: "mitchellh/foo"} if !reflect.DeepEqual(c.Atlas, expectedAtlas) { - t.Fatalf("bad: %#v", c.Atlas) + t.Fatalf("wrong atlas config %#v; want %#v", c.Atlas, expectedAtlas) } actual := variablesStr(c.Variables) @@ -208,6 +208,10 @@ func TestLoadFileBasic(t *testing.T) { t.Fatalf("bad:\n%s", actual) } + if actual, want := localsStr(c.Locals), strings.TrimSpace(basicLocalsStr); actual != want { + t.Fatalf("wrong locals:\n%s\nwant:\n%s", actual, want) + } + actual = outputsStr(c.Outputs) if actual != strings.TrimSpace(basicOutputsStr) { t.Fatalf("bad:\n%s", actual) @@ -288,6 +292,10 @@ func TestLoadFileBasic_json(t *testing.T) { t.Fatalf("bad:\n%s", actual) } + if actual, want := localsStr(c.Locals), strings.TrimSpace(basicLocalsStr); actual != want { + t.Fatalf("wrong locals:\n%s\nwant:\n%s", actual, want) + } + actual = outputsStr(c.Outputs) if actual != strings.TrimSpace(basicOutputsStr) { t.Fatalf("bad:\n%s", actual) @@ -1055,6 +1063,18 @@ web_ip resource: aws_instance.web.private_ip ` +const basicLocalsStr = ` +literal +literal_list +literal_map +security_group_ids + vars + resource: aws_security_group.firewall.*.id +web_ip + vars + resource: aws_instance.web.private_ip +` + const basicProvidersStr = ` aws access_key diff --git a/config/test-fixtures/basic.tf b/config/test-fixtures/basic.tf index aa5a5c6edc77..f64d6a8d5182 100644 --- a/config/test-fixtures/basic.tf +++ b/config/test-fixtures/basic.tf @@ -58,6 +58,17 @@ resource aws_instance "web" { } } +locals { + security_group_ids = "${aws_security_group.firewall.*.id}" + web_ip = "${aws_instance.web.private_ip}" +} + +locals { + literal = 2 + literal_list = ["foo"] + literal_map = {"foo" = "bar"} +} + resource "aws_instance" "db" { security_groups = "${aws_security_group.firewall.*.id}" VPC = "foo" diff --git a/config/test-fixtures/basic.tf.json b/config/test-fixtures/basic.tf.json index be86d5de5a5d..6541beae6d86 100644 --- a/config/test-fixtures/basic.tf.json +++ b/config/test-fixtures/basic.tf.json @@ -79,6 +79,14 @@ } }, + "locals": { + "security_group_ids": "${aws_security_group.firewall.*.id}", + "web_ip": "${aws_instance.web.private_ip}", + "literal": 2, + "literal_list": ["foo"], + "literal_map": {"foo": "bar"} + }, + "output": { "web_ip": { "value": "${aws_instance.web.private_ip}" From a052e5328a749c83e72f23a0a128d710c4a819ff Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Sat, 1 Jul 2017 09:23:42 -0700 Subject: [PATCH 2/5] config: parsing of local.foo variables for interpolation --- config/interpolate.go | 27 +++++++++++++++++++++++++ config/interpolate_test.go | 41 +++++++++++++++++++++++++++----------- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/config/interpolate.go b/config/interpolate.go index bbb355541858..b18f2d3a110b 100644 --- a/config/interpolate.go +++ b/config/interpolate.go @@ -101,6 +101,12 @@ type UserVariable struct { key string } +// A LocalVariable is a variable that references a local value defined within +// the current module, via a "locals" block. This looks like "${local.foo}". +type LocalVariable struct { + Name string +} + func NewInterpolatedVariable(v string) (InterpolatedVariable, error) { if strings.HasPrefix(v, "count.") { return NewCountVariable(v) @@ -112,6 +118,8 @@ func NewInterpolatedVariable(v string) (InterpolatedVariable, error) { return NewTerraformVariable(v) } else if strings.HasPrefix(v, "var.") { return NewUserVariable(v) + } else if strings.HasPrefix(v, "local.") { + return NewLocalVariable(v) } else if strings.HasPrefix(v, "module.") { return NewModuleVariable(v) } else if !strings.ContainsRune(v, '.') { @@ -331,6 +339,25 @@ func (v *UserVariable) GoString() string { return fmt.Sprintf("*%#v", *v) } +func NewLocalVariable(key string) (*LocalVariable, error) { + name := key[len("local."):] + if idx := strings.Index(name, "."); idx > -1 { + return nil, fmt.Errorf("Can't use dot (.) attribute access in local.%s; use square bracket indexing", name) + } + + return &LocalVariable{ + Name: name, + }, nil +} + +func (v *LocalVariable) FullKey() string { + return fmt.Sprintf("local.%s", v.Name) +} + +func (v *LocalVariable) GoString() string { + return fmt.Sprintf("*%#v", *v) +} + // DetectVariables takes an AST root and returns all the interpolated // variables that are detected in the AST tree. func DetectVariables(root ast.Node) ([]InterpolatedVariable, error) { diff --git a/config/interpolate_test.go b/config/interpolate_test.go index 0cdb18b69d8f..db2acf4d2be7 100644 --- a/config/interpolate_test.go +++ b/config/interpolate_test.go @@ -9,10 +9,10 @@ import ( ) func TestNewInterpolatedVariable(t *testing.T) { - cases := []struct { - Input string - Result InterpolatedVariable - Error bool + tests := []struct { + Input string + Want InterpolatedVariable + Error bool }{ { "var.foo", @@ -22,6 +22,18 @@ func TestNewInterpolatedVariable(t *testing.T) { }, false, }, + { + "local.foo", + &LocalVariable{ + Name: "foo", + }, + false, + }, + { + "local.foo.nope", + nil, + true, + }, { "module.foo.bar", &ModuleVariable{ @@ -73,14 +85,19 @@ func TestNewInterpolatedVariable(t *testing.T) { }, } - for i, tc := range cases { - actual, err := NewInterpolatedVariable(tc.Input) - if err != nil != tc.Error { - t.Fatalf("%d. Error: %s", i, err) - } - if !reflect.DeepEqual(actual, tc.Result) { - t.Fatalf("%d bad: %#v", i, actual) - } + for i, test := range tests { + t.Run(test.Input, func(t *testing.T) { + got, err := NewInterpolatedVariable(test.Input) + if err != nil != test.Error { + t.Errorf("%d. Error: %s", i, err) + } + if !test.Error && !reflect.DeepEqual(got, test.Want) { + t.Errorf( + "wrong result\ninput: %s\ngot: %#v\nwant: %#v", + test.Input, got, test.Want, + ) + } + }) } } From 6cdf9f74414c1d6a7e63859dc9420b717c2c6b94 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Sat, 1 Jul 2017 09:48:37 -0700 Subject: [PATCH 3/5] core: graph nodes and edges for local values A local value is similar to an output in that it exists only within state and just always evaluates its value as best it can with the current state. Therefore it has a single graph node type for all walks, which will deal with that evaluation operation. --- terraform/graph_builder_plan.go | 3 + terraform/graph_builder_plan_test.go | 6 ++ terraform/graph_builder_refresh.go | 3 + terraform/node_local.go | 80 +++++++++++++++++++ .../graph-builder-plan-basic/main.tf | 8 ++ terraform/transform_local.go | 40 ++++++++++ terraform/transform_reference.go | 2 + 7 files changed, 142 insertions(+) create mode 100644 terraform/node_local.go create mode 100644 terraform/transform_local.go diff --git a/terraform/graph_builder_plan.go b/terraform/graph_builder_plan.go index 9c7e4c1dbf22..9d05d4a43e20 100644 --- a/terraform/graph_builder_plan.go +++ b/terraform/graph_builder_plan.go @@ -74,6 +74,9 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { Module: b.Module, }, + // Add the local values + &LocalTransformer{Module: b.Module}, + // Add the outputs &OutputTransformer{Module: b.Module}, diff --git a/terraform/graph_builder_plan_test.go b/terraform/graph_builder_plan_test.go index 25578ebaf58a..321ddbe3fe13 100644 --- a/terraform/graph_builder_plan_test.go +++ b/terraform/graph_builder_plan_test.go @@ -61,16 +61,22 @@ aws_load_balancer.weblb provider.aws aws_security_group.firewall provider.aws +local.instance_id + aws_instance.web meta.count-boundary (count boundary fixup) aws_instance.web aws_load_balancer.weblb aws_security_group.firewall + local.instance_id openstack_floating_ip.random + output.instance_id provider.aws provider.openstack var.foo openstack_floating_ip.random provider.openstack +output.instance_id + local.instance_id provider.aws openstack_floating_ip.random provider.aws (close) diff --git a/terraform/graph_builder_refresh.go b/terraform/graph_builder_refresh.go index 3d3e968fae9e..beb5a4a4459d 100644 --- a/terraform/graph_builder_refresh.go +++ b/terraform/graph_builder_refresh.go @@ -133,6 +133,9 @@ func (b *RefreshGraphBuilder) Steps() []GraphTransformer { &ParentProviderTransformer{}, &AttachProviderConfigTransformer{Module: b.Module}, + // Add the local values + &LocalTransformer{Module: b.Module}, + // Add the outputs &OutputTransformer{Module: b.Module}, diff --git a/terraform/node_local.go b/terraform/node_local.go new file mode 100644 index 000000000000..e6ae140a6374 --- /dev/null +++ b/terraform/node_local.go @@ -0,0 +1,80 @@ +package terraform + +import ( + "fmt" + "strings" + + "github.com/hashicorp/terraform/config" +) + +// NodeLocal represents a named local value in a particular module. +// +// Local value nodes only have one operation, common to all walk types: +// evaluate the result and place it in state. +type NodeLocal struct { + PathValue []string + Config *config.Local +} + +func (n *NodeLocal) Name() string { + result := fmt.Sprintf("local.%s", n.Config.Name) + if len(n.PathValue) > 1 { + result = fmt.Sprintf("%s.%s", modulePrefixStr(n.PathValue), result) + } + + return result +} + +// GraphNodeSubPath +func (n *NodeLocal) Path() []string { + return n.PathValue +} + +// RemovableIfNotTargeted +func (n *NodeLocal) RemoveIfNotTargeted() bool { + return true +} + +// GraphNodeReferenceable +func (n *NodeLocal) ReferenceableName() []string { + name := fmt.Sprintf("local.%s", n.Config.Name) + return []string{name} +} + +// GraphNodeReferencer +func (n *NodeLocal) References() []string { + var result []string + result = append(result, ReferencesFromConfig(n.Config.RawConfig)...) + for _, v := range result { + split := strings.Split(v, "/") + for i, s := range split { + split[i] = s + ".destroy" + } + + result = append(result, strings.Join(split, "/")) + } + + return result +} + +// GraphNodeEvalable +func (n *NodeLocal) EvalTree() EvalNode { + return &EvalOpFilter{ + Ops: []walkOperation{ + walkInput, + walkValidate, + walkRefresh, + walkPlan, + walkApply, + walkDestroy, + }, + Node: &EvalSequence{ + Nodes: []EvalNode{ + /*&EvalWriteLocal{ + Name: n.Config.Name, + Value: n.Config.RawConfig, + },*/ + }, + }, + } +} diff --git a/terraform/test-fixtures/graph-builder-plan-basic/main.tf b/terraform/test-fixtures/graph-builder-plan-basic/main.tf index a40802cc98eb..47cf9590b41b 100644 --- a/terraform/test-fixtures/graph-builder-plan-basic/main.tf +++ b/terraform/test-fixtures/graph-builder-plan-basic/main.tf @@ -22,3 +22,11 @@ resource "aws_instance" "web" { resource "aws_load_balancer" "weblb" { members = "${aws_instance.web.id_list}" } + +locals { + instance_id = "${aws_instance.web.id}" +} + +output "instance_id" { + value = "${local.instance_id}" +} diff --git a/terraform/transform_local.go b/terraform/transform_local.go new file mode 100644 index 000000000000..95ecfc0a440a --- /dev/null +++ b/terraform/transform_local.go @@ -0,0 +1,40 @@ +package terraform + +import ( + "github.com/hashicorp/terraform/config/module" +) + +// LocalTransformer is a GraphTransformer that adds all the local values +// from the configuration to the graph. +type LocalTransformer struct { + Module *module.Tree +} + +func (t *LocalTransformer) Transform(g *Graph) error { + return t.transformModule(g, t.Module) +} + +func (t *LocalTransformer) transformModule(g *Graph, m *module.Tree) error { + if m == nil { + // Can't have any locals if there's no config + return nil + } + + for _, local := range m.Config().Locals { + node := &NodeLocal{ + PathValue: normalizeModulePath(m.Path()), + Config: local, + } + + g.Add(node) + } + + // Also populate locals for child modules + for _, c := range m.Children() { + if err := t.transformModule(g, c); err != nil { + return err + } + } + + return nil +} diff --git a/terraform/transform_reference.go b/terraform/transform_reference.go index c5452354d4b6..ce55e1d77564 100644 --- a/terraform/transform_reference.go +++ b/terraform/transform_reference.go @@ -296,6 +296,8 @@ func ReferenceFromInterpolatedVar(v config.InterpolatedVariable) []string { return []string{fmt.Sprintf("%s.%d/%s.N", id, idx, id)} case *config.UserVariable: return []string{fmt.Sprintf("var.%s", v.Name)} + case *config.LocalVariable: + return []string{fmt.Sprintf("local.%s", v.Name)} default: return nil } From 2ac7afd796f02c8eb17c683f4f05d7b2b7445983 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Sat, 1 Jul 2017 11:08:15 -0700 Subject: [PATCH 4/5] 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. --- terraform/context_apply_test.go | 37 +++++++++ terraform/eval_local.go | 58 ++++++++++++++ terraform/eval_local_test.go | 80 +++++++++++++++++++ terraform/graph_builder_apply.go | 3 + terraform/interpolate.go | 55 +++++++++++++ terraform/interpolate_test.go | 29 +++++++ terraform/node_local.go | 8 +- terraform/state.go | 4 + .../apply-local-val/child/child.tf | 4 + .../test-fixtures/apply-local-val/main.tf | 18 +++++ .../test-fixtures/interpolate-local/main.tf | 3 + 11 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 terraform/eval_local.go create mode 100644 terraform/eval_local_test.go create mode 100644 terraform/test-fixtures/apply-local-val/child/child.tf create mode 100644 terraform/test-fixtures/apply-local-val/main.tf create mode 100644 terraform/test-fixtures/interpolate-local/main.tf diff --git a/terraform/context_apply_test.go b/terraform/context_apply_test.go index 4ee704e4512d..48d8a9f490b8 100644 --- a/terraform/context_apply_test.go +++ b/terraform/context_apply_test.go @@ -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(` + +Outputs: + +result_1 = hello +result_3 = hello world + +module.child: + + Outputs: + + result = hello +`) + if got != want { + t.Fatalf("wrong final state\ngot:\n%s\nwant:\n%s", got, want) + } +} diff --git a/terraform/eval_local.go b/terraform/eval_local.go new file mode 100644 index 000000000000..1b63bf4f4f5e --- /dev/null +++ b/terraform/eval_local.go @@ -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 +} diff --git a/terraform/eval_local_test.go b/terraform/eval_local_test.go new file mode 100644 index 000000000000..47803be5d9bb --- /dev/null +++ b/terraform/eval_local_test.go @@ -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), + ) + } + }) + } + +} diff --git a/terraform/graph_builder_apply.go b/terraform/graph_builder_apply.go index 38a90f2775f4..4d7832772e2a 100644 --- a/terraform/graph_builder_apply.go +++ b/terraform/graph_builder_apply.go @@ -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}, diff --git a/terraform/interpolate.go b/terraform/interpolate.go index 22ddce6c836b..52ce1e886aa0 100644 --- a/terraform/interpolate.go +++ b/terraform/interpolate.go @@ -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, diff --git a/terraform/interpolate_test.go b/terraform/interpolate_test.go index 46c0cbf8cd27..c497b43b8e01 100644 --- a/terraform/interpolate_test.go +++ b/terraform/interpolate_test.go @@ -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{} diff --git a/terraform/node_local.go b/terraform/node_local.go index e6ae140a6374..da1564e39045 100644 --- a/terraform/node_local.go +++ b/terraform/node_local.go @@ -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, + }, }, }, } diff --git a/terraform/state.go b/terraform/state.go index 0c46194d6fb5..66097cf4cbd7 100644 --- a/terraform/state.go +++ b/terraform/state.go @@ -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. diff --git a/terraform/test-fixtures/apply-local-val/child/child.tf b/terraform/test-fixtures/apply-local-val/child/child.tf new file mode 100644 index 000000000000..f7febc42f656 --- /dev/null +++ b/terraform/test-fixtures/apply-local-val/child/child.tf @@ -0,0 +1,4 @@ + +output "result" { + value = "hello" +} diff --git a/terraform/test-fixtures/apply-local-val/main.tf b/terraform/test-fixtures/apply-local-val/main.tf new file mode 100644 index 000000000000..67e6053fefd8 --- /dev/null +++ b/terraform/test-fixtures/apply-local-val/main.tf @@ -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}" +} diff --git a/terraform/test-fixtures/interpolate-local/main.tf b/terraform/test-fixtures/interpolate-local/main.tf new file mode 100644 index 000000000000..699667a14328 --- /dev/null +++ b/terraform/test-fixtures/interpolate-local/main.tf @@ -0,0 +1,3 @@ +locals { + foo = "..." +} From 0e50334311dc8c97ae65556f93029d313d68a099 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 17 Aug 2017 10:21:59 -0700 Subject: [PATCH 5/5] website: documentation for local values --- website/docs/configuration/locals.html.md | 59 +++++++++++++++++++++++ website/layouts/docs.erb | 4 ++ 2 files changed, 63 insertions(+) create mode 100644 website/docs/configuration/locals.html.md diff --git a/website/docs/configuration/locals.html.md b/website/docs/configuration/locals.html.md new file mode 100644 index 000000000000..6067c5486b22 --- /dev/null +++ b/website/docs/configuration/locals.html.md @@ -0,0 +1,59 @@ +--- +layout: "docs" +page_title: "Configuring Local Values" +sidebar_current: "docs-config-locals" +description: |- + Local values assign a name to an expression that can then be used multiple times + within a module. +--- + +# Local Value Configuration + +Local values assign a name to an expression, that can then be used multiple +times within a module. + +Comparing modules to functions in a traditional programming language, +if [variables](./variables.html) are analogous to function arguments and +[outputs](./outputs.html) are analogous to function return values then +_local values_ are comparable to a function's local variables. + +This page assumes you're already familiar with +[the configuration syntax](/docs/configuration/syntax.html). + +## Example + +Local values are defined in `locals` blocks: + +```hcl +# Ids for multiple sets of EC2 instances, merged together +locals { + instance_ids = "${concat(aws_instance.blue.*.id, aws_instance.green.*.id)}" +} + +# A computed default name prefix +locals { + default_name_prefix = "${var.project_name}-web" + name_prefix = "${var.name_prefix != "" ? var.name_prefix : local.default_name_prefix}" +} +``` + +## Description + +The `locals` block defines one or more local variables within a module. +Each `locals` block can have as many locals as needed, and there can be any +number of `locals` blocks within a module. + +The names given for the items in the `locals` block must be unique throughout +a module. The given value can be any expression that is valid within +the current module. + +The expression of a local value can refer to other locals, but as usual +reference cycles are not allowed. That is, a local cannot refer to itself +or to a variable that refers (directly or indirectly) back to it. + +It's recommended to group together logically-related local values into +a single block, particulary if they depend on each other. This will help +the reader understand the relationships between variables. Conversely, +prefer to define _unrelated_ local values in _separate_ blocks, and consider +annotating each block with a comment describing any context common to all +of the enclosed locals. diff --git a/website/layouts/docs.erb b/website/layouts/docs.erb index 89a893545315..5457337f99b6 100644 --- a/website/layouts/docs.erb +++ b/website/layouts/docs.erb @@ -40,6 +40,10 @@ Outputs + > + Local Values + + > Modules