Skip to content

toJsonString() double-escapes string literals inside Terraform interpolations (breaks ECS container_definitions) #164

Description

@vincenthsh

toJsonString() resolves tokens and then JSON.stringifys the result. When a token's rendered Terraform expression contains a string literal, the quotes inside ${…} get escaped, and Terraform rejects the attribute. This breaks aws_ecs_task_definition.container_definitions whenever a container field references something like Fn.lookup(map, "key").

Environment: terraconstructs 0.2.18 · cdktn 0.24.0 · hashicorp/aws 6.61.0 · Terraform 1.10.5. Reproduced on the current ECS code (task-definition.ts has had no changes to this rendering since v0.2.18).

Reproduction

import { ecsTaskDefinition } from "@cdktn/provider-aws";
import { Fn, TerraformVariable, Testing } from "cdktn";

const stack = new AwsStack(Testing.app(), "TestStack", { /* ... */ });
const arns = new TerraformVariable(stack, "secret_arns", { type: "map(string)" });

const td = new ecs.Ec2TaskDefinition(stack, "TaskDefinition", {
  networkMode: ecs.NetworkMode.BRIDGE,
});
td.addContainer("main", {
  image: ecs.ContainerImage.fromRegistry("nginx:latest"),
  memoryLimitMiB: 512,
  environment: { SECRET_ARN: Fn.lookup(arns.value, "my-secret") },
});

Rendered container_definitions value (what Terraform evaluates as a template):

... "value":"${var.secret_arns[\"my-secret\"]}" ...

as written to the .tf.json file:

"${var.secret_arns[\\\"my-secret\\\"]}"

terraform validate:

Error: Invalid character
  on cdk.tf.json line …, in resource.aws_ecs_task_definition.…:
This character is not used within the language.

Error: Invalid expression
Expected the start of an expression, but found an invalid expression token.

Expressions without string literals are unaffected, e.g. ${data.aws_partition.p.dns_suffix} or ${data.aws_secretsmanager_secret.s.name}. We hit it with a Secrets Manager valueFrom of Fn.lookup(remoteState.outputs.secret_arns, "…"). The same expression rendered through a hand-written JSON.stringify of an L1 containerDefinitions string validated fine, so this surfaced as a regression when moving from L1 to the ECS L2.

Mechanism

src/aws/compute/ecs/base/task-definition.ts (main, ~L712–715):

containerDefinitions: Lazy.stringValue({
  produce: () => this.stack.toJsonString(this.renderContainers()),
}),

toJsonString() (src/stack-base.ts, ~L364–376) is Tokenization.resolve(...) followed by JSON.stringify(...). By the time JSON.stringify runs, the token is already the literal text ${var.secret_arns["my-secret"]}. So its inner quotes get escaped as JSON string content, but they sit inside a Terraform template interpolation, where a backslash is not valid.

Workaround

Hoist the expression into a TerraformLocal. The container definition then references ${local.<name>}, which has no quotes to escape, and the local carries the original expression:

const secretArn = new TerraformLocal(stack, "secret_arn", Fn.lookup(arns.value, "my-secret"));
td.addContainer("main", {
  // ...
  environment: { SECRET_ARN: secretArn.asString },
});

Rendered: "value":"${local.secret_arn}". terraform validate passes, and the resolved value is unchanged.

Other call sites with the same pattern

toJsonString() is shared, so these likely share the bug class. I have only reproduced the ECS task definition case:

  • src/aws/compute/batch/ecs-job-definition.ts (containerProperties)
  • src/aws/compute/batch/multinode-job-definition.ts
  • src/aws/notify/rule.ts (eventPattern)
  • src/aws/notify/input.ts
  • src/aws/cloudwatch/dashboard.ts (dashboard body)
  • src/aws/compute/state-machine.ts (definition)
  • src/aws/storage/ecr-repository.ts (repository policy)

Possible direction

Let Terraform do the encoding instead of JavaScript, e.g. render the attribute as Fn.jsonencode(resolvedObject) so string escaping happens after interpolation. The alternative is to make toJsonString() aware of ${…} spans and leave their contents unescaped. Either way, a synth-level test that runs the output through terraform validate (or asserts that no \" appears inside ${…}) would catch regressions across all of the call sites above.

Related but different: #116 (Lazy-produced nested blocks bypassing camelCase→snake_case conversion) is the same general family of Lazy output skipping normal cdktn rendering, but a different defect.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions