mirror of
https://github.com/hashicorp/terraform.git
synced 2026-05-28 04:03:27 -04:00
Merge pull request #38240 from hashicorp/stacks-variable-validation-blocks
Stacks variable validation blocks
This commit is contained in:
commit
256e575324
14 changed files with 1757 additions and 19 deletions
5
.changes/v1.15/ENHANCEMENTS-20260305-103721.yaml
Normal file
5
.changes/v1.15/ENHANCEMENTS-20260305-103721.yaml
Normal 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"
|
||||
72
internal/stacks/stackconfig/checks.go
Normal file
72
internal/stacks/stackconfig/checks.go
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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"},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'."
|
||||
}
|
||||
}
|
||||
|
|
@ -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)."
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" {}
|
||||
|
|
@ -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" {}
|
||||
|
|
@ -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" {}
|
||||
|
|
@ -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" {}
|
||||
|
|
@ -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" {}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue