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.
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.
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}
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
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"
cue has 2 ways of representing something as optional input:
foo?: string
*
: foo: string | *""
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}}