Deploy Infrastructure using CDK for Terraform with Go

I joined Sourcegraph earlier this year to the team responsible for our on-prem deployment and cloud offering!

At Sourcegraph, we aim to build the “Google for Code” and make code more accessible to everyone. Many companies leverage Sourcegraph so developers can easily search code, automate code changes at scale, and track code changes, and more! Sourcegraph offers customers two deployment options, cloud multi-tenant offering and on-prem. Although we try really hard to keep your private code safe in multi-tenant offerings, many enterprise customers still prefer high isolation and choose to go with on-prem installation. Sadly, deploying and maintaining a production Sourcegraph instance is not a trivial task. Additionally, not every company has the necessary resources to maintain yet another system (especially if you’re maintaining someone else’s system).

In a company hackathon, a group of us decided to build a “magic instance maker” that allows anyone to one-click deploy a fully managed single-tenant Sourcegraph instance within shared infrastructure on Google Cloud Platform (GCP). Users only need to provide us with a name and we will magically “make” an instance and return the magic URL of a fresh Sourcegraph deployment.

Architecture

There are a lot of moving pieces to provision a fully functional Sourcegraph deployment, just like any other production web-based system. You need compute resources, storage resources, DNS, HTTP (TLS certificates), and much more.

Kubernetes is one of our supported installation methods for large-scale deployment, and Kubernetes has an amazing ecosystem for various infrastructure automation. We deploy Sourcegraph on a shared Google Kubernetes Engine (GKE) cluster using our experimental Helm chart. For datastores, we utilize managed services on GCP as much as possible, such as Cloud SQL and Google Cloud Storage (GCS). For DNS and TLS, we are mostly relying on Cloudflare. How do we automate so much stuff? Terraform (duh). With Terraform, we can provision all kinds of resources (by providers) and it comes with state management for free.

The Problem

We all like Terraform or Infrastructure as Code (IaC). It’s a great tool for declaratively managing infrastructure, and (hopefully) it’s reproducible, unlike ClickOps. However, Terraform (HCL) is static and we usually just commit the HCL files in a git repository. In our use case, we need to provision resources dynamically without human intervention. Unfortunately, Terraform doesn’t provide any out-of-the-box solution to programmatically create a new module and apply changes.

Wouldn’t it be nice to declare terraform modules with one of your favourite programming languages? Additionally, you will have much more control over generated resources, while with plain Terraform, you are bound by the HCL language constraint. CDK for Terraform (cdktf) is an experimental attempt at solveing this problem.

We used cdktf in Go to implement the project. Why Go and Terraform? Go is the go-to language at Sourcegraph and Terraform is something we use everyday (hence we did not go with things like pulumi).

How does it work?

For a complete tutorial, you should check out the Hashicorp’s official tutorial. Code snippets below definitely won’t compile.

First, you need to create a cdktf.json configuration file. It is used to configure providers and modules.

{
  "language": "go",
  "app": "go run main.go",
  "terraformProviders": [
    {
      "name": "google",
      "source": "hashicorp/google",
      "version": "~> 4.15.0"
    },
    {
      "name": "cloudflare",
      "source": "cloudflare/cloudflare",
      "version": "~> 3.11.0"
    }
  ]
  // ...
}

It’s equivalent to the following in HCL,

terraform {
  required_providers {
    google = {
      source = "hashicorp/google"
      version = "4.15.0"
    }
  }
}

Then you need to run cdktf get, which will dynamically generate the go packages for the providers in cdktf.json. You will be able to import these packages later in your code to declare your Terraform module.

What does my module look like?

import (
  "fmt"

  "github.com/aws/constructs-go/constructs/v10"
  jsii "github.com/aws/jsii-runtime-go"
  "github.com/hashicorp/terraform-cdk-go/cdktf"
  "github.com/sourcegraph/magic-instance-maker/generated/google"
  "github.com/sourcegraph/magic-instance-maker/generated/cloudflare"
  "github.com/sourcegraph/magic-instance-maker/generated/helm"
)

func NewStack(scope constructs.Construct, id string) cdktf.TerraformStack {
  stack := cdktf.NewTerraformStack(scope, &id)

  // Configure remote backend to store terraform state
  cdktf.NewGcsBackend(stack, &cdktf.GcsBackendProps{
    Bucket: jsii.String("gcs-bucket-name"),
    Prefix: jsii.String(fmt.Sprintf("tenants/%s", id)),
  })

  // Configure gcp provide, this is equivalent to the `provider` block
  google.NewGoogleProvider(stack, jsii.String("google"), &google.GoogleProviderConfig{
    Zone:    jsii.String("region-name"),
    Project: jsii.String("project-id"),
  })

  // This is equivalent to the data source block `data "google_sql_database_instance" "cloud-sql-instance" {}`
  cloudSqlDatabaseInstance := google.NewDataGoogleSqlDatabaseInstance(stack, jsii.String("cloud-sql-instance"), &google.DataGoogleSqlDatabaseInstanceConfig{
    Project: jsii.String("project-id"),
    Name:    &cloudSqlInstanceId,
  })
  sqlUser := google.NewSqlUser(stack, jsii.String("sql-user"), &google.SqlUserConfig{
    Project:  jsii.String(projectId),
    Name:     jsii.String(fmt.Sprintf("%s-admin", id)),
    Password: cloudSqlAdminPassword.Result(),
    Instance: cloudSqlDatabaseInstance.Name(),
    Type:     jsii.String("BUILT_IN"),
  })
  cloudsqlPgsqlDbDependencies := []cdktf.ITerraformDependable{sqlUser}
  cloudSqlPgsqlDb := google.NewSqlDatabase(stack, jsii.String("pgsql"), &google.SqlDatabaseConfig{
    Project:   jsii.String(projectId),
    Name:      jsii.String(fmt.Sprintf("%s-pgsql", id)),
    Instance:  cloudSqlDatabaseInstance.Name(),
    DependsOn: &cloudsqlPgsqlDbDependencies,
  })

  helm.NewHelmProvider(stack, jsii.String("helm"), &helm.HelmProviderConfig{
    Kubernetes: &helm.HelmProviderKubernetes{
      ConfigPath:    jsii.String("KUBECONFIGPATH"),
      ConfigContext: jsii.String("CLUSTERNAME"),
    },
  })
  // We provision Sourcegraph deployment using our experimental helm chart
  // https://docs.sourcegraph.com/admin/install/kubernetes/helm
  helm.NewRelease(stack, jsii.String("release"), &helm.ReleaseConfig{
    Repository:      jsii.String("https://sourcegraph.github.io/deploy-sourcegraph-helm/"),
    Chart:           jsii.String("sourcegraph"),
    Name:            jsii.String(id),
    Namespace:       jsii.String(id),
    CreateNamespace: jsii.Bool(true),
    Values:          jsii.Strings("values-file-a-yaml-string"),
  })

  // Configure cloudflare provide, this is equivalent to the `provider` block
  cloudflare.NewCloudflareProvider(stack, jsii.String("cloudflare"), &cloudflare.CloudflareProviderConfig{
    ApiToken: jsii.String("cloudflare-api-token"),
  })

  // This is equivalent to `resource "cloudflare_record" "magic-example-com" {}`
  cloudflare.NewRecord(stack, jsii.String("magic-example-com"), &cloudflare.RecordConfig{
    ZoneId:  jsii.String("cloudflare-zone-id"),
    Name:    jsii.String(fmt.Sprintf("magic-%s.example.com", id)),
    Type:    jsii.String("A"),
    Value:   nginxIngressIpAddress.Address(),
    Proxied: jsii.Bool(true),
  })

Above Go code roughly translates to,

variable "tenant_id" { type = string }

terraform {
  required_providers {
    google = {
      source = "hashicorp/google"
      version = "4.15.0"
    }
  }
}

terraform {
  backend "gcs" {
    bucket = "gcs-bucket-name"
    # this actually won't work in terraform
    # backend block doesn't allow interpolations
    prefix = "tenants/${var.tenant_id}"
  }
}

provider "google" { }

data "google_cloud_sql_instance" "cloud-sql-instance" {
  name = ""
}

resource "cloudflare_record" "magic-example-com" {
  name = "magic-${var.tenant_id}.example.com"
  # ...
}

How do you actually apply the module or stack you define in go?

You can do so with cdktf-cli and just run cdktf deploy. But we will need to provision the stack dynamically and we don’t want to “shell it out”,

func main() {
  tempDir, err := os.MkdirTemp("", "magic-instance-maker-")
  if err != nil {
    return nil, err
  }
  defer os.RemoveAll(tempDir)

  app := cdktf.NewApp(&cdktf.AppOptions{Outdir: jsii.String(tempDir)})
  sharedtenant.NewStack(app, name, cluster)
  app.Synth()
}

Now let’s try running go run main.go. Wait, it doesn’t do anything? The app.Synth() invocation only synthesizes the module defined in Go into a JSON file without actually applying it. The great thing about HCL is that it is mostly interchangeable with JSON. In fact, if you cd into the tempDir, you can run the usual terraform init and terraform apply commands to apply your terraform module. This is exactly what the cdktf deploy command is doing behind the scene, it simply runs the terraform command for you. Unfortunately, when it comes to applying the terraform module, we still have to fall back to using the terraform CLI.

We will utilize this nice wrapper hashicorp/terraform-exec to apply the synthesized Terraform module from Go.

import (
  "context"
  "log"
  "os"
  "path/filepath"

  jsii "github.com/aws/jsii-runtime-go"
  "github.com/hashicorp/go-version"
  "github.com/hashicorp/hc-install/product"
  "github.com/hashicorp/hc-install/releases"
  "github.com/hashicorp/terraform-cdk-go/cdktf"
  "github.com/hashicorp/terraform-exec/tfexec"
  tfjson "github.com/hashicorp/terraform-json"
)

func main() {
  tempDir, err := os.MkdirTemp("", "magic-instance-maker-")
  if err != nil {
    return nil, err
  }
  defer os.RemoveAll(tempDir)

  app := cdktf.NewApp(&cdktf.AppOptions{Outdir: jsii.String(tempDir)})
  NewStack(app, name, cluster)
  app.Synth()

  installer := &releases.ExactVersion{
    Product: product.Terraform,
    Version: version.Must(version.NewVersion("1.1.4")),
  }

  execPath, err := installer.Install(context.Background())
  if err != nil {
    log.Fatalf("error installing Terraform: %s", err)
  }

  workingDir := filepath.Join(tempDir, "stacks", name)
  tf, err := tfexec.NewTerraform(workingDir, execPath)
  if err != nil {
    log.Fatalf("error running NewTerraform: %s", err)
  }

  err = tf.Init(context.Background(), tfexec.Upgrade(true))
  if err != nil {
    log.Fatalf("error running Init: %s", err)
  }

  err = tf.Apply(context.Background())
  if err != nil {
    log.Fatalf("error running Apply: %s", err)
  }
}

Run go run main.go again, and all your resources should be live.

Thoughts

cdktf is a really cool project that allows us to actually “code” the infrastructure, and it provides a more convenient way to interact with Terraform programmatically. You can utilize the typical flow control or whatever convention you are already familar with, instead of being limited by Terraform’s own DSL (HCL).

Now, what’s the catch?

Performance Issues with cdktf get

We only have a few providers in cdktf.json and the command still takes a good chunk of time to finish. This might have something to do with compiling a lot of providers’ code on every run. Below is some unscientific benchmark:

On a maxed out M1 Max MacBook,

Generated go constructs in the output directory: generated
________________________________________________________
Executed in   75.17 secs    fish           external
   usr time   92.10 secs   63.00 micros   92.10 secs
   sys time   12.05 secs  863.00 micros   12.05 secs

Docker for Mac,

[cdktf-builder 6/6] RUN --mount=type=cache,target=/tmp/terraform-plugin-cache cdktf get  854.4s

We also tried caching providers, but it didn’t help. Perhaps this can be greatly improved when Hashicorp starts publishing pre-built providers for Go, or if we can maintain our own registry?

Performance Issues at Build Time

Disclaimer: by no means I am an expert in Go and there must be some optimization you can do to the compiler

Prior to introducing cdktf in our Go program, it takes somewhere around 15 seconds to build the binary during the docker image build. After adding cdktf, it takes over two minutes to build.

Moreover, if you want to build a linux/amd64 image, it takes over 15 minutes to compile! Of course, this is mostly due to the poor performance of qemu emulation.

Faster turnaround is key to development velocity and developers’ happiness :(

Unintuitive Usage of cdktf in Go

You may notice the usage of jsii everywhere in the module/stack we defined earlier, and it is required for the Go library to interact with the underlying node runtime.

Lack of Go Support

There is not much documentation for Go example. We are mostly relying on reading TypeScript examples and autocomplete of the generated providers’ code to write the module. Again, Go is the one of the cdktf supported languages that lack pre-built providers.

Final Words

Just write TypeScript!

cdktf is still an early project and the support for Go is still experimental. The idea is great, but I wouldn’t be comfortable using it in a real project just yet. The Go port definitely deserves more love :)

As I mentioned earlier, HCL and JSON are mostly interchangeable, so we can also handcraft the JSON to represent the Terraform module using any programming language or utilize the HCL parser.