lang/eval: Enough resource support for basic validation to work

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
This commit is contained in:
Martin Atkins
2025-09-03 14:51:24 -07:00
parent 507055bc2f
commit 9d7eb9742e
9 changed files with 317 additions and 32 deletions

View File

@@ -13,7 +13,9 @@ import (
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/lang/eval"
"github.com/opentofu/opentofu/internal/providers"
"github.com/opentofu/opentofu/internal/tfdiags"
)
@@ -154,6 +156,65 @@ func TestValidate_valuesOnlyCycle(t *testing.T) {
gotDiags := configInst.Validate(t.Context())
gotDiags.Sort() // we don't care what order they are in
assertDiagnosticsMatch(t, gotDiags, wantDiags)
}
func TestValidate_resourceValid(t *testing.T) {
configInst, diags := eval.NewConfigInstance(t.Context(), &eval.ConfigCall{
EvalContext: &eval.EvalContext{
Modules: eval.ModulesForTesting(map[addrs.ModuleSourceLocal]*configs.Module{
addrs.ModuleSourceLocal("."): configs.ModuleFromStringForTesting(t, `
terraform {
required_providers {
foo = {
source = "test/foo"
}
}
}
variable "in" {
type = string
}
resource "foo" "bar" {
name = var.in
}
output "out" {
value = foo.bar.id
}
`),
}),
Providers: eval.ProvidersForTesting(map[addrs.Provider]*providers.GetProviderSchemaResponse{
addrs.MustParseProviderSourceString("test/foo"): {
Provider: providers.Schema{
Block: &configschema.Block{},
},
ResourceTypes: map[string]providers.Schema{
"foo": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"name": {
Type: cty.String,
Required: true,
},
"id": {
Type: cty.String,
Computed: true,
},
},
},
},
},
},
}),
},
RootModuleSource: addrs.ModuleSourceLocal("."),
InputValues: eval.InputValuesForTesting(map[string]cty.Value{
"in": cty.StringVal("foo bar baz"),
}),
})
if diags.HasErrors() {
t.Fatalf("unexpected errors: %s", diags.Err())
}
diags = configInst.Validate(t.Context())
if diags.HasErrors() {
t.Fatalf("unexpected errors: %s", diags.Err())
}

View File

