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.