SEANK.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 .:

 1package infra
 2
 3import (
 4	"encoding/json"
 5	"tool/file"
 6)
 7
 8command: write: {
 9	task: write: file.Create & {
10		filename: "config.tf.json"
11		contents: json.Indent(json.Marshal({
12			"provider":  provider
13			"resource":  resource
14			"terraform": terraform
15		}), "", "  ")
16	}
17}
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.

 1package infra
 2
 3resource: google_cloudbuild_trigger: trigger_a: {
 4  name: "trigger-a"
 5  filename: "cloudbuild.yaml"
 6  github: [{
 7    owner: "seankhliao"
 8    repo: "vanity"
 9    push: ["^main$"]
10  }]
11}

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

 1package infra
 2
 3_cloudbuild_trigger_github: [string]: #CloudBuildGithub
 4#CloudBuildGithub: {
 5	repo:     string
 6	filename: string | *"cloudbuild.yaml"
 7	push:     _ | *{branch: "^main$"}
 8}
 9
10resource: google_cloudbuild_trigger: { for _id, _cb in _cloudbuild_trigger_github {
11	"\(_id)": {
12		name:     _cb.repo
13		filename: _cb.filename
14		github: [{
15			owner: "seankhliao"
16			name:  _cb.repo
17			push: [_cb.push]
18		}]
19	}
20}}

and I use it like:

1package infra
2
3_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:

 1package infra
 2
 3#CloudBuildGithub: {
 4  filename?: string
 5}
 6
 7resource: google_cloudbuild_trigger: { for _id, _cb in _cloudbuild_trigger_github {
 8  "\(_id)": {
 9    # doesn't work, will fail with can't access field if it's not specified
10    filename?: _cb.filename
11
12    # necessary guard for every optional field
13    if _cb.filename != _|_ {
14      filename: _cb.filename
15    }
16  }
17}}