@@ -7,9 +7,13 @@ package eval
import (
"context"
"errors"
"fmt"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/zclconf/go-cty/cty/gocty"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/instances"
@@ -23,13 +27,22 @@ func compileInstanceSelector(ctx context.Context, declScope exprs.Scope, forEach
// because we expect the configs package to check that.
if forEachExpr != nil {
return compileInstanceSelectorForEach(ctx, declScope, forEachExpr)
return compileInstanceSelectorForEach(ctx, exprs.NewClosure(
exprs.EvalableHCLExpression(forEachExpr),
declScope,
))
}
if countExpr != nil {
return compileInstanceSelectorCount(ctx, declScope, countExpr)
return compileInstanceSelectorCount(ctx, exprs.NewClosure(
exprs.EvalableHCLExpression(countExpr),
declScope,
))
}
if enabledExpr != nil {
return compileInstanceSelectorEnabled(ctx, declScope, enabledExpr)
return compileInstanceSelectorEnabled(ctx, exprs.NewClosure(
exprs.EvalableHCLExpression(enabledExpr),
declScope,
))
}
return compileInstanceSelectorSingleton(ctx)
}
@@ -47,15 +60,107 @@ func compileInstanceSelectorSingleton(_ context.Context) configgraph.InstanceSel
}
}
func compileInstanceSelectorCount(_ context.Context, declScope exprs.Scope, expr hcl.Expression) configgraph.InstanceSelector {
panic("unimplemented")
func compileInstanceSelectorCount(_ context.Context, countValuer exprs.Valuer) configgraph.InstanceSelector {
countValuer = configgraph.ValuerOnce(countValuer)
return &instanceSelector{
keyType: addrs.IntKeyType,
sourceRange: nil,
selectInstances: func(ctx context.Context) (configgraph.Maybe[configgraph.InstancesSeq], cty.ValueMarks, tfdiags.Diagnostics) {
var count int
countVal, diags := countValuer.Value(ctx)
if diags.HasErrors() {
return nil, nil, diags
}
countVal, marks := countVal.Unmark()
countVal, err := convert.Convert(countVal, cty.Number)
if err == nil && !countVal.IsKnown() {
// We represent "unknown" by returning a nil configgraph.Maybe
// without any error diagnostics, but we will still report
// what marks we found on the unknown value.
return nil, marks, diags
}
if err == nil && countVal.IsNull() {
err = errors.New("must not be null")
}
if err == nil {
err = gocty.FromCtyValue(countVal, &count)
}
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid value for instance count",
Detail: fmt.Sprintf("Unsuitable value for the \"count\" meta-argument: %s.", tfdiags.FormatError(err)),
Subject: configgraph.MaybeHCLSourceRange(countValuer.ValueSourceRange()),
})
return nil, marks, diags
}
// If we manage to get here then "count" is the desired number of
// instances, and so we'll yield incrementing integers up to
// that number, exclusive.
seq := func(yield func(addrs.InstanceKey, instances.RepetitionData) bool) {
for i := range count {
more := yield(addrs.IntKey(i), instances.RepetitionData{
CountIndex: cty.NumberIntVal(int64(i)),
})
if !more {
break
}
}
}
return configgraph.Known(seq), nil, nil
},
}
}
func compileInstanceSelectorEnabled(_ context.Context, declScope exprs.Scope, expr hcl.Expression) configgraph.InstanceSelector {
panic("unimplemented")
func compileInstanceSelectorEnabled(_ context.Context, enabledValuer exprs.Valuer) configgraph.InstanceSelector {
enabledValuer = configgraph.ValuerOnce(enabledValuer)
return &instanceSelector{
keyType: addrs.NoKeyType,
sourceRange: nil,
selectInstances: func(ctx context.Context) (configgraph.Maybe[configgraph.InstancesSeq], cty.ValueMarks, tfdiags.Diagnostics) {
var enabled bool
enabledVal, diags := enabledValuer.Value(ctx)
if diags.HasErrors() {
return nil, nil, diags
}
enabledVal, marks := enabledVal.Unmark()
enabledVal, err := convert.Convert(enabledVal, cty.Bool)
if err == nil && !enabledVal.IsKnown() {
// We represent "unknown" by returning a nil configgraph.Maybe
// without any error diagnostics, but we will still report
// what marks we found on the unknown value.
return nil, marks, diags
}
if err == nil && enabledVal.IsNull() {
err = errors.New("must not be null")
}
if err == nil {
err = gocty.FromCtyValue(enabledVal, &enabled)
}
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid value for instance enabled",
Detail: fmt.Sprintf("Unsuitable value for the \"enabled\" meta-argument: %s.", tfdiags.FormatError(err)),
Subject: configgraph.MaybeHCLSourceRange(enabledValuer.ValueSourceRange()),
})
return nil, marks, diags
}
// If we manage to get here then "enabled" is true only if there
// should be an instance of this resource.
seq := func(yield func(addrs.InstanceKey, instances.RepetitionData) bool) {
if enabled {
yield(addrs.NoKey, instances.RepetitionData{})
}
}
return configgraph.Known(seq), nil, nil
},
}
}
func compileInstanceSelectorForEach(_ context.Context, declScope exprs.Scope, expr hcl.Expression) configgraph.InstanceSelector {
func compileInstanceSelectorForEach(_ context.Context, forEachValuer exprs.Valuer) configgraph.InstanceSelector {
// TODO: The logic for this one is a little more complex so I'll come
// back to this once more of the rest of this is working.
panic("unimplemented")
}

View File

@@ -13,7 +13,7 @@ import (
"github.com/opentofu/opentofu/internal/tfdiags"
)
func maybeHCLSourceRange(maybeRng *tfdiags.SourceRange) *hcl.Range {
func MaybeHCLSourceRange(maybeRng *tfdiags.SourceRange) *hcl.Range {
if maybeRng == nil {
return nil
}

View File

@@ -78,7 +78,7 @@ func (i *InputVariable) Value(ctx context.Context) (cty.Value, tfdiags.Diagnosti
Severity: hcl.DiagError,
Summary: "Invalid value for input variable",
Detail: fmt.Sprintf("Unsuitable value for variable %q: %s.", i.Addr.Variable.Name, tfdiags.FormatError(err)),
Subject: maybeHCLSourceRange(i.ValueSourceRange()),
Subject: MaybeHCLSourceRange(i.ValueSourceRange()),
})
finalV = cty.UnknownVal(i.TargetType.WithoutOptionalAttributesDeep())
}

View File

@@ -206,6 +206,8 @@ func valueForInstances[T exprs.Valuer](ctx context.Context, insts *compiledInsta
attrs := make(map[string]cty.Value, len(insts.Instances))
for key, obj := range insts.Instances {
attrName := string(key.(addrs.StringKey)) // panic here means buggy [InstanceSelector]
// Diagnostics for this are collected directly from the instance
// when the CheckAll tree walk visits it.
attrs[attrName] = diagsHandledElsewhere(obj.Value(ctx))
}
return cty.ObjectVal(attrs).WithMarks(insts.ValueMarks)
@@ -214,11 +216,23 @@ func valueForInstances[T exprs.Valuer](ctx context.Context, insts *compiledInsta
if _, ok := insts.Instances[addrs.WildcardKey{addrs.StringKeyType}]; ok {
// In this case we cannot predict anything about the placeholder
// result because if we don't know how many instances we have
// // then we cannot even predict the tuple type.
// then we cannot even predict the tuple type.
return cty.DynamicVal.WithMarks(insts.ValueMarks)
}
// TODO: Implement
panic("value construction for \"count\" resources not yet implemented")
elems := make([]cty.Value, len(insts.Instances))
for i := range elems {
inst, ok := insts.Instances[addrs.IntKey(i)]
if !ok {
// This should not be possible for a correct [InstanceSelector]
// but this is not the place to deal with that.
elems[i] = cty.DynamicVal
continue
}
// Diagnostics for this are collected directly from the instance
// when the CheckAll tree walk visits it.
elems[i] = diagsHandledElsewhere(inst.Value(ctx))
}
return cty.TupleVal(elems).WithMarks(insts.ValueMarks)
default:
// Should not get here because [InstanceSelector] is not allowed to

View File

@@ -166,13 +166,56 @@ func (m *ModuleInstance) ResolveAttr(ref hcl.TraverseAttr) (exprs.Attribute, tfd
})
return nil, diags
// All of these resource-related symbols ultimately end up in
// [ModuleInstance.resolveResourceAttr] after indirecting through
// one or two more attribute steps.
case "resource":
return exprs.NestedSymbolTable(&moduleInstanceResourceSymbolTable{
mode: addrs.ManagedResourceMode,
moduleInst: m,
startRng: ref.SrcRange,
}), diags
case "data":
return exprs.NestedSymbolTable(&moduleInstanceResourceSymbolTable{
mode: addrs.DataResourceMode,
moduleInst: m,
}), diags
case "ephemeral":
return exprs.NestedSymbolTable(&moduleInstanceResourceSymbolTable{
mode: addrs.EphemeralResourceMode,
moduleInst: m,
}), diags
default:
// TODO: Once we support resource references this case should be treated
// as the beginning of a reference to a managed resource, as a
// shorthand omitting the "resource." prefix.
diags = diags.Append(fmt.Errorf("no support for %q references yet", ref.Name))
// We treat all unrecognized prefixes as a shorthand for "resource."
// where the first segment is the resource type name.
return exprs.NestedSymbolTable(&moduleInstanceResourceSymbolTable{
mode: addrs.ManagedResourceMode,
typeName: ref.Name,
moduleInst: m,
}), diags
}
}
func (m *ModuleInstance) resolveResourceAttr(addr addrs.Resource, rng tfdiags.SourceRange) (exprs.Attribute, tfdiags.Diagnostics) {
// This function handles references like "aws_instance.foo" and
// "data.aws_subnet.bar" after the intermediate steps have been
// collected using [moduleInstanceResourceSymbolTable]. Refer to
// [ModuleInstance.ResourceAttr] for the beginning of this process.
var diags tfdiags.Diagnostics
r, ok := m.ResourceNodes[addr]
if !ok {
// TODO: Try using "didyoumean" with resource types and names that
// _are_ declared in the module to see if we can suggest an alternatve.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Reference to undeclared resource variable",
Detail: fmt.Sprintf("There is no declaration of resource %s in this module.", addr),
Subject: rng.ToHCL().Ptr(),
})
return nil, diags
}
return exprs.ValueOf(r), diags
}
func (m *ModuleInstance) resolveSimpleChildAttr(topSymbol string, ref hcl.TraverseAttr) (exprs.Attribute, tfdiags.Diagnostics) {
@@ -317,6 +360,77 @@ func (m *ModuleInstance) AnnounceAllGraphevalRequests(announce func(workgraph.Re
}
}
type moduleInstanceResourceSymbolTable struct {
mode addrs.ResourceMode
// We reuse this type for both the first step like "data." and the
// second step like "data.foo.". typeName is the empty string for
// the first step, and then populated in the second step.
typeName string
moduleInst *ModuleInstance
startRng hcl.Range
}
var _ exprs.SymbolTable = (*moduleInstanceResourceSymbolTable)(nil)
// HandleInvalidStep implements exprs.SymbolTable.
func (m *moduleInstanceResourceSymbolTable) HandleInvalidStep(rng tfdiags.SourceRange) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
if m.typeName == "" {
// We're at the first step and expecting a resource type name, then.
adjective := ""
switch m.mode {
case addrs.ManagedResourceMode:
adjective = "managed "
case addrs.DataResourceMode:
adjective = "data "
case addrs.EphemeralResourceMode:
adjective = "ephemeral "
default:
// We'll just omit any adjective if it isn't one we know, though
// we should ideally update the above if we add a new resource mode.
}
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid reference to resource",
Detail: fmt.Sprintf("An attribute access is required here, naming the type of %sresource to refer to.", adjective),
Subject: rng.ToHCL().Ptr(),
})
} else {
// We're at the second step and expecting a resource name.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid reference to resource",
Detail: fmt.Sprintf("An attribute access is required here, giving the name of the %q resource to refer to.", m.typeName),
Subject: rng.ToHCL().Ptr(),
})
}
return diags
}
// ResolveAttr implements exprs.SymbolTable.
func (m *moduleInstanceResourceSymbolTable) ResolveAttr(ref hcl.TraverseAttr) (exprs.Attribute, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
if m.typeName == "" {
// We're at the first step and expecting a resource type name, then.
// We'll return a new instance with the given type name populated
// so that we can collect the resource name from the next step.
return exprs.NestedSymbolTable(&moduleInstanceResourceSymbolTable{
mode: m.mode,
typeName: ref.Name,
moduleInst: m.moduleInst,
startRng: m.startRng,
}), diags
}
// We're at the second step and expecting a resource name. We'll now
// delegate back to the main module instance to handle the reference.
addr := addrs.Resource{
Mode: m.mode,
Type: m.typeName,
Name: ref.Name,
}
return m.moduleInst.resolveResourceAttr(addr, tfdiags.SourceRangeFromHCL(hcl.RangeBetween(m.startRng, ref.SrcRange)))
}
// moduleInstNestedSymbolTable is a common implementation for all of the
// various "simple" nested symbol table prefixes in a module instance's
// top-level scope, handling the typical case where there's a fixed prefix

