Introduction
CDK for Terraform is a powerful tool that makes cloud resource provisioning more programmatic and flexible. Many users use cdktf-cli for deployment, but it is also possible to deploy resources directly from a function without using the CLI. This article explains that method in detail. Use this guide if you want to make the most of CDK for Terraform without going through the CLI. The method in this article works only with Go because “github.com/hashicorp/terraform-exec/tfexec” is not implemented in other languages.
How to deploy resources with the usual CDKTF workflow
Here is an example that deploys an EC2 instance.
Sample code
Below is the code to deploy an EC2 instance.
package main
import (
"github.com/aws/constructs-go/constructs/v10"
"github.com/aws/jsii-runtime-go"
"github.com/hashicorp/terraform-cdk-go/cdktf"
"github.com/hashicorp/terraform-cdk-go/cdktf/providers/aws"
)
func NewMyStack(scope constructs.Construct, id string) cdktf.TerraformStack {
stack := cdktf.NewTerraformStack(scope, &id)
// Define the AWS provider.
aws.NewAwsProvider(stack, jsii.String("aws"), &aws.AwsProviderConfig{
Region: jsii.String("ap-northeast-1"),
})
// Create an EC2 instance.
aws.NewInstance(stack, jsii.String("Ec2Instance"), &aws.InstanceConfig{
Ami: jsii.String("ami-0c55b159cbfafe1f0"),
InstanceType: jsii.String("t2.micro"),
})
return stack
}
func main() {
app := cdktf.NewApp(nil)
NewMyStack(app, "MyStack")
app.Synth(nil)
}
How to deploy
Deploy the CDKTF resource with the command below.
cdktf deploy
This is how you can manage resources with cdktf-cli.
However, if you want to deploy CDKTF resources from a function without using commands, you need to import the “os/exec” library and write code like cmd := exec.Command("cdktf", "deploy", "--auto-approve").
This is awkward and can complicate security, variable handling, and process management.
How to deploy CDKTF resources without using cdktf-cli
Next, I will show you how to deploy CDKTF resources without using cdktf-cli.
Sample code
The following code refactors the earlier EC2 example so that it can be deployed without using cdktf-cli.
cdktf-cli wraps Terraform execution, but by using the “github.com/hashicorp/terraform-exec/tfexec” module, you can operate Terraform directly and deploy resources without cdktf-cli. This approach is useful if you want more control over Terraform processes or if your environment restricts the use of cdktf-cli.
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/aws/constructs-go/constructs/v10"
"github.com/aws/jsii-runtime-go"
"github.com/hashicorp/terraform-cdk-go/cdktf"
"github.com/hashicorp/go-version"
"github.com/hashicorp/hc-install/product"
"github.com/hashicorp/hc-install/releases"
"github.com/hashicorp/terraform-exec/tfexec"
"github.com/hashicorp/terraform-cdk-go/cdktf/providers/aws"
)
func NewMyStack(scope constructs.Construct, id string) cdktf.TerraformStack {
stack := cdktf.NewTerraformStack(scope, &id)
// Define the AWS provider.
aws.NewAwsProvider(stack, jsii.String("aws"), &aws.AwsProviderConfig{
Region: jsii.String("ap-northeast-1"),
})
// Create an EC2 instance.
aws.NewInstance(stack, jsii.String("Ec2Instance"), &aws.InstanceConfig{
Ami: jsii.String("ami-0c55b159cbfafe1f0"),
InstanceType: jsii.String("t2.micro"),
})
return stack
}
func main() {
// Create a temporary directory.
tempDir, err := os.MkdirTemp("", "temp-")
if err != nil {
return nil, err
}
// Delete the temporary directory when the process is finished.
defer os.RemoveAll(tempDir)
// Initialize the CDKTF application.
app := cdktf.NewApp(&cdktf.AppOptions{Outdir: jsii.String(tempDir)})
NewMyStack(app, "MyStack")
app.Synth()
// Delete JSII-related files when the function ends.
defer jsii.Close()
// Initialize the installer with the Terraform version.
installer := &releases.ExactVersion{
Product: product.Terraform,
Version: version.Must(version.NewVersion("1.6.1")),
}
// Install Terraform.
execPath, err := installer.Install(context.Background())
if err != nil {
return nil, fmt.Errorf("Failed to install Terraform: %w", err)
}
// Set the Terraform working directory.
workingDir := filepath.Join(tempDir, "stacks", name)
tf, err := tfexec.NewTerraform(workingDir, execPath)
if err != nil {
return nil, fmt.Errorf("Failed to set up Terraform: %w", err)
}
// Initialize Terraform.
err = tf.Init(context.Background(), tfexec.Upgrade(true))
if err != nil {
return nil, fmt.Errorf("Failed to initialize Terraform: %w", err)
}
// Run Terraform apply to deploy resources.
err = tf.Apply(context.Background())
if err != nil {
return fmt.Errorf("Failed to apply infrastructure: %w", err)
}
return nil
}
How to deploy
You can deploy CDKTF resources without using cdktf-cli with the command below.
go run main.go
With this approach, you can deploy resources using the standard go run command.
However, this still does not allow deployment from a function, so we will modify the code.
How to deploy CDKTF resources from a function
Next, we will introduce how to deploy CDKTF resources from a function.
Sample code
In Go, a package named main with a main function is treated as an executable program. Because of this, the main function serves as the program’s entry point and is not suitable as a reusable function called from other code.
Therefore, move the deployment logic out of main.go and split it into callable functions.
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/aws/constructs-go/constructs/v10"
"github.com/aws/jsii-runtime-go"
"github.com/hashicorp/terraform-cdk-go/cdktf"
"github.com/hashicorp/terraform-exec/tfexec"
"github.com/hashicorp/hc-install/releases"
"github.com/hashicorp/hc-install/product"
"github.com/hashicorp/go-version"
"github.com/hashicorp/terraform-cdk-go/cdktf/providers/aws"
)
func NewMyStack(scope constructs.Construct, id string) cdktf.TerraformStack {
stack := cdktf.NewTerraformStack(scope, &id)
// Define the AWS provider.
aws.NewAwsProvider(stack, jsii.String("aws"), &aws.AwsProviderConfig{
Region: jsii.String("ap-northeast-1"),
})
// Create an EC2 instance.
aws.NewInstance(stack, jsii.String("Ec2Instance"), &aws.InstanceConfig{
Ami: jsii.String("ami-0c55b159cbfafe1f0"),
InstanceType: jsii.String("t2.micro"),
})
return stack
}
// CreateTempDir creates a temporary directory and returns its path.
func CreateTempDir() (string, error) {
// Create a temporary directory. The prefix is "temp-".
tempDir, err := os.MkdirTemp("", "temp-")
if err != nil {
// Return an error if directory creation fails.
return "", fmt.Errorf("Failed to create temporary directory: %w", err)
}
// Return the temporary directory path.
return tempDir, nil
}
// SetupTerraform sets up Terraform and returns a tfexec.Terraform object.
func SetupTerraform(tempDir, name string) (*tfexec.Terraform, error) {
const terraformVersion = "1.6.1" // Specify the version of Terraform to use.
// Set the working directory for Terraform.
workingDir := filepath.Join(tempDir, "stacks", stackName)
// Create an installer for the specified Terraform version.
installer := &releases.ExactVersion{
InstallDir: workingDir,
Product: product.Terraform,
Version: version.Must(version.NewVersion(terraformVersion)),
}
// Install the Terraform binary and get its path.
execPath, err := installer.Install(context.Background())
if err != nil {
// Return an error if installation fails.
return nil, fmt.Errorf("Failed to install Terraform: %w", err)
}
// Create a new instance of Terraform using tfexec.
tf, err := tfexec.NewTerraform(workingDir, execPath)
if err != nil {
// Return an error if Terraform instance creation fails.
return nil, fmt.Errorf("Failed to set up Terraform: %w", err)
}
// Initialize Terraform. This step prepares the Terraform files and installs the required plugins.
err = tf.Init(context.Background(), tfexec.Upgrade(true))
if err != nil {
// Return an error if initialization fails.
return nil, fmt.Errorf("Failed to initialize Terraform: %w", err)
}
// Return the configured Terraform instance.
return tf, nil
}
// CreateInfrastructure creates infrastructure with the specified parameters.
func CreateInfrastructure(stack_name string) error {
// Create a temporary directory.
tempDir, err := CreateTempDir()
if err != nil {
// Return the error if one occurs.
return err
}
// Delete the temporary directory when the function ends.
defer os.RemoveAll(tempDir)
// Create a new CDKTF application.
app := cdktf.NewApp(&cdktf.AppConfig{Outdir: jsii.String(tempDir)})
// Create a stack and add it to the application.
NewMyStack(app, stack_name)
app.Synth()
// Delete JSII-related files when the function ends.
defer jsii.Close()
// Set up Terraform.
tf, err := SetupTerraform(tempDir, stack_name)
if err != nil {
// Return the error if one occurs.
return err
}
// Apply (create) infrastructure using Terraform.
err = tf.Apply(context.Background())
if err != nil {
// Return an error if apply fails.
return fmt.Errorf("Failed to apply infrastructure: %w", err)
}
// Return nil if successful.
return nil
}
// DestroyInfrastructure destroys infrastructure with the specified parameters.
func DestroyInfrastructure(stack_name string) error {
// Create a temporary directory.
tempDir, err := CreateTempDir()
if err != nil {
// Return the error if one occurs.
return err
}
// Delete the temporary directory when the function ends.
defer os.RemoveAll(tempDir)
// Create a new CDKTF application.
app := cdktf.NewApp(&cdktf.AppConfig{Outdir: jsii.String(tempDir)})
// Create a stack and add it to the application.
NewMyStack(app, stack_name)
app.Synth()
// Delete JSII-related files when the function ends.
defer jsii.Close()
// Set up Terraform.
tf, err := SetupTerraform(tempDir, stack_name)
if err != nil {
// Return the error if one occurs.
return err
}
// Destroy infrastructure using Terraform.
err = tf.Destroy(context.Background())
if err != nil {
// Return an error if destroy fails.
return fmt.Errorf("Failed to destroy infrastructure: %w", err)
}
// Return nil if successful.
return nil
}
How to deploy
You can now deploy resources by calling these functions from another file. This example assumes that inframgt.go is located in the same directory as main.go.
package main
import (
"fmt"
)
func main() {
// Here, we use "MyStack" as the stack name.
stackName := "MyStack"
// Create the infrastructure. This lets you specify the stack name dynamically.
err := CreateInfrastructure(stackName)
if err != nil {
// Print an error message if infrastructure creation fails.
fmt.Printf("Failed to create infrastructure: %s\n", err.Error())
return
}
// Print a message indicating that the infrastructure was created successfully.
fmt.Println("Infrastructure created successfully!")
}
In this way, you can deploy CDKTF resources by calling a function from a separate file. This enables flexible patterns such as controlling resources from gRPC calls.
Bonus
Dynamic resource parameters
In the code above, the EC2 instance creation parameters are hard-coded inside the NewMyStack function. By making the following changes, you can specify the parameters dynamically.
~~Omitted~~
// EC2InstanceOptions defines configuration options for EC2 instances.
type EC2InstanceOptions struct {
Ami string // Amazon Machine Image ID
InstanceType string // EC2 instance type (e.g. t2.micro)
}
// NewMyStack creates a Terraform stack and configures an AWS provider and EC2 instance.
// Arguments include the stack scope, stack ID, and EC2 instance options.
func NewMyStack(scope constructs.Construct, id string, ec2Options EC2InstanceOptions) cdktf.TerraformStack {
stack := cdktf.NewTerraformStack(scope, &id)
// Define the AWS provider.
aws.NewAwsProvider(stack, jsii.String("aws"), &aws.AwsProviderConfig{
Region: jsii.String("ap-northeast-1"), // AWS region setting
})
// Create an EC2 instance.
// Configure the EC2 instance using the parameters received from EC2InstanceOptions.
aws.NewInstance(stack, jsii.String("Ec2Instance"), &aws.InstanceConfig{
Ami: jsii.String(ec2Options.Ami), // AMI ID
InstanceType: jsii.String(ec2Options.InstanceType), // instance type
})
return stack
}
~~Omitted~~
// CreateInfrastructure creates infrastructure with the specified parameters.
func CreateInfrastructure(stack_name string, ec2Options EC2InstanceOptions) error {
// Create a temporary directory.
tempDir, err := CreateTempDir()
if err != nil {
// Return the error if one occurs.
return err
}
// Delete the temporary directory when the function ends.
defer os.RemoveAll(tempDir)
// Create a new CDKTF application.
app := cdktf.NewApp(&cdktf.AppConfig{Outdir: jsii.String(tempDir)})
// Create a stack and add it to the application. We also pass options for the EC2 instance.
NewMyStack(app, stack_name, ec2Options)
app.Synth()
// Delete JSII-related files when the function ends.
defer jsii.Close()
// Set up Terraform.
tf, err := SetupTerraform(tempDir, stack_name)
if err != nil {
// Return the error if one occurs.
return err
}
// Apply (create) infrastructure using Terraform.
err = tf.Apply(context.Background())
if err != nil {
// Return an error if apply fails.
return fmt.Errorf("Failed to apply infrastructure: %w", err)
}
// Return nil if successful.
return nil
}
// DestroyInfrastructure destroys infrastructure with the specified parameters.
func DestroyInfrastructure(stack_name string, ec2Options EC2InstanceOptions) error {
// Create a temporary directory.
tempDir, err := CreateTempDir()
if err != nil {
// Return the error if one occurs.
return err
}
// Delete the temporary directory when the function ends.
defer os.RemoveAll(tempDir)
// Create a new CDKTF application.
app := cdktf.NewApp(&cdktf.AppConfig{Outdir: jsii.String(tempDir)})
// Create a stack and add it to the application. We also pass options for the EC2 instance.
NewMyStack(app, stack_name, ec2Options)
app.Synth()
// Delete JSII-related files when the function ends.
defer jsii.Close()
// Set up Terraform.
tf, err := SetupTerraform(tempDir, stack_name)
if err != nil {
// Return the error if one occurs.
return err
}
// Destroy infrastructure using Terraform.
err = tf.Destroy(context.Background())
if err != nil {
// Return an error if destroy fails.
return fmt.Errorf("Failed to destroy infrastructure: %w", err)
}
// Return nil if successful.
return nil
}
Change the function call accordingly as follows.
package main
import (
"fmt"
)
func main() {
// Here, we use "MyStack" as the stack name.
stackName := "MyStack"
// Set options for the EC2 instance.
ec2Options := EC2InstanceOptions{
Ami: "ami-0c55b159cbfafe1f0",
InstanceType: "t2.micro",
}
// Create the infrastructure. This lets you specify the stack name and EC2 instance options dynamically.
err := CreateInfrastructure(stackName, ec2Options)
if err != nil {
// Print an error message if infrastructure creation fails.
fmt.Printf("Failed to create infrastructure: %s\n", err.Error())
return
}
// Print a message indicating that the infrastructure was created successfully.
fmt.Println("Infrastructure created successfully!")
}
In this way, you can dynamically specify resource parameters.
Conclusion
I wrote this code because I want to deploy CDKTF resources through gRPC calls. I originally considered using commands like cmd := exec.Command("cdktf", "deploy", "--auto-approve") to manage deployments, but this has several issues. First, running external commands carries potential security risks, especially when infrastructure code is involved. Second, this approach can complicate code management. Command execution requires many additional considerations, such as error handling, output parsing, environment differences, and version control. For these reasons, I explored ways to deploy resources directly within the program, which led to this code. I believe this will make the deployment process more secure and manageable.
Addendum (2023/11/03)
Problem where files are saved under /tmp every time a function runs
root@82ceb385ab4e:/app# ls /tmp
jsii-kernel-5f3e7F jsii-runtime.1995419442 jsii-runtime.4233241251 jsii-runtime.868038890 terraform_1.6.1_linux_amd64.zip1711922255
jsii-kernel-nCEcR5 jsii-runtime.2858332123 jsii-runtime.85103194 temp-902113077 terraform_1.6.1_linux_amd64.zip3988491512
Accumulation of JSII-related files
I confirmed that JSII-related files remain under /tmp every time the function runs.
This increases disk usage and must be resolved.
This was an issue raised in the issue below.
- JSII Kernel fills disk space using CDK on Cloud9 #700
- Improve Golang template & docs to include defer jsii.Close() cleanup call #2514
Based on these issues, adding defer jsii.Close() inside the function appears to delete JSII-related files after the CDKTF process runs.
This article has been revised based on this.
Accumulation of terraform-exec-related files
I confirmed that Terraform-related files also remain under /tmp every time a function runs, similar to JSII.
The Terraform-related files were ZIP files and binaries created after extraction.
I changed the code so that Terraform binaries are placed in CDKTF’s tempDir and automatically deleted after the function runs.
However, as mentioned in the issue below, there does not appear to be a mechanism for deleting the Terraform ZIP files.
You may need to create your own ZIP file deletion mechanism.