Featured image of post Wire it, don't configure it

Wire it, don't configure it

My generator could offer twenty-six config adapters, and the wizard's way of asking about them was all wrong. So the generator stopped asking, and the user tells the tool instead.

Where does a tool’s configuration live? For a tool generated by go-tool-base it’s always been a file you point it at with --config, and for plenty of tools that’s all they’ll ever need. But configuration has a habit of getting more involved depending on where a thing is run. One team keeps shared settings in Consul, another keeps secrets in Vault, a third has everything in AWS Parameter Store, and some platform or other drops a .env file in /etc and expects you to read it.

The estate has adapters for all of that, twenty-six of them in the go/config family (it’s a big family). And until recently a generated project couldn’t reach any of them.

Two awkward facts

When I came to bring them into the generator, the brief I gave the session was to work out how the adapters would manifest in a generated project “safely and most importantly securely”. Two facts got in the way, and the spec (0204) put them plainly.

The first is that most of these adapters need a client the framework can’t build for you. A chat provider is a blank import and a name in config. An S3 config store is a blank import and an authenticated client, with a region, a profile, a credential chain and all the baggage that comes with it.

The second is what worried me. A config source is itself configured, and whatever says where Consul lives can’t come from Consul. Those pointers are every bit as sensitive as a token. A repository that sets config.sources.consul.address redirects all the keys the tool reads after that, not just one of them!

The stub I nearly shipped

The first version of the design, which I approved, had the generator write a little stub for each source, a file in the generated project with a comment along the lines of // TODO: build your Vault client here for the author to fill in. It’s a common enough shape for a generator, and it seemed perfectly reasonable at the time.

Before building it I wanted a design document tracing how configuration would flow through a generated tool, and where it touched generated code. That paid for itself almost straight away… it showed the stub couldn’t work: a file the generator owns is rewritten on every regenerate, a file the author owns has their code in it, and one file can’t be both.

The obvious repair was to split it, a generated file plus some seeded files the author owned. I went the other way. The generator does the wiring, and the user must run an init command to fill in the config for each source they’ve enabled. The generator generates the dependencies and the wiring, the user configures it separately, and the connection details come out of the project’s manifest altogether.

Pre-wired for a tow bar

You know how a new car can come pre-wired for a tow bar? The factory runs the loom and fits the socket, so the car’s ready to tow, but what you hitch to it (a caravan, a bike rack, your neighbour’s trailer) is your business, and the factory doesn’t need to know.

That’s the split now. The author decides what the tool can read. The generator turns that into a file of blank imports, generated and never edited, which links the code for each kind of source it declares. This is all of it for a tool declaring a Consul slot, a Vault slot and two fixed files, reading TOML as well as YAML:

// Code generated by gtb. DO NOT EDIT.

// Links the config formats this tool reads beyond YAML and the config
// source kinds it declares, one per line. Generated from properties.config.formats
// and properties.config.sources in .gtb/manifest.yaml; change those and regenerate.
package main

import (
	_ "gitlab.com/phpboyscout/go-tool-base/pkg/config/formats/toml"
	_ "gitlab.com/phpboyscout/go-tool-base/pkg/config/sources/consul"
	_ "gitlab.com/phpboyscout/go-tool-base/pkg/config/sources/file"
	_ "gitlab.com/phpboyscout/go-tool-base/pkg/config/sources/vault"
)

Alongside it the root command declares each source as a named slot with its place in the order of precedence (defaults lowest, flags highest, the sources in between), which I was insistent on: the author has to be able to say which source wins.

Each user then decides where a source connects. For each declared slot the tool grows a command, <tool> init config <name>, that asks for that slot’s settings and writes them into the user’s own config file under config.sources.<name>. For a Vault slot that’s an address, a mount and a path, and the token comes from the usual places. Run the tool before you’ve configured a required slot and it tells you what to do about it:

ERROR failed to load configuration: "team" (consul): config source not configured
  hints="run `moo init config team`, or set config.sources.team in your config file or the tool's defaults"

(The original name was init source <name>, which I changed on sight. “Source” was the spec’s word for a slot, and it means very little to someone reading --help.)

A clone can’t rewire the car

Then there’s the second awkward fact. A project-local config file, the kind that lives in a repository you’ve cloned, can’t set config.sources any more, and that holds even when you’ve explicitly trusted the project. It’s the only key in the tool where trust doesn’t help. In the code:

// configSourcesKey is the subtree that says where configuration comes from
// (spec 0204 D5). A project file may never set it, trusted or not.
const configSourcesKey = "config.sources"

The test that holds it has a project file pointing a Consul slot at https://consul.attacker.example, which is about as subtle as a brick. Trusting a repository to set its own log level is a much smaller act than trusting it to choose your secret store, and the two shouldn’t share a switch. The ordering mattered too: that rule landed hours before the first config source could exist, so a clone never had a window to point a tool at one.

Help text for someone who’s never seen the code

The wizard had its own problem. With each source now a page of its own, there were a lot of pages, and very little on them explained why you’d choose anything. When I went through it, one note told me a source’s settings were “these keys, under config.sources.file”, followed by the single word path. Path to what?

I had to look at it as if I had no idea what the generator would produce, because that’s who’s reading it. In the end I wrote the text myself, and what ships now is close to my version: config isn’t set here, this only wires in the adapter, each user runs init config <name>, here’s what it writes and where, and here’s how to give everyone a default instead. If the tool has no init command at all, the note says so and sends you to the defaults file. The format list stopped showing which Go packages each format would import (noise, to anyone choosing a format) and says whether the format can be written back.

The one that’s waiting

Thirteen kinds of source made it through. The fourteenth, filekv, is on hold. It was built for reading a Kubernetes ConfigMap or Secret mounted as a directory, and I suspect the right pattern for that is to walk the directory and attach each file as a layer of its own, in an order the author chooses, instead of folding the whole directory into one. That’s an open question for go/config first, so for now the generator refuses it, with a test to prove it does. I’d much rather that than ship it the wrong way round and have to take it back.

What the factory knows

So the generator doesn’t ask for an address, a region, a prefix or a token any more. It wires in what the author says the tool can read and leaves the connecting to whoever runs it, in their own file, out of reach of anything they’ve cloned. The factory fits the socket, and what you tow is your business.

Built with Hugo · Theme Stack designed by Jimmy