config

package module
v0.9.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 24, 2026 License: MIT Imports: 24 Imported by: 0

README

config

Layered configuration for Go that can tell you where a value came from — and write one back without wrecking the file

Go Reference Pipeline Coverage phpboyscout Go toolkit

Part of the phpboyscout Go toolkit — small, framework-free Go modules extracted from go-tool-base. Docs: config.go.phpboyscout.uk


gitlab.com/phpboyscout/go/config gives configuration a single owner. A Store reads every source — files, compiled-in defaults, environment variables, command-line flags — merges them in a precedence order you can read off the call site, records which layer supplied each value, and is the only thing that writes any of them back. Everything else is a view over the immutable snapshot it publishes.

That one rule is what makes the rest possible.

Reading: answers, not just values

Any library can hand you a value. Three things are harder:

"Why is this value 9090?" Provenance is recorded during the merge rather than reconstructed afterwards, so the question has an answer. Explain renders the whole chain, Origin names the layer that supplied a value, Shadowed lists every layer defining it.

fmt.Println(view.Explain("server.port"))
// server.port = 9090 (from /home/me/.mytool/config.yaml); also defined in embedded:defaults.yaml

"Do these two values agree with each other?" A View is pinned to one immutable snapshot, so a sequence of reads cannot land either side of a reload and return a configuration that never existed. That is structural rather than a matter of care, and it is measured — two related keys read while the file changes underneath:

reads mismatched pairs
a library reading live mutable state 6,764,880 1,759
config 11,639,791 0

A mismatched pair is host from before a reload and port from after — a host and port never configured together, about to be dialled. coherence_test.go produces the second row and fails the build if it stops being true.

"What happens when the new config is broken?" Reload is fail-closed: a candidate that will not parse, or violates your schema, is rejected and the last-known-good configuration stays live. You never read a mixture of before and after, and a watcher that cannot function returns an error rather than silently doing nothing.

Plus the ordinary things done deliberately — precedence is the order you added the sources rather than a fixed ranking you cannot change; Value[T] reads any type including your own encoding.TextUnmarshaler implementations; there is no global singleton, so configuration-dependent tests run in parallel without touching process state; and the environment prefix is required as a security control.

Reacting to change: told once, told in order, told the truth

The usual shape for hot-reload is a callback handed a filesystem event — which says a file was touched, not what changed, not whether the result was usable, and not whether it is still current by the time you read it. An observer here is handed a configuration, and the guarantees around that delivery are the point:

Guarantee What it means
Exactly once per logical change One Apply touching four keys across three files is one notification — not four, not three, not once per filesystem event.
Never for a rejected change A file that will not parse, or fails your schema, notifies nobody; announcing it would be a lie. Rejections travel on OnReloadError.
Never for a change that changed nothing A write leaving the resolved configuration identical does not notify, even though the file was rewritten.
In order, always Observers are never handed an older snapshot after a newer one. Under concurrent writes a superseded snapshot is dropped, not delivered late.
Pinned for the whole callback One immutable snapshot per delivery, so an observer cannot read half of one configuration and half of the next.
One failure does not silence the rest Observer errors route to OnObserverError; the remaining observers still run.
No writing from inside an observer ErrWriteFromObserver, rather than a cascade with no natural end.

Ordering and exactly-once are structural — delivery is serialised and version-checked in one place — rather than something each observer must defend itself against. It cannot: by the time an older snapshot arrives, an observer has no way to know a newer one already did.

The limit worth knowing: exactly-once is per logical change as the Store understands it. An Apply is one change however many files it touches, because the Store performs it and knows the batch. Two files changed by something else are separate events — nothing in the filesystem says they were one intended change — so foreign changes are coalesced behind a settle window (250ms by default, WithSettleInterval) and reload once.

That reduces the problem rather than removing it: writes spaced wider than the window look exactly like two changes, so observers run twice and the first run sees a combination nobody meant. Each snapshot is still internally coherent, never a mixture of two reads. A change spanning files is atomic only if the writer makes it atomic — keep settings that change together in one file, or swap the directory at once as a Kubernetes ConfigMap update does. Both the coalescing and the residual case are pinned by tests.

Change detection is hybrid and per-path: native OS notification where it genuinely works, polling where it does not — in-memory filesystems, network mounts, containers with inotify watches exhausted. One unwatchable path does not downgrade the rest, and if native notification stops being trustworthy mid-run the affected paths fall back to polling and carry on. Only if polling cannot be established either has the watch genuinely stopped — and then you are told. A watcher silently gone quiet is the one failure this design refuses.

For comparison, viper v1.21.0 calls OnConfigChange with the fsnotify.Event, and its watch loop invokes the callback after a failed re-read as well as a successful one — the parse error goes to viper's internal logger and the callback is told "changed" regardless. It retains the last good values, which is right; but an observer that rebuilds a pool or reopens a listener whenever it is told something changed will do that work for a change that did not happen.

All of the above is asserted in observer_contract_test.go.

Writing: where it gets genuinely hard

Everything above is a better answer to a problem other libraries also address. This is one most cannot address at all, because their architecture forecloses it.

The moment your program writes configuration back — a settings screen, a --set flag, a first-run wizard, an auth login that stores a token — the file stops being an input and becomes something you are responsible for. Here is what changing one value does when a library serialises its merged view over your file:

BeforeAfter changing server.port
# Which port the listener binds to.
# Needs a firewall change too.
server:
  host: localhost   # dev only
  port: 8080

# Feature flags. Keep alphabetical.
features:
  beta_ui: false
db:
    password: hunter2-prod-secret
features:
    beta_ui: false
log:
    level: info
server:
    host: localhost
    port: 9090

Every comment is gone, the order is now alphabetical rather than meaningful, a default the program never had written down is now pinned into the user's file — and a production password that arrived from an environment variable and was never in any file is now sitting in one that is probably in git.

That is not a bug in anything. It is what "serialise the merged view" means, and it follows from merging eagerly: once every source is folded into one map, a writer holding that map has nothing to write but the whole of it. It cannot leave the environment's contribution out, because it can no longer tell which contribution was the environment's.

This module never folds its layers away. A Store owns every read, write and watch, and records provenance during the merge instead of reconstructing it afterwards. Changing server.port above changes server.port, and nothing else.

Any source can be a layer

Layers are not limited to files and readers. WithBackend takes anything satisfying a three-method Backend, so a source this module has never heard of — Consul, a secrets manager, an HTTP endpoint, a database table, a device's NVRAM — becomes an ordinary layer:

store, err := config.NewStore(ctx,
	config.WithFiles(fsys, "/etc/app.yaml"),
	config.WithBackend(consulBackend{client: c, prefix: "app/"}),  // ← outranks the file
	config.WithEnv("APP"),
)

It takes part in precedence, provenance and shadowing exactly as a file does, and Explain names it. Capability beyond reading is opt-in through interfaces you may simply not implement:

Implement To get
Backend reading, merging, precedence, provenance — the whole read surface
WritableBackend Apply can route writes to it
WatchableBackend it takes part in hot-reload, on its own schedule

A read-only Consul layer implements one interface. A backend with a real subscription implements WatchableBackend and gets push-based reload without polling anything. Nothing forces a source to pretend it can do something it cannot — which is what makes a single fat interface with stubbed methods so unpleasant to live with. See Write a custom backend.

A file in another format is smaller still: the file machinery is written once, so you supply only a CodecDecode for reading, and EditingCodec (Check/Apply/Empty) if the format can be edited in place — and pass it to NewCodecBackend. The same capability split, one level down: a read-only codec is skipped by write routing, an editing one is a write target, decided by the type system. YAML is the built-in codec; other formats ship as sibling config-<format> modules, so a consumer who needs JSON never acquires a TOML parser.

Validation on both sides, and repairable when it fails

A Schema from config: struct tags is checked against the resolved configuration rather than any single layer — a base file may legitimately omit a key its overlay supplies. It runs at load, on every reload, and on every write, where the obvious rule is the wrong one:

Apply rejects only the violations your change introduces. Pre-existing violations do not block the write, and are reported separately.

Validating the result outright would lock an already-broken configuration against edits, including the edit that would fix it. NewStore follows the same reasoning, handing back a usable store alongside ErrInvalidConfig — a configuration tool that cannot open a broken configuration is of no use precisely when it is needed.

Quieter things that matter

