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