Skip to content

lib: synth rewrites user-supplied map keys to snake_case in variable defaults, provider maps and backend maps #549

Open

Description

@jsteinich

Problem

keysToSnakeCase (packages/cdktn/src/util.ts) recurses into every nested object and snake-cases every key, except under keys named exactly tags or environment. It runs on whole attribute objects, so it also rewrites the keys of maps the user supplies. Terraform map keys are data, so the output config is wrong. This happens in both JSON and HCL synth.

Carried over from hashicorp/terraform-cdk#2473 (open since January 2023, never fixed upstream).

Where it happens

Checked on upstream/main with the cdktn unit test harness:

Construct Input Synthesized
TerraformVariable default { BusinessOwner: "x", "kebab-key": "y", "Name.With.Dots": "z" } business_owner, kebab_key, name_with_dots
TerraformVariable default, nested { other: { CostCenter: "b" } } other.cost_center (under tags it is preserved)
Provider map attribute env = { AWS_PROFILE = "p" } inside a block (e.g. kubernetes/helm provider exec.env) aws_profile
Provider map attribute labels = { CostCenter = "c" } cost_center
S3Backend assumeRoleTags { CostCenter: "x", "aws:Team": "y" } cost_center, aws_team

Not affected: TerraformOutput values, and DataTerraformRemoteState defaults (removed from the snake-cased config and passed through as-is).

Resource and data source attributes aren't affected either. hashicorp/terraform-cdk@0734976 (October 2020, "remove snake casing which no longer be needed") changed TerraformResource.toTerraform() and TerraformDataSource.toTerraform() from keysToSnakeCase(this.synthesizeAttributes()) to this.synthesizeAttributes(), because generated bindings already emit snake_case keys. Only the meta-arguments still go through keysToSnakeCase, and TerraformEphemeralResource follows the same pattern.

That commit left out TerraformProvider. toTerraform() / toHclTerraform() still call keysToSnakeCase(processDynamicAttributes(this.synthesizeAttributes())), even though generated provider bindings emit snake_case keys the same way resources do, e.g. access_key: cdktn.stringToTerraform(this._accessKey), assume_role: awsProviderAssumeRoleToTerraform(this._assumeRole). That leftover call is the only reason provider map attributes are rewritten.

Callers that still pass user data through it:

  • TerraformVariable.toTerraform / toHclTerraform
  • TerraformProvider.toTerraform / toHclTerraform
  • every backend's toTerraform / toHclTerraform
  • TerraformRemoteState.extractConfig
  • resource / data source / ephemeral resource meta-arguments and provisioner props

Reproduction

import { App, TerraformStack, TerraformVariable } from "cdktn";

const app = new App();
const stack = new TerraformStack(app, "s");
new TerraformVariable(stack, "default_tags", {
  type: "map(string)",
  default: { BusinessOwner: "Magoo", Deployment: "Terraform" },
});
app.synth();
// "default": { "business_owner": "Magoo", "deployment": "Terraform" }

Workaround

Wrap the value in a token: keysToSnakeCase returns resolvables untouched.

default: Token.asAny({ BusinessOwner: "Magoo" }),
// "default": { "BusinessOwner": "Magoo" }

addOverride also works.

Suggested direction

As suggested on #2473: stop converting keys recursively. Convert keys explicitly in the places that need it.

  1. Providers. Make the same change the resource fix made in 2020: drop keysToSnakeCase around synthesizeAttributes() / synthesizeHclAttributes() in TerraformProvider. Generated bindings already produce the right keys. The risk is a hand-written TerraformProvider subclass whose synthesizeAttributes() returns camelCase keys and relies on the conversion.
  2. Variables. Pass default through as-is. Every other TerraformVariable attribute is already a single word or built with snake_case keys (error_message).
  3. Backends and remote state. Convert top-level config keys explicitly, plus the typed nested structs: S3Backend endpoints / assumeRole / assumeRoleWithWebIdentity, CosBackend / OssBackend assumeRole, CloudBackend / RemoteBackend workspaces. Pass map-typed props such as assumeRoleTags through unchanged.
  4. Meta-arguments and provisioners. These are typed structs (lifecycle, connection, provisioner props) and can keep an explicit conversion.

The tags/environment special case can then go.

This changes synthesized output for anyone who currently depends on the conversion, e.g. a camelCase variable default whose consumers expect snake_case keys, or a hand-written provider class. It may need a feature flag or a release note. Step 1 could land on its own first, because generated providers don't depend on the conversion.

Notes

The tests added in #541 (null nested in an object default is preserved in JSON/HCL output) assert the snake-cased keys (some_key, inner_key). They will need updating.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions