HashiCorp recently announced CDK for Terraform: Enabling Python and TypeScript Support - CDK stands for Cloud Development Kit, CDK enables Cloud Engineers to define their Infrastructure as Code (IaC) using programming languages like TypeScript or Python.

CDK currently consists of a new CLI and a library for defining Terraform resources using TypeScript or Python to generate Terraform configuration files that can be used to provisioning resources.

In this blog-post, I will dive into the CDK leveraging the existing Azure providers in order to create an Azure Kubernetes Service (AKS) using TypeScript. The code can be found on my github.com/MarkWarneke.

Cloud Development Kit (CDK) for Terraform on Azure

TLDR: The CDK for Terraform compliments the exiting Terraform ecosystem. The CDK adds the capabilities to

  • compile (strongly typed programming language)
  • debug (improved developer inner-loop)
  • extend (using linter, libraries and packages)

CDK is currently implemented in Node and can be installed using npm install -g cdktf-cli see getting started on the official repo.

CDK Overview

Lets explore the cdktf cli commands.

cdktf [command]

Commands:
  cdktf deploy [OPTIONS]   Deploy the given stack
  cdktf destroy [OPTIONS]  Destroy the given stack
  cdktf diff [OPTIONS]     Perform a diff (terraform plan) for the given stack
  cdktf get [OPTIONS]      Generate CDK Constructs for Terraform providers and modules.
  cdktf init [OPTIONS]     Create a new cdktf project from a template.
  cdktf login              Retrieves an API token to connect to Terraform Cloud.
  cdktf synth [OPTIONS]    Synthesizes Terraform code for the given app in a directory. [aliases: synthesize]

Options:
  --version          Show version number [boolean]
  --disable-logging  Dont write log files. Supported using the env CDKTF_DISABLE_LOGGING.                                                                             [boolean] [default: true]
  --log-level        Which log level should be written. Only supported via setting the env CDKTF_LOG_LEVEL [string]
  -h, --help         Show help [boolean]

Options can be specified via environment variables with the "CDKTF_" prefix (e.g. "CDKTF_OUTPUT")

To get started create a new folder and run cdktf init --template="typescript", this will generate a blueprint TypeScript file structure and download the required dependencies.

-rw-r--r--   1 markmitk markmitk   131 Jul 23 17:27 cdktf.json # CDK configuration, including provider
drwxr-xr-x   3 markmitk markmitk  4096 Jul 23 17:27 .gen # packages to be used in TypeScript
-rw-r--r--   1 markmitk markmitk    69 Jul 23 17:27 .gitignore # prepared gitignore to not check in generated files
-rw-r--r--   1 markmitk markmitk  1129 Jul 23 17:27 help
-rw-r--r--   1 markmitk markmitk    11 Jul 23 17:27 main.d.ts
-rw-r--r--   1 markmitk markmitk  1285 Jul 23 17:27 main.js
-rw-r--r--   1 markmitk markmitk   296 Jul 23 17:27 main.ts # MAIN file, similar to main.tf
drwxr-xr-x 162 markmitk markmitk  4096 Jul 23 17:27 node_modules
-rw-r--r--   1 markmitk markmitk   647 Jul 23 17:27 package.json
-rw-r--r--   1 markmitk markmitk 68478 Jul 23 17:27 package-lock.json
drwxr-xr-x   3 markmitk markmitk  4096 Jul 23 17:27 .terraform  # terraform config
-rw-r--r--   1 markmitk markmitk   716 Jul 23 17:27 tsconfig.json

The output is a bunch of files, including the known main (in this case main.ts instead of main.tf) and the CDK configuration file cdktf.json. Lets add the Azure provider to the cdktf configuration file.

{
  "language": "typescript",
  "app": "npm run --silent compile && node main.js",
  "terraformProviders": ["azurerm@~> 2.0.0"]
}

The terraformProviders can be explored in the registry e.g. azurerm. Add "azurerm@~> 2.0.0" to the cdkf.json and run cdktf get in order to install the Azure provider.

All available providers and resources are downloaded into ./.gen/providers/

Make sure to log-in to Azure using the az cli az login. The interactive cdktf similar to terraform will use the current Azure context by default.

Deploy Kubernetes on Azure using TypeScript

After the providers have been fetched the provider can be explored in .gen/providers/azurerm. You can find all available resources definition here, Kubernetes can be found using

