Merge pull request #38240 from hashicorp/stacks-variable-validation-blocks

Stacks variable validation blocks
This commit is contained in:
sahar-azizighannad 2026-03-11 11:40:01 -04:00 committed by GitHub
commit 256e575324
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1757 additions and 19 deletions

View file

@ -0,0 +1,5 @@
kind: ENHANCEMENTS
body: Add input variable validation for Stacks
time: 2026-03-05T10:37:21.047704-05:00
custom:
Issue: "38240"

View file

@ -0,0 +1,72 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package stackconfig
import "github.com/hashicorp/hcl/v2"
// CheckRule represents a custom validation rule for a stack input variable.
//
// This is the stacks-specific equivalent of configs.CheckRule in the core
// Terraform package. It is intentionally duplicated here to maintain
// separation between stacks and core Terraform, allowing each to evolve
// independently.
type CheckRule struct {
// Condition is an expression that must evaluate to true if the validation
// passes, or false if it fails. The expression may only refer to the
// variable being validated (via var.<name>).
Condition hcl.Expression
// ErrorMessage is an expression that evaluates to the error message shown
// to the user when the condition is false. It must evaluate to a string.
ErrorMessage hcl.Expression
DeclRange hcl.Range
}
// decodeCheckRuleBlock decodes a validation block for stack input variables.
// This is duplicated from the core configs package to maintain separation between
// stacks and core Terraform, allowing each to evolve independently.
func decodeCheckRuleBlock(block *hcl.Block) (*CheckRule, hcl.Diagnostics) {
var diags hcl.Diagnostics
cr := &CheckRule{
DeclRange: block.DefRange,
}
content, hclDiags := block.Body.Content(checkRuleBlockSchema)
diags = append(diags, hclDiags...)
if attr, exists := content.Attributes["condition"]; exists {
cr.Condition = attr.Expr
if len(cr.Condition.Variables()) == 0 {
// A condition expression that doesn't refer to any variable is
// pointless, because its result would always be a constant.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid validation expression",
Detail: "The condition expression must refer to at least one object from elsewhere in the configuration, or else its result would not be checking anything.",
Subject: cr.Condition.Range().Ptr(),
})
}
}
if attr, exists := content.Attributes["error_message"]; exists {
cr.ErrorMessage = attr.Expr
}
return cr, diags
}
var checkRuleBlockSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "condition",
Required: true,
},
{
Name: "error_message",
Required: true,
},
},
}

View file

@ -23,6 +23,12 @@ type InputVariable struct {
Sensitive bool
Ephemeral bool
// Validations contains custom validation rules for this variable.
// These rules are evaluated at runtime during the plan phase to ensure
// that provided values meet the specified constraints.
// Each CheckRule includes a condition expression that must evaluate to true,
// and an error message to display if the validation fails.
Validations []*CheckRule
DeclRange tfdiags.SourceRange
}
@ -89,13 +95,25 @@ func decodeInputVariableBlock(block *hcl.Block) (*InputVariable, tfdiags.Diagnos
diags = diags.Append(hclDiags)
}
// Process any nested blocks (currently only validation blocks are supported)
for _, block := range content.Blocks {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Custom variable validation not yet supported",
Detail: "Input variables for a stack configuration do not yet support custom variable validation.",
Subject: block.DefRange.Ptr(),
})
switch block.Type {
case "validation":
// Decode the validation block into a CheckRule structure.
// This only validates the syntax and structure of the validation block itself,
// not the actual runtime validation of input values.
vv, hclDiags := decodeCheckRuleBlock(block)
diags = diags.Append(hclDiags)
// Only add the validation rule if it was successfully parsed.
// If there were errors (e.g., missing condition or error_message),
// those errors are already captured in diags above.
if !hclDiags.HasErrors() {
ret.Validations = append(ret.Validations, vv)
}
default:
// Should not get here as the schema defines what blocks are allowed
panic("unhandled block type " + block.Type)
}
}
return ret, diags
@ -109,6 +127,8 @@ var inputVariableBlockSchema = &hcl.BodySchema{
{Name: "sensitive", Required: false},
{Name: "ephemeral", Required: false},
},
// Validation blocks allow custom validation rules for input variables.
// Multiple validation blocks are allowed per variable.
Blocks: []hcl.BlockHeaderSchema{
{Type: "validation"},
},

View file

