DEV Community

Cover image for Synth CDK app to Portable CloudFormation
Ivan Bliskavka
Ivan Bliskavka

Posted on • Edited on • Originally published at bliskavka.com

Synth CDK app to Portable CloudFormation

Update 2/27/2024: Read Synth CDK App to Custom Bucket instead.

Consulting requires you to work within the client’s parameters. Some clients have internal standards, and want you to deliver your white-label CDK app as CloudFormation.

Call me old fashioned but I dont expect Apple to rewrite their products in TypeScript because that is my current favorite.

Joking aside, in consulting this is a pretty common ask, so you must be prepared to deal with it.

On a related note, some clients have compliance or security restrictions that make it very difficult to get AWS CLI access to deploy using CDK.

Fortunately, you can synth CDK apps into plain old CloudFormation, package it to an S3 bucket, and deploy it from the CloudFormation web console.

TL;DR;

Check out the demo project on GitHub.

Initial Product Stack

Our demo stack will create a bucket, and name it based on the account, region, and client name.

interface MyProductProps extends StackProps {
  client: string;
}
export class MyProduct extends Stack {
  constructor(scope: Construct, id: string, props: MyProductProps) {
    super(scope, id);
    const bucketName = `${props.env.account}-${props.env.region}-${props.client}`;
    new Bucket(this, 'Bucket', {
      bucketName: bucketName
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The env Field

Typically you will pass an env field to your CDK stack props like this:

const app = new cdk.App();
new MyProduct(app, 'my-product', {
  env: {
    account: '123456879123',
    region: 'us-east-1'
  },
  client: 'foo'
});
Enter fullscreen mode Exit fullscreen mode

And its very easy to access account and region from props like this:

const bucketName = `${props.env.account}-${props.env.region}-${props.client}`;
Enter fullscreen mode Exit fullscreen mode

The synthesized template will look like this:

BucketName: '123456789123-us-east-1-foo'
Enter fullscreen mode Exit fullscreen mode

This is very intuitive and works great for CDK deploys, but IS NOT PORTABLE. The synthesized template will contain hard-code account and region values.

The above props are evaluated at synth-time. We want the account and region values to be evaluated at deploy-time.

Introducing Stack.of()

In plain CloudFormation you wouldn't hard-code the account and region information, you would use pseudo-functions like: !Ref AWS::AccountId and !Ref AWS::Region which get evaluated at deploy time.

Lets rewrite our stack params and exclude the optional env field.

const app = new cdk.App();
new MyProduct(app, 'my-product', {
  client: 'foo'
});
Enter fullscreen mode Exit fullscreen mode

Also, lets rewrite our stack to use Stack.of when env is not available.

export class MyProduct extends Stack {
  constructor(scope: Construct, id: string, props: MyProductProps) {
    super(scope, id);

    const stack = props.env || Stack.of(this);
    const bucketName = `${stack.account}-${stack.region}-${props.client}`;

    new Bucket(this, 'Bucket', {
      bucketName: bucketName
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

If you look at the synthesized CloudFormation template, the result should look familiar:

BucketName: !Sub '${AWS::AccountId}-${AWS::Region}-foo'
Enter fullscreen mode Exit fullscreen mode

Bonus: The above approach works whether env is passed in or not, so we should use it by default.

CloudFormation Parameters and Tokens

The other major requirement for portable apps is CloudFormation Parameters. So far, we have passed in our client name as a string. This is very convenient for CDK deploys so we want to keep this format, but lets rewrite our stack to use CloudFormation parameters so that we can have a deploy-time parameter.

export class MyProduct extends Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);
  }

  // Separate build step from constructor to allow inheriting stack to add properties.
  protected build(props: MyProductProps){
    const stack = props.env || Stack.of(this);
    const bucketName =`${stack.account}-${stack.region}-${props.client}`;

    new Bucket(this, 'Bucket', {
      bucketName: bucketName,
    });
  }
}

export class MyPortableProduct extends MyProduct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // Add CloudFormation parameter
    const client = new CfnParameter(this, 'Client', {
      type: 'String',
      description: 'Used for naming'
    });

    // Build the base stack, using the client parameter as a string token
    this.build({
      client: client.valueAsString
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

We just extended our product stack to make it portable, with just a slight modification in the base stack! If you were to synthesize MyPortableStack, your bucketName would look something like this:

BucketName: !Sub '${AWS::AccountId}-${AWS::Region}-${Client}'
Enter fullscreen mode Exit fullscreen mode

Warning!

valueAsString generates a token. Tokens don't represent your data, so don't perform string manipulations or conditionals with tokens. I will cover how to deal with this in another post.

Synthesizing a Template

So far we have been making our CDK app portable, but we want CDK to generate a plain CloudFormation template.

Create a new bin/product.ts file

const app = new cdk.App();
new MyPortableProduct(app, 'my-product');

const output = app.synth();
const outStack = output.stacks[0];

const templatePath = path.resolve('./cdk.out/template.yaml')
fs.writeFileSync(templatePath, YAML.stringify(outStack.template));
Enter fullscreen mode Exit fullscreen mode

Create a new synth script in package.json. Notice this script excludes metadata and version reporting, this is not required for plain CloudFormation and makes our output template a lot cleaner.

{
  "scripts": {
    "synth:product" : "tsc && npx cdk --app 'npx ts-node --prefer-ts-exts bin/product.ts' --path-metadata false --version-reporting false synth --quiet"
  }
}
Enter fullscreen mode Exit fullscreen mode

Run the script npm run synth:product

Your cdk.out/template.yaml should look like this:

Parameters:
  Client:
    Type: String
    Description: Used for naming
Resources:
  Bucket83908E77:
    Type: AWS::S3::Bucket
    Properties:
      BucketName:
        Fn::Join:
          - ""
          - - Ref: AWS::AccountId
            - "-"
            - Ref: AWS::Region
            - "-"
            - Ref: Client
    UpdateReplacePolicy: Retain
    DeletionPolicy: Retain
Enter fullscreen mode Exit fullscreen mode

CloudFormation Logical Ids

If you are upgrading an existing CloudFormation or SAM app to CDK, you will notice that the bucket logical id (Bucket83908E77) is auto-generated.

If our legacy template logical id was Bucket, this would force the bucket to be recreated if you updated the stack.

We must update the build step to use overrideLogicalId to specify our own logical id.

const bucket = new Bucket(this, 'Bucket', {
  bucketName: bucketName
});
(bucket.node.defaultChild as CfnBucket).overrideLogicalId('Bucket');
Enter fullscreen mode Exit fullscreen mode

Now the template has our expected logical id, and we can update our stack without losing our data.

Parameters:
  Client:
    Type: String
    Description: Used for naming
Resources:
  Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName:
        Fn::Join:
          - ""
          - - Ref: AWS::AccountId
            - "-"
            - Ref: AWS::Region
            - "-"
            - Ref: Client
    UpdateReplacePolicy: Retain
    DeletionPolicy: Retain
Enter fullscreen mode Exit fullscreen mode

Conclusion

This is a lot of extra work if you are building internal AWS apps, but if you are upgrading or building a product that will be installed in client accounts, you need the flexibility to support different deployment mechanisms.

Check out the GitHub Repo for full project setup.

Originally posted on my blog

Top comments (0)