A Terraform CDK Construct which doubles as native Terraform Module

skorfmann

Sebastian Korfmann

Posted on July 27, 2020

A Terraform CDK Construct which doubles as native Terraform Module

Lou raised the question of Terraform Module interoperability in the Terraform CDK:

Module Interoperability — It seems the CDK will support regular Terraform modules, for code sharing. But it does remain to be seen if module sharing can be reversed. Can a CDK project publish a module which is then consumed by HCL? If you’d have to learn HCL to write and share a module, that mostly defeats the point of using the CDK in the first place.

That's a great question, and while I was confident this is something which could be done today already, I thought this would be a good example to actually build out. So, that's what I did and I'd like to share with you.

General Concept

The Terraform CDK is synthesizing from code to HCL compatible JSON. This makes it less a question of if it's possible to, but more like a question of how to organize the code and how to distribute it.

Resource vs TerraformResource

The concept I came up with includes a custom construct, which is sort of the equivalent of a native Terraform module in the Terraform CDK. That's just a Typescript class, which wraps other Terraform constructs (an EC2 Instance in this case). It could contain more constructs of course, they could be nested, whatever you can imagine - it's all just Typescript in the end.

import { Construct } from 'constructs';
import { Resource } from 'cdktf';
import { Instance } from '../imports/providers/aws'

export interface CustomInstanceProps {
  instanceType?: string;
  tags?: {[key: string]: string};
}

export class CustomInstance extends Resource {
  public readonly instance: Instance;

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

    const { tags, instanceType = "t3.nano" } = props || {};

    this.instance = new Instance(this, 'ubuntu2', {
      ami: "ami-0ff8a91507f77f867",
      availabilityZone: "us-east-1a",
      instanceType,
      tags
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

In contrast to the generated Instance class, the CustomInstance class extends Resource and not TerraformResource. While CustomInstance is still a node in the Constructs tree, the CustomInstance class itself will be skipped during synth. Only the actual TerraformResource classes will be rendered down to HCL compatible JSON. It's really just a container for actual Terraform resources.

Distribution

NPM Package

Since our CustomInstance is a Typescript class, distributing this as a NPM package is straightforward. Make sure to setup the following keys in your package.json and off you go:

  "main": "path/to/your/construct.js",
  "types": "path/to/your/construct.d.ts",
  "peerDependencies": {
    "cdktf": "^0.0.12",
    "constructs": "^3.0.0"
  },
Enter fullscreen mode Exit fullscreen mode

Declaring peerDependencies and not dependencies is the important point here. Otherwise, you'd likely end up in dependency hell.

Terraform Module

In order to distribute this as a Terraform module, this should be synthesized to JSON somehow. Let's build a Stack without a provider and that'll pretty much be synthesized to the equivalent of a Terraform module.

import { Construct } from 'constructs';
import { App, TerraformStack, TerraformOutput, Token } from 'cdktf';
import { CustomInstance } from './construct'

class MyStack extends TerraformStack {
  constructor(scope: Construct, name: string) {
    super(scope, name);

    const custom = new CustomInstance(this, 'Custom')

    new TerraformOutput(this, 'arn', {
      value: custom.instance.arn
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Rather than ignoring the the synthesized output in Git, let's commit this and configure cdktf to use a nicer folder name (module in this case) for its output:

{
  "language": "typescript",
  "app": "npm run --silent compile && node lib/module.js",
  "terraformProviders": [
    "aws@~> 2.0"
  ],
  "codeMakerOutput": "imports",
  "output": "module"
}
Enter fullscreen mode Exit fullscreen mode

For the native Terraform module use-case it would be nice to omit the stack traces in the generated JSON, since that'll change depending on where it's build. A little bit of jq could be helpful here

cat ./cdktf.out/cdk.tf.json | jq 'walk(if type == "object" then with_entries(select(.key | test("\/\/") | not)) else . end)'
Enter fullscreen mode Exit fullscreen mode

Mid / long term, native support would be better though. Check out this open issue here.

Input Variables

Terraform Input Variables are not natively supported by the Terraform CDK yet. There's an open issue to change this. However, that's a case where escape hatches come in handy:

const stack = new MyStack(app, 'cdktf-hybrid-module');

// See issue linked above, this will be natively supported
stack.addOverride('variable', {
  tags: {
    description: "Tags for the instance",
    type: "map(string)"
  },
  instance_type: {
    description: "Instance type",
    type: "string"
  }
})
Enter fullscreen mode Exit fullscreen mode

From here on, it's really useable as any other Terraform module:

module "instance" {
  source = "github.com/skorfmann/cdktf-hybrid-module//packages/cdktf-hybrid-module/module"

  instance_type = "t3.nano"

  tags = {
    "CDKTF" = "IS AWESOME"
  }
}
Enter fullscreen mode Exit fullscreen mode

Bonus: Terraform Module via NPM

One thing I really like about NPM: It doesn't make any assumptions about what you're intending to ship. Let's leverage this, to use the native Terraform module via NPM :)

npm init -y
npm install cdktf-hybrid-module
Enter fullscreen mode Exit fullscreen mode

And then just reference it from node_modules.

module "instance" {
  source = "./node_modules/cdktf-hybrid-module/module"

  instance_type = "t3.nano"

  tags = {
    "CDKTF" = "IS AWESOME"
  }
}
Enter fullscreen mode Exit fullscreen mode

That could be a way, to get dependency management for native Terraform modules via NPM. I'm certainly not the first one who had this idea, I'm pretty sure I saw this in other blog posts as well.

I think this could make sense in complex scenarios, where dependency management is important and manually managing this becomes a burden.

Current limitations

As mentioned a few times, there are a few usability gaps at the moment:

  • Outputs are already fully supported by cdktf but due to the random naming a bit hard to use. There' an open issue to address this
  • Variables aren't natively supported in cdktf yet, but can still be done with escape hatches. There's an open issue
  • Since there aren't official prebuilt provider packages at the moment, this has to inline the generated constructs.

The last point is the biggest drawback at the moment from my point of view, but the work to improve this is underway - see this open issue.

Conclusion

I think this demonstrates that hybrid CDK packages / Terraform modules are totally possible and have lots of future potential. Would love to hear what you think about it!

Check out the entire example project as well to see all of this in full context.

GitHub logo skorfmann / cdktf-hybrid-module

A Terraform CDK Construct which is also usable as Terraform Module

In the next post we'll build a Python package for our little CustomInstance, stay tuned!

💖 💪 🙅 🚩
skorfmann
Sebastian Korfmann

Posted on July 27, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related