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 Codec — Codec.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
- Variables
- func MustValue[T any](cfg Reader, path string) T
- func NestedPromotable(c *nestedConfig)
- func Required(m *mounted)
- func ValidateStruct[T any](cfg Reader, opts ...SchemaOption) error
- func Value[T any](cfg Reader, path string) (T, error)
- type Backend
- func Constrained(b Backend, opts ...ConstraintOption) Backend
- func Filtered(b Backend, opts ...FilterOption) Backend
- func Nested(s *Store, id string, opts ...NestedOption) Backend
- func NewCodecBackend(filesystem FS, path string, codec Codec) Backend
- func NewEnvBackend(prefix string, opts ...EnvOption) Backend
- func NewFileBackend(filesystem FS, path string) Backend
- func NewFlagBackend(flags *pflag.FlagSet, opts ...FlagOption) Backend
- func NewReaderBackend(name string, content []byte) Backend
- type Binder
- type Capabilities
- type Change
- type ChangeOption
- type Codec
- type CommentPreservingCodec
- type ConstraintOption
- type DirLister
- type Edit
- type EditingCodec
- type EnvOption
- type FS
- type FieldSchema
- type FilterOption
- type FlagOption
- type Layer
- type LinkReader
- type MountOption
- type Named
- type NamedSource
- type NestedOption
- type Observable
- type Observed
- type ObservedSection
- type ObserverFunc
- type Operation
- type Pending
- type Plan
- type PollIntervalHinter
- type Reader
- type RealPather
- type Schema
- type SchemaOption
- type Section
- type SectionBindingConfig
- type SectionBindingOption
- func WithSectionApply[T any](apply func(SectionChange[T]) error) SectionBindingOption[T]
- func WithSectionDefaultFunc[T any](defaultFunc func(Observed) T, merge func(defaults, overlay T) T) SectionBindingOption[T]
- func WithSectionDefaults[T any](defaults T, merge func(defaults, overlay T) T) SectionBindingOption[T]
- func WithSectionEqual[T any](equal func(previous, current Section[T]) bool) SectionBindingOption[T]
- func WithSectionValidator[T any](validate func(T) error) SectionBindingOption[T]
- type SectionChange
- type Shadow
- type Snapshot
- func (s *Snapshot) Get(path string) (any, bool)
- func (s *Snapshot) Has(path string) bool
- func (s *Snapshot) Keys() []string
- func (s *Snapshot) Layers() []Layer
- func (s *Snapshot) Origin(path string) (Source, bool)
- func (s *Snapshot) Shadowed(path string) []Source
- func (s *Snapshot) Shadows() []Shadow
- func (s *Snapshot) Values() map[string]any
- func (s *Snapshot) Version() uint64
- type Source
- type SourceKind
- type SourceKindDeclarer
- type Store
- func (s *Store) AddLayer(ctx context.Context, name string, r io.Reader) error
- func (s *Store) AddObserver(o Observable)
- func (s *Store) AddObserverFunc(f func(Observed) error)
- func (s *Store) Apply(ctx context.Context, changes ...Change) (*Snapshot, error)
- func (s *Store) Observers() []Observable
- func (s *Store) OnObserverError(f func(error))
- func (s *Store) OnReloadError(f func(error))
- func (s *Store) Plan(changes ...Change) (*Plan, error)
- func (s *Store) Reload(ctx context.Context) error
- func (s *Store) Snapshot() *Snapshot
- func (s *Store) Sources() []string
- func (s *Store) Validate(paths ...string) *ValidationResult
- func (s *Store) View() *View
- func (s *Store) Watch(ctx context.Context, opts ...WatchOption) (stop func(), err error)
- func (s *Store) With(fn func(*View) error) error
- func (s *Store) WritableTargets() []Source
- type StoreOption
- func RequireFirstSource() StoreOption
- func WithBackend(b Backend) StoreOption
- func WithEnv(prefix string, opts ...EnvOption) StoreOption
- func WithFiles(filesystem FS, paths ...string) StoreOption
- func WithFlags(flags *pflag.FlagSet, opts ...FlagOption) StoreOption
- func WithReaders(sources ...NamedSource) StoreOption
- func WithSchema(schema Schema) StoreOption
- func WithSchemaAt(prefix string, schema Schema, opts ...MountOption) StoreOption
- type StructSchema
- type ValidationError
- type ValidationResult
- type View
- func (v *View) Explain(path string) string
- func (v *View) Get(path string) any
- func (v *View) GetBool(path string) bool
- func (v *View) GetDuration(path string) time.Duration
- func (v *View) GetFloat(path string) float64
- func (v *View) GetFloat64(path string) float64
- func (v *View) GetInt(path string) int
- func (v *View) GetInt32(path string) int32
- func (v *View) GetInt64(path string) int64
- func (v *View) GetIntSlice(path string) []int
- func (v *View) GetSizeInBytes(path string) uint
- func (v *View) GetString(path string) string
- func (v *View) GetStringMap(path string) map[string]any
- func (v *View) GetStringMapString(path string) map[string]string
- func (v *View) GetStringMapStringSlice(path string) map[string][]string
- func (v *View) GetStringSlice(path string) []string
- func (v *View) GetTime(path string) time.Time
- func (v *View) GetUint(path string) uint
- func (v *View) GetUint8(path string) uint8
- func (v *View) GetUint16(path string) uint16
- func (v *View) GetUint32(path string) uint32
- func (v *View) GetUint64(path string) uint64
- func (v *View) Has(path string) bool
- func (v *View) IsSet(path string) bool
- func (v *View) Keys() []string
- func (v *View) Origin(path string) (Source, bool)
- func (v *View) SectionExists(path string) bool
- func (v *View) Shadowed(path string) []Source
- func (v *View) Shadows() []Shadow
- func (v *View) Snapshot() *Snapshot
- func (v *View) Sub(key string) *View
- func (v *View) Unmarshal(target any) error
- func (v *View) UnmarshalKey(path string, target any) error
- func (v *View) Validate(schema Schema) *ValidationResult
- type WatchErrorReporter
- type WatchOption
- type WatchPathReporter
- type WatchableBackend
- type Watcher
- type WritableBackend
- type YAMLCodec
Constants ¶
const DefaultPollInterval = 2 * time.Second
DefaultPollInterval is how often the polling watcher checks for changes when the filesystem cannot notify.
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 ¶
var ( // ErrBackendUnsafe is returned when a source contains a construct that // cannot be safely round-tripped, so editing it would risk corruption. ErrBackendUnsafe = errors.NewSentinel("config.backend_unsafe", "config: source cannot be safely edited") // ErrBackendParse is returned when a source is not valid for its format. ErrBackendParse = errors.NewSentinel("config.backend_parse", "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.NewSentinel("config.internal", "config: internal invariant violated") )
Backend errors. Callers should branch on these with errors.Is.
var ( // ErrCyclicStore is returned when composing stores would put one store into // the graph twice — whether or not that closes a cycle. See [Nested]. ErrCyclicStore = errors.NewSentinel("config.cyclic_store", "config: store is already present in this graph") // ErrDuplicateLayer is returned when two layers in a composed store are // indistinguishable: equal in kind, name and document, so nothing can tell // them apart. See [Nested]. ErrDuplicateLayer = errors.NewSentinel("config.duplicate_layer", "config: two layers in the composed store are indistinguishable") )
Composition errors.
var ( // ErrNoWritableLayer is returned when a change has nowhere it can be // written: every layer that could hold it is read-only. ErrNoWritableLayer = errors.NewSentinel("config.no_writable_layer", "config: no writable layer for change") // ErrNoChanges is returned when an apply is asked to do nothing. ErrNoChanges = errors.NewSentinel("config.no_changes", "config: no changes to apply") // ErrInvalidPath is returned for a malformed dotted path. ErrInvalidPath = errors.NewSentinel("config.invalid_path", "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.NewSentinel("config.invalid_target", "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.NewSentinel("config.sensitive_leak", "config: refusing to write a sensitive key into a non-sensitive layer") )
Routing errors.
var ( // ErrNoSources is returned when a Store is built with nothing to read. ErrNoSources = errors.NewSentinel("config.no_sources", "config: no sources configured") // ErrInvalidConfig is returned when a candidate configuration fails schema // validation. The previous configuration, if any, is retained. ErrInvalidConfig = errors.NewSentinel("config.invalid_config", "config: configuration is not valid") )
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.NewSentinel("config.conflict", "config: source changed since it was read") // ErrNotWritable is returned when a change is routed at a backend that // cannot persist. ErrNotWritable = errors.NewSentinel("config.not_writable", "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.NewSentinel("config.partial_commit", "config: commit partially applied") )
Write errors.
var ErrAmbiguousEnvKey = errors.NewSentinel("config.ambiguous_env_key", "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.
var ErrEmptySchema = errors.NewSentinel("config.empty_schema", "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.
var ErrForbiddenKey = errors.NewSentinel("config.forbidden_key", "config: forbidden key")
ErrForbiddenKey is returned when a source is asked to write a key its constraint forbids. Match it with errors.Is to tell a policy refusal apart from a value that merely failed its shape check.
It is its own sentinel rather than an ErrInvalidConfig, because the configuration is not invalid — the write was refused. That is the same distinction ErrSensitiveLeak draws, and a caller acts on the two differently: one is repaired by fixing a value, the other by writing somewhere else.
var ErrNoMergeFunc = errors.NewSentinel("config.no_merge_func", "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.
var ErrReadOnlyFS = errors.NewSentinel("config.read_only_f_s", "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.
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.
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
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 NestedPromotable ¶ added in v0.11.0
func NestedPromotable(c *nestedConfig)
NestedPromotable makes a nested store's writable layers valid targets for an explicitly named write, without making them candidates for routing.
Without it a nested store is read-only: its layers are read and merged, and a write never lands in one. That is the safe default and the usual case — a shared organisational base, a team default.
With it, a write that names an inner layer with To reaches it, while an unpinned write still never does. The asymmetry is the point. Routing prefers a writable layer that already defines the key, so a routable nested store would mean an ordinary project-scoped edit walking past the project's own file and rewriting the shared config every other consumer inherits. Promotion has to be deliberate, and naming the target is what makes it so.
func Required ¶ added in v0.14.0
func Required(m *mounted)
Required makes the mount prefix itself mandatory: the configuration must carry something at that path, not merely satisfy the schema if it does.
Reach for it when a component cannot run without configuration. Leave it off for a component that is optional — an absent subtree is exactly right for a plugin nobody enabled, and exactly wrong for one that is mandatory, which is why this is the contribution's choice and not the composer's.
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
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.
//
// It must be answerable without a connection — see the note on
// [Backend] about backends that resolve their own.
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.
Backends that resolve their own connection ¶
Most backends are handed a client the consumer built. One that instead resolves its own — from an ambient credential chain, say — must still be constructible without touching the network, because a Store is assembled before anything is read:
- ID and Capabilities must be answerable with no connection. The Store calls both while ordering layers, routing writes and deciding what to watch, long before the first Load. A backend that connected to answer either would turn NewStore into a network operation, and fail there rather than at the Load that actually needs the data.
- Load is where the connection is acquired, and it has a context to bound the attempt with.
- Failing to connect is an ordinary error from Load — never a panic, and never io/fs.ErrNotExist. That sentinel means the source is legitimately absent, which the Store may tolerate, so reporting an unreachable connection with it turns a hard failure into a silently missing layer. Absent and unreachable are different answers.
- A failed connection must not be remembered. A Store is long-lived and reloads; a credential chain that was not ready at startup must be picked up by the next Load rather than requiring a restart. This rules out sync.OnceValues, which caches the first error for the life of the process.
config/backendconformance checks all four; an adapter opts in by supplying its Suite.NewUnreachable.
func Constrained ¶ added in v0.14.0
func Constrained(b Backend, opts ...ConstraintOption) Backend
Constrained bounds what a source is allowed to CONTRIBUTE, and reports it when the source oversteps.
It judges only what the backend actually supplies. A layer is never required to be complete — a base file may omit a key its overlay provides — so a key the constraint describes and this source does not carry is somebody else's to supply and is not a failure here.
This is not Filtered, and the difference is the point ¶
Filtered bounds VISIBILITY: a denied key is simply absent, reads fall through to whatever lower layer has one, and nothing is said. That is right for "this source does not get to contribute here".
Constrained bounds POLICY: a forbidden key stays visible and is reported. That is right for "this source contributing here is a fault".
Reaching for the wrong one is costly in exactly the case that matters most. A literal credential in a file layer beneath a working environment reference is a leak; filtering it out would remove it from the store and the leak would never be reported by anything. Filtering hides; constraining tells.
func Filtered ¶ added in v0.11.0
func Filtered(b Backend, opts ...FilterOption) Backend
Filtered bounds which keys a backend contributes, and therefore which it can be written to.
A decorator rather than a feature of any one backend, so the same rules apply to a secrets manager, a parameter store, a file or a nested store:
config.WithBackend(config.Filtered(vault, config.Allow("db.**")))
A denied key is simply absent — reads fall through to whatever lower layer defines it, and a write routes past this backend to the next writable layer, because a backend that defines nothing for a key is correctly skipped by routing. Neither needs a special case.
Filtering happens after the backend has fetched and parsed, so it is a visibility bound and not a fetch optimisation: a denied key is still read from the remote system, still crosses the network, and still sits in memory briefly. It is not a security control against the backend itself.
With no options the backend is returned unchanged, so the zero configuration costs nothing.
func Nested ¶ added in v0.11.0
func Nested(s *Store, id string, opts ...NestedOption) Backend
Nested makes a Store a backend of another Store, so one configuration can carry another's layers as its own.
The inner store's layers pass through as a contiguous block at the position this backend is declared, each keeping its own Source. Provenance therefore survives the join — a value from the inner store is reported as coming from the file it came from, not from the aggregate — and precedence stays exactly what the two stores' declaration orders say it is, with nothing interleaved.
The id names this aggregate in errors. It is not a layer name, because an aggregate contributes no layer of its own.
A nested store is read-only unless NestedPromotable is passed.
Composition forms a tree: a store may appear at most once in a graph, and nesting one already reachable is ErrCyclicStore at construction. Reads recurse to any depth.
A store composed over another does not see writes made directly to the inner store until it next reloads. When the outer store is watching, that happens on the next tick; when it is not, call Store.Reload.
func NewCodecBackend ¶ added in v0.5.0
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
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
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
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.
type ChangeOption ¶ added in v0.11.0
type ChangeOption func(*Change)
ChangeOption adjusts a single Change at the point it is written.
A function type rather than an interface, matching StoreOption and WatchOption. The set of things a change can carry is closed by Change's own fields, so there is nothing for a third party to add that this package could still validate.
func To ¶ added in v0.11.0
func To(name string) ChangeOption
To pins a change to the layer with the given name, overriding routing.
Reach for it sparingly. Routing's default — edit where the key already lives, create where it will be visible — is right far more often than a hand-picked target, and pinning is how an edit ends up written somewhere nobody reads. Plan will say where a change is going, and Operation.ShadowedBy will say whether anything still outranks it, so a pin can be checked before it lands.
The name is matched against the writable layers that exist, and an unmatched one is ErrInvalidTarget rather than a silent fall back to routing. Where more than one layer carries the name, the highest-precedence one wins: the one whose value the caller can read back.
A caller names a layer rather than building a Source because only Name and Document are matched — a Source built by hand invites filling in Kind or Writable and believing they select something.
func ToDocument ¶ added in v0.11.0
func ToDocument(name string, doc int) ChangeOption
ToDocument pins a change to one document of a multi-document file.
Every document in such a file shares a name, so To alone cannot address any but the first. Separate from To rather than a variadic index on it, because To(name, 0) and To(name) would have to mean the same thing while reading as a choice.
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 CommentPreservingCodec ¶ added in v0.9.3
type CommentPreservingCodec interface {
PreservesComments() bool
}
CommentPreservingCodec is an optional interface a Codec may satisfy to declare that its edits retain comments and formatting.
It is asked by interface rather than concrete type, so a codec defined outside this package — a sibling TOML or HCL adapter that genuinely round-trips comments — has its Capabilities PreservesComments reported truthfully. A codec that does not preserve comments, or has none to lose (a key-value format), simply does not implement it and reads as false.
type ConstraintOption ¶ added in v0.14.0
type ConstraintOption func(*constraint)
ConstraintOption configures what a source may contribute.
func ConstraintName ¶ added in v0.14.0
func ConstraintName(name string) ConstraintOption
ConstraintName labels the source in failure messages. Defaults to the backend's ID.
func Forbid ¶ added in v0.14.0
func Forbid(patterns ...string) ConstraintOption
Forbid declares keys this source must not supply. Supplying one is a failure, reported against the key and the source that carried it.
Patterns are the same glob shape Deny uses, so "credentials.*" covers the subtree. Use it for configuration that is legitimate somewhere else and wrong here — a credential in a file, a production endpoint in a checked-in default.
func MustMatch ¶ added in v0.14.0
func MustMatch(s Schema) ConstraintOption
MustMatch declares the shape of what this source supplies.
Only keys the source actually carries are checked, so a schema describing a required key does not fail a source that simply does not provide it. What it catches is a source supplying a value of the wrong shape — a remote store handing back a port that is not a number — at the layer that produced it, before the merge has hidden which one that was.
type DirLister ¶ added in v0.12.0
DirLister optionally enumerates a directory.
FS deliberately has no listing method: every source it was built for is named, so a backend asks for the file it was configured with rather than discovering what is there. A backend whose whole input is a *directory* — a mounted ConfigMap, a secrets directory, one file per key — needs the other shape, and this is it.
Optional rather than part of FS, for the reason RealPather is: adding a method to FS breaks every filesystem adapter in the family at once, and most of them exist to serve named files. A filesystem that cannot enumerate — or one for which enumeration would be unbounded, such as an object store with no delimiter — simply does not have the method.
The same rule applies as to the other optional interfaces: an implementation must not satisfy one it cannot honour. Returning an error from ReadDir rather than omitting the method makes every filesystem look listable, so a consumer selects it and discovers the absence one directory at a time.
ReadDir reports the directory's entries without following into them. An absent directory returns an error satisfying errors.Is(err, fs.ErrNotExist), so a caller can tell "nothing configured here yet" from a real failure.
type Edit ¶ added in v0.3.0
type Edit struct {
// Document is the document index within the source.
Document int
// Target is the layer this edit was routed at.
//
// Almost every backend can ignore it: a backend names its layers after
// itself, so receiving an edit at all identifies the source and Document
// distinguishes the documents of it. A backend contributing several
// DISTINCTLY NAMED layers cannot infer it — a store aggregate carries the
// layers of the store it wraps — and needs to know which one a write was
// aimed at.
//
// Target.Document and Document are the same value; Document predates this
// field and is kept because backends already read it.
Target Source
Path string
Value any
Remove bool
}
Edit is one change addressed at a specific layer 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
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, LinkReader and DirLister — that an implementation may simply not satisfy. See D1 of the filesystem abstraction spec.
func Dir ¶ added in v0.3.0
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.
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 FilterOption ¶ added in v0.11.0
type FilterOption func(*filterRules)
FilterOption narrows what a filtered backend contributes. See Filtered.
A function type over an unexported struct rather than an interface: the set of rules is closed by this package, and an option a third party defined could not be reasoned about by the sensitive-key handling in Filtered.
func Allow ¶ added in v0.11.0
func Allow(patterns ...string) FilterOption
Allow restricts a backend to the keys matching at least one pattern.
With no Allow, every key is permitted unless a Deny excludes it. Several Allow options, or several patterns in one, union.
func Deny ¶ added in v0.11.0
func Deny(patterns ...string) FilterOption
Deny excludes the keys matching any pattern, and beats Allow.
A caller who has written both has expressed a narrowing intention twice, so resolving the overlap the other way would let an Allow re-expose something explicitly denied.
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
Layer is one contributing set of configuration values, with the identity of where they came from.
type LinkReader ¶ added in v0.3.0
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 MountOption ¶ added in v0.14.0
type MountOption func(*mounted)
MountOption adjusts how one contribution is mounted.
type Named ¶ added in v0.15.0
type Named interface {
Name() string
}
Named is an optional interface a Schema may implement to say what it is called.
A report that aggregates several components is only actionable if it can say whose expectation was violated, and the schema is the one thing that knows. An implementation that reports its own Contributor keeps it; one that does not gets named from here, so attribution never depends on an implementation remembering to do it.
It is optional because a schema is not obliged to have a name — an anonymous one is attributed to its mount point instead, which is still something a reader can act on.
type NamedSource ¶ added in v0.3.0
NamedSource is in-memory configuration with an identity for provenance.
type NestedOption ¶ added in v0.11.0
type NestedOption func(*nestedConfig)
NestedOption configures a nested store. See NestedPromotable.
type Observable ¶
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
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.
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
Effective reports whether every operation will be visible once written.
type PollIntervalHinter ¶ added in v0.9.0
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.
A WatchableBackend may implement this too, and Store.Watch consults it the same way: a backend polling a remote store (a parameter store, a KV service) declares a slower cadence than the local-file default, and an explicit WithPollInterval still overrides it.
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
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 interface {
// Validate checks the configuration in snap and appends what it objects to.
//
// at is the mount prefix this validator was registered under — "" for the
// whole configuration. It is passed rather than applied, because a schema
// written in its own terms has to know where it ended up in order to report
// a key by the path a user would recognise.
//
// Appending rather than returning is deliberate: several validators
// contribute to one result, and a validator that returned its own would
// leave the merging — and the attribution — to the caller.
Validate(snap *Snapshot, at string, r *ValidationResult)
}
Schema is the seam an out-of-module schema implementation plugs into.
This module defines what validating a configuration MEANS and takes no position on how a schema is expressed. The tag-derived Schema this package builds is one implementation; `config-schema` supplies a JSON Schema one, with its dialect, its library and its ingestion staying entirely over there.
That split is not tidiness. Twenty-five adapter modules depend on this one and each pins its dependency footprint, so a JSON Schema library linked here would widen every one of them for a capability most do not use. Behind an interface it costs them nothing.
See the composable schema validation spec, D7.
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 ¶
Section is the result of unmarshalling an optional configuration section.
func MustUnmarshalSection ¶
MustUnmarshalSection decodes key into T and panics if decoding fails.
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 Shadow ¶ added in v0.16.0
type Shadow struct {
// Path is the leaf this describes, in the terms of whatever produced the
// report — absolute from a [Snapshot], relative from a scoped [View].
Path string
// InEffect is the layer whose value wins, the same one [Snapshot.Origin]
// reports.
InEffect Source
// Shadowed lists the layers beneath it in precedence order, lowest first.
// It always holds at least one entry, because a path only one layer defines
// is not in the report at all.
Shadowed []Source
}
Shadow describes one path that more than one layer defines.
It carries paths and sources, never values. That is a decision rather than an omission: the report exists to be printed, and the consumer driving it — a credential doctor — is forbidden from putting credential values anywhere near a log line. A report holding values would put them one `%+v` away.
It is also not a distinction worth having. A literal credential sitting under a working environment reference wants removing whether or not it happens to match the value in effect: same-value is redundant configuration, different-value is dead configuration, and both are a secret in a file. A caller that genuinely needs to compare has Snapshot.Layers.
See the whole-configuration shadow report spec, D7.
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) Has ¶ added in v0.3.0
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
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
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
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
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) Shadows ¶ added in v0.16.0
Shadows reports every path that more than one layer defines, with the layer in effect and the layers it shadows, ordered by path.
It answers "which values in this configuration are shadowed, and by what" without the caller first knowing which keys to ask about — which is the point, because not knowing the keys is usually the situation. "Why is my edit not taking effect" and "what in this file is doing nothing" are the same question, and Snapshot.Shadowed already frames it that way; this simply stops asking one path at a time.
Two deliberate differences from Shadowed ¶
The generalisation is not total, and a reader who knows Snapshot.Shadowed will reasonably expect it to be:
- **Leaves only.** Shadowed answers for a populated subtree; this does not report one. Naming the layer in effect for a tree assembled from several would be dishonest, which is exactly why Snapshot.Origin refuses it. The report covers what Snapshot.Keys returns.
- **Only what is actually shadowed.** Shadowed returns a single entry for a path one layer defines; this omits it. Reporting every path with a one-element list would make the common case — a large configuration with a handful of duplicates — need filtering before it could be used.
Ordering is by path so output is stable between runs, which is what a report anyone diffs requires.
See the whole-configuration shadow report spec, D1, D2 and D5.
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
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.
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 SourceKindDeclarer ¶ added in v0.9.3
type SourceKindDeclarer interface {
SourceKind() SourceKind
}
SourceKindDeclarer is an optional interface a Backend may satisfy to name the SourceKind its layers carry.
It matters only for a writable backend that has not contributed a layer yet: the Store must synthesise a source entry for it so a write can be routed at a target that does not exist yet, and without this declaration that entry defaults to SourceFile — so an empty Consul prefix or parameter path would present with file semantics in Plan output and Operation targets. A backend that reports a layer names its kind in the layer's Source and need not implement this; it is the empty-backend case the interface exists for.
A backend that does not implement it keeps the SourceFile default, which is right for the built-in file backend.
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
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
AddObserverFunc registers a function to be told when configuration changes.
func (*Store) Apply ¶ added in v0.3.0
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
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
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
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
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
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
Sources returns the identity of every backend, in precedence order.
func (*Store) Validate ¶ added in v0.14.0
func (s *Store) Validate(paths ...string) *ValidationResult
Validate reports what the registered validators object to in the current configuration.
With no arguments it checks everything registered. With one or more paths it checks only the contributions mounted at or beneath them, so a component can ask about its own branch without hearing about anybody else's.
The result aggregates every contributor, and each failure names the one that raised it — a report that cannot say whose expectation was violated is not actionable when several components have expectations.
Validation also runs at load, where it is fail-closed: a configuration that violates a registered schema never becomes live. This is the same check, asked again on demand.
func (*Store) View ¶ added in v0.3.0
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.
The poll cadence honours a backend's own PollIntervalHinter when the caller left the default: a backend polling a remote store where every check is a billed call declares a slower cadence, and an explicit WithPollInterval still overrides it. A WatchableBackend may implement PollIntervalHinter directly, just as a filesystem may.
func (*Store) With ¶ added in v0.3.0
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.
func (*Store) WritableTargets ¶ added in v0.11.0
WritableTargets returns the layers a change may be pinned to with To, in precedence order.
The contract is "everything To accepts", not "everywhere a write might land". Those are the same set today, and they stop being so as soon as a layer can be nameable without being routed to — so the list is built from the same sources [matchTarget] is given, rather than from anything that reasons about where routing would choose to go.
It includes the synthesised entry for a writable backend that has not contributed a layer yet, because a write may be aimed at a file that does not exist and omitting it would make the list lie about what To accepts.
[Sources] is a different question — what is in this store, readable or not — and stays as it is.
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.
func WithSchemaAt ¶ added in v0.14.0
func WithSchemaAt(prefix string, schema Schema, opts ...MountOption) StoreOption
WithSchemaAt registers a validator against the subtree at prefix.
The contribution is written in its own terms — "enabled", "ttl" — and this decides where it lives, so the same schema can be mounted twice at different points and a component never hard-codes its own location.
An empty prefix registers against the whole configuration.
Registration is a construction option, never a mutation: a store whose validators could change after it was built would let validation move under a running configuration. Components self-register before the store is constructed, which is already how assets work, so nothing needs to add one later.
type StructSchema ¶ added in v0.14.0
type StructSchema struct {
// contains filtered or unexported fields
}
StructSchema is a schema derived from Go struct tags — the ergonomic front end for declaring configuration, and one implementation of Schema.
Its vocabulary is deliberately small: a coarse type, required, enum and unknown-key detection. Richer constraints are what a JSON Schema document expresses, and `config-schema` supplies that without this module growing a dialect of its own.
func NewSchema ¶
func NewSchema(opts ...SchemaOption) (*StructSchema, error)
NewSchema derives a schema from struct tags.
It returns the concrete StructSchema rather than the Schema interface, because a caller often wants Fields. It satisfies Schema, so it can be handed to WithSchema, WithSchemaAt or View.Validate wherever a schema is asked for — a tag-derived schema is one kind of schema, not a separate concept.
func SchemaOf ¶
func SchemaOf[T any](opts ...SchemaOption) (*StructSchema, 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 (*StructSchema) Fields ¶ added in v0.14.0
func (s *StructSchema) Fields() map[string]FieldSchema
Fields returns the schema field definitions.
func (*StructSchema) Validate ¶ added in v0.14.0
func (s *StructSchema) Validate(snap *Snapshot, at string, r *ValidationResult)
Validate makes a tag-derived schema a [Validator], so the same schema a consumer already writes can be mounted as one contribution among several.
The schema's keys are relative to the mount point, matching every other contribution: a schema describing "enabled" mounted at "plugins.cache" validates plugins.cache.enabled. Failures are reported with the FULL path, because a user reading the report needs the key they would edit, not the one the component happened to call it.
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
// Contributor names the validator that raised this failure.
//
// Empty for the tag-derived schema handed to WithSchema, which is the whole
// configuration's and has nobody else to be distinguished from. Set for a
// registered contribution, because a report aggregating several components
// is not actionable if it cannot say whose expectation was violated.
Contributor 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) AddError ¶ added in v0.14.0
func (r *ValidationResult) AddError(e ValidationError)
AddError appends a failure. It is exported so a [Validator] implemented outside this module can report through the same result every other contributor uses, which is what lets one report aggregate them all.
func (*ValidationResult) AddWarning ¶ added in v0.14.0
func (r *ValidationResult) AddWarning(e ValidationError)
AddWarning appends a warning. Warnings do not affect ValidationResult.Valid.
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 (*View) Explain ¶ added in v0.3.0
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) GetDuration ¶ added in v0.3.0
GetDuration returns a path as a duration.
func (*View) GetFloat64 ¶ added in v0.3.0
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) GetIntSlice ¶ added in v0.3.0
GetIntSlice reads a value as a list of ints.
func (*View) GetSizeInBytes ¶ added in v0.3.0
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) GetStringMap ¶ added in v0.3.0
GetStringMap reads a value as a map of string to anything.
func (*View) GetStringMapString ¶ added in v0.3.0
GetStringMapString reads a value as a map of string to string.
func (*View) GetStringMapStringSlice ¶ added in v0.3.0
GetStringMapStringSlice reads a value as a map of string to list of string.
func (*View) GetStringSlice ¶ added in v0.3.0
GetStringSlice returns a path as a string slice.
func (*View) Has ¶ added in v0.3.0
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
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) Origin ¶ added in v0.3.0
Origin reports which layer supplied the effective value at a path.
func (*View) SectionExists ¶ added in v0.3.0
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
Shadowed lists every layer defining a path, lowest precedence first.
func (*View) Shadows ¶ added in v0.16.0
Shadows reports every shadowed path visible through this view.
A scoped view reports only its own subtree, with paths relative to it — the same terms View.Keys uses, so a path from the report can be handed straight back to View.Origin or View.Get. An absolute path would resolve to nothing through a scoped view, and the mistake would be silent.
See the whole-configuration shadow report spec, D6.
func (*View) Sub ¶ added in v0.3.0
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) UnmarshalKey ¶ added in v0.3.0
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 WatchErrorReporter ¶ added in v0.9.3
type WatchErrorReporter interface {
SetWatchErrorHandler(func(error))
}
WatchErrorReporter is an optional interface a WatchableBackend may satisfy to be told where to send the errors raised when watching one of its sources degrades and the fallback cannot be established either.
The Store asks by interface rather than concrete type, so a backend defined outside this package can route its watch-degradation errors to Store.OnReloadError the same way the built-in file backend does. Satisfy it only when the backend genuinely has a watch that can degrade — a backend that cannot lose its watch has nothing to report and should not implement it.
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 WatchPathReporter ¶ added in v0.9.3
WatchPathReporter is an optional interface a Backend reading a filesystem path may satisfy so an injected Watcher can be handed the set of paths up front.
The Store asks by interface rather than concrete type, so a file-like backend defined outside this package is watched by an injected watcher the same way the built-in file backend is. A backend with no filesystem path — an in-memory or remote source — does not implement it and contributes no path.
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
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
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
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
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
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.
func (YAMLCodec) PreservesComments ¶ added in v0.9.3
PreservesComments reports that YAML edits retain comments and formatting, which is what backs the file backend's PreservesComments capability. It satisfies CommentPreservingCodec.
Source Files
¶
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. |