Lightweight and extendable configuration management.
uConfig is a lightweight and extendable configuration management library. Every aspect of configuration is provided through a plugin or extension, which means you can have any combination of flags, environment variables, defaults, secret providers, Kubernetes Downward API, or even config file watching, and any combination of configuration files and formats including json, toml, cue, or just about anything you want, and only what you want, through plugins and extensions.
To use uConfig, you simply define the configuration struct for your services and application, and uConfig does all the heavy-lifting. It just works.
package database
// Config holds the database configurations.
type Config struct {
Address string `default:"localhost"`
Port string `default:"28015"`
Database string `default:"my-project"`
}package redis
// Config describes the requirement for redis client.
type Config struct {
Address string `default:"redis-master"`
Port string `default:"6379"`
Password string `secret:""`
DB int `default:"0"`
Expire time.Duration `default:"5s"`
}package main
import (
"encoding/json"
"fmt"
"os"
"github.com/omeid/uconfig"
"github.com/omeid/uconfig/plugins/file"
"github.com/omeid/uconfig/examples/sample/database"
"github.com/omeid/uconfig/examples/sample/redis"
)
// Config is our application config.
type Config struct {
// yes you can have slices.
Hosts []string `default:"localhost,localhost.local" usage:"the ip or domains to bind to"`
// and maps too.
RegionTimeouts map[string]time.Duration `default:"us:500ms,eu:1s,ap:1200ms" usage:"per-region request timeouts"`
Redis redis.Config
Database database.Config
// the flags plugin allows capturing a single Command after the flags.
// so you can run myprogram -flag=value -s -blah=bleh stop|start|stop and so on.
Mode string `default:"start" flag:",command" usage:"run|start|stop"`
}
var files = uconfig.Files{
{Path: file.Workspace(".demo-app/config.json"), Unmarshal: json.Unmarshal, Optional: true},
{Path: file.Relative("config.json"), Unmarshal: json.Unmarshal, Optional: true},
}
var conf = uconfig.Classic[Config](files)
func main() {
conf := conf.Run()
// use conf as you please.
// let's pretty print it as JSON for example:
configAsJson, err := json.MarshalIndent(conf, "", " ")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Print(string(configAsJson))
}Now lets run our program:
$ go run main.go -h
Usage:
main [flags] [command]
Configurations:
FIELD FLAG ENV DEFAULT USAGE
----- ----- ----- ------- -----
Hosts -hosts HOSTS localhost,localhost.local the ip or domains to bind to
Redis.Address -redis-address REDIS_ADDRESS redis-master
Redis.Port -redis-port REDIS_PORT 6379
Redis.Password -redis-password REDIS_PASSWORD
Redis.DB -redis-db REDIS_DB 0
Redis.Expire -redis-expire REDIS_EXPIRE 5s
Database.Address -database-address DATABASE_ADDRESS localhost
Database.Port -database-port SERVICE_PORT 28015
Database.Database -database-database DB my-project
Mode [command] MODE start run|start|stop
Configuration Files:
workspace: .demo-app/config.json
relative: config.json
$ go run main.go {
"Hosts": [
"localhost",
"localhost.local"
],
"Redis": {
"Address": "redis-master",
"Port": "6379",
"Password": "",
"DB": 0,
"Expire": 5000000000
},
"Database": {
"Address": "localhost",
"Port": "28015",
"Database": "my-project"
},
"Mode": "start"
}
uConfig supports all basic types, time.Duration, slices, maps, and any other type through encoding.TextUnmarshaler interface. Maps use key:value,key:value syntax from flags and env vars (e.g. -my-map "a:1,b:2").
See the flat view package for details.
Config file paths are specified using file.Path constructors. Paths are resolved lazily at parse time, not at declaration time, making them safe to use in var declarations and compatible with live reload via uconfig-watchfiles.
| Constructor | Usage output |
|---|---|
file.Absolute("/etc/app/config.json") |
absolute: /etc/app/config.json |
file.Relative("config.json") |
relative: config.json |
file.Workspace(".myapp/config") |
workspace: .myapp/config |
The name passed to each constructor is shown in the -h usage output as-is, regardless of what the path resolves to on disk.
var files = uconfig.Files{
{Path: file.Workspace(".myapp/config.json"), Unmarshal: json.Unmarshal, Optional: true},
{Path: file.Absolute("/etc/myapp/config.json"), Unmarshal: json.Unmarshal, Optional: true},
{Path: file.Relative("config.json"), Unmarshal: json.Unmarshal, Optional: true},
}Sometimes you might want to use a different env var, or flag name for backwards compatibility or other reasons, you have two options.
- uconfig tag
You can change the name of a field as seen by uconfig.
Please note that this tag only works for Visitor plugins (flags, env, anything flat) and for Walker plugins (file, stream, et al) you will need to use encoder specific tags like json:"field_name" and so on.
- Plugin specific tags
Most plugins support controlling the field name as seen by that specific plugin. For example env:"DB_NAME".
For both type of tags, you can prefix them with . to rename the field only at the struct level.
See the Service.Port and DB_NAME examples below.
package database
// Config holds the database configurations.
type Database struct {
Address string `default:"localhost"`
Port string `default:"28015" uconfig:".Service.Port"` // field level rename.
Database string `default:"my-project" env:"DB_NAME" flag:"main-db-name"` // plugin specific rename.
}package main
// Config is our application config.
type Config struct {
// yes you can have slices.
Hosts []string `default:"localhost,localhost.local"`
Redis redis.Config
Database database.Config
}Which should give you the following settings:
$ go run main.go -h
Usage:
main [flags] [command]
Configurations:
FIELD FLAG ENV DEFAULT USAGE
----- ----- ----- ------- -----
Hosts -hosts HOSTS localhost,localhost.local the ip or domains to bind to
Redis.Address -redis-address REDIS_ADDRESS redis-master
Redis.Port -redis-port REDIS_PORT 6379
Redis.Password -redis-password REDIS_PASSWORD
Redis.DB -redis-db REDIS_DB 0
Redis.Expire -redis-expire REDIS_EXPIRE 5s
Database.Address -database-address DATABASE_ADDRESS localhost
Database.Database -main-db-db DB_NAME my-project
Database.Service.Port -database-service-port DATABASE_SERVICE_PORT 28015For file based plugins, you will need to use the appropriate tags as used by your encoder of choice. For example:
package users
// Config holds the database configurations.
type Config struct {
Host string `json:"bind_addr"`
}The secret provider allows you to grab the value of a config from anywhere you want. You simply need to implement the func(name string) (value string) function and pass it to the secrets plugin.
Unlike most other plugins, secret requires explicit secret:"" tag, this is because only specific config values like passwords and api keys come from a secret provider, compared to the rest of the config which can be set in various ways.
package main
import (
"encoding/json"
"fmt"
"github.com/omeid/uconfig"
"github.com/omeid/uconfig/plugins/secret"
"github.com/omeid/uconfig/examples/secrets/secretsource"
)
// Creds is an example of a config struct that uses secret values.
type Creds struct {
// by default, secret plugin will generate a name that is identical
// to env plugin, SCREAM_SNAKE_CASE, so in this case it will be
// APIKEY however, following the standard uConfig nesting rules
// in Config struct below, it becomes CREDS_APIKEY.
APIKey string `secret:""`
// or you can provide your own name, which will not be impacted
// by nesting or the field name.
APIToken string `secret:"API_TOKEN"`
}
type Config struct {
Creds Creds
}
var secrets = secret.New(func(name string) (string, error) {
// you're free to grab the secret based on the name from wherever
// you please, aws secrets-manager, hashicorp vault, or wherever.
value, ok := secretsource.Get(name)
if !ok {
return "", secret.ErrSecretNotFound
}
return value, nil
})
func main() {
// then you can use the secretPlugin with uConfig like any other plugin.
// Lucky, uconfig.Classic allows passing more plugins, which means
// you can simply do the following for flags, envs, files, and secrets!
conf := uconfig.Classic[Config](nil, secrets).Run()
fmt.Printf("we got an API Key: %s\n", conf.Creds.APIKey)
}For tests, you may consider the Must function to set the defaults, like so
package something
import (
"testing"
"github.com/omeid/uconfig"
"github.com/omeid/uconfig/plugins/defaults"
)
func TestSomething(t *testing.T) {
// It will panic on error
conf := uconfig.Must[Conf](defaults.New())
// Use your conf as you please.
}See the Classic source for how to compose plugins. For more details, see the godoc.
uConfig provides a plugin mechanism for adding new sources of configuration. There are three kinds of plugins: Visitors, Walkers, and Extensions.
To implement your own, see the examples.
Visitors get a flat view of the configuration struct, which is a flat view of the structs regardless of nesting level, for more details see the flat package documentation.
Plugins that load the configurations from flat structures (e.g flags, environment variables, default tags) are good candidates for this type of plugin. See env plugin for an example.
Walkers are used for configuration plugins that take the whole config struct and unmarshal the underlying content into the config struct. Plugins that load the configuration from files are good candidates for this.
See file plugin for an example.
Extensions receive the full plugin list via Extend([]Plugin) error. Like all plugins, they are set up in registration order -- place them after any plugins they need to inspect. For example, uconfig-watchfiles must come after file plugins so that paths are resolved by the time Extend is called. Classic handles this naturally since user plugins are registered after file plugins.
type Extension interface {
Plugin
Extend([]Plugin) error
}Updater is an optional interface that any plugin type (Walker, Visitor, or Extension) can additionally implement. It signals to Watch that the plugin's backing source has changed and the config should be re-parsed.
type Updater interface {
Updated(ctx context.Context) bool
}Updated blocks until the source changes or the context is cancelled. Returns true if the source changed, false otherwise. Watch launches a goroutine per Updater and re-parses when any of them returns true.
This is how uconfig-watchfiles triggers reloads on file changes, but any plugin can participate. For example, a secrets plugin could implement Updater to re-parse when a secret is rotated.
For live config file watching and reload, see uconfig-watchfiles. Add watchfiles.New() to your plugins and use Watch instead of Run:
import watchfiles "github.com/omeid/uconfig-watchfiles"
conf := uconfig.Classic[Config](files, watchfiles.New())
conf.Watch(ctx, func(ctx context.Context, c *Config) error {
// called on initial parse and every file change.
<-ctx.Done()
return nil
})| Plugin | Type | Description |
|---|---|---|
| defaults | Visitor | Sets default values from default struct tags |
| env | Visitor | Reads environment variables |
| flag | Visitor | Command-line flags with -h / --help support |
| file | Walker | Loads config from files (JSON, TOML, etc.) with lazy path resolution via file.Absolute, file.Relative, and file.Workspace |
| secret | Visitor | Loads secrets from external providers |
| Package | Description |
|---|---|
| uconfig-cue | CUE file support |
| uconfig-dapi | Kubernetes Downward API support |
| Package | Description |
|---|---|
| uconfig-watchfiles | Live config reload via fsnotify file watching |
| uconfig-validator | go-playground/validator integration |