mamori

package module
v1.4.0 Latest Latest
Warning

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

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

README

守り  mamori

Typed, watchable config & secrets for Go

Load configuration and secrets from anywhere into validated Go structs - and keep them reconciled at runtime, without a restart.

Go Reference CI Go Report Card


mamori (守り - Japanese for protection / safeguard) is an embedded Go library that loads application configuration and secrets from heterogeneous sources - environment, files, AWS Secrets Manager, Vault, GCP, Azure, Kubernetes, Consul, and more - into typed, validated structs, and keeps them reconciled at runtime. When a source value changes, mamori detects it, re-validates the whole configuration, and - only if the new snapshot is valid - atomically swaps it in and hands your application a diff-aware callback so it can react (rotate a DB pool, rebuild a client) without restarting.

Think: External Secrets Operator's provider model, one layer down - as a library inside your process instead of an operator inside your cluster.

Why

The primitives exist, but nobody composed them. runtimevar watches one variable but has no struct composition or validation. Viper/koanf do multi-source config but treat secrets and rotation as afterthoughts. The AWS caching client and Vault's LifetimeWatcher refresh one provider each, in silos. So every production Go service hand-rolls a ConfigManager with a ticker, a mutex, and a prayer. mamori is that glue, done once, with a provider ecosystem and a conformance kit.

Install

go get github.com/xavidop/mamori

env: and file:// work out of the box. Cloud providers are separate modules so the core has zero cloud-SDK dependencies:

go get github.com/xavidop/mamori/providers/aws     # aws-sm://  aws-ps://  aws-appconfig://
go get github.com/xavidop/mamori/providers/vault   # vault://
go get github.com/xavidop/mamori/providers/k8s     # k8s-secret://  k8s-cm://
# ... gcp, azure, consul, doppler, onepassword, sops

Quick start

type Config struct {
    // A secret string from AWS Secrets Manager (redacted in logs by default).
    // ${ENV} is ref interpolation: expanded from WithRefVars below, never
    // from the ambient environment.
    DBPassword secret.String `source:"aws-sm://${ENV}/db#password"`

    // Plain config from the environment, with a default and validation
    LogLevel   string        `source:"env:LOG_LEVEL" default:"info" validate:"oneof=debug info warn error"`
    Workers    int           `source:"env:WORKERS"   default:"4"    validate:"gte=1,lte=256"`

    // A precedence chain: an environment override wins if set, otherwise a
    // centrally managed Parameter Store value, otherwise the default.
    Port       string        `source:"env:PORT,aws-ps://svc/port" default:"8080"`

    // A nested field, selected with an RFC 6901 JSON Pointer fragment
    DBUser     string        `source:"aws-sm://prod/db#/credentials/user"`

    // A file-backed value, hot-reloaded via fsnotify
    TLSCert    []byte        `source:"file:///etc/tls/tls.crt"`

    // ?decode=base64 declares the stored value is base64; core decodes it
    // back to raw bytes before TLSKey is populated
    TLSKey     []byte        `source:"aws-sm://prod/tls#key?decode=base64"`

    // A nested struct decoded from one JSON secret
    Redis      RedisConfig   `source:"aws-sm://prod/redis" flatten:"json"`
}

// One-shot load
cfg, err := mamori.Load[Config](ctx)

// Or: watch and reconcile at runtime
w, err := mamori.Watch[Config](ctx,
    // Expands DBPassword's ${ENV} above - see "Ref interpolation" below.
    mamori.WithRefVars(map[string]string{"ENV": "prod"}),
    // Proves a rotated password actually opens a connection *before* it
    // becomes what Get() serves - see "Rotation-safe" below.
    mamori.PreApply(func(ctx context.Context, ev mamori.Change[Config]) error {
        if !ev.Changed("DBPassword") {
            return nil
        }
        return pool.Ping(ctx, ev.New.DBPassword.Reveal())
    }),
    mamori.OnChange(func(ev mamori.Change[Config]) {
        if ev.Changed("DBPassword") {
            pool.Rotate(ev.New.DBPassword.Reveal())
        }
    }),
    mamori.OnError(func(err error) { metrics.Inc("config_error") }),
)
defer w.Close()

cfg := w.Get() // lock-free snapshot; always the last *valid* config

What makes it different

  • Typed & tag-driven - one struct, multiple sources, generics API (Load[T] / Watch[T]).
  • Nested selection - #/credentials/password is an RFC 6901 JSON Pointer, addressing a value at any depth through objects and array elements; any other fragment (#ca.crt, #tls.key) stays a literal top-level key, exactly as before.
  • Value decoding - ?decode=base64,gzip runs a stdlib-only pipeline (base64, base64url, hex, gzip, trim) over a resolved value before it reaches your struct field, left to right, outermost wrapper first; a bad payload is a loud ErrInvalid, never a silent passthrough.
  • Ref interpolation - ${VAR} in a source tag expands from mamori.WithRefVars before the tag is parsed, so a variable can supply a scheme, path, fragment, or query value. Variables come only from WithRefVars, never the ambient environment - the same opt-in posture as exec: - and an undefined or malformed ${VAR} is a hard error rather than a silently empty path segment.
  • Precedence chains - source:"env:PORT,aws-ps://svc/port" tries sources in priority order: the first to yield a value wins, not-found falls through to the next, and a real error stops the walk and applies the field's onfail policy instead of silently sliding to a lower-priority source. Every position is watched, so precedence is live.
  • Reconciled at runtime - native watch where the backend supports it (Kubernetes informers, Consul blocking queries, fsnotify), polling with jitter everywhere else, and lease-aware pre-expiry refresh for Vault.
  • Atomic & validated - an update that fails validation is rejected; Get() keeps returning the last good config. Config never enters a broken state mid-flight.
  • Rotation-safe - PreApply gates a candidate snapshot right before the atomic swap, so an application can prove a rotated credential actually works (a password opens a connection, a token is accepted by its issuer) rather than discovering it is broken in the request path. A rejection keeps serving the last good config and delivers a *PreApplyError to OnError; the same gate runs on the very first load, so a bad configured credential fails at startup instead of the first rotation.
  • Forceable - w.Refresh(ctx) re-resolves every field right now, bypassing poll intervals, and blocks until the result is applied or rejected (through the same PreApply gate, never bypassing it), so a SIGHUP handler or your own admin route learns whether the reload actually worked.
  • Coalesced - bursts of field changes within a debounce window produce a single Change event.
  • Pinnable - WithHistory(n) retains recent snapshots (w.History()); w.PinCurrent() / w.Pin(version) freeze Get() at one of them while you debug production, then w.Unpin() resumes and fires one coalesced Change for everything that changed in the meantime.
  • Secret hygiene by default - secret.String / secret.Bytes redact themselves in String(), fmt, MarshalJSON, and slog. Only the explicit, greppable Reveal() exposes the value. A shipped analyzer (run it as mamori vet ./..., or as a go vet tool with go vet -vettool=$(which mamori)) flags sensitive refs assigned to plain string fields.
  • Pluggable - providers register with the database/sql pattern; a providertest conformance kit guarantees they all behave identically.
  • Observable - w.Status() reports live per-field health, w.Health() backs a Kubernetes readiness probe, and mamori.Doctor[T] checks every ref is reachable before you ever deploy. WithLogger(l *slog.Logger) emits structured records for resolve failures, watch errors, applied changes, and more - silent by default, so linking mamori in never writes to your application's stderr on its own.
  • Testable - the mamoritest package gives application code a scriptable in-memory provider (Set/Del/Fail) plus deterministic wait helpers (WaitForSnapshot, WaitForError), so an OnChange handler or error path can be tested without a real backend.

Providers

Module Schemes Watch Errors classified beyond not-found
core (built-in) env: · dotenv:// · file:// · exec: (opt-in) fsnotify (file/dotenv) · poll (env/exec)
providers/aws aws-sm:// · aws-ps:// · aws-appconfig:// poll
providers/gcp gcp-sm:// poll
providers/azure azure-kv:// · azure-appconfig:// poll
providers/vault vault:// lease-aware poll (NotAfter)
providers/k8s k8s-secret:// · k8s-cm:// native (watch API)
providers/consul consul:// native (blocking queries)
providers/doppler doppler:// poll
providers/onepassword op:// poll
providers/sops sops:// fsnotify
providers/postgres postgres:// native (LISTEN/NOTIFY)
providers/mysql mysql:// poll
providers/sqlite sqlite:// fsnotify
providers/mongodb mongodb:// native (change streams)
providers/dynamodb dynamodb:// poll
providers/redis redis:// native (keyspace notifications)
providers/etcd etcd:// native (watch API)
providers/firestore firestore:// native (snapshot listeners)
providers/firebase-rc firebase-rc:// poll
providers/firebase-rtdb firebase-rtdb:// native (streaming) no (chain preserved)
providers/s3 s3:// poll (ETag)
providers/gcs gcs:// poll (generation)
providers/azblob azblob:// poll (ETag)
providers/cosmos cosmos:// poll (ETag)
providers/launchdarkly launchdarkly:// native (streaming)
providers/unleash unleash:// poll n/a (no error surface)
providers/flagsmith flagsmith:// poll no (chain preserved)
providers/configcat configcat:// poll n/a (no error surface)
providers/split split:// poll n/a (no error surface)
providers/growthbook growthbook:// poll no (chain preserved)
providers/flipt flipt:// poll
providers/goff goff:// (GO Feature Flag) poll
providers/openfeature openfeature:// (OpenFeature standard) poll
providers/viper viper:// (Viper config library) poll n/a (no error surface)
providers/mamori mamori:// (config server client) native (SSE stream)

Every provider that passes the providertest conformance kit earns a badge. See each module's README for auth and ref grammar.

The error-classification sweep is complete: every one of the 38 providers now falls into exactly one of three honest states, and not_found itself is detected by every provider regardless of which one.

  • ✅ classifies - the provider maps real backend errors onto mamori.ErrorKind values beyond not_found (permission_denied, unauthenticated, unavailable, rate_limited, invalid, as the backend's own vocabulary supports): thirty-one providers across twenty-eight module rows, since core's single row covers four built-in providers (env:, dotenv://, file://, exec:).
  • no (chain preserved) - providers/firebase-rtdb, providers/growthbook, and providers/flagsmith have no backend-specific error vocabulary to map, so a non-not-found failure still reports unknown. Their Resolve wraps the underlying error with %w rather than flattening it, so errors.Is/errors.As and any mamori sentinel injected by a caller's own middleware still reach it - the chain is preserved even though nothing here narrows it to a more specific kind. Do not read this as classifying permission or availability errors these providers cannot see.
  • n/a (no error surface) - providers/unleash, providers/configcat, providers/split, and providers/viper wrap a client surface with no per-key error at all: the flag SDKs return only bool/string values, and Viper's own read API has no error return (Get returns any, IsSet returns bool). Their Resolve can only ever produce mamori.ErrNotFound or a client-construction error, so there is nothing to classify or preserve a chain for. Each is explicitly exempted from the providertest conformance kit's ErrorClassification case via providertest.Config.NoResolveErrors, a deliberate, greppable declaration rather than a silent gap.

Middleware

Providers compose because they share one interface:

mamori.WithProvider(
    middleware.Cache(5*time.Minute,
        middleware.Audit(logger,
            middleware.Failover(primary, replica))))

Cache, Audit, Failover, RateLimit, and Prefix (multi-tenant namespace rewriting) ship in middleware/.

Observability

w.Status() returns a lock-free, point-in-time Report of every field's health (ref, staleness, last error kind), safe to log or serialize since values never appear and refs have sensitive query options redacted. w.Health() reduces that to a single readiness check: nil when every field is fresh and none carries a terminal error kind (not_found, permission_denied, unauthenticated, invalid), a *HealthError otherwise - a transient kind like unavailable or rate_limited only fails health once the field is also stale.

For a pre-deploy check, mamori.Doctor[Config](ctx, opts...) resolves every field once without starting a watcher and reports every failure at once, not just the first - run it as a build-tagged CI test to catch a rotated-away secret or a typo'd ref before it ships. An optional HTTP endpoint - mamori.Handler on your own mux, or a self-hosted server via mamori.WithAdminHTTP - serves that same Report as JSON, metadata only and never a configuration value, with a pluggable Authenticator (WithAuth; basic auth, bearer token, API key, mTLS, or your own) gating access and support for live credential rotation. mamori.WithLogger(l *slog.Logger) gives the same never-a-value, refs-redacted treatment to a structured log trail of resolve failures, watch errors, and applied changes - silent (a discard logger) until you opt in. See Observability and Auth for the full picture, including the readiness-probe pattern, the Doctor CI test, and credential rotation.

mamori.WithMeter(m Meter) installs a metrics sink - the x/otel module adapts one onto OpenTelemetry, the x/prom module implements one directly against prometheus/client_golang for shops that have not adopted OpenTelemetry, or implement the six-method Meter interface yourself. Beyond resolve latency and refresh/watch-error counts, Meter reports three failure conditions so they can be alerted on rather than merely logged: RecordStale(scheme) when a value has not refreshed within the WithStale threshold, RecordApplyRejected(reason) when a candidate configuration is refused (reason is a RejectReason, a closed type with exactly two values, RejectValidation and RejectPreApply, so it stays safe as a metric label), and RecordChangeDropped() - the signal that an OnChange handler is too slow: the bounded dispatch queue filled up and the oldest change event was silently discarded. Implementing Meter now requires all six methods; the three added here are a source-breaking change for any existing implementation.

Config server

server/ is a separate module: a standalone process that fronts a fixed, operator-declared table of name-to-ref bindings (server.Bind/server.BindFile - never a client-supplied ref) and serves resolved values to authenticated, authorized callers over Unix sockets and TLS TCP, under a mandatory Policy and Authenticator. It reuses the same Authenticator/Identity as the admin endpoint above, plus a Unix-socket-only PeerCred scheme authenticated by kernel-verified uid/gid. It is deliberately the highest-blast-radius component in this project - it concentrates every backend credential its bindings touch into one process - so read the docs before deploying one, not just the quick start.

CLI

cmd/mamori is a standalone CLI with two halves that never mix: explain/schema/policy statically read your Go source and never resolve anything (struct field tables, JSON Schema, least-privilege IAM/GCP/ExternalSecret artifacts), while doctor/status are thin clients of a running process's admin endpoint (WithAdminHTTP above), exiting 0-4 so a script can tell a broken config apart from one it merely couldn't reach.

brew install xavidop/tap/mamori
# or
go install github.com/xavidop/mamori/cmd/mamori@latest

See mamorigo.dev/docs/cli for the full command reference.

Agent skill

Teach your AI coding agent (Claude Code, Cursor, Copilot, and others) how to use mamori with the shipped Agent Skill:

npx skills add xavidop/mamori

See mamorigo.dev/docs/skill for what it covers and manual install.

Documentation

Project layout

This is a multi-module monorepo. The core (github.com/xavidop/mamori) depends only on go-playground/validator, go-viper/mapstructure, and fsnotify. Each provider is its own module with its own release cadence, so a cloud SDK never leaks into your build unless you use that provider.

Contributing

Contributions welcome - new providers especially. See CONTRIBUTING.md and the Write a provider guide. A provider that passes providertest and the conformance kit gets listed here.

License

MIT © Xavier Portilla Edo

Documentation

Overview

Package mamori loads application configuration and secrets from heterogeneous sources (environment, files, cloud secret managers, Vault, Kubernetes, ...) into typed, validated Go structs, and keeps them reconciled at runtime.

When a source value changes, mamori detects it, re-validates the whole configuration, optionally runs it past a PreApply gate, and - only if the new snapshot is valid and the gate accepts it - atomically swaps it in and notifies the application with a diff-aware callback so it can react (rotate a database pool, rebuild a client, ...) without restarting.

Loading

Define a struct whose fields carry a `source` tag describing where each value comes from, then call Load:

type Config struct {
    DBPassword secret.String `source:"aws-sm://prod/db#password"`
    LogLevel   string        `source:"env:LOG_LEVEL" default:"info"`
    Workers    int           `source:"env:WORKERS" default:"4" validate:"gte=1,lte=256"`
}

cfg, err := mamori.Load[Config](ctx)

A source tag's optional #key fragment selects one field from a structured payload: a fragment beginning with '/' is an RFC 6901 JSON Pointer addressing a value at any depth ("#/credentials/password"), and any other fragment is a literal top-level key ("#ca.crt"), exactly as before. See ParseRef and SelectKey.

An optional ?decode= query option declares that the resolved value is encoded, so core decodes it (base64, base64url, hex, gzip, or trim, stacked left to right) before it reaches the field: "aws-sm://prod/tls#key?decode=base64". A bad payload is a loud ErrInvalid, never a silent passthrough, and a field's default: is used as-is, undecoded. See WithDecodeHook for arbitrary per-type conversion beyond this closed set.

A tag may also reference a variable with ${VAR}, expanded once, before parsing, from the map supplied via WithRefVars ("aws-sm://${ENV}/db#password"). Expansion never reads the ambient environment - only WithRefVars, or its explicit opt-in helper EnvVars, supply values - because a ref decides which secret gets read, and that must not be steerable by anything able to set an environment variable. An undefined variable, an unterminated "${", or an empty "${}" are errors rather than a silently empty ref.

Watching

Watch performs an initial fail-fast load and then keeps the configuration reconciled, delivering validated, diff-aware updates:

w, err := mamori.Watch[Config](ctx,
    mamori.OnChange(func(ev mamori.Change[Config]) {
        if ev.Changed("DBPassword") {
            pool.Rotate(ev.New.DBPassword.Reveal())
        }
    }),
)
defer w.Close()
cfg := w.Get() // lock-free snapshot; always the last valid config

PreApply installs a gate that runs after validation and before that swap, for a check struct validation cannot express because it needs I/O: that a rotated database password actually opens a connection, for example. Returning an error rejects the candidate, keeping the last good config current; the same gate runs on the initial load too, so a bad configured credential fails at startup rather than at the first rotation.

Watcher.Refresh forces an immediate re-resolve of every field, bypassing poll intervals, and blocks until the resulting snapshot has been applied or rejected - through the same PreApply gate, never around it - so a SIGHUP handler knows whether the reload it triggered actually worked.

Providers

Sources are pluggable via the Provider SPI and registered with Register using the database/sql pattern. The core module has zero cloud-SDK dependencies; each cloud provider ships as its own module under providers/.

Example

Load reads every field of a struct from the source named in its `source` tag, applies `default` for anything the source does not provide, and validates the whole result before returning it. A field typed secret.String is redacted everywhere except an explicit Reveal.

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/xavidop/mamori"
	"github.com/xavidop/mamori/secret"
)

func main() {
	type Config struct {
		LogLevel string        `source:"env:EXAMPLE_LOG_LEVEL" default:"info" validate:"oneof=debug info warn error"`
		Workers  int           `source:"env:EXAMPLE_WORKERS"   default:"4"    validate:"gte=1,lte=256"`
		APIKey   secret.String `source:"env:EXAMPLE_API_KEY"`
	}

	_ = os.Setenv("EXAMPLE_LOG_LEVEL", "debug")
	_ = os.Setenv("EXAMPLE_API_KEY", "sk-live-abc123")
	defer func() { _ = os.Unsetenv("EXAMPLE_LOG_LEVEL") }()
	defer func() { _ = os.Unsetenv("EXAMPLE_API_KEY") }()

	cfg, err := mamori.Load[Config](context.Background())
	if err != nil {
		fmt.Println("load failed:", err)
		return
	}

	fmt.Println("log level:", cfg.LogLevel)
	fmt.Println("workers:  ", cfg.Workers) // EXAMPLE_WORKERS is unset, so the default applies
	fmt.Println("api key:  ", cfg.APIKey)  // redacted by default
	fmt.Println("revealed: ", cfg.APIKey.Reveal())

}
Output:
log level: debug
workers:   4
api key:   [REDACTED]
revealed:  sk-live-abc123

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrPermissionDenied = errors.New("mamori: permission denied")
	ErrUnauthenticated  = errors.New("mamori: unauthenticated")
	ErrUnavailable      = errors.New("mamori: unavailable")
	ErrRateLimited      = errors.New("mamori: rate limited")
	// ErrInvalid covers both a malformed ref and a payload that could not be
	// parsed.
	ErrInvalid = errors.New("mamori: invalid")
)

Classification sentinels. Providers wrap the underlying SDK error with the matching sentinel using two %w verbs, which preserves both errors.Is for the sentinel and errors.As access to the original SDK error type:

return mamori.Value{}, fmt.Errorf("%w: %w", mamori.ErrPermissionDenied, err)

A single %w with the SDK error formatted as %v only wraps the sentinel; the SDK error becomes a flattened string and errors.As can no longer reach it.

Only ErrNotFound changes mamori's behavior (it is what triggers `default:` and `optional` handling). The rest are diagnostic.

View Source
var ErrForbidden = errors.New("mamori: forbidden")

ErrForbidden, returned from Authenticate, produces a 403 rather than a 401. Use it when the caller is authenticated but not permitted. Any other error produces a 401.

View Source
var ErrNoSuchSnapshot = errors.New("mamori: no such snapshot version")

ErrNoSuchSnapshot is returned by Watcher.Pin when the requested version is not retained. Increase WithHistory to pin older versions.

View Source
var ErrNotFound = errors.New("mamori: not found")

ErrNotFound is the sentinel error providers wrap (or return) when a referenced value does not exist. Consumers and mamori itself test for it with errors.Is(err, ErrNotFound); mamori applies defaults / optional handling only for not-found, never for other errors.

View Source
var ErrReentrantCall = errors.New("mamori: Pin, PinCurrent, Unpin and Refresh cannot be called from the goroutine running a PreApply hook or an OnError callback, which occupies the reconciler goroutine that services them; Get is safe there, but these must be called from another goroutine")

ErrReentrantCall reports a control-channel command issued from the goroutine that is currently running one of the watcher's own callbacks.

Two callbacks run ON the reconciler goroutine, inline: a PreApply hook, and an OnError callback (OnChange does not - it is delivered from the dispatch queue, on its own goroutine, and is unaffected by any of this). Pin, PinCurrent, Unpin and Refresh are commands SERVICED by the reconciler goroutine. Calling one from inside either callback asks that goroutine to answer a message it cannot reach until the callback it is running returns: before this was detected, it blocked until Close, with no reconciliation, no OnChange and no further OnError in the meantime. This sentinel converts that permanent, silent wedge into an immediate, diagnosable refusal that leaves pin state, and the configuration, untouched.

Three of the four commands take no context at all, so they had no way out to begin with. Refresh does take one, and is refused just the same: a context makes the wedge escapable, not absent, and the obvious call to write inside a callback is Refresh(context.Background()), which has no deadline to escape on either.

The refusal is keyed on WHICH goroutine is inside the callback, not merely that one is, so a command issued from any other goroutine - including one that happens to overlap a running callback - queues on the control channel and is serviced normally, exactly as it always was.

Unlike errWatcherClosed it is exported, because the two errors sit at opposite ends of what a caller can do about them: a closed watcher is an expected lifecycle race with Close and needs no test, while this one is a programming mistake in the caller's own callback, and a test that wants to prove the mistake is caught (or a wrapper that wants to translate it) needs errors.Is to reach it.

