config: Allow module authors to specify validation rules for variables
The existing "type" argument allows specifying a type constraint that allows for some basic validation, but often there are more constraints on a variable value than just its type. This new feature (requiring an experiment opt-in for now, while we refine it) allows specifying arbitrary validation rules for any variable which can then cause custom error messages to be returned when a caller provides an inappropriate value. variable "example" { validation { condition = var.example != "nope" error_message = "Example value must not be \"nope\"." } } The core parts of this are designed to do as little new work as possible when no validations are specified, and thus the main new checking codepath here can therefore only run when the experiment is enabled in order to permit having validations.
This commit is contained in:
parent
02576988c1
commit
ff4ea042c2
|
@ -139,5 +139,18 @@ func checkModuleExperiments(m *Module) hcl.Diagnostics {
|
|||
}
|
||||
*/
|
||||
|
||||
if !m.ActiveExperiments.Has(experiments.VariableValidation) {
|
||||
for _, vc := range m.Variables {
|
||||
if len(vc.Validations) != 0 {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Custom variable validation is experimental",
|
||||
Detail: "This feature is currently an opt-in experiment, subject to change in future releases based on feedback.\n\nActivate the feature for this module by adding variable_validation to the list of active experiments.",
|
||||
Subject: vc.Validations[0].DeclRange.Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diags
|
||||
}
|
||||
|
|
|
@ -103,7 +103,8 @@ func NewModule(primaryFiles, overrideFiles []*File) (*Module, hcl.Diagnostics) {
|
|||
diags = append(diags, fileDiags...)
|
||||
}
|
||||
|
||||
diags = append(diags, checkModuleExperiments(mod)...)
|
||||
moreDiags := checkModuleExperiments(mod)
|
||||
diags = append(diags, moreDiags...)
|
||||
|
||||
return mod, diags
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package configs
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"unicode"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/ext/typeexpr"
|
||||
|
@ -23,6 +24,7 @@ type Variable struct {
|
|||
Default cty.Value
|
||||
Type cty.Type
|
||||
ParsingMode VariableParsingMode
|
||||
Validations []*VariableValidation
|
||||
|
||||
DescriptionSet bool
|
||||
|
||||
|
@ -119,6 +121,21 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno
|
|||
v.Default = val
|
||||
}
|
||||
|
||||
for _, block := range content.Blocks {
|
||||
switch block.Type {
|
||||
|
||||
case "validation":
|
||||
vv, moreDiags := decodeVariableValidationBlock(v.Name, block, override)
|
||||
diags = append(diags, moreDiags...)
|
||||
v.Validations = append(v.Validations, vv)
|
||||
|
||||
default:
|
||||
// The above cases should be exhaustive for all block types
|
||||
// defined in variableBlockSchema
|
||||
panic(fmt.Sprintf("unhandled block type %q", block.Type))
|
||||
}
|
||||
}
|
||||
|
||||
return v, diags
|
||||
}
|
||||
|
||||
|
@ -250,6 +267,157 @@ func (m VariableParsingMode) Parse(name, value string) (cty.Value, hcl.Diagnosti
|
|||
}
|
||||
}
|
||||
|
||||
// VariableValidation represents a configuration-defined validation rule
|
||||
// for a particular input variable, given as a "validation" block inside
|
||||
// a "variable" block.
|
||||
type VariableValidation struct {
|
||||
// Condition is an expression that refers to the variable being tested
|
||||
// and contains no other references. The expression must return true
|
||||
// to indicate that the value is valid or false to indicate that it is
|
||||
// invalid. If the expression produces an error, that's considered a bug
|
||||
// in the module defining the validation rule, not an error in the caller.
|
||||
Condition hcl.Expression
|
||||
|
||||
// ErrorMessage is one or more full sentences, which would need to 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
|
||||
|
||||
DeclRange hcl.Range
|
||||
}
|
||||
|
||||
func decodeVariableValidationBlock(varName string, block *hcl.Block, override bool) (*VariableValidation, hcl.Diagnostics) {
|
||||
var diags hcl.Diagnostics
|
||||
vv := &VariableValidation{
|
||||
DeclRange: block.DefRange,
|
||||
}
|
||||
|
||||
if override {
|
||||
// For now we'll just forbid overriding validation blocks, to simplify
|
||||
// the initial design. If we can find a clear use-case for overriding
|
||||
// validations in override files and there's a way to define it that
|
||||
// isn't confusing then we could relax this.
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Can't override variable validation rules",
|
||||
Detail: "Variable \"validation\" blocks cannot be used in override files.",
|
||||
Subject: vv.DeclRange.Ptr(),
|
||||
})
|
||||
return vv, diags
|
||||
}
|
||||
|
||||
content, moreDiags := block.Body.Content(variableValidationBlockSchema)
|
||||
diags = append(diags, moreDiags...)
|
||||
|
||||
if attr, exists := content.Attributes["condition"]; exists {
|
||||
vv.Condition = attr.Expr
|
||||
|
||||
// The validation condition can only refer to the variable itself,
|
||||
// to ensure that the variable declaration can't create additional
|
||||
// edges in the dependency graph.
|
||||
goodRefs := 0
|
||||
for _, traversal := range vv.Condition.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 condition for variable %q can only refer to the variable itself, using var.%s.", varName, varName),
|
||||
Subject: traversal.SourceRange().Ptr(),
|
||||
})
|
||||
}
|
||||
if goodRefs < 1 {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid variable validation condition",
|
||||
Detail: fmt.Sprintf("The condition for variable %q must refer to var.%s in order to test incoming values.", varName, varName),
|
||||
Subject: attr.Expr.Range().Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if attr, exists := content.Attributes["error_message"]; exists {
|
||||
moreDiags := gohcl.DecodeExpression(attr.Expr, nil, &vv.ErrorMessage)
|
||||
diags = append(diags, moreDiags...)
|
||||
if !moreDiags.HasErrors() {
|
||||
const errSummary = "Invalid validation error message"
|
||||
switch {
|
||||
case vv.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(vv.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: "Validation error message must be at least one full English sentence starting with an uppercase letter and ending with a period or question mark.",
|
||||
Subject: attr.Expr.Range().Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vv, 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(s)-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 == '!'
|
||||
}
|
||||
|
||||
// Output represents an "output" block in a module or file.
|
||||
type Output struct {
|
||||
Name string
|
||||
|
@ -367,6 +535,24 @@ var variableBlockSchema = &hcl.BodySchema{
|
|||
Name: "type",
|
||||
},
|
||||
},
|
||||
Blocks: []hcl.BlockHeaderSchema{
|
||||
{
|
||||
Type: "validation",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var variableValidationBlockSchema = &hcl.BodySchema{
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
{
|
||||
Name: "condition",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "error_message",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var outputBlockSchema = &hcl.BodySchema{
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
terraform {
|
||||
experiments = [variable_validation]
|
||||
}
|
||||
|
||||
variable "validation" {
|
||||
validation {
|
||||
condition = var.validation != 4
|
||||
error_message = "not four" # ERROR: Invalid validation error message
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
|
||||
terraform {
|
||||
experiments = [variable_validation]
|
||||
}
|
||||
|
||||
locals {
|
||||
foo = 1
|
||||
}
|
||||
|
||||
variable "validation" {
|
||||
validation {
|
||||
condition = local.foo == var.validation # ERROR: Invalid reference in variable validation
|
||||
error_message = "Must be five."
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
terraform {
|
||||
experiments = [variable_validation]
|
||||
}
|
||||
|
||||
variable "validation" {
|
||||
validation {
|
||||
condition = true # ERROR: Invalid variable validation condition
|
||||
error_message = "Must be true."
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
|
||||
variable "validation_without_optin" {
|
||||
validation { # ERROR: Custom variable validation is experimental
|
||||
condition = var.validation_without_optin != 4
|
||||
error_message = "Must not be four."
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
terraform {
|
||||
experiments = [variable_validation] # WARNING: Experimental feature "variable_validation" is active
|
||||
}
|
||||
|
||||
variable "validation" {
|
||||
validation {
|
||||
condition = var.validation == 5
|
||||
error_message = "Must be five."
|
||||
}
|
||||
}
|
|
@ -13,13 +13,13 @@ type Experiment string
|
|||
// Each experiment is represented by a string that must be a valid HCL
|
||||
// identifier so that it can be specified in configuration.
|
||||
const (
|
||||
// Example = Experiment("example")
|
||||
VariableValidation = Experiment("variable_validation")
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Each experiment constant defined above must be registered here as either
|
||||
// a current or a concluded experiment.
|
||||
// registerCurrentExperiment(Example)
|
||||
registerCurrentExperiment(VariableValidation)
|
||||
}
|
||||
|
||||
// GetCurrent takes an experiment name and returns the experiment value
|
||||
|
|
|
@ -1445,3 +1445,76 @@ resource "test_instance" "bar" {
|
|||
t.Fatalf("wrong error:\ngot: %s\nwant: message containing %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext2Validate_variableCustomValidationsFail(t *testing.T) {
|
||||
// This test is for custom validation rules associated with root module
|
||||
// variables, and specifically that we handle the situation where the
|
||||
// given value is invalid in a child module.
|
||||
m := testModule(t, "validate-variable-custom-validations-child")
|
||||
|
||||
p := testProvider("test")
|
||||
ctx := testContext2(t, &ContextOpts{
|
||||
Config: m,
|
||||
ProviderResolver: providers.ResolverFixed(
|
||||
map[addrs.Provider]providers.Factory{
|
||||
addrs.NewLegacyProvider("test"): testProviderFuncFixed(p),
|
||||
},
|
||||
),
|
||||
})
|
||||
|
||||
diags := ctx.Validate()
|
||||
if !diags.HasErrors() {
|
||||
t.Fatal("succeeded; want errors")
|
||||
}
|
||||
if got, want := diags.Err().Error(), `Invalid value for variable: Value must not be "nope".`; strings.Index(got, want) == -1 {
|
||||
t.Fatalf("wrong error:\ngot: %s\nwant: message containing %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext2Validate_variableCustomValidationsRoot(t *testing.T) {
|
||||
// This test is for custom validation rules associated with root module
|
||||
// variables, and specifically that we handle the situation where their
|
||||
// values are unknown during validation, skipping the validation check
|
||||
// altogether. (Root module variables are never known during validation.)
|
||||
m := testModuleInline(t, map[string]string{
|
||||
"main.tf": `
|
||||
# This feature is currently experimental.
|
||||
# (If you're currently cleaning up after concluding the experiment,
|
||||
# remember to also clean up similar references in the configs package
|
||||
# under "invalid-files" and "invalid-modules".)
|
||||
terraform {
|
||||
experiments = [variable_validation]
|
||||
}
|
||||
|
||||
variable "test" {
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = var.test != "nope"
|
||||
error_message = "Value must not be \"nope\"."
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
p := testProvider("test")
|
||||
ctx := testContext2(t, &ContextOpts{
|
||||
Config: m,
|
||||
ProviderResolver: providers.ResolverFixed(
|
||||
map[addrs.Provider]providers.Factory{
|
||||
addrs.NewLegacyProvider("test"): testProviderFuncFixed(p),
|
||||
},
|
||||
),
|
||||
Variables: InputValues{
|
||||
"test": &InputValue{
|
||||
Value: cty.UnknownVal(cty.String),
|
||||
SourceType: ValueFromCLIArg,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
diags := ctx.Validate()
|
||||
if diags.HasErrors() {
|
||||
t.Fatalf("unexpected error\ngot: %s", diags.Err().Error())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -123,6 +123,17 @@ type EvalContext interface {
|
|||
// previously-set keys that are not present in the new map.
|
||||
SetModuleCallArguments(addrs.ModuleCallInstance, map[string]cty.Value)
|
||||
|
||||
// GetVariableValue returns the value provided for the input variable with
|
||||
// the given address, or cty.DynamicVal if the variable hasn't been assigned
|
||||
// a value yet.
|
||||
//
|
||||
// Most callers should deal with variable values only indirectly via
|
||||
// EvaluationScope and the other expression evaluation functions, but
|
||||
// this is provided because variables tend to be evaluated outside of
|
||||
// the context of the module they belong to and so we sometimes need to
|
||||
// override the normal expression evaluation behavior.
|
||||
GetVariableValue(addr addrs.AbsInputVariableInstance) cty.Value
|
||||
|
||||
// Changes returns the writer object that can be used to write new proposed
|
||||
// changes into the global changes set.
|
||||
Changes() *plans.ChangesSync
|
||||
|
|
|
@ -312,6 +312,16 @@ func (ctx *BuiltinEvalContext) SetModuleCallArguments(n addrs.ModuleCallInstance
|
|||
}
|
||||
}
|
||||
|
||||
func (ctx *BuiltinEvalContext) GetVariableValue(addr addrs.AbsInputVariableInstance) cty.Value {
|
||||
modKey := addr.Module.String()
|
||||
modVars := ctx.VariableValues[modKey]
|
||||
val, ok := modVars[addr.Variable.Name]
|
||||
if !ok {
|
||||
return cty.DynamicVal
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (ctx *BuiltinEvalContext) Changes() *plans.ChangesSync {
|
||||
return ctx.ChangesValue
|
||||
}
|
||||
|
|
|
@ -115,6 +115,10 @@ type MockEvalContext struct {
|
|||
SetModuleCallArgumentsModule addrs.ModuleCallInstance
|
||||
SetModuleCallArgumentsValues map[string]cty.Value
|
||||
|
||||
GetVariableValueCalled bool
|
||||
GetVariableValueAddr addrs.AbsInputVariableInstance
|
||||
GetVariableValueValue cty.Value
|
||||
|
||||
ChangesCalled bool
|
||||
ChangesChanges *plans.ChangesSync
|
||||
|
||||
|
@ -308,6 +312,12 @@ func (c *MockEvalContext) SetModuleCallArguments(n addrs.ModuleCallInstance, val
|
|||
c.SetModuleCallArgumentsValues = values
|
||||
}
|
||||
|
||||
func (c *MockEvalContext) GetVariableValue(addr addrs.AbsInputVariableInstance) cty.Value {
|
||||
c.GetVariableValueCalled = true
|
||||
c.GetVariableValueAddr = addr
|
||||
return c.GetVariableValueValue
|
||||
}
|
||||
|
||||
func (c *MockEvalContext) Changes() *plans.ChangesSync {
|
||||
c.ChangesCalled = true
|
||||
return c.ChangesChanges
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/terraform/addrs"
|
||||
"github.com/hashicorp/terraform/configs"
|
||||
"github.com/hashicorp/terraform/tfdiags"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
"github.com/zclconf/go-cty/cty/convert"
|
||||
)
|
||||
|
@ -96,6 +97,117 @@ func (n *EvalModuleCallArgument) Eval(ctx EvalContext) (interface{}, error) {
|
|||
return nil, diags.ErrWithWarnings()
|
||||
}
|
||||
|
||||
// evalVariableValidations is an EvalNode implementation that ensures that
|
||||
// all of the configured custom validations for a variable are passing.
|
||||
//
|
||||
// This must be used only after any side-effects that make the value of the
|
||||
// variable available for use in expression evaluation, such as
|
||||
// EvalModuleCallArgument for variables in descendent modules.
|
||||
type evalVariableValidations struct {
|
||||
Addr addrs.AbsInputVariableInstance
|
||||
Config *configs.Variable
|
||||
|
||||
// Expr is the expression that provided the value for the variable, if any.
|
||||
// This will be nil for root module variables, because their values come
|
||||
// from outside the configuration.
|
||||
Expr hcl.Expression
|
||||
|
||||
// If this flag is set, this node becomes a no-op.
|
||||
// This is here for consistency with EvalModuleCallArgument so that it
|
||||
// can be populated with the same value, where needed.
|
||||
IgnoreDiagnostics bool
|
||||
}
|
||||
|
||||
func (n *evalVariableValidations) Eval(ctx EvalContext) (interface{}, error) {
|
||||
if n.Config == nil || n.IgnoreDiagnostics || len(n.Config.Validations) == 0 {
|
||||
log.Printf("[TRACE] evalVariableValidations: not active for %s, so skipping", n.Addr)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
// Variable nodes evaluate in the parent module to where they were declared
|
||||
// because the value expression (n.Expr, if set) comes from the calling
|
||||
// "module" block in the parent module.
|
||||
//
|
||||
// Validation expressions are statically validated (during configuration
|
||||
// loading) to refer only to the variable being validated, so we can
|
||||
// bypass our usual evaluation machinery here and just produce a minimal
|
||||
// evaluation context containing just the required value, and thus avoid
|
||||
// the problem that ctx's evaluation functions refer to the wrong module.
|
||||
val := ctx.GetVariableValue(n.Addr)
|
||||
hclCtx := &hcl.EvalContext{
|
||||
Variables: map[string]cty.Value{
|
||||
"var": cty.ObjectVal(map[string]cty.Value{
|
||||
n.Config.Name: val,
|
||||
}),
|
||||
},
|
||||
Functions: ctx.EvaluationScope(nil, EvalDataForNoInstanceKey).Functions(),
|
||||
}
|
||||
|
||||
for _, validation := range n.Config.Validations {
|
||||
const errInvalidCondition = "Invalid variable validation result"
|
||||
const errInvalidValue = "Invalid value for variable"
|
||||
|
||||
result, moreDiags := validation.Condition.Value(hclCtx)
|
||||
diags = diags.Append(moreDiags)
|
||||
if moreDiags.HasErrors() {
|
||||
log.Printf("[TRACE] evalVariableValidations: %s rule %s condition expression failed: %s", n.Addr, validation.DeclRange, diags.Err().Error())
|
||||
}
|
||||
if !result.IsKnown() {
|
||||
log.Printf("[TRACE] evalVariableValidations: %s rule %s condition value is unknown, so skipping validation for now", n.Addr, validation.DeclRange)
|
||||
continue // We'll wait until we've learned more, then.
|
||||
}
|
||||
if result.IsNull() {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: errInvalidCondition,
|
||||
Detail: "Validation condition expression must return either true or false, not null.",
|
||||
Subject: validation.Condition.Range().Ptr(),
|
||||
Expression: validation.Condition,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
result, err = convert.Convert(result, cty.Bool)
|
||||
if err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: errInvalidCondition,
|
||||
Detail: fmt.Sprintf("Invalid validation condition result value: %s.", tfdiags.FormatError(err)),
|
||||
Subject: validation.Condition.Range().Ptr(),
|
||||
Expression: validation.Condition,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if result.False() {
|
||||
if n.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.", validation.ErrorMessage, validation.DeclRange.String()),
|
||||
Subject: n.Expr.Range().Ptr(),
|
||||
})
|
||||
} 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: n.Config.DeclRange.Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, diags.ErrWithWarnings()
|
||||
}
|
||||
|
||||
// hclTypeName returns the name of the type that would represent this value in
|
||||
// a config file, or falls back to the Go type name if there's no corresponding
|
||||
// HCL type. This is used for formatted output, not for comparing types.
|
||||
|
|
|
@ -126,6 +126,14 @@ func (n *NodeApplyableModuleVariable) EvalTree() EvalNode {
|
|||
Module: call,
|
||||
Values: vals,
|
||||
},
|
||||
|
||||
&evalVariableValidations{
|
||||
Addr: n.Addr,
|
||||
Config: n.Config,
|
||||
Expr: n.Expr,
|
||||
|
||||
IgnoreDiagnostics: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,26 @@ func (n *NodeRootVariable) ReferenceableAddrs() []addrs.Referenceable {
|
|||
return []addrs.Referenceable{n.Addr}
|
||||
}
|
||||
|
||||
// GraphNodeEvalable
|
||||
func (n *NodeRootVariable) EvalTree() EvalNode {
|
||||
// We don't actually need to _evaluate_ a root module variable, because
|
||||
// its value is always constant and already stashed away in our EvalContext.
|
||||
// However, we might need to run some user-defined validation rules against
|
||||
// the value.
|
||||
|
||||
if n.Config == nil || len(n.Config.Validations) == 0 {
|
||||
return &EvalSequence{} // nothing to do
|
||||
}
|
||||
|
||||
return &evalVariableValidations{
|
||||
Addr: addrs.RootModuleInstance.InputVariable(n.Addr.Name),
|
||||
Config: n.Config,
|
||||
Expr: nil, // not set for root module variables
|
||||
|
||||
IgnoreDiagnostics: false,
|
||||
}
|
||||
}
|
||||
|
||||
// dag.GraphNodeDotter impl.
|
||||
func (n *NodeRootVariable) DotNode(name string, opts *dag.DotOpts) *dag.DotNode {
|
||||
return &dag.DotNode{
|
||||
|
|
16
terraform/testdata/validate-variable-custom-validations-child/child/child.tf
vendored
Normal file
16
terraform/testdata/validate-variable-custom-validations-child/child/child.tf
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
# This feature is currently experimental.
|
||||
# (If you're currently cleaning up after concluding the experiment,
|
||||
# remember to also clean up similar references in the configs package
|
||||
# under "invalid-files" and "invalid-modules".)
|
||||
terraform {
|
||||
experiments = [variable_validation]
|
||||
}
|
||||
|
||||
variable "test" {
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = var.test != "nope"
|
||||
error_message = "Value must not be \"nope\"."
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
module "child" {
|
||||
source = "./child"
|
||||
|
||||
test = "nope"
|
||||
}
|
|
@ -17,24 +17,37 @@ earlier, see
|
|||
whether the expression produced a result without any errors.
|
||||
|
||||
This is a special function that is able to catch errors produced when evaluating
|
||||
its argument. This function should be used with care, considering many of the
|
||||
same caveats that apply to [`try`](./try.html), to avoid writing configurations
|
||||
that are hard to read and maintain.
|
||||
its argument. For most situations where you could use `can` it's better to use
|
||||
[`try`](./try.html) instead, because it allows for more concise definition of
|
||||
fallback values for failing expressions.
|
||||
|
||||
For most situations it's better to use [`try`](./try.html), because it allows
|
||||
for more concise definition of fallback values for failing expressions.
|
||||
The primary purpose of `can` is to turn an error condition into a boolean
|
||||
validation result when writing
|
||||
[custom variable validation rules](../variables.html#custom-validation-rules).
|
||||
For example:
|
||||
|
||||
```
|
||||
variable "timestamp" {
|
||||
type = string
|
||||
|
||||
validation { # NOTE: custom validation is currently an opt-in experiment (see link above)
|
||||
# formatdate fails if the second argument is not a valid timestamp
|
||||
condition = can(formatdate("", var.timestamp))
|
||||
error_message = "The timestamp argument requires a valid RFC 3339 timestamp."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `can` function can only catch and handle _dynamic_ errors resulting from
|
||||
access to data that isn't known until runtime. It will not catch errors
|
||||
relating to expressions that can be proven to be invalid for any input, such
|
||||
as a malformed resource reference.
|
||||
|
||||
~> **Warning:** The `can` function is intended only for concise testing of the
|
||||
presence of and types of object attributes. Although it can technically accept
|
||||
any sort of expression, we recommend using it only with simple attribute
|
||||
references and type conversion functions as shown in the [`try`](./try.html)
|
||||
examples. Overuse of `can` to suppress errors will lead to a configuration that
|
||||
is hard to understand and maintain.
|
||||
~> **Warning:** The `can` function is intended only for simple tests in
|
||||
variable validation rules. Although it can technically accept any sort of
|
||||
expression and be used elsewhere in the configuration, we recommend against
|
||||
using it in other contexts. For error handling elsewhere in the configuration,
|
||||
prefer to use [`try`](./try.html).
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
|
@ -129,3 +129,40 @@ to newer versions of the provider without altering the module.
|
|||
Root modules should use a `~>` constraint to set both a lower and upper bound
|
||||
on versions for each provider they depend on, as described in
|
||||
[Provider Versions](providers.html#provider-versions).
|
||||
|
||||
## Experimental Language Features
|
||||
|
||||
From time to time the Terraform team will introduce new language features
|
||||
initially via an opt-in experiment, so that the community can try the new
|
||||
feature and give feedback on it prior to it becoming a backward-compatibility
|
||||
constraint.
|
||||
|
||||
In releases where experimental features are available, you can enable them on
|
||||
a per-module basis by setting the `experiments` argument inside a `terraform`
|
||||
block:
|
||||
|
||||
```hcl
|
||||
terraform {
|
||||
experiments = [example]
|
||||
}
|
||||
```
|
||||
|
||||
The above would opt in to an experiment named `example`, assuming such an
|
||||
experiment were available in the current Terraform version.
|
||||
|
||||
Experiments are subject to arbitrary changes in later releases and, depending on
|
||||
the outcome of the experiment, may change drastically before final release or
|
||||
may not be released in stable form at all. Such breaking changes may appear
|
||||
even in minor and patch releases. We do not recommend using experimental
|
||||
features in Terraform modules intended for production use.
|
||||
|
||||
In order to make that explicit and to avoid module callers inadvertently
|
||||
depending on an experimental feature, any module with experiments enabled will
|
||||
generate a warning on every `terraform plan` or `terraform apply`. If you
|
||||
want to try experimental features in a shared module, we recommend enabling the
|
||||
experiment only in alpha or beta releases of the module.
|
||||
|
||||
The introduction and completion of experiments is reported in
|
||||
[Terraform's changelog](https://github.com/hashicorp/terraform/blob/master/CHANGELOG.md),
|
||||
so you can watch the release notes there to discover which experiment keywords,
|
||||
if any, are available in a particular Terraform release.
|
||||
|
|
|
@ -164,6 +164,64 @@ might be included in documentation about the module, and so it should be written
|
|||
from the perspective of the user of the module rather than its maintainer. For
|
||||
commentary for module maintainers, use comments.
|
||||
|
||||
## Custom Validation Rules
|
||||
|
||||
~> *Warning:* This feature is currently experimental and is subject to breaking
|
||||
changes even in minor releases. We welcome your feedback, but cannot
|
||||
recommend using this feature in production modules yet.
|
||||
|
||||
In addition to Type Constraints as described above, a module author can specify
|
||||
arbitrary custom validation rules for a particular variable using a `validation`
|
||||
block nested within the corresponding `variable` block:
|
||||
|
||||
```hcl
|
||||
variable "image_id" {
|
||||
type = string
|
||||
description = "The id of the machine image (AMI) to use for the server."
|
||||
|
||||
validation {
|
||||
condition = length(var.image_id) > 4 && substr(var.image_id, 0, 4) == "ami-"
|
||||
error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `condition` argument is an expression that must use the value of the
|
||||
variable to return `true` if the value is valid, or `false` if it is invalid.
|
||||
The expression can refer only to the variable that the condition applies to,
|
||||
and _must not_ produce errors.
|
||||
|
||||
If the failure of an expression is the basis of the validation decision, use
|
||||
[the `can` function](./functions/can.html) to detect such errors. For example:
|
||||
|
||||
```hcl
|
||||
variable "image_id" {
|
||||
type = string
|
||||
description = "The id of the machine image (AMI) to use for the server."
|
||||
|
||||
validation {
|
||||
# regex(...) fails if it cannot find a match
|
||||
condition = can(regex("^ami-", var.image_id))
|
||||
error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `condition` evaluates to `false`, Terraform will produce an error message
|
||||
that includes the sentences given in `error_message`. The error message string
|
||||
should be at least one full sentence explaining the constraint that failed,
|
||||
using a sentence structure similar to the above examples.
|
||||
|
||||
This is [an experimental language feature](./terraform.html#experimental-language-features)
|
||||
that currently requires an explicit opt-in using the experiment keyword
|
||||
`variable_validation`:
|
||||
|
||||
```hcl
|
||||
terraform {
|
||||
experiments = [variable_validation]
|
||||
}
|
||||
```
|
||||
|
||||
## Assigning Values to Root Module Variables
|
||||
|
||||
When variables are declared in the root module of your configuration, they
|
||||
|
|
Loading…
Reference in New Issue