Skip to content

Commit b06fe04

Browse files
committed
core: Check rule error message expressions
Error messages for preconditions, postconditions, and custom variable validations have until now been string literals. This commit changes this to treat the field as an HCL expression, which must evaluate to a string. Most commonly this will either be a string literal or a template expression. When the check rule condition is evaluated, we also evaluate the error message. This means that the error message should always evaluate to a string value, even if the condition passes. If it does not, this will result in an error diagnostic. If the condition fails, and the error message also fails to evaluate, we fall back to a default error message. This means that the check rule failure will still be reported, alongside diagnostics explaining why the custom error message failed to render. As part of this change, we also necessarily remove the heuristic about the error message format. This guidance can be readded in future as part of a configuration hint system.
1 parent 979ac3d commit b06fe04

File tree

11 files changed

+157
-142
lines changed

11 files changed

+157
-142
lines changed

internal/configs/checks.go

Lines changed: 9 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@ package configs
22

33
import (
44
"fmt"
5-
"unicode"
65

76
"github.com/hashicorp/hcl/v2"
8-
"github.com/hashicorp/hcl/v2/gohcl"
97
"github.com/hashicorp/terraform/internal/addrs"
108
"github.com/hashicorp/terraform/internal/lang"
119
)
@@ -24,12 +22,15 @@ type CheckRule struct {
2422
// input variables can only refer to the variable that is being validated.
2523
Condition hcl.Expression
2624

27-
// ErrorMessage is one or more full sentences, which would need to be in
25+
// ErrorMessage should be one or more full sentences, which should be in
2826
// English for consistency with the rest of the error message output but
29-
// can in practice be in any language as long as it ends with a period.
30-
// The message should describe what is required for the condition to return
31-
// true in a way that would make sense to a caller of the module.
32-
ErrorMessage string
27+
// can in practice be in any language. The message should describe what is
28+
// required for the condition to return true in a way that would make sense
29+
// to a caller of the module.
30+
//
31+
// The error message expression has the same variables available for
32+
// interpolation as the corresponding condition.
33+
ErrorMessage hcl.Expression
3334

3435
DeclRange hcl.Range
3536
}
@@ -111,77 +112,12 @@ func decodeCheckRuleBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diag
111112
}
112113

113114
if attr, exists := content.Attributes["error_message"]; exists {
114-
moreDiags := gohcl.DecodeExpression(attr.Expr, nil, &cr.ErrorMessage)
115-
diags = append(diags, moreDiags...)
116-
if !moreDiags.HasErrors() {
117-
const errSummary = "Invalid validation error message"
118-
switch {
119-
case cr.ErrorMessage == "":
120-
diags = diags.Append(&hcl.Diagnostic{
121-
Severity: hcl.DiagError,
122-
Summary: errSummary,
123-
Detail: "An empty string is not a valid nor useful error message.",
124-
Subject: attr.Expr.Range().Ptr(),
125-
})
126-
case !looksLikeSentences(cr.ErrorMessage):
127-
// Because we're going to include this string verbatim as part
128-
// of a bigger error message written in our usual style in
129-
// English, we'll require the given error message to conform
130-
// to that. We might relax this in future if e.g. we start
131-
// presenting these error messages in a different way, or if
132-
// Terraform starts supporting producing error messages in
133-
// other human languages, etc.
134-
// For pragmatism we also allow sentences ending with
135-
// exclamation points, but we don't mention it explicitly here
136-
// because that's not really consistent with the Terraform UI
137-
// writing style.
138-
diags = diags.Append(&hcl.Diagnostic{
139-
Severity: hcl.DiagError,
140-
Summary: errSummary,
141-
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.",
142-
Subject: attr.Expr.Range().Ptr(),
143-
})
144-
}
145-
}
115+
cr.ErrorMessage = attr.Expr
146116
}
147117

148118
return cr, diags
149119
}
150120

