I’ve been working about 70/30 in CDK and Terraform over the last year, and there are many aspects of each that I wish I could bring to the other. CDK has “constructs” which are akin to Terraform modules. One of my favourite constructs is NodejsFunction - it packages up Lambda code that’s collocated in the same repository and uploads the resulting zip to AWS.

Here’s what it looks like in use:

new nodejs.NodejsFunction(this, "MyCoolFunction", {
  entry: "handler/index.ts",
  handler: "index.handler",
  bundling: {
    minify: true,
    sourceMap: true,
    sourceMapMode: nodejs.SourceMapMode.INLINE,
    target: "es2021",
  },
});

The construct uses a Docker container by default, but we can easily install esbuild and have it bundle the code together. esbuild takes care of bundling all the code into one file, tree shaking and TypeScript transpilation.

I love this developer experience, especially the ability to collocate Lamba functions that provide the “glue” we often write as platform teams in the same codebase as our Infrastructure as Code (IaC). How can we achieve the same thing in Terraform?

First attempt

The first solution I built was a script that would package up the Lambda handlers and populate a tfvars file with their source code hash and file name:

function hash_package() { /* ... */ }

echo "Cleaning build directory..."
rm -rf build

echo "Ensuring code compiles..."
yarn compile

echo "Cleaning bundle code..."
rm -rf packaging