None of these sells the module on its own. Together they are most of what living with it feels like.

  • Generics all the way through, not bolted on. Value[T], UnmarshalSection[T], ObserveSection[T], ValidateStruct[T], SchemaOf[T] — one consistent shape rather than a generic escape hatch beside a hand-written method per type. A type this module has never seen needs no new API and no release: it reads through the same Value[T], and if it implements encoding.TextUnmarshaler it decodes without being special-cased.
  • A multi-document YAML file is several layers, one per document, each taking part in precedence and provenance independently — Explain reports /app.yaml#1. The common alternative is worse than unsupported: viper reads the first document, silently discards the rest, and returns no error.
  • Every failure is a named error you can branch on — seventeen sentinels matched with errors.Is, so handling a specific failure never means comparing strings. Validation errors carry a fix hint alongside the message.
  • Context on every operation that does I/O, so configuration work participates in your shutdown path instead of ignoring it.
  • A smaller dependency graph than the thing it replaces, while doing more — 20 non-stdlib packages across 8 modules, against viper's 36 across 13 (library only, no test dependencies). The filesystem is a six-method interface this module defines, so you are not made to import one.
  • Typed sections + validation. ObservedSection[T] keeps a struct current across reloads — decoded in one operation, so it never holds some fields from before a reload and some from after, and republished only when the struct actually changed. Defaults take your merge function, so "merge" means what your type needs. Schema / ValidateStruct[T] check field rules on both reload and write.
  • Everything is a layer. Files, one document within a multi-document file, defaults, environment, flags and runtime AddLayer sources all take part in precedence, provenance and shadowing the same way.
  • Snapshot.Version() tells a component whether the configuration it holds has moved on without comparing values; Sub scopes a view to a subtree; With pins one snapshot across a block of reads.
  • Plan is a dry run that cannot drift from Apply, because it is the same routing pass rather than a second implementation.
  • Published testify mocks for downstream tests, and a six-method config.FS interface for the filesystem — config.OS(), config.Dir(path), or your own.

Should you use this?

Yes, if you have lost an afternoon to "which source set this?"; if a long-running service reloads configuration and reads straddling a reload are unacceptable; if your program writes configuration back and users live in the file afterwards; if config lands in files that get committed and reviewed; if you want config-dependent tests that run in parallel without process globals; or if your settings have real types you are tired of decoding by hand.

Probably not, if one file and a couple of environment variables cover you, nothing is written back and nothing reloads. That is a large share of programs and a well-served case: viper is battle-tested by an enormous number of them, has an ecosystem this module does not, and is a smaller dependency. Much of what is here was learned from years of using it — see the history.

Install

go get gitlab.com/phpboyscout/go/config

Quick start

package main

import (
	"context"
	"fmt"

	"gitlab.com/phpboyscout/go/config"
)

func main() {
	ctx := context.Background()

	s, err := config.NewStore(ctx,
		config.WithFiles(config.OS(), "config.yaml"),
		config.WithEnv("APP"))
	if err != nil {
		panic(err)
	}

	fmt.Println(s.View().GetString("name"))

	// Where did that value come from?
	fmt.Println(s.View().Explain("name"))

	// Persist a change, preserving the file's comments and layout.
	if _, err := s.Apply(ctx, config.Set("name", "updated")); err != nil {
		panic(err)
	}

	// React to changes made by anything other than this process.
	s.AddObserverFunc(func(cfg config.Observed) error {
		fmt.Println("reloaded:", cfg.GetString("name"))

		return nil
	})
}

What's inside

  • Store — the single owner of config I/O: NewStore, View, With, Plan, Apply, Reload, Watch, AddLayer, Snapshot.
  • ReadingReader / View typed accessors, Sub for a scoped read, Unmarshal.
  • WritingSet / Remove, Plan as a dry run, Operation.Effective() and ShadowedBy for writes a higher layer still overrides.
  • ProvenanceOrigin, Shadowed, Explain.
  • SectionsSection[T] and ObservedSection[T] for typed, reload-aware subtrees.
  • ValidationSchema / FieldSchema / ValidateStruct[T] / ValidationResult, applied on reload and on write.
  • mocks — published testify mocks for downstream tests.

Comment-preserving YAML editing lives in its own module, yamldoc.

Migrating from v0.2.x

v0.3.0 is a breaking release: the Viper-backed container became the Store, and afero.Fs became a six-method config.FS the module defines itself. The typed-section semantics are unchanged, so a package consuming ObservedSection[T] through its own interface needs no change at all; code holding a Containable does.

Two traps have no compiler error. Watching is explicit now — v0.2.x watched from inside every constructor, so skipping Store.Watch(ctx) means configuration silently stops reloading. And ApplyInitial is not a required fix — delivery has always been change-only.

See the migration guide and the history of how the module got here.

Documentation

Full guides and the precedence/hot-reload model: config.go.phpboyscout.uk. API reference: pkg.go.dev.

License

See LICENSE.

Documentation

Overview

Package config loads, merges and writes layered configuration, with a single component owning every read and write.

A Store is that component. It reads each source, merges them in a deterministic precedence order, records which layer supplied each value, and is the only thing that writes any of them back. Everything else is a view over the snapshot it publishes, so there is no protocol between a reader and a writer — there is only one of each, and it is the Store.

s, err := config.NewStore(ctx,
	config.WithFiles(config.OS(), "/etc/app.yaml", "/home/me/.app.yaml"),
	config.WithEnv("APP"),
	config.WithFlags(flags))

Sources are added lowest precedence first, and a later one wins. Files, compiled-in defaults (WithReaders), environment variables and command-line flags are all ordinary layers, so precedence, provenance and shadowing work the same way for each.

Reading

Store.View returns a View over the current snapshot, satisfying Reader. A snapshot is immutable, so a sequence of reads against one is coherent even if a reload lands midway; Store.With pins a single snapshot across a block of reads when several values must agree with each other.

There is a named accessor for the common types — View.GetString, View.GetInt and the rest — and Value for everything else: it reads any type at all, including a consumer's own, so Reader does not have to grow a method per type. Durations, IP addresses, URLs, timezones and anything implementing encoding.TextUnmarshaler decode from their ordinary written form.

Writing

Store.Apply persists changes, and Store.Plan shows what it would do without doing it. A write is routed at the layer that already owns the key, so it lands where it will be read back, and comments, key order and block style in the target file survive it. A write that cannot change the effective value — because a higher layer still defines the key — reports that rather than appearing to succeed.

if _, err := s.Apply(ctx, config.Set("server.port", 9090)); err != nil {
	return err
}

Other file formats

The core reads and writes YAML. Any other file format plugs in through a CodecCodec.Decode turns a source's bytes into values, and an EditingCodec adds in-place editing — passed to NewCodecBackend. The file machinery is shared, so a format is a codec rather than a whole backend, and each ships as its own module so no consumer inherits a parser they do not use.

Provenance

View.Origin reports which source supplied a value, View.Shadowed lists every layer defining it, and View.Explain renders the whole chain for a diagnostic. These answer "why is this value what it is", which a merge-eager library cannot, because merging discards the information before anyone can ask.

Reacting to change

Store.Watch notices changes made by anything other than this process, and observers registered with Store.AddObserver are told exactly once per logical change. An observer must not write configuration while reacting to a change: doing so returns ErrWriteFromObserver, because each such write is itself a change and the two would not stop. Capture what is needed, return, and write from elsewhere.

Typed sections

ObserveSection decodes a section into a struct and keeps it current across reloads, publishing immutable snapshots through ObservedSection. A package consuming those settings declares a one-method interface over its own struct and never imports this one, which is what keeps configuration from spreading through a codebase.

Index

Constants

View Source
const DefaultPollInterval = 2 * time.Second

DefaultPollInterval is how often the polling watcher checks for changes when the filesystem cannot notify.

View Source
const DefaultSettleInterval = 250 * time.Millisecond

DefaultSettleInterval is how long a burst of foreign change reports is allowed to settle before the Store reloads.

It exists because a logical configuration change is not always one filesystem event. A deploy replacing two overlays, a config-management run rewriting a directory, an operator saving two files — each produces separate events, and nothing in the filesystem says they were meant as one change. Without a settle window observers run once per file, and the first run sees a combination nobody intended: the first file updated, the second not yet.

250ms is inherited from the pre-Store implementation, where it was chosen to tolerate slow and networked filesystems. It is long enough to absorb the writes of a single deploy step and short enough to be imperceptible in the reload latency of a long-running service.

Variables

View Source
var (
	// ErrBackendUnsafe is returned when a source contains a construct that
	// cannot be safely round-tripped, so editing it would risk corruption.
	ErrBackendUnsafe = errors.New("config: source cannot be safely edited")

	// ErrBackendParse is returned when a source is not valid for its format.
	ErrBackendParse = errors.New("config: source could not be parsed")

	// ErrInternal is returned when an invariant of this module does not hold.
	// It is never the caller's fault and is always worth reporting.
	ErrInternal = errors.New("config: internal invariant violated")
)

Backend errors. Callers should branch on these with errors.Is.

View Source
var (
	// ErrNoWritableLayer is returned when a change has nowhere it can be
	// written: every layer that could hold it is read-only.
	ErrNoWritableLayer = errors.New("config: no writable layer for change")

	// ErrNoChanges is returned when an apply is asked to do nothing.
	ErrNoChanges = errors.New("config: no changes to apply")

	// ErrInvalidPath is returned for a malformed dotted path.
	ErrInvalidPath = errors.New("config: invalid path")

	// ErrInvalidTarget is returned when a target cannot receive what is being
	// asked of it: a decode target that cannot hold values, a layer with no
	// name, or a pinned write target naming no writable source.
	ErrInvalidTarget = errors.New("config: invalid target")

	// ErrSensitiveLeak is returned when a write would land a key a sensitive
	// source defines into a layer that is not sensitive — writing secret-category
	// material into a plain store. Because secrets backends are read-only, a
	// write to a key they own routes down to the next writable layer, typically a
	// file; refusing it is what keeps that safe.
	ErrSensitiveLeak = errors.New("config: refusing to write a sensitive key into a non-sensitive layer")
)

Routing errors.

