From 0fe26468cdb3eb475e5280b2202ef118a6cded27 Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Mon, 4 Mar 2024 16:20:17 -0500 Subject: [PATCH] stackruntime: Apply defaults to root variables When evaluating a stack's root input variables, supplied by the caller, we must apply any default values specified in the variable configuration for variables with no specified value. This commit adds this default fallback case, using NilVal as a marker indicating the lack of a specified value. If no default value exists for a variable, it is therefore required to be supplied by the caller. This commit also reports a diagnostic error in this case. --- .../internal/stackeval/input_variable.go | 28 +++++++++- .../stackruntime/internal/stackeval/main.go | 10 +++- internal/stacks/stackruntime/plan_test.go | 53 ++++++++++++++++++- .../unset-variable.tfstack.hcl | 3 ++ internal/stacks/stackruntime/validate_test.go | 12 ++++- 5 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/plan-no-value-for-required-variable/unset-variable.tfstack.hcl diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable.go b/internal/stacks/stackruntime/internal/stackeval/input_variable.go index 6b0d2867e0..68e0482baf 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable.go @@ -109,8 +109,34 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va switch { case v.Addr().Stack.IsRoot(): - extVal := v.main.RootVariableValue(ctx, v.Addr().Item, phase) wantTy := v.Declaration(ctx).Type.Constraint + + extVal := v.main.RootVariableValue(ctx, v.Addr().Item, phase) + + // If the calling context does not define a value for this + // variable, we need to fall back to the default. + if extVal.Value == cty.NilVal { + cfg := v.Config(ctx) + + // A separate code path will validate the default value, so + // we don't need to do that here. + defVal := cfg.DefaultValue(ctx) + if defVal == cty.NilVal { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "No value for required variable", + Detail: fmt.Sprintf("The root input variable %q is not set, and has no default value.", v.Addr()), + Subject: cfg.config.DeclRange.ToHCL().Ptr(), + }) + return cty.UnknownVal(wantTy), diags + } + + extVal = ExternalInputValue{ + Value: defVal, + DefRange: cfg.Declaration().DeclRange, + } + } + val, err := convert.Convert(extVal.Value, wantTy) const errSummary = "Invalid value for root input variable" if err != nil { diff --git a/internal/stacks/stackruntime/internal/stackeval/main.go b/internal/stacks/stackruntime/internal/stackeval/main.go index 25d11f6617..e2772bc76b 100644 --- a/internal/stacks/stackruntime/internal/stackeval/main.go +++ b/internal/stacks/stackruntime/internal/stackeval/main.go @@ -442,6 +442,10 @@ func (m *Main) PreviousProviderInstances(addr stackaddrs.AbsComponentInstance, p } } +// RootVariableValue returns the original root variable value specified by the +// caller, if any. The caller of this function is responsible for replacing +// missing values with defaults, and performing type conversion and and +// validation. func (m *Main) RootVariableValue(ctx context.Context, addr stackaddrs.InputVariable, phase EvalPhase) ExternalInputValue { switch phase { case PlanPhase: @@ -450,8 +454,12 @@ func (m *Main) RootVariableValue(ctx context.Context, addr stackaddrs.InputVaria } ret, ok := m.planning.opts.InputVariableValues[addr] if !ok { + // If no value is specified for the given input variable, we return + // a nil placeholder. Nil can never be specified, so the caller can + // determine that the variable's default value should be used (if + // present) or an error raised (if not). return ExternalInputValue{ - Value: cty.NullVal(cty.DynamicPseudoType), + Value: cty.NilVal, } } return ret diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 2466f6a141..7fd9f5f5d8 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -234,6 +234,57 @@ func TestPlanWithMissingInputVariable(t *testing.T) { } } +func TestPlanWithNoValueForRequiredVariable(t *testing.T) { + ctx := context.Background() + cfg := loadMainBundleConfigForTest(t, "plan-no-value-for-required-variable") + + fakePlanTimestamp, err := time.Parse(time.RFC3339, "1994-09-05T08:50:00Z") + if err != nil { + t.Fatal(err) + } + + changesCh := make(chan stackplan.PlannedChange, 8) + diagsCh := make(chan tfdiags.Diagnostic, 2) + req := PlanRequest{ + Config: cfg, + ProviderFactories: map[addrs.Provider]providers.Factory{ + addrs.NewBuiltInProvider("terraform"): func() (providers.Interface, error) { + return terraformProvider.NewProvider(), nil + }, + }, + + ForcePlanTimestamp: &fakePlanTimestamp, + } + resp := PlanResponse{ + PlannedChanges: changesCh, + Diagnostics: diagsCh, + } + + go Plan(ctx, &req, &resp) + _, gotDiags := collectPlanOutput(changesCh, diagsCh) + + // We'll normalize the diagnostics to be of consistent underlying type + // using ForRPC, so that we can easily diff them; we don't actually care + // about which underlying implementation is in use. + gotDiags = gotDiags.ForRPC() + var wantDiags tfdiags.Diagnostics + wantDiags = wantDiags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "No value for required variable", + Detail: `The root input variable "var.beep" is not set, and has no default value.`, + Subject: &hcl.Range{ + Filename: mainBundleSourceAddrStr("plan-no-value-for-required-variable/unset-variable.tfstack.hcl"), + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 16, Byte: 15}, + }, + }) + wantDiags = wantDiags.ForRPC() + + if diff := cmp.Diff(wantDiags, gotDiags); diff != "" { + t.Errorf("wrong diagnostics\n%s", diff) + } +} + func TestPlanWithSingleResource(t *testing.T) { ctx := context.Background() cfg := loadMainBundleConfigForTest(t, "with-single-resource") @@ -433,7 +484,7 @@ func TestPlanVariableOutputRoundtripNested(t *testing.T) { Addr: stackaddrs.InputVariable{ Name: "msg", }, - Value: cty.NullVal(cty.String), + Value: cty.StringVal("default"), }, } sort.SliceStable(gotChanges, func(i, j int) bool { diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/plan-no-value-for-required-variable/unset-variable.tfstack.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/plan-no-value-for-required-variable/unset-variable.tfstack.hcl new file mode 100644 index 0000000000..d02969f012 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/plan-no-value-for-required-variable/unset-variable.tfstack.hcl @@ -0,0 +1,3 @@ +variable "beep" { + type = string +} diff --git a/internal/stacks/stackruntime/validate_test.go b/internal/stacks/stackruntime/validate_test.go index d238edb322..64cd295dc2 100644 --- a/internal/stacks/stackruntime/validate_test.go +++ b/internal/stacks/stackruntime/validate_test.go @@ -50,8 +50,16 @@ var ( }), }, }, - filepath.Join("with-single-input", "provider-name-clash"): {}, - filepath.Join("with-single-input", "valid"): {}, + filepath.Join("with-single-input", "provider-name-clash"): { + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("input"), + }, + }, + filepath.Join("with-single-input", "valid"): { + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("input"), + }, + }, } // invalidConfigurations are shared between the validate and plan tests.