echo "Bundling code..."
yarn esbuild \
  --bundle \
  --external:@aws-sdk'*' \
  --external:@aws-lambda-powertools'*' \
  --minify \
  --outdir=packaging \
  --platform=node \
  --sourcemap \
  --target=es2021 \
  --sourcemap=inline \
  src/handlers/*/index.ts

LAMBDAS=""

echo "Packaging Lambda function..."
cd packaging
for BUNDLE in *; do
  cd "$BUNDLE"
  zip package.zip index.js
  PACKAGE_HASH=$(hash_package package.zip)
  FINAL_PACKAGE_PATH="package-${PACKAGE_HASH}.zip"
  mv package.zip "$FINAL_PACKAGE_PATH"
  echo "Created $FINAL_PACKAGE_PATH for $BUNDLE"
  echo "Uploading..."
  aws s3 cp "$FINAL_PACKAGE_PATH" "s3://automate-aws-access-removal-${AWS_ACCOUNT_ID}-${AWS_REGION}/${FINAL_PACKAGE_PATH}"
  echo "Uploaded $FINAL_PACKAGE_PATH"
  cd ..

  LAMBDA=$(cat <<EOF
    "${BUNDLE}" = {
      s3_key = "${FINAL_PACKAGE_PATH}"
      source_code_hash = "$(echo -n "$PACKAGE_HASH" | xxd -revert -plain | base64)"
    }
EOF
)

  if [[ -z "$LAMBDAS" ]]; then
    LAMBDAS="$LAMBDA"
  else
    LAMBDAS="$LAMBDAS
    $LAMBDA"
  fi
done

cd ..

cat << EOF > ../infrastructure/main.auto.tfvars
# Generated by lambda/scripts/package.sh - do not manually edit
lambdas = {
  ${LAMBDAS}
}
EOF

terraform fmt ../infrastructure/main.auto.tfvars

The generated tfvars file lets us feed the values we need directly into the aws_lambda_function resource:

lambdas = {
  "excluded-users-listener" = {
    s3_key           = "package-5b965c5f4f92abd08970a6e7a608f932269d58f2be2f3ccd8278284013d64121.zip"
    source_code_hash = "W5ZcX0+Sq9CJcKbnpgj5MiadWPK+LzzNgngoQBPWQSE="
  }
  "user-deleted-listener" = {
    s3_key           = "package-64b6cf60cd25373c20ee08686f0b5076fb6822dc0aae7d63b7eaeec9b7ab8db2.zip"
    source_code_hash = "ZLbPYM0lNzwg7ghobwtQdvtoItwKrn1jt+ruyberjbI="
  }
  // ...
}

The script worked, but got a bit frustrating when the source code hash changed every time I repackaged the code. No-one likes a Terraform plan that’s never clean! Let’s fix that by setting the modified time of the bundle file and using it in the zip:

# Before:
zip package.zip index.js

# After:
function set_timestamp() {
  local touch_cmd
  touch_cmd="touch"

  if [[ "$OSTYPE" == "darwin"* ]]; then
    touch_cmd="gtouch"
  fi

  # Use a fixed date to make reproducible bundles
  $touch_cmd --no-dereference --date='2020-08-31 00:00:00' "$1"
}

set_timestamp index.js
zip --latest-time package.zip index.js

Much better! This script approach was reasonably simple, but it’s fiddly to use when you’ve got to remember to run it before your terraform plan. What could we do to improve the dev experience?

Attempt #2 - a custom provider

Terraform provides a framework to help us write the providers which power the resources and data sources we rely on, and we’re lucky that both esbuild and Terraform are written in Go. Unfortunately, the first repository I found was the hashicups example project, which uses an older approach to building providers. The starter project I really wanted was terraform-provider-scaffolding-framework. Let’s have a look at how we make our own provider!

NB: wanting to skip ahead and start using it? Check out the finished solution to package NodeJS Lambda functions in Terraform in the Terraform provider registry.

First, we define a provider with our data source:

// ...

func (p *NodeLambdaPackagerProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
	return []func() datasource.DataSource{
		NewLambdaPackageDataSource,
	}
}

func New(version string) func() provider.Provider {
	return func() provider.Provider {
		return &NodeLambdaPackagerProvider{
			version: version,
		}
	}
}

Then we set some parameters that can be passed into our data source:

func (d *LambdaPackageDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
	resp.Schema = schema.Schema{
		Description:         description,
		MarkdownDescription: strings.ReplaceAll(markdownDescription, "BACKTICK", "`"),
		Blocks:              map[string]schema.Block{},
		DeprecationMessage:  "",

		Attributes: map[string]schema.Attribute{
			"args": schema.ListAttribute{
				ElementType: types.StringType,
				Required:    true,
				Description: "Arguments to pass to esbuild.",
			},
			"entrypoint": schema.StringAttribute{
				Required:    true,
				Description: "Path to lambda function entrypoint.",
			},
			"working_directory": schema.StringAttribute{
				Required:    true,
				Description: "Typically the folder containing the package.json at the root of your Lambda project.",
			},
		},
	}
}

Finally, we call esbuild with the parameters provided by our user:

import (
	// ...
	"github.com/evanw/esbuild/pkg/api"
	"github.com/evanw/esbuild/pkg/cli"
	// ...
)

func (d *LambdaPackageDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
	// ...

	buildArgs, err := cli.ParseBuildOptions(args)

	result := api.Build(buildArgs)

	if len(result.Errors) >= 1 {
		// ...
	}

	// ...
}

The rest of the code to form the zip file isn’t very exciting, but here’s what we get as the developer experience when we use this provider:

data "node-lambda-packager_package" "this" {
  args = [
    "--bundle",
    "--external:@aws-sdk*",
    "--external:@aws-lambda-powertools*",
    "--minify",
    "--platform=node",
    "--sourcemap",
    "--target=es2021",
    "--sourcemap=inline",
  ]

  entrypoint        = var.entrypoint
  working_directory = var.working_directory
}

resource "aws_lambda_function" "this" {
  function_name    = "my-cool-function"
  handler          = "index.handler"
  memory_size      = 256
  runtime          = "nodejs18.x"
  filename         = data.node-lambda-packager_package.this.filename
  source_code_hash = data.node-lambda-packager_package.this.source_code_hash
}

When no changes are made to the Lambda code, the source_code_hash remains the same and we see no changes in our plan. Lambda code collocated with our IaC? Check. Automatic building, tree-shaking and compilation? Check. A very new Terraform provider which needs your input and feedback? Triple check!

Conclusion

With the addition of a custom provider, we can bring some of the great developer experience of CDK to Terraform. I’m a big believer in keeping relevant code close together, and this keeps us away from multiple projects or multiple pipelines that are all too easy when we’re deploying an application and IaC.

Find the Terraform provider and the full source code on GitHub to get started bundling and publishing your Lambdas from the same codebase as your Terraform code!