Files
opentf/internal/lang/exprs/evalable.go
Martin Atkins cf84d991ff lang/exprs: EvalableHCLBodyWithDynamicBlocks
The HCL "dynblock" extension blurs the distinction between static
structure and dynamic expressions by allowing the use of expressions to
generate zero or more nested blocks.

Because this feature is a bit of a layering violation it requires some
different usage patterns to get the correct result, and so this new
EvalableHCLBodyWithDynamicBlocks aims to encapsulate those details so that
the caller can just treat the result like a normal Evalable.

lang/eval now uses this for the body of a resource configuration, so that
"dynamic" blocks can work in there similarly to how they do in the previous
language runtime.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-10-27 10:15:41 -07:00

243 lines
9.0 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package exprs
import (
"context"
"iter"
"slices"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/dynblock"
"github.com/hashicorp/hcl/v2/hcldec"
"github.com/zclconf/go-cty/cty"
"github.com/opentofu/opentofu/internal/tfdiags"
)
// Evalable is implemented by types that encapsulate expressions that can be
// evaluated in some evaluation scope decided by a caller.
//
// An Evalable implementation must include any supporting metadata needed to
// analyze and evaluate the expressions inside. For example, an [Evalable]
// representing a HCL body must also include the expected schema for that body.
type Evalable interface {
// References returns a sequence of references this Evalable makes to
// values in its containing scope.
References() iter.Seq[hcl.Traversal]
// FunctionCalls returns a sequence of all of the function calls that
// could be made if this were evaluated.
//
// TODO: Perhaps References and FunctionCalls should be combined together
// somehow to return a tree that shows when a reference appears as part
// of an argument to a function, to address the problem described in
// this issue:
// https://github.com/opentofu/opentofu/issues/2630
FunctionCalls() iter.Seq[*hcl.StaticCall]
// Evaluate performs the actual expression evaluation, using the given
// HCL evaluation context to satisfy any references.
//
// Callers must first use the References method to discover what the
// wrapped expressions refer to, and make sure that the given evaluation
// context contains at least the variables required to satisfy those
// references.
//
// This method takes a [context.Context] because some implementations
// may internally block on the completion of a potentially-time-consuming
// operation, in which case they should respond gracefully to the
// cancellation or deadline of the given context.
//
// If Evaluate returns diagnostics then it must also return a suitable
// placeholder value that could be use for downstream expression evaluation
// despite the error. Returning [cty.DynamicVal] is acceptable if all else
// fails, but returning an unknown value with a more specific type
// constraint can give more opportunities to proactively detect downstream
// errors in a single evaluation pass.
Evaluate(ctx context.Context, hclCtx *hcl.EvalContext) (cty.Value, tfdiags.Diagnostics)
// ResultTypeConstraint returns a type constrant that all possible results
// from method Evaluate would conform to.
//
// This is used for static type checking. Return [cty.DynamicPseudoType]
// if it's impossible to predict any single type constraint for the
// possible results.
//
// TODO: Some implementations of this would be able to do better if they
// knew the types of everything that'd be passed in hclCtx when calling
// Evaluate. Is there some way we can approximate that?
ResultTypeConstraint() cty.Type
// EvalableSourceRange returns a description of a source location that this
// Evalable was derived from.
EvalableSourceRange() tfdiags.SourceRange
}
// ForcedErrorEvalable returns an [Evalable] that always fails with
// [cty.DynamicVal] as its placeholder result and with the given diagnostics,
// which must include at least one error or this function will panic.
//
// This is primarily intended for unit testing purposes for creating
// placeholders for upstream objects that have failed, but might also be useful
// sometimes for handling early-detected error situations in "real" code.
func ForcedErrorEvalable(diags tfdiags.Diagnostics, sourceRange tfdiags.SourceRange) Evalable {
if !diags.HasErrors() {
panic("ForcedErrorEvalable without any error diagnostics")
}
// We reuse the same type as ForcedErrorValuer here because it can
// implement both interfaces just fine with the information available here.
return forcedErrorValuer{diags, &sourceRange}
}
func StaticCheckTraversal(traversal hcl.Traversal, evalable Evalable) tfdiags.Diagnostics {
return StaticCheckTraversalThroughType(traversal, evalable.ResultTypeConstraint())
}
func StaticCheckTraversalThroughType(traversal hcl.Traversal, typeConstraint cty.Type) tfdiags.Diagnostics {
// We perform a static check by attempting to apply the traversal to
// an unknown value of the given type constraint, which will fail if
// no possible value meeting that type constraint could possibly support
// the traversal.
var diags tfdiags.Diagnostics
placeholder := cty.UnknownVal(typeConstraint)
_, hclDiags := traversal.TraverseRel(placeholder)
diags = diags.Append(hclDiags)
return diags
}
// hclExpression implements [Evalable] for a standalone [hcl.Expression].
type hclExpression struct {
expr hcl.Expression
}
// EvalableHCLExpression returns an [Evalable] that is just a thin wrapper
// around the given HCL expression.
func EvalableHCLExpression(expr hcl.Expression) Evalable {
return hclExpression{expr}
}
// Evaluate implements Evalable.
func (h hclExpression) Evaluate(ctx context.Context, hclCtx *hcl.EvalContext) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
v, hclDiags := h.expr.Value(hclCtx)
diags = diags.Append(hclDiags)
if hclDiags.HasErrors() {
v = cty.UnknownVal(h.ResultTypeConstraint()).WithSameMarks(v)
}
return v, diags
}
// FunctionCalls implements Evalable.
func (h hclExpression) FunctionCalls() iter.Seq[*hcl.StaticCall] {
// For now this is not implemented because the underlying HCL API
// isn't the right shape to implement this method.
return func(yield func(*hcl.StaticCall) bool) {}
}
// References implements Evalable.
func (h hclExpression) References() iter.Seq[hcl.Traversal] {
return slices.Values(h.expr.Variables())
}
// ResultTypeConstraint implements Evalable.
func (h hclExpression) ResultTypeConstraint() cty.Type {
// We can only predict the result type of the expression if it doesn't
// include any references or function calls.
v, hclDiags := h.expr.Value(nil)
if hclDiags.HasErrors() {
return cty.DynamicPseudoType
}
// For an expression that only uses constants, its type is guaranteed
// to always be the same.
return v.Type()
}
// SourceRange implements Evalable.
func (h hclExpression) EvalableSourceRange() tfdiags.SourceRange {
return tfdiags.SourceRangeFromHCL(h.expr.Range())
}
// hclBody implements [Evalable] for a [hcl.Body] and associated [hcldec.Spec].
type hclBody struct {
body hcl.Body
spec hcldec.Spec
dynblock bool
}
// EvalableHCLBody returns an [Evalable] that evaluates the given HCL body
// using the given [hcldec] specification.
func EvalableHCLBody(body hcl.Body, spec hcldec.Spec) Evalable {
return &hclBody{
body: body,
spec: spec,
dynblock: false,
}
}
// EvalableHCLBodyWithDynamicBlocks is a variant of [EvalableHCLBody] that
// calls [dynblock.Expand] before evaluating the body so that "dynamic" blocks
// would be supported and expanded to their equivalent static blocks.
func EvalableHCLBodyWithDynamicBlocks(body hcl.Body, spec hcldec.Spec) Evalable {
return &hclBody{
body: body,
spec: spec,
dynblock: true,
}
}
// Evaluate implements Evalable.
func (h *hclBody) Evaluate(ctx context.Context, hclCtx *hcl.EvalContext) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
body := h.body
if h.dynblock {
// [dynblock.Expand] wraps our body so that hcldec.Decode below will
// indirectly cause the "dynamic" blocks to be expanded, using the
// same evaluation context for the for_each expressions.
body = dynblock.Expand(body, hclCtx)
}
v, hclDiags := hcldec.Decode(body, h.spec, hclCtx)
diags = diags.Append(hclDiags)
if hclDiags.HasErrors() {
v = cty.UnknownVal(h.ResultTypeConstraint()).WithSameMarks(v)
}
return v, diags
}
// FunctionCalls implements Evalable.
func (h hclBody) FunctionCalls() iter.Seq[*hcl.StaticCall] {
// For now this is not implemented because the underlying HCL API
// isn't the right shape to implement this method.
return func(yield func(*hcl.StaticCall) bool) {}
}
// References implements Evalable.
func (h *hclBody) References() iter.Seq[hcl.Traversal] {
if h.dynblock {
// When we're doing dynblock expansion we need to do a little
// more work to also detect references from the for_each
// arguments in the "dynamic" blocks. The dynblock package
// does this additional work for us as long as we ask its
// wrapper function instead of hcldec.Variables directly.
return slices.Values(dynblock.VariablesHCLDec(h.body, h.spec))
}
return slices.Values(hcldec.Variables(h.body, h.spec))
}
// ResultTypeConstraint implements Evalable.
func (h *hclBody) ResultTypeConstraint() cty.Type {
return hcldec.ImpliedType(h.spec)
}
// SourceRange implements Evalable.
func (h *hclBody) EvalableSourceRange() tfdiags.SourceRange {
// The "missing item range" is not necessarily a good range to use here,
// but is the best we can do. At least in HCL native syntax this tends
// to be in the header of the block that contained the body and so
// is _close_ to the body being described.
return tfdiags.SourceRangeFromHCL(h.body.MissingItemRange())
}