core: Evaluate pre/postconditions during validate

During the validation walk, we attempt to proactively evaluate check
rule condition and error message expressions. This will help catch some
errors as early as possible.

At present, resource values in the validation walk are of dynamic type.
This means that any references to resources will cause validation to be
delayed, rather than presenting useful errors. Validation may still
catch other errors, and any future changes which cause better type
propagation will result in better validation too.
This commit is contained in:
Alisdair McDiarmid 2022-02-03 14:14:21 -05:00
parent b06fe04621
commit b59bffada6
2 changed files with 316 additions and 0 deletions

View File

@ -2095,3 +2095,293 @@ func TestContext2Validate_nonNullableVariableDefaultValidation(t *testing.T) {
t.Fatal(diags.ErrWithWarnings()) t.Fatal(diags.ErrWithWarnings())
} }
} }
func TestContext2Validate_precondition_good(t *testing.T) {
p := testProvider("aws")
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"aws_instance": {
Attributes: map[string]*configschema.Attribute{
"foo": {Type: cty.String, Optional: true},
},
},
},
})
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
experiments = [preconditions_postconditions]
}
variable "input" {
type = string
default = "foo"
}
resource "aws_instance" "test" {
foo = var.input
lifecycle {
precondition {
condition = length(var.input) > 0
error_message = "Input cannot be empty."
}
}
}
`,
})
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if diags.HasErrors() {
t.Fatal(diags.ErrWithWarnings())
}
}
func TestContext2Validate_precondition_badCondition(t *testing.T) {
p := testProvider("aws")
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"aws_instance": {
Attributes: map[string]*configschema.Attribute{
"foo": {Type: cty.String, Optional: true},
},
},
},
})
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
experiments = [preconditions_postconditions]
}
variable "input" {
type = string
default = "foo"
}
resource "aws_instance" "test" {
foo = var.input
lifecycle {
precondition {
condition = length(one(var.input)) == 1
error_message = "You can't do that."
}
}
}
`,
})
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if !diags.HasErrors() {
t.Fatalf("succeeded; want error")
}
if got, want := diags.Err().Error(), "Invalid function argument"; !strings.Contains(got, want) {
t.Errorf("unexpected error.\ngot: %s\nshould contain: %q", got, want)
}
}
func TestContext2Validate_precondition_badErrorMessage(t *testing.T) {
p := testProvider("aws")
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"aws_instance": {
Attributes: map[string]*configschema.Attribute{
"foo": {Type: cty.String, Optional: true},
},
},
},
})
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
experiments = [preconditions_postconditions]
}
variable "input" {
type = string
default = "foo"
}
resource "aws_instance" "test" {
foo = var.input
lifecycle {
precondition {
condition = var.input != "foo"
error_message = "This is a bad use of a function: ${one(var.input)}."
}
}
}
`,
})
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if !diags.HasErrors() {
t.Fatalf("succeeded; want error")
}
if got, want := diags.Err().Error(), "Invalid function argument"; !strings.Contains(got, want) {
t.Errorf("unexpected error.\ngot: %s\nshould contain: %q", got, want)
}
}
func TestContext2Validate_postcondition_good(t *testing.T) {
p := testProvider("aws")
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"aws_instance": {
Attributes: map[string]*configschema.Attribute{
"foo": {Type: cty.String, Optional: true},
},
},
},
})
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
experiments = [preconditions_postconditions]
}
resource "aws_instance" "test" {
foo = "foo"
lifecycle {
postcondition {
condition = length(self.foo) > 0
error_message = "Input cannot be empty."
}
}
}
`,
})
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if diags.HasErrors() {
t.Fatal(diags.ErrWithWarnings())
}
}
func TestContext2Validate_postcondition_badCondition(t *testing.T) {
p := testProvider("aws")
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"aws_instance": {
Attributes: map[string]*configschema.Attribute{
"foo": {Type: cty.String, Optional: true},
},
},
},
})
// This postcondition's condition expression does not refer to self, which
// is unrealistic. This is because at the time of writing the test, self is
// always an unknown value of dynamic type during validation. As a result,
// validation of conditions which refer to resource arguments is not
// possible until plan time. For now we exercise the code by referring to
// an input variable.
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
experiments = [preconditions_postconditions]
}
variable "input" {
type = string
default = "foo"
}
resource "aws_instance" "test" {
foo = var.input
lifecycle {
postcondition {
condition = length(one(var.input)) == 1
error_message = "You can't do that."
}
}
}
`,
})
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if !diags.HasErrors() {
t.Fatalf("succeeded; want error")
}
if got, want := diags.Err().Error(), "Invalid function argument"; !strings.Contains(got, want) {
t.Errorf("unexpected error.\ngot: %s\nshould contain: %q", got, want)
}
}
func TestContext2Validate_postcondition_badErrorMessage(t *testing.T) {
p := testProvider("aws")
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"aws_instance": {
Attributes: map[string]*configschema.Attribute{
"foo": {Type: cty.String, Optional: true},
},
},
},
})
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
experiments = [preconditions_postconditions]
}
resource "aws_instance" "test" {
foo = "foo"
lifecycle {
postcondition {
condition = self.foo != "foo"
error_message = "This is a bad use of a function: ${one("foo")}."
}
}
}
`,
})
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if !diags.HasErrors() {
t.Fatalf("succeeded; want error")
}
if got, want := diags.Err().Error(), "Invalid function argument"; !strings.Contains(got, want) {
t.Errorf("unexpected error.\ngot: %s\nshould contain: %q", got, want)
}
}

