Disclaimer: I am working at HashiCorp (now IBM) as part of the Terraform
Core team. The postings on this site are my own and don’t necessarily represent
IBM’s positions, strategies or opinions.
Since I am involved in Terraform my opinions can sometimes be (unconsciously)
biased. I hope you enjoy the post anyway.
This is part of my Inside Terraform series where I deep dive into different parts of Terraform and explain how they work under the hood. This is the third post in the series, the first one is about the addrs package and the second one is about References. You should read both of them before this one, we will be building on the concepts explained there.
This post handles more topics than the last ones, so I changed the format a bit. They all make sense together, so please hang in there while thinking “why is he telling me this now?”.
Content
TL;DR
It’s a lot of content, so here is a quick summary of what we will cover:
go-ctyis the abstraction layer Terraform uses to represent values in a type-safe way.- HCL provides expression evaluation capabilities that we can use to evaluate
hcl.Expressionandhcl.Bodytogo-ctyvalues. - Terraform uses
ScopeandBuiltinEvalContextto provide the right context for evaluation based on the current graph node and state.
go-cty
go-cty self-describes as “a dynamic type system for applications written in Go that need to represent user-supplied values without losing type information”. Everywhere where Terraform deals with values (so everything that you can write a Reference for and every bit of configuration in any block) these values are represented as cty.Value structs from the go-cty library. All go-cty values are immutable and can represent primitive types (like strings, numbers, booleans), complex types (like lists, maps, objects) and special types (like null values and unknown values).
There are a couple of important concepts to understand when dealing with cty.Values.
Basics
First of all let’s see how to create a cty.Value:
import "github.com/zclconf/go-cty/cty"
str := cty.StringVal("hello world")
obj := cty.ObjectVal(map[string]cty.Value{
"greeting": str,
"count": cty.NumberIntVal(42),
})
expected := cty.NumberIntVal(21).Multiply(cty.NumberIntVal(2))
if obj.GetAttr("count").Equals(expected).True() {
fmt.Println("The count is correct!")
}
As you can see go-cty enables us to encapsulate normal value operations like composition, comparison, and arithmetic in a type-safe way. Every cty.Value has a cty.Type that describes the type of the value (e.g. string, number, list of strings, object with specific attributes, etc.). You can query the type of a value with the Type() method. If the type would not match the expected type, operations on the value will return errors.
External values -> go-cty values and vice versa
We normally use helpers like cty.StringVal() or cty.ObjectVal() to create cty.Values from plain Go values. The Schemas we have in Terraform implement ImpliedType() that returns the expected cty.Type for the schema, we then use schema.Block.CoerceValue to ensure the go-cty value matches what we expect. In this example we import data into the terraform_data resource and coerce it into the expected type:
// Source: https://github.com/hashicorp/terraform/blob/a051ac6fa85da7ccc98e7901e47acb19d5330756/internal/builtin/providers/terraform/resource_data.go#L180-L186
func importDataStore(req providers.ImportResourceStateRequest) (resp providers.ImportResourceStateResponse) {
schema := dataStoreResourceSchema()
v := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal(req.ID),
})
state, err := schema.Body.CoerceValue(v)
resp.Diagnostics = resp.Diagnostics.Append(err)
You can find the full list of go-cty value constructors here. Functions ending in Val create a cty.Value from a plain Go value, functions being named without Val (that are named after types) create cty.Type values. cty.Type is used to e.g. create an cty.UnknownVal of a certain type.
While there are many ways to construct a value there is a very simple and great way to create a string representation of a cty.Value or cty.Type for debugging: val.GoString(). This will create a Go-syntax representation of the value or type that you can use to understand what is inside. The output matches the syntax you would use to create the value or type in Go code.
There are some methods on cty.Type that allow you to convert cty.Values into plain Go values. They are dependant on the cty.Type you are dealing with so you need to ensure the type matches what you expect before calling them. For example AsString() on a string type, AsBigFloat() on a number type, AsValueSlice() on a list type, etc. There is cty.Value.Type() to get the type of a value and a bunch of helpers starting with Is on cty.Type to understand a given type.
Another case where we take data from “the outside” and convert it into go-cty values is when we get state back from an external system (e.g. a provider during reading / planning a resource or reading the state). In most cases the outside values are handled through protobuf messages, we have a DynamicValue protobuf message that represents a go-cty value in a serializable way.
We have helpers from and to DynamicValue:
mp, err := msgpack.Marshal(r.PriorState, resSchema.Body.ImpliedType())
This creates the dynamic value from the prior state which is a cty.Value.
state, err := decodeDynamicValue(protoResp.NewState, resSchema.Body.ImpliedType())
This decodes the dynamic value from the protobuf message into a cty.Value. Both use msgpack under the hood for serialization, but the reading version also supports a JSON representation that we used to use for legacy support reasons.
Unknown Values
One special type of cty.Value is the unknown value. Unknown values are used in Terraform to represent values that are not yet known at evaluation time. This can happen for example when a value depends on the result of a resource that has not yet been created. In this case, we represent the value as an unknown value of the expected type. Validation is also an instance where unknowns are used. Unknown values can occur deeply nested in complex structures as well, e.g. if some attributes of an object are only known after apply. There is cty.Value.IsKnown to check if a value is known or unknown and cty.Value.IsWhollyKnown which checks the same for all deeper nested values in lists / objects as well.
Marks
A Mark is a metadata that can be attached to a cty.Value to provide additional information about the value. Marks are used in Terraform to represent sensitive / ephemeral values. Whatever graph node provides the value can attach a mark to it and “consumers” (downstream nodes) can check for the presence of marks and act accordingly. In Terraform we currently use fixed strings as marks, but theoretically they can even be structs. There is the marks package in Terraform that provides helpers to work with marks and contains the actual marks as well.
Refinements
This is quite an advanced and sophisticated concept, so I am just going to talk about it at a high level so you have an idea of what it is. Refinements can be used to give more context to an unknown value. If you can tell by your configuration already that a value can not be null you can use cty.Value.RefineNotNull() to refine the unknown value to be not null. You can also refine a known value and therfore ensure the assertion on the value holds true. Essentially you can restrict the possible future values of an unknown value and users of that value can make use of the refinements.
This is the go-cty documentation page on refinements, the best place to go if you need to learn more.
Evaluating Expressions in HCL
Now that we talked about go-cty, let’s see how we can evaluate expressions in HCL to go-cty values. We basically have two kinds of expressions we deal with:
hcl.Expression is a standalone expression, e.g. var.foo + 1, we encounter those e.g. when we want to evaluate for_each or any other argument that is not a block.
hcl.Body is a (partial) body of a config block, so e.g. an entire resource block or module call block.
For both we have helpers in internal/lang/eval to evaluate them to go-cty values based on the current evaluation context. But before we look at those, let’s begin on a deeper level to understand what those helpers do behind the scenes.
For hcl.Expression we call expr.Value(ctx) to evaluate the expression; For hcl.Body we use hcldec.Decode(body, schema, ctx). Both share the ctx which is an &hcl.EvalContext. The eval context contains information about variables, functions, and other context needed to evaluate expressions. Reading variables you might jump to conclusions that we talk about var.foo here, and we do, but variables describes everything that can be referenced in an expression, so also local.foo, module.bar, self, each, resources, data sources, etc.
So essentially hcl handles the evaluation for us as long as we provide the right context. Getting the right context is the tricky part here, we will talk about that next.
There are also static expressions that we use when e.g. evaluating depends_on. In those cases we don’t evaluate the values, but rather deal with it as a list of keywords or references. In those caes we deal with hcl.Traversal which is basically an AST of a reference. We use hcl.AbsTraversalForExpr to interpret an hcl.Expression as an hcl.Traversal (or fail if it is something else).
// Source: https://github.com/hashicorp/terraform/blob/9fb6f5402f3f600fd5cf26d1006f7876a3da7dfd/internal/configs/depends_on.go#L10-L36
func DecodeDependsOn(attr *hcl.Attribute) ([]hcl.Traversal, hcl.Diagnostics) {
var ret []hcl.Traversal
exprs, diags := hcl.ExprList(attr.Expr)
for _, expr := range exprs {
expr, shimDiags := shimTraversalInString(expr, false)
diags = append(diags, shimDiags...)
traversal, travDiags := hcl.AbsTraversalForExpr(expr)
diags = append(diags, travDiags...)
if len(traversal) != 0 {
if traversal.RootName() == "action" {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid depends_on Action Reference",
Detail: "The depends_on attribute cannot reference action blocks directly. You must reference a resource or data source instead.",
Subject: expr.Range().Ptr(),
})
} else {
ret = append(ret, traversal)
}
}
}
return ret, diags
}
We also use hcl.ExprList here to ensure the expression is a list of expressions (which we then parse in the aforementioned way). In this specific case we later on use addrs.ParseRef to parse the traversals into addrs.References as explained in the previous post of this series and use those to add dependency edges in the graph.
Evaluating Expressions in Terraform
Now let’s take a look at how Terraform uses the above concepts to evaluate expressions in the context of a graph node. The evaluation of a block is similar (and adjacent) to the evaluation of an expression, so we will focus on expressions here for simplicity.
The main object that handles evaluation in Terraform is the Scope. A scope represents the context in which we evaluate expressions. It contains information about variables, functions, and other context needed to evaluate expressions. Scopes can be nested, so we can have a global scope and then create child scopes for modules, resources, etc.
We will first take a look at how the Scope is used and from there go on to how it is constructed / expanded.
Scope.EvalExpr - Evaluating an Expression in a Scope
Below is the Scope.EvalExpr method that is commonly used to evaluate expressions in Terraform. It ties nicely (almost as if per design) into the topics we discussed above and in the previous parts of the series.
// Source: https://github.com/hashicorp/terraform/blob/9fb6f5402f3f600fd5cf26d1006f7876a3da7dfd/internal/lang/eval.go#L170-L201
func (s *Scope) EvalExpr(expr hcl.Expression, wantType cty.Type) (cty.Value, tfdiags.Diagnostics) {
refs, diags := langrefs.ReferencesInExpr(s.ParseRef, expr)
ctx, ctxDiags := s.EvalContext(refs)
diags = diags.Append(ctxDiags)
if diags.HasErrors() {
// We'll stop early if we found problems in the references, because
// it's likely evaluation will produce redundant copies of the same errors.
return cty.UnknownVal(wantType), diags
}
val, evalDiags := expr.Value(ctx)
diags = diags.Append(checkForUnknownFunctionDiags(evalDiags))
if wantType != cty.DynamicPseudoType {
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(),
Expression: expr,
EvalContext: ctx,
})
}
}
return val, diags
}
To evaluate an expression we first use langrefs.ReferencesInExpr (that we discussed in the last part) to get all refeences in the expression. We then use Scope.EvalContext to get an hcl.EvalContext based on the data stored in the scope. Scope.EvalContext is a very mechanic method that constructs giant go-cty objects for everything we consider a variable in the context of hcl. We pass in the list of references to optimize the construction of the eval context a bit, so we only create data for the references that are actually used in the expression.
The final evaluation is then left to expr.Value(ctx) which is the hcl.Expression evaluation we discussed above. After checking if the type matches the expected type we return the evaluated value.
Now you might wonder how the Scope gets its data in the first place. Let’s take a look at that next.
BuiltinEvalContext - Container for all Evaluation Data
Let’s take a look at the most typical way of calling Scope.EvalExpr, which is through BuiltinEvalContext:
// Source: https://github.com/hashicorp/terraform/blob/1caf99a3b6c8b0ca6bee959391e38f644093544e/internal/terraform/eval_context_builtin.go#L349-L352
func (ctx *BuiltinEvalContext) EvaluateExpr(expr hcl.Expression, wantType cty.Type, self addrs.Referenceable) (cty.Value, tfdiags.Diagnostics) {
scope := ctx.EvaluationScope(self, nil, EvalDataForNoInstanceKey)
return scope.EvalExpr(expr, wantType)
}
There are multiple context implementations in Terraform, but BuiltinEvalContext is the one used in normal validate / plan / apply.
The context is something all graph nodes have access to, so it’s both used for reading data when evaluating an expression and for writing data when e.g. setting the value of a resource. There is a lot of functionality and complexity in the context in general, but we will focus on the parts relevant to evaluation here.
A central figure in getting the data from the scope is the lang.Data interface. It consists of a bunch of methods to retrieve a go-cty value for a given address. In the “normal” case we use the evaluationStateData implementation which refers back to the state:
// Source: https://github.com/hashicorp/terraform/blob/1caf99a3b6c8b0ca6bee959391e38f644093544e/internal/terraform/evaluate.go#L658-L658
rs := d.Evaluator.State.Resource(addr.Absolute(d.ModulePath))
It’s a long function that very robustly handles everything that could be going on. Which makes sense it’s a central piece of the evaluation puzzle and used in all kinds of scenarios.
Now that we know the evaluation looks at the state for the data, the question is how the state gets populated in the first place. Each graph node is responsible to write its updated data into a state during both plan and apply. This state is not necessarily the real state that gets written to disk, but rather a temporary state that is used during evaluation.
// Source: https://github.com/hashicorp/terraform/blob/1caf99a3b6c8b0ca6bee959391e38f644093544e/internal/terraform/node_resource_plan_instance.go#L437-L437
diags = diags.Append(n.writeResourceInstanceState(ctx, instancePlanState, workingState))
This method updates the workingState (which is EvalContext.State()) with the new values of the resource after planning. The evaluationStateData implementation references the same state and therefore can read the updated values during evaluation. Since Terraform builds a dependency graph, we know that we will only read updated values since anything referenced will have been evaluated before.
Next
In the next post in this series we will look at how planning in general works and how plans are rendered. I want to make sure to cover everything that the graph does before talking about the graph itself, so that we can have a solid understanding of the building blocks first. Stay tuned!