Pin and Refresh return it directly. PinCurrent returns version 0 and Unpin does nothing; see their doc comments for why, and for what each leaves observable instead.

Two kinds of addition belong here rather than beside it. A future command serviced by the reconciler goroutine: route it through sendPinCtx's guard (pin.go). A future callback this package runs inline on that goroutine: arm armReentrancy (preapply.go) around it, as emitErr does. Either one left out is a wedge that this sentinel's own wording promises does not exist.

Functions

func ContextWithPeerCred added in v1.2.0

func ContextWithPeerCred(ctx context.Context, cred Ucred) context.Context

ContextWithPeerCred returns a copy of ctx carrying cred as the peer credentials of the Unix-socket connection ctx (or a context derived from it) belongs to. This is the injection half of the ConnContext seam: an HTTP server whose Listener accepts Unix connections calls this from http.Server.ConnContext, once per accepted connection - see PeerCred's doc comment for the full plumbing this is designed to support. The listener wrapper that calls it lives in the server module (a later task); core provides only this injection point and the matching reader (peerCredFromContext) so both sides agree on the seam without core depending on the server's listener machinery.

func EnvVars added in v1.4.0

func EnvVars(names ...string) map[string]string

EnvVars reads the named environment variables into a map suitable for WithRefVars:

mamori.WithRefVars(mamori.EnvVars("ENVIRONMENT", "REGION"))

Naming each variable is the point: it keeps the set of things that can influence which secret a process reads enumerable and greppable at the call site, rather than "any environment variable at all".

A name that is not set in the environment is omitted from the result rather than mapped to the empty string, so expansion reports the undefined-variable error instead of silently producing a ref with a hole in it.

func Handler added in v1.2.0

func Handler[T any](w *Watcher[T], opts ...HandlerOption) http.Handler

Handler returns an http.Handler exposing a Watcher's health over HTTP. It serves exactly two routes: GET / (the Report, as JSON) and GET /healthz (a bare liveness/readiness signal). Every other path, and every other method, is 404. There is no route that serves a configuration value, under any option: the Report returned by w.Status() already redacts refs and omits values, so exposing it as-is is safe by construction, and Handler has no way to add a field beyond what Report already carries.

Mount the result on your own mux, optionally under HandlerPrefix.

func Load

func Load[T any](ctx context.Context, opts ...Option) (T, error)

Load resolves all refs of T once, applies defaults, validates, and returns the typed config. It fails fast: on any resolve or validation error it returns a non-nil error and the zero value of T; partial config is never returned.

Example (PrecedenceChain)

A comma-separated `source` tag is a precedence chain: sources are tried in order and the first to yield a value wins, so an operator override can sit in front of a centrally managed value without the caller writing that fallback by hand. Whitespace around the separator is ignored.

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/xavidop/mamori"
)

func main() {
	type Config struct {
		// Prefer an explicit override, fall back to the deploy-wide value,
		// fall back to the default.
		Port string `source:"env:EXAMPLE_PORT_OVERRIDE, env:EXAMPLE_PORT" default:"8080"`
	}

	// Nothing set: the chain falls through to the default.
	cfg, _ := mamori.Load[Config](context.Background())
	fmt.Println("neither set:  ", cfg.Port)

	// The lower-priority source answers.
	_ = os.Setenv("EXAMPLE_PORT", "9090")
	defer func() { _ = os.Unsetenv("EXAMPLE_PORT") }()
	cfg, _ = mamori.Load[Config](context.Background())
	fmt.Println("deploy value: ", cfg.Port)

	// The override wins wherever it is set.
	_ = os.Setenv("EXAMPLE_PORT_OVERRIDE", "3000")
	defer func() { _ = os.Unsetenv("EXAMPLE_PORT_OVERRIDE") }()
	cfg, _ = mamori.Load[Config](context.Background())
	fmt.Println("override wins:", cfg.Port)

}
Output:
neither set:   8080
deploy value:  9090
override wins: 3000
Example (ValidationFailure)

A snapshot that fails validation is rejected whole: Load returns a *ValidationError rather than handing back a half-valid config.

package main

import (
	"context"
	"errors"
	"fmt"
	"os"

	"github.com/xavidop/mamori"
)

func main() {
	type Config struct {
		Workers int `source:"env:EXAMPLE_BAD_WORKERS" default:"4" validate:"gte=1,lte=256"`
	}

	_ = os.Setenv("EXAMPLE_BAD_WORKERS", "9000")
	defer func() { _ = os.Unsetenv("EXAMPLE_BAD_WORKERS") }()

	_, err := mamori.Load[Config](context.Background())

	var verr *mamori.ValidationError
	fmt.Println("rejected as invalid:", errors.As(err, &verr))

}
Output:
rejected as invalid: true

func Register

func Register(p Provider)

Register makes a provider available under its Scheme(), following the database/sql driver pattern. It is normally called from a provider package's init function. Register panics if a provider for the same scheme is already registered (fail fast at process init) or if p is nil.

func RegisteredSchemes

func RegisteredSchemes() []string

RegisteredSchemes returns the sorted list of registered provider schemes. It is useful for diagnostics and for the docs/provider gallery.

func SelectKey

func SelectKey(data []byte, key string) ([]byte, error)

SelectKey extracts a single value from a structured payload, for refs of the form scheme://path#key.

The fragment is interpreted one of two ways, chosen by its first character:

  • A fragment beginning with '/' is an RFC 6901 JSON Pointer, addressing a value at any depth, through objects and array elements alike: "#/credentials/password", "#/replicas/5/host". Escapes are RFC 6901's: "~1" for a literal '/', "~0" for a literal '~'.
  • Any other fragment is a literal top-level key, exactly as it has always been. This is what keeps "#ca.crt" addressing the key named "ca.crt" rather than a path, which matters because "tls.crt"/"tls.key"/"ca.crt" are the canonical Kubernetes TLS secret keys and dotted keys are the norm in ConfigMaps and Java properties files.

If key is empty, data is returned unchanged. String values are returned unquoted; objects, arrays, numbers, and booleans are returned as their JSON encoding, byte-for-byte as they appeared in the payload. A selected JSON null returns zero bytes, not "null", so it is indistinguishable from an empty string (see unquoteJSON for why that is deliberate); a null nested inside a selected object or array is unaffected.

An absent key or an out-of-range index wraps ErrNotFound, so the field's default: or optional handling applies. A structural mismatch (a pointer descending into a scalar, a non-numeric token against an array, a malformed escape, or a payload that is not JSON) wraps ErrInvalid instead, because it is a malformed request against this payload rather than an absence and must not be silently masked by a default.

Provider authors should call SelectKey with ref.Key after fetching the raw payload, so that fragment selection behaves identically across all providers.

func SentinelFor added in v1.2.0

func SentinelFor(k Kind) error

SentinelFor returns the sentinel error corresponding to k, or nil for KindUnknown and the empty Kind, neither of which has one.

It is the inverse of ErrorKind, and exists so a classification that arrived as a value rather than an error (over the wire, from a config file) can be turned back into an error that errors.Is matches.

A nil return never means the operation succeeded. It means no sentinel corresponds to k: either k is KindUnknown (a real failure whose cause could not be classified) or k is some string that does not name a recognized Kind at all, such as one from an untrusted or forward-versioned wire value. Callers reconstructing an error from such a value must treat a nil return as an unclassified failure, never as absence of an error. Do not write `if err := SentinelFor(k); err != nil { ... }` as a substitute for checking whether the original operation failed.

func VersionHash

func VersionHash(b []byte) string

VersionHash produces a stable version string from bytes. Provider authors can use it to synthesize a Value.Version when the backend has no native revision identifier, giving mamori cheap change detection without a byte comparison.

func WatchRef added in v1.2.0

func WatchRef(ctx context.Context, p Provider, ref Ref, opts ...Option) <-chan Update

WatchRef watches a single ref for changes, choosing a provider's native watch when it implements WatchableProvider and falling back to the shared polling adapter (pollWatch) otherwise. This is exactly the per-position source-selection engine.start performs for every ref in a Watch[T]'s field specs (see reconciler.go); it is exported here so a caller that only wants to watch one ref directly - the config server, most notably - gets the identical selection behavior and options handling as the reconciler, rather than a second, independently-maintained copy of the same decision.

opts is turned into an *options the same way Load and Watch build theirs: defaultOptions() overlaid with opts in the order given. Of everything an *options carries, only clock, pollInterval, jitter, and the WithBackoff window matter here - the fields pollWatch itself reads - but the full Option surface (WithClock, WithPollInterval, WithJitter, ...) is accepted so a caller does not need a second, narrower configuration vocabulary just for this entry point. Since backoff lives in the polling adapter, it reaches a ref watched through here on exactly the terms WithBackoff documents: polled refs and native-watch fallbacks, never a native watch that started cleanly.

The returned channel is closed when ctx is cancelled, matching both WatchableProvider's and pollWatch's own contract.

Types

type AuthFunc added in v1.2.0

type AuthFunc func(r *http.Request) (Identity, error)

AuthFunc adapts a plain function to Authenticator.

func (AuthFunc) Authenticate added in v1.2.0

func (f AuthFunc) Authenticate(r *http.Request) (Identity, error)

Authenticate calls f.

type Authenticator added in v1.2.0

type Authenticator interface {
	Authenticate(r *http.Request) (Identity, error)
}

Authenticator decides whether an HTTP request may proceed, and says who the caller is. A nil error allows the request; any error denies it.

The returned Identity is ignored by the admin endpoint (which only serves metadata) and consumed by the config server, whose authorization policy is expressed in terms of it. It is one interface rather than two so an Authenticator written for one surface works unchanged on the other.

func APIKey added in v1.2.0

func APIKey(header string, key secret.String) Authenticator

APIKey authenticates requests carrying the expected key in a named header (for example "X-API-Key") against a fixed key. The key is compared in constant time (see apiKey.Authenticate) so a failed request discloses nothing about the key through response timing.

func APIKeyFunc added in v1.2.0

func APIKeyFunc(header string, fn func() secret.String) Authenticator

