stacks: validate the types of input variables during validation (#34722)

pull/34730/head
Liam Cervante 2 years ago committed by GitHub
parent 68fd15dfdd
commit b2cc7dbadf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -257,6 +257,21 @@ func (c *ComponentConfig) InputsType(ctx context.Context) (cty.Type, *typeexpr.D
return retTy, defs
}
func (c *ComponentConfig) CheckInputVariableValues(ctx context.Context, phase EvalPhase) tfdiags.Diagnostics {
wantTy, defs := c.InputsType(ctx)
if wantTy == cty.NilType {
// Suggests that the module tree is invalid. We validate the full module
// tree elsewhere, which will hopefully detect the problems here.
return nil
}
decl := c.Declaration(ctx)
// We don't care about the returned value, only that it has no errors.
_, diags := EvalComponentInputVariables(ctx, wantTy, defs, decl, phase, c)
return diags
}
// RequiredProviderInstances returns a description of all of the provider
// instance slots ("provider configurations" in main Terraform language
// terminology) that are either explicitly declared or implied by the
@ -434,11 +449,18 @@ func (c *ComponentConfig) checkValid(ctx context.Context, phase EvalPhase) tfdia
}
decl := c.Declaration(ctx)
// TODO: Also check if the input variables are valid.
variableDiags := c.CheckInputVariableValues(ctx, phase)
diags = diags.Append(variableDiags)
// We don't actually exit if we found errors with the input variables,
// we can still validate the actual module tree without them.
_, providerDiags := c.CheckProviders(ctx, phase)
diags = diags.Append(providerDiags)
if providerDiags.HasErrors() {
// If there's invalid provider configuration, we can't actually go
// on and validate the module tree. We need the providers and if
// they're invalid we'll just get crazy and confusing errors
// later if we try and carry on.
return diags, nil
}
@ -448,7 +470,9 @@ func (c *ComponentConfig) checkValid(ctx context.Context, phase EvalPhase) tfdia
return diags, nil
}
// TODO: Manually validate the provider configs.
// TODO: Manually validate the provider configs. Probably shouldn't
// actually do this here though. We can validate all the provider
// configs in the stack configuration in one go at a higher level.
tfCtx, err := terraform.NewContext(&terraform.ContextOpts{
PreloadedProviderSchemas: providerSchemas,

@ -9,7 +9,6 @@ import (
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/collections"
@ -74,7 +73,6 @@ func (c *ComponentInstance) InputVariableValues(ctx context.Context, phase EvalP
}
func (c *ComponentInstance) CheckInputVariableValues(ctx context.Context, phase EvalPhase) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
wantTy, defs := c.call.Config(ctx).InputsType(ctx)
decl := c.call.Declaration(ctx)
@ -83,70 +81,12 @@ func (c *ComponentInstance) CheckInputVariableValues(ctx context.Context, phase
// just report that we don't know the input variable values and trust
// that the module's problems will be reported by some other return
// path.
return cty.DynamicVal, diags
}
v := cty.EmptyObjectVal
expr := decl.Inputs
rng := decl.DeclRange
var hclCtx *hcl.EvalContext
if expr != nil {
result, moreDiags := EvalExprAndEvalContext(ctx, expr, phase, c)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
return cty.DynamicVal, diags
}
expr = result.Expression
hclCtx = result.EvalContext
v = result.Value
rng = tfdiags.SourceRangeFromHCL(result.Expression.Range())
}
if defs != nil {
v = defs.Apply(v)
}
v, err := convert.Convert(v, wantTy)
if err != nil {
// A conversion failure here could either be caused by an author-provided
// expression that's invalid or by the author omitting the argument
// altogether when there's at least one required attribute, so we'll
// return slightly different messages in each case.
if expr != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid inputs for component",
Detail: fmt.Sprintf("Invalid input variable definition object: %s.", tfdiags.FormatError(err)),
Subject: rng.ToHCL().Ptr(),
Expression: expr,
EvalContext: hclCtx,
})
} else {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing required inputs for component",
Detail: fmt.Sprintf("Must provide \"inputs\" argument to define the component's input variables: %s.", tfdiags.FormatError(err)),
Subject: rng.ToHCL().Ptr(),
})
}
return cty.DynamicVal, diags
}
for _, path := range stackconfigtypes.ProviderInstancePathsInValue(v) {
err := path.NewErrorf("cannot send provider configuration reference to Terraform module input variable")
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid inputs for component",
Detail: fmt.Sprintf(
"Invalid input variable definition object: %s.\n\nUse the separate \"providers\" argument to specify the provider configurations to use for this component's root module.",
tfdiags.FormatError(err),
),
Subject: rng.ToHCL().Ptr(),
Expression: expr,
EvalContext: hclCtx,
})
return cty.DynamicVal, nil
}
return v, diags
// We actually checked the errors statically already, so we only care about
// the value here.
return EvalComponentInputVariables(ctx, wantTy, defs, decl, phase, c)
}
// inputValuesForModulesRuntime adapts the result of

