Skip to content

Commit

Permalink
core: Evaluate pre/postconditions during validate
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
alisdair committed Mar 3, 2022
1 parent e27025e commit 45d792f
Show file tree
Hide file tree
Showing 2 changed files with 322 additions and 0 deletions.
296 changes: 296 additions & 0 deletions internal/terraform/context_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2095,3 +2095,299 @@ func TestContext2Validate_nonNullableVariableDefaultValidation(t *testing.T) {
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},
},
},
},
})
// This postcondition's error message 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]
}
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)
}
}
26 changes: 26 additions & 0 deletions internal/terraform/node_resource_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ func (n *NodeValidatableResource) Path() addrs.ModuleInstance {
func (n *NodeValidatableResource) Execute(ctx EvalContext, op walkOperation) (diags tfdiags.Diagnostics) {
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 {
hasCount := n.Config.Count != nil
hasForEach := n.Config.ForEach != nil
Expand Down Expand Up @@ -466,6 +478,20 @@ func (n *NodeValidatableResource) validateResource(ctx EvalContext) tfdiags.Diag
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) {
val, countDiags := evaluateCountExpressionValue(expr, ctx)
// If the value isn't known then that's the best we can do for now, but
Expand Down

0 comments on commit 45d792f

Please sign in to comment.