ls ./.gen/providers/azurerm | grep "Kubernetes"

Lets look at the TerraformResource implementation of KubernetesCluster. To see the implementation display kubernetes-cluster.ts using less or browse the file in your editor.

less ./.gen/providers/azurerm/kubernetes-cluster.ts

You are looking for the exported class: export class KubernetesCluster extends TerraformResource.

KubernetesCluster TerraformResource

In order to use the Kubernetes TerraformResource declare the class in main.ts. First add the dependency to the imports using:

import { AzurermProvider, KubernetesCluster} from './.gen/providers/azurerm'

The main.ts could look somewhat like this:

import { Construct } from "constructs";
import { App, TerraformStack, TerraformOutput } from "cdktf";
import { AzurermProvider, KubernetesCluster } from "./.gen/providers/azurerm";

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

    // Register the AzureRmProvider, make sure to import its
    const provider = new AzurermProvider(this, "AzureRm", {
      features: [{}],
    });

    // ...

    new KubernetesCluster(this, "k8scluster", {
      // ... KubernetesClusterConfig
    });
  }
}

const app = new App();
const k8tstack = new K8SStack(app, "typescript-azurerm-k8s");
app.synth();

TerraformResource accepts a scope, id and a config. The config is depending on the resource to be provisioned and the class name is based on the resource followed by Config. For Kubernetes we thus are looking for KubernetesClusterConfig.

In this example the scope is set to the current stack using this, the id is similar to the resource name in Terraform and should be unique. The config is an implementation of TerraformMetaArguments, lets see how to use the KubernetesClusterConfig.

Define the KubernetesClusterConfig

The KubernetesClusterConfig is an interface that describes the TerraformMetaArguments for a Kubernetes cluster.

export interface KubernetesClusterConfig extends TerraformMetaArguments

The interface describes the properties of the configuration. Leveraging a code editor like VSCode shift-clicking KubernetesClusterKubeConfig will reveal the implementation.

We can see which properties of the configuration are mandatory and which are optional. Optional properties are postfixed with a question-mark ?, e.g. readonly apiServerAuthorizedIpRanges?: string[];.

We can also use intellisense to suggest and displays missing variables. The mandatory config for a KubernetesClusterConfig looks like this:

const k8sconfig: KubernetesClusterConfig = {
  dnsPrefix: AKS_DNS_PREFIX,
  location: LOCATION,
  name: AKS_NAME,
  resourceGroupName: rg.name,
  servicePrincipal: [ident],
  defaultNodePool: [pool],
  dependsOn: [rg],
};

Notice that we are referencing variables here and not hard-coded strings. The resourceGroupName for instance is referencing the property of a previously defined ResourceGroup rg, etc. For reference see the whole implementation in generate terraform.

We can double-check the official terraform provider docs for a Kubernetes cluster terraform.io/azurerm_kubernetes_cluster and see that the config values are matching the mandatory parameter of the argument reference.

Suppose we missed a mandatory property! The TypeScript compiler will throw an error and indicate early that an attribute has been missed. This is a major benefit of the strongly typed programming language TypeScript over a loose configuration file.

# tsc

main.ts:36:18 - error TS2304: Cannot find name 'AKS_DNS_PREFIX'.

36       dnsPrefix: AKS_DNS_PREFIX,

To create a config element use let NAME: Type = {}. The TypeScript object can then be extended based on conditions with additional properties, just like any TypeScript object.

The editor can be used to autocomplete e.g. using shift space, to display additional properties of the given object.

Code Suggestion

Caveat: a Terraform Azure Kubernetes Cluster typically can be provisioned using a servicePrincipal or identity. They are mutually exclusive and one of them has to be defined, using the current KubernetesClusterConfig only the servicePrincipal property is mandatory and thus identity can not be used. I am concluding that there is not a 1:1 mapping of the available Terraform modules to the CDK currently - this a drawback and, I suppose, will be fixed in a later version of the CDK.

Leverage Environment Variables

Environment variables can be used to inject variables to a CDK implementation. In Node you can use process.env e.g. process.env.AZ_SP_CLIENT_ID and process.env.AZ_SP_CLIENT_SECRET.

const ident: KubernetesClusterServicePrincipal = {
  clientId: process.env.AZ_SP_CLIENT_ID as string,
  clientSecret: process.env.AZ_SP_CLIENT_SECRET as string,
};

This allows us to change configuration between deployments without changing the code based on the Twelve-Factor App App. The environment variables can be set using

export AZ_SP_CLIENT_ID=''
export AZ_SP_CLIENT_SECRET=''

Generate Terraform

The CDK is used to generate a Terraform file. The process of generating an IaC file is called synthesizing. The cli can be used to run cdktf synth. This will create a cdktf.out directory. In the directory, we can review the create Terraform file ./cdktf.out/cdk.tf.json.

The full CDK AKS implementation looks like this:

cdktf synth can also be run with -o to specify the output file or using -json to just print the created Terraform file to the console. The cdktf generates json because it is not necessary to be human-readable. Terraform works with both hcl (.tf) and json (.json) files.

Exploring the cdk.tf.json file we can see the familiar Terraform structure. All resources are present including the values & the previously set values of the environment variables e.g. the service principal id and secret.

Secrets are visible here. Make sure to use appropriate measures to prevent leaking them. Use Azure Key Vault azure_key_vault_secret to store and retrieve secrets securely. Checking-In the output of the synth step should be prevented, e.g. through a .gitignore file.

We can run cdktf diff similar to terraform diff to display the changes to be made before applying them. We can also explore the terraform state in the root folder terraform.tfstate. The state can be configured e.g. in a remote backend following the docs for Terraform Remote Backend

Get it Running

# Login to Azure, in order to set the local terraform context
az login

# Export the "not so" secret environment variables
export AZ_SP_CLIENT_ID=''
export AZ_SP_CLIENT_SECRET=''

# Generate the terraform deployment file
cdktf synth

# Run the diff to see planned changes
cdktf diff

# Run terraform apply using the generated deployment file
cdktf deploy

# Destroy the deployment using
cdktf destroy

As the CDK is used to generate Terraform deployment files, we can use the synth steps output with familiar Terraform-CLI commands.

Go to cdktf.out and run terraform validate, terraform plan and terraform apply, this is the magic behind synthesizing Terraform Configuration using CDK for Terraform CLI. This tooling generates valid Terraform code that can be easily added to existing IaC projects. Previously created pipelines can still be used to deploy infrastructure.

We can leverage CDK to abstract the deployment file creation to a higher-level programming language without investing too much into refactoring existing tools and pipelines and without losing any of the benefits of IaC configuration files.

Lookout

Because a higher-level programming language is used we can leverage a couple of tools that have been missing to the IaC development lifecycle.

Compiler

The use of the strongly typed TypeScript language as an intermediary can be used to catch configuration errors early by using the TypeScript compiler in the developer’s inner-loop. Running tsc in the root folder will display any TypeScript errors immediately.

Missing mandatory variable or wrong assignments are for the past.

Linter

Tools like tslint can be used to run statical code-analysis and ensure inconsistencies and errors are caught early. Running tslint -c tslint.json main.ts will display any violation of the configured rules. Linters can also be used to unify a codebase, this is especially interesting with multiple contributors to the same code.

Libraries and Tools

Tools and libraries like hcl2json or json2hcl can be used to extend and add on top of the CDK.

Debuggers

Furthermore, because a programming language is used we can attach debuggers to our development workflow.

Debugger

(In VSCode open main.ts > Run the debugger with F5 > Select Environment Node.js)

Troubleshooting the assignment of variables, understanding complex loops and conditions as well as resolving dependency trees are way easier to resolve by leveraging a debugger. Resolving errors should be less tedious because existing and battle-tested software-development tools can finally be used.

Tests

Complex templates can be broken into small reusable pieces. A complex Terraform deployment can be constructed dynamically. Multiple modules can be composed using loops and conditions. The amount of configuration code can be reduced significantly. Unit tests can be applied to ensure that the generated templates are correct and consistent.

Outlook

The combination of parameterized Terraform modules and the usage of the CDK can abstract deployments into simple to use services, like command-line tools, shared APIs and web services. These services create reproducible IaC based on configuration data that can even be stored in databases.

Custom validation and enforcing naming-convention through custom code can be used to scale and mature IaC projects further.

The flexibility of a full programming language meets the idempotent and declarative nature of IaC.

The code can be found github.com/MarkWarneke/cdk-typescript-azurerm-k8s.