diff --git a/internal/stacks/stackruntime/internal/stackeval/expressions.go b/internal/stacks/stackruntime/internal/stackeval/expressions.go index 57b2113dcb..600e6cc881 100644 --- a/internal/stacks/stackruntime/internal/stackeval/expressions.go +++ b/internal/stacks/stackruntime/internal/stackeval/expressions.go @@ -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() +} diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable_config.go b/internal/stacks/stackruntime/internal/stackeval/input_variable_config.go index 05ed66f7cc..867110a5ea 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable_config.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable_config.go @@ -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 diff --git a/internal/stacks/stackruntime/internal/stackeval/main_apply.go b/internal/stacks/stackruntime/internal/stackeval/main_apply.go index 8053dcc16e..e4c2ae9391 100644 --- a/internal/stacks/stackruntime/internal/stackeval/main_apply.go +++ b/internal/stacks/stackruntime/internal/stackeval/main_apply.go @@ -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) diff --git a/internal/stacks/stackruntime/internal/stackeval/main_plan.go b/internal/stacks/stackruntime/internal/stackeval/main_plan.go index ca3b07fe39..694d3cec2f 100644 --- a/internal/stacks/stackruntime/internal/stackeval/main_plan.go +++ b/internal/stacks/stackruntime/internal/stackeval/main_plan.go @@ -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) - } - }) -} diff --git a/internal/stacks/stackruntime/internal/stackeval/main_validate.go b/internal/stacks/stackruntime/internal/stackeval/main_validate.go index d300e367fd..11fdc2b18a 100644 --- a/internal/stacks/stackruntime/internal/stackeval/main_validate.go +++ b/internal/stacks/stackruntime/internal/stackeval/main_validate.go @@ -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() }) } diff --git a/internal/stacks/stackruntime/internal/stackeval/output_value_config.go b/internal/stacks/stackruntime/internal/stackeval/output_value_config.go index 76e98b7036..3d3666bd97 100644 --- a/internal/stacks/stackruntime/internal/stackeval/output_value_config.go +++ b/internal/stacks/stackruntime/internal/stackeval/output_value_config.go @@ -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) + }) } diff --git a/internal/stacks/stackruntime/internal/stackeval/stack_call_config.go b/internal/stacks/stackruntime/internal/stackeval/stack_call_config.go index 5ec79d5d54..209fa4782e 100644 --- a/internal/stacks/stackruntime/internal/stackeval/stack_call_config.go +++ b/internal/stacks/stackruntime/internal/stackeval/stack_call_config.go @@ -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) + }) } diff --git a/internal/stacks/stackruntime/internal/stackeval/walk_dynamic.go b/internal/stacks/stackruntime/internal/stackeval/walk_dynamic.go new file mode 100644 index 0000000000..de0881183c --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/walk_dynamic.go @@ -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) +} diff --git a/internal/stacks/stackruntime/internal/stackeval/walk_static.go b/internal/stacks/stackruntime/internal/stackeval/walk_static.go new file mode 100644 index 0000000000..ba02f19c29 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/walk_static.go @@ -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) + } +}