Typed configuration for Go services, read from the environment and nowhere else
(12factor III). Each library, each package and the
application itself declares the config it needs as a plain struct; confmaker
fills it, validates it, and hands it to Uber Fx.
- Defaults in code, where the library's tests and callers see the same values
a deployment starts from - not in a tag only the loader can read.
- A misspelled variable fails the start, with a suggestion, instead of
quietly leaving a field at its default.
- What a declaration gets wrong is refused when it is bound - on the first
start, whether or not the environment happens to set that variable.
- A manifest of every variable the application reads, so a
.env.example or
a config map is generated from the types themselves.
- Two dependencies: Fx and a secret type.
go get github.com/uchaloop/confmaker
Quick start
A library declares what it needs and reads nothing:
type Config struct {
Host string `env:"HOST,notEmpty"`
Timeout time.Duration `env:"TIMEOUT"`
Password secret.Secret `env:"PASSWORD"`
}
func (c *Config) SetDefaults() { c.Timeout = 30 * time.Second }
func (c Config) Validate() error {
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be positive, got %s", c.Timeout)
}
return nil
}
The application names the instance, which gives the environment prefix:
fx.New(
confx.Module(),
confx.Provide[store.Config]("store"), // STORE_HOST, STORE_TIMEOUT, ...
confx.ProvideNamed[store.Config]("replica"),// REPLICA_HOST, REPLICA_TIMEOUT, ...
confx.Provide[app.Config]("app"), // APP_SHUTDOWN_WAIT, ...
store.Module,
)
Provide gives the container an untagged value, the single default instance;
ProvideNamed gives it a value tagged name:"replica", so a repository can ask
for either. Nothing else in the process reads the environment.
What it catches
confx.Module() compares the environment against what the Provide calls
declare:
unknown configuration variable "STORE_HSOT" (did you mean "STORE_HOST"?)
It has the whole application in view, so it also refuses two instances that
would read one prefix, or one variable, and could not then be told apart.
Everything a declaration itself can get wrong is refused before any value is
read - a default written in a tag, an option the tag does not define, two fields
claiming one variable, envPrefix on anything but a struct nested by value, a
secret in a slice or a map, a config nested through a pointer or a slice, a field
of a type that cannot be read from text.
Every problem is reported at once, one per line, each naming the config it
belongs to:
config "store": required variable "STORE_HOST" is not set
config "store": timeout must be positive
What it prints
confx.Module(confx.WithDump(os.Stdout)) writes every variable the application
reads, the value it carries and where that value comes from:
INSTANCE VARIABLE TYPE VALUE SOURCE
store STORE_HOST string store:9000 env
store STORE_PASSWORD secret.Secret (set) env
store STORE_TIMEOUT time.Duration 30s default
A secret is never printed: not here, not in the manifest, not in the error for a
value that would not parse.
What it generates
confx.Manifest resolves the same list without building an application:
variables, err := confx.Manifest[store.Config]("store")
STORE_HOST=
STORE_TIMEOUT=30s
STORE_PASSWORD=
Each entry carries its Go type, whether it is required, whether it holds a
secret, and its default rendered as text the variable could carry back. It is the
same traversal that fills the config, so a variable it lists is exactly a
variable the config reads.
Reporting every problem
A Validate that returns on its first problem reports one problem.
validate accumulates them, so a config
answers in one pass and a deployment is fixed in one rollout:
import "github.com/uchaloop/validate"
func (c Config) Validate() error {
var errs validate.Errors
errs.Require(c.Timeout > 0, "timeout must be positive, got %s", c.Timeout)
errs.Require(len(c.Host) != 0, "host is required")
return errs.Err()
}
It is a module of its own and depends on nothing outside the standard library,
this one included, so a library declaring a config validates it without taking
the loader on as a dependency.
Documentation
The tags, the rules and the reasons behind them are in the package documentation:
pkg.go.dev/github.com/uchaloop/confmaker/confx.
Acknowledgements
I am grateful to the authors of Uber Fx, and to
the authors of env, whose tag vocabulary this
library follows and whose implementation it learned from.
License
MIT