Mixing legacy HCL and AwsTerraformAdapter

Hi! I’m having trouble using existing Terraform modules in a step function using the new AwsTerraformAdapter in CDK. We have some existing Lambdas (imported from HCL modules) and would like to pass in their ARNs to the state machine defined in Terraform CDK, but the AWS library complains that the Terraform CDK token doesn’t look like an ARN.

To give some additional context, our process is to run cdktf synth and then include the JSON output within our parent Terraform module, e.g.

# main.tf
module "cdksfn" {
  source = "./cdk.out"
  lambda-arn = module.my_lambda.lambda.arn
}
# cdk/main.ts

class MyStack extends TerraformStack {

  myLambdaArn = new TerraformVariable(this, 'lambda-arn', { type: "string" });

  constructor(scope: Construct, name: string) {
    super(scope, name);

    new AwsProvider(this, "aws", { region: 'us-west-2' });
    const awsAdapter = new AwsTerraformAdapter(this, "adapter");

    new stepfunctions.StateMachine(awsAdapter, "sfn", {
      definition: new aws_stepfunctions_tasks.LambdaInvoke(awsAdapter, "my-lambda-step", {
        lambdaFunction: lambda.Function.fromFunctionArn(
          awsAdapter,
          "my-lambda-id",
          // Apparently the Terraform variable below can't be used as the ARN 
          // because it represents a TF token
          this.myLambdaArn.stringValue),
      }).next(new stepfunctions.Succeed(awsAdapter, "success"))
    })
  }
}

The above stack fails to synthesize with

ARNs must start with "arn:" and have at least 6 components: ${TfToken[TOKEN.1]}

Is it possible to use a Terraform variable within a step function definition defined in CDK (using the AwsTerraformAdapter)?

Could use create an AWS CDK Token that wraps the value? Something like cdk.Token.asString(this.myLambdaArn.stringValue).

Certainly not the most ideal, but could be a workaround.

Thanks @jsteinich! Gave that a try but still got the same error as above. I then tried using

    const lambdaArn = this.myLambdaArn.stringValue;
    ...
    awscdk.Lazy.string({ produce() { return lambdaArn; } })

and this allowed me to synth successfully, but the tf plan subsequently failed. It looks like doing it this way results in a state machine definition that isn’t properly escaped:

"aws_cloudcontrolapi_resource": {
      "adapter_sfn5CB133D2_8A34CE5C": {
        "//": {
          "metadata": {
            "path": "cdk/adapter/sfn5CB133D2",
            "uniqueId": "adapter_sfn5CB133D2_8A34CE5C"
          }
        },
        "desired_state": "${jsonencode({RoleArn = aws_iam_role.adapter_sfnRoleF032151D_E74BDE95.arn, DefinitionString = join(\"\", [\"{\\\"StartAt\\\":\\\"my-lambda-step\\\",\\\"States\\\":{\\\"my-lambda-step\\\":{\\\"Next\\\":\\\"success\\\",\\\"Retry\\\":[{\\\"ErrorEquals\\\":[\\\"Lambda.ServiceException\\\",\\\"Lambda.AWSLambdaException\\\",\\\"Lambda.SdkClientException\\\"],\\\"IntervalSeconds\\\":2,\\\"MaxAttempts\\\":6,\\\"BackoffRate\\\":2}],\\\"Type\\\":\\\"Task\\\",\\\"Resource\\\":\\\"arn:\", data.aws_partition.adapter_aws-partition_5B16AD9D.partition, \"\":states:::lambda:invoke\",\"Parameters\":{\"FunctionName\":\"${var.lambda-arn}\",\"Payload.$\":\"$\"}},\"success\":{\"Type\":\"Succeed\"}}}\"\"])})}",
        "type_name": "AWS::StepFunctions::StateMachine"
      }
    },

i.e. note that it almost looks correct in that the FunctionName is set to "${var.lambda-arn}", but a) I don’t think TF will know to correctly interpolate that since it’s a nested interpolation and b) everything after the ${var.lambda-arn} in the state machine definition is not double-escaped, i.e. \" should be \\\" in most places but it’s not

Hopefully I’ll have a chance to dig into this a little more and see if I can uncover a bug somewhere in the cdktf code. But would love to hear if anyone else has suggestions for potential workaround.

@ansgarm any ideas here? I don’t believe full token interop is supported, but seems like there should be a workaround.

One extreme workaround would be to use a stack level escape hatch to set the desired_state of the state machine.

No immediate idea yet, but I was already lurking here and will have a look at this issue early next week :slight_smile:

1 Like

Hi :wave:

I did quite a bit of debugging today and probably found the underlying issue.
This part of the adapter is responsible for escaping strings that contain quotes ("). It was supposed to ignore strings that are just "${TfToken[112]}" but the check ignores all strings containing tokens. This is the case here and of course it needs to be properly detected and wrapped to support passing CDKTF Tokens to AWS CDK constructs.

I started to build a fix which will work similar to these internals in TFExpression. I’ll report back here as soon as I got a working fix and a PR / new release up.

tl;dr: The adapter needs to support “mapping” CDKTF Tokens in strings.

PR with a fix is up: fix string token interop by ansgarm · Pull Request #69 · hashicorp/cdktf-aws-cdk · GitHub
I was able to deploy the included example successfully to AWS, so this should resolve your issue.
As soon as the PR is merged, a new release of the adapter will be triggered automatically.

edit: Merged and released: Release v0.3.4 · hashicorp/cdktf-aws-cdk · GitHub
(I had to edit instead of posting a new message, because Discuss does not allow three consecutive messages from the same person without replies in between)

Hi. So I also just ran into this issue. Here’s what my dependencies are,

  "dependencies": {
    "@cdktf/aws-cdk": "^0.5.2",
    "aws-cdk-lib": "^2.37.0",
    "cdktf": "^0.11.2",
    "constructs": "^10.1.66"
  },

And for my code, everything is first encapsulated into a SecureBucket construct.

import { aws_s3 as s3, aws_kms as kms } from 'aws-cdk-lib'
import { Construct } from 'constructs'
import { AwsTerraformAdapter } from '@cdktf/aws-cdk'

export class SecureBucket extends Construct {
    readonly bucket: s3.Bucket

    constructor(scope: Construct, id: string, bucketName: string) {
        super(scope, id)

        const awsAdapter = new AwsTerraformAdapter(this, 'adapter')
        const encryptionKey = new kms.Key(awsAdapter, `${id}--kms-key`, {
            alias: `${id}--kms-key`,
            description: 'Managed KMS key for S3 bucket.',
            enableKeyRotation: true,
            enabled: true,
        })

        const bucket = new s3.Bucket(awsAdapter, `${id}--bucket`, {
            bucketName,
            encryptionKey,
            blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
        })

        this.bucket = bucket
    }
}

And then it’s used in a stack as such

import { TerraformStack, TerraformVariable } from 'cdktf'
import StackUtil from '../../utils/stack-util'
import { aws_s3 as s3 } from 'aws-cdk-lib'
import { Construct } from 'constructs'
import { AwsProvider } from '@cdktf/aws-cdk'
import { DEFAULT_REGION } from '../../constants'
import { SecureBucket } from '../../constructs/secure-bucket'

export class GlobalStorageStack extends TerraformStack {
    readonly bucket: s3.Bucket

    constructor(scope: Construct) {
        super(scope, StackUtil.getName(__filename))

        const env = new TerraformVariable(this, 'env', {})
        new AwsProvider(this, 'id--aws-provider-default', {
            region: DEFAULT_REGION
        })
        const bucket = new SecureBucket(this, 'id--xyz-bucket', `xyz-${env.stringValue}`)

        this.bucket = bucket.bucket
    }
}

cdktf synth immediately fails with the error,

Error: Invalid S3 bucket name (value: xyz-${TfToken[TOKEN.5]})
    Bucket name must only contain lowercase characters and the symbols, period (.) and dash (-) (offset: 34)
    Bucket name must start and end with a lowercase character or number (offset: 52)
        at Function.validateBucketName (/foo/bar/node_modules/aws-cdk-lib/aws-s3/lib/bucket.js:1:18330)

It basically fails to recognize/resolve the env Terraform variable. Since a fix was released in v0.3.4 and I am using v0.5.2 I am sure why I am seeing this issue. Maybe the problem is that the validateBucketName() function in the AWS CDK L2 construct causes the failure even before the synthesis can begin. I would appreciate any suggestions on how to get around this.

Hi @aa8y,

yeah, the AWS CDK validation seems to fail here as it can’t detect a CDKTF resolvable and then tries to validate the bucket name.

You could try to wrap the bucketName variable into the AWS CDK’s Token.asString() function to “disguise” it from the validation:

Something along the lines of:

import { Token as AwsCdkToken } from 'aws-cdk-lib';

const bucket = new s3.Bucket(awsAdapter, `${id}--bucket`, {
  bucketName: AwsCdkToken.asString(bucketName),
  encryptionKey,
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
})

I tried what you said, but I am still seeing the same error.

So I tried this on the Node REPL

> let { Token } = await import('cdktf')
undefined
> Token.isUnresolved('xyz--${TfToken[TOKEN.5]}')
true
> let { aws_s3 } = await import('aws-cdk-lib')
undefined
> aws_s3.Bucket.validateBucketName('xyz--${TfToken[TOKEN.5]}')
Uncaught Error: Invalid S3 bucket name (value: xyz--${TfToken[TOKEN.5]})
Bucket name must only contain lowercase characters and the symbols, period (.) and dash (-) (offset: 5)
Bucket name must start and end with a lowercase character or number (offset: 23)
    at Bucket.validateBucketName (/foo/bar/node_modules/aws-cdk-lib/aws-s3/lib/bucket.js:480:13)

So for the bucket name xyz--${TfToken[TOKEN.5]}, Token.isUnresolved() returns true, but Bucket.validateBucketName throws an error. I don’t understand where the disconnect is.

So I figured out what the problem is. See this,

> let { aws_s3 } = await import('aws-cdk-lib')
undefined
> aws_s3.Bucket.validateBucketName('xyz--${TfToken[TOKEN.5]}')
Uncaught Error: Invalid S3 bucket name (value: xyz--${TfToken[TOKEN.5]})
Bucket name must only contain lowercase characters and the symbols, period (.) and dash (-) (offset: 5)
Bucket name must start and end with a lowercase character or number (offset: 23)
    at Bucket.validateBucketName (/Users/aarun/Development/tf-rulius/node_modules/aws-cdk-lib/aws-s3/lib/bucket.js:480:13)
> aws_s3.Bucket.validateBucketName('xyz--${Token[TOKEN.5]}')
undefined

The only difference between the two is that the string form of a regular token starts with Token whereas for Terraform it’s TfToken. The regex in the aws-cdk library assumes it’s always Token (see this).

Edit: As a workaround, I’ve added this line to my code for now.

s3.Bucket.validateBucketName = (_bucketName) => { }

Hi @aa8y,

yes, that is right. The two Token systems are using separate namespaces on purpose.
That’s why Token.isUnresolved() returns true if you import Token from cdktf, but if you’d have imported it from aws-cdk-lib in your repl it should’ve returned false.