View File

@@ -122,7 +122,7 @@ func (o *OutputValue) Value(ctx context.Context) (cty.Value, tfdiags.Diagnostics
Severity: hcl.DiagError,
Summary: "Invalid value for output value",
Detail: fmt.Sprintf("Unsuitable value for output value %q: %s.", o.Addr.OutputValue.Name, tfdiags.FormatError(err)),
Subject: maybeHCLSourceRange(o.ValueSourceRange()),
Subject: MaybeHCLSourceRange(o.ValueSourceRange()),
})
finalV = cty.UnknownVal(o.TargetType.WithoutOptionalAttributesDeep())
}

View File

@@ -114,16 +114,15 @@ func (r *Resource) decideInstances(ctx context.Context) (*compiledInstances[*Res
}
func (r *Resource) compileInstance(ctx context.Context, key addrs.InstanceKey, repData instances.RepetitionData) *ResourceInstance {
var ret *ResourceInstance
ret = &ResourceInstance{
Addr: r.Addr.Instance(key),
Provider: r.Provider,
GetResultValue: r.GetInstanceResultValue(ctx, ret),
ret := &ResourceInstance{
Addr: r.Addr.Instance(key),
Provider: r.Provider,
ConfigValuer: ValuerOnce(exprs.NewClosure(
r.ConfigEvalable,
instanceLocalScope(r.ParentScope, repData),
)),
}
ret.GetResultValue = r.GetInstanceResultValue(ctx, ret)
return ret
}

View File

@@ -76,19 +76,11 @@ func (ri *ResourceInstance) StaticCheckTraversal(traversal hcl.Traversal) tfdiag
return ri.ConfigValuer.StaticCheckTraversal(traversal)
}
// ConfigValue returns the validated configuration value, or a placeholder
// to use instead of an invalid configuration value.
func (ri *ResourceInstance) ConfigValue(ctx context.Context) (cty.Value, tfdiags.Diagnostics) {
// TODO: Check preconditions before calling Value, and then call the
// provider's own validate function after calling Value.
return ri.ConfigValuer.Value(ctx)
}
// Value implements exprs.Valuer.
func (ri *ResourceInstance) Value(ctx context.Context) (cty.Value, tfdiags.Diagnostics) {
// We use the configuration value here only for its marks, since that
// allows us to propagate any
configVal, diags := ri.ConfigValue(ctx)
configVal, diags := ri.ConfigValuer.Value(ctx)
if diags.HasErrors() {
// If we don't have a valid config value then we'll stop early
// with an unknown value placeholder so that the external process