151-
// looksLikeSentence is a simple heuristic that encourages writing error
152-
// messages that will be presentable when included as part of a larger
153-
// Terraform error diagnostic whose other text is written in the Terraform
154-
// UI writing style.
155-
//
156-
// This is intentionally not a very strong validation since we're assuming
157-
// that module authors want to write good messages and might just need a nudge
158-
// about Terraform's specific style, rather than that they are going to try
159-
// to work around these rules to write a lower-quality message.
160-
func looksLikeSentences(s string) bool {
161-
if len(s) < 1 {
162-
return false
163-
}
164-
runes := []rune(s) // HCL guarantees that all strings are valid UTF-8
165-
first := runes[0]
166-
last := runes[len(runes)-1]
167-
168-
// If the first rune is a letter then it must be an uppercase letter.
169-
// (This will only see the first rune in a multi-rune combining sequence,
170-
// but the first rune is generally the letter if any are, and if not then
171-
// we'll just ignore it because we're primarily expecting English messages
172-
// right now anyway, for consistency with all of Terraform's other output.)
173-
if unicode.IsLetter(first) && !unicode.IsUpper(first) {
174-
return false
175-
}
176-
177-
// The string must be at least one full sentence, which implies having
178-
// sentence-ending punctuation.
179-
// (This assumes that if a sentence ends with quotes then the period
180-
// will be outside the quotes, which is consistent with Terraform's UI
181-
// writing style.)
182-
return last == '.' || last == '?' || last == '!'
183-
}
184-
185121
var checkRuleBlockSchema = &hcl.BodySchema{
186122
Attributes: []hcl.AttributeSchema{
187123
{

internal/configs/named_values.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,31 @@ func decodeVariableValidationBlock(varName string, block *hcl.Block, override bo
345345
}
346346
}
347347

348+
if vv.ErrorMessage != nil {
349+
// The same applies to the validation error message, except that
350+
// references are not required. A string literal is a valid error
351+
// message.
352+
goodRefs := 0
353+
for _, traversal := range vv.ErrorMessage.Variables() {
354+
ref, moreDiags := addrs.ParseRef(traversal)
355+
if !moreDiags.HasErrors() {
356+
if addr, ok := ref.Subject.(addrs.InputVariable); ok {
357+
if addr.Name == varName {
358+
goodRefs++
359+
continue // Reference is valid
360+
}
361+
}
362+
}
363+
// If we fall out here then the reference is invalid.
364+
diags = diags.Append(&hcl.Diagnostic{
365+
Severity: hcl.DiagError,
366+
Summary: "Invalid reference in variable validation",
367+
Detail: fmt.Sprintf("The error message for variable %q can only refer to the variable itself, using var.%s.", varName, varName),
368+
Subject: traversal.SourceRange().Ptr(),
369+
})
370+
}
371+
}
372+
348373
return vv, diags
349374
}
350375

internal/configs/named_values_test.go

Lines changed: 0 additions & 33 deletions
This file was deleted.

internal/configs/testdata/invalid-files/variable-validation-bad-msg.tf

Lines changed: 0 additions & 6 deletions
This file was deleted.

internal/configs/testdata/invalid-files/variable-validation-condition-badref.tf

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,10 @@ variable "validation" {
99
error_message = "Must be five."
1010
}
1111
}
12+
13+
variable "validation_error_expression" {
14+
validation {
15+
condition = var.validation_error_expression != 1
16+
error_message = "Cannot equal ${local.foo}." # ERROR: Invalid reference in variable validation
17+
}
18+
}

internal/configs/testdata/valid-files/variable_validation.tf

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,19 @@ variable "validation" {
44
error_message = "Must be five."
55
}
66
}
7+
8+
variable "validation_function" {
9+
type = list(string)
10+
validation {
11+
condition = length(var.validation_function) > 0
12+
error_message = "Must not be empty."
13+
}
14+
}
15+
16+
variable "validation_error_expression" {
17+
type = list(string)
18+
validation {
19+
condition = length(var.validation_error_expression) < 10
20+
error_message = "Too long (${length(var.validation_error_expression)} is greater than 10)."
21+
}
22+
}

