# tinyconf > Go config structs filled from a chain of drivers, every package owns its part of the config and gets its changes without restart tinyconf fills Go config structs from a chain of drivers by struct tags: defaults, YAML, JSON, TOML, INI, properties, environment variables, flags, Consul, Vault and etcd. The next driver overrides the previous one. Packages parse and subscribe to their own parts of the config, reloads create new versions of the config. The module path is github.com/insei/tinyconf/v2. # Start # tinyconf **tinyconf** is a simple and universal library for parsing configs in Go. It fills your config structs with values from *drivers*: default values from struct tags, YAML, JSON, TOML, INI and .properties files, environment variables, command line flags, Consul KV, HashiCorp Vault, etcd. Every driver is a separate small package, and you can add your own. One-line ideology *The config is a plain Go struct. Where the values come from is described by struct tags and by the list of drivers, the last driver wins.* ## Install ```bash go get github.com/insei/tinyconf/v2@latest ``` Minimum Go version is **1.19**. ## Quick start ```go package main import ( "fmt" "time" "github.com/insei/tinyconf/v2" "github.com/insei/tinyconf/v2/drivers/env" "github.com/insei/tinyconf/v2/drivers/tag" "github.com/insei/tinyconf/v2/drivers/yaml" "github.com/insei/tinyconf/v2/logger" ) type HTTP struct { Host string `initial:"localhost" yaml:"host" env:"HTTP_HOST" doc:"http host"` Port int `initial:"8080" yaml:"port" env:"HTTP_PORT" doc:"http port"` Timeout time.Duration `initial:"30s" yaml:"timeout" env:"HTTP_TIMEOUT" doc:"http timeout"` } type Config struct { HTTP HTTP `yaml:"http"` Hosts []string `initial:"a,b" yaml:"hosts" env:"HOSTS" doc:"allowed hosts"` Password string `yaml:"password" env:"PASSWORD" hidden:"true"` } func main() { tagDriver, err := tag.New("initial") // defaults from the `initial` tag if err != nil { panic(err) } yamlDriver, err := yaml.New("config.yaml") if err != nil { panic(err) } envDriver, err := env.New() if err != nil { panic(err) } // the next driver overrides values of the previous one: // defaults < config.yaml < environment variables config, err := tinyconf.New( tinyconf.WithLogger(logger.NewFmtLogger(logger.INFO)), tinyconf.WithDriver(tagDriver), tinyconf.WithDriver(yamlDriver), tinyconf.WithDriver(envDriver), ) if err != nil { panic(err) } c := &Config{} if err = config.Register(c); err != nil { panic(err) } if err = config.Parse(context.Background(), c); err != nil { panic(err) } fmt.Printf("%+v\n", *c) } ``` With this `config.yaml` ```yaml http: port: 9090 password: secret ``` and `HTTP_HOST=example.com` in the environment the program prints ```text {HTTP:{Host:example.com Port:9090 Timeout:30s} Hosts:[a b] Password:secret} ``` `Host` came from the environment, `Port` and `Password` from the file, `Timeout` and `Hosts` from the defaults. ## What to read next - **[Why tinyconf? →](https://insei.github.io/tinyconf/2.2/why-tinyconf/index.md)** Where it fits, what it gives up, and how it compares to viper, koanf, cleanenv and others. - **[Use cases →](https://insei.github.io/tinyconf/2.2/use-cases/index.md)** Recipes for typical tasks: environment only, file with overrides, files per environment, secrets, containers, command line tools, optional sections, reloading, tests. - **[Features →](https://insei.github.io/tinyconf/2.2/features/index.md)** The manager, struct tags, supported types, nested structs and sub configs, required fields, value sources, reloading, documentation generation, logging. - **[Drivers →](https://insei.github.io/tinyconf/2.2/drivers/index.md)** Bundled drivers and how to write your own. - **[API reference →](https://pkg.go.dev/github.com/insei/tinyconf/v2)** Autogenerated on pkg.go.dev. - **[Repository →](https://github.com/Insei/tinyconf)** Source, issue tracker, pull requests. # Why tinyconf? **tinyconf is for Go services built from independent packages, whose config comes from several sources and changes without restart.** Every package declares its own config struct, gets its values and its updates, and knows nothing about the config of the whole application. Where a value came from is always known. ## Who it is for - **Modular services.** An HTTP server, a database client, a queue consumer, a cache — each package has its own config struct and works with it alone. Adding a package does not touch a central config file of the code. - **Services that change config at runtime.** A ConfigMap in Kubernetes, keys in Consul or etcd, a rotated secret in Vault: the change reaches only the packages whose part has changed, without restart and without locks in the code that reads the config. - **Services with many sources.** Defaults, a file, the environment, flags and a remote store at the same time, with one explicit priority order — and an answer to "why is this value here?" when something goes wrong in production. - **Teams that do not want to maintain config templates by hand.** `.env` examples, YAML templates, flags help and the commands for Consul, Vault and etcd are generated from the same structs. ## What you get ### Every package owns its part of the config The application registers the whole config once. A package parses, reads and subscribes to its own struct type — no string keys, no `Unmarshal` of a subtree, no knowledge of where its part lives in the whole config. ```go // in the http package tinyconf.Current[HTTP](config).Port // the current version: an atomic read, no allocations tinyconf.Subscribe(ctx, config, func(prev, next *HTTP) { server.Restart(next) // called only when HTTP has changed }) ``` Other libraries give one callback for any change of the whole config (viper), or a callback per provider (koanf), and leave it to you to find out what has changed and who should know. See [Sub configs](https://insei.github.io/tinyconf/2.2/features/structs/#sub-configs) and [Reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md). ### Reloads that are safe by design A reload builds a new version of the config from the defaults. The version that your code holds is never modified, so readers need no locks and never see a half-applied config. A broken file or an unavailable store keeps the last good config; subscribers are called only with a config without errors, and only for their changed part. `ReloadStatus()` reports the last error and the drivers that stopped watching, for health checks. Files are watched by the events of the file system, Consul and etcd notify about changes, Vault is polled. ### One struct, one chain of drivers The config is your struct, every field says where its value lives: ```go Port int `initial:"8080" yaml:"port" env:"HTTP_PORT" flag:"http-port" required:"true" doc:"port of the server"` ``` Drivers are applied in the order of addition, the next one overrides the previous one field by field. Eleven drivers out of the box — defaults from tags, YAML, JSON, TOML, INI, properties, environment, flags, Consul, Vault, etcd — and [any file format](https://insei.github.io/tinyconf/2.2/drivers/format/index.md) by a decoder. `time.Duration`, `url.URL`, `TextUnmarshaler`, slices, maps, structs in lists and pointers behave the same in every driver: a conformance test checks it. ### You always know where a value came from `Sources(conf)` returns the driver and the key of the final value of every field. The debug log shows the initial value of every field, every override and every change after reloads. Secrets are masked everywhere: `hidden:"true"` in the logs and templates, the `tinyconf.Secret` type also in `fmt`, JSON and the logs of the application. See [Value sources](https://insei.github.io/tinyconf/2.2/features/sources/index.md) and [Logging](https://insei.github.io/tinyconf/2.2/features/logging/index.md). ### Templates of the config from the code `GenDoc` renders the registered configs with the current values and the `doc` descriptions in the format of every driver: a YAML or TOML file, a `.env` file, flags help, `consul kv put`, `vault kv put` and `etcdctl put` commands. See [Documentation generation](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md). ### Light, fast and tested - No SDKs: Consul, Vault and etcd are used through their HTTP APIs, zap is a separate module. The core depends only on the YAML and TOML parsers, fsnotify and two small libraries of the author, fmap and cast. - `Current` takes tens of nanoseconds without allocations, `Parse` of a typical config takes microseconds, so the config can be read on every request. - 100% test coverage, race detector, fuzz tests of the parsers, end-to-end tests with real Consul, Vault and etcd, Go 1.19 and the latest Go in CI, a benchmark diff in every pull request. ### Ready for AI agents The repository has a [skill for agents](https://insei.github.io/tinyconf/2.2/ai-agents/index.md) whose programs are built in CI, and the documentation is published as `llms.txt` and `llms-full.txt` for every version, so an agent writes the code for the current API, not for the one it remembers. ## When another library fits better - You only read environment variables into a struct — [caarlos0/env](https://github.com/caarlos0/env) or [sethvargo/go-envconfig](https://github.com/sethvargo/go-envconfig) have no dependencies at all. - You work with config as dynamic keys rather than a struct, or need many remote providers (S3, AWS Parameter Store and AppConfig, Azure Key Vault, NATS) out of the box — pick [koanf](https://github.com/knadh/koanf). - You are deep in the cobra / pflag ecosystem — [viper](https://github.com/spf13/viper) integrates with them directly. - You need Go older than 1.19. ## Feature matrix Checked against the current documentation of the libraries on 2026-10-16. | | tinyconf | [viper](https://github.com/spf13/viper) | [koanf](https://github.com/knadh/koanf) | [cleanenv](https://github.com/ilyakaznacheev/cleanenv) | [aconfig](https://github.com/cristalhq/aconfig) | [confita](https://github.com/heetch/confita) | [caarlos0/env](https://github.com/caarlos0/env) | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------- | -------------------------------------------- | ----------------------------------------------- | | **Model** | struct + tags | key-value + `Unmarshal` | key-value + `Unmarshal` | struct + tags | struct + tags | struct + tags | struct + tags | | **Defaults in tags** | ✓ (tag driver) | ✗ (`SetDefault`) | ✗ (provider) | ✓ | ✓ | ✗ (pre-filled struct) | ✓ | | **File formats** | YAML, JSON, TOML, INI, properties + [any by a decoder](https://insei.github.io/tinyconf/2.2/drivers/format/index.md) | JSON, TOML, YAML, INI, envfile, properties + codecs | many, each a module | YAML, JSON, TOML, EDN, .env | JSON + modules | JSON, YAML, TOML | ✗ | | **Environment / flags** | ✓ / ✓ | ✓ / pflag | ✓ / modules | ✓ / ✗ | ✓ / ✓ | ✓ / ✓ | ✓ / ✗ | | **Remote stores** | Consul, Vault, etcd | Consul, etcd, Firestore, NATS (separate module) | Consul, etcd, Vault, S3, AWS, Azure, NATS… (modules) | ✗ | ✗ | Consul, etcd, Vault, SSM (in core) | ✗ | | **Required fields** | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✓ | | **Reloading** | files by events, Consul and etcd by notifications, Vault by polling | files by events, remote | some providers | manual, env only | ✗ | ✗ | ✗ | | **Change subscriptions per part** | ✓ | ✗ (one global callback) | ✗ (one callback per provider) | ✗ | ✗ | ✗ | ✗ | | **Old config untouched on reload** | ✓ (new version) | ✗ (in place) | ✓ (fresh instance by hand) | ✗ (in place) | n/a | n/a | n/a | | **Where the value came from** | ✓ driver and key | ✗ | ✗ | ✗ | ✗ | ✗ | partial (default or not) | | **Masking secrets** | ✓ logs, docs, `Secret` type | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | | **Generated docs** | every driver | write config back | write config back | env usage | flags usage | flags usage | external tool | | **Sub configs** | by type | by key | by key | by type | by type | by type | by type | | **Concurrent use** | ✓ | ✗ | ✗ during load | n/a | n/a | n/a | n/a | ## Known limitations - **Fewer remote stores than koanf.** Consul, Vault and etcd only; others need a [custom driver](https://insei.github.io/tinyconf/2.2/drivers/custom/index.md). - **Vault: AppRole, Kubernetes and token authentication.** Cloud IAM, LDAP and JWT login are delegated to Vault Agent; changes are detected by polling, because Vault does not notify. - **No HCL out of the box.** The [format driver](https://insei.github.io/tinyconf/2.2/drivers/format/index.md) accepts any decoder, but the conversion of HCL to a map is up to you. - **Own flag parser.** It ignores unknown flags, so it lives next to cobra or any CLI framework, but it is not a pflag integration. - **Subscriptions are package functions,** `tinyconf.Subscribe(ctx, config, ...)`, to keep Go 1.19 support; methods with type parameters need Go 1.27. - **A younger and smaller project** than viper and koanf. ## Migrating From tinyconf v1: see [Upgrading to v2](https://insei.github.io/tinyconf/2.2/upgrading/index.md). | From | To tinyconf | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------- | | viper `SetDefault("http.port", 8080)` | `initial:"8080"` with the [tag driver](https://insei.github.io/tinyconf/2.2/drivers/tag/index.md) | | viper `AutomaticEnv()` | [`env.WithAutoKeys()`](https://insei.github.io/tinyconf/2.2/drivers/env/#auto-keys) | | viper `WatchConfig()` + `OnConfigChange` | [`tinyconf.Subscribe`](https://insei.github.io/tinyconf/2.2/features/reloading/index.md) per package | | viper `Sub("http")` | `config.Parse(context.Background(), &HTTP{})` or `tinyconf.Current[HTTP](config)` | | cleanenv `env-default`, `env-required` | `initial:"..."` and `required:"true"` | | cleanenv `env-description` + `Usage` | `doc:"..."` + [`GenDoc("env")`](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md) | | caarlos0/env `envDefault`, `required` | `initial:"..."` and `required:"true"` | # Use cases Recipes for typical tasks. Every recipe is a small, complete piece: take the one that matches your application and combine it with others. | I want to… | Recipe | | ------------------------------------------------- | ------------------------------------------------------------------- | | keep defaults next to the fields | [Defaults in code](#defaults-in-code) | | configure a service only by environment | [Environment only](#environment-only) | | have a config file and override it by environment | [File with environment overrides](#file-with-environment-overrides) | | have different files for dev, stage and prod | [Files per environment](#files-per-environment) | | ship defaults inside the binary | [Embedded defaults](#embedded-defaults) | | keep secrets out of the files and logs | [Secrets](#secrets) | | run in Docker or Kubernetes | [Containers](#containers) | | build a command line tool | [Command line tools](#command-line-tools) | | set lists and maps from environment | [Lists and maps in environment](#lists-and-maps-in-environment) | | have a section that can be absent | [Optional sections](#optional-sections) | | give every package its own config | [Config per package](#config-per-package) | | stop at start if something is missing | [Fail fast](#fail-fast) | | apply changes without restart | [Reload without restart](#reload-without-restart) | | keep the config in Consul | [Config in Consul](#config-in-consul) | | generate an example of the config | [Config template](#config-template) | | find out where a value came from | [Debugging](#debugging) | | test the code that uses the config | [Tests](#tests) | | read values from my own storage | [Custom source](#custom-source) | All recipes use this helper to keep the examples short: ```go func must[T any](value T, err error) T { if err != nil { panic(err) } return value } ``` ## Defaults in code Default values live in the tags, right next to the fields. The [tag driver](https://insei.github.io/tinyconf/2.2/drivers/tag/index.md) goes first, so every other driver can override it. ```go type Config struct { Host string `initial:"localhost"` Port int `initial:"8080"` Timeout time.Duration `initial:"30s"` Hosts []string `initial:"a,b"` } config := must(tinyconf.New( tinyconf.WithDriver(must(tag.New("initial"))), )) ``` A value that is computed at runtime can be set in the struct before `Parse`: the field keeps it, if no driver has a value. ```go c := &Config{Host: must(os.Hostname())} ``` ## Environment only A twelve-factor service without config files. With the [auto keys](https://insei.github.io/tinyconf/2.2/drivers/env/#auto-keys) the `env` tags are not needed, with the [prefix](https://insei.github.io/tinyconf/2.2/drivers/env/#prefix) the variables of the service do not clash with others. ```go type Config struct { HTTP struct { Host string `initial:"0.0.0.0"` Port int `initial:"8080"` MaxConns int `initial:"100"` } DatabaseURL string `required:"true" hidden:"true"` } config := must(tinyconf.New( tinyconf.WithDriver(must(tag.New("initial"))), tinyconf.WithDriver(must(env.New(env.WithPrefix("APP_"), env.WithAutoKeys()))), )) ``` ```bash APP_HTTP_PORT=9090 APP_HTTP_MAX_CONNS=500 APP_DATABASE_URL=postgres://db/app ``` ## File with environment overrides The most common setup: defaults, then a file, then the environment on top. ```go type Config struct { HTTP struct { Host string `initial:"localhost" yaml:"host" env:"HTTP_HOST"` Port int `initial:"8080" yaml:"port" env:"HTTP_PORT"` } `yaml:"http"` LogLevel string `initial:"info" yaml:"log_level" env:"LOG_LEVEL"` } config := must(tinyconf.New( tinyconf.WithDriver(must(tag.New("initial"))), tinyconf.WithDriver(must(yaml.New("config.yaml", yaml.WithOptional()))), tinyconf.WithDriver(must(env.New())), )) ``` `yaml.WithOptional()` lets the application start without the file at all. Prefer JSON or TOML? The [json](https://insei.github.io/tinyconf/2.2/drivers/json/index.md) and [toml](https://insei.github.io/tinyconf/2.2/drivers/toml/index.md) drivers have the same options, only the tag is different. ## Files per environment One base file and a small file with differences for every environment. Files are [merged](https://insei.github.io/tinyconf/2.2/drivers/yaml/#several-files): the next file overrides only the keys it has. ```go stage := os.Getenv("APP_ENV") // dev, stage, prod yamlDriver := must(yaml.New("config.yaml", yaml.WithFiles( "config."+stage+".yaml", "config.local.yaml", // personal overrides, not in the repository ), yaml.WithOptional(), )) ``` ```yaml http: host: 0.0.0.0 port: 8080 log_level: info ``` ```yaml log_level: warn ``` ```yaml http: port: 9090 log_level: debug ``` ## Embedded defaults Defaults as a readable file that is compiled into the binary, with an optional external file on top. The binary works without any files around it. ```go //go:embed defaults.yaml var defaults embed.FS yamlDriver := must(yaml.New("", yaml.WithFS(defaults, "defaults.yaml"), yaml.WithFiles("/etc/app/config.yaml"), yaml.WithOptional(), )) ``` ## Secrets Three things protect a secret: it is not printed, it is not stored in the file, and the application does not start without it. ```go type Config struct { Database struct { Host string `yaml:"host" env:"DB_HOST"` Password string `yaml:"password" env:"DB_PASSWORD" hidden:"true" required:"true"` } `yaml:"database"` } ``` - `hidden:"true"` masks the value in [logs](https://insei.github.io/tinyconf/2.2/features/logging/index.md) and removes it from [`GenDoc`](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md). - [`tinyconf.Secret`](https://insei.github.io/tinyconf/2.2/features/types/#secrets) instead of `string` masks the value when the application prints or logs the config itself: `%+v`, JSON, zap, slog. - `required:"true"` makes `Parse` [fail](https://insei.github.io/tinyconf/2.2/features/required/index.md) without the value. Where the value comes from: ```bash DB_PASSWORD=secret ``` ```go env.New(env.WithSecretFiles()) ``` ```bash # the value is read from the file DB_PASSWORD_FILE=/run/secrets/db_password ``` ```go yaml.New("config.yaml", yaml.WithEnvExpansion()) ``` ```yaml database: host: db.internal password: ${DB_PASSWORD} ``` ```go // add after the file driver: the secret overrides the file vault.New("app/database", vault.WithKubernetes("app", "")) ``` ```go Password string `yaml:"password" vault:"password" hidden:"true" required:"true"` ``` The driver logs in by the service account of the pod, AppRole and the token of Vault Agent are supported too, see [vault](https://insei.github.io/tinyconf/2.2/drivers/vault/#authentication). ## Containers A typical Kubernetes deployment: the config file is mounted from a ConfigMap, secrets are mounted as files, a few values are overridden by variables of the pod. ```go config := must(tinyconf.New( tinyconf.WithDriver(must(tag.New("initial"))), tinyconf.WithDriver(must(yaml.New("/etc/app/config.yaml", yaml.WithOptional(), yaml.WithWatchInterval(10*time.Second), ))), tinyconf.WithDriver(must(env.New( env.WithPrefix("APP_"), env.WithSecretFiles(), // APP_DB_PASSWORD_FILE=/run/secrets/db_password env.WithIndexedKeys(), // APP_HOSTS_0, APP_HOSTS_1 env.WithFiles(), // no .env files in the container ))), )) ``` ```yaml env: - name: APP_DB_PASSWORD_FILE value: /run/secrets/db_password - name: APP_HOSTS_0 value: a.example.com - name: APP_HOSTS_1 value: b.example.com ``` A ConfigMap volume is updated by Kubernetes without restart of the pod, so the [reloading](#reload-without-restart) picks up the changes of the file. ## Command line tools Flags have the highest priority, the file and the environment provide the rest. ```go type Config struct { Output string `flag:"output" env:"TOOL_OUTPUT" initial:"out" doc:"output directory"` Include []string `flag:"include" doc:"patterns of files to include"` Verbose bool `flag:"verbose" doc:"print debug messages"` Help bool `flag:"help" doc:"show this help"` } config := must(tinyconf.New( tinyconf.WithDriver(must(tag.New("initial"))), tinyconf.WithDriver(must(env.New())), tinyconf.WithDriver(must(flag.New())), )) c := &Config{} if err := config.Register(c); err != nil { panic(err) } if err := config.Parse(context.Background(), c); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } if c.Help { fmt.Print(config.GenDoc("flag")) return } ``` ```bash tool --output=build --include='*.go' --include='*.md' --verbose ``` The driver [ignores unknown flags](https://insei.github.io/tinyconf/2.2/drivers/flag/#unknown-flags), so it works next to any CLI framework that owns the sub commands and positional arguments. ## Lists and maps in environment Three forms, from the shortest to the most flexible. All of them fill the same fields: ```go type Server struct { Host string `json:"host" env:"HOST"` Port int `json:"port" env:"PORT"` } type Config struct { Hosts []string `env:"HOSTS"` Labels map[string]string `env:"LABELS"` Servers []Server `env:"SERVERS"` } ``` ```bash HOSTS=a.example.com,b.example.com LABELS=env:prod,team:core ``` ```bash HOSTS='["a.example.com","b.example.com"]' LABELS='{"env":"prod","team":"core"}' SERVERS='[{"host":"a","port":1},{"host":"b","port":2}]' ``` ```bash HOSTS_0=a.example.com HOSTS_1=b.example.com LABELS_env=prod LABELS_team=core SERVERS_0_HOST=a SERVERS_0_PORT=1 SERVERS_1_HOST=b SERVERS_1_PORT=2 ``` The indexed form requires [`env.WithIndexedKeys()`](https://insei.github.io/tinyconf/2.2/drivers/env/#indexed-keys). A value with the separator inside can be quoted, `"a,b",c`, or the separator can be changed with the [`separator` tag](https://insei.github.io/tinyconf/2.2/features/tags/#common-tags). ## Optional sections A feature that is turned on by the presence of its settings. The section is a [struct stored by pointer](https://insei.github.io/tinyconf/2.2/features/structs/#structs-stored-by-pointer): it stays `nil` until some driver has a value for one of its fields. ```go type TLS struct { Cert string `yaml:"cert" env:"TLS_CERT"` Key string `yaml:"key" env:"TLS_KEY"` } type Metrics struct { Addr string `yaml:"addr" env:"METRICS_ADDR"` } type Config struct { TLS *TLS `yaml:"tls"` Metrics *Metrics `yaml:"metrics"` } ``` ```go if c.Metrics != nil { go serveMetrics(c.Metrics.Addr) } if c.TLS != nil { // TLS is enabled, but it can be set partially: check the fields if c.TLS.Cert == "" || c.TLS.Key == "" { return errors.New("both tls.cert and tls.key should be set") } } ``` Do not use `required:"true"` inside an optional section: a required field is checked even when the section is `nil`, so it makes the whole section mandatory. ## Config per package Every package declares the struct it needs and does not know about the whole config. The application registers the root config once and passes the manager around. ```go // package storage type Config struct { DSN string `yaml:"dsn" env:"STORAGE_DSN" required:"true" hidden:"true"` PoolSize int `yaml:"pool_size" env:"STORAGE_POOL_SIZE" initial:"10"` } func New(config *tinyconf.Manager) (*Storage, error) { c := &Config{} if err := config.Parse(context.Background(), c); err != nil { return nil, err } return connect(c) } ``` ```go // package main type Config struct { HTTP server.Config `yaml:"http"` Storage storage.Config `yaml:"storage"` } root := &Config{} if err := config.Register(root); err != nil { panic(err) } store := must(storage.New(config)) // parses only the storage section ``` The package gets a [sub config](https://insei.github.io/tinyconf/2.2/features/structs/#sub-configs): its own fields with the keys of the whole config, `storage.dsn` in the file. ## Fail fast Check everything at start and report all problems in one message. ```go if err := config.Parse(context.Background(), c); err != nil { if errors.Is(err, tinyconf.ErrRequired) { fmt.Fprintln(os.Stderr, "some required settings are not set") } fmt.Fprintln(os.Stderr, err) os.Exit(1) } ``` ```text env driver: field HTTP.Port: failed to parse ... "abc": invalid syntax; field Database.Password: required value is not set ``` `Parse` does not stop on the first error: incorrect values of all fields and all missing [required fields](https://insei.github.io/tinyconf/2.2/features/required/index.md) are in the same error. ## Reload without restart Every package subscribes to its own part of the config, the manager calls it only when this part is changed. See [Reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md). ```go c := &Config{} if err := config.Register(c); err != nil { panic(err) } if err := config.Parse(context.Background(), c); err != nil { panic(err) } err := tinyconf.Subscribe(ctx, config, func(prev, next *Logging) { if prev.Level != next.Level { setLogLevel(next.Level) } }) ``` Code that only reads the values takes the current version: `tinyconf.Current[HTTP](config).Timeout`. The versions are never modified, so they are read from any goroutine without locks. ## Config in Consul The defaults are in the file next to the binary, the settings of the environment are in Consul, and the changes in Consul are applied without restart. ```go type Config struct { LogLevel string `yaml:"log_level" consul:"log_level"` HTTP struct { Port int `yaml:"port" consul:"port"` } `yaml:"http" consul:"http"` } config := must(tinyconf.New( // the defaults and the shared document of all instances tinyconf.WithDriver(must(yaml.New("config.yaml", yaml.WithSource(consul.Document("app/config")), ))), // separate keys for quick changes: consul kv put app/log_level debug tinyconf.WithDriver(must(consul.New("app"))), )) ``` The address and the token are taken from `CONSUL_HTTP_ADDR` and `CONSUL_HTTP_TOKEN`, the same variables the Consul CLI uses. [Subscribe](#reload-without-restart) to apply the changes: Consul notifies the driver right after the change. See [consul](https://insei.github.io/tinyconf/2.2/drivers/consul/index.md). ## Config template A flag that prints the example of the config and exits. The template is always up to date, because it is generated from the same structs. ```go c := &Config{} if err := config.Register(c); err != nil { panic(err) } if len(os.Args) > 1 && os.Args[1] == "-doc" { // fills the defaults and the values of this environment, // missing required values are not a problem for the template _ = config.Parse(context.Background(), c) fmt.Println(config.GenDoc("yaml")) fmt.Println(config.GenDoc("env")) return } ``` `GenDoc` shows the current values of the registered config, so after `Parse` the template has the defaults and everything that is set in this environment — it is also a "show effective config" command. Without `Parse` the template has empty values. Fields with `hidden:"true"` are never shown. See [Documentation generation](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md). ## Debugging "Why is the port 9090?" Two tools answer it. [`Sources`](https://insei.github.io/tinyconf/2.2/features/sources/index.md) shows the driver that has set the final value: ```go for _, source := range config.Sources(c) { log.Printf("%s is set by %s driver from %s", source.Field, source.Driver, source.Source) } ``` ```text HTTP.Host is set by env driver from HTTP_HOST HTTP.Port is set by yaml driver from http.port ``` The [logger](https://insei.github.io/tinyconf/2.2/features/logging/index.md) on the debug level shows the [history](https://insei.github.io/tinyconf/2.2/features/logging/#history-of-a-field) of every field: the initial value and every driver that has overridden it, then every change after reloads. ```go tinyconf.WithLogger(logger.NewFmtLogger(logger.DEBUG)) ``` ## Tests Tests should not depend on the files and environment of the machine. Every driver can be fed from memory: ```go func newTestConfig(t *testing.T, yamlDoc string, args ...string) *Config { config := must(tinyconf.New( tinyconf.WithDriver(must(tag.New("initial"))), tinyconf.WithDriver(must(yaml.New("", yaml.WithData("test", []byte(yamlDoc))))), tinyconf.WithDriver(must(env.New(env.WithFiles()))), // no .env files tinyconf.WithDriver(must(flag.New(flag.WithArgs(args)))), )) c := &Config{} if err := config.Register(c); err != nil { t.Fatal(err) } if err := config.Parse(context.Background(), c); err != nil { t.Fatal(err) } return c } func TestPort(t *testing.T) { t.Setenv("HTTP_PORT", "7070") c := newTestConfig(t, "http:\n host: test\n") // c.HTTP.Host == "test", c.HTTP.Port == 7070 } ``` To test with a `.env` file without changing the environment of the test process, use `env.WithFiles(path)` together with [`env.WithFilesIsolation()`](https://insei.github.io/tinyconf/2.2/drivers/env/#isolation). ## Custom source Values from Vault, Consul, a database or any other storage: write a [custom driver](https://insei.github.io/tinyconf/2.2/drivers/custom/index.md). `Load` reads the storage once per `Parse`, `GetValue` converts the values with the `convert` package, so the driver supports the same types as the bundled ones. ```go type kvDriver struct { client *kv.Client values map[string]string } func (d *kvDriver) GetName() string { return "kv" } func (d *kvDriver) GenDoc(...*tinyconf.Registered) string { return "" } func (d *kvDriver) Load() (err error) { d.values, err = d.client.FetchAll() return err } func (d *kvDriver) GetValue(field fmap.Field) (*tinyconf.Value, error) { key, ok := field.GetTag().Lookup("kv") if !ok { return nil, tinyconf.ErrIncorrectTagSettings } raw, ok := d.values[key] if !ok { return nil, tinyconf.ErrValueNotFound } value, err := convert.StringToField(raw, field) if err != nil { return nil, err } return &tinyconf.Value{Source: key, Value: value}, nil } ``` Add the `Watch` method of the `tinyconf.Watcher` interface, and the driver takes part in [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md). # Upgrading to v2 v2 changes the module path and a few methods. The steps for the code of v1: 1. Change the imports from `github.com/insei/tinyconf/...` to `github.com/insei/tinyconf/v2/...`: ```sh go get github.com/insei/tinyconf/v2@latest ``` 1. `Parse` takes the context: `Parse(conf)` is `Parse(context.Background(), conf)`. The context stops the requests of the [remote drivers](https://insei.github.io/tinyconf/2.2/features/manager/#parse). 1. The zap adapter is the separate module, zap is not a dependency of the core anymore: `logger.NewZapLogger(l)` is `zaplogger.New(l)` from `github.com/insei/tinyconf/logger/zaplogger`, see [Logging](https://insei.github.io/tinyconf/2.2/features/logging/index.md). 1. Fields of an [embedded struct](https://insei.github.io/tinyconf/2.2/features/structs/#embedded-structs) without the tag are on the level of the embedding struct, like in `encoding/json`. Check the keys of such fields in the files and the environment variables. 1. `Parse` returns the errors: a value that can't be converted to the type of the field, a file that can't be read, a field with `required:"true"` without a value. v1 only logged them and returned `nil`. All fields are parsed anyway, the errors are returned as one error, see [Errors](https://insei.github.io/tinyconf/2.2/features/manager/#errors). # For AI agents AI agents often write the code that uses a library, and a model trained before tinyconf v2 does not know its API: it writes `Parse(conf)` without the context or imports `github.com/insei/tinyconf` without `/v2`. Give the agent the current documentation. ## The skill [`skills/tinyconf/SKILL.md`](https://github.com/Insei/tinyconf/blob/main/skills/tinyconf/SKILL.md) is a short guide for agents in the [Agent Skills](https://agentskills.io) format: the rules, complete programs, the drivers with their options and the common mistakes. It is versioned with the code, and CI builds its programs against the code of the repository, so it does not describe the API that does not exist. Claude Code finds the skill in the `.claude/skills` directory of the project or of the user: ```sh mkdir -p .claude/skills/tinyconf curl -fsSL https://raw.githubusercontent.com/Insei/tinyconf/main/skills/tinyconf/SKILL.md \ -o .claude/skills/tinyconf/SKILL.md ``` Replace `main` with the release tag, `v2.1.0` for example, to get the skill of the version in your `go.mod`. Other agents read it from any path: mention it in `AGENTS.md` or in the instructions of the agent. The skill describes the library, not your project. Keep the rules of your project — which drivers you use, where the configs live, how they are registered — in your own instructions next to it. ## llms.txt The documentation is published in the [llms.txt](https://llmstxt.org) format for every version: - [`llms.txt`](https://insei.github.io/tinyconf/latest/llms.txt) — the index of the pages with descriptions; - [`llms-full.txt`](https://insei.github.io/tinyconf/latest/llms-full.txt) — the whole documentation as one Markdown file, to put into the context of the agent. Replace `latest` with the version, `2.1` for example, or with `dev` for the documentation of the `main` branch. # Features # Features Reference of tinyconf capabilities grouped by area. Looking for a recipe? The [Use cases](https://insei.github.io/tinyconf/2.2/use-cases/index.md) page shows how the features work together in typical setups: a twelve-factor service, a config file with overrides, a command line tool, a Kubernetes deployment. ## Configuration | Page | What's inside | | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | | [Manager](https://insei.github.io/tinyconf/2.2/features/manager/index.md) | `New`, `WithDriver`, `WithLogger`, `Register`, `Parse`, priority of drivers, errors, concurrency | | [Struct tags](https://insei.github.io/tinyconf/2.2/features/tags/index.md) | Tags of drivers and common tags: `doc`, `hidden`, `required`, `separator`, `kvseparator` | | [Supported types](https://insei.github.io/tinyconf/2.2/features/types/index.md) | Scalars, durations, time, slices, arrays, maps, structs, pointers — the same for all drivers | | [Nested structs and sub configs](https://insei.github.io/tinyconf/2.2/features/structs/index.md) | Grouping fields into structs, optional sections with pointers, parsing a part of the config | | [Required fields](https://insei.github.io/tinyconf/2.2/features/required/index.md) | `required:"true"` tag and `ErrRequired` | ## Operations | Page | What's inside | | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | [Documentation generation](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md) | `GenDoc`: a config template in the format of the driver | | [Value sources](https://insei.github.io/tinyconf/2.2/features/sources/index.md) | `Sources`: which driver has set the value of every field | | [Reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md) | `Subscribe` and `Current`: configs are parsed again after changes of the sources | | [Logging](https://insei.github.io/tinyconf/2.2/features/logging/index.md) | What the manager logs, bundled loggers, custom logger | # Documentation generation `GenDoc(driverName)` generates a template of the config in the format of the driver: all fields of all registered configs with their descriptions and current values. ```go c := &Config{} if err := config.Register(c); err != nil { panic(err) } if err := config.Parse(context.Background(), c); err != nil { panic(err) } fmt.Println(config.GenDoc("yaml")) fmt.Println(config.GenDoc("env")) ``` For this config ```go type HTTP struct { Host string `yaml:"host" env:"HTTP_HOST" initial:"localhost" doc:"http host"` Port int `yaml:"port" env:"HTTP_PORT" initial:"8080" doc:"http port"` } type Config struct { HTTP HTTP `yaml:"http"` Password string `yaml:"password" env:"PASSWORD" hidden:"true"` } ``` the output is ```yaml # #http: #http host #host: localhost #http port #port: 8080 # #password: ``` ```bash #http host #HTTP_HOST=localhost #http port #HTTP_PORT=8080 # #PASSWORD= ``` - The description is taken from the `doc` tag. - The value is the current value of the field in the registered config, so call `GenDoc` after `Parse` to get the actual values, or before it to get a clean template. - Values of the fields with `hidden:"true"` are not shown. - Slices and maps are shown in the format of the driver: `hosts: ["a","b"]` for yaml, `HOSTS=a,b` for env, so the value can be copied from the template as is. - The name of the driver is the name of its tag: `yaml`, `json`, `toml`, `ini`, `properties`, `env`, `flag`, `consul`, `vault`, `etcd`. The consul, vault and etcd drivers generate the commands of their CLI, see [consul](https://insei.github.io/tinyconf/2.2/drivers/consul/#documentation), [vault](https://insei.github.io/tinyconf/2.2/drivers/vault/#documentation) and [etcd](https://insei.github.io/tinyconf/2.2/drivers/etcd/#documentation). - The json and toml drivers generate documents without descriptions, see [json](https://insei.github.io/tinyconf/2.2/drivers/json/#documentation) and [toml](https://insei.github.io/tinyconf/2.2/drivers/toml/#documentation). A common way to use it is a command line flag of the application that prints the template and exits. # Logging The manager logs what happens with every field during `Parse`. | Level | Message | | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Debug` | `initial`: the value of the field before drivers; `override`: the value is overridden by the driver; `skip`: the driver has no value for the field | | `Warn` | the field has no tag of the driver; the driver has found the value in an unexpected place | | `Error` | `access denied`: the source denies access to the values, `tinyconf.ErrAccessDenied`; `failed`, `failed to load`, `failed to watch`: the driver failed to get, to parse or to watch the values | Messages have the fields `config`, `driver`, `field`, `key` and `details` or `value`. Values of the fields with `hidden:"true"` are masked. ## History of a field On the debug level the log has the whole history of every field: grep the log by the name of the field to see where its value came from. - At the start of every `Parse` every field is logged with its initial value: the value in the struct before drivers, usually zero. - Every driver that has changed the value of the field is logged with `override`. A driver that has the same value as the field already has is not logged. - After every [reload](https://insei.github.io/tinyconf/2.2/features/reloading/index.md) only the fields with a new value are logged, with the driver that has set it. Unchanged fields are not logged. ```text [DEBUG] initial: {config: *main.Config} {field: HTTP.Port} {value: 0} [DEBUG] override: {config: *main.Config} {driver: tag} {field: HTTP.Port} {key: initial} {value: 8080} [DEBUG] override: {config: *main.Config} {driver: yaml} {field: HTTP.Port} {key: http.port} {value: 9000} [DEBUG] override: {config: *main.Config} {driver: env} {field: HTTP.Port} {key: HTTP_PORT} {value: 9090} ... [DEBUG] override: {config: *main.Config} {driver: env} {field: HTTP.Port} {key: HTTP_PORT} {value: 9091} ``` `field` is the struct path of the field in the registered config, `key` is the place of the value in the source of the driver: | Driver | `key` | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | tag | name of the tag, `initial` | | yaml, json, toml, ini, properties | path by the tags, `http.port` | | env | name of the variable, with the prefix; `HOSTS_*` for the value from [indexed keys](https://insei.github.io/tinyconf/2.2/drivers/env/#indexed-keys) | | flag | name of the flag, `-http-port` | | consul | the key, `app/http/port` | | vault | path by the tags, `database.password` | | etcd | the key, `app/http/port` | If a field has lost its value in all drivers after a reload, it gets its initial value, and it is logged without `driver` and `key`. ## Warnings of drivers A driver can attach a warning to the value. The value is still used, and the warning is logged with the `Warn` level. The yaml, json, toml, ini, properties, vault and format drivers warn when a parent of the field has no tag of the driver. The path to the value is built from the tags, so without the tag of the parent the value is read from another key: ```go type Config struct { HTTP struct { // no yaml tag Host string `yaml:"host"` } } ``` ```text [WARN] warning: {config: *main.Config} {driver: yaml} {field: HTTP.Host} {details: parent fields HTTP have no 'yaml' tag, the value is read from the 'host' key} ``` Add the tag to the parent, `yaml:"http"`, to read the value from `http.host`. ## Logger from the config The level, the format or the output of the logger are often a part of the config, but the config is parsed by the manager that already needs the logger. `WithDeferredLogger` breaks this cycle: the manager keeps the log records in memory, and `SetLogger` writes them to the logger created from the parsed config. ```go config, _ := tinyconf.New( tinyconf.WithDriver(tagDriver), tinyconf.WithDriver(yamlDriver), tinyconf.WithDeferredLogger(), // the records are kept until SetLogger ) conf := &Config{} _ = config.Register(conf) err := config.Parse(ctx, conf) log := newLogger(conf.Logging) // the logger of the application from its config config.SetLogger(logger.NewSlogLogger(log)) // the kept records are written to it if err != nil { log.Error("config is not parsed", "error", err) os.Exit(1) } // the logger follows the changes of the config without restart _ = tinyconf.Subscribe(ctx, config, func(_, next *Logging) { config.SetLogger(logger.NewSlogLogger(newLogger(*next))) }) ``` - The records of the first `Parse` get to the logger of the application in its format, its level filters them. - `SetLogger` is safe for concurrent use with `Parse`, reloads and other goroutines. The records written during `SetLogger` wait for the kept ones, so nothing is lost and the order is kept. - Up to 1000 records are kept, the oldest are dropped, and the logger gets a warning with the count of the dropped records. - `SetLogger(nil)` disables logging, the kept records are dropped. - `SetLogger` replaces the logger set by `WithLogger` too, without `WithDeferredLogger` nothing is kept. ## Bundled loggers ```go import "github.com/insei/tinyconf/v2/logger" // prints to stdout, levels: FATAL, ERROR, WARN, INFO, DEBUG, TRACE tinyconf.WithLogger(logger.NewFmtLogger(logger.INFO)) // wraps *slog.Logger, Go 1.21 or newer tinyconf.WithLogger(logger.NewSlogLogger(slog.Default())) ``` The adapter of [zap](https://github.com/uber-go/zap) is a separate module, so zap is not a dependency of tinyconf for the applications with other loggers: ```bash go get github.com/insei/tinyconf/logger/zaplogger ``` ```go import "github.com/insei/tinyconf/logger/zaplogger" // wraps *zap.Logger tinyconf.WithLogger(zaplogger.New(zapLogger)) ``` Migration `logger.NewZapLogger(l)` of the previous versions is `zaplogger.New(l)` from the `github.com/insei/tinyconf/logger/zaplogger` module. ## Custom logger Implement the `tinyconf.Logger` interface: ```go type Logger interface { Debug(msg string, fields ...Field) Warn(msg string, fields ...Field) Error(msg string, fields ...Field) Info(msg string, fields ...Field) With(fields ...Field) Logger } ``` `tinyconf.Field` is a pair of `Key` and `Value`. # Manager `tinyconf.Manager` holds the list of drivers and the registered configs. It is created once at the start of the application. ## Setup ```go config, err := tinyconf.New( tinyconf.WithLogger(logger.NewFmtLogger(logger.INFO)), tinyconf.WithDriver(tagDriver), tinyconf.WithDriver(yamlDriver), tinyconf.WithDriver(envDriver), ) ``` | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `WithDriver(driver)` | adds the driver, can be used many times | | `WithLogger(logger)` | sets the logger, see [Logging](https://insei.github.io/tinyconf/2.2/features/logging/index.md) | | `WithDeferredLogger()` | keeps the log records until the logger is set by `SetLogger`, see [Logger from the config](https://insei.github.io/tinyconf/2.2/features/logging/#logger-from-the-config) | ## Priority of drivers Drivers are applied in the order of addition. The next driver overrides values of the previous one, so **the last driver has the highest priority**. A typical order is: defaults from tags, then files, then environment variables. A driver overrides the value only if it has a value for the field. A field that is absent in the file keeps the value from the previous driver, and a field without values in all drivers keeps the value that was in the struct before `Parse`. ## Register ```go c := &Config{} err := config.Register(c) ``` `Register` takes a pointer to struct. The registered config is used by `Parse` and by [`GenDoc`](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md). ## Parse ```go err := config.Parse(context.Background(), c) ``` `Parse` fills the registered config with values from drivers. The config can be a registered one or a field of the registered config with struct type, see [sub configs](https://insei.github.io/tinyconf/2.2/features/structs/#sub-configs). `tinyconf.ErrNotRegisteredConfig` is returned for all other configs. The context is used by the drivers that read their sources by the network — [consul](https://insei.github.io/tinyconf/2.2/drivers/consul/index.md), [vault](https://insei.github.io/tinyconf/2.2/drivers/vault/index.md), [etcd](https://insei.github.io/tinyconf/2.2/drivers/etcd/index.md) and the remote documents: their requests are stopped when the context is done. A start of the application in Kubernetes is a typical case: ```go ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := config.Parse(ctx, c); err != nil { log.Fatal(err) // errors.Is(err, context.DeadlineExceeded) if the storage is not available in time } ``` Migration `Parse(conf)` of the previous versions is `Parse(context.Background(), conf)`, and `ParseContext(ctx, conf)` is `Parse(ctx, conf)`. Every request of the drivers is limited to 30 seconds anyway. The reloads use the context of watching, so the requests are stopped when the last subscription is canceled. ## Other methods | Method | Description | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GenDoc(driverName)` | generates a template of all registered configs, see [Documentation generation](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md) | | `Sources(conf)` | returns which driver has set the value of every field, see [Value sources](https://insei.github.io/tinyconf/2.2/features/sources/index.md) | | `tinyconf.Subscribe(ctx, m, onChange)` | calls `onChange` after the changes of the config or the sub config, see [Reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md) | | `tinyconf.Current[T](m)` | returns the current version of the config or the sub config, see [Reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md) | | `ReloadStatus()` | state of watching and reloading for the health checks, see [Health checks](https://insei.github.io/tinyconf/2.2/features/reloading/#health-checks) | | `SetLogger(logger)` | replaces the logger, safe for concurrent use, see [Logger from the config](https://insei.github.io/tinyconf/2.2/features/logging/#logger-from-the-config) | ## Errors `Parse` does not stop on the first error: all fields are parsed, and all errors are returned as one error. It can be checked with `errors.Is` and `errors.As`. ```go if err := config.Parse(context.Background(), c); err != nil { // env driver: field HTTP.Port: failed to parse env value from key HTTP_PORT ... log.Fatal(err) } ``` Fields that were parsed without errors are filled even if `Parse` returns an error. Missing [required fields](https://insei.github.io/tinyconf/2.2/features/required/index.md) are reported in the same error and can be checked with `errors.Is(err, tinyconf.ErrRequired)`. Not an error A field without a value in the driver and a field without the tag of the driver are not errors, the manager just goes to the next driver. ## Concurrency All methods of the manager are safe for concurrent use: configs can be registered and parsed from several goroutines. The config struct itself is not protected, do not read it while it is being parsed by another goroutine. Configs are documented by `GenDoc` and checked for [sub configs](https://insei.github.io/tinyconf/2.2/features/structs/#sub-configs) in the registration order. # Reloading The manager keeps configs up to date: after every change of the config sources it parses the configs again. The application gets the current version with `Current` and subscribes to the changes with `Subscribe`. ```go c := &Config{} if err := config.Register(c); err != nil { panic(err) } if err := config.Parse(context.Background(), c); err != nil { panic(err) } // a package subscribes to its part of the config err := tinyconf.Subscribe(ctx, config, func(prev, next *HTTP) { if prev.Timeout != next.Timeout { server.SetTimeout(next.Timeout) } }) // anywhere in the application level := tinyconf.Current[Logging](config).Level ``` ## Subscribe ```go func Subscribe[T any](ctx context.Context, m *Manager, onChange func(prev, next *T)) error ``` - `T` is a registered config or a [sub config](https://insei.github.io/tinyconf/2.2/features/structs/#sub-configs): the type of a field of the registered config. A package subscribes to its own part and knows nothing about the whole config. - `Subscribe` does not block, the subscription is active until `ctx` is done. - `onChange` is called only if some fields of `T` are changed, with the previous and the new versions. A change of another part of the config does not call it. - The configs with errors are not applied: the errors are [logged](https://insei.github.io/tinyconf/2.2/features/logging/index.md), the subscribers are not called and keep the last good version. ## Current ```go func Current[T any](m *Manager) *T ``` `Current` returns the current version of the config or the sub config: after the last `Parse` of the registered config or the last reload. It is `nil` before the first `Parse`, and for a sub config stored by pointer that is not created. Do not change the versions The versions from `Current` and `onChange` are shared by all readers, so they are read without locks. Never change them: copy the value if it should be changed. ## How it works - Watching starts with the first subscription and stops after the context of the last one is done. One watching serves all subscribers: every driver watches its sources once for the whole application. - After every change the registered configs with subscribers are parsed again, **from their values before drivers at the first `Parse`**, so the result is the same as `Parse` would give at the start. A value removed from the file gets its default back. - All drivers are applied, not only the changed one: the usual [priority of drivers](https://insei.github.io/tinyconf/2.2/features/manager/#priority-of-drivers) is kept. - The new version replaces the current one atomically, and the subscribers of the changed parts are called. - The config passed to `Register` and `Parse` is never changed in the background, the application can read it as before. - `Parse` called by the application updates the current version too, but does not call the subscribers: the application knows what it does. - [`GenDoc`](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md) shows the values of the current versions. ## What is watched Changes are reported by drivers. The bundled [yaml](https://insei.github.io/tinyconf/2.2/drivers/yaml/index.md), [json](https://insei.github.io/tinyconf/2.2/drivers/json/index.md), [toml](https://insei.github.io/tinyconf/2.2/drivers/toml/index.md), [ini](https://insei.github.io/tinyconf/2.2/drivers/ini/index.md) and [properties](https://insei.github.io/tinyconf/2.2/drivers/properties/index.md) drivers watch their files from the OS file system: the main file and the files from `WithFiles`. The [env](https://insei.github.io/tinyconf/2.2/drivers/env/index.md) driver watches its `.env` files. The [consul](https://insei.github.io/tinyconf/2.2/drivers/consul/index.md) driver and Consul documents are notified by Consul right after the change. The [vault](https://insei.github.io/tinyconf/2.2/drivers/vault/index.md) driver checks the versions of the secrets. The [etcd](https://insei.github.io/tinyconf/2.2/drivers/etcd/index.md) driver and etcd documents watch the stream of the events of etcd. | Source | Watched | | ----------------------------------------- | ------------------------------------------ | | files from `New` and `WithFiles` | yes, events of the file system and polling | | `.env` files of the env driver | yes, events of the file system and polling | | Consul keys and documents | yes, notified by Consul | | Vault secrets | yes, polling, every 30 seconds by default | | etcd keys and documents | yes, notified by etcd | | files from `WithFS`, data from `WithData` | no | | process environment, flags, tags | no | Files are watched by the events of the file system: inotify on Linux, kqueue on macOS and BSD, ReadDirectoryChangesW on Windows. The change is detected right after the file is written: the driver waits 100 milliseconds for the next events, so the several writes of an editor are one change. The directories of the files are watched, not the files themselves, so everything that replaces the file works: editors, atomic renames, `kubectl` updates of ConfigMap volumes that replace symlinks. A file that appears or disappears is a change too. The files are also checked by polling, every 5 seconds by default: it detects the changes when the events are not available — the limit of inotify watches is reached, the directory does not exist yet, the file system does not support events (some network file systems). The interval is set with the `WithWatchInterval` option of the driver: ```go yamlDriver, err := yaml.New("config.yaml", yaml.WithWatchInterval(time.Minute)) ``` ## Errors | Case | Result | | ------------------------------------------------------------ | ------------------------------------------------------------------------------ | | the config is not registered | `Subscribe` returns `tinyconf.ErrNotRegisteredConfig`, `Current` returns `nil` | | no driver can watch for changes | `Subscribe` returns `tinyconf.ErrNoWatchers` | | the new config is parsed with errors | the error is logged as `failed to reload`, the current version is kept | | a driver can not watch anymore, access is denied for example | the error is logged, other drivers continue watching | | the handler panics | the panic is logged as `subscriber panicked`, other subscribers are called | ## Health checks The errors of the reloads are logged, the application keeps working with the last good config. `ReloadStatus` shows this state for the health checks: ```go status := config.ReloadStatus() ``` | Field | Description | | ---------------- | -------------------------------------------------------------------------------------- | | `Watching` | there are subscriptions and the sources are watched | | `LastReload` | the time of the last reload | | `LastError` | the error of the last reload, `nil` if the configs are applied | | `LastApplied` | the time of the last reload without errors | | `StoppedDrivers` | the drivers that can not watch anymore, with the errors: access is denied, for example | A readiness check that fails when the config is broken for a long time or a source is not watched: ```go func configHealthy(config *tinyconf.Manager) error { status := config.ReloadStatus() if status.LastError != nil && time.Since(status.LastApplied) > 5*time.Minute { return fmt.Errorf("config is not reloaded since %s: %w", status.LastApplied, status.LastError) } for driver, err := range status.StoppedDrivers { return fmt.Errorf("%s driver does not watch for changes: %w", driver, err) } return nil } ``` ## Details - Handlers are called one by one in the order of subscription, from one goroutine. The next reload starts after all handlers return, so a slow handler delays the next reload. - Several changes that happen while the configs are being parsed are merged into one reload. - On the debug level only the fields with a new value are [logged](https://insei.github.io/tinyconf/2.2/features/logging/#history-of-a-field) after a reload, with the driver that has set the value. - What can be applied on the fly is up to the application: a log level can be changed at any time, a listening port requires restart of the server. Compare `prev` and `next` and act on the fields that matter. # Required fields A field with the `required:"true"` tag should have a value. Otherwise `Parse` returns `tinyconf.ErrRequired`. ```go type Config struct { DSN string `env:"DSN" required:"true"` Password string `env:"PASSWORD" yaml:"password" hidden:"true" required:"true"` Port int `env:"PORT" initial:"8080"` } ``` ```go err := config.Parse(context.Background(), c) if errors.Is(err, tinyconf.ErrRequired) { // field DSN: required value is not set; field Password: required value is not set log.Fatal(err) } ``` ## What is a value The field passes the check if **any** of these is true: - some driver has set its value, even if the value is zero: `PORT=0` is a value; - the field has a non-zero value in the config that was passed to `Parse`. So a required field without sources can still be filled in the code before `Parse`: ```go c := &Config{DSN: "postgres://localhost/dev"} err := config.Parse(context.Background(), c) // DSN is not required to be in the environment ``` ## All at once `Parse` checks all required fields and reports all missing ones in one error, together with the [errors of drivers](https://insei.github.io/tinyconf/2.2/features/manager/#errors). The application can show the full list of what should be set in one start, not one variable per attempt. Defaults and required A field with a default value in the [tag driver](https://insei.github.io/tinyconf/2.2/drivers/tag/index.md) always has a value, so `required` makes sense only for the fields without defaults: secrets, addresses of external services. ## Sub configs When a [sub config](https://insei.github.io/tinyconf/2.2/features/structs/#sub-configs) is parsed, only its own required fields are checked. # Value sources With several drivers it is not always obvious where the final value came from. `Sources` returns which driver has set every field in the last `Parse` call. ```go if err := config.Parse(context.Background(), c); err != nil { panic(err) } for _, source := range config.Sources(c) { fmt.Printf("%s is set by %s driver from %s\n", source.Field, source.Driver, source.Source) } ``` ```text HTTP.Host is set by env driver from HTTP_HOST HTTP.Port is set by yaml driver from http.port Password is set by env driver from PASSWORD ``` ## FieldSource | Field | Description | | -------- | --------------------------------------------------------------- | | `Field` | struct path of the field in the config, `HTTP.Port` for example | | `Driver` | name of the driver that has set the final value | | `Source` | source of the value inside the driver | `Source` depends on the driver: | Driver | Source | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | tag | name of the tag, `initial` | | yaml, json, toml, ini, properties | path by the tags, `http.port` | | env | name of the variable, with the prefix; `HOSTS_*` for the value from [indexed keys](https://insei.github.io/tinyconf/2.2/drivers/env/#indexed-keys) | | flag | name of the flag, `-http-port` | | consul | the key, `app/http/port` | | vault | path by the tags, `database.password` | | etcd | the key, `app/http/port` | ## Rules - Sources are returned in the order of the config fields. - Only the last driver that has set the value is reported, the overridden ones are not. - Fields without values from drivers are not included: they have the value that was in the config before `Parse`. - Sources are stored per config type, pass the same pointer type that was used in `Parse`. For a [sub config](https://insei.github.io/tinyconf/2.2/features/structs/#sub-configs) the paths are relative to it. It is handy to log the sources once at the start of the application: the answer to "why is this value here" is in the logs. Values themselves are not included, so secrets are not leaked. # Nested structs and sub configs ## Nested structs Fields can be grouped into structs. A field with struct type is parsed by its fields, every nested field has its own tags: ```go type HTTP struct { Host string `yaml:"host" env:"HTTP_HOST" initial:"localhost"` Port int `yaml:"port" env:"HTTP_PORT" initial:"8080"` } type Config struct { HTTP HTTP `yaml:"http"` } ``` ```yaml http: host: example.com port: 9090 ``` For the yaml driver the tag of the struct field is a key of the nested map, so `Host` is read from `http.host`. Names of environment variables are not built from the parent names: `Host` is read from `HTTP_HOST`, as it is written in its tag. Types that are parsed from a single value — `time.Time`, `url.URL` and types with `UnmarshalText` — are not nested structs, see [Supported types](https://insei.github.io/tinyconf/2.2/features/types/index.md). ## Embedded structs Fields of an embedded struct without the tag are on the level of the embedding struct, like in `encoding/json`: ```go type Timeouts struct { Read time.Duration `yaml:"read_timeout"` Write time.Duration `yaml:"write_timeout"` } type Config struct { HTTP struct { Timeouts Port int `yaml:"port"` } `yaml:"http"` } ``` ```yaml http: port: 9090 read_timeout: 5s write_timeout: 10s ``` - The yaml, json, toml, ini, properties, consul, vault and etcd drivers read `Read` from `http.read_timeout`, the embedded struct adds no level. `yaml:",inline"` works the same way. - With the name in the tag, `` Timeouts `yaml:"timeouts"` ``, the embedded struct is a usual nested struct: `http.timeouts.read_timeout`. - An embedded struct without the tag is expected, the manager does not [warn](https://insei.github.io/tinyconf/2.2/features/logging/#warnings-of-drivers) about it. - [Auto keys](https://insei.github.io/tinyconf/2.2/drivers/env/#auto-keys) and [indexed keys](https://insei.github.io/tinyconf/2.2/drivers/env/#indexed-keys) of the env driver skip the name of the embedded struct too: `HTTP.Timeouts.Read` is `HTTP_READ` with auto keys, and `SERVERS_0_READ` for the struct in the slice. ## Structs stored by pointer A struct stored by pointer is parsed the same way, by its fields: ```go type TLS struct { Cert string `yaml:"cert" env:"TLS_CERT"` Key string `yaml:"key" env:"TLS_KEY"` } type Config struct { TLS *TLS `yaml:"tls"` } ``` The pointer makes the section **optional**: - the struct is created when the first value for its fields is found in any driver; - without values the pointer is not changed, so it stays `nil`; - an existing struct is filled in place, it is not replaced with a new one. ```go if err := config.Parse(context.Background(), c); err != nil { panic(err) } if c.TLS != nil { // TLS_CERT, TLS_KEY or the tls section of the file is set } ``` [Required fields](https://insei.github.io/tinyconf/2.2/features/required/index.md) inside such struct are checked even if the pointer is `nil`: a required field makes the whole section mandatory. Parsed as a single value Pointers to pointers (`**Struct`) and recursive types (`Next *Node` inside `Node`) are not parsed by fields. They are single values: JSON in string values, a nested map in files. ## Sub configs A part of the registered config can be parsed separately, by the type of the field. It allows a package to get its own config without knowing about the whole one: ```go c := &Config{} if err := config.Register(c); err != nil { panic(err) } // HTTP is a type of the field in the registered Config http := &HTTP{} if err := config.Parse(context.Background(), http); err != nil { panic(err) } ``` The type can be a type of the struct stored by value or by pointer. Only the fields of the sub config are parsed, with the same tags and keys as in the whole config: `http.host` in the file, `HTTP_HOST` in the environment. If several registered configs have a field with this type, the configs are checked in the registration order and the first found field is used. # Struct tags Everything about a field is described by its struct tags: where the value comes from, how it is documented and how it is parsed. ```go type Config struct { Token string `yaml:"token" env:"TOKEN" initial:"dev" doc:"API token" hidden:"true"` Hosts []string `yaml:"hosts" env:"HOSTS" separator:";"` } ``` ## Driver tags Every driver reads its own tag, the value of the tag is the name of the value in the source of the driver. | Driver | Tag | Value | | ------------------------------------------------------------------ | --------------------- | -------------------------------- | | [tag](https://insei.github.io/tinyconf/2.2/drivers/tag/index.md) | any, set in `tag.New` | the default value itself | | [yaml](https://insei.github.io/tinyconf/2.2/drivers/yaml/index.md) | `yaml` | key in the file | | [json](https://insei.github.io/tinyconf/2.2/drivers/json/index.md) | `json` | key in the file | | [toml](https://insei.github.io/tinyconf/2.2/drivers/toml/index.md) | `toml` | key in the file | | [env](https://insei.github.io/tinyconf/2.2/drivers/env/index.md) | `env` | name of the environment variable | | [flag](https://insei.github.io/tinyconf/2.2/drivers/flag/index.md) | `flag` | name of the command line flag | A field without the tag of the driver is skipped by this driver. ## Common tags | Tag | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `doc:"text"` | description of the field for [`GenDoc`](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md) | | `merge:"true"` | slices and maps from several drivers are merged instead of replaced, see [Several drivers](https://insei.github.io/tinyconf/2.2/features/types/#several-drivers) | | `hidden:"true"` | the value is masked in logs and is not shown in `GenDoc`, use it for secrets; see also the [`Secret`](https://insei.github.io/tinyconf/2.2/features/types/#secrets) type | | `required:"true"` | `Parse` returns an error if the field has no value, see [Required fields](https://insei.github.io/tinyconf/2.2/features/required/index.md) | | `separator:";"` | separator of slice, array and map items in string values, `,` by default | | `kvseparator:"="` | separator of map key and value in string values, `:` by default | See [Supported types](https://insei.github.io/tinyconf/2.2/features/types/index.md) for the syntax of string values. # Supported types All drivers support the same set of types. It is checked by the conformance tests in [`drivers/conformance_test.go`](https://github.com/Insei/tinyconf/blob/main/drivers/conformance_test.go): every type is parsed by every driver, and the results should be equal. Drivers get values in two forms: - **string values** — environment variables, command line flags and struct tags; - **native values** — YAML, JSON and TOML, where lists and maps are a part of the format. | Types | String value | Native value | | -------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------------------------- | | `string`, `bool`, integers, floats and named types based on them | `value` | scalar | | `time.Duration` | `1m30s` | `1m30s` | | `time.Time` | `2024-01-02T03:04:05Z`, `2024-01-02 03:04:05`, `2024-01-02` | the same or yaml timestamp | | `url.URL`, `[]byte`, any `encoding.TextUnmarshaler` (`net.IP`, `uuid.UUID`, ...) | `value` | scalar | | `[]T`, `[N]T` | `a,b,c` | list or string | | `map[K]V` | `a:1,b:2` | map or string | | `[]Struct`, `map[K]Struct`, nested slices and maps | JSON: `[{"host":"a","port":1}]` | native | | pointers to all types above | the same | the same | | `Struct`, `*Struct` | parsed by fields | parsed by fields | ## Slices and arrays ```go type Config struct { Hosts []string `env:"HOSTS" yaml:"hosts"` Ports [2]int `env:"PORTS" yaml:"ports"` } ``` ```bash HOSTS=a.example.com,b.example.com PORTS=80,443 ``` ```yaml hosts: [a.example.com, b.example.com] ports: - 80 - 443 ``` Items that contain the separator can be quoted with double or single quotes: `"a,b",c` is two items. The separator can be changed with the [`separator` tag](https://insei.github.io/tinyconf/2.2/features/tags/#common-tags). The number of items for an array should be equal to its length. ## Maps ```go type Config struct { Labels map[string]string `env:"LABELS" yaml:"labels"` Limits map[string]int `env:"LIMITS" yaml:"limits" separator:";" kvseparator:"="` } ``` ```bash LABELS=env:prod,team:core LIMITS=cpu=2;memory=512 ``` ```yaml labels: env: prod team: core limits: {cpu: 2, memory: 512} ``` ## Structs in slices and maps Nested values are set as JSON in string values and as is in YAML, JSON and TOML files: ```go type Server struct { Host string `json:"host" yaml:"host"` Port int `json:"port" yaml:"port"` } type Config struct { Servers []Server `env:"SERVERS" yaml:"servers"` } ``` ```bash SERVERS='[{"host":"a","port":1},{"host":"b","port":2}]' ``` ```yaml servers: - host: a port: 1 - host: b port: 2 ``` String values of slices, arrays, maps and structs are parsed as JSON if they start with `[` or `{`. Keys of such structs are taken from the `json` tag for string values and JSON files, from the `yaml` tag for YAML files and from the `toml` tag for TOML files, the field name is used if the tag is not set (case-insensitive). Fields with struct type A field with struct type, stored by value or by pointer, is not a single value: it is parsed by its fields, and every field has its own tags. See [Nested structs and sub configs](https://insei.github.io/tinyconf/2.2/features/structs/index.md). ## Secrets `tinyconf.Secret` is a string that is not printed. `hidden:"true"` masks the value in the logs of tinyconf, but the application can print or log the whole config itself; the `Secret` type masks the value there too: ```go type Config struct { Database struct { User string `env:"DB_USER"` Password tinyconf.Secret `env:"DB_PASSWORD"` } } log.Printf("config: %+v", conf) // {Database:{User:app Password:******}} db.Connect(conf.Database.User, conf.Database.Password.Value()) ``` - `String`, `GoString` and `MarshalText` return `******` for a not empty value, so `fmt`, `encoding/json`, yaml, zap and slog print the mask. The length of the value is not shown; an empty value is shown as empty, so it is visible that the secret is not set. - The value is parsed like a usual string by all drivers, slices and pointers of secrets work too. - `Value()` or `string(secret)` returns the value. - [`GenDoc`](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md) shows `******` as the value of the field. ## Several drivers If the value of a slice or map is provided by several drivers, the last driver replaces the whole value. With the `merge:"true"` tag the values are merged: ```go type Config struct { Hosts []string `initial:"default" yaml:"hosts" env:"HOSTS" merge:"true"` Labels map[string]string `initial:"team:core" yaml:"labels" env:"LABELS" merge:"true"` } ``` ```bash # config.yaml: hosts: [from-file], labels: {env: dev} HOSTS=from-env LABELS=env:prod # Hosts: [default from-file from-env] # Labels: map[env:prod team:core] ``` - Slices are appended in the order of the drivers, pointers to slices too. - Maps get the keys of the next drivers, the same key is overridden by the next driver. - The values of the drivers in one `Parse` call are merged: the first driver with the value replaces the value of the struct, so the next `Parse` does not duplicate the items. - Arrays, `[]byte` and other types are replaced. - [`Sources`](https://insei.github.io/tinyconf/2.2/features/sources/index.md) reports the last driver, the [log](https://insei.github.io/tinyconf/2.2/features/logging/index.md) has every step. # Drivers # Drivers A driver is a source of values. Every driver reads its own struct tag and knows how to find the value for the field in its source. | Driver | Package | Struct tag | Source | | ------------------------------------------------------------------------------ | -------------------- | -------------------------- | ----------------------------------------------------------------- | | [tag](https://insei.github.io/tinyconf/2.2/drivers/tag/index.md) | `drivers/tag` | any, `initial` for example | default values from struct tags | | [yaml](https://insei.github.io/tinyconf/2.2/drivers/yaml/index.md) | `drivers/yaml` | `yaml` | YAML files | | [json](https://insei.github.io/tinyconf/2.2/drivers/json/index.md) | `drivers/json` | `json` | JSON files | | [toml](https://insei.github.io/tinyconf/2.2/drivers/toml/index.md) | `drivers/toml` | `toml` | TOML files | | [ini](https://insei.github.io/tinyconf/2.2/drivers/ini/index.md) | `drivers/ini` | `ini` | INI files | | [properties](https://insei.github.io/tinyconf/2.2/drivers/properties/index.md) | `drivers/properties` | `properties` | `.properties` files of Java and Spring | | [env](https://insei.github.io/tinyconf/2.2/drivers/env/index.md) | `drivers/env` | `env` | environment variables and `.env` files | | [flag](https://insei.github.io/tinyconf/2.2/drivers/flag/index.md) | `drivers/flag` | `flag` | command line flags | | [consul](https://insei.github.io/tinyconf/2.2/drivers/consul/index.md) | `drivers/consul` | `consul` | Consul KV: a key per field, or a document for yaml, json and toml | | [vault](https://insei.github.io/tinyconf/2.2/drivers/vault/index.md) | `drivers/vault` | `vault` | secrets from the KV secrets engine of HashiCorp Vault | | [etcd](https://insei.github.io/tinyconf/2.2/drivers/etcd/index.md) | `drivers/etcd` | `etcd` | etcd: a key per field, or a document for yaml, json and toml | Drivers are added to the [manager](https://insei.github.io/tinyconf/2.2/features/manager/index.md) with `tinyconf.WithDriver`, the next driver overrides values of the previous one. All drivers support the same [set of types](https://insei.github.io/tinyconf/2.2/features/types/index.md). Need another file format? See [Custom formats](https://insei.github.io/tinyconf/2.2/drivers/format/index.md): a decoder function is enough. Another source? See [Custom drivers](https://insei.github.io/tinyconf/2.2/drivers/custom/index.md). # consul The consul package reads the config from [Consul KV](https://developer.hashicorp.com/consul/docs/dynamic-app-config/kv) in two ways: | Way | Where the config is | Tags | | ------------------------------- | ---------------------------------------------------- | ------------------------------------- | | [key per field](#key-per-field) | every value in its own key: `app/http/port` | `consul` | | [document](#document) | YAML, JSON or TOML document in one key: `app/config` | of the format: `yaml`, `json`, `toml` | Both ways report changes for [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md) as soon as they happen: the driver uses [blocking queries](https://developer.hashicorp.com/consul/api-docs/features/blocking) of Consul, not polling. The package talks to the HTTP API of Consul directly and adds no dependencies. ## Key per field ```go import "github.com/insei/tinyconf/v2/drivers/consul" consulDriver, err := consul.New("app") ``` The key of the field is the prefix and the path by the `consul` tags, dots are replaced with slashes: ```go type Config struct { HTTP struct { Host string `consul:"host"` Port int `consul:"port"` } `consul:"http"` Hosts []string `consul:"hosts"` } ``` ```bash consul kv put app/http/host example.com consul kv put app/http/port 9090 consul kv put app/hosts a.example.com,b.example.com ``` Values are parsed by the rules of [string values](https://insei.github.io/tinyconf/2.2/features/types/index.md), like environment variables: slices are comma separated, structs in slices are JSON. All keys with the prefix are read by one request once per `Parse` call. A key that is absent is not an error, the field keeps its value from the previous driver. Set the tags of the parents The key of the nested field is built from the `consul` tags of its parents. If the `HTTP` field has no `consul` tag, the `Host` field is read from `app/host`, not from `app/http/host`. When a value is found this way, the manager [logs a warning](https://insei.github.io/tinyconf/2.2/features/logging/index.md). [Embedded structs](https://insei.github.io/tinyconf/2.2/features/structs/#embedded-structs) without the tag are the exception, their fields are on the level of the embedding struct. ## Document `consul.Document` is a source of the config content for the [yaml](https://insei.github.io/tinyconf/2.2/drivers/yaml/index.md), [json](https://insei.github.io/tinyconf/2.2/drivers/json/index.md) and [toml](https://insei.github.io/tinyconf/2.2/drivers/toml/index.md) drivers. The values are read by the tags of the format driver and merged with the files of the driver, as one more file: ```go yamlDriver, err := yaml.New("config.yaml", yaml.WithSource(consul.Document("app/config")), ) ``` ```bash consul kv put app/config @config.yaml ``` Here `config.yaml` has the defaults, and the document from Consul overrides them. An absent key is an error of `Parse`, unless the format driver has `WithOptional()`. ## Connection ```go consulDriver, err := consul.New("app", consul.WithAddress("https://consul.service:8501"), consul.WithToken(token), consul.WithDatacenter("dc2"), consul.WithHTTPClient(&http.Client{Transport: tlsTransport}), ) document := consul.Document("app/config", consul.WithAddress("consul.service:8500")) ``` | Option | Default | Description | | ------------------------ | -------------------------------------- | ------------------------------------------------------------------------------------------------ | | `WithAddress(address)` | `CONSUL_HTTP_ADDR` or `127.0.0.1:8500` | `host:port` or URL with the scheme; without the scheme `https` is used if `CONSUL_HTTP_SSL=true` | | `WithToken(token)` | `CONSUL_HTTP_TOKEN` | ACL token | | `WithDatacenter(dc)` | the datacenter of the agent | datacenter of the keys | | `WithHTTPClient(client)` | `http.DefaultClient` | client with TLS settings, for example; its timeout should be greater than the wait time | | `WithWaitTime(duration)` | 5 minutes | maximum time of one blocking query, Consul limits it to 10 minutes | The options are the same for `New` and `Document`. ## Permissions With ACL Consul hides the keys that the token can not read, and answers as if they are absent. The driver does not let it make the config silently incomplete: | Case | Result | | ------------------------------------------------- | ---------------------------------------------------------------------------------- | | the token can not read the prefix | error of `Parse`: `403 Forbidden` | | the token can read only some keys with the prefix | error of `Parse`: some keys are hidden by ACL | | the token can not read the key of the document | error of `Parse`: `403 Forbidden` | | access is denied while watching | the driver stops watching, the error is logged: retries do not fix the permissions | All these errors wrap `tinyconf.ErrAccessDenied`: the manager [logs](https://insei.github.io/tinyconf/2.2/features/logging/index.md) them as `access denied`, and they can be checked with `errors.Is`. The token needs `read` on the prefix: `key_prefix "app/" { policy = "read" }`. ## Watching The manager receives the changes of the keys with the prefix, or of the key of the document, right after they are written to Consul. Keys outside of the prefix do not cause reloads. If Consul is not available, the driver keeps retrying the requests, with the interval that grows from 1 to 30 seconds, so a restart of Consul does not stop watching. After the failure the config is always reloaded: the changes made while Consul was not available are picked up, even if Consul has lost its data and started its indexes from scratch. The same is true for the start of the application: if Consul is not available at the first `Parse`, the values are loaded by the reloading as soon as Consul is available, and the subscribers are called. ## Documentation `GenDoc("consul")` generates the commands of the Consul CLI that put the current values to Consul KV, values of `hidden:"true"` fields are empty: ```bash # http host consul kv put app/http/host example.com consul kv put app/http/port 9090 consul kv put app/hosts a.example.com,b.example.com ``` After `Parse` these are the values of the current environment: a way to move the config to Consul. For the document use `GenDoc` of the format driver. # Custom drivers Reading config files of another format? A decoder function and the [format driver](https://insei.github.io/tinyconf/2.2/drivers/format/index.md) are enough, no custom driver is needed. A driver is any type that implements the `tinyconf.Driver` interface: ```go type Driver interface { GenDoc(...*Registered) string GetName() string GetValue(field fmap.Field) (*Value, error) } ``` | Method | Description | | ---------- | ----------------------------------------------------------------------------------------------------------- | | `GetName` | name of the driver, it is used in logs, errors and as an argument of `Manager.GenDoc` | | `GetValue` | returns the value for the field, it is called for every field of the config | | `GenDoc` | returns the documentation of the registered configs in the format of the driver, can return an empty string | ## GetValue The field is described by [`fmap.Field`](https://pkg.go.dev/github.com/insei/fmap/v3#Field): `GetTag()` returns its struct tags, `GetStructPath()` — its path in the config, `HTTP.Port` for example. The returned value should have exactly the type of the field. To support [the same types](https://insei.github.io/tinyconf/2.2/features/types/index.md) as the bundled drivers, use the `convert` package: - `convert.StringToField(s, field)` — for sources with string values; - `convert.TreeToField(v, field, tag)` — for values decoded from formats like YAML or JSON. The returned `tinyconf.Value` has three fields: | Field | Description | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Value` | the value with the type of the field | | `Source` | the key of the value in the source, `HTTP_PORT` or `http.port` for example; it is returned by [`Manager.Sources`](https://insei.github.io/tinyconf/2.2/features/sources/index.md) and logged as `key` | | `Warning` | optional message for the user, the manager logs it with the `Warn` level; use it when the value is found, but probably not where the user expects | Two errors have a special meaning, all other errors are returned from `Parse`: | Error | Meaning | | ---------------------------------- | ------------------------------------------------------------------------------- | | `tinyconf.ErrIncorrectTagSettings` | the field has no settings for this driver, it is skipped | | `tinyconf.ErrValueNotFound` | the source has no value for the field, the value of the previous driver is kept | Wrap `tinyconf.ErrAccessDenied` into the error if the source denies access: the token has no permission to read the values. It is still an error of `Parse`, but the manager logs it as `access denied`, so the problem with permissions is not lost among network errors, and the application can check it with `errors.Is`. Return it from `Load`, `GetValue` and `Watch`; `Watch` should stop on it, retries do not fix the permissions. ## Loader `GetValue` is called for every field. If the source should be read once — a file, a response of the remote service — implement the optional `tinyconf.Loader` interface: ```go type Loader interface { Load() error } ``` `Load` is called once at the start of every `Parse` call, before any `GetValue` call. If it returns an error, the driver is skipped in this `Parse` call and the error is returned from `Parse`. A driver that reads its source by the network should implement `tinyconf.ContextLoader` instead: `LoadContext` gets the context of [`Parse`](https://insei.github.io/tinyconf/2.2/features/manager/#parse), or the context of watching for reloads, and the requests should stop when it is done. ```go type ContextLoader interface { LoadContext(ctx context.Context) error } ``` ```go func (d *kvDriver) Load() error { values, err := d.client.FetchAll() if err != nil { return err } d.values = values return nil } ``` ## Watcher A driver can report changes of its source for [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md) by implementing the optional `tinyconf.Watcher` interface: ```go type Watcher interface { Watch(ctx context.Context, onChange func()) error } ``` `Watch` should block until the context is done and call `onChange` after every change of the source. The manager parses the config again after the call, so `Load` and `GetValue` should return the new values. ```go func (d *kvDriver) Watch(ctx context.Context, onChange func()) error { events := d.client.Subscribe(ctx) for { select { case <-ctx.Done(): return nil case <-events: onChange() } } } ``` An error returned before the context is done is logged by the manager, and the driver does not take part in reloading anymore; other drivers continue watching. ## Example A driver that reads values from a map, the name of the value is taken from the `kv` tag: ```go type kvDriver struct { values map[string]string } func (d *kvDriver) GetName() string { return "kv" } func (d *kvDriver) GenDoc(...*tinyconf.Registered) string { return "" } func (d *kvDriver) GetValue(field fmap.Field) (*tinyconf.Value, error) { key, ok := field.GetTag().Lookup("kv") if !ok { return nil, tinyconf.ErrIncorrectTagSettings } raw, ok := d.values[key] if !ok { return nil, tinyconf.ErrValueNotFound } value, err := convert.StringToField(raw, field) if err != nil { return nil, fmt.Errorf("failed to parse %s: %w", key, err) } return &tinyconf.Value{Source: key, Value: value}, nil } ``` ```go type Config struct { Port int `kv:"port"` Hosts []string `kv:"hosts"` } config, err := tinyconf.New(tinyconf.WithDriver(&kvDriver{values: map[string]string{ "port": "8080", "hosts": "a,b", }})) ``` # env The env driver reads values from environment variables and `.env` files. ```go import "github.com/insei/tinyconf/v2/drivers/env" envDriver, err := env.New() ``` ## Names The `env` tag is the name of the variable. Names are not built from the names of the parent structs, the variable is read exactly as it is written in the tag: ```go type Config struct { HTTP struct { Host string `env:"HTTP_HOST"` Port int `env:"HTTP_PORT"` } Hosts []string `env:"HOSTS"` } ``` ```bash HTTP_HOST=example.com HTTP_PORT=9090 HOSTS=a.example.com,b.example.com ``` Values are parsed by the rules of [string values](https://insei.github.io/tinyconf/2.2/features/types/index.md). ## Options ```go envDriver, err := env.New( env.WithPrefix("APP_"), env.WithAutoKeys(), env.WithSecretFiles(), env.WithFiles(".env", ".env.local"), env.WithFilesIsolation(), env.WithIndexedKeys(), ) ``` | Option | Description | | ----------------------------- | ------------------------------------------------------------------------------------ | | `WithPrefix(prefix)` | adds the prefix to the names of all variables | | `WithAutoKeys()` | reads the fields without the `env` tag, the name is built from the path of the field | | `WithSecretFiles()` | reads the value from the file, if the variable with the `_FILE` suffix is set | | `WithFiles(files...)` | custom `.env` files instead of the default ones | | `WithFilesIsolation()` | does not export the values of the `.env` files to the process environment | | `WithWatchInterval(interval)` | the interval of polling the `.env` files for changes, 5 seconds by default | | `WithIndexedKeys()` | reads slices, arrays, maps and structs from separate variables | ### Prefix With `env.WithPrefix("APP_")` the field with the `env:"HTTP_PORT"` tag is read from the `APP_HTTP_PORT` variable. It allows several applications to share one environment and keeps the tags short. ### Auto keys With `env.WithAutoKeys()` the `env` tag is not required. The name of the variable is built from the struct path of the field: | Field | Variable | | -------------------------------------------- | ---------------- | | `Name` | `NAME` | | `HTTP.Port` | `HTTP_PORT` | | `HTTP.MaxConns` | `HTTP_MAX_CONNS` | | `DBName` | `DB_NAME` | | `HTTP.Timeouts.Read`, `Timeouts` is embedded | `HTTP_READ` | The `env` tag still has priority, and the field can be excluded with `env:"-"`. ### Secret files Docker and Kubernetes mount secrets as files. With `env.WithSecretFiles()` the value can be read from such file: if the `HTTP_TOKEN` variable is not set, but `HTTP_TOKEN_FILE` is set, the value is read from the file with the path from `HTTP_TOKEN_FILE`. ```go type Config struct { Token string `env:"HTTP_TOKEN" hidden:"true"` } ``` ```bash HTTP_TOKEN_FILE=/run/secrets/http_token ``` The trailing line break of the file content is removed. The variable with the value itself has priority over the file. ### Indexed keys By default, a slice, a map or a list of structs is set by one variable with the whole value: `HOSTS=a,b` or JSON. With `env.WithIndexedKeys()` every item can have its own variable. It is the form that is natural for Kubernetes manifests and Docker Compose files, where a long JSON in one variable is hard to read and to override. ```go type Server struct { Host string `env:"HOST"` MaxConns int Tags []string } type Config struct { Hosts []string `env:"HOSTS"` Labels map[string]string `env:"LABELS"` Servers []Server `env:"SERVERS"` } ``` | Type | Variables | | -------------------------- | -------------------------------------------------------------------------------------- | | `Hosts []string` | `HOSTS_0=a`, `HOSTS_1=b` | | `Labels map[string]string` | `LABELS_env=prod`, `LABELS_team=core` | | `Servers []Server` | `SERVERS_0_HOST=a`, `SERVERS_0_MAX_CONNS=10`, `SERVERS_0_TAGS_0=x`, `SERVERS_1_HOST=b` | Rules: - **Slices and arrays** — the index is added to the name of the field. Indexes should be sequential and start from 0, a gap is an error. - **Maps** — the rest of the name after the prefix is a key, as is, case-sensitive. - **Structs** inside slices and maps — names of the fields are taken from their `env` tags or built from the field names: `MaxConns` is `MAX_CONNS`. Fields with `env:"-"` are skipped. - **Nesting** is not limited: `SERVERS_0_TAGS_0` is the first tag of the first server. - The variable with the **whole value has priority**: if `HOSTS` is set, `HOSTS_0` is ignored. - The [prefix](#prefix) is added to all names: `APP_HOSTS_0`. Names of the map fields For a map with simple values every variable started with the name of the field and `_` becomes an item of the map. With `Labels` read from `LABELS`, the variable `LABELS_FILE` is the `FILE` key. Choose the name of such field so that it is not a prefix of other variables. ## .env files By default, `env.New` reads the `.env` files from the directory of the executable and from the working directory and exports their values to the process environment. Absent default files are skipped. ```bash # comment HTTP_HOST=example.com TOKEN="quoted value" ``` - Empty lines and lines started with `#` are skipped. - Matching single or double quotes around the value are removed. - Variables that are already set in the process environment are not overridden, so the real environment has priority over the files. ### Custom files ```go env.New(env.WithFiles(".env", ".env.local")) ``` `WithFiles` replaces the default files. All listed files should exist, otherwise `env.New` returns an error. The value from the previous file has priority over the next files, and the process environment has priority over all files. `env.WithFiles()` without arguments disables reading of the `.env` files at all. ### Isolation Exported values are visible to the whole process and to its child processes. With `env.WithFilesIsolation()` the values of the files are available only for the driver, `os.Getenv` does not see them. It is also useful in tests, where the changed environment of the process is a side effect. ### Reloading The `.env` files are read again before every `Parse`, and they are watched for [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md): a change of a file reloads the configs with subscribers. The files are watched by the events of the file system and by polling, as the files of the [yaml](https://insei.github.io/tinyconf/2.2/drivers/yaml/index.md) driver; the default files that do not exist yet are watched too. ```go envDriver, err := env.New(env.WithFiles(".env"), env.WithWatchInterval(time.Minute)) ``` The driver changes only the variables that it exported itself: the new value of the file replaces the exported one, the variable removed from the file is removed from the process environment. Variables set by the environment of the process or changed by the application keep their values. The process environment itself is not watched, it is not changed from the outside of the process. ## Documentation `GenDoc("env")` generates a commented template of the `.env` file, see [Documentation generation](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md). Names of the variables are shown with the prefix, fields without the `env` tag are included if the auto keys are enabled. # etcd The etcd package reads the config from [etcd](https://etcd.io) version 3 in two ways: | Way | Where the config is | Tags | | ------------------------------- | ---------------------------------------------------- | ------------------------------------- | | [key per field](#key-per-field) | every value in its own key: `app/http/port` | `etcd` | | [document](#document) | YAML, JSON or TOML document in one key: `app/config` | of the format: `yaml`, `json`, `toml` | Both ways report changes for [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md) as soon as they happen: the driver watches the keys by the stream of the events of etcd, not by polling. The package talks to the [gRPC gateway](https://etcd.io/docs/v3.5/dev-guide/api_grpc_gateway/) of etcd — its JSON API — directly and adds no dependencies. ## Key per field ```go import "github.com/insei/tinyconf/v2/drivers/etcd" etcdDriver, err := etcd.New("app") ``` The key of the field is the prefix and the path by the `etcd` tags, dots are replaced with slashes: ```go type Config struct { HTTP struct { Host string `etcd:"host"` Port int `etcd:"port"` } `etcd:"http"` Hosts []string `etcd:"hosts"` } ``` ```bash etcdctl put app/http/host example.com etcdctl put app/http/port 9090 etcdctl put app/hosts a.example.com,b.example.com ``` Values are parsed by the rules of [string values](https://insei.github.io/tinyconf/2.2/features/types/index.md), like environment variables: slices are comma separated, structs in slices are JSON. All keys with the prefix are read by one request once per `Parse` call. A key that is absent is not an error, the field keeps its value from the previous driver. Set the tags of the parents The key of the nested field is built from the `etcd` tags of its parents. If the `HTTP` field has no `etcd` tag, the `Host` field is read from `app/host`, not from `app/http/host`. When a value is found this way, the manager [logs a warning](https://insei.github.io/tinyconf/2.2/features/logging/index.md). [Embedded structs](https://insei.github.io/tinyconf/2.2/features/structs/#embedded-structs) without the tag are the exception. ## Document `etcd.Document` is a source of the config content for the [yaml](https://insei.github.io/tinyconf/2.2/drivers/yaml/index.md), [json](https://insei.github.io/tinyconf/2.2/drivers/json/index.md) and [toml](https://insei.github.io/tinyconf/2.2/drivers/toml/index.md) drivers. The values are read by the tags of the format driver and merged with the files of the driver, as one more file: ```go yamlDriver, err := yaml.New("config.yaml", yaml.WithSource(etcd.Document("app/config")), ) ``` ```bash etcdctl put app/config < config.yaml ``` An absent key is an error of `Parse`, unless the format driver has `WithOptional()`. ## Connection ```go etcdDriver, err := etcd.New("app", etcd.WithEndpoints("https://etcd-1:2379", "https://etcd-2:2379", "https://etcd-3:2379"), etcd.WithAuth("app", password), etcd.WithHTTPClient(&http.Client{Transport: tlsTransport}), ) document := etcd.Document("app/config", etcd.WithEndpoints("etcd:2379")) ``` | Option | Default | Description | | ----------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------- | | `WithEndpoints(endpoints...)` | `ETCDCTL_ENDPOINTS` or `http://127.0.0.1:2379` | `host:port` or URL with the scheme; the variable is comma separated | | `WithAuth(user, password)` | `ETCDCTL_USER` (`user` or `user:password`) and `ETCDCTL_PASSWORD` | the user for the clusters with authentication | | `WithHTTPClient(client)` | `http.DefaultClient` | the client with TLS settings and client certificates, for example | The options are the same for `New` and `Document`, and the variables are the same as etcdctl uses. Requests go to the next endpoint if the current one is not available, and the endpoint that has answered is used first next time. A cluster keeps working for the application while it has the quorum. With the user the driver gets the token of etcd and gets it again when the token is expired. ## Permissions | Case | Result | | ---------------------------------------------- | ---------------------------------------------------------------------------------- | | authentication is enabled, the user is not set | error of `Parse`: `user name is empty` | | wrong password | error of `Parse`: `authentication failed` | | the user can not read the prefix or the key | error of `Parse`: `permission denied` | | access is denied while watching | the driver stops watching, the error is logged: retries do not fix the permissions | All these errors wrap `tinyconf.ErrAccessDenied`: the manager [logs](https://insei.github.io/tinyconf/2.2/features/logging/index.md) them as `access denied`, and they can be checked with `errors.Is`. The role of the user needs `read` on the prefix: `etcdctl role grant-permission --prefix=true app read app/`. ## Watching The manager receives the changes of the keys with the prefix, or of the key of the document, right after they are written to etcd. Keys outside of the prefix do not cause reloads. If the stream of the events is broken, the driver watches again from the last known revision, with the interval that grows from 1 to 30 seconds, so no changes are missed. If the history of the changes is already [compacted](https://etcd.io/docs/v3.5/op-guide/maintenance/#history-compaction-v3-api-key-value-database), the config is reloaded from the current state. The same is true for the start of the application: if etcd is not available at the first `Parse`, the values are loaded by the reloading as soon as etcd is available, and the subscribers are called. ## Documentation `GenDoc("etcd")` generates the commands of etcdctl that put the current values to etcd, values of `hidden:"true"` fields are empty: ```bash # http host etcdctl put -- app/http/host example.com etcdctl put -- app/http/port 9090 etcdctl put -- app/hosts a.example.com,b.example.com ``` After `Parse` these are the values of the current environment: a way to move the config to etcd. For the document use `GenDoc` of the format driver. # flag The flag driver reads values from command line flags. ```go import "github.com/insei/tinyconf/v2/drivers/flag" flagDriver, err := flag.New() ``` Add it last, so the flags have the highest priority: ```go config, err := tinyconf.New( tinyconf.WithDriver(tagDriver), tinyconf.WithDriver(yamlDriver), tinyconf.WithDriver(envDriver), tinyconf.WithDriver(flagDriver), ) ``` ## Names The `flag` tag is the name of the flag without dashes: ```go type Config struct { HTTP struct { Host string `flag:"http-host" doc:"http host"` Port int `flag:"http-port" doc:"http port"` } Hosts []string `flag:"host" doc:"allowed hosts"` Verbose bool `flag:"verbose"` } ``` ```bash app --http-host=example.com --http-port 9090 --host=a --host=b -verbose ``` A field without the `flag` tag is not read from the command line. ## Supported forms | Form | Example | | --------------------------------- | ------------------ | | `-name=value`, `--name=value` | `--http-port=9090` | | `-name value`, `--name value` | `--http-port 9090` | | `-name`, `--name` for bool fields | `--verbose` | - Bool fields do not take the next argument as a value: use `--verbose=false` to set false. - Slices and arrays can be filled with repeated flags: `--host=a --host=b,c` is `[a b c]`. For all other types the last occurrence of the flag is used. - All arguments after `--` are ignored. - Values are parsed by the rules of [string values](https://insei.github.io/tinyconf/2.2/features/types/index.md). ## Unknown flags The driver does not own the command line: it only looks for the flags of the config fields. Unknown flags and positional arguments are ignored, so the driver can be used together with the `flag` package of the standard library or with any CLI framework. No --help The driver does not handle `-h` and `--help`. Print [`GenDoc("flag")`](#documentation) from your own handler of the help flag. ## Custom arguments By default `os.Args[1:]` is used. Custom arguments are set with `WithArgs`, in tests or for sub commands: ```go flagDriver, err := flag.New(flag.WithArgs(os.Args[2:])) ``` ## Documentation `GenDoc("flag")` generates the usage of all flags with descriptions from the `doc` tags and current values as defaults: ```text -http-host http host (default example.com) -http-port http port (default 9090) -host allowed hosts (default a,b) -verbose (default false) ``` Values of the fields with `hidden:"true"` are not shown. # Custom formats The format driver reads config files of any format with nested objects. The format is set by a function that decodes the content to a map, everything else is the same as in the [yaml](https://insei.github.io/tinyconf/2.2/drivers/yaml/index.md), [json](https://insei.github.io/tinyconf/2.2/drivers/json/index.md) and [toml](https://insei.github.io/tinyconf/2.2/drivers/toml/index.md) drivers: several files, merging, embedded files, [remote documents](https://insei.github.io/tinyconf/2.2/drivers/consul/#document), environment variables in the content, [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md), [documentation](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md). ```go import "github.com/insei/tinyconf/v2/drivers/format" driver, err := format.New("hujson", decodeHuJSON, "config.hujson") ``` The name of the driver is the name of the struct tag with the keys, and the name for [`GenDoc`](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md): ```go type Config struct { HTTP struct { Port int `hujson:"port"` } `hujson:"http"` } ``` ## Decoder ```go type Decoder func(data []byte) (map[string]any, error) ``` The decoder returns the content as a map: - keys of the map are the keys of the config, nested maps with string keys are nested objects; - slices are lists, other values are scalars: strings, numbers, booleans, `time.Time`; - empty content can be decoded to a nil map. Values are converted to the types of the fields by the same rules as the values of yaml and json, see [Supported types](https://insei.github.io/tinyconf/2.2/features/types/index.md). For example, [HuJSON](https://github.com/tailscale/hujson) — JSON with comments and trailing commas: ```go import "github.com/tailscale/hujson" func decodeHuJSON(data []byte) (map[string]any, error) { data, err := hujson.Standardize(data) if err != nil { return nil, err } var doc map[string]any return doc, json.Unmarshal(data, &doc) } ``` Libraries that decode to the structs only, like HCL, need the conversion of their result to the map. ## Options | Option | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `WithFiles(files...)` | additional files from the OS file system | | `WithFS(fsys, files...)` | files from any `fs.FS`, `embed.FS` for example | | `WithData(name, data)` | in-memory content, the name is used in error messages | | `WithSource(source)` | content from a remote storage: [`consul.Document`](https://insei.github.io/tinyconf/2.2/drivers/consul/#document), [`etcd.Document`](https://insei.github.io/tinyconf/2.2/drivers/etcd/#document) | | `WithOptional()` | files can be absent | | `WithEnvExpansion()` | replaces `${VAR}` in the content with environment variables | | `WithWatchInterval(interval)` | interval of polling the files for [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md), 5 seconds by default | | `WithEncoder(encode)` | encoder of the documents for `GenDoc` | The rules of the files are the same as in the [yaml](https://insei.github.io/tinyconf/2.2/drivers/yaml/#several-files) driver. ## Documentation `GenDoc` needs the encoder: ```go type Encoder func(doc map[string]any) ([]byte, error) driver, err := format.New("hujson", decodeHuJSON, "config.hujson", format.WithEncoder(func(doc map[string]any) ([]byte, error) { return json.MarshalIndent(doc, "", " ") })) ``` The document has the same structure as the result of the decoder, with the current values of the registered configs; values of `hidden:"true"` fields are empty strings. Without the encoder `GenDoc` returns an empty string. ## Not a file format? If the values are not stored as documents — a database, an API, a secret manager — write a [custom driver](https://insei.github.io/tinyconf/2.2/drivers/custom/index.md). # ini The ini driver reads values from INI files. ```go import "github.com/insei/tinyconf/v2/drivers/ini" iniDriver, err := ini.New("config.ini") ``` The driver has its own parser and adds no dependencies. ## Keys The `ini` tag is a key in the file. Tags of [nested structs](https://insei.github.io/tinyconf/2.2/features/structs/index.md) are sections: ```go type Config struct { Name string `ini:"name"` HTTP struct { Host string `ini:"host"` Port int `ini:"port"` TLS struct { Cert string `ini:"cert"` } `ini:"tls"` } `ini:"http"` } ``` ```ini name = app [http] host = example.com port = 9090 [http.tls] cert = /etc/tls/cert.pem ``` Dots in the names of the sections and in the keys are nested objects: `[http.tls]` is the `tls` object inside `http`, and `http.port = 9090` in the root is the same as `port = 9090` in the `[http]` section. Set the tags of the parents The path of the nested field is built from the `ini` tags of its parents. If the `HTTP` field has no `ini` tag, the `Host` field is read from the `host` key in the root of the file, and the manager [logs a warning](https://insei.github.io/tinyconf/2.2/features/logging/index.md). [Embedded structs](https://insei.github.io/tinyconf/2.2/features/structs/#embedded-structs) without the tag are the exception. ## Syntax | Element | Example | | ------------------------------------ | -------------------------------------- | | key and value | `port = 9090`, `port: 9090` | | section | `[http]`, `[http.tls]` | | comment | `; comment`, `# comment` | | comment after the value | `port = 9090 ; comment`, after a space | | value in double quotes, with escapes | `name = "my \"app\"\n"` | | value in single quotes, as is | `name = 'my app; # not a comment'` | | empty value | `name =` | Values of INI are strings, they are parsed by the rules of [string values](https://insei.github.io/tinyconf/2.2/features/types/index.md) like environment variables: `true`, `9090`, `1m30s`; lists are comma separated `hosts = a,b`, maps are `labels = env:prod,team:core`, structs in lists are JSON with the keys by the `ini` tags: `servers = '[{"host": "a", "port": 1}]'`. A map can be a section too: ```ini [labels] env = prod team = core ``` A key can not be a value and a section at the same time: `http = 1` and `[http]` in one file is an error of `Parse` with the number of the line. ## Options The options are the same as of the [yaml](https://insei.github.io/tinyconf/2.2/drivers/yaml/#options) driver: | Option | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `WithFiles(files...)` | additional files from the OS file system | | `WithFS(fsys, files...)` | files from any `fs.FS`, `embed.FS` for example | | `WithData(name, data)` | in-memory content, the name is used in error messages | | `WithSource(source)` | content from a remote storage: [`consul.Document`](https://insei.github.io/tinyconf/2.2/drivers/consul/#document), [`etcd.Document`](https://insei.github.io/tinyconf/2.2/drivers/etcd/#document) | | `WithOptional()` | files can be absent | | `WithEnvExpansion()` | replaces `${VAR}` in the content with environment variables | | `WithWatchInterval(interval)` | interval of polling the files for [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md), 5 seconds by default; changes are detected by the events of the file system without waiting for it | Files are [merged](https://insei.github.io/tinyconf/2.2/drivers/yaml/#several-files) in the order of addition, sections are merged by keys. ## Documentation `GenDoc("ini")` generates the INI file with the current values: the values of the root first, then the sections of the nested structs. Values of `hidden:"true"` fields are empty, the values that need it are quoted, so the file is read back to the same config. ```ini hosts = a,b name = 'my app; with comment chars' [http] host = example.com port = 9090 ``` # json The json driver reads values from JSON files. ```go import "github.com/insei/tinyconf/v2/drivers/json" jsonDriver, err := json.New("config.json") ``` ## Keys The `json` tag is a key in the file. Tags of [nested structs](https://insei.github.io/tinyconf/2.2/features/structs/index.md) are keys of nested objects: ```go type Config struct { Name string `json:"name"` HTTP struct { Host string `json:"host"` Port int `json:"port"` } `json:"http"` Hosts []string `json:"hosts"` } ``` ```json { "name": "app", "http": { "host": "example.com", "port": 9090 }, "hosts": ["a", "b"] } ``` Options after the comma are ignored: `json:"port,omitempty"` is the `port` key. Set the `json` tag on all parents of the field: without the tag of the parent the field is read from the root of the file, and the manager [logs a warning](https://insei.github.io/tinyconf/2.2/features/logging/index.md). [Embedded structs](https://insei.github.io/tinyconf/2.2/features/structs/#embedded-structs) without the tag are the exception, their fields are on the level of the embedding struct. ## Options The options are the same as in the [yaml driver](https://insei.github.io/tinyconf/2.2/drivers/yaml/#options): ```go jsonDriver, err := json.New("config.json", json.WithFiles("config.local.json"), json.WithOptional(), json.WithEnvExpansion(), ) ``` | Option | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `WithFiles(files...)` | additional files from the OS file system | | `WithFS(fsys, files...)` | files from any `fs.FS`, `embed.FS` for example | | `WithData(name, data)` | in-memory content, the name is used in error messages | | `WithSource(source)` | content from a remote storage: [`consul.Document`](https://insei.github.io/tinyconf/2.2/drivers/consul/#document) | | `WithOptional()` | files can be absent | | `WithEnvExpansion()` | replaces `${VAR}` and `${VAR:-default}` in the content with environment variables | | `WithWatchInterval(interval)` | interval of polling the files for [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md), 5 seconds by default; changes are detected by the events of the file system without waiting for it | Several files are [merged](https://insei.github.io/tinyconf/2.2/drivers/yaml/#several-files) in the order of addition, objects are merged by keys. Files are read once per `Parse` call. ## Documentation `GenDoc("json")` generates a JSON document with all fields and their current values: ```json { "hosts": [ "a", "b" ], "http": { "host": "example.com", "port": 9090 }, "name": "app" } ``` JSON has no comments, so the `doc` tags are not used. Values of the fields with `hidden:"true"` are replaced with empty strings. # properties The properties driver reads values from `.properties` files — the format of Java and Spring, `application.properties`. ```go import "github.com/insei/tinyconf/v2/drivers/properties" propertiesDriver, err := properties.New("application.properties") ``` The driver has its own parser and adds no dependencies. ## Keys The `properties` tag is a part of the key. Tags of [nested structs](https://insei.github.io/tinyconf/2.2/features/structs/index.md) are the parts before the dots: ```go type Config struct { Name string `properties:"name"` HTTP struct { Host string `properties:"host"` Port int `properties:"port"` } `properties:"http"` } ``` ```properties name=app http.host=example.com http.port=9090 ``` Set the tags of the parents The key of the nested field is built from the `properties` tags of its parents. If the `HTTP` field has no `properties` tag, the `Host` field is read from the `host` key, and the manager [logs a warning](https://insei.github.io/tinyconf/2.2/features/logging/index.md). [Embedded structs](https://insei.github.io/tinyconf/2.2/features/structs/#embedded-structs) without the tag are the exception. ## Syntax The syntax of [`java.util.Properties`](https://docs.oracle.com/javase/8/docs/api/java/util/Properties.html#load-java.io.Reader-): | Element | Example | | ----------------------------- | --------------------------------------------------------------------------------------- | | key and value | `port=9090`, `port: 9090`, `port 9090` | | comment | `# comment`, `! comment` | | escapes | `\t`, `\n`, `\r`, `\f`, `\\`, `\uXXXX`; `\` before any other character is the character | | separators and spaces in keys | `key\ with\=separators=value` | | long value on several lines | a backslash at the end of the line, the leading spaces of the next line are skipped | The spaces before the value are skipped, the spaces at the end are a part of the value, like in Java. Values are strings, they are parsed by the rules of [string values](https://insei.github.io/tinyconf/2.2/features/types/index.md) like environment variables: `true`, `9090`, `1m30s`; lists are comma separated `hosts=a,b`, maps are `labels=env:prod,team:core` or the keys `labels.env=prod`, structs in lists are JSON with the keys by the `properties` tags: `servers=[{"host": "a", "port": 1}]`. A key can not be a value and an object at the same time: `http=1` and `http.port=2` in one file is an error of `Parse` with the number of the line. ## Options The options are the same as of the [yaml](https://insei.github.io/tinyconf/2.2/drivers/yaml/#options) driver: | Option | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `WithFiles(files...)` | additional files from the OS file system: `application-prod.properties` | | `WithFS(fsys, files...)` | files from any `fs.FS`, `embed.FS` for example | | `WithData(name, data)` | in-memory content, the name is used in error messages | | `WithSource(source)` | content from a remote storage: [`consul.Document`](https://insei.github.io/tinyconf/2.2/drivers/consul/#document), [`etcd.Document`](https://insei.github.io/tinyconf/2.2/drivers/etcd/#document) | | `WithOptional()` | files can be absent | | `WithEnvExpansion()` | replaces `${VAR}` in the content with environment variables | | `WithWatchInterval(interval)` | interval of polling the files for [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md), 5 seconds by default; changes are detected by the events of the file system without waiting for it | Files are [merged](https://insei.github.io/tinyconf/2.2/drivers/yaml/#several-files) in the order of addition, like the profiles of Spring: ```go propertiesDriver, err := properties.New("application.properties", properties.WithFiles("application-"+profile+".properties"), properties.WithOptional(), ) ``` ## Documentation `GenDoc("properties")` generates the file with the current values, sorted by the keys. Values of `hidden:"true"` fields are empty, the special characters are escaped, so the file is read back to the same config. ```properties http.host=example.com http.port=9090 name=app ``` # tag The tag driver takes values right from the struct tags. It is used for default values. ```go import "github.com/insei/tinyconf/v2/drivers/tag" tagDriver, err := tag.New("initial") ``` The argument is the name of the tag with values, any name can be used: `initial`, `default`. ```go type Config struct { Host string `initial:"localhost"` Port int `initial:"8080"` Timeout time.Duration `initial:"30s"` Hosts []string `initial:"a,b"` } ``` Add it first, so all other drivers can override the defaults: ```go config, err := tinyconf.New( tinyconf.WithDriver(tagDriver), tinyconf.WithDriver(yamlDriver), tinyconf.WithDriver(envDriver), ) ``` Values are parsed by the rules of [string values](https://insei.github.io/tinyconf/2.2/features/types/index.md). An incorrect default value is an error of `Parse`, so a typo in the tag is found at the first start. The driver has no own documentation format, `GenDoc` returns an empty string for it. Default values are visible in the documentation of other drivers as the values of the fields. # toml The toml driver reads values from TOML files. ```go import "github.com/insei/tinyconf/v2/drivers/toml" tomlDriver, err := toml.New("config.toml") ``` ## Keys The `toml` tag is a key in the file. Tags of [nested structs](https://insei.github.io/tinyconf/2.2/features/structs/index.md) are names of tables: ```go type Server struct { Host string `toml:"host"` Port int `toml:"port"` } type Config struct { Name string `toml:"name"` HTTP struct { Host string `toml:"host"` Timeout time.Duration `toml:"timeout"` } `toml:"http"` Hosts []string `toml:"hosts"` Labels map[string]string `toml:"labels"` Servers []Server `toml:"servers"` } ``` ```toml name = "app" hosts = ["a", "b"] [http] host = "example.com" timeout = "30s" [labels] env = "prod" [[servers]] host = "a" port = 1 [[servers]] host = "b" port = 2 ``` Set the `toml` tag on all parents of the field: without the tag of the parent the field is read from the root of the file, and the manager [logs a warning](https://insei.github.io/tinyconf/2.2/features/logging/index.md). [Embedded structs](https://insei.github.io/tinyconf/2.2/features/structs/#embedded-structs) without the tag are the exception, their fields are on the level of the embedding struct. Native TOML dates are supported for `time.Time` fields. ## Options The options are the same as in the [yaml driver](https://insei.github.io/tinyconf/2.2/drivers/yaml/#options): ```go tomlDriver, err := toml.New("config.toml", toml.WithFiles("config.local.toml"), toml.WithOptional(), toml.WithEnvExpansion(), ) ``` | Option | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `WithFiles(files...)` | additional files from the OS file system | | `WithFS(fsys, files...)` | files from any `fs.FS`, `embed.FS` for example | | `WithData(name, data)` | in-memory content, the name is used in error messages | | `WithSource(source)` | content from a remote storage: [`consul.Document`](https://insei.github.io/tinyconf/2.2/drivers/consul/#document) | | `WithOptional()` | files can be absent | | `WithEnvExpansion()` | replaces `${VAR}` and `${VAR:-default}` in the content with environment variables | | `WithWatchInterval(interval)` | interval of polling the files for [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md), 5 seconds by default; changes are detected by the events of the file system without waiting for it | Several files are [merged](https://insei.github.io/tinyconf/2.2/drivers/yaml/#several-files) in the order of addition, tables are merged by keys. Files are read once per `Parse` call. ## Documentation `GenDoc("toml")` generates a TOML document with all fields and their current values: ```toml hosts = ["a", "b"] name = "app" [http] host = "example.com" timeout = "30s" [labels] env = "prod" ``` - The `doc` tags are not used. - Values of the fields with `hidden:"true"` are replaced with empty strings. - TOML has no null, so nil pointers and other fields without values are skipped. # vault The vault driver reads values from the [KV secrets engine](https://developer.hashicorp.com/vault/docs/secrets/kv) of HashiCorp Vault. ```go import "github.com/insei/tinyconf/v2/drivers/vault" vaultDriver, err := vault.New("app/config") ``` The driver talks to the HTTP API of Vault directly and adds no dependencies. ## Keys A secret in Vault is a JSON object. The `vault` tag is a key in it, tags of [nested structs](https://insei.github.io/tinyconf/2.2/features/structs/index.md) are keys of nested objects: ```go type Config struct { Database struct { Host string `vault:"host"` Password string `vault:"password" hidden:"true"` } `vault:"database"` Hosts []string `vault:"hosts"` } ``` ```bash vault kv put -mount=secret app/config - <<'EOF' {"database": {"host": "db.internal", "password": "secret"}, "hosts": ["a", "b"]} EOF ``` Values of the secret can be JSON values or strings: `"port": 5432` and `"port": "5432"` are the same for an `int` field, `"timeout": "1m30s"` is a `time.Duration`, see [Supported types](https://insei.github.io/tinyconf/2.2/features/types/index.md). Set the tags of the parents The path of the nested field is built from the `vault` tags of its parents. If the `Database` field has no `vault` tag, the `Password` field is read from the `password` key in the root of the secret, and the manager [logs a warning](https://insei.github.io/tinyconf/2.2/features/logging/index.md). [Embedded structs](https://insei.github.io/tinyconf/2.2/features/structs/#embedded-structs) without the tag are the exception. ## Several secrets ```go vaultDriver, err := vault.New("app/config", vault.WithPaths("app/database", "shared/certificates")) ``` Secrets are merged in the order of addition, like [several files](https://insei.github.io/tinyconf/2.2/drivers/yaml/#several-files): values from the next secret override values from the previous one, nested objects are merged by keys. It is a common layout: the settings and the credentials in different secrets with different access policies. ## Options ```go vaultDriver, err := vault.New("app/config", vault.WithAddress("https://vault.internal:8200"), vault.WithTokenFile("/vault/secrets/token"), vault.WithMount("kv"), ) ``` | Option | Default | Description | | --------------------------------- | ---------------------------------------- | -------------------------------------------------------------- | | `WithAddress(address)` | `VAULT_ADDR` or `https://127.0.0.1:8200` | URL of Vault | | `WithToken(token)` | `VAULT_TOKEN` | the token | | `WithTokenFile(path)` | | the file with the token, it is read before every request | | `WithAppRole(roleID, secretID)` | | login by AppRole, see [Authentication](#authentication) | | `WithKubernetes(role, tokenFile)` | | login by the service account of the pod | | `WithAuthMount(mount)` | `approle`, `kubernetes` | the mount of the auth method | | `WithNamespace(namespace)` | `VAULT_NAMESPACE` | the namespace of Vault Enterprise | | `WithMount(mount)` | `secret` | the mount of the KV secrets engine | | `WithKVv1()` | KV version 2 | read from the KV secrets engine version 1 | | `WithHTTPClient(client)` | `http.DefaultClient` | the client with TLS settings, for example | | `WithPaths(paths...)` | | additional secrets | | `WithOptional()` | | secrets can be absent, by default an absent secret is an error | | `WithWatchInterval(interval)` | 30 seconds | the interval of checking the secrets for changes | The first argument of `New` can be an empty string, if the secrets are set by `WithPaths`. ## Authentication The driver logs in by AppRole or by the service account of a Kubernetes pod, or uses a token. ### AppRole ```go vaultDriver, err := vault.New("app/config", vault.WithAppRole(os.Getenv("VAULT_ROLE_ID"), os.Getenv("VAULT_SECRET_ID")), ) ``` ### Kubernetes ```go // the token of the service account from /var/run/secrets/kubernetes.io/serviceaccount/token vaultDriver, err := vault.New("app/config", vault.WithKubernetes("app", "")) ``` The second argument is the file with the token of the service account, a projected token with the audience of Vault for example. The file is read before every login, so the token rotated by Kubernetes is used. The auth methods are mounted at `approle` and `kubernetes` by default, another mount is set by `WithAuthMount("k8s-prod")`. The driver logs in at the first request and keeps the token. The new token is requested after 2/3 of its lease, and when Vault denies the request: the token can be revoked before its lease expires. Wrong credentials are an error of `Parse` that wraps `tinyconf.ErrAccessDenied`. A login method has priority over `WithToken`, `WithTokenFile` and `VAULT_TOKEN`. ### Token `WithToken` or the `VAULT_TOKEN` variable set the token. Other methods — cloud IAM, LDAP, JWT — are handled by [Vault Agent](https://developer.hashicorp.com/vault/docs/agent-and-proxy/agent): it logs in, renews the token and writes it to the file. Point the driver to this file: ```go vaultDriver, err := vault.New("app/config", vault.WithTokenFile("/vault/secrets/token")) ``` The file is read before every request, so the renewed token is used without restart of the application. ## Errors | Case | Result | | ----------------------------------------------------------------- | --------------------------------------------------------------------------------- | | the secret is absent | error of `Parse`, unless `WithOptional()` | | the token can not read the secret, wrong credentials of the login | error of `Parse` that wraps `tinyconf.ErrAccessDenied`, logged as `access denied` | | Vault is not available or sealed | error of `Parse` | The driver is skipped in the `Parse` call with the error, see [Missing files and values](https://insei.github.io/tinyconf/2.2/drivers/yaml/#missing-files-and-values). ## Reloading Vault does not notify about changes, so the driver checks the versions of the secrets every 30 seconds while there are [subscriptions](https://insei.github.io/tinyconf/2.2/features/reloading/index.md). The interval is set with `WithWatchInterval`. A secret that appears, disappears or becomes available again after an error is a change too. For KV version 2 the version of the secret is checked, for KV version 1 — the hash of its data. ## Documentation `GenDoc("vault")` generates the command of the Vault CLI that puts the current values to the first secret, values of `hidden:"true"` fields are empty: ```bash vault kv put -mount=secret app/config - <<'EOF' { "database": { "host": "db.internal", "password": "" }, "hosts": [ "a", "b" ] } EOF ``` # yaml The yaml driver reads values from YAML files. ```go import "github.com/insei/tinyconf/v2/drivers/yaml" yamlDriver, err := yaml.New("config.yaml") ``` ## Keys The `yaml` tag is a key in the file. Tags of [nested structs](https://insei.github.io/tinyconf/2.2/features/structs/index.md) are keys of nested maps: ```go type Config struct { Name string `yaml:"name"` HTTP struct { Host string `yaml:"host"` Port int `yaml:"port"` } `yaml:"http"` } ``` ```yaml name: app http: host: example.com port: 9090 ``` Options after the comma are ignored: `yaml:"port,omitempty"` is the `port` key. Set the tags of the parents The path of the nested field is built from the `yaml` tags of its parents. If the `HTTP` field has no `yaml` tag, the `Host` field is read from the `host` key in the root of the file, not from `http.host`. When a value is found this way, the manager [logs a warning](https://insei.github.io/tinyconf/2.2/features/logging/index.md) with the name of the parent without the tag and the key that was used. [Embedded structs](https://insei.github.io/tinyconf/2.2/features/structs/#embedded-structs) are the exception: they do not need the tag, their fields are on the level of the embedding struct. ## Options ```go yamlDriver, err := yaml.New("config.yaml", yaml.WithFiles("config.local.yaml"), yaml.WithOptional(), yaml.WithEnvExpansion(), ) ``` | Option | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `WithFiles(files...)` | additional files from the OS file system | | `WithFS(fsys, files...)` | files from any `fs.FS`, `embed.FS` for example | | `WithData(name, data)` | in-memory content, the name is used in error messages | | `WithSource(source)` | content from a remote storage: [`consul.Document`](https://insei.github.io/tinyconf/2.2/drivers/consul/#document) | | `WithOptional()` | files can be absent | | `WithEnvExpansion()` | replaces `${VAR}` in the content with environment variables | | `WithWatchInterval(interval)` | interval of polling the files for [reloading](https://insei.github.io/tinyconf/2.2/features/reloading/index.md), 5 seconds by default; changes are detected by the events of the file system without waiting for it | The first argument of `New` can be an empty string, if all sources are set by options. ## Several files Sources are merged in the order of addition: the file from `New` first, then the sources from options in the order of the options. Values from the next source override values from the previous one, nested maps are merged by keys, all other values — lists too — are replaced. ```yaml http: host: example.com port: 80 hosts: [a, b] ``` ```yaml http: port: 9090 hosts: [c] ``` ```yaml http: host: example.com port: 9090 hosts: [c] ``` It is a base for the layered configs: defaults embedded into the binary, the main file, the local overrides. ```go //go:embed defaults.yaml var defaults embed.FS yamlDriver, err := yaml.New("", yaml.WithFS(defaults, "defaults.yaml"), yaml.WithFiles("/etc/app/config.yaml", "config.local.yaml"), yaml.WithOptional(), ) ``` ## Missing files and values A key that is absent in the files is not an error, the field keeps its value from the previous driver. A file that can not be read is an error of `Parse`, and the driver is skipped in this `Parse` call. With `WithOptional()` absent files are skipped silently, all other errors — a directory instead of the file, incorrect YAML — are still returned. ## Environment variables in files With `WithEnvExpansion()` the content of the files is processed before decoding: | Syntax | Result | | ----------------- | ---------------------------------------------------------- | | `${VAR}` | value of the variable, empty string if it is not set | | `${VAR:-default}` | value of the variable, `default` if it is not set or empty | | `$$` | the `$` sign | ```yaml database: host: ${DB_HOST:-localhost} password: ${DB_PASSWORD} ``` ## Reading Files are read once per `Parse` call, not for every field. The next `Parse` call rereads them, so the changed file is picked up without recreation of the driver. ## Documentation `GenDoc("yaml")` generates a commented template of the file, see [Documentation generation](https://insei.github.io/tinyconf/2.2/features/gendoc/index.md).