diff --git a/lang/data.go b/lang/data.go new file mode 100644 index 000000000..d692a830b --- /dev/null +++ b/lang/data.go @@ -0,0 +1,32 @@ +package lang + +import ( + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +// Data is an interface whose implementations can provide cty.Value +// representations of objects identified by referenceable addresses from +// the addrs package. +// +// This interface will grow each time a new type of reference is added, and so +// implementations outside of the Terraform codebases are not advised. +// +// Each method returns a suitable value and optionally some diagnostics. If the +// returned diagnostics contains errors then the type of the returned value is +// used to construct an unknown value of the same type which is then used in +// place of the requested object so that type checking can still proceed. In +// cases where it's not possible to even determine a suitable result type, +// cty.DynamicVal is returned along with errors describing the problem. +type Data interface { + GetCountAttr(addrs.CountAttr, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) + GetResourceInstance(addrs.ResourceInstance, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) + GetLocalValue(addrs.LocalValue, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) + GetModuleInstance(addrs.ModuleCallInstance, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) + GetModuleInstanceOutput(addrs.ModuleCallOutput, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) + GetPathAttr(addrs.PathAttr, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) + GetSelf(tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) + GetTerraformAttr(addrs.TerraformAttr, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) + GetInputVariable(addrs.InputVariable, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) +} diff --git a/lang/data_test.go b/lang/data_test.go new file mode 100644 index 000000000..2492d6f5d --- /dev/null +++ b/lang/data_test.go @@ -0,0 +1,58 @@ +package lang + +import ( + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +type dataForTests struct { + CountAttrs map[string]cty.Value + ResourceInstances map[string]cty.Value + LocalValues map[string]cty.Value + Modules map[string]cty.Value + PathAttrs map[string]cty.Value + Self cty.Value + TerraformAttrs map[string]cty.Value + InputVariables map[string]cty.Value +} + +var _ Data = &dataForTests{} + +func (d *dataForTests) GetCountAttr(addr addrs.CountAttr, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + return d.CountAttrs[addr.Name], nil +} + +func (d *dataForTests) GetResourceInstance(addr addrs.ResourceInstance, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + return d.ResourceInstances[addr.String()], nil +} + +func (d *dataForTests) GetInputVariable(addr addrs.InputVariable, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + return d.InputVariables[addr.Name], nil +} + +func (d *dataForTests) GetLocalValue(addr addrs.LocalValue, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + return d.LocalValues[addr.Name], nil +} + +func (d *dataForTests) GetModuleInstance(addr addrs.ModuleCallInstance, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + return d.Modules[addr.String()], nil +} + +func (d *dataForTests) GetModuleInstanceOutput(addr addrs.ModuleCallOutput, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + // This will panic if the module object does not have the requested attribute + obj := d.Modules[addr.Call.String()] + return obj.GetAttr(addr.Name), nil +} + +func (d *dataForTests) GetPathAttr(addr addrs.PathAttr, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + return d.PathAttrs[addr.Name], nil +} + +func (d *dataForTests) GetSelf(rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + return d.Self, nil +} + +func (d *dataForTests) GetTerraformAttr(addr addrs.TerraformAttr, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + return d.TerraformAttrs[addr.Name], nil +} diff --git a/lang/doc.go b/lang/doc.go new file mode 100644 index 000000000..af5c5cac0 --- /dev/null +++ b/lang/doc.go @@ -0,0 +1,5 @@ +// Package lang deals with the runtime aspects of Terraform's configuration +// language, with concerns such as expression evaluation. It is closely related +// to sibling package "configs", which is responsible for configuration +// parsing and static validation. +package lang diff --git a/lang/eval.go b/lang/eval.go new file mode 100644 index 000000000..f470654c4 --- /dev/null +++ b/lang/eval.go @@ -0,0 +1,360 @@ +package lang + +import ( + "fmt" + "log" + "strconv" + + "github.com/hashicorp/terraform/addrs" + + "github.com/hashicorp/hcl2/ext/dynblock" + "github.com/hashicorp/hcl2/hcl" + "github.com/hashicorp/hcl2/hcldec" + "github.com/hashicorp/terraform/config/configschema" + "github.com/hashicorp/terraform/tfdiags" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" +) + +// ExpandBlock expands any "dynamic" blocks present in the given body. The +// result is a body with those blocks expanded, ready to be evaluated with +// EvalBlock. +// +// If the returned diagnostics contains errors then the result may be +// incomplete or invalid. +func (s *Scope) ExpandBlock(body hcl.Body, schema *configschema.Block) (hcl.Body, tfdiags.Diagnostics) { + spec := schema.DecoderSpec() + + traversals := dynblock.ForEachVariablesHCLDec(body, spec) + refs, diags := References(traversals) + + ctx, ctxDiags := s.EvalContext(refs) + diags = diags.Append(ctxDiags) + + return dynblock.Expand(body, ctx), diags +} + +// EvalBlock evaluates the given body using the given block schema and returns +// a cty object value representing its contents. The type of the result conforms +// to the implied type of the given schema. +// +// This function does not automatically expand "dynamic" blocks within the +// body. If that is desired, first call the ExpandBlock method to obtain +// an expanded body to pass to this method. +// +// If the returned diagnostics contains errors then the result may be +// incomplete or invalid. +func (s *Scope) EvalBlock(body hcl.Body, schema *configschema.Block) (cty.Value, tfdiags.Diagnostics) { + spec := schema.DecoderSpec() + + traversals := hcldec.Variables(body, spec) + refs, diags := References(traversals) + + ctx, ctxDiags := s.EvalContext(refs) + diags = diags.Append(ctxDiags) + + val, evalDiags := hcldec.Decode(body, spec, ctx) + diags = diags.Append(evalDiags) + + return val, diags +} + +// EvalExpr evaluates a single expression in the receiving context and returns +// the resulting value. The value will be converted to the given type before +// it is returned if possible, or else an error diagnostic will be produced +// describing the conversion error. +// +// Pass an expected type of cty.DynamicPseudoType to skip automatic conversion +// and just obtain the returned value directly. +// +// If the returned diagnostics contains errors then the result may be +// incomplete, but will always be of the requested type. +func (s *Scope) EvalExpr(expr hcl.Expression, wantType cty.Type) (cty.Value, tfdiags.Diagnostics) { + refs, diags := ReferencesInExpr(expr) + + ctx, ctxDiags := s.EvalContext(refs) + diags = diags.Append(ctxDiags) + + val, evalDiags := expr.Value(ctx) + diags = diags.Append(evalDiags) + + var convErr error + val, convErr = convert.Convert(val, wantType) + if convErr != nil { + val = cty.UnknownVal(wantType) + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Incorrect value type", + Detail: fmt.Sprintf("Invalid expression value: %s.", tfdiags.FormatError(convErr)), + Subject: expr.Range().Ptr(), + }) + } + + return val, diags +} + +// EvalContext constructs a HCL expression evaluation context whose variable +// scope contains sufficient values to satisfy the given set of references. +// +// Most callers should prefer to use the evaluation helper methods that +// this type offers, but this is here for less common situations where the +// caller will handle the evaluation calls itself. +func (s *Scope) EvalContext(refs []*addrs.Reference) (*hcl.EvalContext, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + vals := make(map[string]cty.Value) + funcs := s.Functions() + ctx := &hcl.EvalContext{ + Variables: vals, + Functions: funcs, + } + + if len(refs) == 0 { + // Easy path for common case where there are no references at all. + return ctx, diags + } + + // The reference set we are given has not been de-duped, and so there can + // be redundant requests in it for two reasons: + // - The same item is referenced multiple times + // - Both an item and that item's container are separately referenced. + // We will still visit every reference here and ask our data source for + // it, since that allows us to gather a full set of any errors and + // warnings, but once we've gathered all the data we'll then skip anything + // that's redundant in the process of populating our values map. + dataResources := map[string]map[string]map[addrs.InstanceKey]cty.Value{} + managedResources := map[string]map[string]map[addrs.InstanceKey]cty.Value{} + wholeModules := map[string]map[addrs.InstanceKey]cty.Value{} + moduleOutputs := map[string]map[addrs.InstanceKey]map[string]cty.Value{} + inputVariables := map[string]cty.Value{} + localValues := map[string]cty.Value{} + pathAttrs := map[string]cty.Value{} + terraformAttrs := map[string]cty.Value{} + countAttrs := map[string]cty.Value{} + var self cty.Value + + for _, ref := range refs { + rng := ref.SourceRange + + if ref.Subject == addrs.Self { + val, valDiags := normalizeRefValue(s.Data.GetSelf(ref.SourceRange)) + diags = diags.Append(valDiags) + self = val + continue + } + + // This type switch must cover all of the "Referenceable" implementations + // in package addrs. + switch subj := ref.Subject.(type) { + + case addrs.ResourceInstance: + var into map[string]map[string]map[addrs.InstanceKey]cty.Value + switch subj.Resource.Mode { + case addrs.ManagedResourceMode: + into = managedResources + case addrs.DataResourceMode: + into = dataResources + default: + panic(fmt.Errorf("unsupported ResourceMode %s", subj.Resource.Mode)) + } + + val, valDiags := normalizeRefValue(s.Data.GetResourceInstance(subj, rng)) + diags = diags.Append(valDiags) + + r := subj.Resource + if into[r.Type] == nil { + into[r.Type] = make(map[string]map[addrs.InstanceKey]cty.Value) + } + if into[r.Type][r.Name] == nil { + into[r.Type][r.Name] = make(map[addrs.InstanceKey]cty.Value) + } + into[r.Type][r.Name][subj.Key] = val + + case addrs.ModuleCallInstance: + val, valDiags := normalizeRefValue(s.Data.GetModuleInstance(subj, rng)) + diags = diags.Append(valDiags) + + if wholeModules[subj.Call.Name] == nil { + wholeModules[subj.Call.Name] = make(map[addrs.InstanceKey]cty.Value) + } + wholeModules[subj.Call.Name][subj.Key] = val + + case addrs.ModuleCallOutput: + val, valDiags := normalizeRefValue(s.Data.GetModuleInstanceOutput(subj, rng)) + diags = diags.Append(valDiags) + + callName := subj.Call.Call.Name + callKey := subj.Call.Key + if moduleOutputs[callName] == nil { + moduleOutputs[callName] = make(map[addrs.InstanceKey]map[string]cty.Value) + } + if moduleOutputs[callName][callKey] == nil { + moduleOutputs[callName][callKey] = make(map[string]cty.Value) + } + moduleOutputs[callName][callKey][subj.Name] = val + + case addrs.InputVariable: + val, valDiags := normalizeRefValue(s.Data.GetInputVariable(subj, rng)) + diags = diags.Append(valDiags) + inputVariables[subj.Name] = val + + case addrs.LocalValue: + val, valDiags := normalizeRefValue(s.Data.GetLocalValue(subj, rng)) + diags = diags.Append(valDiags) + localValues[subj.Name] = val + + case addrs.PathAttr: + val, valDiags := normalizeRefValue(s.Data.GetPathAttr(subj, rng)) + diags = diags.Append(valDiags) + pathAttrs[subj.Name] = val + + case addrs.TerraformAttr: + val, valDiags := normalizeRefValue(s.Data.GetTerraformAttr(subj, rng)) + diags = diags.Append(valDiags) + terraformAttrs[subj.Name] = val + + case addrs.CountAttr: + val, valDiags := normalizeRefValue(s.Data.GetCountAttr(subj, rng)) + diags = diags.Append(valDiags) + countAttrs[subj.Name] = val + + default: + // Should never happen + panic(fmt.Errorf("Scope.buildEvalContext cannot handle address type %T", ref.Subject)) + } + } + + for k, v := range buildResourceObjects(managedResources) { + vals[k] = v + } + vals["data"] = cty.ObjectVal(buildResourceObjects(dataResources)) + vals["module"] = cty.ObjectVal(buildModuleObjects(wholeModules, moduleOutputs)) + vals["var"] = cty.ObjectVal(inputVariables) + vals["local"] = cty.ObjectVal(localValues) + vals["path"] = cty.ObjectVal(pathAttrs) + vals["terraform"] = cty.ObjectVal(terraformAttrs) + vals["count"] = cty.ObjectVal(countAttrs) + if self != cty.NilVal { + vals["self"] = self + } + + return ctx, diags +} + +func buildResourceObjects(resources map[string]map[string]map[addrs.InstanceKey]cty.Value) map[string]cty.Value { + vals := make(map[string]cty.Value) + for typeName, names := range resources { + nameVals := make(map[string]cty.Value) + for name, keys := range names { + nameVals[name] = buildInstanceObjects(keys) + } + vals[typeName] = cty.ObjectVal(nameVals) + } + return vals +} + +func buildModuleObjects(wholeModules map[string]map[addrs.InstanceKey]cty.Value, moduleOutputs map[string]map[addrs.InstanceKey]map[string]cty.Value) map[string]cty.Value { + vals := make(map[string]cty.Value) + + for name, keys := range wholeModules { + vals[name] = buildInstanceObjects(keys) + } + + for name, keys := range moduleOutputs { + if _, exists := wholeModules[name]; exists { + // If we also have a whole module value for this name then we'll + // skip this since the individual outputs are embedded in that result. + continue + } + + // The shape of this collection isn't compatible with buildInstanceObjects, + // but rather than replicating most of the buildInstanceObjects logic + // here we'll instead first transform the structure to be what that + // function expects and then use it. This is a little wasteful, but + // we do not expect this these maps to be large and so the extra work + // here should not hurt too much. + flattened := make(map[addrs.InstanceKey]cty.Value, len(keys)) + for k, vals := range keys { + flattened[k] = cty.ObjectVal(vals) + } + vals[name] = buildInstanceObjects(flattened) + } + + return vals +} + +func buildInstanceObjects(keys map[addrs.InstanceKey]cty.Value) cty.Value { + if val, exists := keys[addrs.NoKey]; exists { + // If present, a "no key" value supersedes all other values, + // since they should be embedded inside it. + return val + } + + // If we only have individual values then we need to construct + // either a list or a map, depending on what sort of keys we + // have. + haveInt := false + haveString := false + maxInt := 0 + + for k := range keys { + switch tk := k.(type) { + case addrs.IntKey: + haveInt = true + if int(tk) > maxInt { + maxInt = int(tk) + } + case addrs.StringKey: + haveString = true + } + } + + // We should either have ints or strings and not both, but + // if we have both then we'll prefer strings and let the + // language interpreter try to convert the int keys into + // strings in a map. + switch { + case haveString: + vals := make(map[string]cty.Value) + for k, v := range keys { + switch tk := k.(type) { + case addrs.StringKey: + vals[string(tk)] = v + case addrs.IntKey: + sk := strconv.Itoa(int(tk)) + vals[sk] = v + } + } + return cty.ObjectVal(vals) + case haveInt: + // We'll make a tuple that is long enough for our maximum + // index value. It doesn't matter if we end up shorter than + // the number of instances because if length(...) were + // being evaluated we would've got a NoKey reference and + // thus not ended up in this codepath at all. + vals := make([]cty.Value, maxInt+1) + for i := range vals { + if v, exists := keys[addrs.IntKey(i)]; exists { + vals[i] = v + } else { + // Just a placeholder, since nothing will access this anyway + vals[i] = cty.DynamicVal + } + } + return cty.TupleVal(vals) + default: + // Should never happen because there are no other key types. + log.Printf("[ERROR] strange makeInstanceObjects call with no supported key types") + return cty.EmptyObjectVal + } +} + +func normalizeRefValue(val cty.Value, diags tfdiags.Diagnostics) (cty.Value, tfdiags.Diagnostics) { + if diags.HasErrors() { + // If there are errors then we will force an unknown result so that + // we can still evaluate and catch type errors but we'll avoid + // producing redundant re-statements of the same errors we've already + // dealt with here. + return cty.UnknownVal(val.Type()), diags + } + return val, diags +} diff --git a/lang/eval_test.go b/lang/eval_test.go new file mode 100644 index 000000000..c95d58ac9 --- /dev/null +++ b/lang/eval_test.go @@ -0,0 +1,524 @@ +package lang + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/hashicorp/terraform/config/configschema" + + "github.com/hashicorp/hcl2/hcl" + "github.com/hashicorp/hcl2/hcl/hclsyntax" + + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" +) + +func TestScopeEvalContext(t *testing.T) { + data := &dataForTests{ + CountAttrs: map[string]cty.Value{ + "index": cty.NumberIntVal(0), + }, + ResourceInstances: map[string]cty.Value{ + "null_resource.foo": cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("bar"), + }), + "data.null_data_source.foo": cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("bar"), + }), + "null_resource.multi": cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("multi0"), + }), + cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("multi1"), + }), + }), + "null_resource.multi[1]": cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("multi1"), + }), + }, + LocalValues: map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }, + Modules: map[string]cty.Value{ + "module.foo": cty.ObjectVal(map[string]cty.Value{ + "output0": cty.StringVal("bar0"), + "output1": cty.StringVal("bar1"), + }), + }, + PathAttrs: map[string]cty.Value{ + "module": cty.StringVal("foo/bar"), + }, + Self: cty.ObjectVal(map[string]cty.Value{ + "is_self": cty.True, + }), + TerraformAttrs: map[string]cty.Value{ + "workspace": cty.StringVal("default"), + }, + InputVariables: map[string]cty.Value{ + "baz": cty.StringVal("boop"), + }, + } + + tests := []struct { + Expr string + Want map[string]cty.Value + }{ + { + `12`, + map[string]cty.Value{}, + }, + { + `count.index`, + map[string]cty.Value{ + "count": cty.ObjectVal(map[string]cty.Value{ + "index": cty.NumberIntVal(0), + }), + }, + }, + { + `local.foo`, + map[string]cty.Value{ + "local": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + }, + }, + { + `null_resource.foo`, + map[string]cty.Value{ + "null_resource": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("bar"), + }), + }), + }, + }, + { + `null_resource.foo.attr`, + map[string]cty.Value{ + "null_resource": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("bar"), + }), + }), + }, + }, + { + `null_resource.multi`, + map[string]cty.Value{ + "null_resource": cty.ObjectVal(map[string]cty.Value{ + "multi": cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("multi0"), + }), + cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("multi1"), + }), + }), + }), + }, + }, + { + `null_resource.multi[1]`, + map[string]cty.Value{ + "null_resource": cty.ObjectVal(map[string]cty.Value{ + "multi": cty.TupleVal([]cty.Value{ + cty.DynamicVal, + cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("multi1"), + }), + }), + }), + }, + }, + { + `foo(null_resource.multi, null_resource.multi[1])`, + map[string]cty.Value{ + "null_resource": cty.ObjectVal(map[string]cty.Value{ + "multi": cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("multi0"), + }), + cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("multi1"), + }), + }), + }), + }, + }, + { + `data.null_data_source.foo`, + map[string]cty.Value{ + "data": cty.ObjectVal(map[string]cty.Value{ + "null_data_source": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("bar"), + }), + }), + }), + }, + }, + { + `module.foo`, + map[string]cty.Value{ + "module": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "output0": cty.StringVal("bar0"), + "output1": cty.StringVal("bar1"), + }), + }), + }, + }, + { + `module.foo.output1`, + map[string]cty.Value{ + "module": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "output1": cty.StringVal("bar1"), + }), + }), + }, + }, + { + `path.module`, + map[string]cty.Value{ + "path": cty.ObjectVal(map[string]cty.Value{ + "module": cty.StringVal("foo/bar"), + }), + }, + }, + { + `self.baz`, + map[string]cty.Value{ + "self": cty.ObjectVal(map[string]cty.Value{ + "is_self": cty.True, + }), + }, + }, + { + `terraform.workspace`, + map[string]cty.Value{ + "terraform": cty.ObjectVal(map[string]cty.Value{ + "workspace": cty.StringVal("default"), + }), + }, + }, + { + `var.baz`, + map[string]cty.Value{ + "var": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("boop"), + }), + }, + }, + } + + for _, test := range tests { + t.Run(test.Expr, func(t *testing.T) { + expr, parseDiags := hclsyntax.ParseExpression([]byte(test.Expr), "", hcl.Pos{Line: 1, Column: 1}) + if len(parseDiags) != 0 { + t.Errorf("unexpected diagnostics during parse") + for _, diag := range parseDiags { + t.Errorf("- %s", diag) + } + return + } + + refs, refsDiags := ReferencesInExpr(expr) + if refsDiags.HasErrors() { + t.Fatal(refsDiags.Err()) + } + + scope := &Scope{ + Data: data, + } + ctx, ctxDiags := scope.EvalContext(refs) + if ctxDiags.HasErrors() { + t.Fatal(ctxDiags.Err()) + } + + // For easier test assertions we'll just remove any top-level + // empty objects from our variables map. + for k, v := range ctx.Variables { + if v.RawEquals(cty.EmptyObjectVal) { + delete(ctx.Variables, k) + } + } + + gotVal := cty.ObjectVal(ctx.Variables) + wantVal := cty.ObjectVal(test.Want) + + if !gotVal.RawEquals(wantVal) { + // We'll JSON-ize our values here just so it's easier to + // read them in the assertion output. + gotJSON := formattedJSONValue(gotVal) + wantJSON := formattedJSONValue(wantVal) + + t.Errorf( + "wrong result\nexpr: %s\ngot: %s\nwant: %s", + test.Expr, gotJSON, wantJSON, + ) + } + }) + } +} + +func TestScopeExpandEvalBlock(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "bar": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "baz": { + Type: cty.String, + }, + }, + }, + }, + }, + } + data := &dataForTests{ + LocalValues: map[string]cty.Value{ + "greeting": cty.StringVal("howdy"), + "list": cty.ListVal([]cty.Value{ + cty.StringVal("elem0"), + cty.StringVal("elem1"), + }), + "map": cty.MapVal(map[string]cty.Value{ + "key1": cty.StringVal("val1"), + "key2": cty.StringVal("val2"), + }), + }, + } + + tests := map[string]struct { + Config string + Want cty.Value + }{ + "empty": { + ` + `, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NullVal(cty.String), + "bar": cty.MapValEmpty(cty.Object(map[string]cty.Type{ + "baz": cty.String, + })), + }), + }, + "literal attribute": { + ` + foo = "hello" + `, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("hello"), + "bar": cty.MapValEmpty(cty.Object(map[string]cty.Type{ + "baz": cty.String, + })), + }), + }, + "variable attribute": { + ` + foo = local.greeting + `, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("howdy"), + "bar": cty.MapValEmpty(cty.Object(map[string]cty.Type{ + "baz": cty.String, + })), + }), + }, + "one static block": { + ` + bar "static" {} + `, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NullVal(cty.String), + "bar": cty.MapVal(map[string]cty.Value{ + "static": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + "two static blocks": { + ` + bar "static0" { + baz = 0 + } + bar "static1" { + baz = 1 + } + `, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NullVal(cty.String), + "bar": cty.MapVal(map[string]cty.Value{ + "static0": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("0"), + }), + "static1": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("1"), + }), + }), + }), + }, + "dynamic blocks from list": { + ` + dynamic "bar" { + for_each = local.list + labels = [bar.value] + content { + baz = bar.key + } + } + `, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NullVal(cty.String), + "bar": cty.MapVal(map[string]cty.Value{ + "elem0": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("0"), + }), + "elem1": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("1"), + }), + }), + }), + }, + "dynamic blocks from map": { + ` + dynamic "bar" { + for_each = local.map + labels = [bar.key] + content { + baz = bar.value + } + } + `, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NullVal(cty.String), + "bar": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("val1"), + }), + "key2": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("val2"), + }), + }), + }), + }, + "everything at once": { + ` + foo = "whoop" + bar "static0" { + baz = "s0" + } + dynamic "bar" { + for_each = local.list + labels = [bar.value] + content { + baz = bar.key + } + } + bar "static1" { + baz = "s1" + } + dynamic "bar" { + for_each = local.map + labels = [bar.key] + content { + baz = bar.value + } + } + bar "static2" { + baz = "s2" + } + `, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("whoop"), + "bar": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("val1"), + }), + "key2": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("val2"), + }), + "elem0": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("0"), + }), + "elem1": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("1"), + }), + "static0": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("s0"), + }), + "static1": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("s1"), + }), + "static2": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("s2"), + }), + }), + }), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + file, parseDiags := hclsyntax.ParseConfig([]byte(test.Config), "", hcl.Pos{Line: 1, Column: 1}) + if len(parseDiags) != 0 { + t.Errorf("unexpected diagnostics during parse") + for _, diag := range parseDiags { + t.Errorf("- %s", diag) + } + return + } + + body := file.Body + scope := &Scope{ + Data: data, + } + + body, expandDiags := scope.ExpandBlock(body, schema) + if expandDiags.HasErrors() { + t.Fatal(expandDiags.Err()) + } + + got, valDiags := scope.EvalBlock(body, schema) + if valDiags.HasErrors() { + t.Fatal(valDiags.Err()) + } + + if !got.RawEquals(test.Want) { + // We'll JSON-ize our values here just so it's easier to + // read them in the assertion output. + gotJSON := formattedJSONValue(got) + wantJSON := formattedJSONValue(test.Want) + + t.Errorf( + "wrong result\nconfig: %s\ngot: %s\nwant: %s", + test.Config, gotJSON, wantJSON, + ) + } + + }) + } + +} + +func formattedJSONValue(val cty.Value) string { + val = cty.UnknownAsNull(val) // since JSON can't represent unknowns + j, err := ctyjson.Marshal(val, val.Type()) + if err != nil { + panic(err) + } + var buf bytes.Buffer + json.Indent(&buf, j, "", " ") + return buf.String() +} diff --git a/lang/functions.go b/lang/functions.go new file mode 100644 index 000000000..797a27410 --- /dev/null +++ b/lang/functions.go @@ -0,0 +1,108 @@ +package lang + +import ( + "fmt" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" + "github.com/zclconf/go-cty/cty/function/stdlib" +) + +var impureFunctions = []string{ + "timestamp", + "uuid", +} + +// Functions returns the set of functions that should be used to when evaluating +// expressions in the receiving scope. +func (s *Scope) Functions() map[string]function.Function { + s.funcsLock.Lock() + if s.funcs == nil { + s.funcs = map[string]function.Function{ + "abs": stdlib.AbsoluteFunc, + "basename": unimplFunc, // TODO + "base64decode": unimplFunc, // TODO + "base64encode": unimplFunc, // TODO + "base64gzip": unimplFunc, // TODO + "base64sha256": unimplFunc, // TODO + "base64sha512": unimplFunc, // TODO + "bcrypt": unimplFunc, // TODO + "ceil": unimplFunc, // TODO + "chomp": unimplFunc, // TODO + "cidrhost": unimplFunc, // TODO + "cidrnetmask": unimplFunc, // TODO + "cidrsubnet": unimplFunc, // TODO + "coalesce": stdlib.CoalesceFunc, + "coalescelist": unimplFunc, // TODO + "compact": unimplFunc, // TODO + "concat": stdlib.ConcatFunc, + "contains": unimplFunc, // TODO + "csvdecode": stdlib.CSVDecodeFunc, + "dirname": unimplFunc, // TODO + "distinct": unimplFunc, // TODO + "element": unimplFunc, // TODO + "chunklist": unimplFunc, // TODO + "file": unimplFunc, // TODO + "matchkeys": unimplFunc, // TODO + "flatten": unimplFunc, // TODO + "floor": unimplFunc, // TODO + "format": stdlib.FormatFunc, + "formatlist": stdlib.FormatListFunc, + "indent": unimplFunc, // TODO + "index": unimplFunc, // TODO + "join": unimplFunc, // TODO + "jsondecode": stdlib.JSONDecodeFunc, + "jsonencode": stdlib.JSONEncodeFunc, + "length": unimplFunc, // TODO + "list": unimplFunc, // TODO + "log": unimplFunc, // TODO + "lower": stdlib.LowerFunc, + "map": unimplFunc, // TODO + "max": stdlib.MaxFunc, + "md5": unimplFunc, // TODO + "merge": unimplFunc, // TODO + "min": stdlib.MinFunc, + "pathexpand": unimplFunc, // TODO + "pow": unimplFunc, // TODO + "replace": unimplFunc, // TODO + "rsadecrypt": unimplFunc, // TODO + "sha1": unimplFunc, // TODO + "sha256": unimplFunc, // TODO + "sha512": unimplFunc, // TODO + "signum": unimplFunc, // TODO + "slice": unimplFunc, // TODO + "sort": unimplFunc, // TODO + "split": unimplFunc, // TODO + "substr": stdlib.SubstrFunc, + "timestamp": unimplFunc, // TODO + "timeadd": unimplFunc, // TODO + "title": unimplFunc, // TODO + "transpose": unimplFunc, // TODO + "trimspace": unimplFunc, // TODO + "upper": stdlib.UpperFunc, + "urlencode": unimplFunc, // TODO + "uuid": unimplFunc, // TODO + "zipmap": unimplFunc, // TODO + } + + if s.PureOnly { + // Force our few impure functions to return unknown so that we + // can defer evaluating them until a later pass. + for _, name := range impureFunctions { + s.funcs[name] = function.Unpredictable(s.funcs[name]) + } + } + } + s.funcsLock.Unlock() + + return s.funcs +} + +var unimplFunc = function.New(&function.Spec{ + Type: func([]cty.Value) (cty.Type, error) { + return cty.DynamicPseudoType, fmt.Errorf("function not yet implemented") + }, + Impl: func([]cty.Value, cty.Type) (cty.Value, error) { + return cty.DynamicVal, fmt.Errorf("function not yet implemented") + }, +}) diff --git a/lang/references.go b/lang/references.go new file mode 100644 index 000000000..07c85a8ca --- /dev/null +++ b/lang/references.go @@ -0,0 +1,62 @@ +package lang + +import ( + "github.com/hashicorp/hcl2/hcl" + "github.com/hashicorp/hcl2/hcldec" + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/config/configschema" + "github.com/hashicorp/terraform/tfdiags" +) + +// References finds all of the references in the given set of traversals, +// returning diagnostics if any of the traversals cannot be interpreted as a +// reference. +// +// This function does not do any de-duplication of references, since references +// have source location information embedded in them and so any invalid +// references that are duplicated should have errors reported for each +// occurence. +// +// If the returned diagnostics contains errors then the result may be +// incomplete or invalid. Otherwise, the returned slice has one reference per +// given traversal, though it is not guaranteed that the references will +// appear in the same order as the given traversals. +func References(traversals []hcl.Traversal) ([]*addrs.Reference, tfdiags.Diagnostics) { + if len(traversals) == 0 { + return nil, nil + } + + var diags tfdiags.Diagnostics + refs := make([]*addrs.Reference, 0, len(traversals)) + + for _, traversal := range traversals { + ref, refDiags := addrs.ParseRef(traversal) + diags = diags.Append(refDiags) + if ref == nil { + continue + } + refs = append(refs, ref) + } + + return refs, diags +} + +// ReferencesInBlock is a helper wrapper around References that first searches +// the given body for traversals, before converting those traversals to +// references. +// +// A block schema must be provided so that this function can determine where in +// the body variables are expected. +func ReferencesInBlock(body hcl.Body, schema *configschema.Block) ([]*addrs.Reference, tfdiags.Diagnostics) { + spec := schema.DecoderSpec() + traversals := hcldec.Variables(body, spec) + return References(traversals) +} + +// ReferencesInExpr is a helper wrapper around References that first searches +// the given expression for traversals, before converting those traversals +// to references. +func ReferencesInExpr(expr hcl.Expression) ([]*addrs.Reference, tfdiags.Diagnostics) { + traversals := expr.Variables() + return References(traversals) +} diff --git a/lang/scope.go b/lang/scope.go new file mode 100644 index 000000000..92bd403ba --- /dev/null +++ b/lang/scope.go @@ -0,0 +1,28 @@ +package lang + +import ( + "sync" + + "github.com/zclconf/go-cty/cty/function" +) + +// Scope is the main type in this package, allowing dynamic evaluation of +// blocks and expressions based on some contextual information that informs +// which variables and functions will be available. +type Scope struct { + // Data is used to resolve references in expressions. + Data Data + + // BaseDir is the base directory used by any interpolation functions that + // accept filesystem paths as arguments. + BaseDir string + + // PureOnly can be set to true to request that any non-pure functions + // produce unknown value results rather than actually executing. This is + // important during a plan phase to avoid generating results that could + // then differ during apply. + PureOnly bool + + funcs map[string]function.Function + funcsLock sync.Mutex +}