@ -6,16 +6,19 @@ package stackeval
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/hashicorp/terraform/internal/lang"
"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/plans/objchange"
"github.com/hashicorp/terraform/internal/promising"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackconfig"
"github.com/hashicorp/terraform/internal/stacks/stackplan"
"github.com/hashicorp/terraform/internal/stacks/stackstate"
"github.com/hashicorp/terraform/internal/tfdiags"
@ -144,10 +147,7 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va
}
}
// First, apply any defaults that are declared in the
// configuration.
// Next, convert the value to the expected type.
// Convert the value to the expected type.
val, err = convert.Convert(val, wantTy)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
@ -186,9 +186,20 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va
}
}
// TODO: check the value against any custom validation rules
// declared in the configuration.
return cfg.markValue(val), diags
// Mark the value before validation so that validation can detect
// sensitive/ephemeral marks and avoid leaking protected values in error messages.
val = cfg.markValue(val)
// Evaluate custom validation rules against the input value.
// Validation is skipped during ValidatePhase because actual input values
// are not yet available at that phase — only plan/apply provide them.
// This matches the behavior of core Terraform's variable validation.
if phase != ValidatePhase {
moreDiags := v.evalVariableValidations(ctx, val, phase)
diags = diags.Append(moreDiags)
}
return val, diags
default:
definedByCallInst, definedByRemovedCallInst := v.DefinedByStackCallInstance(ctx, phase)
@ -197,18 +208,32 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va
allVals := definedByCallInst.InputVariableValues(ctx, phase)
val := allVals.GetAttr(v.addr.Item.Name)
// TODO: check the value against any custom validation rules
// declared in the configuration.
// Mark the value before validation to prevent leaking sensitive/ephemeral data.
val = cfg.markValue(val)
return cfg.markValue(val), diags
// Evaluate custom validation rules for values from stack call instances.
// Skip during ValidatePhase as values are not yet available.
if phase != ValidatePhase {
moreDiags := v.evalVariableValidations(ctx, val, phase)
diags = diags.Append(moreDiags)
}
return val, diags
case definedByRemovedCallInst != nil:
allVals, _ := definedByRemovedCallInst.InputVariableValues(ctx, phase)
val := allVals.GetAttr(v.addr.Item.Name)
// TODO: check the value against any custom validation rules
// declared in the configuration.
// Mark the value before validation to prevent leaking sensitive/ephemeral data.
val = cfg.markValue(val)
return cfg.markValue(val), diags
// Evaluate validation rules for removed stack instances.
// Skip during ValidatePhase as values are not yet available.
if phase != ValidatePhase {
moreDiags := v.evalVariableValidations(ctx, val, phase)
diags = diags.Append(moreDiags)
}
return val, diags
default:
// We seem to belong to a call instance that doesn't actually
// exist in the configuration. That either means that
@ -364,6 +389,229 @@ func (v *InputVariable) tracingName() string {
return v.addr.String()
}
// evalVariableValidations evaluates all custom validation rules for this input variable
// against the given value, returning diagnostics if any validations fail.
//
// This function implements runtime validation checking, which is distinct from the
// config-time parsing done in stackconfig. The validation rules were parsed and stored
// during config loading; this function evaluates those rules against actual input values.
//
// The validation process:
// 1. Creates an HCL evaluation context with the variable's value and available functions
// 2. Evaluates each validation rule's condition and error_message expressions
// 3. Always validates the error_message structure (sensitive/ephemeral marks are flagged
// regardless of whether the condition passes or fails)
// 4. If the condition is false, reports an "Invalid value for variable" diagnostic
//
// This follows the same approach as core Terraform's evalVariableValidations, including
// handling of sensitive values, unknown values, and error message evaluation.
func (v *InputVariable) evalVariableValidations(ctx context.Context, val cty.Value, phase EvalPhase) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
rules := v.config.config.Validations
if len(rules) == 0 {
// No validation rules defined, nothing to check
return diags
}
// Get provider-defined functions from the stack scope.
// These will be combined with built-in functions below.
functions, moreDiags := v.stack.ExternalFunctions(ctx)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
// If we can't get the function table, we can't evaluate validation expressions
// that depend on functions. Return early to avoid confusing downstream errors.
return diags
}
// Create a scope to get the complete function table (provider-defined + built-in).
// fakeScope.Functions() will combine the provider functions with built-in functions
// like length(), regex(), etc. We don't need a full evaluation context, just the functions.
fakeScope := &lang.Scope{
Data: nil, // not a real scope; can't actually make an evalcontext
BaseDir: ".",
PureOnly: phase != ApplyPhase,
PlanTimestamp: v.stack.PlanTimestamp(),
ExternalFuncs: functions,
}
// Create an HCL evaluation context with the variable value and functions.
// The variable is made available as var.<name> within validation expressions.
hclCtx := &hcl.EvalContext{
Variables: map[string]cty.Value{
"var": cty.ObjectVal(map[string]cty.Value{
v.addr.Item.Name: val,
}),
},
Functions: fakeScope.Functions(),
}
// Evaluate each validation rule independently.
// Multiple validation failures will all be reported.
for _, validation := range rules {
moreDiags := evalVariableValidation(validation, hclCtx, v.config.config.DeclRange.ToHCL())
diags = diags.Append(moreDiags)
}
return diags
}
// evalVariableValidation evaluates a single validation rule against a variable value.
//
// This function handles the evaluation of one validation block's condition and error_message.
// It follows the same logic as core Terraform's variable validation:
//
// 1. Evaluates the condition and error_message expressions up front
// 2. Handles unknown/null/invalid condition results appropriately
// 3. Always validates the error_message structure — sensitive/ephemeral marks
// in the message are flagged even when the condition passes
// 4. Returns early if the condition passes (after reporting any message issues)
// 5. Otherwise constructs an "Invalid value for variable" diagnostic
//
// Parameters:
// - validation: The validation rule to evaluate (contains condition and error_message expressions)
// - hclCtx: The HCL evaluation context with the variable value and functions
// - valueRng: The source range of the variable declaration (for diagnostic reporting)
func evalVariableValidation(validation *stackconfig.CheckRule, hclCtx *hcl.EvalContext, valueRng hcl.Range) tfdiags.Diagnostics {
const errInvalidCondition = "Invalid variable validation result"
const errInvalidValue = "Invalid value for variable"
var diags tfdiags.Diagnostics
result, moreDiags := validation.Condition.Value(hclCtx)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
// If we couldn't evaluate the condition at all (syntax error, etc.),
// return early. The error is already in diags.
return diags
}
// If the condition result is unknown, we can't determine validity yet.
// This can happen when the condition references computed values.
// Skip validation for now - it will be checked during apply if needed.
if !result.IsKnown() {
return diags
}
// Check if the result is null
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,
})
return diags
}
// Convert result to boolean
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,
})
return diags
}
// Remove any marks (sensitive, ephemeral) before checking the boolean value.
// The marks don't affect the validation result, only how we handle the error message.
result, _ = result.Unmark()
// Always evaluate the error_message expression, even when the condition passes —
// unknown, sensitive, or ephemeral values in the message are structural problems
// regardless of whether the check succeeds or fails.
errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx)
diags = diags.Append(errorDiags)
var errorMessage string
if !errorDiags.HasErrors() {
if !errorValue.IsKnown() {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid error message",
Detail: "Unsuitable value for error message: expression refers to values that won't be known until the apply phase.",
Subject: validation.ErrorMessage.Range().Ptr(),
Expression: validation.ErrorMessage,
EvalContext: hclCtx,
})
return diags
} else if !errorValue.IsNull() {
errorValue, err = convert.Convert(errorValue, cty.String)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid error message",
Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)),
Subject: validation.ErrorMessage.Range().Ptr(),
Expression: validation.ErrorMessage,
EvalContext: hclCtx,
})
} else {
// Check for sensitive/ephemeral marks; these are flagged even when
// the condition passes, since the error message is structurally invalid.
if marks.Has(errorValue, marks.Sensitive) {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Error message refers to sensitive values",
Detail: `The error expression used to explain this condition refers to sensitive values. Terraform will not display the resulting message.
You can correct this by removing references to sensitive values, or by carefully using the nonsensitive() function if the expression will not reveal the sensitive data.`,
Subject: validation.ErrorMessage.Range().Ptr(),
Expression: validation.ErrorMessage,
EvalContext: hclCtx,
})
errorMessage = "The error message included a sensitive value, so it will not be displayed."
} else if marks.Has(errorValue, marks.Ephemeral) {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Error message refers to ephemeral values",
Detail: `The error expression used to explain this condition refers to ephemeral values. Terraform will not display the resulting message.
You can correct this by removing references to ephemeral values, or by carefully using the ephemeralasnull() function if the expression will not reveal the ephemeral data.`,
Subject: validation.ErrorMessage.Range().Ptr(),
Expression: validation.ErrorMessage,
EvalContext: hclCtx,
})
errorMessage = "The error message included an ephemeral value, so it will not be displayed."
} else {
errorMessage = strings.TrimSpace(errorValue.AsString())
}
}
}
}
if errorMessage == "" {
errorMessage = "Failed to evaluate condition error message."
}
// If the condition evaluated to true, the validation passed. We've validated
// the error message above, so any structural issues are already reported.
if result.True() {
return diags
}
// Construct the validation failure diagnostic.
// The detail includes both the custom error message and a reference to where
// the validation rule is defined, helping users locate the validation in their config.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: errInvalidValue,
Detail: fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", errorMessage, validation.DeclRange.String()),
Subject: &valueRng,
Expression: validation.Condition,
EvalContext: hclCtx,
})
return diags
}
// ExternalInputValue represents the value of an input variable provided
// from outside the stack configuration.
type ExternalInputValue struct {

View file

@ -5,10 +5,15 @@ package stackeval
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/hcl/v2/hcltest"
"github.com/zclconf/go-cty-debug/ctydebug"
"github.com/zclconf/go-cty/cty"
"google.golang.org/protobuf/encoding/prototext"
@ -17,9 +22,13 @@ import (
"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/promising"
"github.com/hashicorp/terraform/internal/providers"
testing_provider "github.com/hashicorp/terraform/internal/providers/testing"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackconfig"
"github.com/hashicorp/terraform/internal/stacks/stackplan"
"github.com/hashicorp/terraform/internal/stacks/stackstate"
"github.com/hashicorp/terraform/internal/tfdiags"
)
func TestInputVariableValue(t *testing.T) {
@ -487,3 +496,617 @@ func TestInputVariablePlanChanges(t *testing.T) {
})
}
}
// TestEvalVariableValidation tests the evalVariableValidation function directly,
// covering all the "invalid" cases: sensitive/ephemeral values in the error message,
// unknown/null condition results, and unknown error messages. These tests
// exercise the logic independently of the full stack-evaluator machinery.
func TestEvalVariableValidation(t *testing.T) {
// parseExpr parses a real HCL expression from a source string.
parseExpr := func(t *testing.T, src string) hcl.Expression {
t.Helper()
expr, diags := hclsyntax.ParseExpression([]byte(src), "test.hcl", hcl.Pos{Line: 1, Column: 1})
if diags.HasErrors() {
t.Fatalf("failed to parse expression %q: %s", src, diags.Error())
}
return expr
}
// makeFakeRule builds a minimal stackconfig.CheckRule from two expressions.
makeFakeRule := func(condition, errorMessage hcl.Expression) *stackconfig.CheckRule {
return &stackconfig.CheckRule{
Condition: condition,
ErrorMessage: errorMessage,
DeclRange: hcl.Range{
Filename: "test.hcl",
Start: hcl.Pos{Line: 2, Column: 1},
End: hcl.Pos{Line: 5, Column: 1},
},
}
}
// makeVarCtx builds an HCL evaluation context that exposes var.foo = val.
makeVarCtx := func(val cty.Value) *hcl.EvalContext {
return &hcl.EvalContext{
Variables: map[string]cty.Value{
"var": cty.ObjectVal(map[string]cty.Value{
"foo": val,
}),
},
}
}
valueRange := hcl.Range{
Filename: "test.hcl",
Start: hcl.Pos{Line: 1, Column: 1},
End: hcl.Pos{Line: 1, Column: 10},
}
// --- Basic pass/fail ---
t.Run("condition passes, clean message → no diagnostics", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.True),
hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange)
assertNoDiags(t, diags)
})
t.Run("condition fails, clean message → Invalid value for variable", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.StringVal("Value must be 'good'.")),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
})
// --- Sensitive error message ---
t.Run("condition passes, sensitive error_message → flagged even on success", func(t *testing.T) {
// The error_message evaluates to a sensitive string even though the
// condition passes. This structural problem must always be reported.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.True),
hcltest.MockExprLiteral(cty.StringVal("Contains secret").Mark(marks.Sensitive)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to sensitive values"
})
// Condition passed, so there must be no "Invalid value for variable".
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' diagnostic when condition passed")
}
}
})
t.Run("condition fails, sensitive error_message → both diagnostics", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.StringVal("Contains secret").Mark(marks.Sensitive)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to sensitive values"
})
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
})
// --- Ephemeral error message ---
t.Run("condition passes, ephemeral error_message → flagged even on success", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.True),
hcltest.MockExprLiteral(cty.StringVal("Contains ephemeral").Mark(marks.Ephemeral)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to ephemeral values"
})
// Condition passed, so there must be no "Invalid value for variable".
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' diagnostic when condition passed")
}
}
})
t.Run("condition fails, ephemeral error_message → both diagnostics", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.StringVal("Contains ephemeral").Mark(marks.Ephemeral)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to ephemeral values"
})
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
})
// --- Unknown / null condition results ---
t.Run("condition result unknown → no diagnostics", func(t *testing.T) {
// Unknown condition means we cannot determine validity yet; skip quietly.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.UnknownVal(cty.Bool)),
hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.UnknownVal(cty.String)), valueRange)
assertNoDiags(t, diags)
})
t.Run("condition result null → Invalid variable validation result", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.NullVal(cty.Bool)),
hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid variable validation result"
})
})
// --- Unknown error message ---
t.Run("error message unknown, condition fails → Invalid error message only", func(t *testing.T) {
// An unknown error_message is always a structural problem: the validation
// block is invalid regardless of whether the condition passes or fails,
// because Terraform can never safely display the message.
// We return early on the unknown message, so "Invalid value for variable"
// must NOT also be emitted.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.UnknownVal(cty.String)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid error message"
})
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' when error message is unknown")
}
}
})
t.Run("error message unknown, condition passes → Invalid error message only", func(t *testing.T) {
// An unknown error_message is always a structural problem: the validation
// block is invalid regardless of whether the condition passes or fails,
// because Terraform can never safely display the message.
// We return early on the unknown message, so "Invalid value for variable"
// must NOT be emitted even though the condition passed.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.True),
hcltest.MockExprLiteral(cty.UnknownVal(cty.String)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid error message"
})
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' when error message is unknown")
}
}
})
// --- Sensitive variable value in condition expression ---
t.Run("sensitive variable value, plain error message, condition fails → only Invalid value for variable", func(t *testing.T) {
// var.foo carries a sensitive mark. The condition expression
// (var.foo == "good") evaluates to a sensitive bool; Unmark() peels
// off the mark so the check works correctly. The error_message is a
// plain literal → no "Error message refers to sensitive values" diag.
hclCtx := makeVarCtx(cty.StringVal("bad").Mark(marks.Sensitive))
rule := makeFakeRule(
parseExpr(t, `var.foo == "good"`),
hcltest.MockExprLiteral(cty.StringVal("Value is not allowed.")),
)
diags := evalVariableValidation(rule, hclCtx, valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
for _, d := range diags {
if d.Description().Summary == "Error message refers to sensitive values" {
t.Errorf("unexpected 'Error message refers to sensitive values' when error message is plain text")
}
}
})
t.Run("sensitive variable referenced in error message, condition fails → both diagnostics", func(t *testing.T) {
// When the error_message interpolates a sensitive variable the
// evaluated message is itself sensitive — both the sensitive-value
// diagnostic and the generic failure diagnostic must be emitted.
hclCtx := makeVarCtx(cty.StringVal("secret").Mark(marks.Sensitive))
rule := makeFakeRule(
parseExpr(t, `var.foo == "good"`),
parseExpr(t, `"Value '${var.foo}' is not allowed."`),
)
diags := evalVariableValidation(rule, hclCtx, valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to sensitive values"
})
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
})
t.Run("ephemeral variable referenced in error message, condition passes → flagged even on success", func(t *testing.T) {
// The condition passes but the error_message references an ephemeral
// variable, making the message itself ephemeral. This structural
// problem must still be reported.
hclCtx := makeVarCtx(cty.StringVal("good").Mark(marks.Ephemeral))
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.True),
parseExpr(t, `"Value '${var.foo}' is not allowed."`),
)
diags := evalVariableValidation(rule, hclCtx, valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to ephemeral values"
})
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' when condition passed")
}
}
})
// --- Condition evaluation error ---
t.Run("condition evaluation error → early return with HCL error", func(t *testing.T) {
// When the condition expression itself fails to evaluate (e.g. it references
// an undefined variable), evalVariableValidation must return early with the
// evaluation error and must NOT emit "Invalid value for variable" or
// "Invalid variable validation result".
rule := makeFakeRule(
parseExpr(t, "undefined_var.foo"),
hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")),
)
// hclCtx only has "var", so "undefined_var" is unknown → evaluation error.
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange)
if !diags.HasErrors() {
t.Fatal("expected at least one error diagnostic, got none")
}
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' on condition evaluation error")
}
if d.Description().Summary == "Invalid variable validation result" {
t.Errorf("unexpected 'Invalid variable validation result' on condition evaluation error")
}
}
})
// --- Non-bool condition result ---
t.Run("condition result is non-bool (list) → Invalid variable validation result", func(t *testing.T) {
// A condition that returns a list (or any value that cannot be converted
// to bool) hits the convert.Convert(result, cty.Bool) failure path.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.ListValEmpty(cty.String)),
hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid variable validation result"
})
// Must return early — no "Invalid value for variable" should follow.
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' when condition type conversion failed")
}
}
})
// --- Null error message ---
t.Run("null error message, condition fails → Invalid value for variable with fallback text", func(t *testing.T) {
// A null error_message is skipped during string conversion; the framework
// falls back to "Failed to evaluate condition error message." in the
// detail of the "Invalid value for variable" diagnostic.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.NullVal(cty.String)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable" &&
strings.Contains(d.Description().Detail, "Failed to evaluate condition error message.")
})
})
// --- Non-string error message ---
t.Run("non-string error message (list), condition fails → Invalid error message + fallback in failure diag", func(t *testing.T) {
// An error_message that evaluates to a list (unconvertible to string)
// hits the convert.Convert(errorValue, cty.String) failure path. Both
// "Invalid error message" and "Invalid value for variable" (with the
// fallback text) must be emitted.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.ListValEmpty(cty.String)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid error message"
})
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable" &&
strings.Contains(d.Description().Detail, "Failed to evaluate condition error message.")
})
})
}
// TestInputVariableValidation exercises evalVariableValidations end-to-end
// through CheckValue, using the "validation" fixture that declares variables
// with validation blocks.
func TestInputVariableValidation(t *testing.T) {
cfg := testStackConfig(t, "input_variable", "validation")
tests := map[string]struct {
varName string
inputVal cty.Value
wantSummaries []string // diagnostics that MUST be present (by Summary)
wantNoErrors bool // if true, no error diagnostics are expected
}{
// --- validated (plain error message) ---
"validated: clean pass": {
varName: "validated",
inputVal: cty.StringVal("good"),
wantNoErrors: true,
},
"validated: clean fail": {
varName: "validated",
inputVal: cty.StringVal("bad"),
wantSummaries: []string{"Invalid value for variable"},
},
// --- with_msg_ref (error message interpolates var.with_msg_ref) ---
"with_msg_ref: clean pass": {
varName: "with_msg_ref",
inputVal: cty.StringVal("good"),
wantNoErrors: true,
},
"with_msg_ref: clean fail": {
varName: "with_msg_ref",
inputVal: cty.StringVal("bad"),
wantSummaries: []string{"Invalid value for variable"},
},
"with_msg_ref: sensitive value passes → error message diag only": {
// Condition passes, but the interpolated error_message is sensitive
// → we should still flag the structural problem.
varName: "with_msg_ref",
inputVal: cty.StringVal("good").Mark(marks.Sensitive),
wantSummaries: []string{"Error message refers to sensitive values"},
},
"with_msg_ref: sensitive value fails → both diags": {
varName: "with_msg_ref",
inputVal: cty.StringVal("bad").Mark(marks.Sensitive),
wantSummaries: []string{
"Error message refers to sensitive values",
"Invalid value for variable",
},
},
"with_msg_ref: ephemeral value passes → error message diag only": {
varName: "with_msg_ref",
inputVal: cty.StringVal("good").Mark(marks.Ephemeral),
wantSummaries: []string{"Error message refers to ephemeral values"},
},
"with_msg_ref: ephemeral value fails → both diags": {
varName: "with_msg_ref",
inputVal: cty.StringVal("bad").Mark(marks.Ephemeral),
wantSummaries: []string{
"Error message refers to ephemeral values",
"Invalid value for variable",
},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
main := testEvaluator(t, testEvaluatorOpts{
Config: cfg,
InputVariableValues: map[string]cty.Value{
tc.varName: tc.inputVal,
},
})
inPromisingTask(t, func(ctx context.Context, t *testing.T) {
mainStack := main.MainStack()
rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: tc.varName})
_, diags := rootVar.CheckValue(ctx, InspectPhase)
if tc.wantNoErrors {
if diags.HasErrors() {
t.Errorf("unexpected errors: %s", diags.Err())
}
return
}
for _, wantSummary := range tc.wantSummaries {
wantSummary := wantSummary // capture for closure
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == wantSummary
})
}
})
})
}
}
// TestInputVariableValidationWithProviderFunction verifies that provider-defined
// functions can be called inside a variable validation condition expression.
// It uses the "validation_provider_function" fixture together with a mock provider
// that exposes a simple "upper" string function.
func TestInputVariableValidationWithProviderFunction(t *testing.T) {
cfg := testStackConfig(t, "input_variable", "validation_provider_function")
providerTypeAddr := addrs.MustParseProviderSourceString("terraform.io/builtin/testing")
newMockProvider := func(t *testing.T) (*testing_provider.MockProvider, providers.Factory) {
t.Helper()
mockProvider := &testing_provider.MockProvider{
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
Functions: map[string]providers.FunctionDecl{
"upper": {
Parameters: []providers.FunctionParam{
{Name: "input", Type: cty.String},
},
ReturnType: cty.String,
Summary: "Converts a string to upper-case.",
},
},
},
CallFunctionFn: func(req providers.CallFunctionRequest) providers.CallFunctionResponse {
if req.FunctionName != "upper" {
return providers.CallFunctionResponse{
Err: fmt.Errorf("unexpected function call: %s", req.FunctionName),
}
}
input, _ := req.Arguments[0].Unmark()
return providers.CallFunctionResponse{
Result: cty.StringVal(strings.ToUpper(input.AsString())),
}
},
}
return mockProvider, providers.FactoryFixed(mockProvider)
}
t.Run("passes validation", func(t *testing.T) {
_, providerFactory := newMockProvider(t)
main := testEvaluator(t, testEvaluatorOpts{
Config: cfg,
InputVariableValues: map[string]cty.Value{
"foo": cty.StringVal("hello"), // upper("hello") == "HELLO" → condition passes
},
ProviderFactories: ProviderFactories{
providerTypeAddr: providerFactory,
},
})
inPromisingTask(t, func(ctx context.Context, t *testing.T) {
mainStack := main.MainStack()
rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "foo"})
_, diags := rootVar.CheckValue(ctx, InspectPhase)
assertNoDiags(t, diags)
})
})
t.Run("fails validation", func(t *testing.T) {
_, providerFactory := newMockProvider(t)
main := testEvaluator(t, testEvaluatorOpts{
Config: cfg,
InputVariableValues: map[string]cty.Value{
"foo": cty.StringVal("world"), // upper("world") == "WORLD" ≠ "HELLO" → condition fails
},
ProviderFactories: ProviderFactories{
providerTypeAddr: providerFactory,
},
})
inPromisingTask(t, func(ctx context.Context, t *testing.T) {
mainStack := main.MainStack()
rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "foo"})
_, diags := rootVar.CheckValue(ctx, InspectPhase)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
})
})
}
// TestInputVariableMultipleValidationRules verifies that when a variable has
// more than one validation block, every failing rule produces its own
// diagnostic — i.e., all rules are evaluated and none are short-circuited.
//
// The "multi_rule" variable in the "validation" fixture has two rules:
//
// Rule 1: length(var.multi_rule) >= 5
// Rule 2: var.multi_rule != "bad"
//
// The value "bad" has length 3 (< 5) and equals "bad", so it violates both
// rules simultaneously, giving us exactly two "Invalid value for variable"
// diagnostics.
func TestInputVariableMultipleValidationRules(t *testing.T) {
cfg := testStackConfig(t, "input_variable", "validation")
tests := map[string]struct {
inputVal cty.Value
wantErrCount int // expected number of "Invalid value for variable" diagnostics
}{
"passes both rules": {
inputVal: cty.StringVal("hello"), // length 5 >= 5, != "bad"
wantErrCount: 0,
},
"fails first rule only": {
inputVal: cty.StringVal("hi"), // length 2 < 5, != "bad"
wantErrCount: 1,
},
"fails second rule only": {
// length("hello!") = 6 >= 5 → first passes; "hello!" != "bad" → second passes.
// To fail only the second rule we need length >= 5 AND value == "bad".
// "bad" itself has length 3, so the only way to isolate rule 2 failure
// is with a longer value that equals "bad" — impossible for a plain
// string. We therefore omit this sub-case and rely on the unit-level
// TestEvalVariableValidation coverage instead.
inputVal: cty.StringVal("hello"), // deliberately a pass case
wantErrCount: 0,
},
"fails both rules": {
inputVal: cty.StringVal("bad"), // length 3 < 5 AND == "bad"
wantErrCount: 2,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
main := testEvaluator(t, testEvaluatorOpts{
Config: cfg,
InputVariableValues: map[string]cty.Value{
"multi_rule": tc.inputVal,
},
})
inPromisingTask(t, func(ctx context.Context, t *testing.T) {
mainStack := main.MainStack()
rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "multi_rule"})
_, diags := rootVar.CheckValue(ctx, InspectPhase)
var failCount int
for _, d := range diags {
if d.Severity() == tfdiags.Error && d.Description().Summary == "Invalid value for variable" {
failCount++
}
}
if failCount != tc.wantErrCount {
t.Errorf("expected %d 'Invalid value for variable' diagnostic(s), got %d; diags:\n%s",
tc.wantErrCount, failCount, diags.ErrWithWarnings())
}
})
})
}
}