APIKeyFunc reads the expected key on every request rather than freezing it at construction, so a mamori Watcher can rotate it live. A zero key (secret.String's IsZero) denies every request; see BasicAuthFunc for why unset must never be treated as unauthenticated-allowed.

func AllOf added in v1.2.0

func AllOf(as ...Authenticator) Authenticator

AllOf allows a request only if every member allows it, for composing independent checks that must both hold, such as a bearer token AND an mTLS-verified network identity. The first denial fails the whole evaluation and the rest are skipped: unlike AnyOf, there is no matching timing oracle to defend against here, since a partial failure is already a total denial regardless of what a later member would have decided.

The identity of the first member is returned: by convention the first member of an AllOf is the primary authenticator (the one that names a principal), and later members perform supplementary checks, such as a certificate or IP restriction, whose own Identity is typically empty.

func AnyOf added in v1.2.0

func AnyOf(as ...Authenticator) Authenticator

AnyOf allows a request if any member allows it, for composing schemes such as "a static admin token OR mTLS from the mesh sidecar". It evaluates every member on every request, even after one has already succeeded or failed, so the total work AnyOf performs never depends on which member matched (or on how early in the list a mismatch was found). Without this, an attacker could measure response timing to learn which scheme very nearly accepted their request, narrowing an attack from "guess a token" to "guess a token this scheme almost validated".

On total failure it returns an error naming no presented credential (see each member's own Authenticate). If any member implements Challenger, the first such member in argument order determines AnyOf's own Challenge; if none do, the returned Authenticator implements no Challenger either, and a failed request gets a bare 401, matching what a single member with the same property would produce.

func BasicAuth added in v1.2.0

func BasicAuth(user string, pass secret.String) Authenticator

BasicAuth authenticates HTTP Basic credentials against a fixed user and password. Both the username and the password are compared in constant time (see basicAuth.Authenticate), so a failed request discloses neither the password through response timing nor whether user is even the right username. pass is a secret.String so it redacts in logs and error values.

func BasicAuthFunc added in v1.2.0

func BasicAuthFunc(fn func() (string, secret.String)) Authenticator

BasicAuthFunc reads the expected username and password on every request rather than freezing them at construction, so a mamori Watcher can rotate the admin credential live. If fn returns a zero password (secret.String's IsZero), every request is denied: this is what makes rotation safe during the window before the credential has been populated for the first time, since the alternative, treating "unset" as "no password required", would silently open the endpoint.

func BearerToken added in v1.2.0

func BearerToken(token secret.String) Authenticator

BearerToken authenticates requests carrying "Authorization: Bearer <token>" against a fixed token. The token is compared in constant time (see bearerToken.Authenticate) so a failed request discloses nothing about the token through response timing.

func BearerTokenFunc added in v1.2.0

func BearerTokenFunc(fn func() secret.String) Authenticator

BearerTokenFunc reads the expected token on every request rather than freezing it at construction, so a mamori Watcher can rotate it live. A zero token (secret.String's IsZero) denies every request; see BasicAuthFunc for why unset must never be treated as unauthenticated-allowed.

func MTLS added in v1.2.0

func MTLS(opts MTLSOptions) Authenticator

MTLS authenticates a client by its verified TLS client certificate. It requires the server to be configured with tls.RequireAndVerifyClientCert (see WithAdminTLS): the Go TLS stack has already validated the certificate chain by the time Authenticate runs, so this scheme only needs to check which verified identity is allowed, not whether the certificate is trustworthy. On a non-TLS connection, or a TLS connection presenting no client certificate, MTLS denies every request; there is no fallback and no separate secret, so for an endpoint exposing operational detail about a cluster's secret material this is the strongest option that needs no external dependency.

func PeerCred added in v1.2.0

func PeerCred(opts PeerCredOptions) Authenticator

PeerCred authenticates a Unix-domain-socket client by its kernel-verified peer uid/gid. Because the identity comes from the kernel at accept time rather than anything the client presents in the request, it cannot be spoofed by a client that can merely connect to the socket - the strongest authenticator in this package for a sidecar deployment where the config server listens only on a Unix socket shared with trusted local processes.

Support is platform-specific: SO_PEERCRED on Linux (authpeercred_linux.go), LOCAL_PEERCRED via GetsockoptXucred on Darwin (authpeercred_darwin.go), and an unconditional deny with a clear error on every other platform (authpeercred_other.go) - never a silent allow just because credentials could not be checked there.

The ConnContext seam

By the time Authenticate runs, only the *http.Request and its context are in scope; net/http gives no direct path from a request back to the net.Conn it arrived on. The seam that bridges the two is split across core and the server module so both sides agree on it without core depending on the server's listener machinery:

  • Core (this package) exports Ucred, ContextWithPeerCred, and, per supported platform, PeerCredFromConn: a way to read a connection's peer credentials and a way to stash them in a context.
  • The server module's Unix-socket listener wrapper (a later task) calls PeerCredFromConn once per accepted connection and passes the result to ContextWithPeerCred from http.Server.ConnContext, so every request arriving on that connection derives a context carrying the same peer identity the kernel reported at accept time.
  • PeerCred.Authenticate reads it back with the unexported peerCredFromContext.

A request whose context carries no such value - a non-unix listener, TLS, or simply a server that never wired up ConnContext - is denied outright, never treated as "no restriction configured".

type BatchProvider

type BatchProvider interface {
	Provider
	// ResolveBatch resolves all refs, returning a map keyed by each input Ref's
	// Raw string (equivalently Ref.String()). A ref that is not found should be
	// omitted from the map (mamori applies the default) rather than causing the
	// whole batch to fail. Ref itself is not comparable (it holds url.Values), so
	// the raw tag string is used as the key.
	ResolveBatch(ctx context.Context, refs []Ref) (map[string]Value, error)
}

BatchProvider is an optional interface for providers that can resolve many refs in one backend call (SM BatchGetSecretValue, one file read for many keys). mamori uses it automatically when available.

type Challenger added in v1.2.0

type Challenger interface {
	Challenge() string
}

Challenger is optionally implemented by an Authenticator to supply the value of the WWW-Authenticate header sent with a 401. A scheme that does not implement it produces a bare 401.

type Change

type Change[T any] struct {
	Old    T
	New    T
	Fields []FieldChange
}

Change is delivered to OnChange when a reconciled, re-validated update is applied. Old and New are immutable full snapshots; Fields lists what changed.

func (Change[T]) Changed

func (c Change[T]) Changed(path string) bool

Changed reports whether the field at the given dotted path is among the changed fields in this event.

type Clock

type Clock interface {
	// Now returns the current time.
	Now() time.Time
	// NewTimer returns a timer that fires once after d.
	NewTimer(d time.Duration) *Timer
	// NewTicker returns a ticker that fires every d.
	NewTicker(d time.Duration) *Ticker
	// After is shorthand for NewTimer(d).C.
	After(d time.Duration) <-chan time.Time
}

Clock abstracts time so the reconciler can be tested deterministically. All time-dependent engine code uses a Clock rather than the time package directly. The default is the system clock; pass a different one with WithClock.

func SystemClock

func SystemClock() Clock

SystemClock returns the default, real-time Clock.

type FakeClock

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

FakeClock is a manually-driven Clock for deterministic tests. Advance moves time forward, firing any timers/tickers whose deadline is reached.

Advance only ever fires the timers that are already registered when it runs, so a test that advances the clock on behalf of another goroutine must first wait for that goroutine to arm its timer. BlockUntil is how to wait; see its doc comment for the race it closes.

func NewFakeClock

func NewFakeClock(start time.Time) *FakeClock

NewFakeClock returns a FakeClock started at the given time (or a fixed epoch if start is the zero value).

func (*FakeClock) Advance

func (c *FakeClock) Advance(d time.Duration)

Advance moves the fake clock forward by d, firing any timers and tickers whose deadlines fall within the interval, in chronological order.

func (*FakeClock) After

func (c *FakeClock) After(d time.Duration) <-chan time.Time

After registers a timer and returns its channel.

func (*FakeClock) BlockUntil added in v1.3.2

func (c *FakeClock) BlockUntil(ctx context.Context, n int) error

BlockUntil waits until at least n timers are pending on the clock - that is, until n calls to NewTimer or After (across any goroutine) have registered a deadline that has not yet fired and has not been stopped - and returns nil. It returns ctx.Err() if the context is done first, so a test that is wrong about what it is waiting for fails on its own deadline with a clear message instead of hanging until the package test timeout.

It exists because Advance is not a synchronization point. Advance fires only the timers already registered at the moment it runs, and a test typically advances the clock on behalf of a goroutine that arms its own timers: the polling watch adapter, the reconciler's debounce. Nothing orders that goroutine's NewTimer call against the test's Advance call. Lose that race and the goroutine registers its timer just after Advance walked the waiter list, computing its deadline from the already-advanced now - so the deadline lands in the future the test believes it has already passed, the timer never fires, and the test waits for an event that can no longer happen. The failure is silent at the point it happens and only surfaces later, as a timeout in a receive that looks unrelated.

Blocking on the registration first is what makes the ordering real:

clk.BlockUntil(ctx, 1) // the watched goroutine has armed its timer
clk.Advance(2 * time.Second)

The alternative - sleeping long enough to usually win the race - trades a deterministic test for a slow flaky one, which is the whole thing FakeClock exists to avoid.

Two properties are worth knowing before choosing n. Only timers count, not tickers, and a NewTimer(d) with d <= 0 fires immediately without ever becoming pending, so it never counts. And n is compared against the instantaneous pending set, not a running total: firing a timer (Advance) or stopping it (Timer.Stop) drops the count again, so n describes what should be armed right now, not how many have been armed since the clock was created.

func (*FakeClock) NewTicker

func (c *FakeClock) NewTicker(d time.Duration) *Ticker

NewTicker registers a periodic ticker.

func (*FakeClock) NewTimer

func (c *FakeClock) NewTimer(d time.Duration) *Timer

NewTimer registers a one-shot timer.

func (*FakeClock) Now

func (c *FakeClock) Now() time.Time

Now returns the fake current time.

type FieldChange

type FieldChange struct {
	Path       string // dotted field path, e.g. "Redis.Password"
	OldVersion string
	NewVersion string
}

FieldChange records one field that changed between two snapshots.

type FieldStatus added in v1.2.0

type FieldStatus struct {
	Path      string        // dotted field path, e.g. "Redis.Password"
	Scheme    string        // provider scheme of the ref
	Ref       string        // the ref, with sensitive query options redacted
	Version   string        // provider version of the currently observed value
	LastOK    time.Time     // last successful resolve, zero if never
	Age       time.Duration // GeneratedAt minus LastOK, recomputed at read time
	Stale     bool          // Age exceeds the configured WithStale threshold
	LastError string        // text of the last resolve error, empty if none
	LastKind  Kind          // classification of LastError, empty if none
	Sensitive bool          // field is a secret.String or secret.Bytes
}

FieldStatus is the live state of one configured field, as reported by Watcher.Status and Doctor. It is safe to serialize and safe to serve over HTTP: Ref has sensitive query options redacted, and no field value appears.

type HandlerOption added in v1.2.0

type HandlerOption func(*handlerOptions)

HandlerOption configures Handler.

func HandlerMiddleware added in v1.2.0

func HandlerMiddleware(mw func(http.Handler) http.Handler) HandlerOption

HandlerMiddleware wraps the handler with a non-authentication concern such as request logging or rate limiting. It runs outside the Authenticator (and outside HandlerPrefix's stripping), so it sees the request before either applies, in the order the options were given.

func HandlerPrefix added in v1.2.0

func HandlerPrefix(prefix string) HandlerOption

HandlerPrefix strips prefix from request paths before routing, so the handler can be mounted under a subpath of an existing mux (for example "/admin") instead of always owning the root of whatever mux it is attached to.

func WithAuth added in v1.2.0

func WithAuth(a Authenticator) HandlerOption

WithAuth requires every request to pass a before it is served, with one exemption: /healthz still answers without credentials, just without the failing-field detail (see serveHealthz). Applying WithAuth more than once is a construction error rather than a silent overwrite, since "the second call wins" and "the first call wins" are both plausible reads of the call site and neither is obviously right; compose multiple schemes explicitly with AnyOf or AllOf instead so the intended semantics are visible at the call site.

type HealthError added in v1.2.0

type HealthError struct {
	Fields []FieldStatus
}

HealthError is returned by Watcher.Health when one or more fields are unhealthy. It names the offending fields so a readiness probe can log which config is broken rather than a bare "unhealthy".

func (*HealthError) Error added in v1.2.0

func (e *HealthError) Error() string

type Identity added in v1.2.0

type Identity struct {
	Subject string
	Attrs   map[string][]string
}

Identity is the authenticated caller. Subject is a stable principal name; Attrs carries scheme-specific detail (certificate SANs, token claims, a peer uid). Attrs is multi-valued because authorization commonly needs multi-valued claims: groups, scopes, token audiences, or multiple certificate SANs; a single string per key would force a scheme to join-encode them. Both may be empty for schemes that authenticate without naming a principal, such as a shared bearer token.

type Kind added in v1.2.0

type Kind string

Kind is a coarse, provider-independent classification of a resolve failure. It exists so diagnostics can distinguish conditions that need human action (a missing secret, a denied permission) from transient ones (an unreachable backend, a throttled request). Providers produce it by wrapping one of the sentinels below; consumers read it with ErrorKind.

const (
	// KindNotFound represents a missing key, secret, path, or version. This is the
	// only kind that triggers a field's default: or optional: handling; the rest are
	// diagnostic only.
	KindNotFound Kind = "not_found"
	// KindPermissionDenied means the caller has authenticated successfully but is
	// not authorized to access the requested value, such as an IAM deny, Vault
	// policy, or Kubernetes RBAC denial. Distinguish this from KindUnauthenticated,
	// where the caller has not proven their identity.
	KindPermissionDenied Kind = "permission_denied"
	// KindUnauthenticated means credentials are missing, malformed, or expired, or a
	// token renewal failed. The caller has not proven who they are. Distinguish from
	// KindPermissionDenied, where the caller has authenticated but been refused.
	KindUnauthenticated Kind = "unauthenticated"
	// KindUnavailable means the backend could not be reached or did not respond,
	// due to network failure, DNS issues, timeout, a 5xx response, or an open
	// circuit breaker.
	KindUnavailable Kind = "unavailable"
	// KindRateLimited means the backend deliberately rejected this request due to
	// throttling, quota exhaustion, or similar rate control. Distinguish from
	// KindUnavailable, where the backend is reachable and healthy.
	KindRateLimited Kind = "rate_limited"
	// KindInvalid means the ref is malformed for this provider, or the returned
	// payload could not be parsed.
	KindInvalid Kind = "invalid"
	// KindUnknown is the honest answer for an error a provider cannot map. It is
	// a legal outcome, not a failure: a provider that guesses is worse than one
	// that admits it does not know.
	KindUnknown Kind = "unknown"
)

func ErrorKind added in v1.2.0

func ErrorKind(err error) Kind

ErrorKind classifies err by walking its errors.Is chain. It returns the empty Kind for a nil error and KindUnknown for an error carrying no sentinel.

An error that lost its chain (a provider that formatted with %v rather than %w) reports KindUnknown. That is the failure the providertest conformance case exists to catch.

context.DeadlineExceeded and context.Canceled are handled explicitly, after the sentinel checks so an explicit mamori sentinel always wins, and are deliberately classified asymmetrically:

  • context.DeadlineExceeded reports KindUnavailable: the backend genuinely did not respond in time, which is exactly what KindUnavailable denotes.
  • context.Canceled still reports KindUnknown: the caller withdrew the request, which says nothing about the backend's health. This is a deliberate decision, not an oversight.

type MTLSOptions added in v1.2.0

type MTLSOptions struct {
	// AllowedCNs, if non-empty, permits only certificates whose
	// Subject.CommonName is in this list.
	AllowedCNs []string
	// AllowedDNSNames, if non-empty, permits only certificates with at least
	// one DNS SAN in this list.
	AllowedDNSNames []string
}

MTLSOptions configures certificate-based authentication. Both fields are optional allowlists: a name matching either is accepted. If both are empty, any client certificate that the TLS stack has already verified is accepted, on the theory that verification itself (see MTLS) is the security boundary and a further, unconfigured name check would only be security theater.

type Meter

type Meter interface {
	// RecordResolve reports a provider resolve: its scheme, duration, and error
	// (nil on success).
	RecordResolve(scheme string, dur time.Duration, err error)
	// RecordRefresh reports that a watched value changed and was reconciled.
	RecordRefresh(scheme string)
	// RecordWatchError reports a provider watch-channel error.
	RecordWatchError(scheme string)
	// RecordStale reports that a value has not refreshed within the WithStale
	// threshold.
	RecordStale(scheme string)
	// RecordChangeDropped reports that a change event was discarded because the
	// OnChange dispatch queue was full. A non-zero rate means handlers are not
	// keeping up and callers are missing changes.
	RecordChangeDropped()
	// RecordApplyRejected reports that a candidate configuration was refused
	// and the previous one is still being served.
	RecordApplyRejected(reason RejectReason)
}

Meter is the minimal metrics sink mamori emits to. It is deliberately tiny so the core module takes no OpenTelemetry dependency; the x/otel module provides an adapter that implements this interface on top of an OTel meter. Pass one with WithMeter. All methods must be safe for concurrent use.

type Option

type Option func(*options)

Option configures Load and Watch.

func OnChange

func OnChange[T any](fn func(Change[T])) Option

OnChange installs the callback invoked (on a single, serialized goroutine) for each applied update. It is typed to the same T passed to Watch.

func OnError

func OnError(fn func(error)) Option

OnError installs a callback for runtime resolve/validation/stale errors, and for a candidate a PreApply gate rejected.

Unlike OnChange, it runs INLINE on the reconciler goroutine rather than on the dispatch queue: errors are delivered, never dropped, which the drop-oldest queue OnChange uses could not promise. Two things follow, and both are the caller's to design around:

It blocks reconciliation for as long as it runs. Log, count, notify - do not do I/O with no deadline here, and do not wait on something that is itself waiting on this watcher.

It must not call back into the same Watcher. Get is safe (it is a lock-free atomic load), but Pin, PinCurrent, Unpin and Refresh are commands serviced by the very goroutine this callback is occupying, so they would wait for themselves. mamori detects that and refuses the call - see ErrReentrantCall, which spells out what each one returns. "The reload was rejected, retry it" is the tempting thing to write here; issue it from another goroutine, or let the next reconciliation do it.

func PreApply added in v1.4.0

func PreApply[T any](fn func(ctx context.Context, ev Change[T]) error) Option

PreApply installs a gate that runs before a reconciled snapshot becomes current. Returning a non-nil error rejects the candidate: Get keeps returning the last valid configuration, OnChange does not fire, and OnError receives a *PreApplyError describing the rejection.

It exists for the checks struct validation cannot express, because they need I/O: that a rotated database password actually opens a connection, that a new API token is accepted by its issuer, that a reissued certificate chains to a trusted root. Validation answers "is this well-formed". PreApply answers "does this actually work", which is the question a rotation actually turns on.

w, err := mamori.Watch[Config](ctx,
    mamori.PreApply(func(ctx context.Context, ev mamori.Change[Config]) error {
        if !ev.Changed("DBPassword") {
            return nil
        }
        return pool.Ping(ctx, ev.New.DBPassword.Reveal())
    }),
)

The hook runs on the reconciler goroutine, because it has to complete before the swap and the OnChange dispatch queue is asynchronous and lossy by design (WithQueueDepth drops the oldest event when full); a gate cannot be delivered on a channel that is allowed to drop. Two consequences follow, and both matter:

It is bounded by WithPreApplyTimeout, and the bound cannot be removed.

It must not call back into the same Watcher, and mamori now catches it when it does. Get is lock-free and safe here - it Loads a pointer the reconciler already published, so it returns whatever Get returns anywhere else at this instant: the snapshot this candidate would supersede, unless the watcher is pinned, in which case it is the pinned one and the candidate supersedes something else. Pin, PinCurrent, Unpin and Refresh are not safe: they are serviced by the very goroutine the hook is occupying, so sendPin (pin.go) would be waiting for a receiver that cannot exist until this hook returns. That used to block until Close - no reconciliation, no OnChange, no OnError, no diagnostic of any kind, and the hook's own timeout does not rescue it, because the hook is parked inside sendPin, which never looks at the context this hook was given.

It is now detected instead, per call, and the hook keeps running:

  • Pin returns ErrReentrantCall, having pinned nothing.
  • PinCurrent returns 0, which no real version ever is.
  • Unpin does nothing and leaves the watcher pinned exactly as it was.
  • Refresh returns ErrReentrantCall, having re-resolved nothing. Its own context does not save it: the hook would have to outlive the refresh it is waiting for, and Refresh(context.Background()) has no deadline at all.

The detection is keyed on which goroutine is inside the hook, not merely that one is, so a Pin issued from an unrelated goroutine that happens to overlap a hook is untouched: it waits its turn and is serviced normally, as before.

An OnError callback is in the same position and gets the same treatment - it too runs inline on the reconciler goroutine. See ErrReentrantCall for the whole of that rule; OnChange, which is delivered from the dispatch queue on its own goroutine, is not affected by any of it.

It is typed to the same T passed to Watch, and runs on the initial load as well as on every subsequent update, so a credential that does not work is caught at startup rather than at the first rotation.

func WithAdminHTTP added in v1.2.0

func WithAdminHTTP(addr string, opts ...HandlerOption) Option

WithAdminHTTP makes Watch run its own HTTP server on addr, serving the same routes Handler would (see handler.go): GET / and GET /healthz. It exists so a caller who does not already run a mux of their own does not have to wire one up just to expose health.

It is off by default: with no WithAdminHTTP option, no listener is constructed, no port is bound, and no goroutine is started. When set, Watch binds the listener before it returns, so a bind failure (port in use, permission denied) fails Watch with the bind error rather than logging it and leaving the caller believing the endpoint is up; this mirrors Watch's existing fail-fast behavior on the initial Load.

The server's lifetime is tied to the Watcher's: its goroutine is tracked by the same wait group as the reconciliation engine, and Watcher.Close shuts it down gracefully (bounded by a grace period) before returning, so Close returning means the port is free again.

Load accepts this option too, since Load and Watch share the same Option type, but Load has no long-lived Watcher to run a server against and so it silently ignores WithAdminHTTP.

func WithAdminTLS added in v1.2.0

func WithAdminTLS(cfg *tls.Config) Option

WithAdminTLS serves the WithAdminHTTP endpoint over TLS instead of plaintext, using cfg's certificates and, if set, its ClientCAs/ClientAuth for mutual TLS. It has no effect without WithAdminHTTP.

The shipped Authenticator schemes (basic auth, bearer tokens) are only as safe as the transport they run over: a credential sent in the clear is not really a credential. WithAdminTLS exists so those schemes can be used safely on the self-hosted admin endpoint.

func WithBackoff

func WithBackoff(base, max time.Duration) Option

WithBackoff enables per-ref exponential backoff on resolve failure for polled refs. After a ref fails to resolve, its next attempt is delayed by base instead of the poll interval; each further consecutive failure doubles that delay, held at max once it gets there. Any successful round trip with the backend resets it, and the ref returns to the normal poll interval.

Backoff is OFF by default. Without this option a failing ref is retried on the WithPollInterval cadence, exactly as it always has been - which is the point: this option set two fields nothing read until it was implemented, so no existing caller can have been relying on backoff, and switching it on for everyone would have made a just-failed backend get retried far sooner than its operators had ever seen. Choose the window deliberately.

Normalization: a base of zero or less disables backoff, so WithBackoff(0, 0) turns it back off. A max below base is raised to base, which gives WithBackoff(d, 0) the meaning "retry every d while failing" rather than unbounded exponential growth.

Three behaviors are worth knowing before choosing a window.

It does not apply to providers with a native watch. A WatchableProvider (Kubernetes informers, Consul blocking queries, Postgres LISTEN/NOTIFY, the mamori:// SSE client, ...) owns its own stream and its own reconnection cadence; mamori polls nothing on its behalf, so there is no attempt for this option to delay. Reconnect behavior for those is provider-internal and documented per provider. The single exception is a native watch that fails to START: mamori falls back to the polling adapter for that ref, and backoff governs it from then on like any other polled ref.

A not-found is not a failure. ErrNotFound means the backend answered and the ref is absent, which is ordinary default:/optional: territory; it ends a streak rather than extending one, so a ref that gets provisioned after the process starts is still discovered on the normal poll interval.

It interacts with WithStale. Staleness is escalated to a *StaleError on the first failed attempt after maxAge elapses, and backoff is what pushes that attempt out, so a large max delays the OnError signal by up to one backoff step. Watcher.Status and Watcher.Health are unaffected: they recompute Age and Stale at read time from the last success, so a readiness probe still turns unhealthy at exactly maxAge. Keep max well under the WithStale threshold if the OnError timing matters to you.

func WithClock

func WithClock(c Clock) Option

WithClock overrides the clock, primarily for deterministic tests.

func WithDebounce

func WithDebounce(d time.Duration) Option

WithDebounce sets the coalescing window for change events (default 500ms). A per-field `?debounce=` ref option overrides this for that field.

func WithDecodeHook added in v1.1.0

func WithDecodeHook(h mapstructure.DecodeHookFunc) Option

WithDecodeHook adds a mapstructure decode hook applied when decoding a flatten:"json|yaml|env" payload into a nested struct. Hooks run after the built-in secret/duration hook, in the order registered, so you can convert custom field types (a time.Time layout, a net.IP, an enum, ...).

func WithExecProvider

func WithExecProvider() Option

WithExecProvider enables the exec: provider for this Load or Watch call only. It is not registered globally; you must opt in explicitly.

func WithHistory added in v1.2.0

func WithHistory(n int) Option

WithHistory retains the n most recent snapshots in addition to the current one, readable via Watcher.History and pinnable via Watcher.Pin. It defaults to 0. A negative n clamps to 0.

Retained snapshots hold full copies of T, including any secret material that has since been rotated. Enabling history extends the in-memory lifetime of old secrets; enable it deliberately.

func WithJitter

func WithJitter(f float64) Option

WithJitter sets the poll jitter fraction (0..1); a value of 0.2 randomizes each interval by ±20% to avoid thundering herds.

func WithLogger added in v1.4.0

func WithLogger(l *slog.Logger) Option

WithLogger installs a structured logger for engine events: resolve failures and recoveries, watch errors, rejected candidates, applied changes, stale values, and dropped change events.

mamori logs nothing by default. A library that writes to the application's stderr merely because it was linked in has taken a decision that belongs to the application, so the zero configuration is a discard logger and WithLogger(slog.Default()) is the one-line opt-in. Passing nil resets to silent rather than panicking, so a caller wiring this up conditionally can pass nil for the off case.

Two things worth knowing before choosing a handler.

Records never contain a resolved value. They carry the field path, the scheme, the ref with inline credentials redacted, the version, and the error. That is deliberate and tested: a config log is exactly the artifact most likely to be shipped off the host, so a secret must never reach it.

The handler is called from the reconciler goroutine, so a handler that blocks blocks reconciliation, the same constraint OnError carries. A handler writing synchronously to a remote collector will stall the engine; buffer it.

WithLogger and OnError are independent and may both be set. An error reaches both, and neither suppresses the other.

func WithMeter

func WithMeter(m Meter) Option

WithMeter installs a metrics sink (see the x/otel module for an OTel adapter).

func WithPollInterval

func WithPollInterval(d time.Duration) Option

WithPollInterval sets the fallback poll interval for non-watchable providers.

func WithPreApplyTimeout added in v1.4.0

func WithPreApplyTimeout(d time.Duration) Option

WithPreApplyTimeout bounds how long a PreApply hook may run, defaulting to defaultPreApplyTimeout.

The bound is mandatory rather than optional because the hook runs on the reconciler goroutine, which also services every other field's updates, the published Status report, and pin/unpin commands. An unbounded hook would wedge all of that.

Exceeding the budget is a REJECTION, not an acceptance: on timeout mamori does not know whether the new configuration works, and applying it anyway would defeat the point of having a gate. A hook that always times out therefore stalls updates - loudly, emitting a *PreApplyError once per attempt - rather than quietly serving unverified configuration. That is the intended trade.

A zero or negative d clamps to the default rather than being honored, the way WithHistory clamps a negative n. It cannot mean "no bound", because the bound is what keeps a hook from wedging the reconciler goroutine, and honoring it literally is worse still: context.WithTimeout(parent, 0) returns a context that is ALREADY expired, so every candidate would be refused on the deadline check - including the initial load, which would make Watch and Load fail at startup with a DeadlineExceeded a caller writing WithPreApplyTimeout(0) is unlikely to read as "you disabled the gate". Elsewhere in this package a zero duration disables a feature (WithStale), so the one reading a caller is most likely to have in mind is the one meaning this option cannot express at all.

func WithProvider

func WithProvider(p Provider) Option

WithProvider registers a provider for this call only, taking precedence over the global registry for its scheme.

func WithQueueDepth

func WithQueueDepth(n int) Option

WithQueueDepth bounds the OnChange dispatch queue; when full, the oldest event is dropped (default 16).

func WithRefVars added in v1.4.0

func WithRefVars(vars map[string]string) Option

WithRefVars supplies the variables available to ${VAR} expansion in `source` struct tags. Expansion happens once, when Load, Watch, or Doctor walks the config struct, before any ref is parsed, so a variable may supply a scheme, a path segment, a fragment, or a query value. Because expansion runs against the whole raw tag string before it is split into refs and before ParseRef sees it, a variable's value can also re-cut the ref: a comma changes how a multi-ref precedence chain splits, and a '#' or '?' moves the fragment or query delimiter, since ParseRef cuts at the first of each. The last two are the ones to watch, because they yield a valid ref for a different location rather than an error, which then resolves not-found into the field's default:. Values are operator-supplied through this function, so this is part of the same trust model as the rest of this doc comment, not a separate gap; keep ref punctuation out of them.

Nothing is expanded unless it appears here. mamori never reads the ambient environment for this, and that is a deliberate security property rather than an omission: a ref decides which secret a process reads, so expanding one from ambient state would let anything able to set an environment variable redirect that read. This is the same reasoning that makes the exec: provider opt-in via WithExecProvider. Use EnvVars to opt in to named environment variables explicitly.

Applying WithRefVars more than once merges, with later calls winning per key. (WithAuth rejects a second application instead, because "which authenticator wins" has two plausible answers while merging maps has one.)

Values must not be secrets. After expansion a ref's Raw holds the expanded string, which appears in Status, Report, and mamori doctor output. Variables are for environment names, regions, service names, and tenant identifiers.

func WithStale

func WithStale(maxAge time.Duration) Option

WithStale escalates staleness to a hard error: if a ref cannot be refreshed for longer than maxAge, OnError receives a *StaleError.

func WithTracer

func WithTracer(t Tracer) Option

WithTracer installs a tracing sink (see the x/otel module for an OTel adapter).

func WithValidator

func WithValidator(v Validator) Option

WithValidator overrides the default (go-playground/validator) validator.

type PeerCredOptions added in v1.2.0

type PeerCredOptions struct {
	UIDs []int
	GIDs []int
}

PeerCredOptions configures PeerCred. UIDs and GIDs are both optional allowlists: a peer is permitted if its uid is in UIDs, OR its gid is in GIDs (either suffices; they are not ANDed). If both are empty, any peer whose credentials were successfully read is permitted - the same "verification itself is the security boundary" default MTLSOptions documents for an empty certificate allowlist: needing no further name check is not the same as needing no verification at all, and Authenticate still denies outright whenever there are no kernel-verified credentials to check in the first place (a non-unix connection, or a unix one whose creds were never plumbed into the request context - see PeerCred's doc comment).

type PreApplyError added in v1.4.0

type PreApplyError struct {
	Err error
}

PreApplyError is delivered to OnError when a PreApply hook rejects a candidate snapshot, and returned by Watch and Load when the hook rejects the initial configuration. Err is the hook's own error, or context.DeadlineExceeded when it exceeded WithPreApplyTimeout.

func (*PreApplyError) Error added in v1.4.0

func (e *PreApplyError) Error() string

func (*PreApplyError) Unwrap added in v1.4.0

func (e *PreApplyError) Unwrap() error

Unwrap lets errors.Is and errors.As reach the hook's own error, including context.DeadlineExceeded for a hook that exceeded its budget.

type Provider

type Provider interface {
	// Scheme returns the URL scheme this provider handles, e.g. "aws-sm".
	Scheme() string
	// Resolve fetches the current Value for ref. It must return an error that
	// satisfies errors.Is(err, ErrNotFound) when the referenced value does not
	// exist, so defaults and optional fields can be applied.
	Resolve(ctx context.Context, ref Ref) (Value, error)
}

Provider resolves refs of a single scheme into Values. It is the minimum a source must implement. Providers should be safe for concurrent use.

type ProviderError

type ProviderError struct {
	Scheme string
	Ref    string
	Err    error
}

ProviderError wraps an error from a specific provider resolve, tagging it with the scheme and ref for diagnostics and metrics. It is delivered to OnError for runtime resolve failures.

func (*ProviderError) Error

func (e *ProviderError) Error() string

func (*ProviderError) Unwrap

func (e *ProviderError) Unwrap() error

Unwrap allows errors.Is/As to reach the underlying error (e.g. ErrNotFound).

type Ref

type Ref struct {
	// Scheme selects the provider, e.g. "aws-sm", "vault", "env", "file".
	Scheme string
	// Path is the provider-specific location of the value, e.g. "prod/db",
	// "kv/data/api", "/etc/tls/tls.crt", or "LOG_LEVEL".
	Path string
	// Key selects a single field from a structured payload (the URL fragment,
	// i.e. the part after '#'). It is empty when no key is requested.
	Key string
	// Opts holds provider-specific options parsed from the query string, plus a
	// small set of core-recognized options (debounce, optional, version).
	Opts url.Values
	// Raw is the original, unparsed tag value, retained for error messages.
	Raw string
}

Ref is a parsed reference to a value in a provider. It is produced from the `source` struct tag by ParseRef. The general grammar is:

<scheme>://<path>[#<key>][?<opt>=<v>&...]

Opaque schemes such as env: and exec: take everything after the colon as the Path (no "//" authority section):

env:LOG_LEVEL
exec:echo hello

func ParseRef

func ParseRef(tag string) (Ref, error)

ParseRef parses a `source` tag value into a Ref. It returns an error for an empty tag or a tag without a scheme.

The grammar is scheme-agnostic and, per the mamori spec, places the optional #key fragment BEFORE the optional ?opts query (the reverse of a standard URL). Parsing is therefore done by hand rather than via net/url:

scheme://path[#key][?opts]   (hierarchical: aws-sm, vault, file, ...)
scheme:path[#key][?opts]     (opaque: env, exec)

func ParseRefs added in v1.2.0

func ParseRefs(tag string) ([]Ref, error)

ParseRefs parses a `source` tag that may hold a comma-separated precedence chain of refs, e.g. "env:PORT,aws-ps://svc/port". This is the entry point for chained sources (spec 10.2): the first ref to yield a value wins, so the chain lets a field prefer a fast/local source and fall back to a slower or more authoritative one without the caller writing that fallback logic by hand.

A comma is treated as a separator only when the text after it begins with a scheme-like token (see schemeStart); a comma inside a query option (?tags=a,b) or an opaque exec: path (exec:echo a,b) is preserved as part of that ref. To force a literal comma that would otherwise be read as a separator, percent-encode it as %2C.

Whitespace around a separator is ignored, so the spaced form "env:PORT, aws-ps://svc/port" yields exactly the same chain as the compact "env:PORT,aws-ps://svc/port". A space does not by itself make a comma a separator: what follows it must still look like a scheme, so "exec:echo a, b" remains one ref.

Because of that rule, a doubled or trailing comma is not a split point (no scheme token follows it) and is instead kept as part of the adjacent ref's value, e.g. "env:A,,env:B" yields a first ref with path "A," rather than an empty chain entry. Such a malformed ref simply resolves not-found at lookup time and the chain falls through to the next entry, so this is treated as a caller error rather than something ParseRefs rejects outright.

A single-ref tag (the common case) yields a one-element slice, so callers that do not use chains see no behavior change from switching to ParseRefs.

Example

ParseRefs turns a `source` tag into the chain of refs it denotes. A comma is a separator only when what follows it looks like a scheme, so a comma inside an opaque path or a query option is preserved.

package main

import (
	"fmt"

	"github.com/xavidop/mamori"
)

func main() {
	for _, tag := range []string{
		"env:PORT,aws-ps://svc/port",  // compact chain
		"env:PORT, aws-ps://svc/port", // same chain, written with a space
		"exec:echo a, b",              // one opaque command, not a chain
		"vault://kv?tags=a,b",         // comma inside a query option
	} {
		refs, err := mamori.ParseRefs(tag)
		if err != nil {
			fmt.Println(err)
			continue
		}
		fmt.Printf("%d ref(s) from %q:", len(refs), tag)
		for _, r := range refs {
			fmt.Printf(" [%s %s]", r.Scheme, r.Path)
		}
		fmt.Println()
	}

}
Output:
2 ref(s) from "env:PORT,aws-ps://svc/port": [env PORT] [aws-ps svc/port]
2 ref(s) from "env:PORT, aws-ps://svc/port": [env PORT] [aws-ps svc/port]
1 ref(s) from "exec:echo a, b": [exec echo a, b]
1 ref(s) from "vault://kv?tags=a,b": [vault kv]

func (Ref) Opt

func (r Ref) Opt(name string) string

Opt returns the first value for the named option, or "" if unset.

func (Ref) String

func (r Ref) String() string

String renders the Ref back into its canonical tag form. Query options are omitted if empty. It is primarily useful for diagnostics.

type RejectReason added in v1.4.0

type RejectReason string

RejectReason names why a candidate configuration was refused. It is a closed set of two so an adapter can use it as a metric label without unbounded cardinality, which a free-form string would invite.

const (
	// RejectValidation means the candidate failed the configured Validator.
	RejectValidation RejectReason = "validation"
	// RejectPreApply means a PreApply hook refused the change.
	RejectPreApply RejectReason = "preapply"
)

type Report added in v1.2.0

type Report struct {
	Fields      []FieldStatus
	Snapshot    uint64    // version of the snapshot Get currently returns (the pinned version, while Pinned)
	Live        uint64    // newest validated snapshot; diverges from Snapshot while Pinned
	Pinned      bool      // true when Get is frozen at Snapshot while Live keeps advancing; see Watcher.Pin
	Healthy     bool      // no field is stale or carries a terminal error kind
	GeneratedAt time.Time // when this report was built
}

Report is a point-in-time snapshot of a Watcher's health, or the result of a one-shot Doctor probe. Fields are in struct declaration order.

func Doctor added in v1.2.0

func Doctor[T any](ctx context.Context, opts ...Option) (Report, error)

Doctor resolves every field of T exactly once and returns a Report describing what succeeded and what failed, without starting a watcher. It accepts the same Options as Load and Watch, so it exercises the caller's real provider wiring, middleware, and Prefix rewriting: run it in a build-tagged CI test to catch a misconfigured ref before it ships.

The returned error is non-nil only when T itself cannot be walked as a config struct. Individual field failures are recorded in the Report, not returned, so a caller sees every problem at once rather than only the first. Doctor does not populate T's fields or validate them: a field that resolves but fails validation is Load's concern, not a reachability check's. It does run a ref's ?decode= pipeline, which happens inside resolution rather than after it (see resolveRef), so a value whose declared encoding does not match what the backend holds is reported here as a failed field with LastKind "invalid" - exactly as it would fail at Load, which is the point of a preflight check.

Report.Snapshot and Report.Live are always 0, signaling a one-shot probe rather than a running watcher's snapshot (whose version starts at 1).

Example

Doctor resolves every field once, without starting a watcher, and reports every failure rather than stopping at the first. Run it as a pre-deploy check to catch a typo'd ref or a rotated-away secret before it ships.

package main

import (
	"context"
	"fmt"

	"github.com/xavidop/mamori"
	"github.com/xavidop/mamori/mamoritest"
)

func main() {
	type Config struct {
		Present string `source:"mem://present"`
		Missing string `source:"mem://missing"`
	}

	prov := mamoritest.NewProvider("mem")
	prov.Set("present", "value")
	// "missing" is deliberately never Set.

	// err is non-nil only when Config itself cannot be walked as a config
	// struct. A field that fails to resolve is reported, not returned, so one
	// call surfaces every problem instead of just the first.
	report, err := mamori.Doctor[Config](context.Background(), mamori.WithProvider(prov))
	if err != nil {
		fmt.Println("not a usable config struct:", err)
		return
	}

	for _, f := range report.Fields {
		if f.LastKind == "" {
			fmt.Printf("%s: ok\n", f.Path)
			continue
		}
		fmt.Printf("%s: %s\n", f.Path, f.LastKind)
	}
	fmt.Println("all refs reachable:", report.Healthy)

}
Output:
Present: ok
Missing: not_found
all refs reachable: false

type Snapshot added in v1.2.0

type Snapshot[T any] struct {
	Version uint64
	At      time.Time
	Config  T
	Fields  []FieldChange // what changed relative to the previous snapshot
	// contains filtered or unexported fields
}

Snapshot is one validated configuration the Watcher applied, retained when WithHistory is enabled. Config is a full copy of T at that version, so enabling history extends the in-memory lifetime of any secret material it holds; that is why history defaults to off.

type StaleError

type StaleError struct {
	Ref string
	Err error
}

StaleError is returned/delivered when a value has exceeded the configured WithStale max age without a successful refresh.

func (*StaleError) Error

func (e *StaleError) Error() string

func (*StaleError) Unwrap

func (e *StaleError) Unwrap() error

type Ticker

type Ticker struct {
	C <-chan time.Time
	// contains filtered or unexported fields
}

Ticker is a Clock-provided periodic ticker. Its channel C receives ticks.

func (*Ticker) Stop

func (t *Ticker) Stop()

Stop halts the ticker. It does not close C.

type Timer

type Timer struct {
	C <-chan time.Time
	// contains filtered or unexported fields
}

Timer is a Clock-provided one-shot timer. Its channel C receives the time it fires. Stop prevents a not-yet-fired timer from firing.

func (*Timer) Stop

func (t *Timer) Stop() bool

Stop halts the timer, returning true if it had not yet fired.

type Tracer

type Tracer interface {
	// StartResolve begins a span for a resolve and returns a derived context plus
	// a finish function to be called with the resolve error (nil on success).
	StartResolve(ctx context.Context, scheme, ref string) (context.Context, func(err error))
}

Tracer is the minimal tracing sink mamori emits to (see Meter for the no-dep rationale). Pass one with WithTracer.

type Ucred added in v1.2.0

type Ucred struct {
	UID int
	GID int
	PID int
}

Ucred is a Unix-socket peer's kernel-verified credentials, as read once by a listener at accept time (SO_PEERCRED on Linux, LOCAL_PEERCRED via GetsockoptXucred on Darwin - see the platform-specific PeerCredFromConn in authpeercred_linux.go / authpeercred_darwin.go). A connection's peer identity cannot change over its lifetime, so reading it once per accepted connection, rather than re-deriving it per request, loses nothing.

func PeerCredFromConn added in v1.2.0

func PeerCredFromConn(conn net.Conn) (Ucred, error)

PeerCredFromConn reads conn's peer credentials via SO_PEERCRED, the Linux kernel's record of the uid/gid/pid of the process on the other end of a Unix-domain-socket connection at the time it was accepted. It is exported for the server module's Unix-socket listener wrapper (a later task; see PeerCred's doc comment in authpeercred.go for the full ConnContext plumbing this feeds): the listener calls this once per accepted connection and passes the result to ContextWithPeerCred.

type Update

type Update struct {
	// Value is the new value. Valid only when Err is nil.
	Value Value
	// Err carries a transient watch error. The channel remains open; mamori
	// surfaces the error and keeps the last-good value.
	Err error
}

Update is a single event delivered on a WatchableProvider's channel.

type ValidationError

type ValidationError struct {
	Err error
}

ValidationError wraps a validation failure. When an updated snapshot fails validation the update is rejected atomically and this error is delivered to OnError; Get continues to return the last valid config.

func (*ValidationError) Error

func (e *ValidationError) Error() string

func (*ValidationError) Unwrap

func (e *ValidationError) Unwrap() error

type Validator

type Validator interface {
	// Validate returns nil if v is valid, or a non-nil error describing the
	// failure (which mamori wraps in *ValidationError).
	Validate(v any) error
}

Validator validates a fully-decoded config value. It is invoked on the initial load and on every reconciled update. The default implementation wraps go-playground/validator, reading `validate` struct tags. Supply an alternative with WithValidator.

type Value

type Value struct {
	// Bytes is the raw resolved payload.
	Bytes []byte
	// Version is a provider-supplied revision identifier (Secrets Manager
	// VersionId, Vault version, a file mtime+size hash, ...). It enables cheap
	// change detection: mamori treats a changed Version as a changed value
	// without comparing bytes. If empty, mamori falls back to byte comparison.
	Version string
	// Sensitive marks the value as secret, driving redaction downstream. It is
	// set by secret-bearing providers and by the decode layer for
	// secret.String / secret.Bytes fields.
	Sensitive bool
	// NotAfter, when non-zero, is the time at which this value is known to expire
	// (e.g. a Vault lease). mamori schedules a refresh before this instant rather
	// than waiting for the next poll tick.
	NotAfter time.Time
	// Metadata carries optional provider-specific annotations. It must never
	// contain the secret payload.
	Metadata map[string]string
}

Value is what a Provider returns for a resolved Ref. It carries the raw bytes plus the metadata mamori needs for change detection, redaction, and lease-aware refresh.

type WatchableProvider

type WatchableProvider interface {
	Provider
	// Watch returns a channel of Updates for ref. The channel is closed when the
	// watch ends (including on ctx cancellation). Transient errors are delivered
	// as Updates with a non-nil Err; channel closure signals termination.
	Watch(ctx context.Context, ref Ref) (<-chan Update, error)
}

WatchableProvider is an optional interface for providers with native change notification (Vault leases, Kubernetes informers, Consul blocking queries). Providers without native watch support are wrapped by mamori in a polling adapter - provider authors must never fake a Watch with an internal ticker.

type Watcher

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

Watcher holds the reconciled configuration of type T and manages the background watch goroutines. Obtain one from Watch and always Close it.

func Watch

func Watch[T any](ctx context.Context, opts ...Option) (*Watcher[T], error)

Watch performs an initial, fail-fast Load of T and then keeps it reconciled at runtime, delivering validated, diff-aware updates to OnChange. It returns after the initial configuration is resolved (OnChange fires only on subsequent changes).

Example

Watch keeps the configuration reconciled at runtime. When a source value changes, mamori re-resolves, re-validates, atomically swaps the snapshot, and calls OnChange with both the old and new value so the application can react without restarting.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/xavidop/mamori"
	"github.com/xavidop/mamori/mamoritest"
	"github.com/xavidop/mamori/secret"
)

func main() {
	type Config struct {
		DBPassword secret.String `source:"mem://db-password"`
	}

	// A scriptable in-memory provider stands in for a real secret store.
	prov := mamoritest.NewProvider("mem")
	prov.Set("db-password", "old-password")

	rotated := make(chan string, 1)

	w, err := mamori.Watch[Config](context.Background(),
		mamori.WithProvider(prov),
		mamori.WithDebounce(time.Millisecond),
		mamori.OnChange(func(ev mamori.Change[Config]) {
			if ev.Changed("DBPassword") {
				rotated <- ev.New.DBPassword.Reveal()
			}
		}),
	)
	if err != nil {
		fmt.Println("watch failed:", err)
		return
	}
	defer func() { _ = w.Close() }()

	fmt.Println("before rotation:", w.Get().DBPassword.Reveal())

	prov.Set("db-password", "new-password")

	fmt.Println("OnChange saw:  ", <-rotated)
	fmt.Println("after rotation: ", w.Get().DBPassword.Reveal())

}
Output:
before rotation: old-password
OnChange saw:   new-password
after rotation:  new-password
Example (RejectsInvalidUpdate)

An update that fails validation is rejected atomically: OnError is notified and Get keeps returning the last valid configuration rather than entering a broken state mid-flight.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/xavidop/mamori"
	"github.com/xavidop/mamori/mamoritest"
)

func main() {
	type Config struct {
		Workers int `source:"mem://workers" validate:"gte=1,lte=256"`
	}

	prov := mamoritest.NewProvider("mem")
	prov.Set("workers", "8")

	rejected := make(chan error, 1)

	w, err := mamori.Watch[Config](context.Background(),
		mamori.WithProvider(prov),
		mamori.WithDebounce(time.Millisecond),
		mamori.OnError(func(err error) {
			var verr *mamori.ValidationError
			if errors.As(err, &verr) {
				select {
				case rejected <- err:
				default:
				}
			}
		}),
	)
	if err != nil {
		fmt.Println("watch failed:", err)
		return
	}
	defer func() { _ = w.Close() }()

	fmt.Println("valid config:", w.Get().Workers)

	prov.Set("workers", "9000") // out of range

	<-rejected
	fmt.Println("after rejected update:", w.Get().Workers)

}
Output:
valid config: 8
after rejected update: 8

func (*Watcher[T]) AdminAddr added in v1.2.0

func (w *Watcher[T]) AdminAddr() net.Addr

AdminAddr returns the address the admin HTTP server is bound to, or nil if WithAdminHTTP was not passed to Watch. This is the way to discover which port was actually chosen when binding to port 0 (letting the OS pick a free port), for example to register it with service discovery or to point a test client at it.

func (*Watcher[T]) Close

func (w *Watcher[T]) Close() error

Close cancels all provider watches, drains the callback queue, shuts down the admin HTTP server if one was started, and returns. By the time Close returns, the admin port (if any) is free: Close waits for the server's graceful Shutdown, bounded by adminShutdownGrace, before waiting on wg.

func (*Watcher[T]) Get

func (w *Watcher[T]) Get() T

Get returns the current configuration snapshot. It is lock-free and always returns the last valid configuration (never a partially-updated or validation-failing one).

func (*Watcher[T]) Health added in v1.2.0

func (w *Watcher[T]) Health() error

Health returns nil when every field is fresh and no field carries a terminal error kind. It wraps the offending fields in a HealthError otherwise, so a caller can log which fields are broken instead of a bare "unhealthy". Intended for use as a readiness probe.

func (*Watcher[T]) History added in v1.2.0

func (w *Watcher[T]) History() []Snapshot[T]

History returns the retained snapshots, newest first, always including the current one. With WithHistory(0) (the default) only the current snapshot is returned. It is lock-free: it only ever Load()s the pointer most recently published by the reconciler goroutine and copies out of it, so a concurrent publish from recordSnapshot can never be observed mid-mutation.

func (*Watcher[T]) Pin added in v1.2.0

func (w *Watcher[T]) Pin(version uint64) error

Pin freezes Get at the snapshot with the given version and stops applying reconciled updates to Get, though sources keep being watched and Status keeps reporting the diverging Live version underneath. It returns ErrNoSuchSnapshot if that version is not retained; raise WithHistory to pin further back.

It returns ErrReentrantCall, immediately and without pinning anything, when called from inside a PreApply hook or an OnError callback: either one occupies the goroutine that would service this command. Call it from another goroutine.

func (*Watcher[T]) PinCurrent added in v1.2.0

func (w *Watcher[T]) PinCurrent() uint64

PinCurrent freezes Get at whatever snapshot it returns right now and returns that version. Unlike Pin, it always succeeds and needs no retained history, since it pins to the live snapshot rather than looking one up in it.

It returns 0, pinning nothing, in the two cases where it cannot succeed: the watcher is closed, or it was called from inside a PreApply hook or an OnError callback (see ErrReentrantCall - the signature has no room for one, and widening it would break every caller). Zero is unambiguous rather than a convenient lie: versions start at 1, so it can never collide with a version this really pinned, which is the same disambiguation Pinned relies on. Callers that need to distinguish the two causes, or to be sure at all, can check Pinned.

func (*Watcher[T]) Pinned added in v1.2.0

func (w *Watcher[T]) Pinned() (uint64, bool)

Pinned reports whether Get is currently frozen at a pinned snapshot and, if so, which version. It reads the report the reconciler goroutine most recently published, the same lock-free pointer Status reads: while pinned, Report.Snapshot IS the pinned version being served (see engine.buildReport in report.go), so Pinned needs no extra field or control-channel round trip to answer, only Report.Pinned to disambiguate "pinned at version 0" (impossible; versions start at 1) from "not pinned".

func (*Watcher[T]) Refresh added in v1.4.0

func (w *Watcher[T]) Refresh(ctx context.Context) error

Refresh forces an immediate re-resolve of every field, bypassing poll intervals, and blocks until the resulting snapshot has been applied or rejected.

It returns nil when a snapshot was applied and when nothing changed, and the rejection reason when the candidate failed validation or a PreApply gate. A SIGHUP handler therefore learns whether the reload actually worked, which is the whole reason this blocks rather than queueing:

for range sighup {
    switch err := w.Refresh(ctx); {
    case err == nil:
        log.Println("reload applied")
    case ctx.Err() != nil:
        // The wait was abandoned, not the reload. Whether it applied is
        // unknown from here; Status reports what actually happened.
        log.Printf("stopped waiting for the reload: %v", err)
    default:
        log.Printf("reload rejected, still serving the previous config: %v", err)
    }
}

Distinguishing those two is the whole reason the example is written this way. A cancelled ctx returns ctx.Err() while the reconciler goes on to apply the reload anyway (see below), so treating every non-nil error as a rejection reports "still serving the previous config" for a reload that in fact landed.

It does NOT bypass PreApply. A forced refresh is still gated; that is the point of having a gate, and a refresh is what an operator reaches for exactly when a credential has just rotated - the moment a gate matters most.

Refresh is delivered to the reconciler goroutine over the same control channel Pin, PinCurrent, and Unpin use, so it serializes with normal reconciliation rather than racing it, and it answers errWatcherClosed after Close for the same reason they do (see sendPin in pin.go).

It returns ErrReentrantCall, having re-resolved nothing, when called from inside a PreApply hook or an OnError callback: both run ON the goroutine which would have to service this command, so the call would be waiting for itself. Taking a context does not make that survivable - a callback calling Refresh(context.Background()), the obvious thing to write, would block until Close - so this is refused up front exactly as Pin is, rather than left to a deadline the caller may not have set. OnError is the one to watch for: "the reload was rejected, retry it" is a natural thing to write there, and this is what it gets instead. OnChange is unaffected; it runs on the dispatch goroutine, so a Refresh from it is an ordinary call.

ctx bounds the wait, not the work. Cancelling it returns ctx.Err() and stops this call from waiting; it does not recall a command already handed to the reconciler, which re-resolves and applies as usual. There is no half-applied snapshot either way.

While the watcher is pinned, a refresh still re-resolves, still runs the gate, and still advances Live and history - it just does not move Get, which is what the pin is for. It returns nil in that case: the snapshot was applied as far as the pin allows, and Unpin will publish it. Refresh does not silently unpin.

For a field resolved through a mamori:// ref, Refresh re-reads the config server's current value. It does not make the server re-resolve its own upstream: the server exists so that N consumers cost one upstream watch rather than N, and letting any client force an upstream fetch would invert exactly that property.

func (*Watcher[T]) Status added in v1.2.0

func (w *Watcher[T]) Status() Report

Status returns a point-in-time report of the watcher's per-field health. It is lock-free: it only ever Load()s the report pointer most recently published by the reconciler goroutine and works on a copy, never touching the engine's maps directly. That matters because the reconciler goroutine may be mid-mutation of those maps on its own goroutine at the exact moment Status is called from a caller's goroutine.

Age and Stale are recomputed against the watcher's clock (not the stored report's build time) so a watcher that has gone quiet does not keep reporting the age it had at the last reconcile.

Example

Status reports live per-field health without ever exposing a value, which makes it safe to log or serve. Health reduces the same report to a single readiness answer suitable for a Kubernetes probe.

package main

import (
	"context"
	"fmt"

	"github.com/xavidop/mamori"
	"github.com/xavidop/mamori/mamoritest"
	"github.com/xavidop/mamori/secret"
)

func main() {
	type Config struct {
		Token secret.String `source:"mem://token"`
	}

	prov := mamoritest.NewProvider("mem")
	prov.Set("token", "s3cret")

	w, err := mamori.Watch[Config](context.Background(), mamori.WithProvider(prov))
	if err != nil {
		fmt.Println("watch failed:", err)
		return
	}
	defer func() { _ = w.Close() }()

	report := w.Status()
	for _, f := range report.Fields {
		fmt.Printf("%s ref=%s sensitive=%t error=%q\n", f.Path, f.Ref, f.Sensitive, f.LastError)
	}
	fmt.Println("healthy:", report.Healthy)
	fmt.Println("ready:  ", w.Health() == nil)

}
Output:
Token ref=mem://token sensitive=true error=""
healthy: true
ready:   true

func (*Watcher[T]) Unpin added in v1.2.0

func (w *Watcher[T]) Unpin()

Unpin resumes applying reconciled updates to Get: it applies the newest validated snapshot and delivers exactly one Change whose Fields is the accumulated diff of everything that changed while pinned, no matter how many updates were reconciled (and recorded to history) in the meantime. It is a no-op if the watcher is not currently pinned.

It is also a no-op when called from inside a PreApply hook or an OnError callback (see ErrReentrantCall): it returns immediately and leaves the pin exactly as it found it, so the watcher stays pinned and Pinned still says so. It reports nothing, because it has nothing to report it with. Giving it an error return would be an incompatible change to a released API - it breaks every func() this method value is assigned to, t.Cleanup(w.Unpin) among them, and turns every existing call site into an unchecked-error lint finding - which is too much to charge every correct caller for a signal only an incorrect one can ever see. Pin, called the same wrong way, returns the error in full; the mistake is the same mistake, and diagnosing it once is enough.

Directories

Path Synopsis
cmd
mamori module
examples
basic command
Command basic demonstrates mamori: loading typed config from the environment and a file, then watching for changes and reacting without a restart.
Command basic demonstrates mamori: loading typed config from the environment and a file, then watching for changes and reacting without a restart.
Package mamoritest provides an in-memory, scriptable mamori.Provider for testing application code that consumes mamori.
Package mamoritest provides an in-memory, scriptable mamori.Provider for testing application code that consumes mamori.
Package middleware provides composable Provider decorators: Cache, Audit, Failover, RateLimit, and Prefix.
Package middleware provides composable Provider decorators: Cache, Audit, Failover, RateLimit, and Prefix.
Package providertest is the mamori provider conformance kit.
Package providertest is the mamori provider conformance kit.
Package secret provides wrapper types for sensitive configuration values that redact themselves everywhere a value is normally rendered - String, fmt, encoding/json, and log/slog - so a secret cannot leak through a stray log line or error message.
Package secret provides wrapper types for sensitive configuration values that redact themselves everywhere a value is normally rendered - String, fmt, encoding/json, and log/slog - so a secret cannot leak through a stray log line or error message.
tools
reconcilevet module

Jump to

Keyboard shortcuts

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