@ -10,10 +10,15 @@ import (
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hcldec"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/hashicorp/terraform/internal/lang"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackconfig"
"github.com/hashicorp/terraform/internal/stacks/stackconfig/stackconfigtypes"
"github.com/hashicorp/terraform/internal/stacks/stackconfig/typeexpr"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)
type EvalPhase rune
@ -203,6 +208,72 @@ func evalContextForTraversals(ctx context.Context, traversals []hcl.Traversal, p
return hclCtx, diags
}
func EvalComponentInputVariables(ctx context.Context, wantTy cty.Type, defs *typeexpr.Defaults, decl *stackconfig.Component, phase EvalPhase, scope ExpressionScope) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
v := cty.EmptyObjectVal
expr := decl.Inputs
rng := decl.DeclRange
var hclCtx *hcl.EvalContext
if expr != nil {
result, moreDiags := EvalExprAndEvalContext(ctx, expr, phase, scope)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
return cty.DynamicVal, diags
}
expr = result.Expression
hclCtx = result.EvalContext
v = result.Value
rng = tfdiags.SourceRangeFromHCL(result.Expression.Range())
}
if defs != nil {
v = defs.Apply(v)
}
v, err := convert.Convert(v, wantTy)
if err != nil {
// A conversion failure here could either be caused by an author-provided
// expression that's invalid or by the author omitting the argument
// altogether when there's at least one required attribute, so we'll
// return slightly different messages in each case.
if expr != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid inputs for component",
Detail: fmt.Sprintf("Invalid input variable definition object: %s.", tfdiags.FormatError(err)),
Subject: rng.ToHCL().Ptr(),
Expression: expr,
EvalContext: hclCtx,
})
} else {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing required inputs for component",
Detail: fmt.Sprintf("Must provide \"inputs\" argument to define the component's input variables: %s.", tfdiags.FormatError(err)),
Subject: rng.ToHCL().Ptr(),
})
}
return cty.DynamicVal, diags
}
for _, path := range stackconfigtypes.ProviderInstancePathsInValue(v) {
err := path.NewErrorf("cannot send provider configuration reference to Terraform module input variable")
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid inputs for component",
Detail: fmt.Sprintf(
"Invalid input variable definition object: %s.\n\nUse the separate \"providers\" argument to specify the provider configurations to use for this component's root module.",
tfdiags.FormatError(err),
),
Subject: rng.ToHCL().Ptr(),
Expression: expr,
EvalContext: hclCtx,
})
}
return v, diags
}
// EvalExprAndEvalContext evaluates the given HCL expression in the given
// expression scope and returns the resulting value, along with the HCL
// evaluation context that was used to produce it.