View Source
var (
	// ErrNoSources is returned when a Store is built with nothing to read.
	ErrNoSources = errors.New("config: no sources configured")

	// ErrInvalidConfig is returned when a candidate configuration fails schema
	// validation. The previous configuration, if any, is retained.
	ErrInvalidConfig = errors.New("config: configuration is not valid")
)
View Source
var (
	// ErrConflict is returned when a source changed between being read and
	// being written, so committing would silently discard someone else's work.
	ErrConflict = errors.New("config: source changed since it was read")

	// ErrNotWritable is returned when a change is routed at a backend that
	// cannot persist.
	ErrNotWritable = errors.New("config: backend is not writable")

	// ErrPartialCommit is returned when a multi-source commit fails partway
	// and could not be fully rolled back. It always names what is in which
	// state: a caller must never be left guessing.
	ErrPartialCommit = errors.New("config: commit partially applied")
)

Write errors.

View Source
var ErrAmbiguousEnvKey = errors.New("config: environment variable is ambiguous")

ErrAmbiguousEnvKey is returned when a set environment variable could designate more than one configuration key, so honouring it would mean guessing which the user meant.

View Source
var ErrEmptySchema = errors.New("config: schema has no fields defined")

ErrEmptySchema is returned when schema construction produced no fields. A schema that constrains nothing would validate everything, which is worse than having none: the caller believes their configuration is checked.

View Source
var ErrNoMergeFunc = errors.New("config: section defaults require a merge function")

ErrNoMergeFunc is returned when a section supplies defaults but no way to combine them with the configured values. Silently preferring one over the other would drop half the settings without saying so.

View Source
var ErrReadOnlyFS = errors.New("config: filesystem is read-only")

ErrReadOnlyFS is returned by the write methods of a read-only FS — WriteFile, Rename, Remove and MkdirAll. A filesystem adapter over an inherently read-only source (an io/fs.FS, an HTTP URL) returns it so a caller can errors.Is it, rather than a bare fs.ErrPermission that a genuine permission failure is indistinguishable from. See the filesystem adapters spec, D4.

View Source
var ErrWatchUnavailable = errors.New("config: cannot watch sources")

ErrWatchUnavailable is returned when watching cannot work for the given sources. It is deliberately loud: a watcher that silently does nothing is worse than none, because the application believes it will hear about changes and never will.

View Source
var ErrWriteFromObserver = errors.New(
	"config: cannot change configuration from inside an observer")

ErrWriteFromObserver is returned when configuration is changed from inside an observer callback.

Writing while reacting to a write is refused outright rather than made to work. Each such write is itself a change, which notifies, which runs the observer again — a cascade with no natural end, and one the Store cannot break without either dropping notifications other components need or silently reordering what changed when.

An observer that needs to update configuration must take the change *out* of the observation: record what is needed, return, and perform the write from somewhere else — a worker goroutine, a queue, the next tick. Serialising and de-duplicating those deferred writes belongs to the consumer, which is the only party that knows which of them still matter.

Functions

func MustValue added in v0.3.0

func MustValue[T any](cfg Reader, path string) T

MustValue reads a configuration value as T, panicking if it cannot be decoded.

For a value a program cannot run without, read at startup where a panic is a clear failure rather than a surprise later.

func ValidateStruct

func ValidateStruct[T any](cfg Reader, opts ...SchemaOption) error

ValidateStruct checks a view against the schema derived from T's struct tags, returning a formatted error if any rule fails and nil if it does not.

It is the shortest way to validate the slice of configuration a command or feature cares about, without building a Schema by hand:

if err := config.ValidateStruct[MyConfig](store.View()); err != nil {
	return err
}

A scoped view validates its own subtree, so a schema written for a section can be applied to that section:

if err := config.ValidateStruct[Server](store.View().Sub("server")); err != nil {
	return err
}

The parameter is Reader rather than *View so that code under test can pass a mock. Validation only reads — Get, Has, Keys and Shadowed — so requiring the concrete type bought nothing and made every caller's own validation untestable without a real Store.

Schema options such as WithStrictMode may be passed through.

func Value added in v0.3.0

func Value[T any](cfg Reader, path string) (T, error)

Value reads a configuration value as T.

This is the general form of the typed accessors: it decodes into whatever type is asked for, so it covers types this module has no method for and types it has never heard of. A struct, a slice, a map, an IP address, a URL, a timezone, or a consumer's own type implementing encoding.TextUnmarshaler all work, because the decoder is told how to read text into them.

port, err := config.Value[int](cfg, "server.port")
addr, err := config.Value[netip.Addr](cfg, "server.bind")
level, err := config.Value[LogLevel](cfg, "log.level")   // your own type

It is a function rather than a method because Go does not allow type parameters on methods — the same reason UnmarshalSection and ValidateStruct are functions.

An absent key yields the zero value and no error, matching the accessors: a caller distinguishing "absent" from "set to zero" asks Reader.Has.

Types

type Backend added in v0.3.0

type Backend interface {
	// ID identifies the backend for diagnostics and provenance.
	//
	// For a [WritableBackend] it is also how the Store finds this backend
	// again when routing a write, by matching a layer's Source.Name against
	// it. Those two must therefore agree: a backend whose Load reports layers
	// named something other than what ID returns cannot receive writes.
	ID() string

	// Load returns the layers this backend contributes, in precedence order.
	// A backend may contribute more than one — a multi-document file
	// contributes one layer per document.
	//
	// A source that does not exist returns fs.ErrNotExist, so the caller can
	// decide whether that is fatal: a base file usually is, an optional
	// overlay usually is not.
	// Load reads this backend's sources into layers.
	//
	// below is everything the lower-precedence backends contributed, in order.
	// Most backends ignore it; a backend whose reading of its own input depends
	// on what is already defined needs it, and receiving it as an argument is
	// what stops that being a separate call someone can forget to make. The
	// environment backend is the case: mapping APP_SERVER_PORT back to a dotted
	// key is ambiguous without knowing whether server.port already exists.
	Load(ctx context.Context, below []Layer) ([]Layer, error)

	// Capabilities describes what this backend can do, so callers can adapt
	// rather than discover limitations by hitting them.
	Capabilities() Capabilities
}

Backend is a source of configuration layers.

Read and write are deliberately separate interfaces. Reading is fetch, parse, normalise; writing carries ownership, atomicity, conflict detection, secret handling and failure modes that differ enormously between a local file and a remote parameter store. One interface pretending they are the same would either lie about those differences or degrade to the weakest member.

func NewCodecBackend added in v0.5.0

func NewCodecBackend(filesystem FS, path string, codec Codec) Backend

NewCodecBackend returns a backend reading a single file through a codec.

It returns a write target only when the codec can edit: the concrete type implements WritableBackend exactly when the codec implements EditingCodec, so routing and Plan agree with the type system instead of a backend advertising a capability at the type level and failing at call time. A file on a filesystem is always watchable, so both shapes implement WatchableBackend.

func NewEnvBackend added in v0.3.0

func NewEnvBackend(prefix string, opts ...EnvOption) Backend

NewEnvBackend returns a backend contributing environment variables that carry the given prefix.

The prefix is required, and is a security control rather than tidiness. Without one, any environment variable matching a configuration key can silently change behaviour — on a shared CI runner, a container host or a multi-tenant box, an unrelated process setting LOG_LEVEL or AI_PROVIDER would reconfigure every tool running there. With a prefix, unprefixed variables cannot reach configuration at all.

Pass the prefix without a trailing underscore.

func NewFileBackend added in v0.3.0

func NewFileBackend(filesystem FS, path string) Backend

NewFileBackend returns a backend reading YAML from a path on the given filesystem.

YAML is the module's built-in format: it is the default, and the comment-preserving write path — the module's headline feature — is built on yamldoc. Every other format ships as a sibling module supplying its own codec to NewCodecBackend; this is that call with the YAML codec.

func NewFlagBackend added in v0.3.0

func NewFlagBackend(flags *pflag.FlagSet, opts ...FlagOption) Backend

NewFlagBackend returns a backend contributing flags the user actually changed.

Only changed flags contribute, and that is the whole point. Binding a flag regardless of whether it was set means its default silently masks configuration: the file says port 9090, nobody passed --port, and the effective value is the flag default of 8080. The user then has no way to discover why their configuration is being ignored.

func NewReaderBackend added in v0.3.0

func NewReaderBackend(name string, content []byte) Backend

NewReaderBackend returns a backend contributing YAML from bytes.

The name appears in provenance, so give it something a user would recognise — "embedded:defaults.yaml" rather than "reader1".

type Binder added in v0.3.0

type Binder interface {
	// View returns a read surface over the current configuration.
	View() *View
	// AddObserverFunc registers a function to run when configuration changes.
	AddObserverFunc(func(Observed) error)
}

Binder is something that can be read and will report when it changes.

A Store is one. The interface exists so a typed section can be bound to anything that behaves like configuration — a fixed snapshot in a test, for instance — without dragging in the machinery that loads one.

type Capabilities added in v0.3.0

type Capabilities struct {
	// PreservesComments reports whether an edit retains comments and
	// formatting. True for document-like sources; meaningless for key-value
	// stores, which have nowhere to put a comment.
	PreservesComments bool
	// AtomicMultiKey reports whether several keys can be written as one
	// indivisible operation.
	AtomicMultiKey bool
	// NativeWatch reports whether the backend can notify of foreign changes,
	// as opposed to needing to be polled.
	NativeWatch bool
	// Sensitive marks a backend as holding secret material. A value sourced
	// from one must never be written into a layer that is not also sensitive;
	// the write path enforces this, refusing such a write with [ErrSensitiveLeak].
	Sensitive bool
}

