Object value interpolation in .tf.json not working as documented

I am working with programmatically-generated Terraform configuration files using the JSON syntax. I am trying to update the generator to emit configuration for Terraform v0.12, but can’t figure out how to interpolate a complex expression.

The documentation says,

When a JSON string is encountered in a location where arbitrary expressions are expected, its value is first parsed as a string template and then it is evaluated to produce the final result.

If the given template consists only of a single interpolation sequence, the result of its expression is taken directly, without first converting it to a string. This allows non-string expressions to be used within the JSON syntax…

But when I try to run this configuration:

  "locals": {
    "transition": {
      "days": 366,
      "storage_class": "STANDARD_IA"
    },
    "expiration": {
      "days": 732
    },
    "lifecycle_rule": {
      "enabled": true,
      "transition": [
        "${local.transition}"
      ],
      "expiration": [
        "${local.expiration}"
      ]
    }
  },
  "resource": {
    "aws_s3_bucket": {
      "alb_logs": {
        "bucket": "my-alb-logs",
        "lifecycle_rule": [
          "${local.lifecycle_rule}"
        ]
      },
      "elb_logs": {
        "bucket": "my-elb-logs",
        "lifecycle_rule": [
          "${local.lifecycle_rule}"
        ]
      }
    }
  }

Terraform errors out:

Error: Incorrect JSON value type

  on terraform.tf.json line 118, in resource.aws_s3_bucket.alb_logs.lifecycle_rule:
 118:           "${local.lifecycle_rule}"

Either a JSON object or JSON array of objects is required here, to define
arguments and child blocks.


Error: Missing required argument

  on terraform.tf.json line 118, in resource.aws_s3_bucket.alb_logs.lifecycle_rule:
 118:           "${local.lifecycle_rule}"

The argument "enabled" is required, but no definition was found.


Error: Incorrect JSON value type

  on terraform.tf.json line 118, in resource.aws_s3_bucket.alb_logs.lifecycle_rule:
 118:           "${local.lifecycle_rule}"

Either a JSON object or JSON array of objects is required here, to define
arguments and child blocks.


Error: Incorrect JSON value type

  on terraform.tf.json line 124, in resource.aws_s3_bucket.elb_logs.lifecycle_rule:
 124:           "${local.lifecycle_rule}"

Either a JSON object or JSON array of objects is required here, to define
arguments and child blocks.


Error: Missing required argument

  on terraform.tf.json line 124, in resource.aws_s3_bucket.elb_logs.lifecycle_rule:
 124:           "${local.lifecycle_rule}"

The argument "enabled" is required, but no definition was found.


Error: Incorrect JSON value type

  on terraform.tf.json line 124, in resource.aws_s3_bucket.elb_logs.lifecycle_rule:
 124:           "${local.lifecycle_rule}"

Either a JSON object or JSON array of objects is required here, to define
arguments and child blocks.

So the doc says that the result of the evaluation of the template should be an object, since it consists only of a single interpolation sequence. But judging by the error messages, it seems like Terraform is evaluating the template as a string.

Is this a bug in Terraform’s interpreter, or have I missed something?

Hi @syskill!

The important detail here is part of the documentation you quoted (emphasis added by me):

When a JSON string is encountered in a location where arbitrary expressions are expected, its value is first parsed as a string template and then it is evaluated to produce the final result.

As the error message is noting, lifecycle_rule is a name representing a nested block type, rather than an attribute. As a result, the mapping under Nested Block Mapping apply in this case:

When a JSON object property is named after a nested block type, the value of this property represents one or more blocks of that type. The value of the property must be either a JSON object or a JSON array.

You can see that the guidance in the error message aligns with the requirement in the documentation for this case.

The native syntax for that lifecycle_rule block would look like this:

  lifecycle_rule {
    enabled = true

    expiration {
      days = local.expiration.days
    }

    transition {
      days          = local.transition.days
      storage_class = local.transition.storage_class
    }
  }

Following the rules for mapping nested blocks into JSON syntax, the equivalent to the above would be the following:

{
  "lifecycle_rule": [{
    "enabled": true,
    "expiration": [{
      "days": "${local.expiration.days}"
    }],
    "transition": [{
      "days": "${local.transition.days}",
      "storage_class": "${local.transition.storage_class}"
    }]
  }]
}

It looks like this configuration was previously relying on the attributes vs. blocks ambiguity in Terraform 0.11 and earlier. The above is a JSON variant of the rewrite that would’ve been necessary in native syntax as documented in the upgrade guide. Although this case is simple enough not to need it, if you find that you need to use dynamic blocks as shown in the upgrade guide, these can also map to JSON syntax by following the rules for mapping nested blocks:

{
  "dynamic": [
    {
      "expiration": {
        "for_each": "${local.lifecycle_rule.expiration}",
        "content": {
          "days": "${expiration.value.days}"
        }
      }
    },
    {
      "transition": {
        "for_each": "${local.lifecycle_rule.transition}",
        "content": {
          "days": "${transition.value.days}",
          "storage_class": "${transition.value.storage_class}"
        }
      }
    }
  ]
}

The above is equivalent to the following:

dynamic "expiration" {
  for_each = local.lifecycle_rule.expiration
  content {
    days = expiration.value.days
  }
}

dynamic "transition" {
  for_each = local.lifecycle_rule.transition
  content {
    days          = transition.value.days
    storage_class = transition.value.storage_class
  }
}

Hi @apparentlymart,

Thank you for the detailed explanation. I had been using local values as nested blocks in Terraform 0.11 to keep my configuration DRY. The fact that local values could contain arbitrary HCL objects, which could be used directly in nested blocks without boilerplate syntax as in your example, was a big draw for me.

In this case I could combine the two buckets into one declaration, and that would keep the configuration size about the same.

Thanks again!