Merge pull request #30613 from hashicorp/alisdair/check-rule-error-message-expressions
core: Check rule error message expressions
This commit is contained in:
commit
32f9fa9aac
|
@ -2,10 +2,8 @@ package configs
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"unicode"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/gohcl"
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/lang"
|
||||
)
|
||||
|
@ -24,12 +22,15 @@ type CheckRule struct {
|
|||
// input variables can only refer to the variable that is being validated.
|
||||
Condition hcl.Expression
|
||||
|
||||
// ErrorMessage is one or more full sentences, which would need to be in
|
||||
// ErrorMessage should be one or more full sentences, which should be in
|
||||
// English for consistency with the rest of the error message output but
|
||||
// can in practice be in any language as long as it ends with a period.
|
||||
// The message should describe what is required for the condition to return
|
||||
// true in a way that would make sense to a caller of the module.
|
||||
ErrorMessage string
|
||||
// can in practice be in any language. The message should describe what is
|
||||
// required for the condition to return true in a way that would make sense
|
||||
// to a caller of the module.
|
||||
//
|
||||
// The error message expression has the same variables available for
|
||||
// interpolation as the corresponding condition.
|
||||
ErrorMessage hcl.Expression
|
||||
|
||||
DeclRange hcl.Range
|
||||
}
|
||||
|
@ -111,77 +112,12 @@ func decodeCheckRuleBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diag
|
|||
}
|
||||
|
||||
if attr, exists := content.Attributes["error_message"]; exists {
|
||||
moreDiags := gohcl.DecodeExpression(attr.Expr, nil, &cr.ErrorMessage)
|
||||
diags = append(diags, moreDiags...)
|
||||
if !moreDiags.HasErrors() {
|
||||
const errSummary = "Invalid validation error message"
|
||||
switch {
|
||||
case cr.ErrorMessage == "":
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: errSummary,
|
||||
Detail: "An empty string is not a valid nor useful error message.",
|
||||
Subject: attr.Expr.Range().Ptr(),
|
||||
})
|
||||
case !looksLikeSentences(cr.ErrorMessage):
|
||||
// Because we're going to include this string verbatim as part
|
||||
// of a bigger error message written in our usual style in
|
||||
// English, we'll require the given error message to conform
|
||||
// to that. We might relax this in future if e.g. we start
|
||||
// presenting these error messages in a different way, or if
|
||||
// Terraform starts supporting producing error messages in
|
||||
// other human languages, etc.
|
||||
// For pragmatism we also allow sentences ending with
|
||||
// exclamation points, but we don't mention it explicitly here
|
||||
// because that's not really consistent with the Terraform UI
|
||||
// writing style.
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: errSummary,
|
||||
Detail: "The validation error message must be at least one full sentence starting with an uppercase letter and ending with a period or question mark.\n\nYour given message will be included as part of a larger Terraform error message, written as English prose. For broadly-shared modules we suggest using a similar writing style so that the overall result will be consistent.",
|
||||
Subject: attr.Expr.Range().Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
cr.ErrorMessage = attr.Expr
|
||||
}
|
||||
|
||||
return cr, diags
|
||||
}
|
||||
|
||||
// looksLikeSentence is a simple heuristic that encourages writing error
|
||||
// messages that will be presentable when included as part of a larger
|
||||
// Terraform error diagnostic whose other text is written in the Terraform
|
||||
// UI writing style.
|
||||
//
|
||||
// This is intentionally not a very strong validation since we're assuming
|
||||
// that module authors want to write good messages and might just need a nudge
|
||||
// about Terraform's specific style, rather than that they are going to try
|
||||
// to work around these rules to write a lower-quality message.
|
||||
func looksLikeSentences(s string) bool {
|
||||
if len(s) < 1 {
|
||||
return false
|
||||
}
|
||||
runes := []rune(s) // HCL guarantees that all strings are valid UTF-8
|
||||
first := runes[0]
|
||||
last := runes[len(runes)-1]
|
||||
|
||||
// If the first rune is a letter then it must be an uppercase letter.
|
||||
// (This will only see the first rune in a multi-rune combining sequence,
|
||||
// but the first rune is generally the letter if any are, and if not then
|
||||
// we'll just ignore it because we're primarily expecting English messages
|
||||
// right now anyway, for consistency with all of Terraform's other output.)
|
||||
if unicode.IsLetter(first) && !unicode.IsUpper(first) {
|
||||
return false
|
||||
}
|
||||
|
||||
// The string must be at least one full sentence, which implies having
|
||||
// sentence-ending punctuation.
|
||||
// (This assumes that if a sentence ends with quotes then the period
|
||||
// will be outside the quotes, which is consistent with Terraform's UI
|
||||
// writing style.)
|
||||
return last == '.' || last == '?' || last == '!'
|
||||
}
|
||||
|
||||
var checkRuleBlockSchema = &hcl.BodySchema{
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
{
|
||||
|
|
|
@ -345,6 +345,31 @@ func decodeVariableValidationBlock(varName string, block *hcl.Block, override bo
|
|||
}
|
||||
}
|
||||
|
||||
if vv.ErrorMessage != nil {
|
||||
// The same applies to the validation error message, except that
|
||||
// references are not required. A string literal is a valid error
|
||||
// message.
|
||||
goodRefs := 0
|
||||
for _, traversal := range vv.ErrorMessage.Variables() {
|
||||
ref, moreDiags := addrs.ParseRef(traversal)
|
||||
if !moreDiags.HasErrors() {
|
||||
if addr, ok := ref.Subject.(addrs.InputVariable); ok {
|
||||
if addr.Name == varName {
|
||||
goodRefs++
|
||||
continue // Reference is valid
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we fall out here then the reference is invalid.
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid reference in variable validation",
|
||||
Detail: fmt.Sprintf("The error message for variable %q can only refer to the variable itself, using var.%s.", varName, varName),
|
||||
Subject: traversal.SourceRange().Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return vv, diags
|
||||
}
|
||||
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
package configs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_looksLikeSentences(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
args string
|
||||
want bool
|
||||
}{
|
||||
"empty sentence": {
|
||||
args: "",
|
||||
want: false,
|
||||
},
|
||||
"valid sentence": {
|
||||
args: "A valid sentence.",
|
||||
want: true,
|
||||
},
|
||||
"valid sentence with an accent": {
|
||||
args: `A Valid sentence with an accent "é".`,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := looksLikeSentences(tt.args); got != tt.want {
|
||||
t.Errorf("looksLikeSentences() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
variable "validation" {
|
||||
validation {
|
||||
condition = var.validation != 4
|
||||
error_message = "not four" # ERROR: Invalid validation error message
|
||||
}
|
||||
}
|
|
@ -9,3 +9,10 @@ variable "validation" {
|
|||
error_message = "Must be five."
|
||||
}
|
||||
}
|
||||
|
||||
variable "validation_error_expression" {
|
||||
validation {
|
||||
condition = var.validation_error_expression != 1
|
||||
error_message = "Cannot equal ${local.foo}." # ERROR: Invalid reference in variable validation
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,3 +4,19 @@ variable "validation" {
|
|||
error_message = "Must be five."
|
||||
}
|
||||
}
|
||||
|
||||
variable "validation_function" {
|
||||
type = list(string)
|
||||
validation {
|
||||
condition = length(var.validation_function) > 0
|
||||
error_message = "Must not be empty."
|
||||
}
|
||||
}
|
||||
|
||||
variable "validation_error_expression" {
|
||||
type = list(string)
|
||||
validation {
|
||||
condition = length(var.validation_error_expression) < 10
|
||||
error_message = "Too long (${length(var.validation_error_expression)} is greater than 10)."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2561,7 +2561,7 @@ func TestContext2Plan_preconditionErrors(t *testing.T) {
|
|||
{
|
||||
"test_resource.c.value",
|
||||
"Invalid condition result",
|
||||
"Invalid validation condition result value: a bool is required",
|
||||
"Invalid condition result value: a bool is required",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package terraform
|
|||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
@ -59,12 +60,20 @@ func evalCheckRules(typ checkType, rules []*configs.CheckRule, ctx EvalContext,
|
|||
|
||||
refs, moreDiags := lang.ReferencesInExpr(rule.Condition)
|
||||
ruleDiags = ruleDiags.Append(moreDiags)
|
||||
moreRefs, moreDiags := lang.ReferencesInExpr(rule.ErrorMessage)
|
||||
ruleDiags = ruleDiags.Append(moreDiags)
|
||||
refs = append(refs, moreRefs...)
|
||||
|
||||
scope := ctx.EvaluationScope(self, keyData)
|
||||
hclCtx, moreDiags := scope.EvalContext(refs)
|
||||
ruleDiags = ruleDiags.Append(moreDiags)
|
||||
|
||||
result, hclDiags := rule.Condition.Value(hclCtx)
|
||||
ruleDiags = ruleDiags.Append(hclDiags)
|
||||
|
||||
errorValue, errorDiags := rule.ErrorMessage.Value(hclCtx)
|
||||
ruleDiags = ruleDiags.Append(errorDiags)
|
||||
|
||||
diags = diags.Append(ruleDiags)
|
||||
|
||||
if ruleDiags.HasErrors() {
|
||||
|
@ -91,7 +100,7 @@ func evalCheckRules(typ checkType, rules []*configs.CheckRule, ctx EvalContext,
|
|||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: errInvalidCondition,
|
||||
Detail: fmt.Sprintf("Invalid validation condition result value: %s.", tfdiags.FormatError(err)),
|
||||
Detail: fmt.Sprintf("Invalid condition result value: %s.", tfdiags.FormatError(err)),
|
||||
Subject: rule.Condition.Range().Ptr(),
|
||||
Expression: rule.Condition,
|
||||
EvalContext: hclCtx,
|
||||
|
@ -99,16 +108,38 @@ func evalCheckRules(typ checkType, rules []*configs.CheckRule, ctx EvalContext,
|
|||
continue
|
||||
}
|
||||
|
||||
if result.False() {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: typ.FailureSummary(),
|
||||
Detail: rule.ErrorMessage,
|
||||
Subject: rule.Condition.Range().Ptr(),
|
||||
Expression: rule.Condition,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
if result.True() {
|
||||
continue
|
||||
}
|
||||
|
||||
var errorMessage string
|
||||
if !errorDiags.HasErrors() && errorValue.IsKnown() && !errorValue.IsNull() {
|
||||
var err error
|
||||
errorValue, err = convert.Convert(errorValue, cty.String)
|
||||
if err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid error message",
|
||||
Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)),
|
||||
Subject: rule.ErrorMessage.Range().Ptr(),
|
||||
Expression: rule.ErrorMessage,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
} else {
|
||||
errorMessage = strings.TrimSpace(errorValue.AsString())
|
||||
}
|
||||
}
|
||||
if errorMessage == "" {
|
||||
errorMessage = "Failed to evaluate condition error message."
|
||||
}
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: typ.FailureSummary(),
|
||||
Detail: errorMessage,
|
||||
Subject: rule.Condition.Range().Ptr(),
|
||||
Expression: rule.Condition,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
}
|
||||
|
||||
return diags
|
||||
|
|
|
@ -3,8 +3,10 @@ package terraform
|
|||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/gohcl"
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/configs"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
|
@ -205,11 +207,67 @@ func evalVariableValidations(addr addrs.AbsInputVariableInstance, config *config
|
|||
for _, validation := range config.Validations {
|
||||
const errInvalidCondition = "Invalid variable validation result"
|
||||
const errInvalidValue = "Invalid value for variable"
|
||||
var ruleDiags tfdiags.Diagnostics
|
||||
|
||||
result, moreDiags := validation.Condition.Value(hclCtx)
|
||||
diags = diags.Append(moreDiags)
|
||||
if moreDiags.HasErrors() {
|
||||
log.Printf("[TRACE] evalVariableValidations: %s rule %s condition expression failed: %s", addr, validation.DeclRange, diags.Err().Error())
|
||||
ruleDiags = ruleDiags.Append(moreDiags)
|
||||
errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx)
|
||||
|
||||
// The following error handling is a workaround to preserve backwards
|
||||
// compatibility. Due to an implementation quirk, all prior versions of
|
||||
// Terraform would treat error messages specified using JSON
|
||||
// configuration syntax (.tf.json) as string literals, even if they
|
||||
// contained the "${" template expression operator. This behaviour did
|
||||
// not match that of HCL configuration syntax, where a template
|
||||
// expression would result in a validation error.
|
||||
//
|
||||
// As a result, users writing or generating JSON configuration syntax
|
||||
// may have specified error messages which are invalid template
|
||||
// expressions. As we add support for error message expressions, we are
|
||||
// unable to perfectly distinguish between these two cases.
|
||||
//
|
||||
// To ensure that we don't break backwards compatibility, we have the
|
||||
// below fallback logic if the error message fails to evaluate. This
|
||||
// should only have any effect for JSON configurations. The gohcl
|
||||
// DecodeExpression function behaves differently when the source of the
|
||||
// expression is a JSON configuration file and a nil context is passed.
|
||||
if errorDiags.HasErrors() {
|
||||
// Attempt to decode the expression as a string literal. Passing
|
||||
// nil as the context forces a JSON syntax string value to be
|
||||
// interpreted as a string literal.
|
||||
var errorString string
|
||||
moreErrorDiags := gohcl.DecodeExpression(validation.ErrorMessage, nil, &errorString)
|
||||
if !moreErrorDiags.HasErrors() {
|
||||
// Decoding succeeded, meaning that this is a JSON syntax
|
||||
// string value. We rewrap that as a cty value to allow later
|
||||
// decoding to succeed.
|
||||
errorValue = cty.StringVal(errorString)
|
||||
|
||||
// This warning diagnostic explains this odd behaviour, while
|
||||
// giving us an escape hatch to change this to a hard failure
|
||||
// in some future Terraform 1.x version.
|
||||
errorDiags = hcl.Diagnostics{
|
||||
&hcl.Diagnostic{
|
||||
Severity: hcl.DiagWarning,
|
||||
Summary: "Validation error message expression is invalid",
|
||||
Detail: fmt.Sprintf("The error message provided could not be evaluated as an expression, so Terraform is interpreting it as a string literal.\n\nIn future versions of Terraform, this will be considered an error. Please file a GitHub issue if this would break your workflow.\n\n%s", errorDiags.Error()),
|
||||
Subject: validation.ErrorMessage.Range().Ptr(),
|
||||
Context: validation.DeclRange.Ptr(),
|
||||
Expression: validation.ErrorMessage,
|
||||
EvalContext: hclCtx,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// We want to either report the original diagnostics if the
|
||||
// fallback failed, or the warning generated above if it succeeded.
|
||||
ruleDiags = ruleDiags.Append(errorDiags)
|
||||
}
|
||||
|
||||
diags = diags.Append(ruleDiags)
|
||||
|
||||
if ruleDiags.HasErrors() {
|
||||
log.Printf("[TRACE] evalVariableValidations: %s rule %s check rule evaluation failed: %s", addr, validation.DeclRange, ruleDiags.Err().Error())
|
||||
}
|
||||
if !result.IsKnown() {
|
||||
log.Printf("[TRACE] evalVariableValidations: %s rule %s condition value is unknown, so skipping validation for now", addr, validation.DeclRange)
|
||||
|
@ -245,26 +303,53 @@ func evalVariableValidations(addr addrs.AbsInputVariableInstance, config *config
|
|||
// we discard the marks now.
|
||||
result, _ = result.Unmark()
|
||||
|
||||
if result.False() {
|
||||
if expr != nil {
|
||||
if result.True() {
|
||||
continue
|
||||
}
|
||||
|
||||
var errorMessage string
|
||||
if !errorDiags.HasErrors() && errorValue.IsKnown() && !errorValue.IsNull() {
|
||||
var err error
|
||||
errorValue, err = convert.Convert(errorValue, cty.String)
|
||||
if err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: errInvalidValue,
|
||||
Detail: fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", validation.ErrorMessage, validation.DeclRange.String()),
|
||||
Subject: expr.Range().Ptr(),
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid error message",
|
||||
Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)),
|
||||
Subject: validation.ErrorMessage.Range().Ptr(),
|
||||
Expression: validation.ErrorMessage,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
} else {
|
||||
// Since we don't have a source expression for a root module
|
||||
// variable, we'll just report the error from the perspective
|
||||
// of the variable declaration itself.
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: errInvalidValue,
|
||||
Detail: fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", validation.ErrorMessage, validation.DeclRange.String()),
|
||||
Subject: config.DeclRange.Ptr(),
|
||||
})
|
||||
errorMessage = strings.TrimSpace(errorValue.AsString())
|
||||
}
|
||||
}
|
||||
if errorMessage == "" {
|
||||
errorMessage = "Failed to evaluate condition error message."
|
||||
}
|
||||
|
||||
if expr != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: errInvalidValue,
|
||||
Detail: fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", errorMessage, validation.DeclRange.String()),
|
||||
Subject: expr.Range().Ptr(),
|
||||
Expression: validation.Condition,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
} else {
|
||||
// Since we don't have a source expression for a root module
|
||||
// variable, we'll just report the error from the perspective
|
||||
// of the variable declaration itself.
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: errInvalidValue,
|
||||
Detail: fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", errorMessage, validation.DeclRange.String()),
|
||||
Subject: config.DeclRange.Ptr(),
|
||||
Expression: validation.Condition,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return diags
|
||||
|
|
|
@ -2,12 +2,14 @@ package terraform
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/lang"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
|
@ -561,3 +563,155 @@ func TestPrepareFinalInputVariableValue(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
// These tests cover the JSON syntax configuration edge case handling,
|
||||
// the background of which is described in detail in comments in the
|
||||
// evalVariableValidations function. Future versions of Terraform may
|
||||
// be able to remove this behaviour altogether.
|
||||
func TestEvalVariableValidations_jsonErrorMessageEdgeCase(t *testing.T) {
|
||||
cfgSrc := `{
|
||||
"variable": {
|
||||
"valid": {
|
||||
"type": "string",
|
||||
"validation": {
|
||||
"condition": "${var.valid != \"bar\"}",
|
||||
"error_message": "Valid template string ${var.valid}"
|
||||
}
|
||||
},
|
||||
"invalid": {
|
||||
"type": "string",
|
||||
"validation": {
|
||||
"condition": "${var.invalid != \"bar\"}",
|
||||
"error_message": "Invalid template string ${"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
cfg := testModuleInline(t, map[string]string{
|
||||
"main.tf.json": cfgSrc,
|
||||
})
|
||||
variableConfigs := cfg.Module.Variables
|
||||
|
||||
// Because we loaded our pseudo-module from a temporary file, the
|
||||
// declaration source ranges will have unpredictable filenames. We'll
|
||||
// fix that here just to make things easier below.
|
||||
for _, vc := range variableConfigs {
|
||||
vc.DeclRange.Filename = "main.tf.json"
|
||||
for _, v := range vc.Validations {
|
||||
v.DeclRange.Filename = "main.tf.json"
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
varName string
|
||||
given cty.Value
|
||||
wantErr []string
|
||||
wantWarn []string
|
||||
}{
|
||||
// Valid variable validation declaration, assigned value which passes
|
||||
// the condition generates no diagnostics.
|
||||
{
|
||||
varName: "valid",
|
||||
given: cty.StringVal("foo"),
|
||||
},
|
||||
// Assigning a value which fails the condition generates an error
|
||||
// message with the expression successfully evaluated.
|
||||
{
|
||||
varName: "valid",
|
||||
given: cty.StringVal("bar"),
|
||||
wantErr: []string{
|
||||
"Invalid value for variable",
|
||||
"Valid template string bar",
|
||||
},
|
||||
},
|
||||
// Invalid variable validation declaration due to an unparseable
|
||||
// template string. Assigning a value which passes the condition
|
||||
// results in a warning about the error message.
|
||||
{
|
||||
varName: "invalid",
|
||||
given: cty.StringVal("foo"),
|
||||
wantWarn: []string{
|
||||
"Validation error message expression is invalid",
|
||||
"Missing expression; Expected the start of an expression, but found the end of the file.",
|
||||
},
|
||||
},
|
||||
// Assigning a value which fails the condition generates an error
|
||||
// message including the configured string interpreted as a literal
|
||||
// value, and the same warning diagnostic as above.
|
||||
{
|
||||
varName: "invalid",
|
||||
given: cty.StringVal("bar"),
|
||||
wantErr: []string{
|
||||
"Invalid value for variable",
|
||||
"Invalid template string ${",
|
||||
},
|
||||
wantWarn: []string{
|
||||
"Validation error message expression is invalid",
|
||||
"Missing expression; Expected the start of an expression, but found the end of the file.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("%s %#v", test.varName, test.given), func(t *testing.T) {
|
||||
varAddr := addrs.InputVariable{Name: test.varName}.Absolute(addrs.RootModuleInstance)
|
||||
varCfg := variableConfigs[test.varName]
|
||||
if varCfg == nil {
|
||||
t.Fatalf("invalid variable name %q", test.varName)
|
||||
}
|
||||
|
||||
// Build a mock context to allow the function under test to
|
||||
// retrieve the variable value and evaluate the expressions
|
||||
ctx := &MockEvalContext{}
|
||||
|
||||
// We need a minimal scope to allow basic functions to be passed to
|
||||
// the HCL scope
|
||||
ctx.EvaluationScopeScope = &lang.Scope{}
|
||||
ctx.GetVariableValueFunc = func(addr addrs.AbsInputVariableInstance) cty.Value {
|
||||
if got, want := addr.String(), varAddr.String(); got != want {
|
||||
t.Errorf("incorrect argument to GetVariableValue: got %s, want %s", got, want)
|
||||
}
|
||||
return test.given
|
||||
}
|
||||
|
||||
gotDiags := evalVariableValidations(
|
||||
varAddr, varCfg, nil, ctx,
|
||||
)
|
||||
|
||||
if len(test.wantErr) == 0 && len(test.wantWarn) == 0 {
|
||||
if len(gotDiags) > 0 {
|
||||
t.Errorf("no diags expected, got %s", gotDiags.Err().Error())
|
||||
}
|
||||
} else {
|
||||
wantErrs:
|
||||
for _, want := range test.wantErr {
|
||||
for _, diag := range gotDiags {
|
||||
if diag.Severity() != tfdiags.Error {
|
||||
continue
|
||||
}
|
||||
desc := diag.Description()
|
||||
if strings.Contains(desc.Summary, want) || strings.Contains(desc.Detail, want) {
|
||||
continue wantErrs
|
||||
}
|
||||
}
|
||||
t.Errorf("no error diagnostics found containing %q\ngot: %s", want, gotDiags.Err().Error())
|
||||
}
|
||||
|
||||
wantWarns:
|
||||
for _, want := range test.wantWarn {
|
||||
for _, diag := range gotDiags {
|
||||
if diag.Severity() != tfdiags.Warning {
|
||||
continue
|
||||
}
|
||||
desc := diag.Description()
|
||||
if strings.Contains(desc.Summary, want) || strings.Contains(desc.Detail, want) {
|
||||
continue wantWarns
|
||||
}
|
||||
}
|
||||
t.Errorf("no warning diagnostics found containing %q\ngot: %s", want, gotDiags.Err().Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -176,10 +176,14 @@ func (n *NodeAbstractResource) References() []*addrs.Reference {
|
|||
for _, check := range c.Preconditions {
|
||||
refs, _ := lang.ReferencesInExpr(check.Condition)
|
||||
result = append(result, refs...)
|
||||
refs, _ = lang.ReferencesInExpr(check.ErrorMessage)
|
||||
result = append(result, refs...)
|
||||
}
|
||||
for _, check := range c.Postconditions {
|
||||
refs, _ := lang.ReferencesInExpr(check.Condition)
|
||||
result = append(result, refs...)
|
||||
refs, _ = lang.ReferencesInExpr(check.ErrorMessage)
|
||||
result = append(result, refs...)
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/hcltest"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
|
@ -101,7 +102,7 @@ func TestNodeRootVariableExecute(t *testing.T) {
|
|||
}
|
||||
return cty.True, nil
|
||||
}),
|
||||
ErrorMessage: "Must be a number.",
|
||||
ErrorMessage: hcltest.MockExprLiteral(cty.StringVal("Must be a number.")),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -335,11 +335,15 @@ Error: Resource postcondition failed
|
|||
The selected AMI must be tagged with the Component value "nomad-server".
|
||||
```
|
||||
|
||||
The `error_message` argument must always be a literal string, and should
|
||||
typically be written as a full sentence in a style similar to Terraform's own
|
||||
error messages. Terraform will show the given message alongside the name
|
||||
of the resource that detected the problem and any outside values used as part
|
||||
of the condition expression.
|
||||
The `error_message` argument can be any expression which evaluates to a string.
|
||||
This includes literal strings, heredocs, and template expressions. Multi-line
|
||||
error messages are supported, and lines with leading whitespace will not be
|
||||
word wrapped.
|
||||
|
||||
Error message should typically be written as one or more full sentences in a
|
||||
style similar to Terraform's own error messages. Terraform will show the given
|
||||
message alongside the name of the resource that detected the problem and any
|
||||
outside values used as part of the condition expression.
|
||||
|
||||
## Preconditions or Postconditions?
|
||||
|
||||
|
|
|
@ -198,10 +198,13 @@ variable "image_id" {
|
|||
```
|
||||
|
||||
If `condition` evaluates to `false`, Terraform will produce an error message
|
||||
that includes the sentences given in `error_message`. The error message string
|
||||
that includes the result of the `error_message` expression. The error message
|
||||
should be at least one full sentence explaining the constraint that failed,
|
||||
using a sentence structure similar to the above examples.
|
||||
|
||||
Error messages can be literal strings, heredocs, or template expressions. The
|
||||
only valid reference in an error message is the variable under validation.
|
||||
|
||||
Multiple `validation` blocks can be declared in which case error messages
|
||||
will be returned for _all_ failed conditions.
|
||||
|
||||
|
|
Loading…
Reference in New Issue