Repository navigation
Description
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/toHclTerraformTerraformProvider.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.
- Providers. Make the same change the resource fix made in 2020: drop
keysToSnakeCasearoundsynthesizeAttributes()/synthesizeHclAttributes()inTerraformProvider. Generated bindings already produce the right keys. The risk is a hand-writtenTerraformProvidersubclass whosesynthesizeAttributes()returns camelCase keys and relies on the conversion. - Variables. Pass
defaultthrough as-is. Every otherTerraformVariableattribute is already a single word or built with snake_case keys (error_message). - Backends and remote state. Convert top-level config keys explicitly, plus the typed nested structs:
S3Backendendpoints/assumeRole/assumeRoleWithWebIdentity,CosBackend/OssBackendassumeRole,CloudBackend/RemoteBackendworkspaces. Pass map-typed props such asassumeRoleTagsthrough unchanged. - 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