stackeval: Factor out the two "walk drivers"

With validate, plan, and apply now all at least stubbed out we can see
that they all share some similar logic for visiting everything that's
relevant to an evaluation phase and gathering up diagnostics and other
external reports about each object.

Since it's important that the phases all visit the same objects so that
we can produce consistent results between phases, we'll accept a little
inversion-of-control complexity here in return for now having only two
"walk-driver" implementations: one for visiting the "static" objects and
one for visiting the "dynamic" objects.

 - The validate phase only visits static objects.
 - The plan phase visits static objects first, and then dynamic objects
   only if the static walk doesn't produce any errors.
 - The apply phase only visits the dynamic objects.

This also required some retroactive changes to some of my earlier work on
the validation phase since we can no longer assume that the validation
walk is the only one which visits the "static" objects. Their methods
now take EvalPhase arguments similar to those for the dynamic objects,
and we differentiate between the results in each phase so that we can
potentially add slight differences between the phases in future if needed.
As of this commit, though, the validate phase and the static portion of
the plan phase should produce identical results.
pull/34738/head
Martin Atkins 3 years ago
parent fafa36e73a
commit f8d2fef129

@ -289,3 +289,16 @@ func (pep *perEvalPhase[T]) For(phase EvalPhase) *T {
pep.mu.Unlock()
return ret
}
// Each calls the given reporting callback for all of the values the
// receiver is currently tracking.
//
// Each blocks calls to the For method throughout its execution, so callback
// functions must not interact with the receiver to avoid a deadlock.
func (pep *perEvalPhase[T]) Each(report func(EvalPhase, *T)) {
pep.mu.Lock()
for phase, val := range pep.vals {
report(phase, val)
}
pep.mu.Unlock()
}

@ -9,6 +9,7 @@ import (
"github.com/hashicorp/terraform/internal/promising"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackconfig"
"github.com/hashicorp/terraform/internal/stacks/stackplan"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
@ -131,7 +132,7 @@ func (v *InputVariableConfig) ExprReferenceValue(ctx context.Context, phase Eval
// Our apparent value is the value assigned in the definition object
// in the parent call.
call := v.StackCallConfig(ctx)
val := call.InputVariableValues(ctx)[v.Addr().Item]
val := call.InputVariableValues(ctx, phase)[v.Addr().Item]
if val == cty.NilVal {
val = cty.UnknownVal(v.TypeConstraint())
}
@ -139,14 +140,23 @@ func (v *InputVariableConfig) ExprReferenceValue(ctx context.Context, phase Eval
}
}
// Validate implements Validatable
func (v *InputVariableConfig) Validate(ctx context.Context) tfdiags.Diagnostics {
func (v *InputVariableConfig) checkValid(ctx context.Context, phase EvalPhase) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
_, moreDiags := v.ValidateDefaultValue(ctx)
diags = diags.Append(moreDiags)
return diags
}
// Validate implements Validatable
func (v *InputVariableConfig) Validate(ctx context.Context) tfdiags.Diagnostics {
return v.checkValid(ctx, ValidatePhase)
}
// PlanChanges implements Plannable.
func (v *InputVariableConfig) PlanChanges(ctx context.Context) ([]stackplan.PlannedChange, tfdiags.Diagnostics) {
return nil, v.checkValid(ctx, PlanPhase)
}
// reportNamedPromises implements namedPromiseReporter.
func (s *InputVariableConfig) reportNamedPromises(cb func(id promising.PromiseID, name string)) {
// Nothing to report yet

@ -40,6 +40,9 @@ func ApplyPlan(ctx context.Context, config *stackconfig.Config, rawPlan []*anypb
reg.RegisterComponentInstanceChange(
ctx, addr,
func(ctx context.Context, main *Main) (*states.State, tfdiags.Diagnostics) {
ctx, span := tracer.Start(ctx, addr.String()+" apply")
defer span.End()
stack := main.Stack(ctx, addr.Stack, ApplyPhase)
component := stack.Component(ctx, addr.Item.Component)
insts := component.Instances(ctx, ApplyPhase)
@ -91,6 +94,9 @@ func ApplyPlan(ctx context.Context, config *stackconfig.Config, rawPlan []*anypb
// each object to check itself (producing diagnostics) and announce any
// changes that were applied to it.
diags, err := promising.MainTask(ctx, func(ctx context.Context) (tfdiags.Diagnostics, error) {
ctx, span := tracer.Start(ctx, "apply-time checks")
defer span.End()
var seenSelfDepDiag atomic.Bool
ws, complete := newWalkStateCustomDiags(
func(diags tfdiags.Diagnostics) {
@ -118,12 +124,12 @@ func ApplyPlan(ctx context.Context, config *stackconfig.Config, rawPlan []*anypb
out: &outp,
}
// walkCheckAppliedChanges, and all of the downstream functions it calls,
// must take care to ensure that there's always at least one
// planWalk-tracked async task running until the entire process is
// complete. If one task launches another then the child task call
// must come before the caller's implementation function returns.
main.walkCheckAppliedChanges(ctx, walk, main.MainStack(ctx))
walkDynamicObjects(
ctx, walk, main,
func(ctx context.Context, walk *walkWithOutput[*ApplyOutput], obj DynamicEvaler) {
main.walkApplyCheckObjectChanges(ctx, walk, obj)
},
)
// Note: in practice this "complete" cannot actually return any
// diagnostics because our custom walkstate hooks above just announce
@ -178,80 +184,6 @@ type ApplyOutput struct {
// driver functions below.
type applyWalk = walkWithOutput[*ApplyOutput]
func (m *Main) walkCheckAppliedChanges(ctx context.Context, walk *applyWalk, stack *Stack) {
// We'll get the expansion of any child stack calls going first, so that
// we can explore downstream stacks concurrently with this one. Each
// stack call can represent zero or more child stacks that we'll analyze
// by recursive calls to this function.
for _, call := range stack.EmbeddedStackCalls(ctx) {
call := call // separate symbol per loop iteration
m.walkApplyCheckObjectChanges(ctx, walk, call)
// We need to perform the whole expansion in an overall async task
// because it involves evaluating for_each expressions, and one
// stack call's for_each might depend on the results of another.
walk.AsyncTask(ctx, func(ctx context.Context) {
insts := call.Instances(ctx, PlanPhase)
for _, inst := range insts {
m.walkApplyCheckObjectChanges(ctx, walk, inst)
childStack := inst.CalledStack(ctx)
m.walkCheckAppliedChanges(ctx, walk, childStack)
}
})
}
// We also need to visit and check all of the other declarations in
// the current stack.
for _, component := range stack.Components(ctx) {
component := component // separate symbol per loop iteration
m.walkApplyCheckObjectChanges(ctx, walk, component)
// We need to perform the instance expansion in an overall async task
// because it involves potentially evaluating a for_each expression.
// and that might depend on data from elsewhere in the same stack.
walk.AsyncTask(ctx, func(ctx context.Context) {
insts := component.Instances(ctx, PlanPhase)
for _, inst := range insts {
// This is the means by which we learn of any diagnostics from
// applying the component's plan and report that we've applied
// the changes; this indirectly consumes the results from
// the change actions scheduled earlier in [ApplyPlan].
m.walkApplyCheckObjectChanges(ctx, walk, inst)
}
})
}
for _, provider := range stack.Providers(ctx) {
provider := provider // separate symbol per loop iteration
m.walkApplyCheckObjectChanges(ctx, walk, provider)
// We need to perform the instance expansion in an overall async
// task because it involves potentially evaluating a for_each expression,
// and that might depend on data from elsewhere in the same stack.
walk.AsyncTask(ctx, func(ctx context.Context) {
insts := provider.Instances(ctx, PlanPhase)
for _, inst := range insts {
m.walkApplyCheckObjectChanges(ctx, walk, inst)
}
})
}
for _, variable := range stack.InputVariables(ctx) {
m.walkApplyCheckObjectChanges(ctx, walk, variable)
}
// TODO: Local values
for _, output := range stack.OutputValues(ctx) {
m.walkApplyCheckObjectChanges(ctx, walk, output)
}
// Finally we'll also check the stack itself, to deal with any problems
// with the stack as a whole rather than individual declarations inside.
m.walkApplyCheckObjectChanges(ctx, walk, stack)
}
// walkApplyCheckObjectChanges deals with the leaf objects that can directly
// contribute changes and/or diagnostics to the apply result, which should each
// implement [ApplyChecker].
@ -262,6 +194,9 @@ func (m *Main) walkCheckAppliedChanges(ctx context.Context, walk *applyWalk, sta
// deals with changes.)
func (m *Main) walkApplyCheckObjectChanges(ctx context.Context, walk *applyWalk, obj ApplyChecker) {
walk.AsyncTask(ctx, func(ctx context.Context) {
ctx, span := tracer.Start(ctx, obj.tracingName()+" apply-time checks")
defer span.End()
changes, diags := obj.CheckApply(ctx)
for _, change := range changes {
walk.out.AnnounceAppliedChange(ctx, change)

@ -45,38 +45,71 @@ func (m *Main) PlanAll(ctx context.Context, outp PlanOutput) {
// resolution to achieve the correct evaluation order.
var seenSelfDepDiag atomic.Bool
ws, complete := newWalkStateCustomDiags(
func(diags tfdiags.Diagnostics) {
for _, diag := range diags {
if diagIsPromiseSelfReference(diag) {
// We'll discard all but the first promise-self-reference
// diagnostic we see; these tend to get duplicated
// because they emerge from all codepaths participating
// in the self-reference at once.
if !seenSelfDepDiag.CompareAndSwap(false, true) {
continue
}
var seenAnyErrors atomic.Bool
reportDiags := func(diags tfdiags.Diagnostics) {
for _, diag := range diags {
if diag.Severity() == tfdiags.Error {
seenAnyErrors.Store(true)
}
if diagIsPromiseSelfReference(diag) {
// We'll discard all but the first promise-self-reference
// diagnostic we see; these tend to get duplicated
// because they emerge from all codepaths participating
// in the self-reference at once.
if !seenSelfDepDiag.CompareAndSwap(false, true) {
continue
}
outp.AnnounceDiagnostics(ctx, tfdiags.Diagnostics{diag})
}
},
func() tfdiags.Diagnostics {
// We emit all diagnostics immediately as they arrive, so
// we never have any accumulated diagnostics to emit at the end.
return nil
},
)
outp.AnnounceDiagnostics(ctx, tfdiags.Diagnostics{diag})
}
}
noopComplete := func() tfdiags.Diagnostics {
// We emit all diagnostics immediately as they arrive, so
// we never have any accumulated diagnostics to emit at the end.
return nil
}
// First we walk the static objects to give them a chance to check
// whether they are configured appropriately for planning. This
// allows us to report static problems only once for an entire
// configuration object, rather than redundantly reporting for every
// instance of the object.
ws, complete := newWalkStateCustomDiags(reportDiags, noopComplete)
walk := &planWalk{
state: ws,
out: &outp,
}
walkStaticObjects(
ctx, walk, m,
func(ctx context.Context, walk *walkWithOutput[*PlanOutput], obj StaticEvaler) {
m.walkPlanObjectChanges(ctx, walk, obj)
},
)
// Note: in practice this "complete" cannot actually return any
// diagnostics because our custom walkstate hooks above just announce
// the diagnostics immediately. But "complete" still serves the purpose
// of blocking until all of the async jobs are complete.
diags := complete()
if seenAnyErrors.Load() {
// If we already found static errors then we'll halt here to have
// the user correct those first.
return diags, nil
}
// walkPlanStackChanges, and all of the downstream functions it calls,
// must take care to ensure that there's always at least one
// planWalk-tracked async task running until the entire process is
// complete. If one task launches another then the child task call
// must come before the caller's implementation function returns.
m.walkPlanChanges(ctx, walk, m.MainStack(ctx))
// If the static walk completed then we'll now perform a dynamic walk
// which is where we'll actually produce the plan and where we'll
// learn about any dynamic errors which affect only specific instances
// of objects.
// We'll use a fresh walkState here because we already completed
// the previous one after the static walk.
ws, complete = newWalkStateCustomDiags(reportDiags, noopComplete)
walk.state = ws
walkDynamicObjects(
ctx, walk, m,
func(ctx context.Context, walk *planWalk, obj DynamicEvaler) {
m.walkPlanObjectChanges(ctx, walk, obj)
},
)
// Note: in practice this "complete" cannot actually return any
// diagnostics because our custom walkstate hooks above just announce
@ -124,99 +157,13 @@ type PlanOutput struct {
// driver functions below.
type planWalk = walkWithOutput[*PlanOutput]
func (m *Main) walkPlanChanges(ctx context.Context, walk *planWalk, stack *Stack) {
// We'll get the expansion of any child stack calls going first, so that
// we can explore downstream stacks concurrently with this one. Each
// stack call can represent zero or more child stacks that we'll analyze
// by recursive calls to this function.
for _, obj := range stack.EmbeddedStackCalls(ctx) {
// This must be a local variable inside the loop and _not_ a
// loop iterator variable because otherwise the function below
// will capture the same variable on every iteration, rather
// than a separate value each time.
call := obj
obj = nil // DO NOT use obj in the rest of this loop
m.walkPlanValidateConfig(ctx, walk, call.Config(ctx))
// We need to perform the whole expansion in an overall async task
// because it involves evaluating for_each expressions, and one
// stack call's for_each might depend on the results of another.
walk.AsyncTask(ctx, func(ctx context.Context) {
insts := call.Instances(ctx, PlanPhase)
for _, inst := range insts {
// We'll visit both the call instance itself and the stack
// instance it implies concurrently because output values
// inside one stack can contribute to the per-instance
// arguments of another stack.
m.walkPlanObjectChanges(ctx, walk, inst)
childStack := inst.CalledStack(ctx)
m.walkPlanChanges(ctx, walk, childStack)
}
})
}
// We also need to plan all of the other declarations in the current stack.
for _, obj := range stack.Components(ctx) {
// This must be a local variable inside the loop and _not_ a
// loop iterator variable because otherwise the function below
// will capture the same variable on every iteration, rather
// than a separate value each time.
component := obj
obj = nil // DO NOT use obj in the rest of this loop
m.walkPlanValidateConfig(ctx, walk, component.Config(ctx))
m.walkPlanObjectChanges(ctx, walk, component)
// We need to perform the instance expansion in an overall async task
// because it involves potentially evaluating a for_each expression.
// and that might depend on data from elsewhere in the same stack.
walk.AsyncTask(ctx, func(ctx context.Context) {
insts := component.Instances(ctx, PlanPhase)
for _, inst := range insts {
m.walkPlanObjectChanges(ctx, walk, inst)
}
})
}
for _, obj := range stack.Providers(ctx) {
obj := obj // to avoid sharing obj across all iterations
m.walkPlanValidateConfig(ctx, walk, obj.Config(ctx))
m.walkPlanObjectChanges(ctx, walk, obj)
// We need to perform the instance expansion in an overall async
// task because it involves potentially evaluating a for_each expression,
// and that might depend on data from elsewhere in the same stack.
walk.AsyncTask(ctx, func(ctx context.Context) {
insts := obj.Instances(ctx, PlanPhase)
for _, inst := range insts {
m.walkPlanObjectChanges(ctx, walk, inst)
}
})
}
for _, obj := range stack.InputVariables(ctx) {
m.walkPlanValidateConfig(ctx, walk, obj.Config(ctx))
m.walkPlanObjectChanges(ctx, walk, obj)
}
for _, obj := range stack.OutputValues(ctx) {
m.walkPlanValidateConfig(ctx, walk, obj.Config(ctx))
m.walkPlanObjectChanges(ctx, walk, obj)
}
// We'll also finally plan the stack itself, which will deal with anything
// that relates to the stack as a whole rather than to the objects declared
// inside.
m.walkPlanObjectChanges(ctx, walk, stack)
}
// walkPlanObjectChanges deals with the leaf objects that can directly
// contribute changes to the plan, which should each implement [Plannable].
func (m *Main) walkPlanObjectChanges(ctx context.Context, walk *planWalk, obj Plannable) {
walk.AsyncTask(ctx, func(ctx context.Context) {
ctx, span := tracer.Start(ctx, obj.tracingName()+" planning")
defer span.End()
changes, diags := obj.PlanChanges(ctx)
for _, change := range changes {
walk.out.AnnouncePlannedChange(ctx, change)
@ -226,21 +173,3 @@ func (m *Main) walkPlanObjectChanges(ctx context.Context, walk *planWalk, obj Pl
}
})
}
// walkPlanValidateConfig adapts the Validatable API to work in the planning
// phase, so that we can reuse the config-level validation logic to detect
// and report errors during planning.
//
// FIXME: The way we're currently calling this above is a bit wonky because
// we'll be re-validating the same objects multiple times for each instance
// of an embedded stack. Should probably treat the validation pass as a
// separate walk to be done first -- before planning -- so we can have it
// walk the configuration tree instead of the instance tree.
func (m *Main) walkPlanValidateConfig(ctx context.Context, walk *planWalk, obj Validatable) {
walk.AsyncTask(ctx, func(ctx context.Context) {
diags := obj.Validate(ctx)
if len(diags) != 0 {
walk.out.AnnounceDiagnostics(ctx, diags)
}
})
}

@ -26,11 +26,21 @@ func (m *Main) ValidateAll(ctx context.Context) tfdiags.Diagnostics {
// resolution to achieve the correct evaluation order.
ws, complete := newWalkState()
// walkValidateStackConfig, and all of the downstream functions it calls,
// must begin all of their asynchronous tasks before returning, so that
// the complete() call below knows the full set of asynchronous tasks
// that it's waiting for.
m.walkValidateStackConfig(ctx, ws, m.MainStackConfig(ctx))
// Our generic static walker is built to support the more advanced
// needs of the plan walk which produces streaming results through
// an "output" object. We don't need that here so we'll just stub
// it out as a zero-length type.
walk := &walkWithOutput[struct{}]{
out: struct{}{},
state: ws,
}
walkStaticObjects(
ctx, walk, m,
func(ctx context.Context, walk *walkWithOutput[struct{}], obj StaticEvaler) {
m.walkValidateObject(ctx, walk.state, obj)
},
)
return complete(), nil
})
@ -38,26 +48,6 @@ func (m *Main) ValidateAll(ctx context.Context) tfdiags.Diagnostics {
return finalDiagnosticsFromEval(diags)
}
func (m *Main) walkValidateStackConfig(ctx context.Context, ws *walkState, cfg *StackConfig) {
for _, obj := range cfg.InputVariables(ctx) {
m.walkValidateObject(ctx, ws, obj)
}
for _, obj := range cfg.OutputValues(ctx) {
m.walkValidateObject(ctx, ws, obj)
}
// TODO: All of the other validatable object types
for _, obj := range cfg.StackCalls(ctx) {
m.walkValidateObject(ctx, ws, obj)
}
for _, childCfg := range cfg.ChildConfigs(ctx) {
m.walkValidateStackConfig(ctx, ws, childCfg)
}
}
// walkValidateObject arranges for any given [Validatable] object to be
// asynchronously validated, reporting any of its diagnostics to the
// [walkState].
@ -69,11 +59,11 @@ func (m *Main) walkValidateStackConfig(ctx context.Context, ws *walkState, cfg *
func (m *Main) walkValidateObject(ctx context.Context, ws *walkState, obj Validatable) {
ws.AsyncTask(ctx, func(ctx context.Context) {
ctx, span := tracer.Start(ctx, obj.tracingName()+" validation")
defer span.End()
diags := obj.Validate(ctx)
ws.AddDiags(diags)
if diags.HasErrors() {
span.SetStatus(codes.Error, "validation returned errors")
}
defer span.End()
})
}

@ -8,6 +8,7 @@ import (
"github.com/hashicorp/terraform/internal/promising"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackconfig"
"github.com/hashicorp/terraform/internal/stacks/stackplan"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
@ -20,7 +21,7 @@ type OutputValueConfig struct {
main *Main
validatedValue promising.Once[withDiagnostics[cty.Value]]
validatedValue perEvalPhase[promising.Once[withDiagnostics[cty.Value]]]
}
var _ Validatable = (*OutputValueConfig)(nil)
@ -62,8 +63,8 @@ func (ov *OutputValueConfig) StackConfig(ctx context.Context) *StackConfig {
// If this output value is itself invalid then the result may be a
// compatibly-typed unknown placeholder value that's suitable for partial
// downstream validation.
func (ov *OutputValueConfig) Value(ctx context.Context) cty.Value {
v, _ := ov.ValidateValue(ctx)
func (ov *OutputValueConfig) Value(ctx context.Context, phase EvalPhase) cty.Value {
v, _ := ov.ValidateValue(ctx, phase)
return v
}
@ -80,9 +81,9 @@ func (ov *OutputValueConfig) ValueTypeConstraint(ctx context.Context) cty.Type {
// If the returned diagnostics has errors then the returned value might be
// just an approximation of the result, such as an unknown value with the
// declared type constraint.
func (ov *OutputValueConfig) ValidateValue(ctx context.Context) (cty.Value, tfdiags.Diagnostics) {
func (ov *OutputValueConfig) ValidateValue(ctx context.Context, phase EvalPhase) (cty.Value, tfdiags.Diagnostics) {
return withCtyDynamicValPlaceholder(doOnceWithDiags(
ctx, &ov.validatedValue, ov.main,
ctx, ov.validatedValue.For(phase), ov.main,
ov.validateValueInner,
))
}
@ -120,15 +121,31 @@ func (ov *OutputValueConfig) validateValueInner(ctx context.Context) (cty.Value,
return v, diags
}
// Validate implements Validatable.
func (ov *OutputValueConfig) Validate(ctx context.Context) tfdiags.Diagnostics {
func (ov *OutputValueConfig) checkValid(ctx context.Context, phase EvalPhase) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
_, moreDiags := ov.ValidateValue(ctx)
_, moreDiags := ov.ValidateValue(ctx, phase)
diags = diags.Append(moreDiags)
return diags
}
// Validate implements Validatable.
func (ov *OutputValueConfig) Validate(ctx context.Context) tfdiags.Diagnostics {
return ov.checkValid(ctx, ValidatePhase)
}
// PlanChanges implements Plannable.
func (ov *OutputValueConfig) PlanChanges(ctx context.Context) ([]stackplan.PlannedChange, tfdiags.Diagnostics) {
return nil, ov.checkValid(ctx, PlanPhase)
}
// reportNamedPromises implements namedPromiseReporter.
func (ov *OutputValueConfig) reportNamedPromises(report func(id promising.PromiseID, name string)) {
report(ov.validatedValue.PromiseID(), ov.addr.String()+" value")
// We'll report all of our value promises with the same name, since
// promises from different eval phases should not interact with one
// another and so mentioning the phase will typically just make any
// error messages more confusing.
valueName := ov.addr.String() + " value"
ov.validatedValue.Each(func(ep EvalPhase, once *promising.Once[withDiagnostics[cty.Value]]) {
report(once.PromiseID(), valueName)
})
}

@ -9,6 +9,7 @@ import (
"github.com/hashicorp/terraform/internal/promising"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackconfig"
"github.com/hashicorp/terraform/internal/stacks/stackplan"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
@ -22,9 +23,9 @@ type StackCallConfig struct {
main *Main
forEachValue promising.Once[withDiagnostics[cty.Value]]
inputVariableValues promising.Once[withDiagnostics[map[stackaddrs.InputVariable]cty.Value]]
resultValue promising.Once[withDiagnostics[cty.Value]]
forEachValue perEvalPhase[promising.Once[withDiagnostics[cty.Value]]]
inputVariableValues perEvalPhase[promising.Once[withDiagnostics[map[stackaddrs.InputVariable]cty.Value]]]
resultValue perEvalPhase[promising.Once[withDiagnostics[cty.Value]]]
}
var _ Validatable = (*StackCallConfig)(nil)
@ -95,9 +96,9 @@ func (s *StackCallConfig) ResultType(ctx context.Context) cty.Type {
// If the for_each expression is invalid in some way then the returned
// diagnostics will contain errors and the returned value will be a placeholder
// unknown value.
func (s *StackCallConfig) ValidateForEachValue(ctx context.Context) (cty.Value, tfdiags.Diagnostics) {
func (s *StackCallConfig) ValidateForEachValue(ctx context.Context, phase EvalPhase) (cty.Value, tfdiags.Diagnostics) {
return withCtyDynamicValPlaceholder(doOnceWithDiags(
ctx, &s.forEachValue, s.main,
ctx, s.forEachValue.For(phase), s.main,
s.validateForEachValueInner,
))
}
@ -127,9 +128,9 @@ func (s *StackCallConfig) validateForEachValueInner(ctx context.Context) (cty.Va
// If the returned diagnostics contains errors then the returned values may
// be incomplete, but should at least be of the types specified in the
// variable declarations.
func (s *StackCallConfig) ValidateInputVariableValues(ctx context.Context) (map[stackaddrs.InputVariable]cty.Value, tfdiags.Diagnostics) {
func (s *StackCallConfig) ValidateInputVariableValues(ctx context.Context, phase EvalPhase) (map[stackaddrs.InputVariable]cty.Value, tfdiags.Diagnostics) {
return doOnceWithDiags(
ctx, &s.inputVariableValues, s.main,
ctx, s.inputVariableValues.For(phase), s.main,
s.validateInputVariableValuesInner,
)
}
@ -231,8 +232,8 @@ func (s *StackCallConfig) validateInputVariableValuesInner(ctx context.Context)
// the validate phase, rather than for direct validation of this object. If you
// are intending to report problems directly to the user, use
// [StackCallConfig.ValidateInputVariableValues] instead.
func (s *StackCallConfig) InputVariableValues(ctx context.Context) map[stackaddrs.InputVariable]cty.Value {
ret, _ := s.ValidateInputVariableValues(ctx)
func (s *StackCallConfig) InputVariableValues(ctx context.Context, phase EvalPhase) map[stackaddrs.InputVariable]cty.Value {
ret, _ := s.ValidateInputVariableValues(ctx, phase)
return ret
}
@ -245,8 +246,8 @@ func (s *StackCallConfig) InputVariableValues(ctx context.Context) map[stackaddr
//
// The result is a good value to use for resolving "stack.foo" references
// in expressions elsewhere while running in validation mode.
func (s *StackCallConfig) ResultValue(ctx context.Context) cty.Value {
v, _ := s.ValidateResultValue(ctx)
func (s *StackCallConfig) ResultValue(ctx context.Context, phase EvalPhase) cty.Value {
v, _ := s.ValidateResultValue(ctx, phase)
return v
}
@ -260,43 +261,41 @@ func (s *StackCallConfig) ResultValue(ctx context.Context) cty.Value {
// is always an unknown value with a suitable type constraint, allowing
// downstream references to detect type-related errors but not value-related
// errors.
func (s *StackCallConfig) ValidateResultValue(ctx context.Context) (cty.Value, tfdiags.Diagnostics) {
func (s *StackCallConfig) ValidateResultValue(ctx context.Context, phase EvalPhase) (cty.Value, tfdiags.Diagnostics) {
return withCtyDynamicValPlaceholder(doOnceWithDiags(
ctx, &s.resultValue, s.main,
s.validateResultValueInner,
ctx, s.resultValue.For(phase), s.main,
func(ctx context.Context) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// Our result is really just all of the output values of all of our
// instances aggregated together into a single data structure, but
// we do need to do this a little differently depending on what
// kind of repetition (if any) this stack call is using.
switch {
case s.config.ForEach != nil:
// The call uses for_each, and so we can't actually build a known
// result just yet because we don't know yet how many instances
// there will be and what their keys will be. We'll just construct
// an unknown value of a suitable type instead.
return cty.UnknownVal(s.ResultType(ctx)), diags
default:
// No repetition at all, then. In this case we _can_ attempt to
// construct at least a partial result, because we already know
// there will be exactly one instance and can assume that
// the output value implementation will provide a suitable
// approximation of the final value.
calleeStack := s.CalleeConfig(ctx)
calleeOutputs := calleeStack.OutputValues(ctx)
attrs := make(map[string]cty.Value, len(calleeOutputs))
for addr, ov := range calleeOutputs {
attrs[addr.Name] = ov.Value(ctx, phase)
}
return cty.ObjectVal(attrs), diags
}
},
))
}
func (s *StackCallConfig) validateResultValueInner(ctx context.Context) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// Our result is really just all of the output values of all of our
// instances aggregated together into a single data structure, but
// we do need to do this a little differently depending on what
// kind of repetition (if any) this stack call is using.
switch {
case s.config.ForEach != nil:
// The call uses for_each, and so we can't actually build a known
// result just yet because we don't know yet how many instances
// there will be and what their keys will be. We'll just construct
// an unknown value of a suitable type instead.
return cty.UnknownVal(s.ResultType(ctx)), diags
default:
// No repetition at all, then. In this case we _can_ attempt to
// construct at least a partial result, because we already know
// there will be exactly one instance and can assume that
// the output value implementation will provide a suitable
// approximation of the final value.
calleeStack := s.CalleeConfig(ctx)
calleeOutputs := calleeStack.OutputValues(ctx)
attrs := make(map[string]cty.Value, len(calleeOutputs))
for addr, ov := range calleeOutputs {
attrs[addr.Name] = ov.Value(ctx)
}
return cty.ObjectVal(attrs), diags
}
}
// ResolveExpressionReference implements ExpressionScope for evaluating
// expressions within a "stack" block during the validation phase.
//
@ -323,26 +322,48 @@ func (s *StackCallConfig) ResolveExpressionReference(ctx context.Context, ref st
resolveExpressionReference(ctx, ref, instances.RepetitionData{}, nil)
}
// Validate implements Validatable
func (s *StackCallConfig) Validate(ctx context.Context) tfdiags.Diagnostics {
func (s *StackCallConfig) checkValid(ctx context.Context, phase EvalPhase) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
_, moreDiags := s.ValidateForEachValue(ctx)
_, moreDiags := s.ValidateForEachValue(ctx, phase)
diags = diags.Append(moreDiags)
_, moreDiags = s.ValidateInputVariableValues(ctx)
_, moreDiags = s.ValidateInputVariableValues(ctx, phase)
diags = diags.Append(moreDiags)
_, moreDiags = s.ValidateResultValue(ctx)
_, moreDiags = s.ValidateResultValue(ctx, phase)
diags = diags.Append(moreDiags)
return diags
}
// Validate implements Validatable
func (s *StackCallConfig) Validate(ctx context.Context) tfdiags.Diagnostics {
return s.checkValid(ctx, ValidatePhase)
}
// PlanChanges implements Plannable.
func (s *StackCallConfig) PlanChanges(ctx context.Context) ([]stackplan.PlannedChange, tfdiags.Diagnostics) {
return nil, s.checkValid(ctx, PlanPhase)
}
// ExprReferenceValue implements Referenceable.
func (s *StackCallConfig) ExprReferenceValue(ctx context.Context, phase EvalPhase) cty.Value {
return s.ResultValue(ctx)
return s.ResultValue(ctx, phase)
}
// reportNamedPromises implements namedPromiseReporter.
func (s *StackCallConfig) reportNamedPromises(cb func(id promising.PromiseID, name string)) {
cb(s.forEachValue.PromiseID(), s.Addr().String()+" for_each")
cb(s.inputVariableValues.PromiseID(), s.Addr().String()+" inputs")
cb(s.resultValue.PromiseID(), s.Addr().String()+" collected outputs")
// We'll report the same names for each promise in a given category
// because promises from different phases should not typically interact
// with one another and so mentioning phase here will typically just
// make error messages more confusing.
forEachName := s.Addr().String() + " for_each"
s.forEachValue.Each(func(ep EvalPhase, once *promising.Once[withDiagnostics[cty.Value]]) {
cb(once.PromiseID(), forEachName)
})
inputsName := s.Addr().String() + " inputs"
s.inputVariableValues.Each(func(ep EvalPhase, once *promising.Once[withDiagnostics[map[stackaddrs.InputVariable]cty.Value]]) {
cb(once.PromiseID(), inputsName)
})
resultName := s.Addr().String() + " collected outputs"
s.resultValue.Each(func(ep EvalPhase, once *promising.Once[withDiagnostics[cty.Value]]) {
cb(once.PromiseID(), resultName)
})
}

@ -0,0 +1,110 @@
package stackeval
import (
"context"
)
// DynamicEvaler is implemented by types that participate in dynamic
// evaluation phases, which currently includes [PlanPhase] and [ApplyPhase].
type DynamicEvaler interface {
Plannable
ApplyChecker
}
// walkDynamicObjects is a generic helper for visiting all of the "dynamic
// objects" in scope for a particular [Main] object. "Dynamic objects"
// essentially means the objects that are involved in the plan and apply
// operations, which includes instances of objects that can expand using
// "count" or "for_each" arguments.
//
// The walk value stays constant throughout the walk, being passed to
// all visited objects. Visits can happen concurrently, so any methods
// offered by Output must be concurrency-safe.
//
// The type parameter Object should be either [Plannable] or [ApplyChecker]
// depending on which walk this call is intending to drive. All dynamic
// objects must implement both of those interfaces, although for many
// object types the logic is equivalent across both.
func walkDynamicObjects[Output any](
ctx context.Context,
walk *walkWithOutput[Output],
main *Main,
visit func(ctx context.Context, walk *walkWithOutput[Output], obj DynamicEvaler),
) {
walkDynamicObjectsInStack(ctx, walk, main.MainStack(ctx), visit)
}
func walkDynamicObjectsInStack[Output any](
ctx context.Context,
walk *walkWithOutput[Output],
stack *Stack,
visit func(ctx context.Context, walk *walkWithOutput[Output], obj DynamicEvaler),
) {
// We'll get the expansion of any child stack calls going first, so that
// we can explore downstream stacks concurrently with this one. Each
// stack call can represent zero or more child stacks that we'll analyze
// by recursive calls to this function.
for _, call := range stack.EmbeddedStackCalls(ctx) {
call := call // separate symbol per loop iteration
visit(ctx, walk, call)
// We need to perform the whole expansion in an overall async task
// because it involves evaluating for_each expressions, and one
// stack call's for_each might depend on the results of another.
walk.AsyncTask(ctx, func(ctx context.Context) {
insts := call.Instances(ctx, PlanPhase)
for _, inst := range insts {
visit(ctx, walk, inst)
childStack := inst.CalledStack(ctx)
walkDynamicObjectsInStack(ctx, walk, childStack, visit)
}
})
}
// We also need to visit and check all of the other declarations in
// the current stack.
for _, component := range stack.Components(ctx) {
component := component // separate symbol per loop iteration
visit(ctx, walk, component)
// We need to perform the instance expansion in an overall async task
// because it involves potentially evaluating a for_each expression.
// and that might depend on data from elsewhere in the same stack.
walk.AsyncTask(ctx, func(ctx context.Context) {
insts := component.Instances(ctx, PlanPhase)
for _, inst := range insts {
visit(ctx, walk, inst)
}
})
}
for _, provider := range stack.Providers(ctx) {
provider := provider // separate symbol per loop iteration
visit(ctx, walk, provider)
// We need to perform the instance expansion in an overall async
// task because it involves potentially evaluating a for_each expression,
// and that might depend on data from elsewhere in the same stack.
walk.AsyncTask(ctx, func(ctx context.Context) {
insts := provider.Instances(ctx, PlanPhase)
for _, inst := range insts {
visit(ctx, walk, inst)
}
})
}
for _, variable := range stack.InputVariables(ctx) {
visit(ctx, walk, variable)
}
// TODO: Local values
for _, output := range stack.OutputValues(ctx) {
visit(ctx, walk, output)
}
// Finally we'll also check the stack itself, to deal with any problems
// with the stack as a whole rather than individual declarations inside.
visit(ctx, walk, stack)
}

@ -0,0 +1,60 @@
package stackeval
import (
"context"
)
// StaticEvaler is implemented by types that participate in static
// evaluation phases, which currently includes [ValidatePhase] and [PlanPhase].
type StaticEvaler interface {
Validatable
Plannable
}
// walkDynamicObjects is a generic helper for visiting all of the "static
// objects" in scope for a particular [Main] object. "Static objects"
// essentially means the objects that are involved in the validation
// operation, which typically includes objects representing static
// configuration elements that haven't yet been expanded into their
// dynamic counterparts.
//
// The walk value stays constant throughout the walk, being passed to
// all visited objects. Visits can happen concurrently, so any methods
// offered by Output must be concurrency-safe.
//
// The Object type parameter should either be Validatable or Plannable
// depending on which of the two relevant evaluation phases this function
// is supposed to be driving.
func walkStaticObjects[Output any](
ctx context.Context,
walk *walkWithOutput[Output],
main *Main,
visit func(ctx context.Context, walk *walkWithOutput[Output], obj StaticEvaler),
) {
walkStaticObjectsInStackConfig(ctx, walk, main.MainStackConfig(ctx), visit)
}
func walkStaticObjectsInStackConfig[Output any](
ctx context.Context,
walk *walkWithOutput[Output],
stackConfig *StackConfig,
visit func(ctx context.Context, walk *walkWithOutput[Output], obj StaticEvaler),
) {
for _, obj := range stackConfig.InputVariables(ctx) {
visit(ctx, walk, obj)
}
for _, obj := range stackConfig.OutputValues(ctx) {
visit(ctx, walk, obj)
}
// TODO: All of the other static object types
for _, obj := range stackConfig.StackCalls(ctx) {
visit(ctx, walk, obj)
}
for _, childCfg := range stackConfig.ChildConfigs(ctx) {
walkStaticObjectsInStackConfig(ctx, walk, childCfg, visit)
}
}
Loading…
Cancel
Save