Capabilities describes what a backend supports.

Declaring them is what lets a heterogeneous set of backends coexist without the weakest one setting the contract for all of them.

Nothing here says whether a backend can be written to or watched. Those are answered by implementing WritableBackend and WatchableBackend, so the type system checks them once instead of every caller checking a flag at runtime — and so a backend cannot claim one thing and do another.

Each field carries a consequence worth recording: a value from a Sensitive source must never be written into a layer that is not (the environment-secret leak in a new costume), the comment guarantee is document-backend-only and must be scoped rather than implied, cross-backend atomicity is impossible and must be refused or declared, and foreign-change latency differs per backend and must be stated.

Sensitive is enforced: the write path refuses a change that would land a key a Sensitive source defines into a layer that is not sensitive, returning ErrSensitiveLeak. The remaining fields are still forward-declared — stated where the reasoning is fresh so the consumer that eventually reads them inherits the intent rather than re-deriving it.

type Change added in v0.3.0

type Change struct {
	// Path is the dotted key to change.
	Path string
	// Value is the new value. Ignored when Remove is set.
	Value any
	// Remove deletes the key and its subtree instead of setting it.
	Remove bool
	// Target optionally pins the change to a specific layer, overriding
	// routing. Use when the caller genuinely knows better; routing's default
	// is right far more often.
	Target *Source
}

Change is a single edit to persist.

Setting and removing are one type rather than two methods because they route identically and must be applied in one batch — a caller replacing a subtree by removing some keys and setting others needs both halves to land together.

func Remove added in v0.3.0

func Remove(path string) Change

Remove returns a change that deletes a key and its subtree.

func Set added in v0.3.0

func Set(path string, value any) Change

Set returns a change that assigns a value.

type Codec added in v0.5.0

type Codec interface {
	// Decode returns one map per document in the source. A format with no
	// multi-document concept returns exactly one. A document that is empty is a
	// nil entry rather than an omission, so a later document keeps its index and
	// its provenance.
	Decode(path string, src []byte) ([]map[string]any, error)
}

Codec turns a source's bytes into layer values.

It is the format-specific half of a file-backed backend: everything else — reading the file, translating fs.ErrNotExist, fingerprinting for conflict detection, staging, atomic rename, mode preservation, symlink resolution, rollback and watching — is the same whatever the format, and lives in the backend. A codec knows only how to decode bytes to values, and (if it can) how to edit those bytes in place.

type Edit added in v0.3.0

type Edit struct {
	// Document is the document index within the source.
	Document int
	Path     string
	Value    any
	Remove   bool
}

Edit is one change addressed at a specific document of a backend.

type EditingCodec added in v0.5.0

type EditingCodec interface {
	Codec

	// Check reports whether this content can be round-tripped safely. It is
	// called at load, so a source that cannot be edited is refused before the
	// user has made any edits to lose.
	Check(path string, src []byte) error

	// Apply edits the source, preserving whatever the format can preserve.
	Apply(path string, src []byte, edits []Edit) ([]byte, error)

	// Empty returns the content of a new, empty document, for creating a file
	// that does not exist yet.
	Empty() []byte
}

EditingCodec is a Codec that can also edit a document in place.

A format that cannot be safely round-tripped simply does not implement this, and the backend built from it is not a write target: routing skips it and a write lands in the next writable layer down, reported as shadowed rather than failing. The type system checks the capability once, the same way WritableBackend splits from Backend, so a codec cannot claim an ability it does not have.

type EnvOption added in v0.3.0

type EnvOption func(*envBackend)

EnvOption configures the environment backend.

func WithEnviron added in v0.3.0

func WithEnviron(fn func() []string) EnvOption

WithEnviron overrides where environment variables are read from. Tests use it to avoid mutating process state, which is global and makes parallel tests interfere with each other.

type FS added in v0.3.0

type FS interface {
	// ReadFile returns a source's contents, or an error satisfying
	// errors.Is(err, fs.ErrNotExist) when it is absent. That distinction is
	// load-bearing: a missing overlay is normal and a missing base file is a
	// broken installation, and only the Store can decide which.
	ReadFile(name string) ([]byte, error)

	// WriteFile replaces a file's contents.
	WriteFile(name string, data []byte, perm fs.FileMode) error

	// Stat reports a file's metadata. Used to preserve permissions across a
	// write and to confirm a path is the file the watcher thinks it is.
	Stat(name string) (fs.FileInfo, error)

	// Rename moves a file, and is how a write is committed: content is staged
	// beside the target and renamed over it, so a reader never observes a
	// half-written file.
	Rename(oldpath, newpath string) error

	// Remove deletes a file, used to discard staged content that was never
	// committed.
	Remove(name string) error

	// MkdirAll creates a directory and any missing parents, for writing a
	// configuration file into a directory that does not exist yet.
	MkdirAll(path string, perm fs.FileMode) error
}

FS is the filesystem surface this module needs, and the whole of it.

It is deliberately small. Six operations is what loading, watching and writing configuration actually requires, and stating them exactly is what lets a caller supply the operating system, a rooted directory, an in-memory filesystem or their own implementation without inheriting a dependency chosen on their behalf.

The standard library has no equivalent. io/fs.FS is read-only by design — no write, no rename, no remove — so it cannot express a module whose central feature is writing configuration back. os.Root has every operation needed and is hardened against escaping its root, but it is a concrete type with no in-memory implementation, so nothing can substitute for it in a test.

Adding a method here is a breaking change for every implementation, so capability beyond this minimum is expressed as an optional interface — RealPather and LinkReader — that an implementation may simply not satisfy. See D1 of the filesystem abstraction spec.

func Dir added in v0.3.0

func Dir(path string) (FS, error)

Dir returns an FS rooted at a directory.

Every operation is confined to the directory: a path resolving outside it — through "..", an absolute path, or a symlink pointing away — is refused by the operating system rather than by a check this module performs. A tool reading configuration from a directory the user named gets that containment without asking for it.

The root is opened per operation rather than held open. An earlier version kept the handle for the FS's lifetime, which read as a feature — the root survived being renamed underneath it — and was a file-descriptor leak: FS has no Close, nothing in the module closes a filesystem, and a caller that builds one per unit of work accumulates descriptors until the process ends. Measured at a default 1024-descriptor limit it failed after 1021 calls, and at the 256 typical of macOS after 253 — which the documented testing pattern of config.Dir(t.TempDir()) per test would reach in a large suite.

The cost is one extra openat per operation. Configuration is read at startup, on reload and on write, so that is not a hot path; a leak that surfaces only at scale is the worse trade.

**Paths are relative to the root.** Pass "config.yaml", not "/etc/app/config.yaml" — an absolute path is treated as an attempt to escape and fails with "path escapes from parent" rather than fs.ErrNotExist. That distinction matters to the Store, which treats a missing optional source as normal and anything else as fatal, so an absolute path turns a file that is merely absent into a hard failure.

func OS added in v0.3.0

func OS() FS

OS returns an FS backed by the operating system.

type FieldSchema

type FieldSchema struct {
	// Type is the expected Go type: "string", "int", "float64", "bool", "duration".
	Type string
	// Required indicates the field must be present and non-zero.
	Required bool
	// Description is used in validation error messages.
	Description string
	// Default is the default value for documentation and error hints only.
	// The validation layer does not inject defaults — use embedded assets for that.
	Default any
	// Enum restricts the field to a set of allowed values.
	Enum []any
}

FieldSchema describes a single configuration field.

type FlagOption added in v0.3.0

type FlagOption func(*flagBackend)

FlagOption configures the flag backend.

func BindFlag added in v0.3.0

func BindFlag(flagName, key string) FlagOption

BindFlag maps a flag name to a configuration key, for the cases where the two differ.

type Layer added in v0.3.0

type Layer struct {
	Source Source
	Values map[string]any
}

Layer is one contributing set of configuration values, with the identity of where they came from.

type LinkReader added in v0.3.0

type LinkReader interface {
	Readlink(name string) (string, error)
}

LinkReader optionally resolves a symbolic link.

A configuration file reached through a symlink should be written through it — editing the link itself would replace it with a regular file and detach every other path that pointed at the target. A filesystem with no notion of links does not implement this, and paths are used as given.

type NamedSource added in v0.3.0

type NamedSource struct {
	Name    string
	Content []byte
}

NamedSource is in-memory configuration with an identity for provenance.

type Observable

type Observable interface {
	Run(cfg Observed) error
}

Observable is a component that reacts to configuration changes.

type Observed added in v0.3.0

type Observed interface {
	Reader

	// Sub returns a scoped view, nil when the key is absent.
	Sub(key string) *View
	// Snapshot exposes the exact configuration this notification describes.
	Snapshot() *Snapshot
}

Observed is what an observer is handed when configuration changes.

It is pinned to the snapshot that triggered the notification rather than tracking the latest one. Without that, an observer processing one change could read values from a later change partway through its own callback and produce a result that never existed as a coherent configuration.

type ObservedSection

