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 .
:
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
}), "", " ")
}
}
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
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"
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:
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
}
}
}}