diff --git a/configs/experiments.go b/configs/experiments.go index 8af1e951f..435bac11d 100644 --- a/configs/experiments.go +++ b/configs/experiments.go @@ -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 } diff --git a/configs/module.go b/configs/module.go index a3afcd9fb..a5ba28581 100644 --- a/configs/module.go +++ b/configs/module.go @@ -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 } diff --git a/configs/named_values.go b/configs/named_values.go index 280b70692..128bd2787 100644 --- a/configs/named_values.go +++ b/configs/named_values.go @@ -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{ diff --git a/configs/testdata/invalid-files/variable-validation-bad-msg.tf b/configs/testdata/invalid-files/variable-validation-bad-msg.tf new file mode 100644 index 000000000..37ec496c0 --- /dev/null +++ b/configs/testdata/invalid-files/variable-validation-bad-msg.tf @@ -0,0 +1,11 @@ + +terraform { + experiments = [variable_validation] +} + +variable "validation" { + validation { + condition = var.validation != 4 + error_message = "not four" # ERROR: Invalid validation error message + } +} diff --git a/configs/testdata/invalid-files/variable-validation-condition-badref.tf b/configs/testdata/invalid-files/variable-validation-condition-badref.tf new file mode 100644 index 000000000..de88ced7a --- /dev/null +++ b/configs/testdata/invalid-files/variable-validation-condition-badref.tf @@ -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." + } +} diff --git a/configs/testdata/invalid-files/variable-validation-condition-noref.tf b/configs/testdata/invalid-files/variable-validation-condition-noref.tf new file mode 100644 index 000000000..e6b217b14 --- /dev/null +++ b/configs/testdata/invalid-files/variable-validation-condition-noref.tf @@ -0,0 +1,11 @@ + +terraform { + experiments = [variable_validation] +} + +variable "validation" { + validation { + condition = true # ERROR: Invalid variable validation condition + error_message = "Must be true." + } +} diff --git a/configs/testdata/invalid-modules/variable-validation-without-optin/variable-validation-without-optin.tf b/configs/testdata/invalid-modules/variable-validation-without-optin/variable-validation-without-optin.tf new file mode 100644 index 000000000..655f2cd4e --- /dev/null +++ b/configs/testdata/invalid-modules/variable-validation-without-optin/variable-validation-without-optin.tf @@ -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." + } +} diff --git a/configs/testdata/warning-files/variable_validation_experiment.tf b/configs/testdata/warning-files/variable_validation_experiment.tf new file mode 100644 index 000000000..28c285ad2 --- /dev/null +++ b/configs/testdata/warning-files/variable_validation_experiment.tf @@ -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." + } +} diff --git a/experiments/experiment.go b/experiments/experiment.go index fb30a5fc4..037b34e73 100644 --- a/experiments/experiment.go +++ b/experiments/experiment.go @@ -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 diff --git a/terraform/context_validate_test.go b/terraform/context_validate_test.go index 131831b27..6d2718462 100644 --- a/terraform/context_validate_test.go +++ b/terraform/context_validate_test.go @@ -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()) + } +} diff --git a/terraform/eval_context.go b/terraform/eval_context.go index e36805e90..ec7a3ae0e 100644 --- a/terraform/eval_context.go +++ b/terraform/eval_context.go @@ -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 diff --git a/terraform/eval_context_builtin.go b/terraform/eval_context_builtin.go index f7e9973fe..f1cfab66e 100644 --- a/terraform/eval_context_builtin.go +++ b/terraform/eval_context_builtin.go @@ -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 } diff --git a/terraform/eval_context_mock.go b/terraform/eval_context_mock.go index 26ed4be1f..0985e9e8b 100644 --- a/terraform/eval_context_mock.go +++ b/terraform/eval_context_mock.go @@ -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 diff --git a/terraform/eval_variable.go b/terraform/eval_variable.go index 7f6651c4c..e8a88a14d 100644 --- a/terraform/eval_variable.go +++ b/terraform/eval_variable.go @@ -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. diff --git a/terraform/node_module_variable.go b/terraform/node_module_variable.go index 6b675e570..7494e0045 100644 --- a/terraform/node_module_variable.go +++ b/terraform/node_module_variable.go @@ -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, + }, }, } } diff --git a/terraform/node_root_variable.go b/terraform/node_root_variable.go index 1c302903d..e3aee6fc8 100644 --- a/terraform/node_root_variable.go +++ b/terraform/node_root_variable.go @@ -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{ diff --git a/terraform/testdata/validate-variable-custom-validations-child/child/child.tf b/terraform/testdata/validate-variable-custom-validations-child/child/child.tf new file mode 100644 index 000000000..88598e042 --- /dev/null +++ b/terraform/testdata/validate-variable-custom-validations-child/child/child.tf @@ -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\"." + } +} diff --git a/terraform/testdata/validate-variable-custom-validations-child/validate-variable-custom-validations.tf b/terraform/testdata/validate-variable-custom-validations-child/validate-variable-custom-validations.tf new file mode 100644 index 000000000..8b8111e67 --- /dev/null +++ b/terraform/testdata/validate-variable-custom-validations-child/validate-variable-custom-validations.tf @@ -0,0 +1,5 @@ +module "child" { + source = "./child" + + test = "nope" +} diff --git a/website/docs/configuration/functions/can.html.md b/website/docs/configuration/functions/can.html.md index ab638f36a..c957020cb 100644 --- a/website/docs/configuration/functions/can.html.md +++ b/website/docs/configuration/functions/can.html.md @@ -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 diff --git a/website/docs/configuration/terraform.html.md b/website/docs/configuration/terraform.html.md index 16209970e..c714adf67 100644 --- a/website/docs/configuration/terraform.html.md +++ b/website/docs/configuration/terraform.html.md @@ -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. diff --git a/website/docs/configuration/variables.html.md b/website/docs/configuration/variables.html.md index f13afeedf..bbc0dd4c9 100644 --- a/website/docs/configuration/variables.html.md +++ b/website/docs/configuration/variables.html.md @@ -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