From b59bffada68eec94b7d8741cce452b028c73543d Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Thu, 3 Feb 2022 14:14:21 -0500 Subject: [PATCH] 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. --- internal/terraform/context_validate_test.go | 290 +++++++++++++++++++ internal/terraform/node_resource_validate.go | 26 ++ 2 files changed, 316 insertions(+) diff --git a/internal/terraform/context_validate_test.go b/internal/terraform/context_validate_test.go index 1f0491f1a..c049b17ce 100644 --- a/internal/terraform/context_validate_test.go +++ b/internal/terraform/context_validate_test.go @@ -2095,3 +2095,293 @@ 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}, + }, + }, + }, + }) + 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) + } +} diff --git a/internal/terraform/node_resource_validate.go b/internal/terraform/node_resource_validate.go index f3eda571e..4f316906d 100644 --- a/internal/terraform/node_resource_validate.go +++ b/internal/terraform/node_resource_validate.go @@ -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 @@ -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