type ObservedSection[T any] struct {
	// contains filtered or unexported fields
}

ObservedSection stores the latest typed snapshot of an observed config section.

func ObserveSection

func ObserveSection[T any](
	binder Binder,
	key string,
	opts ...SectionBindingOption[T],
) (*ObservedSection[T], error)

ObserveSection decodes a typed section and keeps it current.

The returned value is the decoupling boundary this module exists for. A reusable package declares a one-method interface of its own —

type SettingsSource interface { Current() *ServerSettings }

— and *ObservedSection[T] satisfies it structurally, so the package can take live, reloadable configuration without importing this module at all.

Each snapshot is decoded in a single operation against a single configuration snapshot, so the struct handed out can never hold some fields from before a reload and some from after.

func (*ObservedSection[T]) ApplyInitial added in v0.3.0

func (s *ObservedSection[T]) ApplyInitial() error

ApplyInitial delivers the section the binding started with to the apply callback, exactly once, as an initial change.

It exists because the natural place to react to configuration is the same callback that reacts to a change — but a callback usually closes over something built after the binding, so firing it during ObserveSection would run it against a half-constructed world. Handing the caller the trigger lets them say when their dependencies exist.

A consumer that never calls this gets change-only delivery, which is what binding has always done. Calling it more than once is a no-op, so it is safe in a startup path that may run twice.

Returns nil when no apply callback was registered, and whatever the callback returns otherwise.

func (*ObservedSection[T]) Current

func (s *ObservedSection[T]) Current() *T

Current returns a pointer to the latest immutable settings snapshot. Callers must treat the returned value as read-only and call Current again when they need to observe a later reload.

func (*ObservedSection[T]) Exists

func (s *ObservedSection[T]) Exists() bool

Exists reports whether the latest snapshot came from an explicit section.

func (*ObservedSection[T]) Value

func (s *ObservedSection[T]) Value() T

Value returns the latest complete settings snapshot.

func (*ObservedSection[T]) Version

func (s *ObservedSection[T]) Version() uint64

Version returns the latest settings version. It starts at 1 after the initial snapshot and increments only when a later reload changes the observed section.

type ObserverFunc added in v0.3.0

type ObserverFunc func(cfg Observed) error

ObserverFunc adapts a function to Observable.

func (ObserverFunc) Run added in v0.3.0

func (f ObserverFunc) Run(cfg Observed) error

Run calls the function.

type Operation added in v0.3.0

type Operation struct {
	Change Change
	// Target is the layer the change will be written to.
	Target Source
	// Creates reports whether the key is new to the target layer.
	Creates bool
	// ShadowedBy lists layers that will still outrank the target after the
	// write, so the change will not be visible in the effective configuration.
	//
	// This is not an error — writing the file is what was asked — but a caller
	// that cannot tell the user "written, but the environment still wins" will
	// leave them confused about why nothing happened.
	ShadowedBy []Source
}

Operation is one routed change: what to do, and where it will land.

func (Operation) Effective added in v0.3.0

func (o Operation) Effective() bool

Effective reports whether the change will be visible in the resolved configuration once written.

type Pending added in v0.3.0

type Pending interface {
	// Layers is what the backend will contribute once committed. This is what
	// lets the Store build the next snapshot directly from the content it just
	// wrote, rather than re-reading and hoping it gets the same answer.
	Layers() []Layer

	// Verify reports whether the source is still as it was when Prepare ran.
	Verify(ctx context.Context) error

	// Commit makes the staged content visible.
	Commit(ctx context.Context) error

	// Rollback restores the source to its pre-commit state. Best effort: it is
	// called when a later commit in the same batch failed.
	Rollback(ctx context.Context) error

	// Discard abandons staged work that was never committed.
	Discard(ctx context.Context) error
}

Pending is staged work that has not yet been made visible.

The three-phase shape — prepare, verify, commit — exists so that the expensive and failure-prone part happens while nothing is visible, and the window in which a partially applied set could be observed is as short as the backend can make it.

type Plan added in v0.3.0

type Plan struct {
	Operations []Operation
}

Plan is a routed set of changes, inspectable before anything is written.

Producing the plan is the expensive part of an apply; executing it is not. Exposing it means a caller can show what a save would do — a CLI dry run, or a settings screen previewing its own changes — using exactly the routing the write will use, rather than an approximation of it.

func (*Plan) Effective added in v0.3.0

func (p *Plan) Effective() bool

Effective reports whether every operation will be visible once written.

func (*Plan) String added in v0.3.0

func (p *Plan) String() string

String renders the plan for a dry run.

func (*Plan) Targets added in v0.3.0

func (p *Plan) Targets() []Source

Targets returns the distinct layers the plan will write to.

type PollIntervalHinter added in v0.9.0

type PollIntervalHinter interface {
	PollInterval() time.Duration
}

PollIntervalHinter optionally reports how often a filesystem that must be polled should be checked for changes.

A filesystem with no real path is watched by polling (see RealPather), and the default cadence suits a local one. But for a remote filesystem — an object store, a network share — each poll is a round-trip, and often a billed API call, so the 2-second default is far too eager. Such a filesystem implements this to declare a sensible default (minutes, not seconds), which the watcher adopts unless the consumer sets one explicitly with WithPollInterval — an explicit interval always wins.

Implement this only when the filesystem genuinely wants a non-default cadence; a local or in-memory filesystem should not, so it keeps the responsive default.

type Reader added in v0.3.0

type Reader interface {
	Get(path string) any
	GetString(path string) string
	GetBool(path string) bool
	GetInt(path string) int
	GetInt32(path string) int32
	GetInt64(path string) int64
	GetUint(path string) uint
	GetUint8(path string) uint8
	GetUint16(path string) uint16
	GetUint32(path string) uint32
	GetUint64(path string) uint64
	GetFloat(path string) float64
	GetFloat64(path string) float64
	GetDuration(path string) time.Duration
	GetTime(path string) time.Time
	GetSizeInBytes(path string) uint

	GetStringSlice(path string) []string
	GetIntSlice(path string) []int
	GetStringMap(path string) map[string]any
	GetStringMapString(path string) map[string]string
	GetStringMapStringSlice(path string) map[string][]string

	Has(path string) bool
	IsSet(path string) bool
	SectionExists(path string) bool
	Keys() []string

	// Unmarshal decodes into a struct, and UnmarshalKey decodes one section.
	// [Value] is the general form: it reads any type, including a consumer's
	// own, so this interface does not have to grow a method per type.
	Unmarshal(target any) error
	UnmarshalKey(path string, target any) error

	// Origin reports which layer supplied a value, and Shadowed lists every
	// layer defining it. Both are what a merge-eager library cannot answer.
	Origin(path string) (Source, bool)
	Shadowed(path string) []Source
	Explain(path string) string
}

Reader is the read surface a consumer depends on.

It is deliberately small and free of any dependency's types. The previous interface exposed the underlying configuration library directly, which made the abstraction fictional — anything reachable through that escape hatch became part of the contract, and replacing what sat behind it became impossible.

type RealPather added in v0.3.0

type RealPather interface {
	RealPath(name string) (realPath string, ok bool)
}

RealPather optionally reports the operating-system path backing a name.

Native filesystem notification works on real paths, so a filesystem that has one can be watched by fsnotify, and one that does not — an in-memory filesystem, a network abstraction — is polled instead.

This replaced a switch on concrete afero types, which could not see through a wrapper it did not recognise: a consumer decorating the real filesystem with their own type silently lost native notification and got polling with no diagnosis. Asking the filesystem about itself has no such blind spot.

A real path is a claim, not proof. The watcher still confirms that both sides see the same file before pointing fsnotify at it, because a base-path wrapper over an in-memory filesystem produces real-looking paths for files that exist only in memory.

Implement this only when the filesystem genuinely has operating-system paths. Satisfying it unconditionally — returning false from the method instead of not having the method — makes every filesystem look watchable, so native notification is selected and the absence of anything to watch is discovered one path at a time. An implementation must not satisfy an optional interface it cannot honour.

type Schema

type Schema struct {
	// contains filtered or unexported fields
}

Schema defines the expected structure and constraints for configuration values.

func NewSchema

func NewSchema(opts ...SchemaOption) (*Schema, error)

NewSchema creates a Schema from the provided options.

func SchemaOf

func SchemaOf[T any](opts ...SchemaOption) (*Schema, error)

SchemaOf returns a Schema derived from the struct tags of T. For the option-free call the result is cached per type, so repeated calls for the same T do not re-reflect. When opts are supplied (for example WithStrictMode) the schema is built fresh and not cached, since options can change the result.

T must be a struct; see WithStructSchema for the supported tags.

func (*Schema) Fields

func (s *Schema) Fields() map[string]FieldSchema

Fields returns the schema field definitions.

type SchemaOption

type SchemaOption func(*schemaConfig)

SchemaOption configures schema construction.

func WithStrictMode

func WithStrictMode() SchemaOption

WithStrictMode treats unknown keys as errors instead of warnings.

func WithStructSchema

func WithStructSchema(v any) SchemaOption

WithStructSchema derives a schema from a tagged Go struct. Supported tags: `config:"key" validate:"required" enum:"a,b,c" default:"value"`.

type Section

type Section[T any] struct {
	Value  T
	Exists bool
}

Section is the result of unmarshalling an optional configuration section.

