terraform with CUE

using a better config language?

SEAN K.H. LIAO

terraform with CUE

using a better config language?

terraforming

In the morning, I created a bunch of resources in the GCP Console as part of splitting up my monorepo. I got quite annoyed with creating a lot of very similar resources. The next day, I thought, I should have managed them with terraform.

But terraform uses HCL which isn't a very flexible language. Pretty much the only way you can deduplicate things is with modules, but they're clunky and not very composable.

CUE to the rescue

Terraform can also take its config in json. And there are many tools to generate json. CUE looks to be the most interesting one around, values are iteratively constrained/refined until they are concrete.

command

So first a utility user defined command that will write out our generated config. We can call it with cue write .:

package infra

import (
	"encoding/json"
	"tool/file"
)

command: write: {
	task: write: file.Create & {
		filename: "config.tf.json"
		contents: json.Indent(json.Marshal({
			"provider":  provider
			"resource":  resource
			"terraform": terraform
		}), "", "  ")
	}
}
example

So say we want a Cloud Build Trigger. We look at the terraform provider docs and translate that into cue.

Terraform expects resoorces to be placed in resources.$resource_type.$instance_name, and HCL blocks are actually just lists.

package infra

resource: google_cloudbuild_trigger: trigger_a: {
  name: "trigger-a"
  filename: "cloudbuild.yaml"
  github: [{
    owner: "seankhliao"
    repo: "vanity"
    push: ["^main$"]
  }]
}

This in and of itself might not be very DRY, but we can do better

abstractions

What I have is the following. Our cloudbuild resources are all in _cloudbuild_trigger_github, It has a few fields with inputs and our actual resource is generated by expanding the given resource with a few defaults

package infra

_cloudbuild_trigger_github: [string]: #CloudBuildGithub
#CloudBuildGithub: {
	repo:     string
	filename: string | *"cloudbuild.yaml"
	push:     _ | *{branch: "^main$"}
}

resource: google_cloudbuild_trigger: { for _id, _cb in _cloudbuild_trigger_github {
	"\(_id)": {
		name:     _cb.repo
		filename: _cb.filename
		github: [{
			owner: "seankhliao"
			name:  _cb.repo
			push: [_cb.push]
		}]
	}
}}

and I use it like:

package infra

_cloudbuild_trigger_github: trigger_a: repo: "vanity"

optional vs default

cue has 2 ways of representing something as optional input:

If you're building abstractions via intermmediate objects like I do above, you'll find that true optional fields aren't very ergonomic, there's no easy way to pass along the optionality:

package infra

#CloudBuildGithub: {
  filename?: string
}

resource: google_cloudbuild_trigger: { for _id, _cb in _cloudbuild_trigger_github {
  "\(_id)": {
    # doesn't work, will fail with can't access field if it's not specified
    filename?: _cb.filename

    # necessary guard for every optional field
    if _cb.filename != _|_ {
      filename: _cb.filename
    }
  }
}}