View file

@ -0,0 +1,43 @@
# A variable with a simple validation that does not reference the variable
# value inside the error_message.
variable "validated" {
type = string
validation {
condition = var.validated != "bad"
error_message = "Value must not be 'bad'."
}
}
# A variable whose error_message expression interpolates the variable value.
# When the input carries a sensitive or ephemeral mark, evaluating the
# interpolation causes the error_message result to inherit that mark which
# is the behaviour we want to exercise.
variable "with_msg_ref" {
type = string
validation {
condition = var.with_msg_ref != "bad"
error_message = "Got disallowed value '${var.with_msg_ref}'."
}
}
# A variable with two validation blocks to verify that all rules are evaluated
# and all failures are reported independently.
#
# Inputs that trigger both failures simultaneously:
# "bad" — length("bad") = 3 < 5 AND "bad" == "bad"
variable "multi_rule" {
type = string
validation {
condition = length(var.multi_rule) >= 5
error_message = "Value must be at least 5 characters long."
}
validation {
condition = var.multi_rule != "bad"
error_message = "Value must not be 'bad'."
}
}

View file

@ -0,0 +1,25 @@
# This fixture is used by TestInputVariableValidationWithProviderFunction to
# verify that provider-defined functions can be called inside a validation
# condition expression.
#
# The mock test provider exposes a single function "upper" that converts a
# string to upper-case; the validation here checks that the given value
# equals "HELLO" when converted to upper-case.
required_providers {
testing = {
source = "terraform.io/builtin/testing"
}
}
provider "testing" "main" {}
variable "foo" {
type = string
validation {
condition = provider::testing::upper(var.foo) == "HELLO"
error_message = "Value must equal 'hello' (case-insensitive)."
}
}

View file

@ -6590,3 +6590,360 @@ func TestPlanWithDeferredActionInvocation(t *testing.T) {
}
}
}
// TestPlan_variableValidationAdvanced tests advanced variable validation scenarios
func TestPlan_variableValidationAdvanced(t *testing.T) {
ctx := context.Background()
lock := depsfile.NewLocks()
lock.SetProvider(
addrs.NewDefaultProvider("testing"),
providerreqs.MustParseVersion("0.0.0"),
providerreqs.MustParseVersionConstraints("=0.0.0"),
providerreqs.PreferredHashes([]providerreqs.Hash{}),
)
fakePlanTimestamp, _ := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z")
testCases := map[string]struct {
configPath string
planInputVars map[string]cty.Value
wantErrorMessages []string // Just check for error message presence, not exact diagnostic structure
}{
// Type validation tests
"types-number-pass": {
configPath: path.Join("with-single-input", "validation-types"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"number_input": cty.NumberIntVal(50),
"list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}),
"map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}),
},
wantErrorMessages: nil,
},
"types-number-out-of-range": {
configPath: path.Join("with-single-input", "validation-types"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"number_input": cty.NumberIntVal(150),
"list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}),
"map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}),
},
wantErrorMessages: []string{"Number must be between 0 and 100."},
},
"types-list-too-many": {
configPath: path.Join("with-single-input", "validation-types"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"number_input": cty.NumberIntVal(50),
"list_input": cty.ListVal([]cty.Value{cty.StringVal("1"), cty.StringVal("2"), cty.StringVal("3"), cty.StringVal("4"), cty.StringVal("5"), cty.StringVal("6")}),
"map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}),
},
wantErrorMessages: []string{"List must contain 1-5 items."},
},
"types-list-empty-string": {
configPath: path.Join("with-single-input", "validation-types"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"number_input": cty.NumberIntVal(50),
"list_input": cty.ListVal([]cty.Value{cty.StringVal("item"), cty.StringVal("")}),
"map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}),
},
wantErrorMessages: []string{"List items cannot be empty strings."},
},
"types-map-missing-key": {
configPath: path.Join("with-single-input", "validation-types"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"number_input": cty.NumberIntVal(50),
"list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}),
"map_input": cty.MapVal(map[string]cty.Value{"other_key": cty.StringVal("value")}),
},
wantErrorMessages: []string{"Map must contain 'required_key'."},
},
// Sensitive variable validation tests
"sensitive-password-pass": {
configPath: path.Join("with-single-input", "validation-sensitive"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("SecurePass123"),
"api_key": cty.StringVal("abcdef0123456789abcdef0123456789"),
},
wantErrorMessages: nil,
},
"sensitive-password-too-short": {
configPath: path.Join("with-single-input", "validation-sensitive"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("Short1"),
"api_key": cty.StringVal("abcdef0123456789abcdef0123456789"),
},
wantErrorMessages: []string{"Password must be at least 8 characters long."},
},
"sensitive-password-no-uppercase": {
configPath: path.Join("with-single-input", "validation-sensitive"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("securepass123"),
"api_key": cty.StringVal("abcdef0123456789abcdef0123456789"),
},
wantErrorMessages: []string{"Password must contain at least one uppercase letter."},
},
"sensitive-password-no-number": {
configPath: path.Join("with-single-input", "validation-sensitive"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("SecurePass"),
"api_key": cty.StringVal("abcdef0123456789abcdef0123456789"),
},
wantErrorMessages: []string{"Password must contain at least one number."},
},
"sensitive-api-key-wrong-length": {
configPath: path.Join("with-single-input", "validation-sensitive"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("SecurePass123"),
"api_key": cty.StringVal("abc123"),
},
wantErrorMessages: []string{"API key must be exactly 32 characters."},
},
"sensitive-api-key-invalid-chars": {
configPath: path.Join("with-single-input", "validation-sensitive"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("SecurePass123"),
"api_key": cty.StringVal("ABCDEF0123456789ABCDEF0123456789"),
},
wantErrorMessages: []string{"API key must only contain lowercase hex characters."},
},
// Complex validation tests
"complex-email-pass": {
configPath: path.Join("with-single-input", "validation-complex"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"email": cty.StringVal("user@example.com"),
"ip_address": cty.StringVal("192.168.1.1"),
"environment": cty.StringVal("dev"),
"tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team"), "env": cty.StringVal("dev")}),
},
wantErrorMessages: nil,
},
"complex-email-invalid": {
configPath: path.Join("with-single-input", "validation-complex"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"email": cty.StringVal("not-an-email"),
"ip_address": cty.StringVal("192.168.1.1"),
"environment": cty.StringVal("dev"),
"tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}),
},
wantErrorMessages: []string{"Must be a valid email address."},
},
"complex-ip-invalid": {
configPath: path.Join("with-single-input", "validation-complex"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"email": cty.StringVal("user@example.com"),
"ip_address": cty.StringVal("999.999.999.999"),
"environment": cty.StringVal("dev"),
"tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}),
},
wantErrorMessages: []string{"Must be a valid IPv4 address."},
},
"complex-environment-invalid": {
configPath: path.Join("with-single-input", "validation-complex"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"email": cty.StringVal("user@example.com"),
"ip_address": cty.StringVal("192.168.1.1"),
"environment": cty.StringVal("test"),
"tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}),
},
wantErrorMessages: []string{"Environment must be dev, staging, or prod."},
},
"complex-tags-invalid-key": {
configPath: path.Join("with-single-input", "validation-complex"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"email": cty.StringVal("user@example.com"),
"ip_address": cty.StringVal("192.168.1.1"),
"environment": cty.StringVal("dev"),
"tags": cty.MapVal(map[string]cty.Value{"Owner": cty.StringVal("team"), "owner": cty.StringVal("team")}),
},
wantErrorMessages: []string{"Tag keys must start with lowercase letter and contain only lowercase letters, numbers, and hyphens."},
},
"complex-tags-missing-owner": {
configPath: path.Join("with-single-input", "validation-complex"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"email": cty.StringVal("user@example.com"),
"ip_address": cty.StringVal("192.168.1.1"),
"environment": cty.StringVal("dev"),
"tags": cty.MapVal(map[string]cty.Value{"env": cty.StringVal("dev")}),
},
wantErrorMessages: []string{"Tags must include 'owner' key."},
},
"complex-tags-empty-value": {
configPath: path.Join("with-single-input", "validation-complex"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"email": cty.StringVal("user@example.com"),
"ip_address": cty.StringVal("192.168.1.1"),
"environment": cty.StringVal("dev"),
"tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal(""), "env": cty.StringVal("dev")}),
},
wantErrorMessages: []string{"Tag values must be 1-256 characters."},
},
"complex-tags-value-too-long": {
configPath: path.Join("with-single-input", "validation-complex"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"email": cty.StringVal("user@example.com"),
"ip_address": cty.StringVal("192.168.1.1"),
"environment": cty.StringVal("dev"),
"tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team"), "description": cty.StringVal(strings.Repeat("x", 257))}),
},
wantErrorMessages: []string{"Tag values must be 1-256 characters."},
},
// Invalid error message tests - these verify that invalid error messages
// are caught even when validation passes or fails
"invalid-error-message-sensitive-in-error": {
configPath: path.Join("with-single-input", "validation-invalid-error-message"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("short"),
"token": cty.StringVal("abcdef0123456789abcdef0123456789"),
"count_value": cty.SetVal([]cty.Value{cty.StringVal("a")}),
"api_key": cty.StringVal("abcdef0123456789"),
},
wantErrorMessages: []string{
"error expression used to explain this condition refers to sensitive values",
},
},
"invalid-error-message-ephemeral-in-error": {
configPath: path.Join("with-single-input", "validation-invalid-error-message"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("SecurePass123"),
"token": cty.StringVal("short_token"),
"count_value": cty.SetVal([]cty.Value{cty.StringVal("a")}),
"api_key": cty.StringVal("abcdef0123456789"),
},
wantErrorMessages: []string{
"error expression used to explain this condition refers to ephemeral values",
},
},
"invalid-error-message-not-string": {
configPath: path.Join("with-single-input", "validation-invalid-error-message"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("SecurePass123"),
"token": cty.StringVal("abcdef0123456789abcdef0123456789"),
"count_value": cty.SetValEmpty(cty.String), // empty set fails condition; a set cannot be converted to a string
"api_key": cty.StringVal("abcdef0123456789"),
},
// A set value cannot be converted to a string, so we get an "Invalid error message" diagnostic
// rather than the condition failure message.
wantErrorMessages: []string{
"Unsuitable value for error message",
},
},
"invalid-error-message-sensitive-even-when-passing": {
configPath: path.Join("with-single-input", "validation-invalid-error-message"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("SecurePass123"),
"token": cty.StringVal("abcdef0123456789abcdef0123456789"),
"count_value": cty.SetVal([]cty.Value{cty.StringVal("a")}),
"api_key": cty.StringVal("abcdef0123456789abcdef0123456789abcdef0123456789"),
},
// This tests that we evaluate error_message even when validation passes
wantErrorMessages: []string{
"error expression used to explain this condition refers to sensitive values",
},
},
// Provider function tests
"provider-functions-pass": {
configPath: path.Join("with-single-input", "validation-provider-functions"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"echo_value": cty.StringVal("test_value"),
"combined": cty.StringVal("long_enough"),
},
wantErrorMessages: nil,
},
"provider-functions-fail": {
configPath: path.Join("with-single-input", "validation-provider-functions"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"echo_value": cty.StringVal("test"),
"combined": cty.StringVal("short"),
},
wantErrorMessages: []string{
"Combined value must be longer than 5 characters after echo",
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
cfg := loadMainBundleConfigForTest(t, tc.configPath)
req := PlanRequest{
Config: cfg,
InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue {
inputs := make(map[stackaddrs.InputVariable]ExternalInputValue, len(tc.planInputVars))
for k, v := range tc.planInputVars {
inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{Value: v}
}
return inputs
}(),
ProviderFactories: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) {
return stacks_testing_provider.NewProvider(t), nil
},
},
DependencyLocks: *lock,
ForcePlanTimestamp: &fakePlanTimestamp,
}
changesCh := make(chan stackplan.PlannedChange)
diagsCh := make(chan tfdiags.Diagnostic)
resp := PlanResponse{
PlannedChanges: changesCh,
Diagnostics: diagsCh,
}
go Plan(ctx, &req, &resp)
_, diags := collectPlanOutput(changesCh, diagsCh)
// Check that we get the expected error messages
if tc.wantErrorMessages == nil {
if len(diags) > 0 {
t.Errorf("expected no diagnostics, got: %s", diags.ErrWithWarnings())
}
} else {
if len(diags) == 0 {
t.Fatalf("expected diagnostics with messages %v, got none", tc.wantErrorMessages)
}
// Check that all expected error messages are present
for _, wantMsg := range tc.wantErrorMessages {
found := false
for _, diag := range diags {
if strings.Contains(diag.Description().Detail, wantMsg) {
found = true
break
}
}
if !found {
t.Errorf("expected error message %q not found in diagnostics: %s", wantMsg, diags.ErrWithWarnings())
}
}
}
})
}
}