func MustUnmarshalSection

func MustUnmarshalSection[T any](cfg Reader, key string) Section[T]

MustUnmarshalSection decodes key into T and panics if decoding fails.

func UnmarshalSection

func UnmarshalSection[T any](cfg Reader, key string) (Section[T], error)

Unmarshal decodes the full resolved configuration into target.

type SectionBindingConfig

type SectionBindingConfig[T any] struct {
	// contains filtered or unexported fields
}

SectionBindingConfig holds ObserveSection option state.

type SectionBindingOption

type SectionBindingOption[T any] func(*SectionBindingConfig[T])

SectionBindingOption customises ObserveSection behaviour.

func WithSectionApply

func WithSectionApply[T any](apply func(SectionChange[T]) error) SectionBindingOption[T]

WithSectionApply registers a callback that runs after a successful rehydrate changes the observed typed section.

func WithSectionDefaultFunc

func WithSectionDefaultFunc[T any](defaultFunc func(Observed) T, merge func(defaults, overlay T) T) SectionBindingOption[T]

WithSectionDefaultFunc starts each observed section from defaults derived from the current config snapshot and merges the decoded section over it when the section exists.

func WithSectionDefaults

func WithSectionDefaults[T any](defaults T, merge func(defaults, overlay T) T) SectionBindingOption[T]

WithSectionDefaults starts each observed section from defaults and merges the decoded section over it when the section exists.

func WithSectionEqual

func WithSectionEqual[T any](equal func(previous, current Section[T]) bool) SectionBindingOption[T]

WithSectionEqual sets the equality function used to decide whether a successful rehydrate changed the observed section.

func WithSectionValidator

func WithSectionValidator[T any](validate func(T) error) SectionBindingOption[T]

WithSectionValidator validates each settings snapshot before it is published.

type SectionChange

type SectionChange[T any] struct {
	Previous Section[T]
	Current  Section[T]
	Initial  bool
	Changed  bool
	Version  uint64
}

SectionChange describes an observed typed section change.

Initial marks the delivery ObservedSection.ApplyInitial makes: the section the binding started with, rather than a change to it. Changed is its complement, true for every later delivery. A caller that treats both the same way can ignore them and read Current.

type Snapshot added in v0.3.0

type Snapshot struct {
	// contains filtered or unexported fields
}

Snapshot is an immutable, fully-resolved view of configuration at a point in time: the layers that contributed, the values they merge to, and which layer supplied each key.

Immutability is the point. A caller holding a snapshot sees a coherent configuration for its whole lifetime, so a sequence of related reads cannot straddle a reload and observe a mixture of old and new state. Nothing mutates a Snapshot after construction; a reload produces a new one.

func (*Snapshot) Get added in v0.3.0

func (s *Snapshot) Get(path string) (any, bool)

Get returns the effective value at a dotted path.

func (*Snapshot) Has added in v0.3.0

func (s *Snapshot) Has(path string) bool

Has reports whether a path resolves to a value.

A key whose value is an empty map or empty sequence is present: emptiness is a value, not an absence, and code may require a parent to exist while it holds nothing.

func (*Snapshot) Keys added in v0.3.0

func (s *Snapshot) Keys() []string

Keys returns every leaf path in the snapshot, sorted.

Keys whose value is an empty container are included. Excluding them would make enumeration disagree with the getters, which is the class of inconsistency this design exists to remove.

func (*Snapshot) Layers added in v0.3.0

func (s *Snapshot) Layers() []Layer

Layers returns the contributing layers in precedence order, lowest first. The slice is a copy; the layers' values are not exposed for mutation.

func (*Snapshot) Origin added in v0.3.0

func (s *Snapshot) Origin(path string) (Source, bool)

Origin returns the layer that supplied the effective value at a path.

This is the question no merge-eager configuration library can answer, and the reason provenance is recorded during the fold rather than reconstructed afterwards.

Provenance is defined for leaves — scalars, and containers that are empty. A populated subtree is assembled from however many layers contributed to it, so naming one source for it would be dishonest; Origin reports not-found and Snapshot.Shadowed answers what the caller actually wants to know.

func (*Snapshot) Shadowed added in v0.3.0

func (s *Snapshot) Shadowed(path string) []Source

Shadowed returns every layer that defines a path, in precedence order — lowest first, so the last entry is the one in effect.

"Which file do I edit?" and "why is my edit not taking effect?" are the same question asked from two directions, and both need the full list rather than just the winner.

func (*Snapshot) Values added in v0.3.0

func (s *Snapshot) Values() map[string]any

Values returns the merged configuration as a map. The result is a copy, so mutating it cannot affect the snapshot.

func (*Snapshot) Version added in v0.3.0

func (s *Snapshot) Version() uint64

Version is a monotonically increasing counter, incremented for every snapshot the Store emits. It lets a caller tell whether the configuration it is looking at has moved on.

type Source added in v0.3.0

type Source struct {
	Kind SourceKind
	// Name is the file path for file sources, the variable name for env,
	// the flag name for flags, and empty otherwise.
	Name string
	// Document is the zero-based index within a multi-document file. Always
	// zero for non-file sources and single-document files.
	Document int
	// Writable reports whether this layer can be persisted to. Env, flags and
	// compiled-in defaults are readable but not writable, and routing must
	// skip them.
	Writable bool
}

Source identifies one contributing layer.

A file that holds several YAML documents contributes one Source per document, because a document is a layer: that is what lets routing, provenance and precedence treat documents and files uniformly.

func (Source) Authored added in v0.3.0

func (s Source) Authored() bool

Authored reports whether a source is one a person edits and a schema should therefore govern.

A file and a compiled-in default are written deliberately, so a key appearing in one that the schema does not describe is worth reporting. The environment, command-line flags and layers added at runtime are ambient: a deployment platform exporting an unrelated prefixed variable must not be able to stop an application starting.

Declared beside the kinds it derives from, so the question is answered once rather than by an allowlist in whatever code happens to need it.

func (Source) String added in v0.3.0

func (s Source) String() string

String renders a source for display and for provenance reporting.

type SourceKind added in v0.3.0

type SourceKind string

SourceKind identifies where a layer's values came from.

Provenance is only useful if it can name every kind of source, not just files: "came from prod.yaml line 14" and "came from the environment" are both answers a user needs when asking why a value is what it is.

const (
	// SourceFile is a configuration file.
	SourceFile SourceKind = "file"
	// SourceEnv is an environment variable.
	SourceEnv SourceKind = "env"
	// SourceFlag is a bound command-line flag.
	SourceFlag SourceKind = "flag"
	// SourceDefault is a compiled-in default.
	SourceDefault SourceKind = "default"
	// SourceOverride is an ephemeral in-process override. It is never
	// persisted.
	SourceOverride SourceKind = "override"
)

type Store added in v0.3.0

type Store struct {
	// contains filtered or unexported fields
}

Store is the sole owner of configuration I/O.

Nothing else reads, writes or watches a configuration source. That single rule is what removes the need for coordination between components: there is no protocol between a reader and a writer because there is only one of each, and it is this.

Access is serialised internally. Concurrent loads and writes cannot interleave, so a reader never observes a partially applied change, and two writers cannot both decide to write and then overwrite one another.

func NewStore added in v0.3.0

func NewStore(ctx context.Context, opts ...StoreOption) (*Store, error)

NewStore builds a Store and performs its first load.

Construction and loading are deliberately not separable. A Store that exists but has not loaded is a state every caller would have to handle and most would forget to.

A configuration that loads and parses but violates the schema is returned *alongside* ErrInvalidConfig rather than discarded. The usual `if err != nil { return }` still fails fast, so a service refuses to start on a bad config exactly as before. But a tool whose job is to repair configuration needs a Store to repair it through, and returning nil would make one missing key unfixable by the surface designed to fix it — see D15. Such a caller checks for ErrInvalidConfig specifically and proceeds:

s, err := config.NewStore(ctx, opts...)
if err != nil && !errors.Is(err, config.ErrInvalidConfig) {
    return err // genuinely unusable: no sources, unreadable, unparseable
}

Every other failure — no sources, a source that cannot be read, one that cannot be parsed — still returns nil, because there is no configuration to hand back.

func (*Store) AddLayer added in v0.3.0

func (s *Store) AddLayer(ctx context.Context, name string, r io.Reader) error

AddLayer contributes configuration from an in-memory source at runtime, at the highest precedence.

This is how a tool supplies configuration it computed rather than read: a resolved path, a value negotiated with a server, a toggle that applies to this run only. It is an ordinary layer, so precedence, provenance, merging and shadowing all work on it exactly as they do on a file, and nothing special has to be remembered about it.

It is never a write target. An in-memory source has nowhere to persist to, so routing skips it — a later write lands in the file beneath and the added layer goes on winning, which the plan reports as a shadowed write rather than letting the caller believe their edit took effect.

Compiled-in defaults belong in WithReaders at construction instead, where they sit at the bottom of the order rather than the top.

The layer survives reloads: it is a source like any other, re-read each time. A layer that cannot be parsed is refused and not adopted, because leaving it in place would make every later reload fail on content the caller has no way to withdraw.

func (*Store) AddObserver added in v0.3.0

func (s *Store) AddObserver(o Observable)

AddObserver registers a component to be told when configuration changes.

