Configuring a humus service
humus packages are configured with plain Go values. They read no environment
variables for their settings, define no variable names and read no files on
your behalf; the few variables humus does read are
listed below. Every
value is sourced in your own Init, with whatever names and formats your
service already uses: an environment variable called READ_TIMEOUT or
MYSVC_HTTP_READ_TIMEOUT, a duration written 5s, a file of PEM, a flag. The
usual tool is bedrock’s
config package, and
this page uses it throughout.
A framework that defined variable names would have to teach them, keep them consistent and document them twice. With Go values, godoc is the only documentation of a package’s settings, and a service’s variables are whatever its team chooses.
The design behind everything on this page is in the configuration revision of Proposal: configuration for humus apps.
What a package with settings exports
Every humus package with settings exports the same three things:
- a
Settingsstruct, whose fields are plain values; DefaultSettings(), which returns the package’s defaults;- a check function,
Check<Field>, for each field that has a valid range.
A zero field means zero, never “use the default”. A package’s defaults are
the values DefaultSettings returns, and nothing else: a field left at zero
is checked, and used, as zero. This keeps zero meaningful, as it is for
net/http’s timeouts, where a zero ReadTimeout means no timeout.
The godoc of each field gives its default, its valid range and its meaning,
and names its check. A check is a func(T) error. Its error says what is
wrong, such as must not be negative, and does not name the field: whatever
reads the value adds the name.
The samples on this page use a stand-in, declared in the root package’s
tests in the shape every package gives it. The
telemetry package’s
Settings has the same shape, and Telemetry
sources it with the same pattern, in the App’s telemetry Builder rather than
in Init:
// Settings configures a Greeter. Every field is a plain value, and a field's
// zero value means zero, never "use the default".
type Settings struct {
// Greeting is what the Greeter says. Default "hello".
Greeting string
// ReadTimeout bounds reading a whole request. Default 5s. Zero means
// no timeout. Must not be negative; checked by CheckReadTimeout.
ReadTimeout time.Duration
// MaxConns is how many connections the Greeter serves at once.
// Default 100. Must be positive; checked by CheckMaxConns.
MaxConns int
}
// DefaultSettings returns the defaults of a Greeter.
func DefaultSettings() Settings {
return Settings{
Greeting: "hello",
ReadTimeout: 5 * time.Second,
MaxConns: 100,
}
}
// CheckReadTimeout reports why d is not a valid ReadTimeout, or nil if it
// is.
func CheckReadTimeout(d time.Duration) error {
if d < 0 {
return errors.New("must not be negative")
}
return nil
}
// CheckMaxConns reports why n is not a valid MaxConns, or nil if it is.
func CheckMaxConns(n int) error {
if n <= 0 {
return errors.New("must be positive")
}
return nil
}
In a real service these come from the package, as httpserver.Settings,
httpserver.DefaultSettings() and httpserver.CheckReadTimeout, and you
never copy a range out of godoc. Serving HTTP
sources httpserver.Settings this way.
Sourcing Settings in Init
Init reads the package’s Settings with
config.Struct.
Each field is one config.Value chain: where the value comes from, how it is
parsed, .Default with the field of DefaultSettings(), and then the
package’s check, chained with
.Validate:
func GreeterInit(ctx context.Context, s *humus.Setup) ([]humus.Service, error) {
d := DefaultSettings()
env := func(name string) config.Value[string] { return config.Env(s.Process.Environment, name) }
settings, err := config.Struct(func(r *config.Resolver) Settings {
return Settings{
// The file is read as part of the setting, so a missing file
// is a config error named after the variable.
Greeting: r.Get(env("GREETING_FILE").Parse(readFile(s.Host.FS)).Default(d.Greeting)),
ReadTimeout: r.Get(env("READ_TIMEOUT").Parse(time.ParseDuration).Default(d.ReadTimeout).Validate(CheckReadTimeout)),
MaxConns: r.Get(env("MAX_CONNS").Parse(strconv.Atoi).Default(d.MaxConns).Validate(CheckMaxConns)),
}
}).Read(ctx)
if err != nil {
return nil, err // exit 78: every bad variable, one line each
}
g, err := NewGreeter(settings, s.Process.Stdout)
if err != nil {
return nil, err
}
return []humus.Service{g}, nil
}
The check goes after .Default, and the order matters for two reasons:
- The default is checked too. A value that is not set is never checked, so
a check chained before
.Defaultwould let a bad default through. Chained after it, a bad default fails exactly as a bad variable does. - The error names the variable. A check in a field’s chain fails under
that value’s name, so the error says
READ_TIMEOUT, which is what an operator sets. A check on the whole struct could only name the field,ReadTimeout, which no operator sets.
config.Struct attempts every Get, even after one has failed, so a
misconfigured service reports all of its problems in one error rather than one
per restart. Read every value through the Process and the Host in Setup,
never through package os, so that the whole App runs in a test.
Reading a file
A setting that names a file reads the file inside the field’s chain. Here
GREETING_FILE is parsed by readFile, which opens the file through
s.Host.FS:
// readFile returns a parser for config.Value.Parse that reads the whole
// file a variable names, through fsys.
func readFile(fsys humus.FS) func(name string) (string, error) {
return func(name string) (string, error) {
f, err := fsys.Open(name)
if err != nil {
return "", err
}
defer func() { _ = f.Close() }()
b, err := io.ReadAll(f)
return string(b), err
}
}
Because the file is read as part of the setting, a missing or unreadable file
is a config error named after its variable, and it exits 78 with the rest.
humus.FS has no ReadFile: a helper this small is your code, not humus’s
API, and it stays unexported. The same goes for decoding: a package takes a
decoded value, such as a *tls.Config, never a path or a file’s contents, so
how a certificate is stored and encoded is yours to decide. A further
.Parse in the chain does the decoding.
How a bad value fails
A bad value renders as config <variable>: <cause>. With
READ_TIMEOUT=-5s and MAX_CONNS=lots, the App above logs both at ERROR
through its telemetry, in one record of two lines, and exits 78:
init: config READ_TIMEOUT: must not be negative
config MAX_CONNS: strconv.Atoi: parsing "lots": invalid syntax
The lifecycle wraps the error Init returns once, as init: <error>, and
logs it as it renders. config.Struct joins its failures with
errors.Join, which renders one line per failure, so only the first line
carries the init: prefix. Every line that names a bad variable starts
config , prefix or not, and that is the word to search for.
And with GREETING_FILE naming a file that does not exist:
init: config GREETING_FILE: openat etc/greeter/greeting.txt: no such file or directory
The cause is whatever the Host’s FS returned. This output is from the
example, on a humustest Host, whose FS opens files through an os.Root,
hence openat. On humus.OSHost() the same file fails with
open <path>: no such file or directory.
Every error a config.Value returns is a
*config.Error.
When one is in the chain of the error Init returns, the App exits 78
(humus.ExitConfig, EX_CONFIG from sysexits.h), which tells an operator
“fix the configuration” apart from “the service failed”.
errors.AsType[*config.Error] finds the first bad variable in the chain, which
is READ_TIMEOUT in the run above. A test that checks every bad variable
walks the joined error, whose Unwrap() []error returns one *config.Error
per variable, or compares the whole of the record the App logged. The
lifecycle’s exit codes list the rest.
Building Settings in code
config is a convenience, not a requirement. Settings can come from flags,
a YAML file, or plain code:
settings := DefaultSettings()
settings.ReadTimeout = -time.Second
settings.MaxConns = 0
_, err := NewGreeter(settings, io.Discard)
fmt.Println(err)
A Settings built this way never passes through a check in a config chain,
so the package’s constructor runs every check itself. It reports every bad
field in one error, naming each field rather than a variable:
ReadTimeout: must not be negative
MaxConns: must be positive
The constructor of the stand-in shows the shape:
func NewGreeter(s Settings, out io.Writer) (*Greeter, error) {
var errs []error
if err := CheckReadTimeout(s.ReadTimeout); err != nil {
errs = append(errs, fmt.Errorf("ReadTimeout: %w", err))
}
if err := CheckMaxConns(s.MaxConns); err != nil {
errs = append(errs, fmt.Errorf("MaxConns: %w", err))
}
if err := errors.Join(errs...); err != nil {
return nil, err
}
return &Greeter{settings: s, out: out}, nil
}
Its error is not a *config.Error, so an Init that returns it exits 1. A
service that sources its settings with config and chains each check never
reaches it: the bad value has already exited 78, named after its variable.
The variables humus reads
humus reads the environment in two places, and neither is a setting of a package:
The lifecycle reads its two variables,
HUMUS_SHUTDOWN_GRACE_PERIODandHUMUS_SHUTDOWN_DRAIN_DELAY.humus.App.Runreads them before it callsInit, with defaults fromApp’s own fields, and checks each after its default, as on this page:HUMUS_SHUTDOWN_GRACE_PERIODdefaults to the App’sShutdownGracePeriodfield:humus.Main’s 20s, or whatever an option, such as a runtime package’s defaults orhumus.WithShutdownGracePeriod, or an App literal set it to.HUMUS_SHUTDOWN_DRAIN_DELAY, when it is set, replaces the App’sDrain, whatever that is, withhumus.DrainDelayof its value, and adds a drain to an App that has none. When it is not set, the App’sDrainis the drain.
The lifecycle’s settings describes them.
jobreads the variables its propagator’s keys name. A job’s run joins the trace of the process that started it:job.Serviceextracts a trace context from the Process’s environment with the telemetry’s propagator, reading each of the propagator’s keys under the name OpenTelemetry’s environment-variable carriers give it. Withtelemetry.New’s propagator, those areTRACEPARENT,TRACESTATEandBAGGAGE; aBuilderwith a propagator of another kind, such as B3, changes them. When none is set, or the value is malformed, the run starts a new trace and nothing fails. Jobs describes them.
Apart from the lifecycle’s two, humus still defines no variable names. The
names job reads are OpenTelemetry’s: its specification names them, from
the propagator’s keys, so a process that starts a job sets the same
variables whatever language the job is written in. The telemetry package
reads no OTEL_* variable either: a Builder that wants them maps them onto
telemetry.Settings itself, as Telemetry
shows.
Logging and secrets
humus logs no settings, and it does not redact any. Whether to log the values
a service started with, which ones, and how to hide a secret among them are
your decisions. Init is given the same Process and Host that humus has,
so it can log anything it wants, environment variables included, to
s.Process.Stderr or to a logger of its own.
What humus does log is the error Init returns, through the App’s telemetry,
and a parse error often quotes the value it could not parse: parsing "lots"
above. A malformed secret, such as a token in the wrong format or a key that
does not decode, would be logged the same way. Keeping a secret out of that error is
part of the same choice: give the secret’s chain a parser of your own whose
error says what is wrong without quoting the input.
Testing it
Settings are tested by running the whole App with
humustest.Start:
humustest.WithEnv sets the variables, and a
humustest Host holds the
files the settings name. One of the root package’s
examples
runs the App above this way:
h := humustest.NewHost(t, humustest.WithFiles(files))
tel := humustest.NewTelemetry()
p, status := humustest.Start(t, GreeterInit,
humustest.WithApp(humus.WithHost(h.Host()), humus.WithTelemetry(tel.Builder())),
humustest.WithEnv(env),
)
st := <-status
humus.WithHost replaces the Host Start builds with one the example made
with its files, and humus.WithTelemetry passes humustest.Telemetry’s
Builder, so the example prints the errors the App logged from the
recorder’s Logs(). Both are humus’s own options, passed with
humustest.WithApp as main passes them to humus.Main.
The App runs with Main’s defaults, so its grace period is Main’s 20s, as
in production, and a HUMUS_SHUTDOWN_GRACE_PERIOD or
HUMUS_SHUTDOWN_DRAIN_DELAY the test passes in WithEnv overrides it, as an
operator’s would. WithEnv given twice keeps only the last map, so a test
that adds to a base environment merges the maps itself, as
Testing a humus App shows.
A relative name given to the humustest Host’s FS is relative to the test’s
temp directory, and an absolute one, from h.Path, must be inside it, so a
setting naming a file never reaches the real disk.
Testing a humus App covers the Host in full.