Search code examples
goterraformterraform-provider-kubernetes

How to show a warning/error when running 'terraform plan'?


I'm building a Terraform plugin/provider (link) which will help users manage their cloud resources e.g. cloud instances, Kubernetes clusters & etc on a cloud platform.

The cloud platform at this moment does not support Kubernetes nodes size change after it gets created. If user wants to change the nodes size, they need to create a new node pool with the new nodes size.

So I'm adding this block in my plugin code, specifically in the Kubernetes cluster update method (link):

if d.HasChange("target_nodes_size") {
    errMsg := []string{
        "[ERR] Unable to update 'target_nodes_size' after creation.",
        "Please create a new node pool with the new node size.",
    }
    return fmt.Errorf(strings.Join(errMsg, " "))
}

The problem is, the error only appears when I run terraform apply command. What I want is, I want it to show when user runs terraform plan command so they know it early that it's not possible to change the nodes size without creating a new node pool.

How do I make that target_nodes_size field immutable and show the error early in terraform plan output?

enter image description here


Solution

  • The correct thing to do here is to tell Terraform that changes to the resource cannot be done in place and instead requires the recreation of the resource (normally destroy followed by creation but you can reverse that with lifecycle.create_before_destroy).

    When creating a provider you can do this with the ForceNew parameter on a schema's attribute.

    As an example, the aws_launch_configuration resource is considered immutable from AWS' API side so every non computed attribute in the schema is marked with ForceNew: true.:

    func resourceAwsLaunchConfiguration() *schema.Resource {
        return &schema.Resource{
            Create: resourceAwsLaunchConfigurationCreate,
            Read:   resourceAwsLaunchConfigurationRead,
            Delete: resourceAwsLaunchConfigurationDelete,
            Importer: &schema.ResourceImporter{
                State: schema.ImportStatePassthrough,
            },
    
            Schema: map[string]*schema.Schema{
                "arn": {
                    Type:     schema.TypeString,
                    Computed: true,
                },
    
                "name": {
                    Type:          schema.TypeString,
                    Optional:      true,
                    Computed:      true,
                    ForceNew:      true,
                    ConflictsWith: []string{"name_prefix"},
                    ValidateFunc:  validation.StringLenBetween(1, 255),
                },
    // ...
    

    If you then attempt to modify any of the ForceNew: true fields then Terraform's plan will show that it needs to replace the resource and at apply time it will automatically do that as long as the user accepts the plan.

    For a more complicated example, the aws_elasticsearch_domain resource allows in place version changes but only for specific version upgrade paths (so you can't eg go from 5.4 to 7.8 directly and instead have to go to 5.4 -> 5.6 -> 6.8 -> 7.8. This is done by using the CustomizeDiff attribute on the schema which allows you to use logic at plan time to give a different result than would normally be found from static configuration.

    The CustomizeDiff for the aws_elasticsearch_domain elasticsearch_version attribute looks like this:

    func resourceAwsElasticSearchDomain() *schema.Resource {
        return &schema.Resource{
            Create: resourceAwsElasticSearchDomainCreate,
            Read:   resourceAwsElasticSearchDomainRead,
            Update: resourceAwsElasticSearchDomainUpdate,
            Delete: resourceAwsElasticSearchDomainDelete,
            Importer: &schema.ResourceImporter{
                State: resourceAwsElasticSearchDomainImport,
            },
    
            Timeouts: &schema.ResourceTimeout{
                Update: schema.DefaultTimeout(60 * time.Minute),
            },
    
            CustomizeDiff: customdiff.Sequence(
                customdiff.ForceNewIf("elasticsearch_version", func(_ context.Context, d *schema.ResourceDiff, meta interface{}) bool {
                    newVersion := d.Get("elasticsearch_version").(string)
                    domainName := d.Get("domain_name").(string)
    
                    conn := meta.(*AWSClient).esconn
                    resp, err := conn.GetCompatibleElasticsearchVersions(&elasticsearch.GetCompatibleElasticsearchVersionsInput{
                        DomainName: aws.String(domainName),
                    })
                    if err != nil {
                        log.Printf("[ERROR] Failed to get compatible ElasticSearch versions %s", domainName)
                        return false
                    }
                    if len(resp.CompatibleElasticsearchVersions) != 1 {
                        return true
                    }
                    for _, targetVersion := range resp.CompatibleElasticsearchVersions[0].TargetVersions {
                        if aws.StringValue(targetVersion) == newVersion {
                            return false
                        }
                    }
                    return true
                }),
                SetTagsDiff,
            ),
    

    Attempting to upgrade an aws_elasticsearch_domain's elasticsearch_version on an accepted upgrade path (eg 7.4 -> 7.8) will show that it's an in place upgrade in the plan and apply that at apply time. On the other hand if you tried to upgrade via a path that isn't allowed directly (eg 5.4 -> 7.8 directly) then Terraform's plan will show that it needs to destroy the existing Elasticsearch domain and create a new one.