func (*Store) AddObserverFunc added in v0.3.0

func (s *Store) AddObserverFunc(f func(Observed) error)

AddObserverFunc registers a function to be told when configuration changes.

func (*Store) Apply added in v0.3.0

func (s *Store) Apply(ctx context.Context, changes ...Change) (*Snapshot, error)

Apply routes changes, writes them, and publishes the resulting snapshot.

The snapshot is constructed from the content just written rather than by re-reading afterwards. That is what makes a write self-contained: there is no round trip through the filesystem, no waiting for a watcher, and no window in which the configuration in memory disagrees with the file on disk.

Execution is prepare → verify → commit. Everything expensive or likely to fail happens while nothing is visible; commit is a sequence of renames.

func (*Store) Observers added in v0.3.0

func (s *Store) Observers() []Observable

Observers returns the registered observers, for tests and diagnostics.

func (*Store) OnObserverError added in v0.3.0

func (s *Store) OnObserverError(f func(error))

OnObserverError registers a callback for observers that failed to react.

Separate from OnReloadError because the two mean different things: a rejected reload means the configuration did not change, whereas a failing observer means it did and one component could not keep up.

func (*Store) OnReloadError added in v0.3.0

func (s *Store) OnReloadError(f func(error))

OnReloadError registers a callback for reloads that were rejected.

Rejections travel on their own channel because nothing changed: telling observers "configuration changed" when it did not would have them re-read values identical to the ones they hold.

func (*Store) Plan added in v0.3.0

func (s *Store) Plan(changes ...Change) (*Plan, error)

Plan routes changes without writing anything.

The result is exactly what Apply would execute, so a dry run cannot drift from the real thing the way a separate preview implementation would.

func (*Store) Reload added in v0.3.0

func (s *Store) Reload(ctx context.Context) error

Reload re-reads every backend and, on success, publishes a new snapshot.

It is fail-closed: if any source fails to load or parse, the error is returned and the previous snapshot is retained. A configuration that is partly the old values and partly the new is worse than either, and worse than refusing.

func (*Store) Snapshot added in v0.3.0

func (s *Store) Snapshot() *Snapshot

Snapshot returns the current configuration.

The returned snapshot is immutable and will not change underneath the caller, so a sequence of reads against one snapshot is coherent even if a reload lands midway.

func (*Store) Sources added in v0.3.0

func (s *Store) Sources() []string

Sources returns the identity of every backend, in precedence order.

func (*Store) View added in v0.3.0

func (s *Store) View() *View

View returns a typed read surface over the Store's current snapshot.

func (*Store) Watch added in v0.3.0

func (s *Store) Watch(ctx context.Context, opts ...WatchOption) (stop func(), err error)

Watch begins reacting to changes made outside this process.

The Store's own writes never travel this path: Apply builds the next snapshot from what it just wrote and notifies directly, so a write cannot come back round through the watcher. That is what makes a write-then-react-then-write cascade unrepresentable rather than something to be detected and broken.

Watching only covers file-backed sources. Environment variables and flags do not change under a running process, and an in-memory source is changed by the code that owns it.

A watcher that cannot function fails here rather than silently doing nothing: an application that believes it will hear about changes and never does is worse off than one that knows it must restart.

func (*Store) With added in v0.3.0

func (s *Store) With(fn func(*View) error) error

With runs fn against a view pinned to one snapshot.

Individual reads are always coherent, but a sequence of them can straddle a reload — read the host from one snapshot and the port from the next, and you connect to the new host on the old port. With closes that window for a block of related reads.

It is scoped to a closure rather than handed out as a handle because a handle can be kept indefinitely: it would pin parsed configuration in memory and serve values that quietly grew arbitrarily old.

type StoreOption added in v0.3.0

type StoreOption func(*Store)

StoreOption configures a Store.

func RequireFirstSource added in v0.3.0

func RequireFirstSource() StoreOption

RequireFirstSource makes the first backend mandatory: if it is missing, the Store fails to load rather than starting up with a hole in its configuration.

func WithBackend added in v0.3.0

func WithBackend(b Backend) StoreOption

WithBackend appends a backend. Backends are read in the order they are added, and later ones take precedence.

func WithEnv added in v0.3.0

func WithEnv(prefix string, opts ...EnvOption) StoreOption

WithEnv appends an environment backend reading variables under a prefix.

Add it after the file sources: environment variables are expected to override what is on disk, and precedence follows the order backends are added.

func WithFiles added in v0.3.0

func WithFiles(filesystem FS, paths ...string) StoreOption

WithFiles appends a file backend per path, in precedence order — the first is the base and the last wins.

func WithFlags added in v0.3.0

func WithFlags(flags *pflag.FlagSet, opts ...FlagOption) StoreOption

WithFlags appends a backend contributing the flags the user actually changed. Add it last: an explicit flag is the most deliberate input there is.

func WithReaders added in v0.3.0

func WithReaders(sources ...NamedSource) StoreOption

WithReaders appends in-memory sources, in precedence order.

Compiled-in defaults belong here: they contribute a layer like any other, so they take part in precedence and provenance rather than being a special case that has to be remembered.

func WithSchema

func WithSchema(schema *Schema) StoreOption

WithSchema validates every candidate configuration before it is published.

Validation is fail-closed and applies to the resolved configuration rather than to any single layer, because a layer can be legitimately incomplete on its own — a base file may omit a key that an overlay supplies, and judging it alone would reject a perfectly valid setup.

A reload that fails validation leaves the previous configuration in place: running on the last known good values is better than running on values the application has said it cannot use.

type ValidationError

type ValidationError struct {
	// Key is the dot-separated config key.
	Key string
	// Message is a human-readable description of the failure.
	Message string
	// Hint is an actionable fix suggestion.
	Hint string
}

ValidationError contains details about a single validation failure.

func (ValidationError) String

func (e ValidationError) String() string

type ValidationResult

type ValidationResult struct {
	Errors   []ValidationError
	Warnings []ValidationError
}

ValidationResult holds the outcome of schema validation.

func (*ValidationResult) Error

func (r *ValidationResult) Error() string

Error returns a formatted multi-line error string, or empty string if valid.

func (*ValidationResult) Valid

func (r *ValidationResult) Valid() bool

Valid returns true if no errors were found. Warnings do not affect validity.

type View added in v0.3.0

type View struct {
	// contains filtered or unexported fields
}

View is a typed read surface over one snapshot.

A View performs no I/O. It resolves values from the snapshot it was built with, so what it returns cannot change underneath a caller — a sequence of reads through one View is coherent even if the Store publishes a new snapshot midway.

Views are cheap: taking one is a pointer copy, not a load.

func NewView added in v0.3.0

func NewView(snap *Snapshot) *View

NewView returns a typed view over a snapshot.

func (*View) Explain added in v0.3.0

func (v *View) Explain(path string) string

Explain describes where a value came from and what else defines it.

This is the question every configuration debugging session starts with, and the one a merge-eager library cannot answer at all.

func (*View) Get added in v0.3.0

func (v *View) Get(path string) any

Get returns the raw value at a path.

func (*View) GetBool added in v0.3.0

func (v *View) GetBool(path string) bool

GetBool returns a path as a bool.

func (*View) GetDuration added in v0.3.0

func (v *View) GetDuration(path string) time.Duration

GetDuration returns a path as a duration.

func (*View) GetFloat added in v0.3.0

func (v *View) GetFloat(path string) float64

GetFloat returns a path as a float64.

func (*View) GetFloat64 added in v0.3.0

func (v *View) GetFloat64(path string) float64

GetFloat64 reads a value as a float64.

The same as View.GetFloat, under the name the incumbent used, so porting code does not have to be edited to say the same thing differently.

func (*View) GetInt added in v0.3.0

func (v *View) GetInt(path string) int

GetInt returns a path as an int.

func (*View) GetInt32 added in v0.3.0

func (v *View) GetInt32(path string) int32

GetInt32 reads a value as an int32.

func (*View) GetInt64 added in v0.3.0

func (v *View) GetInt64(path string) int64

GetInt64 reads a value as an int64.

func (*View) GetIntSlice added in v0.3.0

func (v *View) GetIntSlice(path string) []int

GetIntSlice reads a value as a list of ints.

func (*View) GetSizeInBytes added in v0.3.0

func (v *View) GetSizeInBytes(path string) uint

GetSizeInBytes reads a value written as a size, such as "10MB", in bytes.

Suffixes are the binary ones — kb, mb, gb, tb — each a multiple of 1024, and a bare number is a count of bytes. A value that cannot be read as a size is zero, which is the same answer the other accessors give.

func (*View) GetString added in v0.3.0

func (v *View) GetString(path string) string

GetString returns a path as a string.

func (*View) GetStringMap added in v0.3.0

func (v *View) GetStringMap(path string) map[string]any

GetStringMap reads a value as a map of string to anything.

func (*View) GetStringMapString added in v0.3.0

func (v *View) GetStringMapString(path string) map[string]string

GetStringMapString reads a value as a map of string to string.

func (*View) GetStringMapStringSlice added in v0.3.0

func (v *View) GetStringMapStringSlice(path string) map[string][]string

GetStringMapStringSlice reads a value as a map of string to list of string.

func (*View) GetStringSlice added in v0.3.0

func (v *View) GetStringSlice(path string) []string

GetStringSlice returns a path as a string slice.

func (*View) GetTime added in v0.3.0

func (v *View) GetTime(path string) time.Time

GetTime returns a path as a time.

func (*View) GetUint added in v0.3.0

func (v *View) GetUint(path string) uint

GetUint reads a value as a uint.

func (*View) GetUint8 added in v0.3.0

func (v *View) GetUint8(path string) uint8

GetUint8 reads a value as a uint8.

func (*View) GetUint16 added in v0.3.0

func (v *View) GetUint16(path string) uint16

GetUint16 reads a value as a uint16.

func (*View) GetUint32 added in v0.3.0

func (v *View) GetUint32(path string) uint32

GetUint32 reads a value as a uint32.

func (*View) GetUint64 added in v0.3.0

func (v *View) GetUint64(path string) uint64

GetUint64 reads a value as a uint64.

func (*View) Has added in v0.3.0

func (v *View) Has(path string) bool

Has reports whether a path is present.

A key whose value is an empty container is present: emptiness is a value.

func (*View) IsSet added in v0.3.0

func (v *View) IsSet(path string) bool

IsSet is an alias for Has.

The incumbent distinguished "present in a file" from "present anywhere", because its file layer and its environment layer were different kinds of thing. Here every source is a layer, so there is one honest answer and two names for it, kept for familiarity.

func (*View) Keys added in v0.3.0

func (v *View) Keys() []string

Keys returns every leaf path visible through this view, sorted.

func (*View) Origin added in v0.3.0

func (v *View) Origin(path string) (Source, bool)

Origin reports which layer supplied the effective value at a path.

func (*View) SectionExists added in v0.3.0

func (v *View) SectionExists(path string) bool

SectionExists reports whether a path holds a mapping.

An empty path asks about the view's own root, which is a mapping whenever there is any configuration at all — that is what lets a caller check a scoped view without special-casing the top level.

func (*View) Shadowed added in v0.3.0

func (v *View) Shadowed(path string) []Source

Shadowed lists every layer defining a path, lowest precedence first.

func (*View) Snapshot added in v0.3.0

func (v *View) Snapshot() *Snapshot

Snapshot returns the snapshot this view reads from.

func (*View) Sub added in v0.3.0

func (v *View) Sub(key string) *View

Sub returns a view scoped to a subtree.

The result stays live against the same snapshot rather than holding a detached copy: every read is resolved through the full path, so a scoped view cannot serve values that the rest of the configuration has moved past. Returns nil when the key is absent, so `if sub != nil` guards behave.

func (*View) Unmarshal added in v0.3.0

func (v *View) Unmarshal(target any) error

Unmarshal decodes the whole view into a struct.

func (*View) UnmarshalKey added in v0.3.0

func (v *View) UnmarshalKey(path string, target any) error

UnmarshalKey decodes a subtree into a struct.

Decoding is a single operation against one snapshot, so a struct populated this way is internally consistent by construction — it cannot contain some fields from before a reload and some from after.

func (*View) Validate added in v0.3.0

func (v *View) Validate(schema *Schema) *ValidationResult

Validate checks what this view describes against a schema.

A scoped view validates its own subtree, so a schema written for a section applies to that section rather than to the whole configuration. The result carries errors and warnings separately; check ValidationResult.Valid.

type WatchOption added in v0.3.0

type WatchOption func(*watchConfig)

WatchOption configures watching.

func WithPollInterval added in v0.3.0

func WithPollInterval(d time.Duration) WatchOption

WithPollInterval sets how often a polling watcher checks for changes.

func WithSettleInterval added in v0.3.0

func WithSettleInterval(d time.Duration) WatchOption

WithSettleInterval sets how long a burst of foreign changes is allowed to settle before the Store reloads.

A logical configuration change is not always one filesystem event: a deploy replacing two overlays produces two, and nothing in the filesystem says they were meant as one. Waiting for the burst to settle turns them into a single reload, and a single notification.

Zero disables settling, reloading on each report. Tests driving an injected watcher usually want that, so the trigger and the reload stay in step without waiting on a timer.

This does not make a multi-file change atomic. Writes spaced further apart than the window are still seen as separate changes — the guarantee belongs to whoever writes the files, by writing them atomically or by keeping settings that change together in one file.

func WithWatcher added in v0.3.0

func WithWatcher(w Watcher) WatchOption

WithWatcher supplies a watcher, mainly so tests can drive change detection deterministically instead of waiting on a real filesystem.

type WatchableBackend added in v0.3.0

type WatchableBackend interface {
	Backend

	// Watch calls onChange when this backend's sources may have changed. The
	// returned function stops watching and releases whatever it holds.
	//
	// It reports *possible* change rather than actual change: a backend cannot
	// know whether a write altered anything that resolves, and deciding that is
	// the Store's job.
	Watch(ctx context.Context, interval time.Duration, onChange func()) (stop func(), err error)
}

WatchableBackend is a backend that can report when its own sources change.

Separate from Backend for the same reason writing is: being readable does not make a source watchable, and only the backend knows how to notice a change to whatever it reads — a file has a path on a filesystem, a remote store has a subscription — and the Store should not have to know which.

The Store's job is coordination: it asks each backend that can watch to say when something moved, then decides for itself whether the resolved configuration actually changed.

type Watcher added in v0.3.0

type Watcher interface {
	// Watch begins watching, calling onChange when the paths may have changed.
	// The returned function stops watching and releases resources.
	Watch(ctx context.Context, paths []string, onChange func()) (stop func(), err error)
}

Watcher reports that watched paths may have changed.

It reports *possible* change rather than actual change: filesystem notification is noisy, delivering events for permission changes, atomic renames and editors writing in several passes. Deciding whether anything really changed belongs to the Store, which can compare configurations.

func NewWatcher added in v0.3.0

func NewWatcher(filesystem FS, interval time.Duration) Watcher

NewWatcher returns the watcher appropriate to a filesystem.

Native notification is used where it works and polling everywhere else. The distinction is not cosmetic: fsnotify operates on real paths, so it silently fails on an in-memory filesystem — which is exactly what a test, or a tool working over a virtual worktree, will be using.

type WritableBackend added in v0.3.0

type WritableBackend interface {
	Backend

	// Prepare stages edits without making them visible. It must not modify
	// the source: everything it does has to be abandonable.
	Prepare(ctx context.Context, edits []Edit) (Pending, error)
}

WritableBackend is a backend that can persist changes.

It is separate from Backend because reading and writing are genuinely different problems. A backend that can be read is not thereby able to handle atomicity, conflict detection or rollback, and pretending otherwise would make every caller check capabilities at runtime instead of the type system checking them once.

type YAMLCodec added in v0.7.0

type YAMLCodec struct{}

YAMLCodec reads and edits YAML configuration. It is what NewFileBackend and WithFiles use, and it is exported so a backend adapter can reuse it as a value decoder — a Consul or etcd key whose value is a YAML document, decoded into a subtree via that adapter's WithValueCodec option. It is a Codec (and an EditingCodec); the sibling format adapters export theirs as `Codec`, but the plain name is the interface here, so this one carries the format.

It reads the file twice, deliberately. Values are decoded by the YAML value parser, while document structure — comments, positions, and whether the file can be edited safely at all — comes from yamldoc. The two disagree about scalar types (`8080` decodes as int in one and uint64 in the other, and large integers survive in one and are destroyed in the other), so the boundary between documents and values must not be crossed. Values never come from yamldoc; documents never come from the value parser.

func (YAMLCodec) Apply added in v0.7.0

func (YAMLCodec) Apply(path string, src []byte, edits []Edit) ([]byte, error)

Apply edits the document tree and re-emits it.

Editing goes through yamldoc so comments, key order, quoting and block styles survive. Nothing here decodes values from that tree: the documents-versus-values boundary is what keeps the two YAML parsers from disagreeing about types.

func (YAMLCodec) Check added in v0.7.0

func (YAMLCodec) Check(path string, src []byte) error

Check reports whether a source can be edited without risking corruption.

The judgement is this module's; the detection is yamldoc's. It reports what it cannot round-trip safely, and refusing is the policy applied to that report.

func (YAMLCodec) Decode added in v0.7.0

func (YAMLCodec) Decode(path string, src []byte) ([]map[string]any, error)

Decode decodes every YAML document in a source into its own map.

An empty document is a nil entry rather than an omission, so the documents that follow it keep their index — which is how routing, precedence and provenance treat documents and files uniformly, and it fixes a defect in the incumbent, which reads the first document of a multi-document file and silently discards the rest.

func (YAMLCodec) Empty added in v0.7.0

func (YAMLCodec) Empty() []byte

Empty returns the content of a new, empty YAML document.

A YAML file created from nothing needs no preamble — an empty file is a valid empty document. The create-a-file path seeds a mapping to edit into from within Apply, so nothing here has to.

Directories

Path Synopsis
Package backendconformance is a reusable test suite that a config backend adapter runs against its own backend to prove it behaves like a first-class layer.
Package backendconformance is a reusable test suite that a config backend adapter runs against its own backend to prove it behaves like a first-class layer.
Package conformance is a reusable test suite that a config codec adapter runs against its own codec to prove it behaves like a first-class backend.
Package conformance is a reusable test suite that a config codec adapter runs against its own codec to prove it behaves like a first-class backend.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL