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:
Martin Atkins 2020-01-03 17:12:49 -08:00
parent 02576988c1
commit ff4ea042c2
21 changed files with 641 additions and 14 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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{

View File

@ -0,0 +1,11 @@
terraform {
experiments = [variable_validation]
}
variable "validation" {
validation {
condition = var.validation != 4
error_message = "not four" # ERROR: Invalid validation error message
}
}

View File

@ -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."
}
}

View File

@ -0,0 +1,11 @@
terraform {
experiments = [variable_validation]
}
variable "validation" {
validation {
condition = true # ERROR: Invalid variable validation condition
error_message = "Must be true."
}
}

View File

@ -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."
}
}

View File

@ -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."
}
}

View File

@ -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

View File

@ -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())
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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.

View File

@ -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,
},
},
}
}

View File

@ -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{

View 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\"."
}
}

View File

@ -0,0 +1,5 @@
module "child" {
source = "./child"
test = "nope"
}

View File

@ -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

View File

@ -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.

View File

@ -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