Plan-Time Validation

7 minute read

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

// ✅ 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

Updated: