Plan-Time Validation
Plan-Time Validation Overview
Plan-time validation allows complex validation logic that requires access to multiple attributes or external data. This validation runs during terraform plan before any API calls.
ValidateConfig Method
Implement the ValidateConfig method to perform cross-attribute validation:
func (r *CacheResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data CacheModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Perform validation logic
}
Cross-Attribute Validation
Engine-Specific Constraints
func (r *CacheResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data CacheModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Validate engine-specific node limits
if !data.Engine.IsNull() && !data.NumNodes.IsNull() {
engine := data.Engine.ValueString()
numNodes := data.NumNodes.ValueInt64()
if engine == "memcached" && numNodes > 20 {
resp.Diagnostics.AddError(
"Invalid Configuration",
fmt.Sprintf(
"Memcached clusters support maximum 20 nodes, got: %d. "+
"Use Redis for clusters with more than 20 nodes.",
numNodes,
),
)
}
if engine == "redis" && numNodes > 90 {
resp.Diagnostics.AddError(
"Invalid Configuration",
fmt.Sprintf(
"Redis clusters support maximum 90 nodes, got: %d",
numNodes,
),
)
}
}
// Validate node type is compatible with engine
if !data.Engine.IsNull() && !data.NodeType.IsNull() {
engine := data.Engine.ValueString()
nodeType := data.NodeType.ValueString()
if engine == "memcached" && strings.HasPrefix(nodeType, "cache.r") {
resp.Diagnostics.AddError(
"Incompatible Configuration",
fmt.Sprintf(
"Node type '%s' is optimized for Redis and not recommended for Memcached. "+
"Use cache.t3 or cache.m node types for Memcached.",
nodeType,
),
)
}
}
}
Backup Configuration Validation
func (r *CacheResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data CacheModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// If backup is enabled, require retention period
if !data.BackupEnabled.IsNull() && data.BackupEnabled.ValueBool() {
if data.BackupRetentionDays.IsNull() {
resp.Diagnostics.AddAttributeError(
path.Root("backup_retention_days"),
"Required Configuration",
"backup_retention_days is required when backup_enabled is true",
)
}
if data.BackupWindow.IsNull() {
resp.Diagnostics.AddAttributeError(
path.Root("backup_window"),
"Required Configuration",
"backup_window is required when backup_enabled is true",
)
}
// Validate backup window format
if !data.BackupWindow.IsNull() {
window := data.BackupWindow.ValueString()
if !isValidBackupWindow(window) {
resp.Diagnostics.AddAttributeError(
path.Root("backup_window"),
"Invalid Backup Window",
fmt.Sprintf(
"backup_window must be in format 'hh24:mi-hh24:mi' (e.g., '03:00-05:00'), got: %s",
window,
),
)
}
}
}
}
func isValidBackupWindow(window string) bool {
// Format: hh24:mi-hh24:mi (e.g., "03:00-05:00")
regex := regexp.MustCompile(`^([0-1][0-9]|2[0-3]):[0-5][0-9]-([0-1][0-9]|2[0-3]):[0-5][0-9]$`)
return regex.MatchString(window)
}
Network Configuration Validation
func (r *CacheResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data CacheModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Public access and VPC are mutually exclusive
hasPublicAccess := !data.PublicAccess.IsNull() && data.PublicAccess.ValueBool()
hasVPC := !data.VpcID.IsNull()
if hasPublicAccess && hasVPC {
resp.Diagnostics.AddError(
"Conflicting Configuration",
"Cannot enable public_access when vpc_id is specified. "+
"Choose either public access or VPC deployment.",
)
}
// If VPC is specified, require subnet
if hasVPC && data.SubnetID.IsNull() {
resp.Diagnostics.AddAttributeError(
path.Root("subnet_id"),
"Required Configuration",
"subnet_id is required when vpc_id is specified",
)
}
// Validate security groups are provided for VPC
if hasVPC && data.SecurityGroupIDs.IsNull() {
resp.Diagnostics.AddWarning(
"Security Group Recommendation",
"No security groups specified. The cache will use the default VPC security group. "+
"Consider specifying security_group_ids for better control.",
)
}
}
Conditional Requirements
Version-Specific Features
func (r *CacheResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data CacheModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Check version requirements for features
if !data.EngineVersion.IsNull() && !data.ACLEnabled.IsNull() {
version := data.EngineVersion.ValueString()
aclEnabled := data.ACLEnabled.ValueBool()
if aclEnabled && compareVersion(version, "6.0") < 0 {
resp.Diagnostics.AddError(
"Feature Not Supported",
fmt.Sprintf(
"ACL support requires Redis 6.0 or later, current version: %s. "+
"Either upgrade to Redis 6.0+ or disable ACL.",
version,
),
)
}
}
// TLS requires minimum version
if !data.EngineVersion.IsNull() && !data.TLSEnabled.IsNull() {
version := data.EngineVersion.ValueString()
tlsEnabled := data.TLSEnabled.ValueBool()
if tlsEnabled && compareVersion(version, "6.0") < 0 {
resp.Diagnostics.AddError(
"Feature Not Supported",
fmt.Sprintf(
"TLS/SSL requires Redis 6.0 or later, current version: %s",
version,
),
)
}
}
}
func compareVersion(v1, v2 string) int {
parts1 := strings.Split(v1, ".")
parts2 := strings.Split(v2, ".")
for i := 0; i < len(parts1) && i < len(parts2); i++ {
n1, _ := strconv.Atoi(parts1[i])
n2, _ := strconv.Atoi(parts2[i])
if n1 < n2 {
return -1
}
if n1 > n2 {
return 1
}
}
return 0
}
Validate Attribute Combinations
Replication Configuration
func (r *CacheResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data CacheModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Validate replication configuration
if !data.ReplicationEnabled.IsNull() && data.ReplicationEnabled.ValueBool() {
// Multi-AZ requires at least 2 nodes
if !data.NumNodes.IsNull() && data.NumNodes.ValueInt64() < 2 {
resp.Diagnostics.AddError(
"Invalid Replication Configuration",
fmt.Sprintf(
"Replication requires at least 2 nodes, got: %d. "+
"Set num_nodes to at least 2 or disable replication.",
data.NumNodes.ValueInt64(),
),
)
}
// Replication requires availability zones
if data.AvailabilityZones.IsNull() || len(data.AvailabilityZones.Elements()) < 2 {
resp.Diagnostics.AddAttributeError(
path.Root("availability_zones"),
"Required Configuration",
"Replication requires at least 2 availability zones",
)
}
}
}
Maintenance Window Validation
func (r *CacheResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data CacheModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Validate maintenance and backup windows don't overlap
if !data.MaintenanceWindow.IsNull() && !data.BackupWindow.IsNull() {
maintWindow := data.MaintenanceWindow.ValueString()
backupWindow := data.BackupWindow.ValueString()
if windowsOverlap(maintWindow, backupWindow) {
resp.Diagnostics.AddError(
"Conflicting Configuration",
fmt.Sprintf(
"Maintenance window (%s) and backup window (%s) cannot overlap. "+
"Adjust the windows to avoid conflicts.",
maintWindow,
backupWindow,
),
)
}
}
}
func windowsOverlap(window1, window2 string) bool {
// Parse time windows and check for overlap
// Format: "day:hh:mi-day:hh:mi" (e.g., "mon:03:00-mon:05:00")
w1Start, w1End := parseWindow(window1)
w2Start, w2End := parseWindow(window2)
// Check if windows overlap
return (w1Start < w2End && w1End > w2Start)
}
Complex Validation Logic
Quota Validation
func (r *CacheResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data CacheModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Calculate total memory based on node type and count
if !data.NodeType.IsNull() && !data.NumNodes.IsNull() {
nodeType := data.NodeType.ValueString()
numNodes := data.NumNodes.ValueInt64()
memoryPerNode := getNodeMemoryGB(nodeType)
totalMemory := memoryPerNode * float64(numNodes)
// Check against account limits (would query API in real implementation)
maxMemory := 1000.0 // GB
if totalMemory > maxMemory {
resp.Diagnostics.AddError(
"Quota Exceeded",
fmt.Sprintf(
"Requested configuration requires %.1f GB total memory, "+
"which exceeds account limit of %.1f GB. "+
"Reduce num_nodes or use smaller node types. "+
"Contact support to increase quota.",
totalMemory,
maxMemory,
),
)
}
// Warning for approaching limits
if totalMemory > maxMemory*0.8 {
resp.Diagnostics.AddWarning(
"Approaching Quota Limit",
fmt.Sprintf(
"This configuration will use %.1f GB (%.0f%% of account limit). "+
"Consider quota limits when planning future growth.",
totalMemory,
(totalMemory/maxMemory)*100,
),
)
}
}
}
func getNodeMemoryGB(nodeType string) float64 {
memoryMap := map[string]float64{
"cache.t3.micro": 0.5,
"cache.t3.small": 1.37,
"cache.t3.medium": 3.09,
"cache.m5.large": 6.38,
"cache.m5.xlarge": 12.93,
}
if memory, ok := memoryMap[nodeType]; ok {
return memory
}
return 0
}
Validation with External Data
Check Resource Existence
func (r *CacheResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data CacheModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Validate VPC exists (if specified)
if !data.VpcID.IsNull() && !data.VpcID.IsUnknown() {
vpcID := data.VpcID.ValueString()
// Check if VPC exists
_, err := r.client.GetVPC(ctx, vpcID)
if err != nil {
if client.IsNotFoundError(err) {
resp.Diagnostics.AddAttributeError(
path.Root("vpc_id"),
"VPC Not Found",
fmt.Sprintf(
"VPC '%s' does not exist or is not accessible. "+
"Verify the VPC ID and ensure you have permission to access it.",
vpcID,
),
)
} else {
// API error - add warning instead of error
resp.Diagnostics.AddWarning(
"Unable to Validate VPC",
fmt.Sprintf(
"Could not verify VPC '%s' exists: %s. "+
"Configuration will be validated during apply.",
vpcID,
err.Error(),
),
)
}
}
}
}
Warning vs Error
Use warnings for non-blocking issues:
func (r *CacheResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data CacheModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Error: Blocks operation
if !data.Engine.IsNull() && data.Engine.ValueString() == "invalid" {
resp.Diagnostics.AddError(
"Invalid Engine",
"Engine must be 'redis' or 'memcached'",
)
}
// Warning: Allows operation but notifies user
if !data.EngineVersion.IsNull() {
version := data.EngineVersion.ValueString()
if compareVersion(version, "5.0") < 0 {
resp.Diagnostics.AddWarning(
"Deprecated Version",
fmt.Sprintf(
"Redis %s is deprecated and will reach end-of-life soon. "+
"Consider upgrading to Redis 7.0 or later.",
version,
),
)
}
}
// Warning: Best practice recommendation
if data.BackupEnabled.IsNull() || !data.BackupEnabled.ValueBool() {
resp.Diagnostics.AddWarning(
"Backup Not Enabled",
"Backups are not enabled. Enable backups for production workloads "+
"to protect against data loss.",
)
}
}
Best Practices
1. Validate Related Attributes Together
// ✅ Good: Validate related configuration together
if data.BackupEnabled.ValueBool() {
if data.BackupRetentionDays.IsNull() {
// Error
}
if data.BackupWindow.IsNull() {
// Error
}
}
// ❌ Bad: Scattered validation
if data.BackupEnabled.ValueBool() { /* check 1 */ }
// ... other code ...
if data.BackupRetentionDays.IsNull() { /* check 2 */ }
2. Provide Helpful Context
// ✅ Good: Explain why and how to fix
resp.Diagnostics.AddError(
"Incompatible Configuration",
fmt.Sprintf(
"Memcached does not support replication. "+
"Either use Redis for replication or disable replication_enabled. "+
"See: https://docs.example.com/engines/memcached#limitations",
),
)
// ❌ Bad: Minimal information
resp.Diagnostics.AddError("Error", "Invalid")
3. Use Warnings Appropriately
// ✅ Good: Warning for non-blocking issues
resp.Diagnostics.AddWarning(
"Deprecated Feature",
"This configuration uses deprecated settings",
)
// ❌ Bad: Error for non-blocking issue
resp.Diagnostics.AddError(
"Deprecated Feature",
"Don't use deprecated settings",
)
Key Takeaways
- ✅ Use ValidateConfig for cross-attribute validation
- ✅ Validate attribute combinations and dependencies
- ✅ Provide clear error messages with context
- ✅ Use warnings for non-blocking issues
- ✅ Validate version compatibility
- ✅ Check resource quotas and limits
- ✅ Handle null and unknown values
- ✅ Give users guidance on how to fix issues
- ✅ Validate before making API calls
- ✅ Test validation logic thoroughly
What’s Next?
Learn how to design error type hierarchies and wrap errors with context.
Next: Error Types & Wrapping →
Previous: ← Schema-Level Validation
Last updated: June 30, 2026