internal/terraform/context_plan2_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2561,7 +2561,7 @@ func TestContext2Plan_preconditionErrors(t *testing.T) {
25612561
{
25622562
"test_resource.c.value",
25632563
"Invalid condition result",
2564-
"Invalid validation condition result value: a bool is required",
2564+
"Invalid condition result value: a bool is required",
25652565
},
25662566
}
25672567

internal/terraform/eval_conditions.go

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package terraform
33
import (
44
"fmt"
55
"log"
6+
"strings"
67

78
"github.com/hashicorp/hcl/v2"
89
"github.com/zclconf/go-cty/cty"
@@ -59,12 +60,20 @@ func evalCheckRules(typ checkType, rules []*configs.CheckRule, ctx EvalContext,
5960

6061
refs, moreDiags := lang.ReferencesInExpr(rule.Condition)
6162
ruleDiags = ruleDiags.Append(moreDiags)
63+
moreRefs, moreDiags := lang.ReferencesInExpr(rule.ErrorMessage)
64+
ruleDiags = ruleDiags.Append(moreDiags)
65+
refs = append(refs, moreRefs...)
66+
6267
scope := ctx.EvaluationScope(self, keyData)
6368
hclCtx, moreDiags := scope.EvalContext(refs)
6469
ruleDiags = ruleDiags.Append(moreDiags)
6570

6671
result, hclDiags := rule.Condition.Value(hclCtx)
6772
ruleDiags = ruleDiags.Append(hclDiags)
73+
74+
errorValue, errorDiags := rule.ErrorMessage.Value(hclCtx)
75+
ruleDiags = ruleDiags.Append(errorDiags)
76+
6877
diags = diags.Append(ruleDiags)
6978

7079
if ruleDiags.HasErrors() {
@@ -91,24 +100,46 @@ func evalCheckRules(typ checkType, rules []*configs.CheckRule, ctx EvalContext,
91100
diags = diags.Append(&hcl.Diagnostic{
92101
Severity: hcl.DiagError,
93102
Summary: errInvalidCondition,
94-
Detail: fmt.Sprintf("Invalid validation condition result value: %s.", tfdiags.FormatError(err)),
103+
Detail: fmt.Sprintf("Invalid condition result value: %s.", tfdiags.FormatError(err)),
95104
Subject: rule.Condition.Range().Ptr(),
96105
Expression: rule.Condition,
97106
EvalContext: hclCtx,
98107
})
99108
continue
100109
}
101110

102-
if result.False() {
103-
diags = diags.Append(&hcl.Diagnostic{
104-
Severity: hcl.DiagError,
105-
Summary: typ.FailureSummary(),
106-
Detail: rule.ErrorMessage,
107-
Subject: rule.Condition.Range().Ptr(),
108-
Expression: rule.Condition,
109-
EvalContext: hclCtx,
110-
})
111+
if result.True() {
112+
continue
113+
}
114+
115+
var errorMessage string
116+
if !errorDiags.HasErrors() && errorValue.IsKnown() && !errorValue.IsNull() {
117+
var err error
118+
errorValue, err = convert.Convert(errorValue, cty.String)
119+
if err != nil {
120+
diags = diags.Append(&hcl.Diagnostic{
121+
Severity: hcl.DiagError,
122+
Summary: "Invalid error message",
123+
Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)),
124+
Subject: rule.ErrorMessage.Range().Ptr(),
125+
Expression: rule.ErrorMessage,
126+
EvalContext: hclCtx,
127+
})
128+
} else {
129+
errorMessage = strings.TrimSpace(errorValue.AsString())
130+
}
131+
}
132+
if errorMessage == "" {
133+
errorMessage = "Failed to evaluate condition error message."
111134
}
135+
diags = diags.Append(&hcl.Diagnostic{
136+
Severity: hcl.DiagError,
137+
Summary: typ.FailureSummary(),
138+
Detail: errorMessage,
139+
Subject: rule.Condition.Range().Ptr(),
140+
Expression: rule.Condition,
141+
EvalContext: hclCtx,
142+
})
112143
}
113144

114145
return diags

0 commit comments

Comments
 (0)