diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bd94904..f620fed 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -44,8 +44,10 @@ jobs: if [[ "$GITHUB_REF" == refs/tags/v* ]]; then # v1.4.2 is the version 1.4, the latest release version="${GITHUB_REF_NAME#v}" + export LLMSTXT_BASE_URL="https://insei.github.io/tinyconf/${version%.*}/" mike deploy --push --update-aliases "${version%.*}" latest elif mike list | grep -v '^dev' | grep -q .; then + export LLMSTXT_BASE_URL="https://insei.github.io/tinyconf/dev/" mike deploy --push dev else # latest is dev until the first release with the versions of the documentation @@ -58,6 +60,8 @@ jobs: # mike builds the version into the site directory, the published site is checked out to another one git fetch origin gh-pages --depth=1 git worktree add pages origin/gh-pages + # llms.txt of the latest version is also at the root of the site, where the agents look for it first + cp -L pages/latest/llms.txt pages/latest/llms-full.txt pages/ 2>/dev/null || true - uses: actions/upload-pages-artifact@v3 with: path: pages diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 04894c7..946ce19 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -41,6 +41,10 @@ jobs: go vet ./... go build -o /dev/null ./... + - name: Build the programs of the skill for AI agents + if: matrix.go-version == 'stable' + run: scripts/check-skill.sh + - name: Test zap logger module if: matrix.go-version == 'stable' working-directory: logger/zaplogger diff --git a/Readme.md b/Readme.md index 9f78c46..492ad66 100644 --- a/Readme.md +++ b/Readme.md @@ -3,10 +3,13 @@ [![Goreport](https://goreportcard.com/badge/github.com/insei/tinyconf)](https://goreportcard.com/report/github.com/insei/tinyconf) [![GoDoc](https://godoc.org/github.com/insei/tinyconf/v2?status.svg)](https://godoc.org/github.com/insei/tinyconf/v2) # tinyconf -tinyconf - a simple and universal library for parsing configs. +tinyconf fills Go config structs from a chain of drivers — defaults, files, environment, flags, Consul, Vault, etcd. +Every package owns its part of the config and gets its changes without restart, and every value tells where it came from. Documentation: [insei.github.io/tinyconf](https://insei.github.io/tinyconf/) — get started, [why tinyconf](https://insei.github.io/tinyconf/latest/why-tinyconf/), [use cases](https://insei.github.io/tinyconf/latest/use-cases/), features and drivers. +For AI agents: the [skill](skills/tinyconf/SKILL.md) with the current API, and the documentation as one file, [llms-full.txt](https://insei.github.io/tinyconf/latest/llms-full.txt). + # Installation Install via go get. Note that Go 1.19 or newer is required. ```sh diff --git a/docs/ai-agents.md b/docs/ai-agents.md new file mode 100644 index 0000000..971435b --- /dev/null +++ b/docs/ai-agents.md @@ -0,0 +1,28 @@ +# 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. diff --git a/docs/requirements.txt b/docs/requirements.txt index 844a2a4..fc6e5d2 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ mkdocs-material>=9.5,<10 pymdown-extensions>=10.7,<11 mike>=2.1,<3 +mkdocs-llmstxt>=0.5,<0.6 diff --git a/docs/why-tinyconf.md b/docs/why-tinyconf.md index 8a3f339..2441864 100644 --- a/docs/why-tinyconf.md +++ b/docs/why-tinyconf.md @@ -1,25 +1,64 @@ # Why tinyconf? -Go has many config libraries: big key-value stores, struct-tag loaders, environment-only parsers. tinyconf occupies a specific niche: **one config struct, filled by a chain of drivers, that tells you where every value came from and lets every package subscribe to its own part**. This page lays out where it fits, what it gives up, and how it compares to the closest alternatives. +**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. -## The 30-second pitch +## Who it is for -- **The config is your struct.** Every field says where its value lives: `initial:"8080" yaml:"port" env:"HTTP_PORT" flag:"http-port"`. No key-value store in between, no `Unmarshal` step. -- **Drivers are a chain with priorities:** defaults from tags, files, remote stores, environment, flags — the next driver overrides the previous one, field by field. -- **Every value has a history.** [`Sources`](features/sources.md) tells which driver and which key gave the final value; the debug [log](features/logging.md#history-of-a-field) shows every override and every change after reloads. Secrets are masked everywhere. -- **Packages own their parts.** A package parses or [subscribes](features/reloading.md) to its sub config by type — `tinyconf.Subscribe(ctx, config, func(prev, next *HTTP) {...})` — and is called only when its part changes. -- **Documentation from the same structs:** a YAML template, a `.env` file, flags help, `consul kv put` / `vault kv put` / `etcdctl put` commands — in the format of every driver. -- **Remote stores without heavy dependencies:** Consul, Vault and etcd talk to their HTTP APIs directly; changes in Consul and etcd arrive right away, files are watched by the events of the file system. +- **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. -## When to pick tinyconf +## What you get -- Your service has several sources of config — defaults, a file, the environment, flags, a remote store — and you want one explicit priority order. -- You want to answer "why is this value here?" from the logs, without guessing. -- Your packages are independent: each one knows its own config struct, not the whole config. -- You want config changes applied without restart, with the old config never modified under your readers. -- You want the template of the config generated, not maintained by hand. +### Every package owns its part of the config -## When **not** to pick tinyconf +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](features/structs.md#sub-configs) and [Reloading](features/reloading.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](drivers/format.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](features/sources.md) and [Logging](features/logging.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](features/gendoc.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](ai-agents.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). @@ -42,21 +81,12 @@ Checked against the current documentation of the libraries on 2026-10-16. | **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 and docs | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | +| **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 | -## Strengths - -- **Provenance is built in.** No other library from the table reports which source gave the final value of every field. With several drivers it is the difference between reading the logs and guessing. See [Value sources](features/sources.md) and [Logging](features/logging.md). -- **Reloading that is safe to read.** A reload builds a new version from the defaults, the old one is never modified, so readers need no locks. Subscribers are called only for their part and only with a config without errors; a broken file keeps the last good config. See [Reloading](features/reloading.md). -- **The same types in every driver.** `time.Duration`, `url.URL`, `TextUnmarshaler`, slices, maps, structs in lists and pointers behave the same in a file, an environment variable, a flag and a remote store — the conformance tests check it for the tag, yaml, json, toml, env and flag drivers, the other drivers share their conversion. -- **Remote stores without the weight.** Consul, Vault and etcd drivers use their HTTP APIs: no SDKs, no gRPC. They handle the real-world cases — ACL that hides keys, expired tokens, compacted history, restarts of the store, failover between etcd endpoints — and are tested against real servers in Docker in CI. -- **Errors you can act on.** `Parse` collects all errors of all fields at once; `ErrAccessDenied` separates permission problems from network ones; `required:"true"` fails fast at the start. -- **100% test coverage**, race detector and the minimal supported Go version in CI. - -## Weaknesses +## Known limitations - **Fewer remote stores than koanf.** Consul, Vault and etcd only; others need a [custom driver](drivers/custom.md). - **Vault: token authentication only.** Kubernetes, AppRole and cloud login are delegated to Vault Agent; changes are detected by polling, because Vault does not notify. diff --git a/mkdocs.yml b/mkdocs.yml index e67771d..0d38370 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: tinyconf -site_description: Simple and universal library for parsing configs in Go +site_description: Go config structs filled from a chain of drivers, every package owns its part of the config and gets its changes without restart site_url: https://insei.github.io/tinyconf/ repo_url: https://github.com/Insei/tinyconf repo_name: Insei/tinyconf @@ -74,6 +74,7 @@ nav: - Why tinyconf?: why-tinyconf.md - Use cases: use-cases.md - Upgrading to v2: upgrading.md + - For AI agents: ai-agents.md - Features: - features/index.md - Manager: features/manager.md @@ -100,3 +101,27 @@ nav: - etcd: drivers/etcd.md - Custom formats: drivers/format.md - Custom drivers: drivers/custom.md + +plugins: + - search + # llms.txt and llms-full.txt for AI agents: https://llmstxt.org + - llmstxt: + full_output: llms-full.txt + # the links point to the pages of the deployed version, it is set by the docs workflow + base_url: !ENV [LLMSTXT_BASE_URL, "https://insei.github.io/tinyconf/latest/"] + markdown_description: >- + 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. + sections: + Start: + - index.md: installation and the first config + - why-tinyconf.md: for whom and for what, comparison with viper, koanf, cleanenv + - use-cases.md: recipes + - upgrading.md: upgrading from v1 + - ai-agents.md: the skill for AI agents + Features: + - features/*.md + Drivers: + - drivers/*.md diff --git a/scripts/check-skill.sh b/scripts/check-skill.sh new file mode 100755 index 0000000..8789d44 --- /dev/null +++ b/scripts/check-skill.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Builds the complete programs (code blocks with "package main") of the skill for AI agents against the code of the repository, +# so the skill does not describe the API that does not exist. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +skill=${1:-$root/skills/tinyconf/SKILL.md} +# SKILL_WORKDIR keeps the built programs to run them by hand +if [ -n "${SKILL_WORKDIR:-}" ]; then + work=$SKILL_WORKDIR + mkdir -p "$work" +else + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT +fi + +awk -v dir="$work" ' + /^```go$/ { block = ""; inside = 1; next } + /^```$/ && inside { + inside = 0 + if (block ~ /^package main/) { + n++ + path = dir "/p" n + system("mkdir -p " path) + printf "%s", block > (path "/main.go") + close(path "/main.go") + } + next + } + inside { block = block $0 "\n" } +' "$skill" + +count=$(find "$work" -name main.go | wc -l) +if [ "$count" -eq 0 ]; then + echo "no programs in $skill" >&2 + exit 1 +fi +cat > "$work/go.mod" < $root +MOD +cp "$root/go.sum" "$work/go.sum" +cd "$work" +go mod tidy >/dev/null +go vet ./... +go build ./... +echo "$count programs of $(basename "$(dirname "$skill")") skill are built" diff --git a/skills/tinyconf/SKILL.md b/skills/tinyconf/SKILL.md new file mode 100644 index 0000000..8042081 --- /dev/null +++ b/skills/tinyconf/SKILL.md @@ -0,0 +1,261 @@ +--- +name: tinyconf +description: Write Go code that loads configuration with github.com/insei/tinyconf/v2 — config structs with tags filled by a chain of drivers (tag defaults, yaml, json, toml, ini, properties, env, flag, Consul, Vault, etcd), sub configs of packages, reloading with Subscribe and Current, required fields, secrets, value sources, generated config templates and loggers. Use when the code imports github.com/insei/tinyconf, or when a Go service needs configuration from several sources with priorities or reloading without restart. +--- + +# tinyconf v2 + +A config is a Go struct. Every field says where its value lives by tags, drivers fill the struct in the order they are added, **the next driver overrides the previous one** field by field. Full documentation: https://insei.github.io/tinyconf/latest/, as one file for the context: https://insei.github.io/tinyconf/latest/llms-full.txt. + +## Rules + +- The module path is `github.com/insei/tinyconf/v2`: `go get github.com/insei/tinyconf/v2@latest`, the drivers are `github.com/insei/tinyconf/v2/drivers/`. Never import `github.com/insei/tinyconf` without `/v2`, it is v1 with another API. +- `Parse` takes the context: `config.Parse(ctx, conf)`. There is no `ParseContext`, no `Watch` and no `logger.NewZapLogger` in v2. +- `Register` the config (a pointer to struct) before `Parse`. `Parse` accepts the registered config or a field of it with struct type (a sub config), otherwise `tinyconf.ErrNotRegisteredConfig`. +- `Parse` parses all fields and returns all errors as one error, check it with `errors.Is(err, tinyconf.ErrRequired)` and so on. A field gets the value of the last driver that has one, a field without values keeps the value it had before `Parse`. +- A field is read by a driver only if it has the tag of the driver (`yaml:"port"`, `env:"HTTP_PORT"`...). The yaml, json, toml, ini, properties, Consul, Vault and etcd drivers need the tags on the parent structs too: `yaml:"port"` inside `yaml:"http"` is read from `http.port`. The env and flag drivers use the full name in the tag of the field. +- Defaults are the `initial:"..."` tags read by `tag.New("initial")` as the first driver, or values set in the struct before `Parse`. + +| Tag | Meaning | +|---|---| +| `initial:"8080"` | default value, read by the tag driver created with `tag.New("initial")` (any tag name works) | +| `yaml`, `json`, `toml`, `ini`, `properties`, `consul`, `vault`, `etcd` | key of the field inside its parent | +| `env:"HTTP_PORT"`, `flag:"http-port"` | full name of the variable or the flag | +| `required:"true"` | `Parse` returns `tinyconf.ErrRequired` if no driver has a value and the field is zero | +| `hidden:"true"` | the value is masked in the logs and is empty in `GenDoc` | +| `doc:"port of the server"` | description in the templates generated by `GenDoc` | +| `merge:"true"` | slices are appended and maps are merged from all drivers instead of overriding | + +Supported types: strings, numbers, bools, `time.Duration`, `time.Time`, `url.URL`, `encoding.TextUnmarshaler`, slices, arrays, maps, nested structs, pointers. Use `tinyconf.Secret` for passwords and tokens: it prints `******` in `fmt`, JSON and logs, `Value()` returns the value. + +## A service with defaults, a file, the environment and flags + +```go +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/insei/tinyconf/v2" + "github.com/insei/tinyconf/v2/drivers/env" + "github.com/insei/tinyconf/v2/drivers/flag" + "github.com/insei/tinyconf/v2/drivers/tag" + "github.com/insei/tinyconf/v2/drivers/yaml" +) + +type HTTP struct { + Host string `initial:"localhost" yaml:"host" env:"HTTP_HOST" flag:"http-host" doc:"host of the server"` + Port int `initial:"8080" yaml:"port" env:"HTTP_PORT" flag:"http-port" doc:"port of the server"` + Timeout time.Duration `initial:"30s" yaml:"timeout" env:"HTTP_TIMEOUT" doc:"timeout of the requests"` +} + +type Database struct { + DSN string `yaml:"dsn" env:"DB_DSN" required:"true" doc:"address of the database"` + Password tinyconf.Secret `yaml:"password" env:"DB_PASSWORD" doc:"password of the database"` +} + +type Config struct { + HTTP HTTP `yaml:"http"` + Database Database `yaml:"database"` +} + +func main() { + tagDriver, err := tag.New("initial") + if err != nil { + panic(err) + } + // the file is optional: the defaults and the environment are enough + yamlDriver, err := yaml.New("config.yaml", yaml.WithOptional()) + if err != nil { + panic(err) + } + envDriver, err := env.New() // also reads .env files if they exist + if err != nil { + panic(err) + } + flagDriver, err := flag.New() // reads os.Args, unknown flags are ignored + if err != nil { + panic(err) + } + // priority: tags < config.yaml < environment < flags + config, err := tinyconf.New( + tinyconf.WithDriver(tagDriver), + tinyconf.WithDriver(yamlDriver), + tinyconf.WithDriver(envDriver), + tinyconf.WithDriver(flagDriver), + ) + if err != nil { + panic(err) + } + conf := &Config{} + if err = config.Register(conf); err != nil { + panic(err) + } + if err = config.Parse(context.Background(), conf); err != nil { + fmt.Fprintln(os.Stderr, "config:", err) + os.Exit(1) + } + fmt.Printf("%+v\n", *conf) // the password is printed as ****** + + // which driver and which key gave every value + for _, source := range config.Sources(conf) { + fmt.Println(source.Field, source.Driver, source.Source) + } + // the template of the config with the current values and the doc tags: "yaml", "env", "flag"... + fmt.Print(config.GenDoc("env")) +} +``` + +## Packages own their parts: sub configs and reloading + +A package works with its own struct type, not with the whole config. `Parse(ctx, &HTTP{})` parses only this part of the registered config, `tinyconf.Current[HTTP](config)` returns its current version, `tinyconf.Subscribe` calls the handler only when this part changes. Reloading needs a driver that watches its source: files (by the events of the file system), Consul, Vault, etcd; otherwise `Subscribe` returns `tinyconf.ErrNoWatchers`. + +```go +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/insei/tinyconf/v2" + "github.com/insei/tinyconf/v2/drivers/tag" + "github.com/insei/tinyconf/v2/drivers/yaml" +) + +type HTTP struct { + Port int `initial:"8080" yaml:"port"` +} + +type Config struct { + HTTP HTTP `yaml:"http"` +} + +// server is a package that knows only its own config. +type server struct { + config *tinyconf.Manager +} + +func (s *server) handle() { + // Current is an atomic read without allocations, call it on every request; never modify the result + port := tinyconf.Current[HTTP](s.config).Port + fmt.Println("port:", port) +} + +func main() { + tagDriver, err := tag.New("initial") + if err != nil { + panic(err) + } + yamlDriver, err := yaml.New("config.yaml", yaml.WithOptional()) + if err != nil { + panic(err) + } + config, err := tinyconf.New(tinyconf.WithDriver(tagDriver), tinyconf.WithDriver(yamlDriver)) + if err != nil { + panic(err) + } + conf := &Config{} + if err = config.Register(conf); err != nil { + panic(err) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if err = config.Parse(ctx, conf); err != nil { + panic(err) + } + + s := &server{config: config} + s.handle() + // called after a reload only if HTTP has changed; a broken file keeps the last good config. + // The subscription ends with ctx, watching stops after the last subscription. + err = tinyconf.Subscribe(ctx, config, func(prev, next *HTTP) { + fmt.Printf("port: %d -> %d\n", prev.Port, next.Port) + }) + if err != nil { + panic(err) + } + <-ctx.Done() +} +``` + +`config.ReloadStatus()` reports the last reload error and the drivers that stopped watching, for health checks. Reloads never modify the config passed to `Parse` and the versions returned by `Current`, they create new versions. + +## The logger configured by the config itself + +Without a logger nothing is logged. `tinyconf.WithLogger(l)` sets it, the bundled ones are `logger.NewFmtLogger(logger.INFO)`, `logger.NewSlogLogger(*slog.Logger)` (Go 1.21+) and `zaplogger.New(*zap.Logger)` from the separate module `github.com/insei/tinyconf/logger/zaplogger`. If the logger is configured by the config, use `WithDeferredLogger`: the records are kept until `SetLogger`, which is safe for concurrent use. + +```go +package main + +import ( + "context" + "log/slog" + "os" + + "github.com/insei/tinyconf/v2" + "github.com/insei/tinyconf/v2/drivers/env" + "github.com/insei/tinyconf/v2/logger" +) + +type Logging struct { + Level string `env:"LOG_LEVEL" doc:"debug, info, warn, error"` +} + +type Config struct { + Logging Logging +} + +func newLogger(conf Logging) tinyconf.Logger { + var level slog.Level + _ = level.UnmarshalText([]byte(conf.Level)) + return logger.NewSlogLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) +} + +func main() { + envDriver, err := env.New() + if err != nil { + panic(err) + } + config, err := tinyconf.New(tinyconf.WithDriver(envDriver), tinyconf.WithDeferredLogger()) + if err != nil { + panic(err) + } + conf := &Config{} + if err = config.Register(conf); err != nil { + panic(err) + } + err = config.Parse(context.Background(), conf) + config.SetLogger(newLogger(conf.Logging)) // the records of Parse are written to it + if err != nil { + panic(err) + } +} +``` + +## Drivers + +| Driver | Constructor | Notes | +|---|---|---| +| defaults | `tag.New("initial")` | the first driver | +| yaml, json, toml, ini, properties | `yaml.New("config.yaml", opts...)` | `WithFiles(...)` more files, `WithFS(fsys, ...)`, `WithData(name, []byte)`, `WithOptional()` absent files are skipped, `WithEnvExpansion()` `${VAR}` in the file, `WithWatchInterval(d)`; `yaml.New("", yaml.WithData(...))` without a file | +| env | `env.New(opts...)` | `WithPrefix("APP_")`, `WithAutoKeys()` names from the field paths without tags (`HTTP.MaxConns` → `HTTP_MAX_CONNS`), `WithIndexedKeys()` `SERVERS_0_HOST`, `WithSecretFiles()` `DB_PASSWORD_FILE`, `WithFiles(".env.local")` custom .env files, `WithFiles()` no .env files | +| flag | `flag.New(opts...)` | `WithArgs(args)` instead of `os.Args[1:]`; slices by repeated or comma separated flags: `--host=a --host=b,c` | +| Consul KV | `consul.New("app/config", opts...)` | key per field: `prefix/http/port`; `WithAddress`, `WithToken` (or `CONSUL_HTTP_ADDR`, `CONSUL_HTTP_TOKEN`), `WithDatacenter`; a whole document: `yaml.New("", yaml.WithSource(consul.Document("app/config")))` | +| Vault KV | `vault.New("app", opts...)` | one secret with JSON keys; `WithAddress`, `WithToken`, `WithTokenFile` (or `VAULT_ADDR`, `VAULT_TOKEN`), `WithMount("secret")`, `WithKVv1()`, `WithPaths(...)`, `WithOptional()`; changes by polling | +| etcd | `etcd.New("/app/config", opts...)` | key per field; `WithEndpoints(...)`, `WithAuth(user, password)`; `etcd.Document(key)` like Consul | +| any format | `format.New("hcl", decode, "config.hcl", opts...)` | `decode func([]byte) (map[string]any, error)`, the options of the file drivers, `WithEncoder` for `GenDoc` | + +## Common mistakes + +- Importing `github.com/insei/tinyconf` (v1) or calling `Parse(conf)` without the context. +- `Parse` before `Register`, or a non-pointer config. +- A missing tag on the parent struct for the file and remote drivers: `yaml:"port"` in a struct field without `yaml:"http"` is not read from `http.port`. +- Expecting `env:"PORT"` to get a prefix from the parent struct: env and flag tags are full names (use `env.WithPrefix` or `env.WithAutoKeys` for generated names). +- Modifying the struct returned by `Current`: it is shared by all readers. +- `Subscribe` without a driver that watches (only tag, env and flag drivers): `ErrNoWatchers`. +- Logging the config with a `string` password field: use `tinyconf.Secret` or at least `hidden:"true"` for the logs of tinyconf. diff --git a/status_test.go b/status_test.go index f502b8e..73f4cba 100644 --- a/status_test.go +++ b/status_test.go @@ -35,6 +35,8 @@ func TestManager_ReloadStatus(t *testing.T) { // the applied reload file.set(map[string]any{"Name": "second"}) <-configs + // the status is updated after the subscribers of all configs are called + assert.Eventually(t, func() bool { return !c.ReloadStatus().LastApplied.IsZero() }, 5*time.Second, time.Millisecond) status = c.ReloadStatus() assert.NoError(t, status.LastError) assert.False(t, status.LastReload.IsZero())