@ -169,7 +169,7 @@ func (s *Stack) StackConfig(ctx context.Context) *StackConfig {
func (s *Stack) ConfigDeclarations(ctx context.Context) *stackconfig.Declarations {
// The declarations really belong to the static StackConfig, since
// all instances of a particular stack configuration share the same
// source code.
// source code.ResolveExpressionReference
return s.StackConfig(ctx).ConfigDeclarations(ctx)
}
@ -410,6 +410,13 @@ func (s *Stack) resolveExpressionReference(ctx context.Context, ref stackaddrs.R
// TODO: Most of the below would benefit from "Did you mean..." suggestions
// when something is missing but there's a similarly-named object nearby.
// See also a very similar function in stack_config.go. Both are returning
// similar referenceable objects but the context is different. For example,
// in this function we return an instanced Component, while in the other
// function we return a static ComponentConfig.
//
// Some of the returned types are the same across both functions, but most
// are different in terms of static vs dynamic types.
switch addr := ref.Target.(type) {
case stackaddrs.InputVariable:
ret := s.InputVariable(ctx, addr)

@ -62,7 +62,21 @@ func TestPlan_valid(t *testing.T) {
changesCh := make(chan stackplan.PlannedChange, 8)
diagsCh := make(chan tfdiags.Diagnostic, 2)
req := PlanRequest{
Config: cfg,
Config: cfg,
ProviderFactories: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) {
return stacks_testing_provider.NewProvider(), nil
},
},
InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue {
inputs := map[stackaddrs.InputVariable]ExternalInputValue{}
for k, v := range tc.planInputVars {
inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{
Value: v,
}
}
return inputs
}(),
ForcePlanTimestamp: &fakePlanTimestamp,
}
resp := PlanResponse{
@ -128,6 +142,15 @@ func TestPlan_invalid(t *testing.T) {
return stacks_testing_provider.NewProvider(), nil
},
},
InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue {
inputs := map[stackaddrs.InputVariable]ExternalInputValue{}
for k, v := range tc.planInputVars {
inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{
Value: v,
}
}
return inputs
}(),
ForcePlanTimestamp: &fakePlanTimestamp,
}
resp := PlanResponse{
@ -1139,6 +1162,48 @@ func TestPlanWithSensitivePropagationNested(t *testing.T) {
}
}
func TestPlanWithForEach(t *testing.T) {
ctx := context.Background()
cfg := loadMainBundleConfigForTest(t, path.Join("with-single-input", "input-from-component-list"))
fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z")
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.NewDefaultProvider("testing"): func() (providers.Interface, error) {
return stacks_testing_provider.NewProvider(), nil
},
},
ForcePlanTimestamp: &fakePlanTimestamp,
InputValues: map[stackaddrs.InputVariable]ExternalInputValue{
stackaddrs.InputVariable{Name: "components"}: {
Value: cty.ListVal([]cty.Value{cty.StringVal("one"), cty.StringVal("two"), cty.StringVal("three")}),
DefRange: tfdiags.SourceRange{},
},
},
}
resp := PlanResponse{
PlannedChanges: changesCh,
Diagnostics: diagsCh,
}
go Plan(ctx, &req, &resp)
_, diags := collectPlanOutput(changesCh, diagsCh)
reportDiagnosticsForTest(t, diags)
if len(diags) != 0 {
t.FailNow() // We reported the diags above/
}
}
// collectPlanOutput consumes the two output channels emitting results from
// a call to [Plan], and collects all of the data written to them before
// returning once changesCh has been closed by the sender to indicate that

@ -0,0 +1,36 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
variable "components" {
type = set(string)
}
provider "testing" "default" {}
component "output" {
source = "../../with-single-output"
providers = {
testing = provider.testing.default
}
for_each = var.components
}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
input = component.output[each.value].id
}
for_each = var.components
}

@ -0,0 +1,28 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
provider "testing" "default" {}
component "output" {
source = "../../with-single-output"
providers = {
testing = provider.testing.default
}
}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
input = component.output.id
}
}

@ -0,0 +1,21 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
provider "testing" "default" {}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
// This component doesn't exist. We should see an error.
input = component.output.id
}
}

@ -0,0 +1,21 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
provider "testing" "default" {}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
# Shouldn't be able to reference providers from here.
input = provider.testing.default
}
}

@ -0,0 +1,23 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
provider "testing" "default" {}
variable "input" {
type = string
}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
# We do have a required variable, so this should complain.
inputs = {}
}

@ -0,0 +1,21 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
provider "testing" "default" {}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
# var.input is not defined
input = var.input
}
}

@ -0,0 +1,14 @@
terraform {
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
}
resource "testing_resource" "data" {}
output "id" {
value = testing_resource.data.id
}

@ -11,6 +11,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/providers"
@ -21,6 +22,11 @@ import (
type validateTestInput struct {
skip bool
diags func() tfdiags.Diagnostics
// planInputVars is used only in the plan tests to provide a set of input
// variables to use for the plan request. Validate operates statically so
// does not need any input variables.
planInputVars map[string]cty.Value
}
var (
@ -29,6 +35,16 @@ var (
"empty": {},
"variable-output-roundtrip": {},
"variable-output-roundtrip-nested": {},
filepath.Join("with-single-input", "input-from-component"): {},
filepath.Join("with-single-input", "input-from-component-list"): {
planInputVars: map[string]cty.Value{
"components": cty.SetVal([]cty.Value{
cty.StringVal("one"),
cty.StringVal("two"),
cty.StringVal("three"),
}),
},
},
filepath.Join("with-single-input", "provider-name-clash"): {
skip: true,
},
@ -106,6 +122,70 @@ var (
// error as for missing provider, which is not ideal.
skip: true,
},
filepath.Join("with-single-input", "undeclared-variable"): {
diags: func() tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Reference to undeclared input variable",
Detail: `There is no variable "input" block declared in this stack.`,
Subject: &hcl.Range{
Filename: mainBundleSourceAddrStr("with-single-input/undeclared-variable/undeclared-variable.tfstack.hcl"),
Start: hcl.Pos{Line: 19, Column: 13, Byte: 284},
End: hcl.Pos{Line: 19, Column: 22, Byte: 293},
},
})
return diags
},
},
filepath.Join("with-single-input", "missing-variable"): {
diags: func() tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid inputs for component",
Detail: "Invalid input variable definition object: attribute \"input\" is required.",
Subject: &hcl.Range{
Filename: mainBundleSourceAddrStr("with-single-input/missing-variable/missing-variable.tfstack.hcl"),
Start: hcl.Pos{Line: 22, Column: 12, Byte: 338},
End: hcl.Pos{Line: 22, Column: 14, Byte: 340},
},
})
return diags
},
},
filepath.Join("with-single-input", "input-from-missing-component"): {
diags: func() tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Reference to undeclared component",
Detail: "There is no component \"output\" block declared in this stack.",
Subject: &hcl.Range{
Filename: mainBundleSourceAddrStr("with-single-input/input-from-missing-component/input-from-missing-component.tfstack.hcl"),
Start: hcl.Pos{Line: 19, Column: 13, Byte: 314},
End: hcl.Pos{Line: 19, Column: 29, Byte: 330},
},
})
return diags
},
},
filepath.Join("with-single-input", "input-from-provider"): {
diags: func() tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid inputs for component",
Detail: "Invalid input variable definition object: attribute \"input\": string required.",
Subject: &hcl.Range{
Filename: mainBundleSourceAddrStr("with-single-input/input-from-provider/input-from-provider.tfstack.hcl"),
Start: hcl.Pos{Line: 17, Column: 12, Byte: 239},
End: hcl.Pos{Line: 20, Column: 4, Byte: 339},
},
})
return diags
},
},
}
)
@ -129,6 +209,11 @@ func TestValidate_valid(t *testing.T) {
diags := Validate(ctx, &ValidateRequest{
Config: cfg,
ProviderFactories: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) {
return stacks_testing_provider.NewProvider(), nil
},
},
})
// The following will fail the test if there are any error

Loading…
Cancel
Save