View File

@ -41,6 +41,18 @@ func (n *NodeValidatableResource) Path() addrs.ModuleInstance {
func (n *NodeValidatableResource) Execute(ctx EvalContext, op walkOperation) (diags tfdiags.Diagnostics) { func (n *NodeValidatableResource) Execute(ctx EvalContext, op walkOperation) (diags tfdiags.Diagnostics) {
diags = diags.Append(n.validateResource(ctx)) diags = diags.Append(n.validateResource(ctx))
var self addrs.Referenceable
switch {
case n.Config.Count != nil:
self = n.Addr.Resource.Instance(addrs.IntKey(0))
case n.Config.ForEach != nil:
self = n.Addr.Resource.Instance(addrs.StringKey(""))
default:
self = n.Addr.Resource.Instance(addrs.NoKey)
}
diags = diags.Append(validateCheckRules(ctx, n.Config.Preconditions, nil))
diags = diags.Append(validateCheckRules(ctx, n.Config.Postconditions, self))
if managed := n.Config.Managed; managed != nil { if managed := n.Config.Managed; managed != nil {
hasCount := n.Config.Count != nil hasCount := n.Config.Count != nil
hasForEach := n.Config.ForEach != nil hasForEach := n.Config.ForEach != nil
@ -466,6 +478,20 @@ func (n *NodeValidatableResource) validateResource(ctx EvalContext) tfdiags.Diag
return diags return diags
} }
func validateCheckRules(ctx EvalContext, crs []*configs.CheckRule, self addrs.Referenceable) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
for _, cr := range crs {
_, conditionDiags := ctx.EvaluateExpr(cr.Condition, cty.Bool, self)
diags = diags.Append(conditionDiags)
_, errorMessageDiags := ctx.EvaluateExpr(cr.ErrorMessage, cty.String, self)
diags = diags.Append(errorMessageDiags)
}
return diags
}
func validateCount(ctx EvalContext, expr hcl.Expression) (diags tfdiags.Diagnostics) { func validateCount(ctx EvalContext, expr hcl.Expression) (diags tfdiags.Diagnostics) {
val, countDiags := evaluateCountExpressionValue(expr, ctx) val, countDiags := evaluateCountExpressionValue(expr, ctx)
// If the value isn't known then that's the best we can do for now, but // If the value isn't known then that's the best we can do for now, but