View file

@ -0,0 +1,71 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
variable "input" {
type = string
default = "default"
}
variable "email" {
type = string
validation {
condition = can(regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", var.email))
error_message = "Must be a valid email address."
}
}
variable "ip_address" {
type = string
validation {
condition = can(regex("^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", var.ip_address))
error_message = "Must be a valid IPv4 address."
}
}
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "tags" {
type = map(string)
validation {
condition = alltrue([for k, v in var.tags : can(regex("^[a-z][a-z0-9-]*$", k))])
error_message = "Tag keys must start with lowercase letter and contain only lowercase letters, numbers, and hyphens."
}
validation {
condition = alltrue([for k, v in var.tags : length(v) > 0 && length(v) <= 256])
error_message = "Tag values must be 1-256 characters."
}
validation {
condition = contains(keys(var.tags), "owner")
error_message = "Tags must include 'owner' key."
}
}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
input = var.input
}
}
provider "testing" "default" {}

View file

@ -0,0 +1,68 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
variable "input" {
type = string
default = "default"
}
# Test case: error_message references sensitive value
variable "password" {
type = string
sensitive = true
validation {
condition = length(var.password) >= 8
error_message = "Password '${var.password}' is too short."
}
}
# Test case: error_message references ephemeral value
variable "token" {
type = string
ephemeral = true
validation {
condition = length(var.token) == 32
error_message = "Token '${var.token}' is invalid."
}
}
# Test case: error_message that is not a string (and cannot be converted to one)
variable "count_value" {
type = set(string)
validation {
condition = length(var.count_value) > 0
error_message = var.count_value # Invalid: a set cannot be converted to a string
}
}
# Test case: error_message references sensitive value even when validation passes
variable "api_key" {
type = string
sensitive = true
validation {
condition = length(var.api_key) >= 16
error_message = "API key '${var.api_key}' must be at least 16 characters."
}
}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
input = var.input
}
}
provider "testing" "default" {}

View file

@ -0,0 +1,45 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
variable "input" {
type = string
default = "default"
}
# Test case: Validation using provider-defined function in condition
variable "echo_value" {
type = string
validation {
condition = provider::testing::echo(var.echo_value) == var.echo_value
error_message = "Echo function did not return the same value."
}
}
# Test case: Validation using provider function with built-in functions
variable "combined" {
type = string
validation {
condition = length(provider::testing::echo(var.combined)) > 5
error_message = "Combined value must be longer than 5 characters after echo."
}
}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
input = var.input
}
}
provider "testing" "default" {}

View file

@ -0,0 +1,60 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
variable "input" {
type = string
default = "default"
}
variable "password" {
type = string
sensitive = true
validation {
condition = length(var.password) >= 8
error_message = "Password must be at least 8 characters long."
}
validation {
condition = can(regex("[A-Z]", var.password))
error_message = "Password must contain at least one uppercase letter."
}
validation {
condition = can(regex("[0-9]", var.password))
error_message = "Password must contain at least one number."
}
}
variable "api_key" {
type = string
sensitive = true
validation {
condition = length(var.api_key) == 32
error_message = "API key must be exactly 32 characters."
}
validation {
condition = can(regex("^[a-f0-9]+$", var.api_key))
error_message = "API key must only contain lowercase hex characters."
}
}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
input = var.input
}
}
provider "testing" "default" {}

View file

@ -0,0 +1,57 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
variable "input" {
type = string
default = "default"
}
variable "number_input" {
type = number
validation {
condition = var.number_input > 0 && var.number_input < 100
error_message = "Number must be between 0 and 100."
}
}
variable "list_input" {
type = list(string)
validation {
condition = length(var.list_input) > 0 && length(var.list_input) <= 5
error_message = "List must contain 1-5 items."
}
validation {
condition = alltrue([for s in var.list_input : length(s) > 0])
error_message = "List items cannot be empty strings."
}
}
variable "map_input" {
type = map(string)
validation {
condition = contains(keys(var.map_input), "required_key")
error_message = "Map must contain 'required_key'."
}
}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
input = var.input
}
}
provider "testing" "default" {}

View file

@ -0,0 +1,44 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
provider "testing" "default" {}
variable "input" {
type = string
validation {
condition = length(var.input) > 5
error_message = "Input must be longer than 5 characters."
}
validation {
condition = startswith(var.input, "H")
error_message = "Input must start with H."
}
validation {
condition = !contains(["bad", "invalid", "nope"], var.input)
error_message = "Input cannot be 'bad', 'invalid', or 'nope'."
}
validation {
condition = can(regex("^[A-Z]", var.input))
error_message = "Input must start with an uppercase letter."
}
}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
input = var.input
}
}