# mamori documentation > Typed, validated, watchable configuration and secrets for Go, loaded from a broad provider ecosystem (env, files, AWS, Vault, GCP, Azure, Kubernetes, databases, feature flags) and reconciled at runtime without a restart. The entire mamori documentation, concatenated for agent consumption. --- > Source: https://mamorigo.dev/docs # Introduction `mamori` (守り, "protection") loads typed, validated configuration and secrets into a Go struct from a broad provider ecosystem (environment, files, AWS, Vault, GCP, Azure, Kubernetes, Consul, and more), then keeps that struct reconciled while your program runs. Reach for it when you want one struct to describe every value your service needs, revalidated and hot-swapped whenever a source changes, without a restart. ## Install The core module ships the `env:` and `file://` providers and has zero cloud-SDK dependencies: ```bash go get github.com/xavidop/mamori ``` Requires Go 1.26 or newer. Each cloud or backend provider is a separate module, so its SDK only enters your build if you actually use it: ```bash go get github.com/xavidop/mamori/providers/aws # aws-sm:// aws-ps:// go get github.com/xavidop/mamori/providers/vault # vault:// go get github.com/xavidop/mamori/providers/k8s # k8s-secret:// k8s-cm:// ``` See the [Providers overview](/docs/providers/) for the full list of schemes. ## Quick start Tag a struct with `source:` refs, then call `Load`. A blank import registers a provider (the `database/sql` pattern): ```go package main import ( "context" "log" "github.com/xavidop/mamori" "github.com/xavidop/mamori/secret" _ "github.com/xavidop/mamori/providers/aws" // registers aws-sm:// and aws-ps:// ) type Config struct { DBPassword secret.String `source:"aws-sm://prod/db#password"` 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"` TLSCert []byte `source:"file:///etc/tls/tls.crt"` } func main() { cfg, err := mamori.Load[Config](context.Background()) if err != nil { log.Fatal(err) } log.Printf("workers=%d level=%s password=%s", cfg.Workers, cfg.LogLevel, cfg.DBPassword) // password prints as [REDACTED]; cfg.DBPassword.Reveal() returns the value. } ``` To react to changes at runtime instead of loading once, see [Loading & watching](/docs/usage/). ## How it works The model has three moving parts: 1. **Refs.** Each struct field carries a `source` tag: a small URL-ish reference to a value in some provider (`aws-sm://prod/db#password`, `env:LOG_LEVEL`, `file:///etc/tls/tls.crt`). 2. **Providers.** A provider resolves a scheme (`aws-sm`, `vault`, `env`, ...) into a `Value`. Providers register with the `database/sql` pattern, so the core module keeps zero cloud-SDK dependencies. 3. **The reconciler.** `Watch` resolves everything once (fail-fast), then watches each source: natively where the backend can push, by polling with jitter otherwise. On a change it re-validates the whole struct and, only if the result is valid, atomically swaps it in and fires your callback. ```mermaid flowchart LR Struct["Config struct with source: tags"] --> R["Reconciler (Load / Watch)"] R --> P["Providers, one per scheme"] P --> B["env, files, AWS, Vault, GCP, ..."] B -->|value or change| R R -->|"validate, then atomic swap"| Struct R -.->|on change| CB["your OnChange callback"] ``` The payoff: rotate a database password in Secrets Manager and your connection pool rotates with it, without a restart and without a half-applied config ever being observed. ## Where to go next - **[Concepts](/docs/concepts/):** refs, providers, the reconciler, and the full `source`/`default`/`validate` tag grammar. - **[Loading & watching](/docs/usage/):** one-shot `Load` versus a live `Watch`, change events, source chains, and snapshot pinning. - **[Validation](/docs/validation/):** the `validate:` rules applied on load and on every update. - **[Providers overview](/docs/providers/):** every scheme, its watch strategy, and how to authenticate it. - **[Config server](/docs/server/):** the opt-in module that serves resolved values to other callers behind mandatory auth. - **[CLI](/docs/cli/):** `explain`, `schema`, and `policy` read your source statically; `doctor` and `status` probe a running process. - **[Observability](/docs/observability/):** `Status`, `Health`, the pre-deploy `Doctor` check, and the admin HTTP endpoint. - **[Security](/docs/security/):** secret hygiene, the two HTTP surfaces, and what `mamori` is deliberately not (it is not a secrets store). --- > Source: https://mamorigo.dev/docs/quickstart # Quick start From zero to typed, validated, hot-reloading config in a few minutes. This walks through installing mamori, loading a config struct once, then upgrading to a live watch. ## 1. Install The core module ships the `env:` and `file://` providers with zero cloud-SDK dependencies: ```bash go get github.com/xavidop/mamori ``` Requires Go 1.26 or newer. Each backend (AWS, Vault, GCP, ...) is a separate module you add only if you use it: ```bash go get github.com/xavidop/mamori/providers/aws # aws-sm:// aws-ps:// ``` ## 2. Describe your config Tag each field with a `source:` ref. Use `secret.String` for secret values so they redact everywhere except an explicit `Reveal()`, and add `validate:` rules to reject bad config at load time: ```go package main import ( "context" "log" "github.com/xavidop/mamori" "github.com/xavidop/mamori/secret" ) type Config struct { 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"` DBURL secret.String `source:"env:DATABASE_URL" validate:"required"` } ``` ## 3. Load it once `Load` resolves every field, validates the struct, and returns it. If anything fails to resolve or validate, it returns an error and no partial struct: ```go func main() { cfg, err := mamori.Load[Config](context.Background()) if err != nil { log.Fatal(err) // e.g. DATABASE_URL missing, or WORKERS out of range } log.Printf("level=%s workers=%d db=%s", cfg.LogLevel, cfg.Workers, cfg.DBURL) // db prints as [REDACTED]; cfg.DBURL.Reveal() returns the real value. } ``` Run it: ```bash LOG_LEVEL=debug WORKERS=8 DATABASE_URL=postgres://... go run . ``` ## 4. Add a backend provider Providers register with the `database/sql` blank-import pattern. Import one, then point a field at its scheme: ```go import _ "github.com/xavidop/mamori/providers/aws" // registers aws-sm:// and aws-ps:// type Config struct { DBPassword secret.String `source:"aws-sm://prod/db#password"` } ``` See the [providers overview](/docs/providers/) for every scheme and how to authenticate it. ## 5. Watch for changes Swap `Load` for `Watch` to keep the struct reconciled while your program runs. `Get` always returns the latest fully-valid snapshot, and `OnChange` fires when a value changes: ```go w, err := mamori.Watch[Config](context.Background(), mamori.OnChange(func(ev mamori.Change[Config]) { if ev.Changed("Workers") { pool.Resize(ev.New.Workers) } }), ) if err != nil { log.Fatal(err) } defer w.Close() cfg := w.Get() // current snapshot, safe to call anytime ``` Rotate a secret in the backend and your process picks it up, with no restart and no half-applied config ever observed. ## A runnable example A complete program that loads from `env:` and `file://`, watches, and reacts to a live file rotation lives in [`examples/basic`](https://github.com/xavidop/mamori/tree/main/examples/basic): ```bash LOG_LEVEL=debug WORKERS=8 go run ./examples/basic ``` ## Where to go next - [Loading and watching](/docs/usage/) - `Load` vs `Watch`, change events, and error handling. - [Concepts](/docs/concepts/) - refs, the reconciler, source chains, and error kinds. - [Validation](/docs/validation/) - the full `validate:` rule set. - [Providers overview](/docs/providers/) - every backend and its watch strategy. - [Observability](/docs/observability/) - `Status`, `Health`, and the pre-deploy `Doctor` check. --- > Source: https://mamorigo.dev/docs/providers/onepassword # 1Password [1Password Connect](https://developer.1password.com/docs/connect/) over the REST API. Pure `net/http`, no third-party SDK. | | | | --- | --- | | Scheme | `op://` | | Module | `github.com/xavidop/mamori/providers/onepassword` | | Sensitive | yes | | Watch | poll | | Auth | `OP_CONNECT_HOST`, `OP_CONNECT_TOKEN` | ## Install ```bash go get github.com/xavidop/mamori/providers/onepassword ``` ```go import _ "github.com/xavidop/mamori/providers/onepassword" ``` ## Using the ref An `op://` ref points at one field of one item in a 1Password vault. This matches the familiar 1Password secret-reference format. ```text op://// ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | Vault name or id. A name is looked up first, then falls back to being treated as an id. | | `` | yes | Item title or id within that vault. | | `` | yes | Field label or id on that item. | **Examples** - `op://Production/postgres/password` reads the `password` field of the `postgres` item in the `Production` vault. - `op://Production/stripe/api_key` reads the `api_key` field of the `stripe` item. ```go type Config struct { DBPassword secret.String `source:"op://Production/postgres/password"` APIKey secret.String `source:"op://Production/stripe/api_key"` } ``` Values are marked `Sensitive`, and `Value.Version` is the item version (or a content hash when the item has no version). ## Explicit configuration ```go import opprov "github.com/xavidop/mamori/providers/onepassword" mamori.WithProvider(opprov.New( opprov.WithHost("https://connect.internal:8080"), opprov.WithToken(os.Getenv("OP_CONNECT_TOKEN")), )) ``` ## Watch 1Password Connect has no push channel, so mamori polls (`WithPollInterval` + jitter). ## Error classification | HTTP status | mamori kind | |---|---| | 404 | `not_found` | | 403 | `permission_denied` | | 401 | `unauthenticated` | | 429 | `rate_limited` | | 5xx | `unavailable` | | 400 | `invalid` | | anything else | `unknown` | Connect's error responses carry only a numeric status and a free-text message, no machine-readable error code, so classification is by status alone. A missing vault, item, or field is reported directly as `not_found` with its own message rather than through this table. Verified by unit tests and the conformance kit against an in-process HTTP fake of the Connect API (injected `*http.Client`). Live behavior against a running Connect server is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/observability/admin # Admin endpoint Serve a watcher's [`Report`](/docs/observability/#report-and-fieldstatus) over HTTP two ways: mount `Handler` on a mux you already run, or let mamori run its own server with `WithAdminHTTP`. Both expose exactly the same two routes. | Route | Response | | --- | --- | | `GET /` | The `Report`, as JSON (same shape as `w.Status()`) | | `GET /healthz` | A liveness/readiness signal, `{"status":"ok"}` or `{"status":"unhealthy",...}` | Every other path, and every other method, is `404`. ## Metadata only, never a value **This is a metadata endpoint. It never serves a configuration value, under any option, on any route.** The JSON body is always `w.Status()`, whose `Ref` fields are already redacted and which never carries a resolved value. `Watcher.Pin` / `Watcher.PinCurrent` / `Watcher.Unpin` are not reachable through either route: `GET /` reports `Pinned` (and the `Snapshot`/`Live` divergence it causes) read-only, but nothing here changes it. For the surface that serves resolved config *values* to many callers, see the [config server](/docs/server/). ## Mount Handler on your own mux ```go func Handler[T any](w *Watcher[T], opts ...HandlerOption) http.Handler ``` ```go w, err := mamori.Watch[Config](ctx) if err != nil { log.Fatal(err) } defer w.Close() mux := http.NewServeMux() mux.Handle("/", mamori.Handler(w)) go http.ListenAndServe(":8080", mux) ``` Mount it under a subpath with `HandlerPrefix`, which strips the prefix before the request reaches mamori's own routing: ```go mux.Handle("/admin/", mamori.Handler(w, mamori.HandlerPrefix("/admin"))) ``` `HandlerMiddleware` wraps the handler with a non-authentication concern such as request logging. It runs outside `HandlerPrefix`'s stripping and outside any `WithAuth` check, in the order the options are given. Authentication itself, `WithAuth` and the shipped schemes, is covered on the [Auth](/docs/auth/) page. ## Run a standalone server with WithAdminHTTP ```go func WithAdminHTTP(addr string, opts ...HandlerOption) Option func WithAdminTLS(cfg *tls.Config) Option ``` ```go w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP("127.0.0.1:9090"), ) if err != nil { log.Fatal(err) // includes a bind failure } defer w.Close() log.Printf("admin endpoint listening on %s", w.AdminAddr()) ``` `WithAdminHTTP` is for a caller who does not already run a mux of their own. It carries the same fail-fast lifecycle guarantees as the rest of mamori: - **Off by default.** With no `WithAdminHTTP` option, no listener is bound and no goroutine starts. - **A bind failure fails `Watch`.** The listener is bound before `Watch` returns, so a port already in use, or a permission error, comes back as `Watch`'s own error. - **`Close` releases the port.** `Watcher.Close` shuts the admin server down gracefully, bounded by a short grace period, before it returns. - **`AdminAddr()` gives you the bound address** (`func (w *Watcher[T]) AdminAddr() net.Addr`), which is `nil` unless `WithAdminHTTP` was used. This is how you discover the port the OS actually chose when binding to `:0`. - **`WithAdminTLS(cfg)` serves the endpoint over TLS** instead of plaintext, and has no effect without `WithAdminHTTP`. Pair it with an `Authenticator` (see [Auth](/docs/auth/)) so a credential sent to the endpoint is never sent in the clear. - `Load` accepts `WithAdminHTTP` too, since `Load` and `Watch` share the same `Option` type, but `Load` has no long-lived watcher to run a server against, so it silently ignores the option. ## Wire a readiness probe `GET /healthz` is built to back a Kubernetes readiness probe directly. Start the admin endpoint and point the probe at it: ```go w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP(":9090")) ``` ```yaml readinessProbe: httpGet: path: /healthz port: 9090 periodSeconds: 5 ``` An unauthenticated caller, such as a kubelet probe, always gets a bare status (`200 {"status":"ok"}` or `503 {"status":"unhealthy"}`), so readiness never depends on holding a credential, even when the endpoint has [`WithAuth`](/docs/auth/) configured. `/healthz` never returns `401`. If auth is configured and the caller authenticates (or no auth is configured at all), the body also includes the failing-field detail (the same fields a `*HealthError` carries). The response body is always metadata, never a config value. ## See also - [Observability overview](/docs/observability/) - `Status`, `Health`, and the `Report` shape. - [Doctor](/docs/observability/doctor/) - the pre-deploy counterpart to these live endpoints. - [Config server](/docs/server/) - serves resolved config *values*, not metadata. - [Auth](/docs/auth/) - `WithAuth`, the shipped schemes, and credential rotation. --- > Source: https://mamorigo.dev/docs/skill # Agent skill mamori ships an [Agent Skill](https://www.skills.sh/) that teaches an AI coding agent (Claude Code, Cursor, Copilot, Windsurf, Gemini, and others) how to use mamori: defining config structs with `source:` tags, picking and wiring providers, keeping secrets in `secret.String`, watching for live changes, and driving the `mamori` CLI. The skill lives in this repository under [`skills/mamori/`](https://github.com/xavidop/mamori/tree/main/skills/mamori). ## Install The one-line install works for any agent skills.sh supports: ```bash npx skills add xavidop/mamori ``` This fetches the skill and drops it into your agent's skills directory. Your agent then loads it automatically when a task involves loading config or secrets, wiring a provider, or the mamori CLI. ## Install manually If you would rather not use the CLI, copy the skill folder into your agent's skills directory yourself. For Claude Code that is `~/.claude/skills/`: ```bash git clone https://github.com/xavidop/mamori cp -r mamori/skills/mamori ~/.claude/skills/mamori ``` Other agents use their own location (for example a project-level `.cursor/` or `.github/` skills folder); see your agent's documentation. ## What it covers - The model: `source:` tags, providers as blank imports, `Load` versus `Watch`, and `secret.String` redaction. - Defining and loading a config struct, with validation. - Watching for live changes and reacting per field. - Choosing a provider, with a full scheme cheat-sheet in the skill's `references/providers.md`. - The `mamori` CLI (`explain`, `schema`, `policy`, `vet`, `doctor`, `status`) and its exit codes. ## llms.txt If your agent does not use skills, point it at the documentation directly. This site publishes the [llms.txt convention](https://llmstxt.org/): - [`/llms.txt`](https://mamorigo.dev/llms.txt) - a short index of the docs with links, for an agent to navigate. - [`/llms-full.txt`](https://mamorigo.dev/llms-full.txt) - the entire documentation as one Markdown file, for an agent to load in a single fetch. A prompt that works in most coding agents: ```text Add mamori to my Go project. Docs: https://mamorigo.dev/llms.txt ``` ## See also - [Quick start](/docs/quickstart/) - the same ground for a human reader. - [Providers overview](/docs/providers/) - every scheme and how to authenticate it. - [CLI](/docs/cli/) - the command reference the skill points agents at. --- > Source: https://mamorigo.dev/docs/providers/s3 # Amazon S3 Fetch a config or secret blob from an S3 bucket (or any S3-compatible store: MinIO, Cloudflare R2). | | | | --- | --- | | Scheme | `s3://` | | Module | `github.com/xavidop/mamori/providers/s3` | | Sensitive | no (opt-in with `WithSensitive`) | | Watch | poll (ETag) | | Auth | default AWS credential chain (`WithRegion`) | ## Install ```bash go get github.com/xavidop/mamori/providers/s3 ``` ```go import _ "github.com/xavidop/mamori/providers/s3" ``` ## Using the ref An `s3://` ref points at one object in a bucket, fetched with a single `GetObject`. ```text s3:///[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The S3 bucket name. | | `` | yes | The object key. It may contain slashes - everything after the bucket segment is the key. | | `#json-key` | no | Treat the object as a JSON object and return one field of it. | **Examples** - `s3://my-bucket/config/app.json` fetches the whole object - decode it with `flatten:"json"`. - `s3://my-bucket/config/app.json#database` returns just the `database` field of that JSON object. - `s3://my-bucket/tls/app.crt` fetches a raw blob (a certificate) - pair it with a `[]byte` field. ```go type Config struct { AppConfig AppConfig `source:"s3://my-bucket/config/app.json" flatten:"json"` Cert []byte `source:"s3://my-bucket/tls/app.crt"` } ``` The object key may contain slashes, so `config/prod/app.json` is a single key. `Value.Version` is the object ETag (or version id), so change detection is cheap: mamori compares the ETag before downloading again. Objects are not marked sensitive by default; because buckets often hold secret bundles (credential JSON, PEM chains, dotenv files), pass `WithSensitive(true)` to redact resolved values downstream. ## Watch mamori polls (`WithPollInterval` + jitter) using the ETag. For push, wire S3 Event Notifications to SQS/EventBridge and reload on demand. ## Error classification Failures are classified so `mamori.ErrorKind` can distinguish them: | S3 error code | mamori kind | |---|---| | `NoSuchKey`, `NoSuchBucket`, `NoSuchVersion`, `NotFound` | `not_found` | | `AccessDenied`, `AllAccessDisabled` | `permission_denied` | | `InvalidAccessKeyId`, `SignatureDoesNotMatch`, `ExpiredToken`, `InvalidToken`, `TokenRefreshRequired` | `unauthenticated` | | `SlowDown` | `rate_limited` | | `ServiceUnavailable`, `InternalError` | `unavailable` | | `InvalidRequest`, `InvalidArgument`, `MalformedXML` | `invalid` | | anything else | `unknown` | Codes not listed above report `unknown` rather than being guessed at. `SlowDown` and `ServiceUnavailable` share a 503 status but mean different things (throttling vs. overload), so they map to different kinds. `NoSuchVersion` is defensive: `Resolve` never sets `GetObjectInput.VersionId`, so this provider cannot actually trigger it today; it is included in case a future code path requests a specific object version. The original SDK error stays reachable with `errors.As`. ## Configuration ```go import s3prov "github.com/xavidop/mamori/providers/s3" mamori.WithProvider(s3prov.New(s3prov.WithRegion("eu-west-1"))) // S3-compatible (MinIO / R2): mamori.WithProvider(s3prov.New(s3prov.WithEndpoint("https://.r2.cloudflarestorage.com"))) ``` Verified with an in-memory fake; live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/auth # Auth An `Authenticator` decides whether an HTTP request may proceed and says who the caller is. One interface serves both mamori surfaces, the admin HTTP endpoint (`Handler`, `WithAdminHTTP`) and the [config server](/docs/server/), so a scheme configured for one works unchanged on the other. ## Quick start The admin endpoint has no `Authenticator` by default: any request that can reach it gets the `Report`. `WithAuth` attaches one. This wires a shared bearer token onto mamori's self-hosted admin server: ```go auth := mamori.BearerToken(secret.NewString(os.Getenv("ADMIN_TOKEN"))) w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP("127.0.0.1:9090", mamori.WithAuth(auth)), ) ``` The same `auth` value drops onto your own mux, or onto the config server, unchanged: ```go mux.Handle("/", mamori.Handler(w, mamori.WithAuth(auth))) ``` ## The Authenticator interface ```go type Authenticator interface { Authenticate(r *http.Request) (Identity, error) } ``` A `nil` error allows the request; any other error denies it. On success `Authenticate` returns an `Identity`: ```go type Identity struct { Subject string Attrs map[string][]string } ``` `Subject` is a stable principal name; `Attrs` carries scheme-specific detail (certificate SANs, token claims, a peer uid/gid/pid) and is multi-valued so a scheme can return groups, scopes, or multiple SANs directly. The admin endpoint ignores the `Identity` (it only serves metadata); the [config server](/docs/server/) consumes it, since its `Policy` decides what a caller may see based on it. Two optional pieces complete the interface: - `Challenger` supplies the `WWW-Authenticate` header on a `401`; a scheme that does not implement it produces a bare `401`. - `ErrForbidden` selects the status code: return it from `Authenticate` to produce a `403` (authenticated but not permitted) rather than a `401`. ```go type Challenger interface { Challenge() string } var ErrForbidden = errors.New("mamori: forbidden") ``` ## Wiring it up `WithAuth` is a `HandlerOption`, so it goes wherever `HandlerOption`s go: as an argument to `Handler`, or after the address in `WithAdminHTTP`. ```go auth := mamori.BearerToken(secret.NewString(os.Getenv("ADMIN_TOKEN"))) // Mounted on your own mux: mux.Handle("/", mamori.Handler(w, mamori.WithAuth(auth))) // Or with mamori's self-hosted server: w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP("127.0.0.1:9090", mamori.WithAuth(auth)), ) ``` Applying `WithAuth` more than once panics rather than silently letting the second call win; compose multiple schemes explicitly with `AnyOf` or `AllOf` instead. ## Next - [Auth schemes](/docs/auth/schemes/): BasicAuth, BearerToken, APIKey, MTLS, PeerCred, JWT, AnyOf/AllOf. - [Custom authenticators](/docs/auth/custom/): the `Func` variants, credential rotation, and writing your own. - [HTTP exposure](/docs/observability/admin/) covers the admin endpoint the auth attaches to. - [Config server](/docs/server/) is the second surface, where `Policy` authorizes against the `Identity`. --- > Source: https://mamorigo.dev/docs/auth/schemes # Auth schemes The schemes mamori ships. Each is a `mamori.Authenticator`, so it works unchanged on the admin endpoint and the [config server](/docs/server/). Attach any of them with `WithAuth` (see the [overview](/docs/auth/)). For rotation and writing your own, see [Custom authenticators](/docs/auth/custom/). ## BasicAuth ```go func BasicAuth(user string, pass secret.String) Authenticator ``` ```go auth := mamori.BasicAuth("admin", secret.NewString("hunter2")) ``` Checks HTTP Basic credentials against a fixed user and password. Both the username and the password are compared in constant time. `pass` is a `secret.String`, so it redacts in logs and error values. Implements `Challenger` (`Basic realm="mamori"`). ## BearerToken ```go func BearerToken(token secret.String) Authenticator ``` ```go auth := mamori.BearerToken(secret.NewString(os.Getenv("ADMIN_TOKEN"))) ``` Checks `Authorization: Bearer ` against a fixed token, compared in constant time. The `Bearer ` prefix itself is checked with `strings.HasPrefix`, since it is a fixed, public protocol string. Implements `Challenger` (`Bearer`). ## APIKey ```go func APIKey(header string, key secret.String) Authenticator ``` ```go auth := mamori.APIKey("X-API-Key", secret.NewString(os.Getenv("ADMIN_KEY"))) ``` Checks a named header against a fixed key, compared in constant time. Implements no `Challenger`: an API key is not a scheme a generic HTTP client knows how to answer a `WWW-Authenticate` challenge for, so a failed request gets a bare `401`. ## MTLS ```go func MTLS(opts MTLSOptions) Authenticator type MTLSOptions struct { AllowedCNs []string AllowedDNSNames []string } ``` ```go auth := mamori.MTLS(mamori.MTLSOptions{ AllowedCNs: []string{"admin-client"}, }) ``` Authenticates by the client's already-verified TLS certificate. It requires the server be configured with `tls.RequireAndVerifyClientCert` (via `WithAdminTLS`); `MTLS` only checks which verified identity is allowed, not whether the chain is trustworthy (the Go TLS stack has already done that). `AllowedCNs`/`AllowedDNSNames` are optional allowlists checked against the leaf certificate's `CommonName` and DNS SANs; if both are empty, any verified certificate is accepted. On a non-TLS connection, or a TLS connection with no client certificate, `MTLS` denies every request. ## PeerCred ```go func PeerCred(opts PeerCredOptions) Authenticator type PeerCredOptions struct { UIDs []int GIDs []int } ``` ```go auth := mamori.PeerCred(mamori.PeerCredOptions{ UIDs: []int{1000, 1001}, }) ``` Authenticates a Unix-domain-socket peer by the uid/gid the kernel reports at accept time (`SO_PEERCRED` on Linux, `LOCAL_PEERCRED` via `GetsockoptXucred` on Darwin), never anything the client presents. Because the identity comes from the kernel, it cannot be spoofed by a client that can merely connect to the socket. `UIDs`/`GIDs` are optional allowlists, ORed together (a peer is permitted if its uid is in `UIDs` or its gid is in `GIDs`). If both are empty, any peer whose credentials were read is permitted. On success, `Identity.Subject` is `"uid:"` and `Attrs` carries `"uid"`, `"gid"`, and `"pid"` (Darwin's `Xucred` carries no pid, so `Attrs["pid"]` is always `["0"]` there). `PeerCred` requires the [config server](/docs/server/)'s `Unix(...)` transport. `WithAdminHTTP` does not support it: it only listens on TCP, where there is no Unix-socket peer to read credentials from. It also denies outright when no peer credentials are available, and on any platform other than Linux or Darwin. ## JWT (x/authjwt) JWT support ships as a separate module, `github.com/xavidop/mamori/x/authjwt`, since it depends on `github.com/golang-jwt/jwt/v5` and core takes no non-stdlib dependencies. The returned value is a `mamori.Authenticator` like any other. ```go import "github.com/xavidop/mamori/x/authjwt" auth, err := authjwt.New(authjwt.Config{ Key: authjwt.HMAC(secretBytes), Issuer: "https://issuer.example.com/", Audiences: []string{"mamori-admin"}, Claims: []string{"groups", "scope"}, }) if err != nil { log.Fatal(err) } w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP("127.0.0.1:9090", mamori.WithAuth(auth)), ) ``` ```sh go get github.com/xavidop/mamori/x/authjwt ``` Key material comes from exactly one of `Key` or `Keyfunc`. `Key` is the normal path: each helper supplies both the key and the algorithms it is valid for, so the two can never drift apart. ```go func HMAC(secret []byte) KeyOption // HS256, HS384, HS512 func RSAPublicKey(key *rsa.PublicKey) KeyOption // RS256/384/512, PS256/384/512 func ECDSAPublicKey(key *ecdsa.PublicKey) KeyOption // ES256, ES384, ES512 func EdDSAPublicKey(key ed25519.PublicKey) KeyOption // EdDSA ``` `Keyfunc` is the escape hatch (most commonly a JWKS endpoint that picks a key by the token's `kid`). Because a raw `Keyfunc` can return key material for any algorithm, `Algorithms` must also be set explicitly; leaving it empty is a `Config` error, not a permissive default. `Issuer` (when set) must match the token's `iss` exactly; `Audiences` (when non-empty) requires `aud` to contain at least one listed value. `SubjectClaim` (default `"sub"`) names the claim copied into `Identity.Subject`. `Claims` names claims copied into `Identity.Attrs`; the `scope`/`scp` claims are split on spaces even when string-valued. `Realm`, if set, appears in the challenge. Security posture, enforced on every request: parsing is restricted with `jwt.WithValidMethods` to exactly the algorithms implied by the key (so `alg: none` and the RSA/HMAC key-confusion attack are rejected); expiration is mandatory (`jwt.WithExpirationRequired` rejects an expired token and one with no `exp`); issuer/audience are validated when configured; and the token is read only from the `Authorization` header with a case-insensitive `Bearer ` prefix. A missing, malformed, expired, or invalid token is a `401`, never `ErrForbidden`. The authenticator implements `Challenger`. ## AnyOf and AllOf ```go func AnyOf(as ...Authenticator) Authenticator func AllOf(as ...Authenticator) Authenticator ``` `AnyOf` allows a request if any member allows it, for example a static admin token or mTLS from a mesh sidecar: ```go auth := mamori.AnyOf( mamori.BearerToken(secret.NewString(os.Getenv("ADMIN_TOKEN"))), mamori.MTLS(mamori.MTLSOptions{}), ) ``` Every member is evaluated on every request, even after one succeeds or fails, so total work never depends on which member matched (no timing oracle). If any member implements `Challenger`, the first such member in argument order determines `AnyOf`'s challenge. `AllOf` allows a request only if every member allows it, for example a bearer token and an mTLS-verified identity: ```go auth := mamori.AllOf( mamori.BearerToken(secret.NewString(os.Getenv("ADMIN_TOKEN"))), mamori.MTLS(mamori.MTLSOptions{AllowedCNs: []string{"mesh-sidecar"}}), ) ``` The first denial fails the whole check and later members are skipped. The `Identity` of the first member is returned; by convention that first member is the primary authenticator and later members perform supplementary checks. --- > Source: https://mamorigo.dev/docs/providers/aws # AWS Secrets Manager and SSM Parameter Store, built on `aws-sdk-go-v2`. | | | | --- | --- | | Schemes | `aws-sm://` `aws-ps://` | | Module | `github.com/xavidop/mamori/providers/aws` | | Sensitive | Secrets Manager: yes · Parameter Store: SecureString only | | Watch | poll | | Auth | default AWS credential chain (`AWS_REGION`, env, shared config, IAM role) | ## Install ```bash go get github.com/xavidop/mamori/providers/aws ``` ```go import _ "github.com/xavidop/mamori/providers/aws" // registers aws-sm:// and aws-ps:// ``` ## Using the ref An `aws-sm://` ref points at one secret in AWS Secrets Manager; an `aws-ps://` ref points at one parameter in SSM Parameter Store. ```text aws-sm://[#json-key] aws-ps://[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Secrets Manager secret name or ARN. | | `` | yes | The Parameter Store name, including its leading slash, e.g. `/myapp/log-level`. | | `#json-key` | no | Select one field from a JSON secret/parameter payload (via `mamori.SelectKey`). | **Examples** - `aws-sm://prod/api-key` returns the whole secret string - use it for an opaque token. - `aws-sm://prod/db#password` returns just the `password` field of a JSON secret. - `aws-ps:///myapp/log-level` reads the `/myapp/log-level` parameter (note the extra slash: the `aws-ps://` scheme plus the `/myapp/...` name). - `aws-ps:///myapp/db#password` selects `password` from a JSON parameter. ```go type Config struct { APIKey secret.String `source:"aws-sm://prod/api-key"` // whole secret string DBPassword secret.String `source:"aws-sm://prod/db#password"` // one key of a JSON secret LogLevel string `source:"aws-ps:///myapp/log-level"` // SecureString is marked sensitive } ``` Secrets Manager values are always `Sensitive`; Parameter Store reads with `WithDecryption=true` and marks only `SecureString` parameters `Sensitive`. `Value.Version` is the secret's `VersionId` or the parameter's numeric `Version`. Secrets Manager implements `BatchProvider`, so multiple `aws-sm://` refs resolve in one `BatchGetSecretValue` call. ## Explicit configuration ```go import awsprov "github.com/xavidop/mamori/providers/aws" mamori.WithProvider(awsprov.NewSecretsManager(awsprov.WithRegion("eu-west-1"))) mamori.WithProvider(awsprov.NewParameterStore(awsprov.WithRegion("eu-west-1"))) ``` ## Watch Neither backend has native change notification, so mamori polls (`WithPollInterval` + jitter, `Value.Version` comparison). For push-based rotation you can pair this with an EventBridge -> SQS trigger in your app and call `Load` on demand. ## Error classification Failures are classified so `mamori.ErrorKind` can distinguish them: | AWS error code | mamori kind | |---|---| | `ResourceNotFoundException`, `ParameterNotFound`, `ParameterVersionNotFound` | `not_found` | | `AccessDeniedException` | `permission_denied` | | `UnrecognizedClientException`, `ExpiredTokenException`, `InvalidSignatureException`, `MissingAuthenticationToken`, `IncompleteSignature` | `unauthenticated` | | `ThrottlingException`, `Throttling`, `TooManyRequestsException`, `RequestLimitExceeded` | `rate_limited` | | `InternalServiceError`, `InternalServerError`, `InternalFailure`, `ServiceUnavailable`, `ServiceUnavailableException` | `unavailable` | | `InvalidParameterException`, `InvalidRequestException`, `ValidationException`, `InvalidParameterValue`, `InvalidKeyId` | `invalid` | | anything else | `unknown` | Codes not listed above report `unknown` rather than being guessed at. Notably, Secrets Manager's `DecryptionFailure` is deliberately left unmapped: it can mean a KMS key policy problem, a disabled key, or a KMS outage, and doesn't map cleanly to one kind. The original SDK error stays reachable with `errors.As`. Verified by unit tests and the `providertest` conformance kit against in-memory fakes; live AWS behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/azblob # Azure Blob Storage Fetch a config or secret blob from Azure Blob Storage. | | | | --- | --- | | Scheme | `azblob://` | | Module | `github.com/xavidop/mamori/providers/azblob` | | Sensitive | no (opt-in with `WithSensitive`) | | Watch | poll (ETag) | | Auth | `DefaultAzureCredential` (+ account URL) | ## Install ```bash go get github.com/xavidop/mamori/providers/azblob ``` ```go import _ "github.com/xavidop/mamori/providers/azblob" ``` ## Using the ref An `azblob://` ref points at one blob in a container. The storage account is provider configuration, not part of the ref. ```text azblob:///[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The blob container name. | | `` | yes | The blob name. It may contain slashes (a virtual-directory path), e.g. `config/app.json`. | | `#json-key` | no | Treat the blob as a JSON object and return one field of it. | **Examples** - `azblob://config/app.json` fetches the whole blob from the `config` container - decode it with `flatten:"json"`. - `azblob://config/app.json#database` returns just the `database` field of that JSON object. - `azblob://secrets/tls/app.pem` fetches a nested blob (the blob name carries slashes). ```go type Config struct { AppConfig AppConfig `source:"azblob://config/app.json" flatten:"json"` } mamori.WithProvider(azblobprov.New( azblobprov.WithAccountURL("https://myaccount.blob.core.windows.net"), )) ``` The storage account is provider-level configuration (set it with `WithAccountURL` / `WithServiceURL`, or the `AZURE_STORAGE_ACCOUNT` environment variable), so the same ref can resolve against different accounts across environments. The blob name may contain slashes, so `config/app.json` is a single name. `Value.Version` is the blob ETag (or version id when versioning is enabled), so change detection is cheap. Blobs are not marked sensitive by default; pass `WithSensitive(true)` for accounts that hold secrets. ## Watch mamori polls (`WithPollInterval` + jitter) using the ETag. For push, Azure Event Grid blob events can trigger an on-demand reload. ## Configuration Authentication uses `DefaultAzureCredential` (environment, managed identity, Azure CLI). Verified with an in-memory fake; live behavior is covered by `//go:build integration` tests. ## Error classification | HTTP status | mamori kind | |---|---| | 404 | `not_found` | | 403 | `permission_denied` | | 401 | `unauthenticated` | | 429 | `rate_limited` | | 5xx | `unavailable` | | 400 | `invalid` | | anything else | `unknown` | A transport failure (no HTTP response at all) stays `unknown`, since it could be a bug in this provider rather than a genuine backend outage. `*azcore.ResponseError` stays reachable with `errors.As`, both through the not-found path (typed `bloberror` codes and a raw 404) and the classified fallback path. Verified by unit tests and the conformance kit against an in-memory fake; live Azure behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/azure # Azure Azure Key Vault, built on the `azsecrets` SDK. | | | | --- | --- | | Scheme | `azure-kv://` | | Module | `github.com/xavidop/mamori/providers/azure` | | Sensitive | yes | | Watch | poll | | Auth | `DefaultAzureCredential` | ## Install ```bash go get github.com/xavidop/mamori/providers/azure ``` ```go import _ "github.com/xavidop/mamori/providers/azure" ``` ## Using the ref An `azure-kv://` ref points at one secret in an Azure Key Vault, at a specific (or the latest) version. ```text azure-kv:///[#json-key][?version=] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Key Vault name. The URL is built as `https://.vault.azure.net`. | | `` | yes | The secret name within that vault. | | `#json-key` | no | Select one field from a JSON secret payload (via `mamori.SelectKey`). | | `?version=` | no | Pin a specific secret version id. Empty resolves the latest. | **Examples** - `azure-kv://my-vault/db-password` reads the latest version of `db-password`. - `azure-kv://my-vault/api-key?version=abc123` pins the `abc123` version. - `azure-kv://my-vault/creds#password` selects the `password` field of a JSON secret. ```go type Config struct { DBPassword secret.String `source:"azure-kv://my-vault/db-password"` APIKey secret.String `source:"azure-kv://my-vault/api-key?version=abc123"` Nested secret.String `source:"azure-kv://my-vault/creds#password"` } ``` Values are always `Sensitive`, and `Value.Version` is the secret's version id (a content hash if unavailable). ## Explicit configuration Authentication uses `DefaultAzureCredential` (environment, managed identity, Azure CLI, ...). Inject a credential or client explicitly for tests or non-default auth: ```go import azureprov "github.com/xavidop/mamori/providers/azure" mamori.WithProvider(azureprov.New(azureprov.WithCredential(myCred))) ``` ## Watch Key Vault has no native change notification, so mamori polls (`WithPollInterval` + jitter). ## Error classification | HTTP status | mamori kind | |---|---| | 404 | `not_found` | | 403 | `permission_denied` | | 401 | `unauthenticated` | | 429 | `rate_limited` | | 5xx | `unavailable` | | 400 | `invalid` | | anything else | `unknown` | A transport failure (no HTTP response at all) stays `unknown`, since it could be a client problem rather than a backend one. `*azcore.ResponseError` stays reachable with `errors.As`. Verified by unit tests and the conformance kit against an in-memory fake; live Azure behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/cli # CLI `mamori` (import path `github.com/xavidop/mamori/cmd/mamori`) is a standalone CLI built around the same `source:` tag conventions and `Report` shape the library uses. It has two halves that never mix: - **Static commands** ([`explain`](/docs/cli/explain/), [`schema`](/docs/cli/schema/), [`policy`](/docs/cli/policy/), [`vet`](/docs/cli/vet/)) read your Go source and never resolve anything. - **Live commands** ([`doctor` and `status`](/docs/cli/doctor-status/)) query a running process's admin endpoint and set an exit code. ```mermaid flowchart TD CLI[mamori CLI] CLI --> S["Static: explain, schema, policy, vet"] CLI --> L["Live: doctor, status"] S -->|"read Go source, never resolve"| Src[("your config structs")] L -->|"GET / on the admin endpoint"| Proc[("a running process")] ``` ## Install Both halves are the same binary. Homebrew tracks tagged releases; `go install` builds from source at whatever ref you name. ```bash brew install xavidop/tap/mamori ``` ```bash go install github.com/xavidop/mamori/cmd/mamori@latest ``` ## Getting help Every command prints its own usage, including the flags it accepts and, for the sensitivity-aware ones, the built-in secret-bearing schemes: ```bash mamori --help # the command list mamori vet --help # one command's flags (also -h) ``` Help goes to stdout and exits `0`, so it pipes to a pager cleanly and a script checking the exit code does not read it as a failure. Errors are the opposite: the message and usage go to stderr with a non-zero exit. ## Quick start Static commands take a Go package pattern (`./...` for a whole module, a package path, or nothing for the current directory). Read what a config struct declares: ```bash $ mamori explain ./... --type=Config main.Config FIELD TYPE CHAIN DEFAULT OPTIONAL SENSITIVE Redis.Addr string env://REDIS_ADDR - false false Redis.Password secret.String aws-sm://prod/redis-pw - false true ``` Live commands query a running process over its admin endpoint and set an exit code you can branch on: ```bash $ mamori doctor --endpoint https://svc.internal:9090 HEALTHY: 2 field(s), snapshot 3 (live 3), generated 2026-07-26T10:00:00Z $ echo $? 0 ``` ## Next - [`mamori explain`](/docs/cli/explain/) - list every config struct and its `source:` refs. - [`mamori schema`](/docs/cli/schema/) - emit a JSON Schema for a config struct. - [`mamori policy`](/docs/cli/policy/) - emit a least-privilege access artifact. - [`mamori vet`](/docs/cli/vet/) - flag secret-bearing sources stored in plain `string`/`[]byte`. - [`mamori doctor` and `status`](/docs/cli/doctor-status/) - check a running process, with exit codes. ## See also [Observability](/docs/observability/) covers the `Report`/`FieldStatus` shape the live commands render. [Config server](/docs/server/) shares the same endpoint forms and `Authenticator` types. [Concepts](/docs/concepts/) covers source chains, which `explain`'s `CHAIN` column and `policy`'s ref extraction read. --- > Source: https://mamorigo.dev/docs/concepts # Concepts You annotate config struct fields with `source:` tags. mamori parses each tag into a **ref** (or a precedence chain of refs), a **provider** resolves each ref to a `Value`, and a **reconciler** applies validated results as monotonically versioned snapshots you can pin. ## The mental model - **Ref** - a parsed pointer to a value in a provider, produced from a `source` tag by `ParseRef`. See the grammar below. - **Provider** - resolves a ref to a `Value` (bytes plus metadata). One provider per scheme (`aws-sm`, `vault`, `env`, `file`, ...). - **Reconciler** - the goroutine behind every `Watcher[T]`. It watches every ref, decodes and validates the result, and publishes a new snapshot on each accepted update. `Get`, `Status`, `Pin`, and `OnChange` all read what it publishes. ## The source ref grammar A ref is produced from a `source` tag by `ParseRef`. The grammar is: ```text ://[#][?=&...] ``` Opaque schemes such as `env:` and `exec:` take everything after the colon as the path (no `//`): ```go type Config struct { // whole secret string APIKey secret.String `source:"aws-sm://prod/api-key"` // one key of a JSON secret DBPassword secret.String `source:"aws-sm://prod/db#password"` // provider option Leased secret.String `source:"vault://kv/data/api#key?renew=true"` // opaque scheme LogLevel string `source:"env:LOG_LEVEL"` // absolute file path Cert []byte `source:"file:///etc/tls/tls.crt"` } ``` `ParseRef` produces a `Ref{Scheme, Path, Key, Opts, Raw}`. `#key` selects one field from a structured (JSON) payload; `?opts` are provider-specific plus a small set of core-recognized options (`debounce`, `optional`, `version`). ### Supplementary tags Other struct tags refine how a field resolves: | Tag | Meaning | | --- | --- | | `default:"..."` | Value used when the ref resolves to not-found (never on error). | | `validate:"..."` | Field validation (go-playground/validator syntax), evaluated on **every** update. See [Validation](/docs/validation/). | | `flatten:"json\|yaml\|env"` | Decode a single provider payload into a nested struct. | | `optional:"true"` | Not-found is tolerated with no default (field keeps its zero value). | | `onfail:"keeplast\|default\|fail"` | Policy for a chain error, not absence. Default `keeplast`. See [Source chains and precedence](/docs/concepts/source-chains/#the-onfail-policy). | | `?debounce=` | Per-field coalescing window override, e.g. `?debounce=0` for certs. | A struct field with a `source` and `flatten` decodes one payload into the sub-struct; a struct field with no `source` is a container mamori recurses into: ```go type Config struct { Redis RedisConfig `source:"aws-sm://prod/redis" flatten:"json"` } type RedisConfig struct { Addr string `mapstructure:"addr"` Password secret.String `mapstructure:"password"` DB int `mapstructure:"db"` } ``` ## The Value type Providers return a `Value`, not raw bytes. This is the keystone for change detection and lease-aware refresh: ```go type Value struct { Bytes []byte Version string // provider revision: SM VersionId, Vault version, file mtime hash Sensitive bool // drives redaction downstream NotAfter time.Time // zero if unknown; e.g. a Vault lease expiry schedules a refresh Metadata map[string]string } ``` `Version` gives cheap change detection (no byte comparison when the provider supplies a revision). `NotAfter` lets lease-based providers request a refresh *before* expiry rather than waiting for the next poll tick. ## Next - [Source chains and precedence](/docs/concepts/source-chains/) - multiple refs per field, `onfail`, and the comma-split rule. - [Snapshots and pinning](/docs/usage/snapshots/) - snapshot versions, `WithHistory`, and pinning. - [Error kinds](/docs/concepts/error-kinds/) - the typed `Kind` values and their sentinels. - [Secret types](/docs/concepts/secret-types/) - `secret.String` / `secret.Bytes`, redaction, `Reveal`. ## See also - [Loading and watching](/docs/usage/) - the how-to for chains, snapshots, and pinning. - [Middleware](/docs/middleware/) - per-provider decorators such as `middleware.Failover`. --- > Source: https://mamorigo.dev/docs/server # Config server `server` (import path `github.com/xavidop/mamori/server`) is a standalone process that fronts a fixed, operator-declared table of name-to-ref bindings and serves the resolved values to authenticated, authorized callers over a small v1 HTTP wire protocol (Unix socket and TLS TCP). Run it as a sidecar or a fan-out: one process holds the backend credentials and watches each upstream once, everyone else asks it for values by name. That concentration is also its main cost, so read [Blast radius](#blast-radius) before you deploy one. ```mermaid flowchart LR AWS[AWS Secrets Manager] --> S Vault[Vault] --> S GCP[GCP Secret Manager] --> S S["mamori config server (holds every credential, one watch per binding)"] S -->|"mamori:// over UDS or TLS"| A[Service A] S -->|mamori://| B[Service B] S -->|mamori://| C[Service C] ``` ## Quick start Stand up a server that fronts two bindings over a `0600` Unix socket, gated by a bearer token and a per-subject policy: ```go package main import ( "context" "log" "os" "github.com/xavidop/mamori" "github.com/xavidop/mamori/secret" "github.com/xavidop/mamori/server" ) func main() { ctx := context.Background() srv, err := server.New( // Operator-declared bindings only: a client requests a name, never a ref. server.Bind("db-password", "vault://secret/data/db#password"), server.Bind("api-key", "aws-sm://prod/api-key"), // Providers resolve the ref schemes above (no registry fallback here). server.WithProvider(vaultProvider), server.WithProvider(awsProvider), // Authorization and authentication are both mandatory. server.WithPolicy(server.PrefixPolicy(map[string][]string{ "svc-orders": {"db-password"}, })), server.WithAuth(mamori.BearerToken(secret.NewString(os.Getenv("SERVER_TOKEN")))), // A Unix socket, owner read/write only. server.Unix("/run/mamori/server.sock", 0600), ) if err != nil { log.Fatal(err) } defer srv.Close() if err := srv.Serve(ctx); err != nil { log.Fatal(err) } } ``` `New` starts nothing: it validates every security invariant (a `Policy` is configured, auth is configured or `NoAuth()` is explicit, `NoAuth()` is never combined with TCP, every binding parses and passes its scheme gate) and returns a `*Server`. `Serve(ctx)` begins upstream watching, binds every transport, and blocks until a listener fails or the server is closed. `Close()` is idempotent and safe even if `Serve` never ran, so defer it unconditionally right after `New`. A client resolves a binding through the `mamori://` provider ([mamoriprov](/docs/providers/mamori/)): ```go type Config struct { DBPassword secret.String `source:"mamori://db-password"` } cfg, err := mamori.Load[Config](ctx, mamori.WithProvider(mamoriprov.New( mamoriprov.Config{Endpoint: "unix:///run/mamori/server.sock"}, mamoriprov.WithHeader("Authorization", "Bearer "+os.Getenv("SERVER_TOKEN")), )), ) ``` Or read it straight off the wire: ```sh curl --unix-socket /run/mamori/server.sock \ -H "Authorization: Bearer $SERVER_TOKEN" \ http://unix/v1/values/db-password ``` ## Blast radius **A config server is deliberately the highest-blast-radius piece of mamori.** A single `Load` or `Watch` caller holds only the credentials it was given; a config server, by design, concentrates every backend credential its bindings touch into one process, reachable by every consumer it serves. Compromising it is strictly worse than compromising any one consumer. The mitigations are structural, not conventions an operator has to remember: - **No client-supplied refs.** A client can only name a binding, never supply a ref, so `file:///etc/shadow` and arbitrary `exec:` commands are unreachable by construction. See [Server bindings](/docs/server/bindings/). - **Mandatory `Policy`.** `New` will not construct a `Server` with no authorization policy, not even a deny-all default. See [Server auth and policy](/docs/server/authorization/). - **Mandatory authentication over the network.** `NoAuth()` is refused with a TCP listener; the only unauthenticated posture is Unix-socket-only. - **Mandatory TLS over TCP.** Plaintext TCP requires the deliberately uncomfortable, greppable `InsecureNoTLS()`. See [Deploy and expose](/docs/server/transports/). - **No values in the audit trail.** Structurally impossible, not merely discouraged. These mitigations narrow how the concentration can be abused, not the concentration itself. A trusted sidecar reading a handful of bindings over a `0600` Unix socket is a very different risk from a network-reachable server fronting every credential a fleet needs. Treat the config server's own host and process the way you would treat a secrets-manager credential itself. ## Next - [Server bindings](/docs/server/bindings/) - `Bind`/`BindFile`, the operator-declared-only model, and the `exec:`/`mamori:` gates. - [Deploy and expose](/docs/server/transports/) - Unix sockets, TLS TCP, running both, and the audit log. - [Server auth and policy](/docs/server/authorization/) - `WithAuth`, the shipped schemes, and the `Policy` model. - [High availability](/docs/server/ha/) - running several replicas: readiness gating, draining, freshness, and client failover. - [Server wire protocol](/docs/server/wire-protocol/) - v1 routes, response shapes, the fresh-vs-stale `kind`, watch (SSE), and healthz. ## See also [Auth](/docs/auth/) covers every shipped `Authenticator`, including `PeerCred`. [Providers: mamori](/docs/providers/mamori/) is the `mamori://` client that resolves bindings against this server. [Security](/docs/security/) covers the blast-radius point in the context of both HTTP surfaces. [Observability](/docs/observability/) covers `WithAdminHTTP`, the metadata-only sibling that answers "is my config healthy" rather than "give me a value." --- > Source: https://mamorigo.dev/docs/providers/configcat # ConfigCat Load a [ConfigCat](https://configcat.com) setting's value as config. | | | | --- | --- | | Scheme | `configcat://` | | Module | `github.com/xavidop/mamori/providers/configcat` | | Sensitive | no | | Watch | poll | | Auth | `CONFIGCAT_SDK_KEY` | ## Install ```bash go get github.com/xavidop/mamori/providers/configcat ``` ```go import _ "github.com/xavidop/mamori/providers/configcat" ``` ## Using the ref A `configcat://` ref points at one setting key. It resolves to that setting's evaluated value. ```text configcat:// ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The ConfigCat setting key. | The value maps to bytes by type: a boolean setting resolves to `true` / `false`, a string to its string, a number to its decimal form. A key not present in the config resolves to not-found (mamori does not silently return the SDK default for a missing key). **Examples** - `configcat://isAwesomeFeatureEnabled` resolves to `true` / `false` - pair it with a `bool` field. - `configcat://maxUploadSizeMB` resolves to e.g. `50` - pair it with an `int` field. ## Watch The ConfigCat SDK auto-polls its config; mamori polls (`WithPollInterval` + jitter) on top. ## Error classification ConfigCat's client surface returns a bare value with no error, so this provider has no error vocabulary beyond not-found. It is conformance-exempt from the `ErrorClassification` case via `providertest.Config.NoResolveErrors`. ## Configuration ```go import configcatprov "github.com/xavidop/mamori/providers/configcat" mamori.WithProvider(configcatprov.New(configcatprov.WithSDKKey(os.Getenv("CONFIGCAT_SDK_KEY")))) ``` Verified with an injected fake (un-seeded keys resolve to not-found); live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/writing-a-provider/conformance # Conformance `github.com/xavidop/mamori/providertest` runs one function that exercises resolution, not-found typing, error classification, `Version` monotonicity, concurrency, context cancellation, native watch, goroutine hygiene (goleak), and a no-payload-logging assertion. Every provider must pass it. ## Run the conformance case Inject a client interface so the kit and your unit tests run against an in-memory fake, with live-backend tests behind a `//go:build integration` tag: ```go func TestConformance(t *testing.T) { backend := newInMemoryFake() providertest.Run(t, providertest.Config{ New: func() mamori.Provider { return myprovider.New(myprovider.WithClient(backend)) }, Ref: func(key string) string { return "myscheme://" + key }, Seed: func(ctx context.Context, key, val string) error { return backend.set(key, val) }, Mutate: func(ctx context.Context, key, val string) error { return backend.set(key, val) }, Fail: func(ctx context.Context, key string, err error) error { return backend.fail(key, err) }, Clear: func(ctx context.Context, key string) error { return backend.clear(key) }, }) } ``` `Fail` and `Clear` are **required**: `Fail` makes the fake return a given error for a key, `Clear` cancels it. They power the `ErrorClassification` case, which injects a mamori sentinel through your fake and checks it returns from `Resolve` with its `errors.Is` chain intact (catching the `%v`-instead-of-`%w` bug). A provider supplying neither `Fail` nor `NoResolveErrors: true` fails `providertest.Run` outright. Set `NoResolveErrors: true` (with a comment naming why) only if your backend has no per-key error surface. Live integration tests also set it, since the unit-test run already covers classification against the fake. Classification is guarded twice: the conformance case cannot prove your SDK mapping exists (no fake produces real SDK errors), so add a table test over real SDK errors too. `KindUnknown` is always a legal outcome. ## Build and test the module Build and test each module independently with the workspace disabled, exactly as CI does: ```bash cd providers/ GOWORK=off go mod tidy GOWORK=off go build ./... GOWORK=off go vet ./... GOWORK=off go test ./... ``` Or from the repo root, `make test` / `make lint` run every module. ## Acceptance checklist - [ ] `Scheme()` returns your scheme; `Resolve` returns `mamori.ErrNotFound` (via `errors.Is`) for missing values. - [ ] Other failures map to a sentinel (`ErrPermissionDenied`, `ErrUnauthenticated`, `ErrUnavailable`, `ErrRateLimited`, `ErrInvalid`) with `%w`. Cover it two ways: `Fail`/`Clear` in `providertest.Config` (required unless `NoResolveErrors: true` with a reason), AND a table test mapping real SDK errors to kinds. - [ ] `Value.Version` is set and changes when the value changes; secret-bearing values set `Sensitive: true`. - [ ] `#key` uses `mamori.SelectKey`; the payload is never logged. - [ ] `Watch` is implemented only for native-push backends, closes on `ctx` cancel, and leaks no goroutines. - [ ] A client interface is injected so `providertest.Run` passes against a fake; live tests are behind `//go:build integration`. - [ ] `go build`, `go vet`, and `go test` are clean with `GOWORK=off`; the README documents scheme, ref grammar, and auth. - [ ] The module's `README.md` has an `## Error classification` section, mirrored onto its docs-site page. - [ ] The module's row is flipped in both coverage tables: root `README.md` and `site/src/pages/docs/providers/index.md`. Error classification lives in three places that drift if edited alone (module `README.md`, its docs-site page, and the two coverage tables); update them together. See also: [Resolve and errors](/docs/writing-a-provider/resolve/), [Testing](/docs/testing/). --- > Source: https://mamorigo.dev/docs/providers/consul # Consul Consul KV, with **native watch** via blocking queries, built on `hashicorp/consul/api`. | | | | --- | --- | | Scheme | `consul://` | | Module | `github.com/xavidop/mamori/providers/consul` | | Sensitive | no | | Watch | native (blocking queries) | | Auth | `CONSUL_HTTP_ADDR`, `CONSUL_HTTP_TOKEN` | ## Install ```bash go get github.com/xavidop/mamori/providers/consul ``` ```go import _ "github.com/xavidop/mamori/providers/consul" ``` ## Using the ref A `consul://` ref points at one key in the Consul KV store, optionally selecting a field from a JSON value stored there. ```text consul://[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Consul KV key path, e.g. `config/service/endpoint`. No leading slash. | | `#json-key` | no | When the stored value is a JSON object, return one field from it (via `mamori.SelectKey`). | **Examples** - `consul://config/service/endpoint` - reads the raw value stored at key `config/service/endpoint`. - `consul://config/service/db#max_conns` - reads the JSON object at `config/service/db` and returns its `max_conns` field. - `consul://features/flags#dark_mode` - returns the `dark_mode` field of the JSON at `features/flags`. ```go type Config struct { Endpoint string `source:"consul://config/service/endpoint"` // a JSON value stored at one key, then a field selected from it MaxConns int `source:"consul://config/service/db#max_conns"` } ``` `Value.Version` is the KV pair's `ModifyIndex`, Consul's native revision, so change detection is exact and cheap. Consul KV holds configuration rather than managed secrets, so values are non-sensitive; wrap a field in `secret.String` if you want redaction anyway. ## Watch `Watch` uses Consul **blocking queries**: it re-issues `KV.Get` with `WaitIndex` set to the last `ModifyIndex`, so the request parks on the server until the value changes (or a wait timeout elapses), then emits an `Update`. It handles index resets, backs off on transient errors, and closes on context cancellation. ## Error classification A missing key is not an SDK error: `KV.Get` returns a nil pair with a nil error for a 404, and the provider maps that directly to `mamori.ErrNotFound` - there is no not-found row below because the classifier never sees that case. Every other non-2xx response surfaces as an `api.StatusError{Code, Body}`, classified by HTTP status: | HTTP status | mamori kind | |---|---| | 403 | `permission_denied` | | 401 | `unauthenticated` | | 429 | `rate_limited` | | 5xx | `unavailable` | | 400 | `invalid` | | anything else | `unknown` | 403 (ACL token denied) is the confirmed common case for this endpoint. 401 and 429 are mapped on ordinary HTTP semantics rather than confirmed Consul KV behavior - whether they actually occur depends on cluster ACL/rate-limit configuration. The original `api.StatusError` stays reachable with `errors.As`. ## Explicit configuration ```go import consulprov "github.com/xavidop/mamori/providers/consul" mamori.WithProvider(consulprov.New( consulprov.WithAddress("consul.internal:8500"), consulprov.WithToken(os.Getenv("CONSUL_HTTP_TOKEN")), )) ``` Verified by unit tests and the conformance kit against an in-memory fake that reproduces blocking-query semantics, so the watch checks run for real. A live-Consul integration test is provided behind `//go:build integration`. --- > Source: https://mamorigo.dev/docs/providers/cosmos # Azure Cosmos DB Read a value from an Azure Cosmos DB document (SQL / Core API). | | | | --- | --- | | Scheme | `cosmos://` | | Module | `github.com/xavidop/mamori/providers/cosmos` | | Sensitive | no (opt-in with `WithSensitive`) | | Watch | poll (ETag) | | Auth | `DefaultAzureCredential` + endpoint, or a connection string | ## Install ```bash go get github.com/xavidop/mamori/providers/cosmos ``` ```go import _ "github.com/xavidop/mamori/providers/cosmos" ``` ## Using the ref A `cosmos://` ref points at one item (document) in a container, read by its id and partition key. ```text cosmos:////[#field][?pk=] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Cosmos database name. | | `` | yes | The container within that database. | | `` | yes | The item's `id`. | | `#field` | no | Return one field of the document instead of the whole thing (JSON selection). | | `?pk=` | no | The partition-key value. Defaults to `` (common when the id is the partition key). | **Examples** - `cosmos://appdb/config/service-a` reads the document with id `service-a` (partition key `service-a`) and returns it as JSON. - `cosmos://appdb/config/service-a#endpoint` returns just the `endpoint` field of that document. - `cosmos://appdb/tenants/acme?pk=us-east` reads id `acme` in the `us-east` logical partition. `Value.Version` is the document's `_etag`, so change detection is cheap. Wrap a field in `secret.String` (or set `WithSensitive`) if the document holds credentials. ## Watch Cosmos DB's change feed is pull-based, so mamori polls (`WithPollInterval` + jitter) using the ETag. The change feed can drive an on-demand reload in your app for push. ## Configuration ```go import cosmosprov "github.com/xavidop/mamori/providers/cosmos" // AAD credential + account endpoint mamori.WithProvider(cosmosprov.New(cosmosprov.WithEndpoint("https://myacct.documents.azure.com:443/"))) // or a connection string mamori.WithProvider(cosmosprov.New(cosmosprov.WithConnectionString(os.Getenv("COSMOS_CONNECTION_STRING")))) ``` Verified with an in-memory fake; live behavior is covered by `//go:build integration` tests. ## Error classification | HTTP status | mamori kind | |---|---| | 404 | `not_found` | | 403 | `permission_denied` | | 401 | `unauthenticated` | | 429 | `rate_limited` | | 5xx | `unavailable` | | 400 | `invalid` | | anything else | `unknown` | Cosmos DB returns **429 for request-unit (RU/s) throttling** - its most common operational failure. It maps to `rate_limited` like any other 429, but if you see `rate_limited` on a `cosmos://` ref, check the container's/database's provisioned RU/s first; it is very unlikely to be a client-side rate limiter. A transport failure (no HTTP response at all) stays `unknown`, since it could be a bug in this provider rather than a genuine backend outage. `*azcore.ResponseError` stays reachable with `errors.As` through the classified fallback path. The not-found path is a special case: the item reader returns the bare `mamori.ErrNotFound` sentinel for a 404 (an internal signal, not the SDK error itself), so there is no `*azcore.ResponseError` to recover there. Verified by unit tests and the conformance kit against an in-memory fake; live Azure behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/auth/custom # Custom authenticators Rotate a shipped scheme's credential live with the `Func` variants, or implement the `Authenticator` interface yourself. For the interface and the shipped schemes, see the [overview](/docs/auth/) and [Auth schemes](/docs/auth/schemes/). ## The Func variants and credential rotation ```go func BasicAuthFunc(fn func() (string, secret.String)) Authenticator func BearerTokenFunc(fn func() secret.String) Authenticator func APIKeyFunc(header string, fn func() secret.String) Authenticator ``` Each `Func` variant reads the expected credential on every request instead of freezing it at construction. That is what makes it possible to rotate the admin credential live: read it from a mamori-managed config instead of a value fixed at startup. A typical shape is a small config watched independently, with a closure that reads its current snapshot: ```go type AdminConfig struct { AdminToken secret.String `source:"aws-sm://prod/admin#token"` } adminW, err := mamori.Watch[AdminConfig](ctx) if err != nil { log.Fatal(err) } defer adminW.Close() auth := mamori.BearerTokenFunc(func() secret.String { return adminW.Get().AdminToken }) w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP("127.0.0.1:9090", mamori.WithAuth(auth)), ) ``` When the secret manager rotates `prod/admin#token`, `adminW` picks it up on its own reconcile loop (see [Loading and watching](/docs/usage/)), and the very next request is checked against the new value: no restart, and no gap where the endpoint accepts no credential. Until the first successful resolve, `AdminToken` is a zero `secret.String`, and the fail-closed rule below denies every request. ## Constant-time comparison and fail-closed behavior `BasicAuth`, `BearerToken`, and `APIKey` all compare credential material with `crypto/subtle.ConstantTimeCompare`, never `==` or `bytes.Equal`. A naive comparison short-circuits on the first mismatching byte, which would let an attacker recover a valid credential one byte at a time by timing requests; constant-time comparison always walks the full length of both operands. (`MTLS`'s CommonName/DNS-SAN check is an ordinary string comparison, since those are public identifiers on an already-verified certificate, not secrets.) All three secret-based schemes also fail closed on an unconfigured credential: if the expected value is a zero `secret.String` (`IsZero()` is true), every request is denied outright, before even looking at what the caller presented. "Unset" is never treated as "no credential required". None of the failure messages echo the value the caller presented, only that the credential was missing or invalid, so a misconfigured client cannot leak its guess into logs. ## Writing your own Authenticator Anything satisfying the interface works. The simplest path is `AuthFunc`, for a check that does not need its own type: ```go auth := mamori.AuthFunc(func(r *http.Request) (mamori.Identity, error) { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil || host != "10.0.0.5" { return mamori.Identity{}, errors.New("peer not allowed") } return mamori.Identity{Subject: host}, nil }) ``` Implement `Authenticator` (and optionally `Challenger`) on a named type when the check needs its own state or its own `WWW-Authenticate` value. Whichever shape you pick, keep the two properties the shipped schemes share: compare any caller-supplied secret material with `crypto/subtle.ConstantTimeCompare` rather than `==`, and fail closed (deny the request outright) whenever the expected credential is unset, rather than treating "not configured" as "no credential required". --- > Source: https://mamorigo.dev/docs/server/transports # Deploy and expose A server binds one or more listeners and serves the same v1 wire protocol on all of them. Declare a listener with `Unix` or `TCP`; call either any number of times. `Serve` binds every `Unix` listener before every `TCP` listener, then blocks. ```go func Unix(path string, mode os.FileMode) Option func TCP(addr string, opts ...TCPOption) Option func TLS(cfg *tls.Config) TCPOption func InsecureNoTLS() TCPOption ``` ## Expose over a Unix socket Bind a `0600` socket (owner read/write only). Every connection here has its kernel-verified peer credentials captured, so a [`PeerCred`](/docs/auth/schemes/#peercred) authenticator can identify callers by uid/gid. ```go package main import ( "context" "log" "os" "github.com/xavidop/mamori" "github.com/xavidop/mamori/secret" "github.com/xavidop/mamori/server" ) func main() { srv, err := server.New( server.Bind("db-password", "vault://secret/data/db#password"), server.WithProvider(vaultProvider), server.WithPolicy(server.AllowAll()), server.WithAuth(mamori.BearerToken(secret.NewString(os.Getenv("SERVER_TOKEN")))), server.Unix("/run/mamori/server.sock", 0600), ) if err != nil { log.Fatal(err) } defer srv.Close() if err := srv.Serve(context.Background()); err != nil { log.Fatal(err) } } ``` `Serve` unlinks any stale file at the path before binding and removes it again on `Close`, so a crashed server always restarts cleanly. ## Expose over TLS TCP `TCP` without TLS fails construction. Pass `TLS(cfg)` for the ordinary case: ```go package main import ( "context" "crypto/tls" "log" "os" "github.com/xavidop/mamori" "github.com/xavidop/mamori/secret" "github.com/xavidop/mamori/server" ) func main() { cert, err := tls.LoadX509KeyPair("server.crt", "server.key") if err != nil { log.Fatal(err) } tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}} srv, err := server.New( server.Bind("db-password", "vault://secret/data/db#password"), server.WithProvider(vaultProvider), server.WithPolicy(server.AllowAll()), server.WithAuth(mamori.BearerToken(secret.NewString(os.Getenv("SERVER_TOKEN")))), server.TCP(":8443", server.TLS(tlsConfig)), ) if err != nil { log.Fatal(err) } defer srv.Close() if err := srv.Serve(context.Background()); err != nil { log.Fatal(err) } } ``` `InsecureNoTLS()` is the only way to serve plaintext TCP. Its name is deliberately greppable: `grep -r InsecureNoTLS` finds every place bindings are served without TLS, which should never be an accident. ```go server.TCP(":8080", server.InsecureNoTLS()) // deliberate, greppable opt-out ``` `NoAuth()` is refused on any TCP listener; over TCP, authentication is mandatory. `Addrs() []net.Addr` reports every bound listener's address, useful for discovering the OS-chosen port after binding to `:0`. ## Both at once Call `Unix` and `TCP` together. Both run under the same policy and wire handler; `Serve` binds every listener before accepting on any. ```go srv, err := server.New( server.Bind("db-password", "vault://secret/data/db#password"), server.WithProvider(vaultProvider), server.WithPolicy(server.AllowAll()), server.WithAuth(mamori.BearerToken(secret.NewString(os.Getenv("SERVER_TOKEN")))), server.Unix("/run/mamori/server.sock", 0600), server.TCP(":8443", server.TLS(tlsConfig)), ) ``` ## Audit requests ```go func WithAudit(l *slog.Logger) Option ``` Audit logging is off by default. It is a diagnostic aid, not the enforcement mechanism (`Policy` and `Authenticator` decide access). When configured, one structured record is written per request (per name, for a batch or watch subscription), carrying identity subject, binding name, allow/deny decision, resulting `kind`, HTTP status, and latency. ```go srv, err := server.New( // ... bindings, policy, auth, transport ... server.WithAudit(slog.New(slog.NewJSONHandler(os.Stdout, nil))), ) ``` **The audit record structurally cannot carry a resolved value.** The Go type backing it has no field a value's bytes could be assigned to, so a `mamori.Value` can never be wired into the log. ## Next - [Server auth and policy](/docs/server/authorization/) - authenticate and authorize callers. - [Server wire protocol](/docs/server/wire-protocol/) - the routes served on every listener. - [Config server overview](/docs/server/) - the fan-out model and blast radius. --- > Source: https://mamorigo.dev/docs/observability/doctor # Doctor: pre-deploy check ```go func Doctor[T any](ctx context.Context, opts ...Option) (Report, error) ``` `Doctor` resolves every field of `T` exactly once and returns a [`Report`](/docs/observability/#report-and-fieldstatus) describing what succeeded and what failed, without starting a watcher. It accepts the same `Option`s as `Load` and `Watch`, so it exercises your real provider wiring, middleware, and `Prefix` rewriting. Unlike `Load`, `Doctor` never aborts on the first failure: it walks every field and records each result, so one run reports every misconfigured ref instead of just the first. The returned `error` is non-nil only when `T` itself cannot be walked as a config struct (an unsupported field type, for example); individual field failures live in the `Report`, not the returned error. `Doctor` does not decode or validate values. `Report.Snapshot` and `Report.Live` are always `0` for a `Doctor` report (and `Report.Pinned` is always `false`), marking a one-shot probe rather than a running watcher's snapshot (whose version starts at 1). ## Run it before a watcher starts ```go rep, err := mamori.Doctor[Config](ctx, appProviders()...) if err != nil { log.Fatal(err) // T is not a walkable config struct } for _, f := range rep.Fields { if f.LastKind != "" { log.Printf("unreachable: %s (%s): %s: %s", f.Path, f.Ref, f.LastKind, f.LastError) } } if !rep.Healthy { log.Fatal("config is not deployable") } ``` ## CI preflight Run `Doctor` as a build-tagged test, gated behind a tag so it only runs where real credentials and network access are available, and fail the build on any field that did not come back healthy: ```go //go:build preflight func TestConfigPreflight(t *testing.T) { rep, err := mamori.Doctor[Config](context.Background(), appProviders()...) if err != nil { t.Fatal(err) } for _, f := range rep.Fields { if f.LastKind != "" { t.Errorf("%s (%s): %s: %s", f.Path, f.Ref, f.LastKind, f.LastError) } } } ``` ```bash go test -tags preflight ./... ``` That catches a rotated-away secret, a missing IAM permission, or a typo'd ref before it ships, instead of at container startup. ## See also - [Observability overview](/docs/observability/) - `Status` and `Health` for a running watcher, and the `Report` shape. - [HTTP exposure](/docs/observability/admin/) - serve the same `Report` over HTTP. - [Error kinds](/docs/concepts/error-kinds/) - what each `LastKind` value means. --- > Source: https://mamorigo.dev/docs/providers/doppler # Doppler [Doppler](https://doppler.com) secrets over the REST API. Pure `net/http`, no third-party SDK. | | | | --- | --- | | Scheme | `doppler://` | | Module | `github.com/xavidop/mamori/providers/doppler` | | Sensitive | yes | | Watch | poll | | Auth | `DOPPLER_TOKEN` (service token) | ## Install ```bash go get github.com/xavidop/mamori/providers/doppler ``` ```go import _ "github.com/xavidop/mamori/providers/doppler" ``` ## Using the ref A `doppler://` ref points at one secret inside a Doppler project and config. The `#` fragment naming the secret is required. ```text doppler:///# ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Doppler project. | | `` | yes | The config (environment) within that project, e.g. `prd`. | | `#` | yes | The secret to fetch. Unlike other providers, this fragment is required - a ref with no `#` is an error. | **Examples** - `doppler://backend/prd#STRIPE_API_KEY` reads `STRIPE_API_KEY` from the `prd` config of the `backend` project. - `doppler://backend/prd#DATABASE_URL` reads `DATABASE_URL` from the same config. ```go type Config struct { StripeKey secret.String `source:"doppler://backend/prd#STRIPE_API_KEY"` DBURL secret.String `source:"doppler://backend/prd#DATABASE_URL"` } ``` Values are marked `Sensitive`. Doppler exposes no per-secret revision, so `Value.Version` is a content hash, which still gives cheap, correct change detection. The provider returns the computed value (with Doppler references resolved), falling back to the raw value. ## Explicit configuration ```go import dopplerprov "github.com/xavidop/mamori/providers/doppler" mamori.WithProvider(dopplerprov.New( dopplerprov.WithToken(os.Getenv("DOPPLER_TOKEN")), )) ``` ## Watch Doppler has no push channel, so mamori polls (`WithPollInterval` + jitter). ## Error classification A missing secret is detected before any status classification runs: a 404 maps directly to `mamori.ErrNotFound`. Every other non-2xx response is classified by HTTP status: | HTTP status | mamori kind | | --- | --- | | 403 | `permission_denied` | | 401 | `unauthenticated` | | 429 | `rate_limited` | | 5xx | `unavailable` | | 400 | `invalid` | | anything else | `unknown` | 429 is the confirmed case: Doppler's API reference documents rate limiting explicitly, including the response headers that accompany a 429. 401 is well established from Doppler's token-based auth model, though the API reference's status-code section only describes the 2xx/4xx/5xx categories in general terms rather than naming 401 specifically. 403, 400, and 5xx are mapped on ordinary HTTP semantics - defensible, not individually confirmed by Doppler's documentation. A raw transport failure (no HTTP response at all) is returned unclassified, since it could be a client-side problem as easily as a genuine backend outage. Verified by unit tests and the conformance kit against an in-process HTTP fake of the Doppler API (injected `*http.Client`), so no network is required. The conformance `ErrorClassification` case injects mamori sentinels directly at the `http.RoundTripper`, before the fake's handler runs, and confirms they survive `http.Client.Do`'s error wrapping unchanged. Live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/dotenv # dotenv Read a single variable from a `.env` file and hot-reload it with fsnotify, without touching the process environment. Built into the core module and registered automatically. | | | | --- | --- | | Scheme | `dotenv://` | | Module | core (built-in) | | Sensitive | no | | Watch | fsnotify (native) | | Auth | filesystem permissions | ## Install Nothing to install - `dotenv://` ships with the core module and is always registered. ```go import "github.com/xavidop/mamori" ``` ## Using the ref A `dotenv://` ref points at one variable inside a `.env` file on disk. ```text dotenv://[#KEY] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | Path to the `.env` file. Relative (`dotenv://.env`) or absolute with a leading slash (`dotenv:///etc/app/.env`). | | `#KEY` | no | The variable to read. Without it, the whole file's raw bytes are returned (use `flatten:"env"` to decode it into a struct). | **Examples** - `dotenv://.env#DB_PASSWORD` reads `DB_PASSWORD` from a `.env` in the working directory. - `dotenv:///etc/app/.env#API_KEY` reads `API_KEY` from an absolute path. - `dotenv://config/.env` returns the whole file - pair it with `flatten:"env"` to decode every variable into a nested struct. ```go type Config struct { DBPassword secret.String `source:"dotenv://.env#DB_PASSWORD"` APIKey secret.String `source:"dotenv:///etc/app/.env#API_KEY"` } ``` The parser understands a leading `export `, single- and double-quoted values (with the usual `\n` / `\t` / `\"` escapes inside double quotes), full-line `#` comments, and a trailing ` #` comment on unquoted values. `Value.Version` is a hash of the file size and modification time, so an unchanged file is not re-read. A missing file, or a missing `#KEY`, resolves to not-found, so `default:` / `optional:"true"` applies. Values are not marked `Sensitive` by default; because `.env` files often hold secrets, wrap the field in `secret.String` (as above) so it stays redacted in logs. ## How it relates to `env:` and `file://` - **`env:NAME`** reads a process environment variable (`os.Getenv`). - **`dotenv://path#KEY`** reads one variable from a specific `.env` file, hot-reloaded, without polluting the process environment. - **`file:///path/.env` with `flatten:"env"`** decodes a whole `.env` file into a nested struct. ## Watch `Watch` uses fsnotify on the file (watching the parent directory to catch atomic renames), re-reading and re-emitting when the `.env` file changes. ## Error classification | Condition | mamori kind | |---|---| | File does not exist | `not_found` | | File exists but is unreadable (permission denied) | `permission_denied` | | Requested `#KEY` not present in the file | `not_found` | | Any other OS error | `unknown` | Like `file://`, `dotenv://` has no malformed-ref case, so there's no `invalid` row. --- > Source: https://mamorigo.dev/docs/providers/dynamodb # DynamoDB Read an item attribute from an Amazon DynamoDB table, built on `aws-sdk-go-v2`. | | | | --- | --- | | Scheme | `dynamodb://` | | Module | `github.com/xavidop/mamori/providers/dynamodb` | | Sensitive | no (opt-in with `WithSensitive`) | | Watch | poll | | Auth | default AWS credential chain (`WithRegion`) | ## Install ```bash go get github.com/xavidop/mamori/providers/dynamodb ``` ```go import _ "github.com/xavidop/mamori/providers/dynamodb" ``` ## Using the ref A `dynamodb://` ref points at one item in a table, fetched by its primary key with a single `GetItem`. ```text dynamodb:///[#attr][?pk_name=&sk=&sk_name=] ``` | Part | Required | What it means | | --- | --- | --- | | `
` | yes | The DynamoDB table name. | | `` | yes | The partition-key value (a string). | | `#attr` | no | Return one top-level attribute instead of the whole item. Without it, the whole item comes back as JSON. | | `?pk_name=` | no | The partition-key attribute name. Defaults to `pk`. | | `?sk=` | no | The sort-key value, for a table with a composite primary key. | | `?sk_name=` | no | The sort-key attribute name. Defaults to `sk`. | **Examples** - `dynamodb://app-config/service` reads the item whose `pk` is `service` and returns the whole item as JSON. - `dynamodb://app-config/service#endpoint` returns just the `endpoint` attribute of that item. - `dynamodb://services/payments#url?pk_name=service_id` looks the item up by a `service_id` partition key instead of the default `pk`. - `dynamodb://events/e-1#payload?sk=2024&sk_name=year` reads an item with a composite key (partition `pk` = `e-1`, sort `year` = `2024`). ```go type Config struct { Endpoint string `source:"dynamodb://app-config/service#endpoint"` Whole string `source:"dynamodb://app-config/service"` // whole item as JSON } ``` `#attr` selects a top-level DynamoDB attribute, not a nested JSON key: a scalar comes back stringified (`S`/`N` verbatim, `BOOL` as `true`/`false`, `NULL` as `null`), while maps, lists, and sets are JSON. `Value.Version` is the item's `version` attribute when present (bump it to force a refresh), otherwise a content hash. Items are not marked sensitive by default; construct the provider with `WithSensitive` when a table holds secret material. ## Watch mamori polls (`WithPollInterval` + jitter). For push, DynamoDB Streams can drive an on-demand reload in your app. ## Error classification Failures are classified so `mamori.ErrorKind` can distinguish them: | DynamoDB error code | mamori kind | |---|---| | `ResourceNotFoundException` | `not_found` | | `AccessDeniedException` | `permission_denied` | | `UnrecognizedClientException`, `ExpiredTokenException`, `InvalidSignatureException`, `MissingAuthenticationToken`, `IncompleteSignature` | `unauthenticated` | | `ProvisionedThroughputExceededException`, `ThrottlingException`, `RequestLimitExceeded`, `Throttling`, `TooManyRequestsException` | `rate_limited` | | `InternalServerError`, `ServiceUnavailable`, `InternalFailure` | `unavailable` | | `ValidationException` | `invalid` | | anything else | `unknown` | Codes not listed above report `unknown` rather than being guessed at. `ResourceNotFoundException` is matched first as its typed `*ddbtypes.ResourceNotFoundException` shape before classification runs; the classifier also recognizes the raw code, so an untyped error carrying it still maps to `not_found`. The original SDK error stays reachable with `errors.As`. Most codes come from DynamoDB's own error reference or the AWS-wide Common Errors page (`MissingAuthenticationToken`, `IncompleteSignature`, `Throttling`, `TooManyRequestsException`, `InternalFailure` are AWS-wide codes, mapped the same way the AWS provider maps them for other services). `ExpiredTokenException` and `InvalidSignatureException` are real AWS signing and credential errors DynamoDB can return, but are not actually enumerated on either reference page. ## Configuration ```go import ddbprov "github.com/xavidop/mamori/providers/dynamodb" mamori.WithProvider(ddbprov.New(ddbprov.WithRegion("eu-west-1"))) ``` Verified with an in-memory fake; live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/env # env Reads process environment variables. Built into the core module and **registered automatically** - no import needed. | | | | --- | --- | | Scheme | `env:` | | Module | core (built-in) | | Sensitive | no | | Watch | poll | | Auth | none | ## Using the ref An `env:` ref identifies one process environment variable by name. ```text env:NAME ``` | Part | Required | What it means | | --- | --- | --- | | `env:` | yes | Opaque scheme - everything after the colon is taken literally, with no `//` authority. | | `NAME` | yes | The environment variable to read, e.g. `LOG_LEVEL`. | **Examples** - `env:LOG_LEVEL` reads `$LOG_LEVEL`; pair it with `default:` and a `validate:"oneof=..."` rule so a missing or mistyped value is caught early. - `env:WORKERS` reads `$WORKERS` into an `int` - add `validate:"gte=1,lte=256"` to bound it. - `env:AWS_REGION` with `optional:"true"` leaves the field at its zero value when the variable is unset. ```go type Config struct { 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"` Region string `source:"env:AWS_REGION" optional:"true"` } cfg, err := mamori.Load[Config](ctx) // env is already registered ``` ## Notes - An unset variable resolves to not-found, so `default:` or `optional:"true"` applies. - `Value.Version` is a hash of the value, so a watcher notices changes across a re-exec or an explicit `os.Setenv` in tests. - Environment values are treated as non-sensitive. If a variable holds a secret, use `secret.String` for the field anyway so it stays out of logs. Environment variables rarely change in a running process, so mamori polls on `WithPollInterval`. ## Error classification | Condition | mamori kind | |---|---| | Variable not set | `not_found` | `os.LookupEnv` either finds the variable or it doesn't, so not-found is the only failure `env:` can produce - there's no `permission_denied` or `invalid` case for this scheme. --- > Source: https://mamorigo.dev/docs/concepts/error-kinds # Error kinds Every resolve failure carries a coarse, provider-independent classification you read with `mamori.ErrorKind(err)`. It exists so diagnostics can tell a misconfiguration from an outage. ## Kind values The typed `Kind` values, each paired with the sentinel `errors.Is` matches: | `Kind` | Wire value | Sentinel (`errors.Is`) | Meaning | | --- | --- | --- | --- | | `KindNotFound` | `not_found` | `ErrNotFound` | Missing key, secret, path, or version. The only kind that drives resolution behavior. | | `KindPermissionDenied` | `permission_denied` | `ErrPermissionDenied` | Authenticated but not authorized (IAM deny, Vault policy, Kubernetes RBAC). | | `KindUnauthenticated` | `unauthenticated` | `ErrUnauthenticated` | Credentials missing, malformed, or expired; identity not proven. | | `KindUnavailable` | `unavailable` | `ErrUnavailable` | Backend unreachable or unresponsive (network, DNS, timeout, 5xx, open breaker). | | `KindRateLimited` | `rate_limited` | `ErrRateLimited` | Throttled or quota-exhausted by the backend. | | `KindInvalid` | `invalid` | `ErrInvalid` | Ref malformed for the provider, or payload could not be parsed. | | `KindUnknown` | `unknown` | (none) | An error the provider could not map. A legal outcome, not a failure. | Only `not_found` changes behavior by default, since it is what triggers a field's `default:` or `optional` handling. The rest are diagnostic. The one opt-in exception is `onfail:"default"` on a source chain, which explicitly treats a non-`not_found` error like absence too (see [Source chains and precedence](/docs/concepts/source-chains/#the-onfail-policy)). ## Reaching the sentinels `errors.Is` reaches the sentinels through the error chain, so you can branch on a specific condition: ```go if errors.Is(err, mamori.ErrPermissionDenied) { // the credential is fine, the authorization is not } ``` `ErrorKind(nil)` returns the empty `Kind`; an error carrying no recognizable sentinel returns `KindUnknown`. A `context.DeadlineExceeded` is reported as `KindUnavailable` (the backend did not respond in time), while a `context.Canceled` stays `KindUnknown` (the caller withdrew the request, which says nothing about the backend). `KindUnknown` and the empty `Kind` have no sentinel of their own. `SentinelFor(k)` is the inverse: it turns a `Kind` back into its sentinel error, or `nil` for `KindUnknown` and the empty `Kind`. A `nil` return means "no sentinel corresponds to this kind," never "the operation succeeded." ## See also - [Concepts overview](/docs/concepts/) - refs, providers, and the reconciler. - [Source chains and precedence](/docs/concepts/source-chains/) - how `not_found` versus other errors drive a chain. - [Observability](/docs/observability/) - `Kind` values on `Report` / `FieldStatus`. --- > Source: https://mamorigo.dev/docs/providers/etcd # etcd The etcd v3 key-value store, with **native watch**. | | | | --- | --- | | Scheme | `etcd://` | | Module | `github.com/xavidop/mamori/providers/etcd` | | Sensitive | no | | Watch | native | | Auth | `ETCD_ENDPOINTS` (or `WithEndpoints`) | ## Install ```bash go get github.com/xavidop/mamori/providers/etcd ``` ```go import _ "github.com/xavidop/mamori/providers/etcd" ``` ## Using the ref An `etcd://` ref points at one key in the etcd v3 store, optionally selecting a field from a JSON value stored there. ```text etcd://[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The etcd key, e.g. `service/endpoint`. A fully-slashed form (`etcd:///service/endpoint`) keeps the leading slash, addressing keys under a leading-`/` namespace. | | `#json-key` | no | When the value is a JSON object, return one field from it (via `mamori.SelectKey`). | **Examples** - `etcd://service/endpoint` - reads the raw value stored at key `service/endpoint`. - `etcd://service/db#max_conns` - reads the JSON object at `service/db` and returns its `max_conns` field. - `etcd:///features/flags#dark_mode` - returns the `dark_mode` field of the JSON at the leading-slash key `/features/flags`. ```go type Config struct { Endpoint string `source:"etcd://service/endpoint"` MaxConns int `source:"etcd://service/db#max_conns"` // key holds JSON } ``` `Value.Version` is the key's `ModRevision`, etcd's native per-key revision, so change detection is exact and monotonic. etcd holds configuration rather than managed secrets, so values are non-sensitive; wrap a field in `secret.String` if you want redaction anyway. ## Error classification Beyond the not-found case (an empty `Kvs` slice, never a gRPC code), other `Get`/watch failures are classified by gRPC status: | gRPC code | mamori kind | | --- | --- | | `PermissionDenied` | `permission_denied` | | `Unauthenticated` | `unauthenticated` | | `Unavailable`, `DeadlineExceeded` | `unavailable` | | `ResourceExhausted` | `rate_limited` | | `InvalidArgument` | `unknown` (deliberately unmapped) | | anything else | `unknown` | `InvalidArgument` is deliberately left unmapped: etcd reports a bad username/password (`rpctypes.ErrGRPCAuthFailed`) as `InvalidArgument`, the same code ordinary malformed requests use, so there is no way to tell the two apart from the code alone. Mapping it either way would be wrong about half the time, so it stays `unknown`. `codes.NotFound` is never returned by etcd for a missing key either; the local empty-`Kvs` check drives `not_found` instead. The etcd v3 client also rewrites a fixed set of well-known server error messages (permission denied, invalid auth token, no leader/timed out, no space/too many requests) into an `rpctypes.EtcdError` that does not implement the `GRPCStatus()` interface `status.Code` relies on. The classifier falls back to `errors.As`-ing into `rpctypes.EtcdError` when `status.Code` reports `Unknown`, so these still classify correctly against a live server instead of silently reporting `unknown`. ## Watch `Watch` uses the etcd v3 `Watch` API, a genuine server push: it emits an `Update` on every PUT to the key and closes cleanly on context cancellation. ## Configuration ```go import etcdprov "github.com/xavidop/mamori/providers/etcd" mamori.WithProvider(etcdprov.New(etcdprov.WithEndpoints("etcd-0:2379", "etcd-1:2379"))) ``` Verified with an in-memory fake supporting Get and Watch, so the watch conformance checks run for real. A live-etcd integration test is provided behind `//go:build integration`. --- > Source: https://mamorigo.dev/docs/providers/exec # exec Runs a command and uses its standard output as the value. Built into the core module but **disabled by default** - executing commands from configuration is a meaningful attack surface, so you must opt in. | | | | --- | --- | | Scheme | `exec:` | | Module | core (opt-in) | | Sensitive | yes | | Watch | poll | | Auth | none | ## Enabling it `exec:` is not auto-registered. Enable it for a single `Load` / `Watch` call with `WithExecProvider`: ```go type Config struct { Token secret.String `source:"exec:vault-agent token"` } cfg, err := mamori.Load[Config](ctx, mamori.WithExecProvider()) ``` ## Using the ref An `exec:` ref names a command line; mamori runs it and uses its standard output as the value. ```text exec:command arg1 arg2 ... ``` | Part | Required | What it means | | --- | --- | --- | | `exec:` | yes | Opaque scheme - the entire remainder is the command line, with no `//` authority. | | `command` | yes | The executable to run (resolved on `PATH`). | | `arg1 arg2 ...` | no | Arguments, split on spaces. Taken verbatim from the ref, never interpolated from other resolved values. | **Examples** - `exec:vault-agent token` runs `vault-agent token` and captures its stdout as the value - pair it with a `secret.String` field. - `exec:aws ecr get-login-password` shells out to the AWS CLI to mint a short-lived registry password. The `exec:` scheme must be enabled per call with `WithExecProvider()` (see above), and its output is always marked `Sensitive`. See Security below. ## Security - Disabled unless you call `WithExecProvider()`. - The command is taken verbatim from the ref and is **never interpolated from other resolved values**, so one secret cannot be used to build another's command (no injection chains). - Output is marked `Sensitive`. A non-zero exit status becomes an error (last-good value is retained under `Watch`). Because there is no native change signal, mamori re-runs the command on `WithPollInterval`. ## Error classification | Condition | mamori kind | |---|---| | Empty command (nothing after `exec:`) | `invalid` | | Binary not found on `PATH` | `unknown` | | Permission denied executing the binary | `permission_denied` | | Command runs and exits non-zero, or any other failure | `unknown` | A binary missing from `PATH` reports `unknown`, not `not_found`: it means mamori could not even attempt to fetch the value, not that the value itself is absent, so it must never trigger `default:` or `optional` handling. A non-zero exit also reports `unknown` because mamori cannot tell whether it failed from a missing value, a permission problem, or a bug in the script. --- > Source: https://mamorigo.dev/docs/providers/file # file Reads a file's contents and **natively watches it with fsnotify**, so file-backed values are hot-reloaded. Built into the core module and registered automatically. | | | | --- | --- | | Scheme | `file://` | | Module | core (built-in) | | Sensitive | no | | Watch | fsnotify (native) | | Auth | filesystem permissions | ## Using the ref A `file://` ref points at one file on disk; the file's contents become the value. ```text file:///absolute/path file://relative/path ``` | Part | Required | What it means | | --- | --- | --- | | `file://` | yes | Scheme. A third slash (`file:///`) starts an absolute path; `file://name` is relative to the working directory. | | `/absolute/path` | yes | The file to read. Its full contents (raw bytes) are the value. | | `?debounce=` | no | Coalescing window for watch events. Set `?debounce=0` to emit the instant the file changes (see Watch). | **Examples** - `file:///etc/tls/tls.crt` reads a certificate into a raw `[]byte` field. - `file:///etc/tls/tls.key` into a `secret.Bytes` field keeps the private key redacted in logs. - `file:///etc/app/config.yaml` with `flatten:"yaml"` decodes the file into a nested struct. - `file:///etc/tls/tls.crt?debounce=0` hot-reloads the moment the cert is atomically replaced. ```go type Config struct { TLSCert []byte `source:"file:///etc/tls/tls.crt"` TLSKey secret.Bytes `source:"file:///etc/tls/tls.key"` Config AppConfig `source:"file:///etc/app/config.yaml" flatten:"yaml"` } cfg, err := mamori.Load[Config](ctx) // file is already registered ``` ## Watch `file://` watches the target's **parent directory** with fsnotify, so it catches atomic replaces (write-to-temp then rename) - the pattern used by Kubernetes secret mounts and cert-renewal tools. It re-reads and emits on write, create, or rename of the target, and closes cleanly on context cancellation. For a certificate that should update the instant it changes, disable coalescing on that field: ```go Cert []byte `source:"file:///etc/tls/tls.crt?debounce=0"` ``` ## Notes - `Value.Version` is a hash of the file's size and modification time, so unchanged files are not re-read. - A missing file resolves to not-found, so `default:` / `optional:"true"` applies. - `file://` values are non-sensitive by default; use `secret.Bytes` for private keys so they stay redacted. ## Error classification | Condition | mamori kind | |---|---| | File does not exist | `not_found` | | File exists but is unreadable (permission denied) | `permission_denied` | | Any other OS error | `unknown` | `file://` has no malformed-ref case - any path is syntactically valid - so there's no `invalid` row. --- > Source: https://mamorigo.dev/docs/providers/firebase-rtdb # Firebase Realtime Database Read a value at a Realtime Database path, with **native watch** via the streaming API. | | | | --- | --- | | Scheme | `firebase-rtdb://` | | Module | `github.com/xavidop/mamori/providers/firebase-rtdb` | | Sensitive | no | | Watch | native (SSE streaming) | | Auth | Application Default Credentials (`WithDatabaseURL`) | ## Install ```bash go get github.com/xavidop/mamori/providers/firebase-rtdb ``` ```go import _ "github.com/xavidop/mamori/providers/firebase-rtdb" ``` ## Using the ref A `firebase-rtdb://` ref points at one location (path) in your Realtime Database. ```text firebase-rtdb://[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The database location to read, e.g. `config/service/db`. The value at that path becomes the value, as JSON. | | `#json-key` | no | Treat the value at the path as a JSON object and return one field of it. | **Examples** - `firebase-rtdb://config/flags` returns the value at `config/flags` as JSON. - `firebase-rtdb://config/service/endpoint` reads the `endpoint` leaf under `config/service` (a string leaf comes back unquoted). - `firebase-rtdb://config/service/db#password` selects the `password` field from the JSON object stored at `config/service/db`. ```go type Config struct { Endpoint string `source:"firebase-rtdb://config/service/endpoint"` Flags string `source:"firebase-rtdb://config/flags"` } ``` A JSON string leaf is returned unquoted; other JSON (objects, arrays, numbers, booleans) as its JSON encoding. A null or missing path resolves to not-found, so `default:` / `optional:"true"` applies. `Value.Version` is the database ETag when available (an exact native revision), falling back to a content hash. The Realtime Database holds configuration rather than managed secrets, so values are not marked sensitive; wrap a field in `secret.String` for redaction. ## Error classification Beyond the not-found case above, this provider has no SDK-specific error taxonomy to classify against, so any other backend failure reports `unknown` via `mamori.ErrorKind`. `Resolve` still wraps the backend error with `%w`, so the classification chain (`errors.Is`, `errors.As`) is preserved rather than flattened, even though nothing here maps it to a more specific kind. ## Watch `Watch` uses the Realtime Database streaming endpoint (server-sent events): the server pushes `put` and `patch` events as the data changes, and mamori emits an update on each. This is a genuine realtime subscription. ## Configuration ```go import rtdbprov "github.com/xavidop/mamori/providers/firebase-rtdb" mamori.WithProvider(rtdbprov.New( rtdbprov.WithDatabaseURL("https://my-project-default-rtdb.firebaseio.com"), )) ``` Verified with an in-memory fake stream; live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/firebase-rc # Firebase Remote Config Read a parameter from your Firebase Remote Config server template - the classic dynamic-config use case. | | | | --- | --- | | Scheme | `firebase-rc://` | | Module | `github.com/xavidop/mamori/providers/firebase-rc` | | Sensitive | no | | Watch | poll | | Auth | Application Default Credentials (`WithProjectID`) | ## Install ```bash go get github.com/xavidop/mamori/providers/firebase-rc ``` ```go import _ "github.com/xavidop/mamori/providers/firebase-rc" ``` ## Using the ref A `firebase-rc://` ref points at one parameter in your project's server Remote Config template. ```text firebase-rc://[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The name of a parameter in the server Remote Config template. Its server-side default value becomes the value. | | `#json-key` | no | Treat the parameter value as a JSON object and return one field of it. | **Examples** - `firebase-rc://welcome_banner` returns the server-side value of the `welcome_banner` parameter. - `firebase-rc://max_items` returns the `max_items` parameter - pair it with an `int` field. - `firebase-rc://feature_flags#new_ui` treats the `feature_flags` parameter as JSON and returns its `new_ui` field. ```go type Config struct { Banner string `source:"firebase-rc://welcome_banner"` MaxItems int `source:"firebase-rc://max_items"` } ``` The provider reads the current server template (the one used by the Admin SDK and server workloads) and returns the named parameter's default value. A missing parameter, or one configured to use the in-app default (no server value), resolves to not-found, so `default:` / `optional:"true"` applies. `Value.Version` is the template's version number, which is template-wide - it changes whenever any parameter is published, so mamori may occasionally re-apply an unchanged value (harmless). ## Watch The server template has no push channel, so mamori polls (`WithPollInterval` + jitter). ## Configuration ```go import rcprov "github.com/xavidop/mamori/providers/firebase-rc" mamori.WithProvider(rcprov.New(rcprov.WithProjectID("my-project"))) ``` Authentication uses Application Default Credentials (a service account via `GOOGLE_APPLICATION_CREDENTIALS`, or workload identity). Verified with an in-memory fake; live behavior is covered by `//go:build integration` tests. ## Error classification A non-200 response from the Remote Config REST API is classified by HTTP status: | HTTP status | mamori kind | |---|---| | 403 | `permission_denied` | | 401 | `unauthenticated` | | 429 | `rate_limited` | | 5xx | `unavailable` | | 400 | `invalid` | | anything else | `unknown` | A missing parameter is a separate case: it is detected after a successful (200) fetch by looking the key up in the decoded template, and maps to not-found, not to one of the statuses above. Verified by unit tests (direct classification, plus a real `httptest` 403 response driven through `Resolve`) and the conformance kit against an in-memory fake; live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/firestore # Firestore Read a field from a Cloud Firestore document, with **native watch** via snapshot listeners. | | | | --- | --- | | Scheme | `firestore://` | | Module | `github.com/xavidop/mamori/providers/firestore` | | Sensitive | no | | Watch | native (snapshot listeners) | | Auth | Application Default Credentials (`WithProjectID`) | ## Install ```bash go get github.com/xavidop/mamori/providers/firestore ``` ```go import _ "github.com/xavidop/mamori/providers/firestore" ``` ## Using the ref A `firestore://` ref points at one document in a collection, read by its ID. ```text firestore:///[#field] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Firestore collection ID. | | `` | yes | The document ID within that collection. | | `#field` | no | Return one top-level field of the document instead of the whole thing. Without it, the whole document comes back as JSON. | **Examples** - `firestore://config/service` reads the `service` document in the `config` collection and returns it as JSON. - `firestore://config/service#endpoint` returns just the `endpoint` field of that document. - `firestore://config/app#max_retries` returns the `max_retries` field - pair it with an `int` field. ```go type Config struct { Endpoint string `source:"firestore://config/service#endpoint"` Whole string `source:"firestore://config/service"` // whole document as JSON } ``` A scalar field is returned unquoted; maps and arrays as their JSON encoding. `Value.Version` is the document's `UpdateTime`, computed over the whole document, so a change to any field is detected even for a `#field` ref. Firestore holds application configuration rather than managed secrets, so values are not marked sensitive; wrap a field in `secret.String` for redaction. ## Watch `Watch` uses `Doc.Snapshots`, Firestore's real-time listener, emitting an update on every change to the document. No polling. ## Error classification | gRPC code | mamori kind | |---|---| | `NotFound` | `not_found` | | `PermissionDenied` | `permission_denied` | | `Unauthenticated` | `unauthenticated` | | `Unavailable`, `DeadlineExceeded` | `unavailable` | | `ResourceExhausted` | `rate_limited` | | `InvalidArgument` | `invalid` | | anything else | `unknown` | The Firestore client is gRPC-based, so codes map one to one and no string matching is needed. Codes with no clear mamori meaning (`Internal`, `Unimplemented`, `Aborted`, `FailedPrecondition`) report `unknown` rather than being guessed at. The gRPC status stays reachable through `status.Code` on a classified error for both `Resolve` and `Watch` - a listener failure mid-watch is classified the same way a `Resolve` failure would be, instead of surfacing as `unknown`. ## Configuration ```go import fsprov "github.com/xavidop/mamori/providers/firestore" mamori.WithProvider(fsprov.New(fsprov.WithProjectID("my-project"))) ``` Verified with an in-memory fake; live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/flagsmith # Flagsmith Load a [Flagsmith](https://www.flagsmith.com) flag's value or state as config. | | | | --- | --- | | Scheme | `flagsmith://` | | Module | `github.com/xavidop/mamori/providers/flagsmith` | | Sensitive | no | | Watch | poll | | Auth | `FLAGSMITH_ENVIRONMENT_KEY` | ## Install ```bash go get github.com/xavidop/mamori/providers/flagsmith ``` ```go import _ "github.com/xavidop/mamori/providers/flagsmith" ``` ## Using the ref A `flagsmith://` ref points at one flag. Without a fragment it resolves to the flag's value; `#enabled` resolves to its on/off state. ```text flagsmith://[#enabled] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Flagsmith feature name. | | `#enabled` | no | Resolve to the feature's enabled state (`true` / `false`) instead of its value. | A feature that does not exist resolves to not-found. **Examples** - `flagsmith://banner_text` resolves to the feature's value (a string, number, or JSON). - `flagsmith://new_dashboard#enabled` resolves to `true` / `false` - pair it with a `bool` field. ## Error classification Flagsmith's SDK exposes no structured error vocabulary beyond feature-not-found, so this provider does not classify errors into finer kinds: `mamori.ErrorKind` reports `unknown` for anything other than `not_found`. `Resolve` propagates a backend error unchanged (never flattened with `%v`), so `errors.Is` / `errors.As` and any wrapped mamori sentinel survive the chain intact. ## Watch mamori polls (`WithPollInterval` + jitter); the Flagsmith client also refreshes internally. ## Configuration ```go import flagsmithprov "github.com/xavidop/mamori/providers/flagsmith" mamori.WithProvider(flagsmithprov.New( flagsmithprov.WithEnvironmentKey(os.Getenv("FLAGSMITH_ENVIRONMENT_KEY")), )) // self-hosted: add flagsmithprov.WithBaseURL("https://flagsmith.internal/api/v1/") ``` Verified with an injected fake (un-seeded features resolve to not-found); live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/flipt # Flipt Load a flag from [Flipt](https://www.flipt.io), the Go-native open-source feature-flag server. | | | | --- | --- | | Scheme | `flipt://` | | Module | `github.com/xavidop/mamori/providers/flipt` | | Sensitive | no | | Watch | poll | | Auth | `FLIPT_URL` (+ optional token) | ## Install ```bash go get github.com/xavidop/mamori/providers/flipt ``` ```go import _ "github.com/xavidop/mamori/providers/flipt" ``` ## Using the ref A `flipt://` ref points at one flag within a namespace, evaluated for an entity. ```text flipt:///[?entity=] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Flipt namespace (use `default` if you have not created others). | | `` | yes | The flag key. | | `?entity=` | no | The evaluation entity id. Defaults to `mamori`. | A boolean flag resolves to `true` / `false`; a variant flag resolves to the matched variant key. A flag that does not exist resolves to not-found. **Examples** - `flipt://default/new-billing` on a boolean flag resolves to `true` / `false`. - `flipt://default/experiment-a?entity=service-1` on a variant flag resolves to the variant matched for `service-1`. ## Watch Flipt is evaluated over its API; mamori polls (`WithPollInterval` + jitter). ## Error classification Beyond the not-found case, other evaluation failures are classified by gRPC status: | gRPC code | mamori kind | | --- | --- | | `PermissionDenied` | `permission_denied` | | `Unauthenticated` | `unauthenticated` | | `Unavailable`, `DeadlineExceeded` | `unavailable` | | `ResourceExhausted` | `rate_limited` | | `NotFound`, `InvalidArgument` | not classified here (deliberately) | | anything else | `unknown` | `NotFound` already drives not-found before classification runs, and `InvalidArgument` is Flipt's flag-type-mismatch signal, consumed internally to trigger the boolean/variant fallback rather than surfaced as an error, so neither is remapped here. The typed errors `flipterrors.ErrUnauthenticated` and `flipterrors.ErrUnauthorized` are also checked, mapping to `unauthenticated` and `permission_denied` respectively. ## Configuration ```go import fliptprov "github.com/xavidop/mamori/providers/flipt" mamori.WithProvider(fliptprov.New( fliptprov.WithURL(os.Getenv("FLIPT_URL")), fliptprov.WithToken(os.Getenv("FLIPT_TOKEN")), )) ``` Verified with an injected fake (un-seeded flags resolve to not-found, and injected gRPC status errors exercise error classification through `Resolve`); live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/gcp # GCP Google Cloud Secret Manager, built on `cloud.google.com/go/secretmanager`. | | | | --- | --- | | Scheme | `gcp-sm://` | | Module | `github.com/xavidop/mamori/providers/gcp` | | Sensitive | yes | | Watch | poll | | Auth | Application Default Credentials | ## Install ```bash go get github.com/xavidop/mamori/providers/gcp ``` ```go import _ "github.com/xavidop/mamori/providers/gcp" ``` ## Using the ref A `gcp-sm://` ref points at one secret in a GCP project's Secret Manager, at a specific (or the latest) version. ```text gcp-sm:///[#json-key][?version=] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The GCP project ID (or number). | | `` | yes | The secret ID within that project. | | `#json-key` | no | Select one field from a JSON secret payload (via `mamori.SelectKey`). | | `?version=` | no | Pin a specific secret version. Defaults to `latest`. | **Examples** - `gcp-sm://my-project/db-password` reads the latest version of `db-password`. - `gcp-sm://my-project/api-key?version=3` pins version `3`, so the value never changes under you. - `gcp-sm://my-project/creds#password` selects the `password` field of a JSON secret. ```go type Config struct { DBPassword secret.String `source:"gcp-sm://my-project/db-password"` // latest version APIKey secret.String `source:"gcp-sm://my-project/api-key?version=3"` // pinned version Nested secret.String `source:"gcp-sm://my-project/creds#password"` // key of a JSON secret } ``` Values are always `Sensitive`, and `Value.Version` is the resolved secret version name (e.g. `.../versions/3`), so change detection is cheap. ## Explicit configuration Authentication uses Application Default Credentials (a service-account key file via `GOOGLE_APPLICATION_CREDENTIALS`, workload identity, or the metadata server). For tests or custom transports, inject a client: ```go import gcpprov "github.com/xavidop/mamori/providers/gcp" mamori.WithProvider(gcpprov.New(gcpprov.WithClient(myClient))) ``` ## Watch Secret Manager has no native change notification, so mamori polls (`WithPollInterval` + jitter). Pub/Sub rotation notifications can drive an on-demand `Load` in your app if you need push. ## Error classification | gRPC code | mamori kind | |---|---| | `NotFound` | `not_found` | | `PermissionDenied` | `permission_denied` | | `Unauthenticated` | `unauthenticated` | | `Unavailable`, `DeadlineExceeded` | `unavailable` | | `ResourceExhausted` | `rate_limited` | | `InvalidArgument` | `invalid` | | anything else | `unknown` | A malformed ref (not `/`) reports `invalid` without a round trip to Secret Manager. The gRPC status stays reachable through `status.Code`. Verified by unit tests and the conformance kit against an in-memory fake; live GCP behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/getting-help # Getting help - **Docs** - you are here. Use the sidebar to find a topic or a specific provider. - **API reference** - [pkg.go.dev/github.com/xavidop/mamori](https://pkg.go.dev/github.com/xavidop/mamori) for the full godoc of the core module and each provider. - **Questions & ideas** - [GitHub Discussions](https://github.com/xavidop/mamori/discussions). - **Bugs** - [open an issue](https://github.com/xavidop/mamori/issues/new/choose). Include your Go version, the module versions, and a minimal reproduction, with no real secret values. - **Security** - report vulnerabilities privately via [GitHub Security Advisories](https://github.com/xavidop/mamori/security/advisories/new), never in a public issue. See the Security page. ## Contributing Contributions are welcome, new providers especially. Read [CONTRIBUTING.md](https://github.com/xavidop/mamori/blob/main/CONTRIBUTING.md) and the [Write a provider guide](/docs/writing-a-provider/). A provider that passes the `providertest` conformance kit gets listed in the README with a badge. Before filing a bug, please check these docs and existing issues. When you do file one, keep secret values out of it. --- > Source: https://mamorigo.dev/docs/providers/goff # GO Feature Flag Load a flag from [GO Feature Flag](https://gofeatureflag.org), the Go-native open-source flag engine. | | | | --- | --- | | Scheme | `goff://` | | Module | `github.com/xavidop/mamori/providers/goff` | | Sensitive | no | | Watch | poll | | Auth | flag config retriever (file / URL / bucket) | ## Install ```bash go get github.com/xavidop/mamori/providers/goff ``` ```go import _ "github.com/xavidop/mamori/providers/goff" ``` ## Using the ref A `goff://` ref points at one flag key. It resolves to that flag's evaluated variation for a fixed evaluation context (your service). ```text goff://[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The GO Feature Flag flag key. | | `#json-key` | no | Select one field from a JSON variation (via SelectKey). | The variation maps to bytes by type (boolean, string, number, or JSON). A flag that does not exist resolves to not-found. **Examples** - `goff://new-feature` resolves to `true` / `false`. - `goff://rollout-config#percentage` resolves to the `percentage` field of a JSON variation. The evaluation context defaults to a stable targeting key (`mamori`); override it with `WithTargetingKey`. ## Error classification Beyond the not-found case, other evaluation failures are classified by go-feature-flag's OpenFeature-style `ErrorCode` so `mamori.ErrorKind` can distinguish them: | ErrorCode | mamori kind | |---|---| | `PROVIDER_NOT_READY` | `unavailable` | | `PARSE_ERROR`, `TYPE_MISMATCH`, `INVALID_CONTEXT`, `TARGETING_KEY_MISSING` | `invalid` | | anything else (including `GENERAL`) | `unknown` | `GENERAL` is go-feature-flag's catch-all for a failure that fits no other code, so mapping it would be a guess; it stays `unknown` along with any unrecognized code. The underlying error stays reachable with `errors.Is`/`errors.As` when RawVariation returned one. ## Watch GO Feature Flag reloads its flag definitions from the configured retriever on an interval; mamori polls (`WithPollInterval` + jitter). ## Configuration GO Feature Flag reads its flag definitions from a retriever - a local file, an HTTP URL, or an object-storage bucket: ```go import goffprov "github.com/xavidop/mamori/providers/goff" mamori.WithProvider(goffprov.New(goffprov.WithConfigFile("/etc/goff/flags.yaml"))) // or from a URL: mamori.WithProvider(goffprov.New(goffprov.WithConfigURL("https://cdn.example.com/flags.yaml"))) ``` Verified with an injected fake (un-seeded flags resolve to not-found); live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/gcs # Google Cloud Storage Fetch a config or secret blob from a GCS bucket. | | | | --- | --- | | Scheme | `gcs://` | | Module | `github.com/xavidop/mamori/providers/gcs` | | Sensitive | no (opt-in with `WithSensitive`) | | Watch | poll (generation) | | Auth | Application Default Credentials | ## Install ```bash go get github.com/xavidop/mamori/providers/gcs ``` ```go import _ "github.com/xavidop/mamori/providers/gcs" ``` ## Using the ref A `gcs://` ref points at one object in a bucket. ```text gcs:///[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The GCS bucket name. | | `` | yes | The object name. It may contain slashes, e.g. `config/prod/app.json`. | | `#json-key` | no | Treat the object as a JSON object and return one field of it. | **Examples** - `gcs://my-bucket/config/app.json` fetches the whole object - decode it with `flatten:"json"`. - `gcs://my-bucket/app/config.json#feature_x` returns just the `feature_x` field of that JSON object. - `gcs://my-bucket/env/prod/settings.yaml` fetches a nested object (the object name carries slashes). ```go type Config struct { AppConfig AppConfig `source:"gcs://my-bucket/config/app.json" flatten:"json"` } ``` The object name may contain slashes, so `env/prod/settings.yaml` is a single name. `Value.Version` is the object generation number (or ETag), which changes on every overwrite, so change detection is cheap. Objects are not marked sensitive by default; pass `WithSensitive()` for buckets that hold secret material. ## Watch mamori polls (`WithPollInterval` + jitter) using the generation. For push, GCS Pub/Sub object-change notifications can trigger an on-demand reload. ## Error classification Failures are classified so `mamori.ErrorKind` can distinguish them: | Condition | mamori kind | | --- | --- | | `storage.ErrObjectNotExist`, `storage.ErrBucketNotExist`, HTTP 404 | `not_found` | | HTTP 403 | `permission_denied` | | HTTP 401 | `unauthenticated` | | HTTP 429 | `rate_limited` | | HTTP 5xx | `unavailable` | | HTTP 400 | `invalid` | | anything else | `unknown` | The GCS Go client is REST-based: a missing object surfaces as `storage.ErrObjectNotExist`, and every other failure surfaces as a `*googleapi.Error` carrying the HTTP status. The classifier also matches `storage.ErrBucketNotExist`, but this provider's read path cannot actually produce it - a missing bucket is reported the same way as a missing object, as `storage.ErrObjectNotExist` - so that case is defensive, not something you will see through this provider today. Only the statuses above are mapped; anything else reports `unknown` rather than being guessed at. The original SDK error stays reachable with `errors.As`. ## Configuration ```go import gcsprov "github.com/xavidop/mamori/providers/gcs" mamori.WithProvider(gcsprov.New()) // uses Application Default Credentials ``` Verified with an in-memory fake; live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/growthbook # GrowthBook Load a [GrowthBook](https://www.growthbook.io) feature's evaluated value as config. | | | | --- | --- | | Scheme | `growthbook://` | | Module | `github.com/xavidop/mamori/providers/growthbook` | | Sensitive | no | | Watch | poll | | Auth | GrowthBook client key + API host | ## Install ```bash go get github.com/xavidop/mamori/providers/growthbook ``` ```go import _ "github.com/xavidop/mamori/providers/growthbook" ``` ## Using the ref A `growthbook://` ref points at one feature key. It resolves to that feature's evaluated value. ```text growthbook://[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The GrowthBook feature key. | | `#json-key` | no | Select one field from a JSON feature value (via SelectKey). | The value maps to bytes by type (boolean, string, number, or JSON). A feature not present in the loaded feature set resolves to not-found. **Examples** - `growthbook://dark-mode` resolves to `true` / `false`. - `growthbook://checkout-config#timeout_ms` resolves to the `timeout_ms` field of a JSON feature value. ## Watch GrowthBook loads its features from the API and refreshes them; mamori polls (`WithPollInterval` + jitter). ## Configuration ```go import gbprov "github.com/xavidop/mamori/providers/growthbook" mamori.WithProvider(gbprov.New( gbprov.WithClientKey(os.Getenv("GROWTHBOOK_CLIENT_KEY")), gbprov.WithAPIHost("https://cdn.growthbook.io"), )) ``` Verified with an injected fake (un-seeded features resolve to not-found); live behavior is covered by `//go:build integration` tests. ## Error classification Beyond not-found, this provider has no error vocabulary to classify against - a non-not-found evaluator error reports `unknown`. The chain is preserved: `Resolve` wraps the error with `%w`, so `errors.Is` / `errors.As` still reach it. --- > Source: https://mamorigo.dev/docs/server/ha # High availability Run several config server replicas behind one address so a single process is never a hard dependency for every consumer it serves. The important thing to know first: **the config server holds no authoritative state.** It is a fan-out cache. Each replica watches upstream itself and caches the result, and the upstream backend stays the source of truth. So HA here means "several independent replicas, each safe to talk to", not a cluster. There is no leader election, no quorum, no replicated log, and no shared cache to operate, because there is no state to agree on. What that leaves is a small number of real problems: not sending traffic to a replica whose cache is still cold, letting clients notice when they reach a replica that is behind, and failing over on the client side. Those are what this page covers. ```mermaid flowchart LR C1[consumer] --> LB{{load balancer}} C2[consumer] --> LB LB -->|"/v1/readyz gates routing"| R1[replica A] LB --> R2[replica B] LB --> R3[replica C] R1 -->|own watch| U[("upstream backend
the source of truth")] R2 -->|own watch| U R3 -->|own watch| U ``` ## Readiness, the part that matters most Every replica serves two probe endpoints, and they answer different questions: | Endpoint | Question | Answer | | --- | --- | --- | | `GET /v1/healthz` | Is this process alive? | Always `200` for as long as it can reply at all | | `GET /v1/readyz` | Should this replica get traffic? | `200` when ready, `503` while `priming` or `draining` | Route on `readyz`, not `healthz`. A replica that has just started has resolved nothing yet: its cache is cold, and every request it accepts it can only fail. `healthz` cannot express that, by design, it is a liveness signal, so a load balancer using it will send real traffic to a replica that is not ready for it. ```bash $ curl -s localhost:8080/v1/readyz {"status":"priming"} # 503, still resolving its bindings $ curl -s localhost:8080/v1/readyz {"status":"ready"} # 200, every binding has settled ``` A replica becomes ready once every binding has **settled**, meaning it has a value or a definite upstream answer. Note the deliberate asymmetry: a binding that settled on an *error* still counts as ready. That error is what upstream is saying, and every other replica would say the same, so holding all of them out of rotation would turn one broken binding into a total outage. The probes are unauthenticated, since a prober has no credential to offer. They therefore report a bare status and nothing else: no binding names, no counts, no node identity. Nothing an unauthenticated caller could use to map your deployment. ## Draining on shutdown A load balancer only stops sending requests once it has *observed* a failing probe, which takes up to its probe interval. If a replica stops accepting the moment it is told to shut down, everything sent in that gap is refused. `Close` marks the replica `draining` before it touches any listener, so the probe starts failing while requests are still being served normally. `DrainGrace` holds it in that state long enough for the balancer to notice: ```go srv, err := server.New( server.WithPolicy(policy), server.WithAuth(auth), server.TCP(":8443", server.TLS(tlsCfg)), server.Bind("db-password", "aws-sm://prod/db#password"), // Stay up but report not-ready for 15s on Close, so the balancer // takes this replica out before its listeners stop accepting. server.DrainGrace(15*time.Second), ) ``` Set it to at least your balancer's probe interval times its unhealthy threshold. The default is zero, which tears down immediately, so nothing changes for deployments that have not opted in. ## Telling a stale replica from a fresh one Each replica watches upstream on its own schedule, so two replicas routinely hold the same binding at different ages. Without help, a client that reconnects and lands on a laggier replica would apply an older value as though it were a new change, going backwards in time. Every value response is therefore dated: ```json { "name": "db-password", "bytes": "...", "resolved_at": "2026-07-26T21:04:11Z", "stale": false } ``` - `resolved_at` is when *that replica* last fetched the bytes from upstream. - `stale` is `true` when the replica is serving its last-known-good value because upstream is currently failing. If you consume this with the [`mamori://` client](/docs/providers/mamori/), you get the protection for free: it tracks the newest `resolved_at` it has delivered per binding and **drops an update that is older**, so a watch that reconnects onto a laggier replica cannot hand your program a stale value dressed up as a change. Writing your own client is the only case where you need to compare the field yourself. Both are omitted when they do not apply, so a single-replica deployment sees the wire shape it always did. Note that `resolved_at` dates the *value*, not the last attempt: a failed refresh carries the timestamp forward with the bytes it belongs to, so it always tells you how old the data you were handed really is. ## Client-side failover The [`mamori://` client](/docs/providers/mamori/) can take a list of replicas and fail over between them, so you are not forced to put a load balancer in front: ```go prov, err := mamoriprov.New(mamoriprov.Config{ Endpoints: []string{ "https://config-a.internal:8443", "https://config-b.internal:8443", }, }) ``` Failover applies when a replica is unreachable or broken: a refused dial, a TLS failure, a timeout, `unavailable`, or an unclassifiable `5xx`. It deliberately does **not** apply to an authoritative answer, one every replica would give alike (`not_found`, `permission_denied`, `unauthenticated`, `invalid`, `rate_limited`). Retrying those would turn one clean `403` into one request per replica, and would amplify precisely the load a `rate_limited` throttle exists to shed. `Watch` rotates endpoints on each reconnect, and backs off only after a full cycle through the list, so a dead replica cannot black-hole a stream. Setting both `Endpoint` and `Endpoints`, or mistyping any entry, is a configuration error rather than a silently dropped replica. Full detail on the [provider page](/docs/providers/mamori/#several-replicas). ## Attributing requests to a replica Audit records carry a `node` field naming the replica that served the request, which is what makes "which replica returned that stale value" answerable after the fact. It defaults to the hostname, already the right answer under most schedulers, and is never sent to a client: ```go server.NodeID("config-server-7") // defaults to os.Hostname() ``` ## What to watch out for A few sharp edges are inherent to running N replicas, and no amount of code on our side removes them: - **Unix sockets do not work for this.** A second replica binding the same socket path takes it over from the first, and a socket cannot be load balanced anyway. Multi-replica means TCP with TLS, which also means giving up [`PeerCred`](/docs/auth/schemes/) and using a token or mTLS scheme instead. - **Watch (SSE) connections are sticky.** Each one is a long-lived stream against one replica, with no resumption cursor. Your balancer must not buffer responses, and its idle timeout must exceed the 15s heartbeat, or streams will be cut repeatedly. Clients reconnect on their own, possibly landing on a different replica, which is what makes `resolved_at` worth checking. - **Upstream load multiplies by N.** Every replica watches every binding independently, so three replicas mean three times the calls to your secrets backend. Watch your rate limits, and prefer fewer, longer-lived replicas over many short-lived ones. - **Configuration must match across replicas.** Bindings are declared per process, and nothing detects divergence. A replica started from a stale bind file simply reports "binding not found" for a name its peers serve happily, which looks identical on the wire to a name that was never bound. Ship the same config to every replica. ## Kubernetes example ```yaml readinessProbe: httpGet: { path: /v1/readyz, port: 8443, scheme: HTTPS } periodSeconds: 5 failureThreshold: 2 livenessProbe: httpGet: { path: /v1/healthz, port: 8443, scheme: HTTPS } periodSeconds: 10 ``` Pair that with `DrainGrace(15 * time.Second)` and a matching `terminationGracePeriodSeconds`, so the pod stays up long enough to be pulled from the Service's endpoints before it stops accepting. ## See also - [Deploy and expose](/docs/server/transports/) - the listeners a replica binds, and why TCP is required here. - [Wire protocol](/docs/server/wire-protocol/) - the full response shapes, including `resolved_at` and `stale`. - [Providers: mamori](/docs/providers/mamori/) - the client that consumes these replicas. - [Observability](/docs/observability/) - the admin endpoint, the metadata-only surface for "is my config healthy". --- > Source: https://mamorigo.dev/docs/providers/kubernetes # Kubernetes Secrets and ConfigMaps, with **native watch** via the Kubernetes watch API (the same mechanism informers use), built on `client-go`. | | | | --- | --- | | Schemes | `k8s-secret://` `k8s-cm://` | | Module | `github.com/xavidop/mamori/providers/k8s` | | Sensitive | Secret: yes · ConfigMap: no | | Watch | native | | Auth | in-cluster config, else `KUBECONFIG` / `~/.kube/config` | ## Install ```bash go get github.com/xavidop/mamori/providers/k8s ``` ```go import _ "github.com/xavidop/mamori/providers/k8s" // registers k8s-secret:// and k8s-cm:// ``` ## Using the ref A `k8s-secret://` or `k8s-cm://` ref points at one Secret or ConfigMap in a namespace, optionally selecting one entry from its data map. ```text k8s-secret:///[#key] k8s-cm:///[#key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The namespace that holds the object. | | `` | yes | The Secret or ConfigMap name. | | `#key` | no | Return one entry of the object's `data` map. Without it, the whole data map is JSON-encoded as an object of string values. | **Examples** - `k8s-secret://prod/db-creds#password` - returns the `password` entry of the `db-creds` Secret in namespace `prod` (client-go base64-decodes it for you). - `k8s-secret://prod/tls#ca.crt` - returns the raw `ca.crt` bytes from the `tls` Secret. - `k8s-cm://prod/app-config#log_level` - returns the `log_level` entry of the `app-config` ConfigMap. - `k8s-cm://prod/app-config` - returns the whole ConfigMap data map as a JSON object. ```go type Config struct { DBPassword secret.String `source:"k8s-secret://prod/db-creds#password"` CACert []byte `source:"k8s-secret://prod/tls#ca.crt"` LogLevel string `source:"k8s-cm://prod/app-config#log_level"` } ``` For a `#key` on a ConfigMap, `data` is consulted first and then `binaryData`. `Value.Version` is the object's `ResourceVersion`, giving monotonic, native change detection. Secret values are marked `Sensitive`; ConfigMap values are not. ## Watch `Watch` opens a name-scoped watch and emits an `Update` on every Added/Modified event. If the server-side watch ends while the context is alive it re-establishes (re-list + re-watch), and it closes cleanly on cancellation. This is a genuine push: no polling. ## Explicit configuration ```go import k8sprov "github.com/xavidop/mamori/providers/k8s" mamori.WithProvider(k8sprov.New(k8sprov.WithKubeconfig("/home/me/.kube/config"))) mamori.WithProvider(k8sprov.NewConfigMap(k8sprov.WithClient(myClientset))) ``` ## Error classification | Kubernetes condition | mamori kind | |---|---| | `IsNotFound` | `not_found` | | `IsForbidden` (RBAC) | `permission_denied` | | `IsUnauthorized` | `unauthenticated` | | `IsTooManyRequests` | `rate_limited` | | `IsServiceUnavailable`, `IsTimeout`, `IsServerTimeout` | `unavailable` | | `IsBadRequest`, `IsInvalid` | `invalid` | | Malformed ref (not `/`) | `invalid` | | anything else | `unknown` | Detection uses the `apierrors` predicates rather than raw status codes, so it stays correct across API versions. The underlying `*StatusError` remains reachable, so `apierrors.IsForbidden` still works on an error that has passed through mamori. Verified against `client-go`'s fake clientset, which supports watch - so the watch conformance checks run for real, not skipped. Live-cluster behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/launchdarkly # LaunchDarkly Load a LaunchDarkly feature flag's value as config, with **native watch** via the LaunchDarkly streaming SDK. | | | | --- | --- | | Scheme | `launchdarkly://` | | Module | `github.com/xavidop/mamori/providers/launchdarkly` | | Sensitive | no | | Watch | native (streaming) | | Auth | `LAUNCHDARKLY_SDK_KEY` | ## Install ```bash go get github.com/xavidop/mamori/providers/launchdarkly ``` ```go import _ "github.com/xavidop/mamori/providers/launchdarkly" ``` ## Using the ref A `launchdarkly://` ref points at one flag key. The flag is evaluated for a fixed evaluation context (your service), and its variation becomes the value. ```text launchdarkly:// ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The LaunchDarkly flag key. | The variation maps to bytes by type: a boolean flag resolves to `true` / `false`, a string flag to its string, a number to its decimal form, and a JSON flag to its JSON encoding. A flag key that does not exist resolves to not-found, so `default:` / `optional:"true"` applies. **Examples** - `launchdarkly://enable-new-checkout` on a boolean flag resolves to `true` or `false` - pair it with a `bool` field. - `launchdarkly://max-batch-size` on a number flag resolves to e.g. `250` - pair it with an `int` field and a `validate` rule. - `launchdarkly://pricing-config` on a JSON flag resolves to the JSON, which you can decode with `flatten:"json"`. ```go type Config struct { NewCheckout bool `source:"launchdarkly://enable-new-checkout"` Pricing PricingConfig `source:"launchdarkly://pricing-config" flatten:"json"` } ``` The evaluation context defaults to a stable key (`mamori`); override it with `WithContextKey`. ## Error classification Beyond the not-found case, other evaluation failures are classified by the SDK's per-evaluation error reason: | Evaluation error kind | mamori kind | | --- | --- | | `CLIENT_NOT_READY` | `unavailable` | | `FLAG_NOT_FOUND` | not classified here - already drives not-found | | anything else (`MALFORMED_FLAG`, `USER_NOT_SPECIFIED`, `WRONG_TYPE`, `EXCEPTION`, ...) | `unknown` | LaunchDarkly's per-evaluation error vocabulary is thin: `CLIENT_NOT_READY` (evaluating before the client finished connecting) is the only kind with a confirmed, unambiguous meaning, so it is the only one mapped. The rest describe internal or data conditions with no reliable single real-world cause and are deliberately left `unknown` rather than guessed at. ## Watch `Watch` uses the SDK flag tracker: LaunchDarkly streams flag changes, and mamori emits an update the instant the flag's value for your context changes. This is a genuine push, not polling. ## Configuration ```go import ldprov "github.com/xavidop/mamori/providers/launchdarkly" mamori.WithProvider(ldprov.New(ldprov.WithSDKKey(os.Getenv("LAUNCHDARKLY_SDK_KEY")))) ``` Verified with an injected fake evaluator (including value-change subscriptions, not-found, and injected per-flag failures for error classification), so the watch and `providertest` `ErrorClassification` conformance checks run without a live LaunchDarkly. A real-SDK test is provided behind `//go:build integration`. --- > Source: https://mamorigo.dev/docs/usage # Loading and watching mamori loads your typed config from refs (environment variables, secret managers, files, and more). Two entry points, both generic over your config type `T`: `Load` reads once, `Watch` stays reconciled and hands you diff-aware callbacks. ## Quick start Define a config struct, tag each field with a `source:`, and `Load` it: ```go import ( "context" "log" "github.com/xavidop/mamori" "github.com/xavidop/mamori/secret" ) type Config struct { Port string `source:"env:PORT" default:"8080"` DBPassword secret.String `source:"aws-sm://prod/db-password"` } func main() { ctx := context.Background() cfg, err := mamori.Load[Config](ctx) if err != nil { log.Fatal(err) } log.Printf("listening on :%s", cfg.Port) pool.Connect(cfg.DBPassword.Reveal()) } ``` One call resolves every ref, applies defaults, validates, and returns a fully typed `Config`. ## Load config once `Load` resolves every ref once, applies defaults, validates, and returns the typed config. It fails fast: on any resolve or validation error it returns the zero value and a non-nil error, so you never get partial config. ```go cfg, err := mamori.Load[Config](ctx, opts...) ``` Batch-capable providers (for example AWS Secrets Manager) are resolved in a single API call automatically. ## Next - [Watch for changes](/docs/usage/watching/) - `Watch`, `Get`, `OnChange`, and `OnError`. - [Source chains](/docs/concepts/source-chains/) - comma-separated precedence and the `onfail` policy. - [Snapshots and pinning](/docs/usage/snapshots/) - `Status`, `WithHistory`, and `Pin` / `Unpin`. ## See also - [Concepts](/docs/concepts/) for refs, the tag grammar, and error kinds. - [Validation](/docs/validation/) for the defaults and validation rules applied on every load. - [Observability](/docs/observability/) for `Status`, `Health`, `Doctor`, and the read-only HTTP surface. --- > Source: https://mamorigo.dev/docs/providers/mamori # mamori (client) The `mamori://` provider is the client half of the [config server](/docs/server/): it resolves **binding names** against a running `server.Server`, over that server's v1 HTTP wire protocol, instead of resolving a secret-manager ref directly. A `mamori://` ref never names an upstream ref. `mamori://db-password` asks the config server "give me the binding called `db-password`," and the server answers with whatever value that binding currently resolves to on its own end, whether that is `vault://secret/data/db#password`, `aws-sm://prod/db`, or another mamori server three hops away. The consuming struct has no way to tell any of that apart: it sees a name go out and a value come back, exactly as it would for any other provider. | | | | --- | --- | | Scheme | `mamori://` | | Module | `github.com/xavidop/mamori/providers/mamori` (package `mamoriprov`) | | Sensitive | passthrough (the server's `Sensitive` bit survives the hop) | | Watch | **native** (Server-Sent Events, with reconnect) | | Errors | passthrough (the real upstream kind, not a generic proxy failure) | ## Install ```bash go get github.com/xavidop/mamori/providers/mamori ``` ```go import ( "github.com/xavidop/mamori" mamoriprov "github.com/xavidop/mamori/providers/mamori" ) cfg, err := mamori.Load[Config](ctx, mamori.WithProvider(mamoriprov.New(mamoriprov.Config{ Endpoint: "unix:///run/mamori.sock", })), ) ``` This provider's own package is already named `mamoriprov`, not `mamori`, precisely so that code importing both the core `github.com/xavidop/mamori` package and this provider never has to alias either one to avoid a collision. The `mamoriprov` alias on the import line above is therefore not compiler-required (Go already resolves identifiers by the package's declared name), but every example on this page writes it anyway, the same way the package's own doc comment does, so the two `mamori`-named things stay visually distinct at every call site. Unlike most providers, there is nothing to register with a blank import: a `mamori://` binding name only means something relative to one specific config server's `Endpoint`, so this provider is always constructed explicitly with `mamoriprov.New` and passed via `mamori.WithProvider`. ## Using the ref ```text mamori:// ``` `mamori://db-password` resolves the binding `db-password`. The path is a binding name the server operator declared with `server.Bind`/`server.BindFile` (see the config server's [Bindings section](/docs/server/bindings/)); it is never, and can never be, an upstream ref supplied by this side. That asymmetry is the whole point of decision D9: a client that could instead send its own ref would let the config server's credentials be used to reach anything the client asked for, not just the bindings the operator chose to expose. ```go type Config struct { DBPassword secret.String `source:"mamori://db-password"` APIKey secret.String `source:"mamori://api-key"` } ``` A struct with several `mamori://` fields, like `Config` above, does not make one round trip per field. See [Watch](#watch) below for how `Load`/`Watch`'s batch path collapses them into a single request. ## Explicit configuration ### Endpoint forms `Config.Endpoint` accepts exactly three forms: | Form | Example | Notes | | --- | --- | --- | | `unix://` | `unix:///run/mamori.sock` | Dials the Unix domain socket at the given path with a custom dialer; no TLS involved. | | `https://` | `https://config.internal:8443` | Standard TLS. Attach `Config.TLSConfig` for custom roots, or `TLSConfig.Certificates` to present a client certificate for mTLS. | | `http://` | `http://config.internal:8080` | Refused unless `Config.InsecureNoTLS` is `true`. | `InsecureNoTLS` is named to be uncomfortable to type and read, on purpose, the same way the config server's own `server.InsecureNoTLS()` transport option is: `grep -r InsecureNoTLS` finds every place a client was pointed at a plaintext endpoint, which should never be an accident. Any other scheme, or an empty `Endpoint`, is rejected with an error satisfying `errors.Is(err, mamori.ErrInvalid)`. ```go mamoriprov.New(mamoriprov.Config{ Endpoint: "https://config.internal:8443", TLSConfig: &tls.Config{ Certificates: []tls.Certificate{clientCert}, // mTLS }, }) ``` `New` never fails outright, matching how every other provider registers a zero-config default from `init`: a malformed or empty `Endpoint` is recorded on the returned `*Provider` and surfaced (wrapped as `mamori.ErrInvalid`) from the first `Resolve`, `ResolveBatch`, or `Watch` call instead. ### Several replicas When the config server runs as [several replicas](/docs/server/ha/), give the client the list instead of a single endpoint. `Config.Endpoints` replaces `Config.Endpoint` and takes the same three forms, in failover order: ```go prov, err := mamoriprov.New(mamoriprov.Config{ Endpoints: []string{ "https://mamori-0.internal:8443", "https://mamori-1.internal:8443", "https://mamori-2.internal:8443", }, }) ``` Each entry gets its own transport, so the list may mix forms, for example a local `unix://` replica tried first with TCP replicas behind it. **When it fails over.** `Resolve` and `ResolveBatch` walk the list until a replica answers, moving on only when a replica is unreachable or broken: a refused dial, a TLS failure, a timeout, `unavailable`, or an unclassifiable `5xx`. **When it does not.** An *authoritative* answer, one every replica would give alike, is returned immediately rather than replayed against the rest: `not_found`, `permission_denied`, `unauthenticated`, `invalid`, and `rate_limited`. Retrying those would turn one clean `403` into one request per replica, and in the `rate_limited` case would amplify exactly the load a throttle exists to shed. If every replica fails, the last error is returned with its kind intact, so `errors.Is(err, mamori.ErrUnavailable)` still holds. **Watch** rotates to the next endpoint on each reconnect, so a replica that dies mid-stream cannot black-hole the watch. The backoff sleep applies only after a full cycle through the list, never between one replica and the next, so a three-replica deployment fails over in three quick dials rather than sleeping in between. Setting both `Endpoint` and `Endpoints`, or a malformed entry anywhere in the list, is a configuration error surfaced from every call. Neither is quietly ignored: an operator who mistyped one of three replicas should find out, not run on two believing they have three. A single-element `Endpoints` behaves exactly like the equivalent `Endpoint`, one request and no retries. ### Client credentials The config server authenticates inbound requests with a `mamori.Authenticator` (see [Auth](/docs/auth/)), which is a server-side concept: it authenticates a request that has already arrived. This provider does not reuse `mamori.Authenticator` for the reverse direction, because attaching a credential to an outbound request is a different shape entirely. Instead: ```go mamoriprov.New(mamoriprov.Config{Endpoint: "https://config.internal:8443"}, mamoriprov.WithHeader("Authorization", "Bearer "+token), ) ``` - **`WithHeader(key, value string)`** sets one static header on every outbound request. It covers `mamori.BearerToken`, `mamori.APIKey`, and `mamori.BasicAuth` on the server side. - **`WithRequestEditor(fn func(*http.Request))`** is the general form `WithHeader` is built on, for anything more dynamic than a static header (a token refreshed per request, a signed header, and so on). - **mTLS** is configured entirely through `Config.TLSConfig.Certificates`, matching the server's `mamori.MTLS` authenticator. - **`PeerCred`** needs no client-side configuration at all: over a Unix socket, the kernel supplies the connecting process's uid/gid to the server directly (`SO_PEERCRED`/`LOCAL_PEERCRED`), so there is nothing for this provider to attach. ## Watch `mamori://` implements `mamori.WatchableProvider` as a **native** watch: mamori must never wrap it in its own polling adapter. `Watch` opens a persistent `GET /v1/watch?name=` Server-Sent Events connection and forwards every `update`/`error` frame the server sends as a `mamori.Update`. The connection is not assumed to stay up forever. The server drops the SSE stream on shutdown (and idle proxies, restarts, and ordinary network hiccups can too), so the client reconnects and resubscribes automatically, with exponential backoff and jitter between attempts, capped so a persistently unreachable server is retried at most every 30 seconds. Backoff resets to its floor as soon as a stream is successfully re-established, regardless of how long it then stays up. None of this is visible on the `<-chan mamori.Update` the caller reads from: a transient disconnect-and-reconnect is not itself delivered as an error, only a genuine classified failure from the server is (see [Error classification](#error-classification) below). A struct with several `mamori://` fields does not open several connections, either for a one-shot resolve or for `ResolveBatch`: `mamori.Load`/`mamori.Watch` group same-provider refs and this provider answers a batch with a single `POST /v1/values` request carrying every binding name at once, rather than one request per field. ### Going backwards in time is prevented `Watch` reconnects on its own, and across [several replicas](#several-replicas) it rotates endpoints, so a reconnect can land on a replica whose copy of a binding is older. Left alone, that older value would arrive looking like a brand new change. It does not. The client tracks the newest `resolved_at` it has delivered for each binding and drops an update older than that watermark. The watermark is per binding name and **survives reconnects**, which is the whole point: the reconnect is exactly when the risk appears. Three deliberate details: - An update carrying no `resolved_at` is always forwarded, so an older server that does not send the field behaves exactly as before. - Error frames are never dropped. An error describes the connection right now and cannot be out of order. - Comparison is against wall clocks on different machines, so a one second tolerance absorbs ordinary clock skew. Replica lag is tens of seconds (the upstream poll interval) while NTP-synced hosts sit within milliseconds, so the band is far below the lag it is catching, and ties go to delivering the value rather than withholding it. ## Error classification Classification is a full passthrough, not just a "some backend, some error" mapping: the wire `kind` the config server reports is exactly the `mamori.Kind` its own upstream provider produced, and this client reconstructs the matching sentinel from it. Concretely: - `errors.Is(err, mamori.ErrPermissionDenied)` holds against a `mamori://` resolve exactly when it would hold resolving the real backend ref directly, through the config server hop, because the server's own classification (see the config server's [wire protocol section](/docs/server/wire-protocol/)) never gets flattened into something generic on the way back. - `mamori.Doctor` and a running watcher's `Status`/`Health` report the real upstream kind in `FieldStatus.LastKind`, not a generic "the proxy failed" kind. A `vault://` binding whose Vault lease was revoked reports `permission_denied` through `mamori://` exactly as it would resolving `vault://` directly. - A wire `not_found` (an unbound name, or the server's own upstream reporting one) maps to `mamori.ErrNotFound`, so a struct field's default value or `Optional` tag still applies exactly as it would for any other provider. - `mamori.Value.Sensitive` survives the hop unchanged: a binding backed by a secret-manager ref that sets `Sensitive` keeps it set on this side, so `secret.String`/`secret.Bytes` redaction, audit logging, and every other sensitivity-aware behavior downstream of `Load`/`Watch` keeps working exactly as if the field had resolved directly against the upstream provider. A batch (`ResolveBatch`) treats a hard per-name classified error (anything other than `not_found`) as a whole-call failure rather than silently dropping that one entry, since dropping it would let a struct field fall back to its zero value in place of a secret the caller was denied, or one that is genuinely unavailable - not something a per-ref `Resolve` of that same name would ever do either. Across replicas, a value response also carries `resolved_at` (when that replica last fetched the bytes from upstream) and `stale` (it is serving last-known-good while upstream fails). Because each replica watches on its own schedule, these are what let a client tell that it reached a replica running behind one it already spoke to. See [High availability](/docs/server/ha/). See the config server's page for the full [`kind` field semantics](/docs/server/wire-protocol/#read-kind-on-a-success-fresh-vs-stale-but-serving), including the distinction between a failure `kind` and a successful-but-stale `kind` on a value that is still being served while its upstream is currently failing (this provider returns that stale value with a nil error, exactly as the server intends). --- > Source: https://mamorigo.dev/docs/cli/doctor-status # mamori doctor and status Both are thin clients of a running process's admin endpoint (`mamori.WithAdminHTTP` / `mamori.Handler`, see [Observability](/docs/observability/)). They GET `/`, render the `mamori.Report` that comes back, and resolve nothing themselves. `doctor` is a one-shot health check with drift detection; `status` is the same table, optionally on a loop. For the library-side preflight you run in CI before deploying, see [Doctor: pre-deploy check](/docs/observability/doctor/). That runs your exact wiring inside your build; `mamori doctor` runs from anywhere against a process that is already running. ## doctor ```bash mamori doctor --endpoint [--insecure] [--json] [--compare ""] ``` - `--json` emits the admin endpoint's raw response body unchanged, byte for byte, instead of a table. - `--compare` takes space-separated Go package patterns. It statically extracts `source:` refs (the same walk [`explain`](/docs/cli/explain/) uses) and flags any field present in source but missing from the live report, or present live but not in source. It needs a buildable source tree and does not change the exit code. ```bash $ mamori doctor --endpoint https://svc.internal:9090 --compare ./... PATH SCHEME REF VERSION STALE LAST_KIND LAST_ERROR SENSITIVE Redis.Addr env env://REDIS_ADDR 3 false - - false Redis.Password aws-sm aws-sm://prod/redis-pw 3 false - - true HEALTHY: 2 field(s), snapshot 3 (live 3), generated 2026-07-26T10:00:00Z compare: source vs. live field paths no drift: source and live field sets match ``` ## status Renders the same report table as `doctor`, without `--compare` or `--json`. ```bash mamori status --endpoint [--insecure] [--watch] [--interval ] ``` - `--watch` renders immediately, then again on every tick of `--interval` (default `2s`) until interrupted with Ctrl-C. - `--interval ` sets the poll interval when `--watch` is set (e.g. `5s`). ```bash $ mamori status --endpoint unix:///run/app-admin.sock --watch --interval 5s PATH SCHEME REF VERSION STALE LAST_KIND LAST_ERROR SENSITIVE Redis.Addr env env://REDIS_ADDR 3 false - - false HEALTHY: 1 field(s), snapshot 3 (live 3), generated 2026-07-26T10:00:00Z # ...re-renders every 5s until Ctrl-C ``` ## Exit codes Both live commands share one exit-code table, so a script can tell "my config is broken" (`1`) apart from "I couldn't even see my config" (`2`/`3`/`4`). | Code | Meaning | | --- | --- | | `0` | Healthy: every field is fresh with no terminal error | | `1` | Unhealthy: reachable, but at least one field is stale or terminally errored | | `2` | Reachable, but not a usable mamori admin API: a 404, or a 200 body that does not decode as a `mamori.Report` | | `3` | Unreachable: never got an HTTP response (connection refused, no such socket, TLS failure, or a malformed `--endpoint`) | | `4` | Auth failed: a reachable mamori admin API returned `401` | Branch on this directly: `1` means fix the config; `2`/`3` mean fix connectivity (or wire up `mamori.WithAdminHTTP`); `4` means fix the credential. `--watch` never returns these mid-loop: each poll's outcome is only printed, and the command returns `0` once you interrupt it. ## Endpoint forms `--endpoint` accepts three forms, matching what the admin endpoint and the [config server](/docs/server/) serve: - `https://host:port` - `unix:///path/to.sock` - `http://host:port`, only with `--insecure` passed explicitly; a plain `http://` endpoint is refused otherwise. ## Auth flags Credentials reuse the same `Authenticator` schemes the admin endpoint supports. Bearer and basic are mutually exclusive. | Flag | Purpose | | --- | --- | | `--bearer` / `--bearer-file` | Bearer token, as a value or a file path (`-` for stdin) | | `--basic` / `--basic-file` | `user:pass`, as a value or a file path (`-` for stdin) | | `--client-cert` / `--client-key` | Client certificate and key (PEM) for mTLS | **Prefer the file/stdin forms.** `--bearer`/`--basic` put the credential in `os.Args`, visible to anything that can read this process's argv (e.g. `ps`). The `-file` forms (including `-` for stdin) keep the token out of both shell history and argv. ## Custom provider schemes with --compare `--compare` is the only part of `doctor` that reads source, so it is the only part affected by the scheme set. If you [wrote your own provider](/docs/writing-a-provider/), name its scheme so drift detection classifies its fields as secrets: ```bash mamori doctor --endpoint https://svc:9090 --compare ./... --secret-schemes=mysecrets ``` The flag is validated before any network call, so a typo fails immediately rather than after the round trip. See [`mamori vet`](/docs/cli/vet/#covering-a-custom-provider) for the built-in set. ## See also [CLI overview](/docs/cli/). [Observability](/docs/observability/) covers the `Report` shape these render; [Config server](/docs/server/) shares the same endpoint forms and auth schemes. --- > Source: https://mamorigo.dev/docs/cli/explain # mamori explain Prints every struct type with at least one `source:`-tagged field: its field paths, Go types, source chains, defaults, and which fields are sensitive. It reads Go source only and resolves nothing. ```bash mamori explain [patterns...] [--type=Name] [--json] ``` - `patterns` are Go package patterns (default: the current directory, e.g. `./...`). - `--type=Name` narrows to one struct by name. - `--json` emits the same data as JSON instead of a table. ## Table output One row per field, under a `package.TypeName` banner: ```bash $ mamori explain ./... main.Config FIELD TYPE CHAIN DEFAULT OPTIONAL SENSITIVE Redis.Addr string env://REDIS_ADDR - false false Redis.Password secret.String aws-sm://prod/redis-pw - false true Timeout int env://TIMEOUT 30 false false ``` ## Columns | Column | Meaning | | --- | --- | | `FIELD` | Dotted field path, e.g. `Redis.Password` | | `TYPE` | The field's Go type, e.g. `secret.String` | | `CHAIN` | The `source:` tag's comma-separated ref chain, in precedence order (see [Source chains](/docs/concepts/source-chains/)) | | `DEFAULT` | The `default:` tag's value, or `-` if none | | `OPTIONAL` | Whether the field carries `optional:"true"` | | `SENSITIVE` | Whether the field is `secret.String`/`secret.Bytes`, or any ref in its chain uses a secret-bearing scheme | ## Custom provider schemes `SENSITIVE` is computed from the same [built-in scheme set `mamori vet` uses](/docs/cli/vet/#what-it-flags), which only knows the providers mamori ships. If you [wrote your own provider](/docs/writing-a-provider/), name its scheme so its fields report as sensitive: ```bash mamori explain --secret-schemes=mysecrets ./... ``` Pass several as a comma-separated list. The flag adds to the built-in set rather than replacing it, and takes a bare scheme token (`mysecrets`), not a full ref. ## See also [`mamori schema`](/docs/cli/schema/) turns the same structs into a JSON Schema; [`mamori policy`](/docs/cli/policy/) turns their refs into an access artifact. [CLI overview](/docs/cli/). --- > Source: https://mamorigo.dev/docs/cli/policy # mamori policy Emits a least-privilege access artifact derived entirely from the `source:` refs in the loaded packages. It resolves nothing, and never fabricates an identifier no ref carries: missing account IDs, projects, and store names show up as clearly-marked placeholders for you to fill in. ```bash mamori policy [patterns...] [--type=Name] --format=aws-iam|gcp|external-secret ``` `--format` is required. A format whose relevant refs are empty still emits a valid, empty artifact plus a stderr note, never a misleadingly-complete success. ## --format=aws-iam Grants `secretsmanager:GetSecretValue` on every `aws-sm://` ref and `ssm:GetParameter`/`ssm:GetParameters` on every `aws-ps://` ref. The account ID and region are not part of a ref (both come from ambient AWS config at resolve time), so every ARN uses a `*:*` placeholder for them. ```bash $ mamori policy ./... --format=aws-iam { "Version": "2012-10-17", "Statement": [ { "Sid": "SecretsManagerGetSecretValue", "Effect": "Allow", "Action": ["secretsmanager:GetSecretValue"], "Resource": ["arn:aws:secretsmanager:*:*:secret:prod/redis-pw"] } ] } ``` ## --format=gcp Lists `roles/secretmanager.secretAccessor` and the `projects//secrets/` resource name for every `gcp-sm://` ref. The project comes from the ref itself (`gcp-sm:///`); `PROJECT` only appears as a placeholder for a malformed ref. This is a summary you turn into IAM bindings (one `gcloud secrets add-iam-policy-binding` per resource), not a ready-to-apply policy: a real binding also needs a principal, which no `source:` ref carries. ```bash $ mamori policy ./... --format=gcp { "role": "roles/secretmanager.secretAccessor", "resources": ["projects/my-project/secrets/redis-pw"] } ``` ## --format=external-secret Emits an `external-secrets.io/v1` `ExternalSecret` manifest with one `spec.data` entry per `aws-sm://`, `aws-ps://`, or `gcp-sm://` ref. `spec.secretStoreRef.name` is always a placeholder (`REPLACE_ME_SECRET_STORE`): no `source:` ref names a Kubernetes `SecretStore`. ```yaml $ mamori policy ./... --format=external-secret apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: name: mamori-managed-secrets spec: secretStoreRef: name: REPLACE_ME_SECRET_STORE kind: SecretStore target: name: mamori-managed-secrets data: - secretKey: aws-sm-redis-pw remoteRef: key: prod/redis-pw ``` ## Custom provider schemes Sensitivity is computed from the same [built-in scheme set `mamori vet` uses](/docs/cli/vet/#what-it-flags), which only knows the providers mamori ships. If you [wrote your own provider](/docs/writing-a-provider/), name its scheme so its fields are treated as secrets here too: ```bash mamori policy --format=aws-iam --secret-schemes=mysecrets ./... ``` The flag adds to the built-in set rather than replacing it, takes a bare scheme token (not a full ref), and is accepted by `explain`, `schema`, `policy`, `vet`, and `doctor --compare`, so every command agrees on what counts as a secret. ## See also [`mamori explain`](/docs/cli/explain/) shows the refs this reads. [CLI overview](/docs/cli/). --- > Source: https://mamorigo.dev/docs/cli/schema # mamori schema Emits a JSON Schema (draft 2020-12) derived from each qualifying struct's field types and `validate:` tags, ready to feed straight into a JSON Schema validator. ```bash mamori schema [patterns...] [--type=Name] ``` - `--type=Name` narrows to one struct by name. - If exactly one struct qualifies, the output is a single schema document. If more than one qualifies, it is a JSON array of documents, each carrying a `title` of `package.TypeName`. ## Schema output ```bash $ mamori schema ./... --type=Config { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "main.Config", "type": "object", "properties": { "Redis": { "type": "object", "properties": { "Addr": { "type": "string" }, "Password": { "type": "string" } }, "required": ["Addr", "Password"] }, "Timeout": { "type": "integer", "minimum": 1, "default": 30 } } } ``` ## How validate tags map | `validate:` rule | JSON Schema | | --- | --- | | `required` | field listed in the object's `required` array | | `oneof=a b c` | `enum` | | `gte` / `lte` (numeric only) | `minimum` / `maximum` | | `min` / `max` on a number field | `minimum` / `maximum` | | `min` / `max` on a string field | `minLength` / `maxLength` | A field is also `required` when it has no `default:` and is not `optional:"true"`. A `default:` tag becomes the schema's `default`, typed as a JSON number where the field is numeric. ## Custom provider schemes Sensitivity is computed from the same [built-in scheme set `mamori vet` uses](/docs/cli/vet/#what-it-flags), which only knows the providers mamori ships. If you [wrote your own provider](/docs/writing-a-provider/), name its scheme so its fields are treated as secrets here too: ```bash mamori schema --secret-schemes=mysecrets ./... ``` The flag adds to the built-in set rather than replacing it, takes a bare scheme token (not a full ref), and is accepted by `explain`, `schema`, `policy`, `vet`, and `doctor --compare`, so every command agrees on what counts as a secret. ## See also [`mamori explain`](/docs/cli/explain/) lists the same structs as a table. [CLI overview](/docs/cli/). --- > Source: https://mamorigo.dev/docs/middleware # Middleware Because every provider is a `Provider`, decorators from `github.com/xavidop/mamori/middleware` nest freely. They instrument the resolve path; native watch is delegated to the wrapped provider. ## Using middleware ```go import "github.com/xavidop/mamori/middleware" mamori.WithProvider( middleware.Cache(5*time.Minute, // memoize resolves for a TTL middleware.Audit(logger, // log every access, never the payload middleware.RateLimit(10, // <= 10 resolves/second middleware.Failover( // primary, then replicas primarySM, replicaSM, ), ), ), ), ) ``` | Middleware | What it does | | --- | --- | | `Cache(ttl, inner)` | Memoize successful resolves for `ttl`. Errors and not-found are not cached. | | `Audit(logger, inner)` | Log scheme, ref, latency, and outcome. Never the value. | | `Failover(primary, replicas...)` | Try primary, then each replica on a transport error. Not-found is authoritative. | | `RateLimit(rps, inner)` | Space resolves to at most `rps` per second. | | `Prefix(prefix, inner)` | Rewrite each ref's path under a namespace (multi-tenant). | `Cache` and `RateLimit` accept a `WithClock` option for deterministic tests. ## Writing middleware Middleware is just a `Provider` that wraps another `Provider`: ```go func Trace(inner mamori.Provider) mamori.Provider { return &tracer{inner: inner} } type tracer struct{ inner mamori.Provider } func (t *tracer) Scheme() string { return t.inner.Scheme() } func (t *tracer) Resolve(ctx context.Context, ref mamori.Ref) (mamori.Value, error) { start := time.Now() v, err := t.inner.Resolve(ctx, ref) log.Printf("resolve %s took %s (err=%v)", ref.Raw, time.Since(start), err) return v, err } // Optionally forward Watch so a watchable inner stays watchable: func (t *tracer) Watch(ctx context.Context, ref mamori.Ref) (<-chan mamori.Update, error) { if wp, ok := t.inner.(mamori.WatchableProvider); ok { return wp.Watch(ctx, ref) } return nil, errors.New("inner is not watchable") // mamori falls back to polling } ``` The shipped middleware uses a shared wrapper that preserves `WatchableProvider` and `BatchProvider` when the inner supports them, so decoration never silently drops a capability. --- > Source: https://mamorigo.dev/docs/providers/mongodb # MongoDB Read a value from a MongoDB document, with **native watch** via change streams. | | | | --- | --- | | Scheme | `mongodb://` | | Module | `github.com/xavidop/mamori/providers/mongodb` | | Sensitive | no | | Watch | native (change streams) | | Auth | `MONGODB_URI` (+ `WithDatabase`) | ## Install ```bash go get github.com/xavidop/mamori/providers/mongodb ``` ```go import _ "github.com/xavidop/mamori/providers/mongodb" ``` ## Using the ref A `mongodb://` ref points at one document in a collection, optionally selecting a single field from it. ```text mongodb:///[#field][?key=] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The collection to look in. | | `` | yes | Identifies the document. By default matched as `_id == ` (as an ObjectID when it is a valid 24-character hex string, otherwise as a plain value). | | `#field` | no | Return one field of the matched document (via `mamori.SelectKey`). Without it, the whole document is returned as JSON. | | `?key=` | no | Match the document by an arbitrary field instead of `_id`, i.e. where ` == `. | **Examples** - `mongodb://config/service` - returns the whole document with `_id == "service"` as JSON. - `mongodb://config/service#endpoint` - returns just the `endpoint` field of that document. - `mongodb://users/507f1f77bcf86cd799439011#email` - returns the `email` field of the user whose ObjectID `_id` is `507f...`. - `mongodb://users/ada@example.com#apiKey?key=email` - returns `apiKey` for the user whose `email == "ada@example.com"`. ```go type Config struct { Endpoint string `source:"mongodb://config/service#endpoint"` Whole string `source:"mongodb://config/service"` // entire document as JSON } ``` Per the mamori grammar the `#field` fragment comes before the `?opts` query (`mongodb://coll/id#field?key=email`). `Value.Version` is the document's `version` field when present, otherwise a content hash over the document JSON. Values are non-sensitive; wrap a field in `secret.String` for redaction. Native watch needs a replica set (or sharded cluster) - change streams do not run against a standalone `mongod`, where mamori falls back to polling. ## Watch `Watch` opens a change stream on the collection filtered to the target document and emits an update on each change. Change streams require the server to be a replica set (or sharded cluster). ## Error classification Beyond the not-found case, other `FindOne`/change-stream failures are classified by MongoDB server error code so `mamori.ErrorKind` can distinguish them: | Code | mamori kind | |---|---| | `18` (AuthenticationFailed) | `unauthenticated` | | `13` (Unauthorized) | `permission_denied` | | anything else | `unknown` | This table is deliberately small: only auth and authorization are classified today. MongoDB has dozens of replica-set and write codes (`NotWritablePrimary`, `PrimarySteppedDown`, and similar) whose mapping to `unavailable` is arguable and whose reachability on a read is uncertain, so they are left unmapped rather than guessed at. The original `mongo.CommandError` stays reachable with `errors.As`. ## Configuration ```go import mongoprov "github.com/xavidop/mamori/providers/mongodb" mamori.WithProvider(mongoprov.New( mongoprov.WithURI(os.Getenv("MONGODB_URI")), mongoprov.WithDatabase("app"), )) ``` Verified with an in-memory fake; live behavior against a replica set is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/mysql # MySQL Read a config or secret value from a MySQL / MariaDB table. | | | | --- | --- | | Scheme | `mysql://` | | Module | `github.com/xavidop/mamori/providers/mysql` | | Sensitive | no (opt-in with `WithSensitive`) | | Watch | poll | | Auth | DSN (`WithDSN` / `MYSQL_DSN`) | ## Install ```bash go get github.com/xavidop/mamori/providers/mysql ``` ```go import _ "github.com/xavidop/mamori/providers/mysql" ``` ## Using the ref A `mysql://` ref points at one row of a MySQL or MariaDB table (a key/value lookup), optionally selecting a field from a JSON value. ```text mysql://
/[#json-field][?key_col=&val_col=] ``` | Part | Required | What it means | | --- | --- | --- | | `
` | yes | The table to read. Validated against a strict identifier allowlist, then backtick-quoted. | | `` | yes | The row key, bound as a `?` placeholder (never interpolated) and matched with `WHERE = ?`. | | `#json-field` | no | Parse the value column as a JSON object and return one field (via `mamori.SelectKey`). | | `?key_col=` | no | Override the key column name (default `key`). | | `?val_col=` | no | Override the value column name (default `value`). | **Examples** - `mysql://settings/greeting` - runs `SELECT value FROM settings WHERE key = ?` with the key bound to `greeting`. - `mysql://settings/workers?val_col=int_value` - reads the same row but from the `int_value` column instead of `value`. - `mysql://config/db#host` - reads the JSON object at key `db` and returns its `host` field. - `mysql://settings/feature_x?key_col=name&val_col=data` - runs `SELECT data FROM settings WHERE name = ?`. ```go type Config struct { Greeting string `source:"mysql://settings/greeting"` Workers int `source:"mysql://settings/workers?val_col=int_value"` } ``` The row key is always a bound `?` placeholder; the table and column names are validated against a strict identifier allowlist (`^[A-Za-z_][A-Za-z0-9_]*$`) and backtick-quoted before any query runs, so a ref can never inject SQL. `Value.Version` is a content hash of the value by default, or a native revision column via `WithVersionColumn`. Values are non-sensitive unless you set `WithSensitive(true)` or wrap the field in `secret.String`. ## Watch MySQL has no built-in change notification, so mamori polls (`WithPollInterval` + jitter). ## Configuration ```go import mysqlprov "github.com/xavidop/mamori/providers/mysql" mamori.WithProvider(mysqlprov.New(mysqlprov.WithDSN("user:pass@tcp(mysql:3306)/appdb"))) ``` ## Error classification Beyond the not-found case, query failures are classified by the driver's numeric server error code so `mamori.ErrorKind` can distinguish them: | Number | mamori kind | |---|---| | `1044` (ER_DBACCESS_DENIED_ERROR), `1142` (ER_TABLEACCESS_DENIED_ERROR) | `permission_denied` | | `1045` (ER_ACCESS_DENIED_ERROR) | `unauthenticated` | | `1040` (ER_CON_COUNT_ERROR), `1203` (ER_TOO_MANY_USER_CONNECTIONS) | `unavailable` | | anything else | `unknown` | MySQL has no rate-limit error class, so nothing maps to `rate_limited`, and the syntax-error code is unreachable through this provider's fixed query template, so nothing maps to `invalid` either. Numbers not listed above report `unknown` rather than being guessed at, and the original `*mysqldriver.MySQLError` stays reachable with `errors.As`. A refused TCP connection (client errors 2002/2003, a fully down database) is **not** a `*MySQLError` - it is a net-level error whose exact wrapped type is not stable enough to match reliably, so it deliberately reports `unknown` rather than being type-guessed. A connection-limit rejection (`1040`/`1203`, a reachable but overloaded database) still reports `unavailable`. Verified with an in-memory fake (including an identifier-allowlist rejection test); live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/observability # Observability Three functions answer "is my config healthy" at three different times: `Status` for a live per-field snapshot, `Health` for a single yes/no a probe can act on, and `Doctor` for a pre-deploy check that runs before a watcher ever starts. ```mermaid flowchart LR D["Doctor - before a watcher starts (CI / pre-deploy)"] S["Status - live per-field snapshot while running"] H["Health - one yes/no for a readiness probe"] D --> Deploy([Deploy]) Deploy --> S Deploy --> H ``` ## Quick start Read a running watcher's per-field health with `Status`, and back a readiness probe with `Health`. ```go w, err := mamori.Watch[Config](ctx) if err != nil { log.Fatal(err) } defer w.Close() // Live snapshot: per-field health right now. for _, f := range w.Status().Fields { log.Printf("%s (%s): kind=%s stale=%v age=%s", f.Path, f.Scheme, f.LastKind, f.Stale, f.Age) } // One-shot yes/no, ready to back a readiness probe. http.HandleFunc("/readyz", func(rw http.ResponseWriter, r *http.Request) { if err := w.Health(); err != nil { http.Error(rw, err.Error(), http.StatusServiceUnavailable) return } rw.WriteHeader(http.StatusOK) }) ``` ## Status: the live snapshot ```go func (w *Watcher[T]) Status() Report ``` `Status` returns a point-in-time report of the watcher's per-field health. `Age` and `Stale` are recomputed against the watcher's clock at call time, so a watcher that has gone quiet does not keep reporting the age it had at the last reconcile. It is lock-free. ```go for _, f := range w.Status().Fields { log.Printf("%s (%s): kind=%s stale=%v age=%s", f.Path, f.Scheme, f.LastKind, f.Stale, f.Age) } ``` A `Report` is safe to log, serialize, or hand to another team: `Ref` has sensitive query options redacted, and no field's resolved value ever appears in a `FieldStatus`. This holds whether the report came from a running `Watcher` or from [`Doctor`](/docs/observability/doctor/). ### Report and FieldStatus Every report shares this shape, whichever function produced it. ```go 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 } 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 } ``` `Snapshot` and `Live` are equal, and `Pinned` is `false`, unless the watcher is frozen with `Watcher.Pin` / `Watcher.PinCurrent`: see [Snapshots and pinning](/docs/usage/snapshots/) for what that divergence means. ## Health: one yes/no for a probe ```go func (w *Watcher[T]) Health() error ``` `Health` returns `nil` when every field is fresh and no field carries a terminal error kind. Otherwise it returns a `*HealthError` naming the offending fields, so a caller can log which fields are broken instead of a bare "unhealthy". ```go if err := w.Health(); err != nil { var he *mamori.HealthError if errors.As(err, &he) { for _, f := range he.Fields { log.Printf("unhealthy: %s (%s): %s", f.Path, f.LastKind, f.LastError) } } } ``` ### When is a field unhealthy? One rule, shared by `Status`, `Health`, and `Doctor`: - **Terminal kinds are unhealthy immediately**: `not_found`, `permission_denied`, `unauthenticated`, `invalid`. These will not clear without human action. - **Transient kinds (`unavailable`, `rate_limited`) only count once the field is also stale**, that is, once `Age` exceeds the threshold set with `WithStale(maxAge)`. A brief blip does not flip readiness; a field stuck past the stale threshold does. - No error at all is judged purely by staleness too. See [Error kinds](/docs/concepts/error-kinds/) for the full list of `Kind` values. ## Next - [Doctor: pre-deploy check](/docs/observability/doctor/) - resolve every field once before a watcher starts, and fail a CI build if config would not resolve. - [HTTP exposure](/docs/observability/admin/) - serve the report on your own mux or a standalone admin server, and back a Kubernetes readiness probe. ## See also - [Config server](/docs/server/) serves resolved config *values* to many callers, the counterpart to this metadata-only endpoint. - [Auth](/docs/auth/) covers `WithAuth`, the shipped schemes, and credential rotation for the admin endpoint. - [OpenTelemetry](/docs/opentelemetry/) covers metrics and tracing for individual resolves, complementary to these reports: it answers "what happened over time," `Status`/`Health`/`Doctor` answer "what is true right now." --- > Source: https://mamorigo.dev/docs/opentelemetry # OpenTelemetry The `github.com/xavidop/mamori/x/otel` bridge (package `mamoriotel`) turns mamori's config resolves into OpenTelemetry spans and metrics: one `mamori.resolve` span per resolve, plus latency, refresh, and watch-error instruments, all tagged with the provider scheme and, on failure, a `mamori.error.kind` classification. Reach for it when you already run OTel and want config resolution to show up in the same traces and dashboards as the rest of your service. ## Quick start Install the bridge module (the core `mamori` module stays free of any OpenTelemetry dependency): ```bash go get github.com/xavidop/mamori/x/otel ``` Wrap an OTel meter and tracer with `NewMeter` / `NewTracer`, then pass the results to `mamori.WithMeter` / `mamori.WithTracer`: ```go package main import ( "context" "log" "go.opentelemetry.io/otel" "github.com/xavidop/mamori" mamoriotel "github.com/xavidop/mamori/x/otel" ) type Config struct { Port int `mamori:"file:///etc/app.yaml#port"` } func run(ctx context.Context) error { // otel.Meter / otel.Tracer come from your configured OTel providers; // substitute your own MeterProvider / TracerProvider if you do not use // the globals. meter, err := mamoriotel.NewMeter(otel.Meter("mamori")) if err != nil { return err } tracer := mamoriotel.NewTracer(otel.Tracer("mamori")) w, err := mamori.Watch[Config](ctx, mamori.WithMeter(meter), mamori.WithTracer(tracer), ) if err != nil { return err } defer w.Close() log.Printf("port: %d", w.Get().Port) return nil } ``` Every resolve now emits a `mamori.resolve` span and records to `mamori.resolve.duration`; failed resolves also carry `mamori.error.kind`. ## Trace each resolve `NewTracer` wraps an OTel `trace.Tracer` and starts one span per resolve, ending it with the correct status (and a recorded error on failure). Pass the result to `mamori.WithTracer`: ```go tracer := mamoriotel.NewTracer(otel.Tracer("mamori")) w, err := mamori.Watch[Config](ctx, mamori.WithTracer(tracer)) ``` Each resolve produces one span, summarized below: | Field | Value | | --- | --- | | Name | `mamori.resolve` (exported as `SpanResolve`) | | Attributes | `mamori.scheme`, `mamori.ref`, and `mamori.error.kind` (failed resolves only) | | Status on success | `Ok` | | Status on failure | `Error`, with the error message as the description and the error recorded as an `exception` span event via `RecordError` | ## Record metrics `NewMeter` wraps an OTel `metric.Meter` and registers three instruments up front, recording to them as mamori resolves and reconciles config. Pass the result to `mamori.WithMeter`: ```go meter, err := mamoriotel.NewMeter(otel.Meter("mamori")) if err != nil { return err } w, err := mamori.Watch[Config](ctx, mamori.WithMeter(meter)) ``` The three instruments and their attributes: | Instrument | Name | Kind | Unit | Attributes | | --- | --- | --- | --- | --- | | Resolve duration | `mamori.resolve.duration` | Float64 histogram | `ms` | `scheme`, `status` (`ok` \| `error`), `mamori.error.kind` (failed resolves only) | | Refresh count | `mamori.refresh.count` | Int64 counter | - | `scheme` | | Watch errors | `mamori.watch.errors` | Int64 counter | - | `scheme` | - `scheme` is the provider scheme of the resolved ref (e.g. `file`, `aws`, `vault`). - `status` is `error` when the resolve returned a non-nil error, otherwise `ok`. The instrument names are also exported as constants (`MetricResolveDuration`, `MetricRefreshCount`, `MetricWatchErrors`). ## The `mamori.error.kind` attribute `mamori.error.kind` is the useful differentiator for dashboards: it lets you separate a denied permission from a rate limit from a missing key rather than lumping every failure into one `error` bucket. It appears on both the `mamori.resolve.duration` histogram and the `mamori.resolve` span. It is present only when the resolve fails, carrying the same classification as `mamori.ErrorKind(err)` (see [Concepts](/docs/concepts/error-kinds/)): - `not_found` - `permission_denied` - `unauthenticated` - `unavailable` - `rate_limited` - `invalid` - `unknown` It is never set to an empty or placeholder value on success, so its mere presence on the histogram or the span selects failed resolves without also checking `status` or the span status. ## How it works The core module takes no OpenTelemetry dependency. `WithMeter` and `WithTracer` accept tiny internal interfaces (`mamori.Meter`, `mamori.Tracer`), and this bridge is what adapts a real OTel `metric.Meter` and `trace.Tracer` to them. You only pull in OTel if you opt into the bridge. The Go package is named `mamoriotel` (rather than `otel`) so it can be imported alongside `go.opentelemetry.io/otel` without a name clash. `NewMeter` returns an error if any instrument fails to register, and the meter records measurements against `context.Background()`. Both adapters are safe for concurrent use. Because the bridge only implements the small `mamori.Meter` / `mamori.Tracer` interfaces, you can also write your own sink (to Prometheus, statsd, or a test recorder) without pulling in OpenTelemetry at all. ## See also [Observability](/docs/observability/) covers `Status`, `Health`, and the pre-deploy `Doctor` check, which answer "what is true right now" where the spans and metrics here answer "what happened over time." [Concepts](/docs/concepts/error-kinds/) covers the error-kind classification that `mamori.error.kind` carries. --- > Source: https://mamorigo.dev/docs/providers/postgres # PostgreSQL Read a config or secret value from a Postgres table, with **native watch** via `LISTEN`/`NOTIFY`. Built on `pgx/v5`. | | | | --- | --- | | Scheme | `postgres://` | | Module | `github.com/xavidop/mamori/providers/postgres` | | Sensitive | no (opt-in with `WithSensitive`) | | Watch | native (LISTEN/NOTIFY) | | Auth | `DATABASE_URL` (or `WithDSN`) | ## Install ```bash go get github.com/xavidop/mamori/providers/postgres ``` ```go import _ "github.com/xavidop/mamori/providers/postgres" ``` ## Using the ref A `postgres://` ref points at one row of a table (a key/value lookup), optionally selecting a field from a JSON value. ```text postgres://
/[#json-field][?key_col=&val_col=] ``` | Part | Required | What it means | | --- | --- | --- | | `
` | yes | The table to read. May be schema-qualified with a single dot (`public.settings`). Validated against a strict identifier allowlist. | | `` | yes | The row key, bound as the `$1` query parameter (never interpolated) and matched with `WHERE = $1`. | | `#json-field` | no | Parse the value column as a JSON object and return one field (via `mamori.SelectKey`). | | `?key_col=` | no | Override the key column name (default `key`). | | `?val_col=` | no | Override the value column name (default `value`). | **Examples** - `postgres://settings/greeting` - runs `SELECT value FROM settings WHERE key = $1` with `$1 = 'greeting'`. - `postgres://settings/http?val_col=int_value` - reads the same row but from the `int_value` column instead of `value`. - `postgres://settings/db#host` - reads the JSON object at key `db` and returns its `host` field. - `postgres://public.settings/feature_x?key_col=name&val_col=data` - runs `SELECT data FROM public.settings WHERE name = $1`. ```go type Config struct { Greeting string `source:"postgres://settings/greeting"` Timeout int `source:"postgres://settings/http?val_col=int_value"` DBPass secret.String `source:"postgres://secrets/db_password"` } ``` The row key is always bound as the `$1` parameter, while the table and column names are validated against a strict identifier allowlist (`^[A-Za-z_][A-Za-z0-9_]*$`, with one optional schema dot) before any query is built, so a ref can never be used for SQL injection. `Value.Version` is a content hash of the value by default, or the column named by `WithVersionColumn` (cast to text) for exact native change detection. Values are non-sensitive unless you set `WithSensitive(true)` or wrap the field in `secret.String`. ## Watch `Watch` issues `LISTEN ` (default `mamori_config`) and re-queries on each `NOTIFY`. Have your writes signal the channel, for example with a trigger: ```sql CREATE OR REPLACE FUNCTION mamori_notify() RETURNS trigger AS $$ BEGIN PERFORM pg_notify('mamori_config', NEW.key); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER settings_notify AFTER INSERT OR UPDATE ON settings FOR EACH ROW EXECUTE FUNCTION mamori_notify(); ``` ## Configuration ```go import pgprov "github.com/xavidop/mamori/providers/postgres" mamori.WithProvider(pgprov.New(pgprov.WithDSN(os.Getenv("DATABASE_URL")))) ``` ## Error classification Beyond the not-found case, query failures are classified by SQLSTATE so `mamori.ErrorKind` can distinguish them: | SQLSTATE | mamori kind | |---|---| | `42501` (insufficient_privilege) | `permission_denied` | | `28P01` (invalid_password), `28000` (invalid_authorization_specification) | `unauthenticated` | | `53300` (too_many_connections), `57P03` (cannot_connect_now), `08006`, `08001`, `08004` (connection failures) | `unavailable` | | anything else | `unknown` | PostgreSQL has no rate-limit SQLSTATE class, so nothing maps to `rate_limited`. Codes not listed above report `unknown` rather than being guessed at, and the original `*pgconn.PgError` stays reachable with `errors.As`. Because the pool is lazy, this is what turns a bad password or a refused connection, which only surface at query time, into a diagnosable kind instead of a bare `unknown`. Verified with an in-memory fake (including a test that the identifier allowlist rejects malicious names); live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers # Providers A provider resolves one scheme. It implements `mamori.Provider` (`Scheme()` + `Resolve`), optionally `WatchableProvider` (native push) and `BatchProvider` (one call for many refs). Register with the `database/sql` pattern - a blank import is enough: ```go import _ "github.com/xavidop/mamori/providers/vault" ``` Every provider that ships in this repo passes the conformance kit (see **Write a provider**). Pick one from the sidebar for its scheme, ref grammar, auth, and examples. The **Errors** column shows which providers classify a failure beyond `not_found`, mapping backend-specific errors onto `mamori.ErrorKind` values like `permission_denied`, `unauthenticated`, `unavailable`, `rate_limited`, and `invalid`. The classification sweep across the whole catalog is now complete, and every provider falls into exactly one of three honest states: - **✅** - classifies real backend errors beyond `not_found`: thirty providers across twenty-seven modules (the `env:` / `dotenv://` / `file://` / `exec:` rows are one core module, counted as four providers). - **no (chain preserved)** - `firebase-rtdb`, `growthbook`, and `flagsmith` have no backend-specific error vocabulary to map, so a non-not-found failure still reports `unknown`, but `Resolve` wraps the underlying error with `%w` rather than flattening it, so `errors.Is`/`errors.As` still reach it. This is not classification; it is proof that the chain survives even where there is nothing more specific to name. - **n/a (no error surface)** - `unleash`, `configcat`, and `split` wrap client surfaces that return only `bool`/`string`, with no per-key error at all, so `Resolve` can only ever produce `not_found` or a client-construction error. Each is explicitly exempt from the conformance kit's `ErrorClassification` case via `providertest.Config.NoResolveErrors`, a deliberate, greppable opt-out rather than a silent gap. Don't read either non-✅ state as broken: `not_found` is detected everywhere regardless of this column, and neither state claims to see permission or availability errors that provider genuinely cannot observe. | Scheme | Page | Sensitive | Watch | Errors | | --- | --- | --- | --- | --- | | `env:` | env | no | poll | ✅ | | `dotenv://` | dotenv | no | fsnotify | ✅ | | `file://` | file | no | fsnotify | ✅ | | `exec:` | exec | yes | poll | ✅ | | `mamori://` | mamori (client) | passthrough | **native** (SSE) | ✅ | | `aws-sm://` `aws-ps://` | AWS | yes / secure | poll | ✅ | | `vault://` | Vault | yes | lease-aware poll | ✅ | | `gcp-sm://` | GCP | yes | poll | ✅ | | `azure-kv://` | Azure | yes | poll | ✅ | | `doppler://` | Doppler | yes | poll | ✅ | | `op://` | 1Password | yes | poll | ✅ | | `sops://` | SOPS | yes | fsnotify | ✅ | | `postgres://` | PostgreSQL | no | **native** (LISTEN/NOTIFY) | ✅ | | `mysql://` | MySQL | no | poll | ✅ | | `sqlite://` | SQLite | no | fsnotify | ✅ | | `mongodb://` | MongoDB | no | **native** (change streams) | ✅ | | `dynamodb://` | DynamoDB | no | poll | ✅ | | `cosmos://` | Cosmos DB | no | poll (ETag) | ✅ | | `redis://` | Redis | no | **native** (keyspace) | ✅ | | `consul://` | Consul | no | **native** | ✅ | | `etcd://` | etcd | no | **native** | ✅ | | `k8s-secret://` `k8s-cm://` | Kubernetes | yes / no | **native** | ✅ | | `firestore://` | Firestore | no | **native** (snapshots) | ✅ | | `firebase-rc://` | Remote Config | no | poll | ✅ | | `firebase-rtdb://` | Realtime DB | no | **native** (streaming) | no (chain preserved) | | `s3://` | Amazon S3 | no | poll (ETag) | ✅ | | `gcs://` | Google GCS | no | poll (generation) | ✅ | | `azblob://` | Azure Blob | no | poll (ETag) | ✅ | | `launchdarkly://` | LaunchDarkly | no | **native** (streaming) | ✅ | | `unleash://` | Unleash | no | poll | n/a (no error surface) | | `flagsmith://` | Flagsmith | no | poll | no (chain preserved) | | `configcat://` | ConfigCat | no | poll | n/a (no error surface) | | `split://` | Split | no | poll | n/a (no error surface) | | `growthbook://` | GrowthBook | no | poll | no (chain preserved) | | `flipt://` | Flipt | no | poll | ✅ | | `goff://` | GO Feature Flag | no | poll | ✅ | ## Choosing and configuring a provider Most providers auto-register a zero-config instance that reads ambient credentials (env vars, the AWS/GCP/Azure default credential chains, in-cluster Kubernetes config). A blank import is then all you need. When you must configure a provider explicitly - a region, an address, an injected client - construct it and pass it with `WithProvider`: ```go import awsprov "github.com/xavidop/mamori/providers/aws" cfg, err := mamori.Load[Config](ctx, mamori.WithProvider(awsprov.NewSecretsManager(awsprov.WithRegion("eu-west-1"))), ) ``` `WithProvider` takes precedence over the registry for that scheme, for that call only. The [mamori (client)](/docs/providers/mamori/) provider is the one shipped provider with no zero-config default at all: a `mamori://` binding name only means something relative to one specific config server, so it is always constructed explicitly with `mamoriprov.New(mamoriprov.Config{Endpoint: ...})` and passed via `WithProvider`, never registered from a blank import. It is also structurally different from every other row in the table above: it is not itself a secret manager, database, or flag service, it is a client for the [config server](/docs/server/), a fan-out process that fronts one of those backends and re-serves it to many callers. `Sensitive` and error classification are marked "passthrough" for it because both are whatever its upstream backend reports, carried through the hop unchanged, rather than a property fixed by this provider itself. ## Watch behavior - **native** - the backend pushes changes (Kubernetes watch API, Consul blocking queries, a mamori config server's `/v1/watch` Server-Sent Events stream). mamori subscribes directly. - **fsnotify** - a local file is watched for writes (built-in `file://`, `sops://`). - **lease-aware poll** - polling, but `Value.NotAfter` from a Vault lease triggers a refresh before expiry. - **poll** - mamori polls on `WithPollInterval` with jitter, using `Value.Version` to detect change. Provider authors implement the smallest interface native to their backend and never fake a watch with an internal ticker - mamori supplies the poller. --- > Source: https://mamorigo.dev/docs/providers/redis # Redis Redis keys, with **native watch** via keyspace notifications. | | | | --- | --- | | Scheme | `redis://` | | Module | `github.com/xavidop/mamori/providers/redis` | | Sensitive | no | | Watch | native (keyspace notifications) | | Auth | `REDIS_URL` (or `WithAddr` / `WithClient`) | ## Install ```bash go get github.com/xavidop/mamori/providers/redis ``` ```go import _ "github.com/xavidop/mamori/providers/redis" ``` ## Using the ref A `redis://` ref points at one Redis key, fetched with a single `GET`. ```text redis://[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Redis key to read. Its raw string value becomes the value. | | `#json-key` | no | Treat the value as a JSON object and return one field of it. | **Examples** - `redis://app/flags` reads the key `app/flags` and returns its raw string value. - `redis://app/settings#timeout_ms` treats the key `app/settings` as JSON and returns its `timeout_ms` field - pair it with an `int` field. - `redis://config/app/db#password` selects the `password` field from a JSON value stored at `config/app/db`. ```go type Config struct { FeatureFlags string `source:"redis://app/flags"` Timeout int `source:"redis://app/settings#timeout_ms"` // key holds JSON } ``` `Value.Version` is a content hash of the stored value - Redis has no per-key revision counter - so mamori still gets cheap change detection. Redis usually holds configuration and caches rather than managed secrets, so values are not marked sensitive; wrap a field in `secret.String` if you want redaction anyway. ## Watch `Watch` subscribes to Redis keyspace notifications and re-reads the key on change. This requires the server to have notifications enabled: ```text redis-cli config set notify-keyspace-events KEA ``` Set the database index with `WithDB(n)` if your key lives outside DB 0. Without keyspace notifications, use polling (`WithPollInterval`). ## Configuration ```go import redisprov "github.com/xavidop/mamori/providers/redis" mamori.WithProvider(redisprov.New(redisprov.WithAddr("redis:6379"), redisprov.WithDB(0))) ``` ## Error classification Beyond the not-found case, other `GET` failures are classified using go-redis's typed predicates so `mamori.ErrorKind` can distinguish them: | Redis condition | mamori kind | |---|---| | `NOAUTH`, `WRONGPASS` (missing/bad credentials) | `unauthenticated` | | `NOPERM` (ACL denies the command) | `permission_denied` | | `LOADING`, `CLUSTERDOWN`, `MASTERDOWN` | `unavailable` | | dial failure (connection refused, DNS failure, ...) | `unavailable` | | anything else | `unknown` | Redis has no rate-limit error on this path, so nothing maps to `rate_limited`. Unlike Postgres or MySQL, go-redis has no single error-code field to switch on, so detection uses its exported `IsAuthError`/`IsPermissionError`/`IsLoadingError`/`IsClusterDownError`/`IsMasterDownError` predicates, which recognize the underlying error even when wrapped. A raw dial failure isn't a `redis.Error` at all, so it's detected separately as a `*net.OpError` with `Op == "dial"` - the same signal go-redis's own retry logic uses internally. Verified with an in-memory fake. --- > Source: https://mamorigo.dev/docs/writing-a-provider/resolve # Resolve and errors `Resolve` returns the current `mamori.Value` for a `Ref` and classifies every failure. Get this right and your provider is interchangeable with every other one. ## Implement Resolve A `Ref` gives you `Path` (the backend location), `Key` (the `#key` fragment, empty when absent), and `Opts` (query options): ```go func (p *Provider) Resolve(ctx context.Context, ref mamori.Ref) (mamori.Value, error) { raw, err := p.client.get(ctx, ref.Path) if isNotFound(err) { return mamori.Value{}, mamori.ErrNotFound } if err != nil { return mamori.Value{}, err } if ref.Key != "" { // #key selects from a JSON payload, identically everywhere raw, err = mamori.SelectKey(raw, ref.Key) if err != nil { return mamori.Value{}, err } } return mamori.Value{ Bytes: raw, Version: mamori.VersionHash(raw), // or a native backend revision Sensitive: true, // true for secret managers }, nil } ``` Rules that keep providers interchangeable: - Missing values: return an error satisfying `errors.Is(err, mamori.ErrNotFound)`, never a nil error with empty bytes. - Set `Value.Version` from a native revision or `mamori.VersionHash(bytes)`. It must change whenever the value changes; that is what mamori uses for change detection. - Use `mamori.SelectKey(payload, ref.Key)` for `#key` selection. - Set `Sensitive: true` on secret-bearing values; never log the payload. - Honor `ctx` in every network call. ## Map backend errors to kinds `ErrNotFound` is the only error that changes mamori's behavior (it triggers `default:` and `optional` handling). Classify every other failure too, so telemetry (`mamori.error.kind`) and callers using `mamori.ErrorKind` can tell an operator what went wrong. Wrap the SDK error with the matching sentinel using **two `%w` verbs, sentinel first**: ```go var ae smithy.APIError if errors.As(err, &ae) { switch ae.ErrorCode() { case "ResourceNotFoundException": return mamori.Value{}, fmt.Errorf("%w: %w", mamori.ErrNotFound, err) case "AccessDeniedException": return mamori.Value{}, fmt.Errorf("%w: %w", mamori.ErrPermissionDenied, err) case "ThrottlingException": return mamori.Value{}, fmt.Errorf("%w: %w", mamori.ErrRateLimited, err) } } return mamori.Value{}, err // unmapped: reports as unknown, which is fine ``` Two `%w` verbs keep `errors.Is(err, mamori.ErrPermissionDenied)` matching the sentinel while `errors.As` can still reach the original SDK error type. **Never use `%v` for the sentinel.** It flattens the sentinel into a string and breaks the chain: ```go // WRONG: %v flattens the sentinel into a string and destroys the chain. return mamori.Value{}, fmt.Errorf("secretsmanager: %v", mamori.ErrPermissionDenied) ``` This still compiles and reads fine, but `errors.Is` no longer matches and every failure reports as `unknown`. The `ErrorClassification` [conformance case](/docs/writing-a-provider/conformance/) exists to catch exactly this. ### Which kind to use Map each failure to the sentinel that names its cause. Leaving an error unmapped (reported as `unknown`) is fine; guessing is not. | Kind | Use for | |---|---| | `ErrNotFound` | Key, secret, path, or version genuinely absent | | `ErrPermissionDenied` | Authenticated but not authorized: IAM deny, Vault policy, RBAC | | `ErrUnauthenticated` | Missing, malformed, or expired credentials; failed token renewal | | `ErrUnavailable` | Network failure, DNS, timeout, 5xx, circuit open | | `ErrRateLimited` | Throttling, quota exhaustion, 429 | | `ErrInvalid` | The ref is malformed for this provider, or the payload cannot be parsed | | (unmapped) | Anything else. Reports as `unknown`, an honest answer. | | *(automatic)* | `context.DeadlineExceeded` is classified as `unavailable` by `ErrorKind`; you need not map it. A plain `context.Canceled` reports `unknown`, since the caller withdrew the request. | See [Error kinds](/docs/concepts/error-kinds/) for how consumers read `Kind`. --- > Source: https://mamorigo.dev/docs/concepts/secret-types # Secret types Import `github.com/xavidop/mamori/secret`. `secret.String` and `secret.Bytes` redact themselves everywhere a value is normally rendered, so a secret cannot leak into a log line or a JSON dump by accident. ## Redaction Both types render as `[REDACTED]` through every common formatting path: ```go s := secret.NewString("hunter2") fmt.Println(s) // [REDACTED] fmt.Sprintf("%v", s) // [REDACTED] json.Marshal(s) // "[REDACTED]" slog.Info("login", "pw", s) // pw=[REDACTED] ``` `secret.Bytes` behaves the same way for `[]byte` payloads (`secret.NewBytes(...)`), and mamori sets a field's `Value.Sensitive` for you when it decodes into either type. ## Reveal and Zero Reading the plaintext is deliberately the single, greppable access point, so secret reads are easy to audit: ```go s.Reveal() // "hunter2" <- the only way to read a secret.String s.Zero() // best-effort wipe of the backing bytes ``` `secret.String.Reveal()` returns a `string` (`RevealBytes()` returns the raw `[]byte`); `secret.Bytes.Reveal()` returns `[]byte`. `IsZero()` reports whether the backing bytes are empty. `Zero()` is best-effort: Go's GC may already have copied the value, and mamori documents that honestly rather than promise memory safety it cannot deliver. [`mamori vet`](/docs/cli/vet/) flags a secret-bearing ref stored in a plain `string`. ## See also - [Concepts overview](/docs/concepts/) - refs, providers, and the reconciler. - [Security](/docs/security/) - redaction guarantees and the `WithHistory` retention tradeoff. --- > Source: https://mamorigo.dev/docs/security # Security & releases mamori is built so secret values stay in process memory and never reach a surface that could leak them: logs, `fmt`, JSON, or the metadata it serves over HTTP. This page covers the guarantees you get automatically, the two deployment choices that are yours to get right (how you expose the admin endpoint, whether you retain history), and how releases are versioned and published. ## Two HTTP surfaces: metadata and values mamori exposes exactly two things over HTTP, and they sit on opposite sides of a hard line. ```mermaid flowchart TD subgraph admin ["Admin endpoint (core: Handler / WithAdminHTTP)"] M["Report: field paths, schemes, staleness, error kinds"] MN["Never a resolved value"] end subgraph server ["Config server (server/ module)"] V["Resolved values, the actual secret bytes"] VG["Gated by mandatory Policy + Auth + audit"] end admin -.->|metadata leak if exposed| Risk1["Bad, but not a secret leak"] server -.->|highest blast radius| Risk2["Leaks the values themselves"] ``` - **The admin endpoint** (`Handler`, `WithAdminHTTP`, see [HTTP exposure](/docs/observability/admin/)) is part of the core module and serves operational metadata only: a `Report` of field paths, provider schemes, refs with sensitive query options redacted, staleness, and error kinds. It cannot serve a configuration value by construction, not by a check the handler could forget to make: the response body is always `w.Status()`, and a `Report` never carries a resolved value in the first place, so no route or option can return one even by mistake. - **The [config server](/docs/server/)** (the separate `server/` module) serves resolved values, the actual secret bytes, gated by a mandatory authorization `Policy` expressed in terms of the `Identity` an `Authenticator` returns (see [Auth](/docs/auth/)). It concentrates every backend credential its bindings touch into one process, which makes it the highest-blast-radius component in mamori: compromising it is worse than compromising any single consumer that would otherwise hold its own slice of those credentials. See the config server's [Blast radius](/docs/server/#blast-radius) section for the full accounting and its structural mitigations (no client-supplied refs, mandatory policy, mandatory auth and TLS over the network, no values in the audit log). Keep the two apart when reasoning about exposure. Pointing an unauthenticated admin endpoint at the public internet is a metadata leak, not a secret leak. That is still worth avoiding, but it is a categorically different mistake from misconfiguring the config server, which leaks the values themselves. ## `WithAdminHTTP` is unauthenticated by default `WithAdminHTTP` starts with no `Authenticator` attached: any request that can reach the port gets the `Report`. Before exposing it beyond a single trusted host, do at least one of the following. - **Bind to localhost or a non-ingress port.** Use `WithAdminHTTP("127.0.0.1:9090")` rather than a port your load balancer or ingress controller forwards, so the endpoint is reachable only from the same host or network namespace. - **Require authentication.** Pass a shipped `Authenticator` scheme (or your own) as `WithAuth` to the admin handler (see [Auth](/docs/auth/)), so a request must present a credential. - **Layer `WithAdminTLS`** on top, so a credential sent to the endpoint (a bearer token, a basic-auth password) is never sent in the clear. `WithAdminTLS` has no effect without `WithAdminHTTP`. These combine. Bind to a non-ingress port and require auth when the endpoint has to be reachable from more than one host: ```go w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP("127.0.0.1:9090", mamori.WithAuth(auth)), mamori.WithAdminTLS(tlsConfig), ) ``` ## Ref redaction Every `Report.Fields[i].Ref` has a fixed denylist of query-option names redacted before it can leave the process: `token`, `password`, `secret`, `key`, `apikey`, `api_key`, `sas`, `credential`, `client_secret`, `secret_access_key`, `access_key`, `private_key`, `secret_key`, `pwd`, `passwd` (matched case-insensitively). The scheme, path, key, and any non-sensitive option are left intact so the ref stays useful for diagnostics; only the values behind those names are replaced with `secret.Redacted`. Redaction runs once, where the `Report` is built, not per surface. The same denylist therefore applies whether a ref leaves via `Status()`, `Doctor`, or the admin HTTP endpoint, so there is no path that emits an un-redacted ref. See [Observability](/docs/observability/) for the `Report` shape this applies to. ## Redaction and memory safety These guarantees hold without any configuration on your part. - Sensitive values never pass through `fmt` or logs unredacted. `secret.String` / `secret.Bytes` render as `[REDACTED]`, and only `Reveal()` exposes the value. - [`mamori vet`](/docs/cli/vet/) catches sensitive refs stored in plain `string` / `[]byte` fields at `go vet` time. - `Zero()` is best-effort and documented as such: Go's GC may already have copied the value, so mamori makes no false promises about memory safety. - The `exec:` provider is off by default and must be enabled with `WithExecProvider()`. Refs are never interpolated from other resolved values, so there are no injection chains. - Providers must not log payloads; the conformance kit enforces this with a log-capture assertion. ## `WithHistory` retains past secrets in memory `WithHistory(n)` (see [Loading & watching](/docs/usage/snapshots/#retaining-snapshots-with-withhistory)) defaults to `0`: a `Watcher` normally keeps only its current, live snapshot. Turning it on trades that off against operability (being able to inspect what config looked like a few versions back, or `Pin` `Get()` to it) at a cost worth stating plainly. - Each retained `Snapshot[T]` holds a **full copy of `T`** as it was at that version, including every `secret.String` / `secret.Bytes` field's value at the time. - A secret that has since rotated is not gone from process memory just because it rotated: it stays reachable through `w.History()[i].Config` (or via `w.Pin` to that version) for as long as that snapshot sits inside the `n`-snapshot window. - This does not weaken redaction in logs, `fmt`, or JSON. That protection lives on the `secret.String` / `secret.Bytes` type itself, not on whether history is enabled. It only extends how long an old secret value sits somewhere in memory, reachable by any application code that calls `Reveal()` on it. Enable `WithHistory` deliberately: pick `n` for the operational need you actually have (recent-regression debugging, an audit trail of the last few changes), not "as large as possible." Leave it at the default `0` unless you have a concrete reason to look backward. ## Supply chain The core module has minimal dependencies; each provider module isolates its SDK blast radius. Releases are published with checksums and SLSA provenance via GoReleaser. Report vulnerabilities privately via [GitHub Security Advisories](https://github.com/xavidop/mamori/security/advisories/new), never in a public issue. ## Out of scope `mamori` is not a secrets store and provides no encryption at rest. It is a library: it holds values in process memory only and writes nothing to disk. The core module's only server component is the optional, metadata-only admin HTTP endpoint above, which exposes no way to serve a configuration value. The separate [config server](/docs/server/) module does serve values, deliberately and behind mandatory auth and authz, but it is opt-in (a distinct module you must import and deploy) rather than something the core library does on your behalf. Protecting the backends it reads from (IAM policies, Vault ACLs, KMS keys) is your infrastructure's responsibility either way. ## Releases and versioning Core releases are automated from [Conventional Commits](https://www.conventionalcommits.org/). When commits land on `main`, **semantic-release** decides the next version (`fix:` -> patch, `feat:` -> minor, breaking -> major), updates the changelog, and creates the `vX.Y.Z` tag; **GoReleaser** then builds the `mamori` CLI binary and publishes the GitHub Release with checksums, an SBOM, and SLSA provenance. Modules are versioned with semantic-version git tags. The core module tags as `v0.1.0`; each submodule tags with its path prefix: ```text v0.1.0 # core providers/aws/v0.1.0 # AWS provider module x/otel/v0.1.0 # OpenTelemetry bridge ``` Import a specific version the usual way: ```bash go get github.com/xavidop/mamori@v0.1.0 go get github.com/xavidop/mamori/providers/aws@v0.1.0 ``` Each provider module keeps its own release cadence, so a breaking change in one SDK never forces a core release. --- > Source: https://mamorigo.dev/docs/server/authorization # Server auth and policy Every request runs two gates in order: **authenticate** (who is this caller) then **authorize** (may this caller have this name). Both are mandatory; `New` refuses to construct a `Server` that is missing either. ## Authenticate callers ```go func WithAuth(a mamori.Authenticator) Option func NoAuth() Option ``` The config server reuses core's `Authenticator`/`Identity` unchanged, so [every shipped scheme](/docs/auth/schemes/) works here exactly as it does against the admin HTTP endpoint: `BasicAuth`, `BearerToken`, `APIKey`, `MTLS`, `PeerCred`, and their `AnyOf`/`AllOf` composition. ```go server.WithAuth(mamori.BearerToken(secret.NewString(os.Getenv("SERVER_TOKEN")))) ``` `NoAuth()` exists for a Unix-socket-only deployment where the filesystem (and, with `PeerCred`, the kernel) already bounds who can connect. **`NoAuth()` is refused on a TCP listener**, and `New` (not `Serve`) rejects that combination, so the mistake surfaces before any port is bound. ### Authenticate a Unix peer with `PeerCred` [`PeerCred`](/docs/auth/schemes/#peercred) is the strongest option for a Unix-socket deployment: it authenticates a connecting process by the uid/gid the kernel reports at accept time (`SO_PEERCRED` on Linux, `LOCAL_PEERCRED` on Darwin), which the client cannot spoof. It requires this server's `Unix(...)` transport, which wires up the plumbing that reads a connection's peer credentials. ```go srv, err := server.New( server.Bind("db-password", "vault://secret/data/db#password"), server.WithProvider(vaultProvider), server.WithPolicy(server.PrefixPolicy(map[string][]string{ "uid:1000": {"db-password"}, // Identity.Subject is "uid:" })), server.WithAuth(mamori.PeerCred(mamori.PeerCredOptions{UIDs: []int{1000}})), server.Unix("/run/mamori/server.sock", 0600), ) ``` ## Authorize with a Policy ```go type Policy interface { Allow(id mamori.Identity, name string) error } type PolicyFunc func(id mamori.Identity, name string) error func AllowAll() Policy func PrefixPolicy(rules map[string][]string) Policy ``` `WithPolicy(p Policy)` is mandatory: `New` refuses to construct a `Server` with no policy, not even a deny-all default. - **`AllowAll()`** permits every identity access to every binding. Use it for a trusted-sidecar deployment where per-name rules add configuration without adding security. - **`PrefixPolicy(rules)`** grants access by subject: `rules[id.Subject]` is a list of `path.Match` globs checked against the requested binding name (`*` and `?` do not cross a `/`; `[...]`/`[^...]` character classes work as in a shell glob). A subject with no entry is denied, with no fallback default-allow. - **`PolicyFunc`** adapts a plain function, the same pattern as `mamori.AuthFunc`. ```go server.WithPolicy(server.PrefixPolicy(map[string][]string{ "svc-orders": {"db-*", "api-key"}, "svc-billing": {"stripe-*"}, })) ``` ```go server.WithPolicy(server.PolicyFunc(func(id mamori.Identity, name string) error { if strings.HasPrefix(name, id.Subject+"-") { return nil } return server.ErrDenied })) ``` A denied name is indistinguishable from a nonexistent one: `Policy.Allow` is handed only an `Identity` and a name (never the binding table), and the wire handler always answers a denial with the same `403 permission_denied`. Authorization runs before the binding table is consulted, so a policy can never enumerate what a server serves. Recognize a denial with `errors.Is(err, server.ErrDenied)`. ## Next - [Server bindings](/docs/server/bindings/) - the names a policy grants access to. - [Server wire protocol](/docs/server/wire-protocol/) - where the auth-then-authorize order runs on every request. - [Auth schemes](/docs/auth/schemes/) - every shipped `Authenticator` in full. --- > Source: https://mamorigo.dev/docs/server/bindings # Server bindings A binding maps a name (what a client requests) to a ref (what actually gets resolved). Bindings are the whole security model: a client sends a name, never a ref, so a request can never carry `file:///etc/shadow` or a client-chosen `exec:` command. ## Declare bindings `Bind` declares one binding inline; `BindFile` reads them from YAML. ```go func Bind(name, ref string) Option func BindFile(path string) Option ``` ```go srv, err := server.New( server.Bind("db-password", "vault://secret/data/db#password"), server.Bind("api-key", "aws-sm://prod/api-key"), server.BindFile("/etc/mamori/bindings.yaml"), // ... policy, auth, transport ... ) ``` ```yaml # /etc/mamori/bindings.yaml bindings: db-password: vault://secret/data/db#password api-key: aws-sm://prod/api-key ``` `Bind`/`BindFile` are the only way a binding comes into existence, and the operator calls them in the server's startup code. A duplicate binding name fails `New`. All declarations are validated together in one pass after every option applies, so declaration order never matters. ## Register providers Each binding resolves through the provider registered for its ref's scheme. ```go func WithProvider(p mamori.Provider) Option ``` `WithProvider` registers a `Provider` for its own `Scheme()`. Every provider a binding can use is one the operator named explicitly; there is no process-wide registry fallback. A binding whose scheme has no registered provider still constructs cleanly and simply reports a resolve error on lookup, while every other binding keeps working. ## Allow `exec:` and `mamori:` schemes Two ref schemes are rejected at construction unless explicitly allowed: ```go func AllowExec() Option func AllowChaining() Option ``` ```go srv, err := server.New( server.Bind("build-token", "exec://vault-agent-token"), server.AllowExec(), server.WithProvider(myExecProvider), // required: core ships no exec provider constructor // ... policy, auth, transport ... ) ``` - **`exec:`** runs an arbitrary command on the server's host. `AllowExec()` lifts the construction-time gate, but core's `exec:` provider has no exported constructor, so making an `exec:` binding resolve also requires you to supply your own exec `Provider` via `WithProvider`. Two deliberate steps for a scheme that means remote command execution reachable by every authorized consumer. - **`mamori:`** chains to another config server. `AllowChaining()` lifts the gate; without it, a `mamori:` binding could quietly wire up a cycle. Every other scheme (`env:`, `aws-sm:`, `vault:`, `file:`, `gcp-sm:`, and so on) needs neither option. ## Next - [Server auth and policy](/docs/server/authorization/) - decide who may request which binding. - [Deploy and expose](/docs/server/transports/) - serve the bindings over a socket or TLS TCP. - [Config server overview](/docs/server/) - the fan-out model and blast radius. --- > Source: https://mamorigo.dev/docs/server/wire-protocol # Server wire protocol The handler `Handler()` returns (and `Serve` mounts on every transport) exposes five v1 routes. This is a versioned wire protocol, not an internal detail: once an external client depends on today's routes and JSON fields, they are a stable contract. Every value-bearing response carries `Cache-Control: no-store`. | Route | Purpose | | --- | --- | | `GET /v1/values/{name}` | Resolve one binding by name | | `POST /v1/values` | Resolve a batch: body `{"names":[...]}` | | `GET /v1/watch` | Subscribe to one or more bindings over Server-Sent Events | | `GET /v1/healthz` | Bare liveness check | | `GET /v1/readyz` | Readiness: should a load balancer route here | ## Request ordering Every value-bearing route runs, in this exact order, on every request. The two probe routes (`/v1/healthz` and `/v1/readyz`) are exempt: 1. **Authenticate** (`mamori.Authenticator.Authenticate`, or skipped in `NoAuth` mode). Failure is `401`, with `WWW-Authenticate` set if the `Authenticator` implements `Challenger`. 2. **Authorize the specific name** (`Policy.Allow`), separately for every name in a batch or watch subscription. Failure is `403`. 3. **Only then** read the binding. A denied caller gets `403` for a name whether or not it is a real binding: authorization runs and fails closed before the binding table is consulted. The probe routes are the exception; neither authenticates, authorizes, nor names a binding. ## Response shapes A successful single value, one entry of a batch response, or one SSE update frame all share one JSON shape: ```json { "name": "db-password", "bytes": "aHVudGVyMg==", "version": "v3", "sensitive": true, "not_after": "2026-08-01T00:00:00Z", "metadata": {}, "kind": "", "resolved_at": "2026-07-26T21:04:11Z", "stale": false } ``` - **`bytes`** is base64-encoded (`encoding/json`'s standard `[]byte` handling). - **`version`**, **`sensitive`**, **`not_after`**, **`bytes`**, **`kind`**, **`resolved_at`**, and **`stale`** are all `omitempty`; `metadata` is never omitted (a `nil` map becomes `{}`, never `null`). - **`resolved_at`** is when the replica answering you last fetched these bytes from upstream, and **`stale`** says it is serving them as last-known-good while upstream currently fails. They exist for [multi-replica deployments](/docs/server/ha/), where each replica watches on its own schedule: comparing `resolved_at` is how a client notices it reached a replica that is behind one it already spoke to. `resolved_at` dates the value, not the last attempt, so a failed refresh carries it forward unchanged. A whole-request failure (auth failure, a malformed batch body, `GET /v1/values/{name}` for an unbound or unresolvable name) is: ```json {"error": {"kind": "not_found", "message": "binding not found"}} ``` A batch or watch response never fails the whole request over one bad name: a single name that is denied, unbound, or unresolvable becomes exactly that one entry, shaped like an error but carrying the requested `name`: ```json {"name": "some-other-binding", "error": {"kind": "permission_denied", "message": "permission denied"}} ``` **Distinguish success from failure by the presence of the `error` field, never by whether `bytes` is present.** A genuinely empty (zero-length) secret has its `bytes` key omitted exactly the way an error entry does, so "no bytes means error" misreads a legitimate empty value. `kind` round-trips through core's `mamori.ErrorKind`/`mamori.SentinelFor`, so a client can recover the original sentinel: `mamori.SentinelFor(mamori.Kind("permission_denied"))` gives back `mamori.ErrPermissionDenied`. The kind-to-status mapping: | `kind` | HTTP status | | --- | --- | | `not_found` | 404 | | `invalid` | 400 | | `unauthenticated` | 401 | | `permission_denied` | 403 | | `rate_limited` | 429 | | `unavailable` | 503 | | `unknown` | 500 | ## Read `kind` on a success: fresh vs. stale-but-serving `kind` appears in two structurally different places, and conflating them is the easiest client mistake: - **On an error entry** (`{"error":{"kind":...}}`), `kind` is why the request failed. - **On a successful value** (`bytes` present, no `error`), a non-empty `kind` means the server is serving a last-known-good value while the binding's upstream is currently failing. This is an annotation on a success, not a failure. A client that ignores `kind` will silently keep using a value the server knows is stale; a client that treats any non-empty `kind` as an error will reject usable data. Check `error` presence for success vs. failure, and use `kind` on a success only to decide whether to log or alert on staleness. ## Resolve one binding with `GET /v1/values/{name}` Fetch a single binding by name, URL-path-escaped: ```text GET /v1/values/db-password ``` `{name}` is a binding name the operator declared on the server, never an upstream ref (a client cannot ask the server to resolve an arbitrary ref). On success the body is the single [value shape](#response-shapes) above with a `200`. An unbound name, or one whose upstream has never resolved, returns the `{"error":{...}}` envelope with the matching status from the kind table (for example `404` with `kind: not_found` for a name the server does not serve). A last-known-good value being served while its upstream is currently failing still comes back as a `200` value with a non-empty `kind`, not as an error (see [fresh vs. stale-but-serving](#read-kind-on-a-success-fresh-vs-stale-but-serving)). ## Resolve a batch with `POST /v1/values` Resolve several bindings in one round trip. The body lists the names to resolve: ```json {"names": ["db-password", "api-key", "region"]} ``` The response is a `values` array with one entry per requested name, in request order: ```json { "values": [ {"name": "db-password", "bytes": "aHVudGVyMg==", "sensitive": true, "metadata": {}}, {"name": "api-key", "error": {"kind": "permission_denied", "message": "permission denied"}}, {"name": "region", "bytes": "dXMtZWFzdC0x", "metadata": {}} ] } ``` Each entry is independently a value or a per-name error (same [shapes](#response-shapes) as the single route), and each name is authorized on its own. One denied, unbound, or unresolvable name becomes that single entry's error; the request itself still returns `200`. This lets a client resolve every field of a config struct in a single request rather than one call per field, which is exactly what the [`mamori://` client](/docs/providers/mamori/) does through its `BatchProvider`. A body that is not `{"names":[...]}` is a whole-request `400` (`kind: invalid`). ## Subscribe to changes with `GET /v1/watch` ```text GET /v1/watch?name=db-password&name=api-key ``` `/v1/watch` opens a Server-Sent Events stream: an `error` frame at subscribe time for any denied or unbound name (the stream still opens and stays live for the names that were allowed), then an `update` frame each time an allowed name's resolved state changes, plus a periodic heartbeat comment so idle-closing proxies do not drop the connection. This is a fast poll (roughly 200ms) of already-resolved local state, not push from the backend. The stream ends when the client disconnects or a write fails; reconnecting is the client's responsibility. ## Probe liveness with `GET /v1/healthz` ```json {"status": "ok"} ``` A fixed, unconditional `200`, never any binding detail. It is exempt from authentication and authorization, so a probe with no credential still works, and it can never become a way to learn what the server holds. ## Probe readiness with `GET /v1/readyz` ```json {"status": "ready"} ``` Liveness says the process is alive; readiness says whether it should be sent traffic. Route your load balancer on this one. It answers `200` with `ready`, or `503` with `priming` (bindings still resolving, so the cache is cold) or `draining` (shutting down). Like `healthz` it is unauthenticated and therefore reports a bare status with no binding names, counts, or node identity. See [High availability](/docs/server/ha/) for how to use it when running several replicas. ## Next - [Server auth and policy](/docs/server/authorization/) - the authenticate-then-authorize gates these routes enforce. - [High availability](/docs/server/ha/) - `readyz`, draining, and the freshness fields across replicas. - [Providers: mamori](/docs/providers/mamori/) - the `mamori://` client that speaks this protocol. - [Config server overview](/docs/server/) - the fan-out model and blast radius. --- > Source: https://mamorigo.dev/docs/usage/snapshots # Snapshots and pinning A `Watcher` can inspect per-field health, retain past snapshots, and freeze what `Get()` returns while you investigate, without stopping reconciliation underneath: sources keep being watched, only what reaches `Get()` and `OnChange` is held back. Each applied update is a `Snapshot[T]`: ```go type Snapshot[T any] struct { Version uint64 At time.Time Config T Fields []FieldChange // what changed relative to the previous snapshot } ``` `Version` increments on each validated, non-empty update. ## Check field health with Status in a watch loop `w.Status()` returns a point-in-time `Report` of every field's health: which ref is winning, how stale it is, and the last error kind. It is lock-free and safe to call from any goroutine. ```go rep := w.Status() log.Printf("snapshot=%d live=%d pinned=%v healthy=%v", rep.Snapshot, rep.Live, rep.Pinned, rep.Healthy) for _, f := range rep.Fields { log.Printf("%s via %s: age=%s stale=%v %s", f.Path, f.Ref, f.Age, f.Stale, f.LastError) } ``` `rep.Snapshot` is the version `Get()` currently returns and `rep.Live` is the newest validated version underneath it; the two diverge only while pinned (below). For a readiness probe, `w.Health()` returns `nil` when every field is fresh and error-free, or a `*HealthError` naming the broken fields. See [Observability](/docs/observability/) for `Status`, `Health`, and the read-only HTTP exposure. ## Retaining snapshots with `WithHistory` By default a `Watcher` keeps only its current snapshot. `WithHistory(n)` retains the `n` most recent snapshots in addition to the current one: ```go func WithHistory(n int) Option func (w *Watcher[T]) History() []Snapshot[T] // newest first, always includes current ``` ```go w, err := mamori.Watch[Config](ctx, mamori.WithHistory(10)) if err != nil { log.Fatal(err) } defer w.Close() for _, snap := range w.History() { log.Printf("v%d at %s: %d field(s) changed", snap.Version, snap.At, len(snap.Fields)) } ``` Retained snapshots hold a full copy of `T`, including any `secret.String` / `secret.Bytes` field's value at that version, even after the live config has since rotated that field away. Enabling history extends how long old secret material stays reachable in process memory, which is why `WithHistory` defaults to `0` (off). Read [Security](/docs/security/#withhistory-retains-past-secrets-in-memory) before enabling it in a service that handles credentials, and size `n` to the operational need you actually have. ## Pin and unpin during a rollout During a rollout you often want the config to hold still while you investigate, without stopping the watcher: sources keep being watched and reconciled, but whatever `Get()` returns should not shift under you mid-investigation. ```go func (w *Watcher[T]) Pin(version uint64) error // ErrNoSuchSnapshot if version is not retained func (w *Watcher[T]) PinCurrent() uint64 // pins whatever Get() serves right now; always succeeds func (w *Watcher[T]) Unpin() // resumes; a no-op if not currently pinned func (w *Watcher[T]) Pinned() (uint64, bool) // the current pin, if any ``` The pin, investigate, unpin pattern: ```go w, err := mamori.Watch[Config](ctx, mamori.WithHistory(10)) if err != nil { log.Fatal(err) } defer w.Close() // Something looks wrong. Freeze config before poking around. version := w.PinCurrent() log.Printf("pinned at snapshot %d", version) rep := w.Status() log.Printf("snapshot=%d live=%d pinned=%v", rep.Snapshot, rep.Live, rep.Pinned) // Sources are still watched, so Live can keep advancing while Snapshot holds // still. A validation failure on a live update still reaches OnError even // while pinned; it just never becomes what Get() serves. // ... investigate; Get() keeps returning exactly the pinned config ... w.Unpin() // Get() resumes tracking the newest validated snapshot. OnChange fires // exactly one Change, whose Fields is the accumulated diff of everything // that changed while pinned. If nothing changed, Unpin fires no Change. ``` To go back further than the currently pinned snapshot, `Pin` a specific retained version by number. It fails if that version has fallen outside the `WithHistory` window: ```go if err := w.Pin(42); err != nil { if errors.Is(err, mamori.ErrNoSuchSnapshot) { // not retained; raise WithHistory(n) to reach further back } } ``` `Pin` and `Unpin` are **not** exposed over the admin HTTP endpoint: that endpoint is deliberately read-only metadata, while pinning changes what your application actually does with `Get()`. An app that wants remote pinning should mount its own authenticated route that calls `w.Pin` / `w.Unpin` directly. ## See also - [Watch for changes](/docs/usage/watching/) for `Watch`, `Get`, and callbacks. - [Observability](/docs/observability/) for `Status`, `Health`, and the HTTP surface. - [Security](/docs/security/#withhistory-retains-past-secrets-in-memory) for the secret-retention tradeoff of `WithHistory`. --- > Source: https://mamorigo.dev/docs/providers/sops # SOPS Decrypts a [SOPS](https://github.com/getsops/sops)-encrypted file and **watches it with fsnotify**, so a re-encrypted file is hot-reloaded. | | | | --- | --- | | Scheme | `sops://` | | Module | `github.com/xavidop/mamori/providers/sops` | | Sensitive | yes | | Watch | fsnotify (native) | | Auth | `SOPS_AGE_KEY` / `SOPS_AGE_KEY_FILE`, or KMS credentials | ## Install ```bash go get github.com/xavidop/mamori/providers/sops ``` ```go import _ "github.com/xavidop/mamori/providers/sops" ``` ## Using the ref A `sops://` ref points at a SOPS-encrypted file on disk, optionally selecting one key inside the decrypted document. ```text sops://[#key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | Path to the encrypted file. Relative (`sops://secrets/app.enc.json`) or absolute with a leading slash (`sops:///etc/secrets/db.enc.yaml`). The format (`yaml`, `json`, `dotenv`, `binary`) is inferred from the extension. | | `#key` | no | Treat the decrypted document as a JSON/YAML object and return a single field. Without it, the whole decrypted content is the value. | **Examples** - `sops:///etc/secrets/db.enc.yaml#password` - decrypts the absolute-path YAML file and returns just its `password` field. - `sops://secrets/app.enc.json#api_key` - decrypts the relative-path JSON file and returns the `api_key` field. - `sops://config/app.enc.yaml` - returns the whole decrypted document when you want the entire file, not one key. ```go type Config struct { DBPassword secret.String `source:"sops:///etc/secrets/db.enc.yaml#password"` // absolute path APIKey secret.String `source:"sops://secrets/app.enc.json#api_key"` // relative path } ``` Values are always marked `Sensitive`. `Value.Version` is a hash of the encrypted file's size and modification time, so re-resolving an unchanged file is cheap and never decrypts twice just to compare. ## Error classification Beyond the not-found case above, an unreadable encrypted file is classified so `mamori.ErrorKind` can distinguish it: | Condition | Detected via | mamori kind | | --- | --- | --- | | Missing file | `os.IsNotExist` | `not_found` | | Unreadable file (e.g. restrictive ownership on a mounted secret) | `os.IsPermission` | `permission_denied` | | Anything else (bad/missing key material, corrupt ciphertext, ...) | - | `unknown` | This applies to both the initial stat and the decrypt step, since a SOPS-encrypted file is read like any other local file and shares the same os-level vocabulary as the built-in `file://` provider. SOPS's own decrypt failures carry no further os-level vocabulary to classify, so anything else reports `unknown` rather than being guessed at. The original error stays reachable with `errors.As`. ## Auth and watch mamori does not manage keys; it calls SOPS to decrypt, so whatever key material SOPS finds in the environment applies (`SOPS_AGE_KEY` for age, or the configured AWS/GCP/Azure KMS credentials). The provider watches the encrypted file with fsnotify (watching the parent directory to catch atomic renames) and re-decrypts on change. ## Explicit configuration The decryption step is injectable, which is how the conformance kit runs without real keys: ```go import sopsprov "github.com/xavidop/mamori/providers/sops" mamori.WithProvider(sopsprov.New( sopsprov.WithDecrypt(func(path, format string) ([]byte, error) { /* ... */ }), )) ``` Verified by unit tests (yaml/json key selection, format detection, not-found, error classification, fsnotify watch) with an injected decrypt function, including a `Resolve`-level test that injects a real os-shaped permission error through the same seam. Real SOPS decryption with a generated age key is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/concepts/source-chains # Source chains and precedence A `source` tag can hold more than one ref, comma-separated. This is a **precedence chain**, not a list of alternates tried on failure: ```go type Config struct { Port string `source:"env:PORT,aws-ps://svc/port"` } ``` A single-ref tag (the common case) is just a one-element chain, so an untagged field behaves exactly as before. ## How a chain resolves The chain is walked in the order you declare: 1. The first ref that resolves to a value wins; the walk stops there. 2. A ref that resolves not-found falls through to the next ref in the chain. 3. A ref that fails with any other error (permission denied, unavailable, rate limited, an unregistered scheme, ...) stops the walk immediately. Lower-priority refs are deliberately **not** tried, so config resolution never depends on backend health instead of the order you declared. That error becomes the field's terminal error and is handled by `onfail` below. 4. If every ref resolves not-found, the field falls back to `default:` / `optional:"true"` exactly as a single-ref field does. ```mermaid flowchart TD A["Try the next ref in the chain"] --> B{"Result?"} B -->|value| Win["Use it, stop the walk"] B -->|not found| C{"More refs left?"} C -->|yes| A C -->|no| D{"default or optional?"} D -->|yes| Def["Use default, or stay empty"] D -->|no| Err["Field errors: not found"] B -->|any other error| OF{"onfail policy"} OF -->|keeplast| KL["Keep last good value"] OF -->|default| OD["Use the field default"] OF -->|fail| OFail["Field errors now"] ``` This is precedence, not availability fallback. To retry the *same* backend on a transport error, use `middleware.Failover` (see [Middleware](/docs/middleware/)), a decorator around one `Provider`. A source chain instead lets one field name sources across *different* schemes in priority order, and it is a property of the tag, not the provider. ## Live precedence: takeover and fallback Every position in a chain is watched, not just the current winner, so precedence is live: ```go type Config struct { Port string `source:"chain-a://port,chain-b://port"` } w, err := mamori.Watch[Config](ctx /* , providers for chain-a and chain-b */) // chain-a absent, chain-b holds "8080": w.Get().Port == "8080" // chain-a is exported at runtime: // -> Watch recomputes the winner, one Change (Old "8080", New "9090") // -> w.Get().Port == "9090" // chain-a is unset again: // -> falls back to chain-b's last value, one more Change (Old "9090", New "8080") ``` A change to a position that is not currently winning (chain-b updating while chain-a wins) is still observed in `w.Status()`, but it does not move `Get()` or fire a `Change`. ## The onfail policy `onfail` controls what a field does when step 3 stops the walk on a genuine error, as opposed to absence: | Policy | Behavior on a non-not-found error | | --- | --- | | `onfail:"keeplast"` (default) | Keep the last successfully applied value and report the error via `OnError`/`Status`. On the very first `Load` there is no prior value to keep, so it **fails** rather than falling back to `default:`. | | `onfail:"default"` | Apply the field's `default:` value, as if every ref had resolved not-found. The explicit opt-in for "treat this error like absence." Requires a `default:` tag and is rejected at parse time without one. | | `onfail:"fail"` | Reject the candidate outright: the whole update on `Watch` (not just this field), or the whole `Load`. | The rule to hold onto: **`default:` applies only to genuine absence**, never to an error. A permission-denied, an outage, or a misconfigured provider never silently falls back to `default:` unless the field explicitly opts in with `onfail:"default"`. ## The comma-split rule A comma splits the chain only when the text right after it looks like a new scheme (matches `^[a-zA-Z][a-zA-Z0-9+.-]*:`). A comma anywhere else, inside a query string or an opaque path, stays part of that ref's value: ```go // Two refs: "env:PORT" and "aws-ps://svc/port" Port string `source:"env:PORT,aws-ps://svc/port"` // One ref: the comma in the query string is not a chain separator Tags string `source:"consul://kv/tags?filter=a,b"` // One ref: the comma in the exec: path is not a chain separator Report string `source:"exec:echo a,b"` ``` Because of that rule, a doubled or trailing comma is not a split point (no scheme follows it) and is kept as part of the adjacent ref's value: `env:A,,env:B` yields a first ref with path `A,`, which resolves not-found and falls through. To force a literal comma where it would otherwise split, percent-encode it as `%2C`. mamori does not decode it back; the parsed path keeps the literal `%2C`: ```go // %2C keeps this one ref; the resolved path is literally "echo a%2Cenv:b". V string `source:"exec:echo a%2Cenv:b"` ``` ## See also - [Concepts overview](/docs/concepts/) - the ref grammar and supplementary tags. - [Watch for changes](/docs/usage/watching/) - Watch, Get, and change callbacks. --- > Source: https://mamorigo.dev/docs/providers/split # Split Load a [Split](https://www.split.io) (Harness FME) feature flag's treatment as config. | | | | --- | --- | | Scheme | `split://` | | Module | `github.com/xavidop/mamori/providers/split` | | Sensitive | no | | Watch | poll | | Auth | `SPLIT_API_KEY` (server-side SDK key) | ## Install ```bash go get github.com/xavidop/mamori/providers/split ``` ```go import _ "github.com/xavidop/mamori/providers/split" ``` ## Using the ref A `split://` ref points at one feature flag (split). It resolves to the treatment string returned for the evaluation key. ```text split://[?key=] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Split feature flag name. | | `?key=` | no | The traffic key (the identity evaluated). Defaults to `mamori`. | The value is the treatment, which is a string you define in Split (commonly `on` / `off`, but it can be any named treatment). Split's special `control` treatment - returned when the flag is missing or the client is not ready - resolves to not-found. **Examples** - `split://new-onboarding` resolves to e.g. `on` or `off` - pair with `validate:"oneof=on off"`. - `split://pricing-tier?key=service-a` resolves to the treatment for the `service-a` traffic key. ## Watch The Split SDK synchronizes definitions in the background; mamori polls (`WithPollInterval` + jitter). The client is started and waited-until-ready lazily on first use. ## Error classification Split's client surface returns a bare treatment string (not-found is the `control` sentinel, not an error), so this provider has no error vocabulary beyond not-found. It is conformance-exempt from the `ErrorClassification` case via `providertest.Config.NoResolveErrors`. ## Configuration ```go import splitprov "github.com/xavidop/mamori/providers/split" mamori.WithProvider(splitprov.New( splitprov.WithAPIKey(os.Getenv("SPLIT_API_KEY")), splitprov.WithKey("my-service"), )) ``` Verified with an injected fake (un-seeded flags return `control` -> not-found); live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/sqlite # SQLite Read a config or secret value from a local SQLite database, hot-reloaded with fsnotify. Uses the pure-Go `modernc.org/sqlite` driver (no cgo). | | | | --- | --- | | Scheme | `sqlite://` | | Module | `github.com/xavidop/mamori/providers/sqlite` | | Sensitive | no (opt-in with `WithSensitive`) | | Watch | fsnotify (native) | | Auth | file permissions (`WithPath` / `SQLITE_PATH`) | ## Install ```bash go get github.com/xavidop/mamori/providers/sqlite ``` ```go import _ "github.com/xavidop/mamori/providers/sqlite" ``` ## Using the ref A `sqlite://` ref points at one row of a table in the configured SQLite database file (a key/value lookup), optionally selecting a field from a JSON value. The database file path is provider configuration, not part of the ref. ```text sqlite://
/[#json-field][?key_col=&val_col=] ``` | Part | Required | What it means | | --- | --- | --- | | `
` | yes | The table to read. Validated against a strict identifier allowlist. | | `` | yes | The row key, bound as a `?` query parameter (never interpolated) and matched with `WHERE = ?`. | | `#json-field` | no | Parse the value column as a JSON object and return one field (via `mamori.SelectKey`). | | `?key_col=` | no | Override the key column name (default `key`). | | `?val_col=` | no | Override the value column name (default `value`). | **Examples** - `sqlite://settings/greeting` - reads `value` where `key = 'greeting'` in table `settings`. - `sqlite://config/db#host` - reads the JSON object at key `db` and returns its `host` field. - `sqlite://settings/region?key_col=name&val_col=val` - reads `val` where `name = 'region'` in table `settings`. ```go type Config struct { Greeting string `source:"sqlite://settings/greeting"` } mamori.WithProvider(sqliteprov.New(sqliteprov.WithPath("/var/lib/app/config.db"))) ``` The database file path is not part of the ref (set it with `WithPath` or `SQLITE_PATH`), so refs stay portable across environments. The row key is always a bound `?` parameter; the table and column names are validated against a strict identifier allowlist (`^[A-Za-z_][A-Za-z0-9_]*$`) before any query runs, so a ref can never inject SQL. `Value.Version` is a content hash of the value by default, or a native revision column via `WithVersionColumn`. Values are non-sensitive unless you set `WithSensitive(true)` or wrap the field in `secret.String`. ## Error classification Beyond the not-found case, a query failure that carries a SQLite result code is classified via `modernc.org/sqlite`'s own `*sqlite.Error.Code()`, so `mamori.ErrorKind` can distinguish it: | SQLite result code | mamori kind | | --- | --- | | `SQLITE_PERM` (3), `SQLITE_READONLY` (8) | `permission_denied` | | `SQLITE_BUSY` (5), `SQLITE_LOCKED` (6), `SQLITE_CANTOPEN` (14) | `unavailable` | | `SQLITE_AUTH` (23) | `unauthenticated` | | anything else | `unknown` | Codes not listed above report `unknown` rather than being guessed at, and the original `*sqlite.Error` stays reachable with `errors.As`. ## Watch `Watch` uses fsnotify on the database file: when the file changes, mamori re-queries and emits an update. Ideal for a config DB written by another process. Verified against a real temporary SQLite database (no cgo, no external service). An identifier-allowlist rejection test is included, and error classification is verified both by a table test over real driver errors and by a dedicated Resolve-level test proving the wiring is non-vacuous. --- > Source: https://mamorigo.dev/docs/testing # Testing `mamoritest` is an in-memory, scriptable `mamori.Provider` for testing code that consumes `mamori` (an `OnChange` handler, an `OnError` sink) without a real AWS account, Vault, or Kubernetes cluster. Its wait helpers block until a change is actually applied instead of sleeping and hoping. ```bash go get github.com/xavidop/mamori/mamoritest ``` ## Quick start Create a provider, script a value, `Watch` it, then push a change and wait for it to land before asserting: ```go func TestConfigReactsToChange(t *testing.T) { p := mamoritest.NewProvider("mt") p.Set("cfg/level", "info") type Config struct { Level string `source:"mt://cfg/level"` } w, err := mamori.Watch[Config](context.Background(), mamori.WithProvider(p)) if err != nil { t.Fatalf("Watch: %v", err) } defer func() { _ = w.Close() }() if w.Get().Level != "info" { t.Fatalf("Level = %q, want info", w.Get().Level) } p.Set("cfg/level", "debug") // push a change mamoritest.WaitForSnapshot(t, w, 2) // block until it lands (1 = initial load) if w.Get().Level != "debug" { t.Fatalf("Level = %q, want debug", w.Get().Level) } } ``` `Provider` implements `mamori.Provider` and `mamori.WatchableProvider`, so it works with `Load`, `Watch`, and `Doctor` like a real provider. Register it with `mamori.WithProvider` and reference keys with `source:"://"`. ## Drive the fake provider Five methods script what the provider returns for a key: ```go func (p *Provider) Set(key, val string) func (p *Provider) SetBytes(key string, b []byte) func (p *Provider) Del(key string) func (p *Provider) Fail(key string, err error) func (p *Provider) Clear(key string) ``` | Method | Effect | | --- | --- | | `Set` | Store a string value and push it to any watcher of `key`. | | `SetBytes` | Byte-oriented `Set`, for binary payloads. | | `Del` | Remove the value; later resolves and watchers see `mamori.ErrNotFound`. | | `Fail` | Every future resolve of `key` returns `err` (and pushes it) until `Clear`. | | `Clear` | Remove an injected failure and push the restored state. | - `key` must match the ref's path exactly: `source:"mt://cfg/level"` is keyed by `Set("cfg/level", ...)`, not the full `mt://...` URL. - A key with neither `Set` nor `Fail` called on it resolves as `mamori.ErrNotFound`. - After `Del`, a field with `default:` or `optional` falls back to its default; a required field with no default becomes an unhealthy, erroring field. ## Wait for async propagation A push from `Set`, `Del`, or `Fail` reaches a real `mamori.Watcher` asynchronously on the reconciler's goroutine. These helpers block until the effect is observable, so a test never needs a fixed `time.Sleep`. ### Wait for a change to land ```go func WaitForSnapshot[T any](tb testing.TB, w *mamori.Watcher[T], v uint64) ``` Blocks until `w.Status().Snapshot` reaches version `v`, then returns; fails via `tb.Fatalf` if `v` never arrives in time. The initial load is version **1**, the first applied change is **2**, the next is **3**, and so on. ```go p.Set("db/password", "hunter2") w, err := mamori.Watch[Config](context.Background(), mamori.WithProvider(p)) // ... err check, defer w.Close() p.Set("db/password", "rotated") // one change since Watch started mamoritest.WaitForSnapshot(t, w, 2) ``` ### Wait for an error ```go func CaptureErrors() (mamori.Option, *ErrorCapture) func WaitForError(tb testing.TB, c *ErrorCapture, kind mamori.Kind) error ``` `CaptureErrors` returns an `OnError` sink option plus the `*ErrorCapture` it feeds; pass the option to `Watch` or `Load`. `WaitForError` blocks until the capture holds an error classified as `kind` (via `mamori.ErrorKind`) and returns it, else fails the test. See [Error kinds](/docs/concepts/error-kinds/) for the `Kind` values. ```go onErr, errs := mamoritest.CaptureErrors() w, err := mamori.Watch[Config](context.Background(), mamori.WithProvider(p), onErr) // ... err check, defer w.Close() p.Fail("cfg/level", mamori.ErrPermissionDenied) got := mamoritest.WaitForError(t, errs, mamori.KindPermissionDenied) ``` ## A complete test Rotating a value and asserting both `Get()` and the `OnChange` handler, then failing a key and asserting `OnError` reaches it with the expected kind: ```go package myapp_test import ( "context" "testing" "time" "github.com/xavidop/mamori" "github.com/xavidop/mamori/mamoritest" ) type Config struct { DBPassword string `source:"mt://db/password"` } func TestRotationTriggersOnChange(t *testing.T) { p := mamoritest.NewProvider("mt") p.Set("db/password", "hunter2") // OnChange runs on its own goroutine; signal through a channel. changed := make(chan string, 1) w, err := mamori.Watch[Config](context.Background(), mamori.WithProvider(p), mamori.OnChange(func(ev mamori.Change[Config]) { changed <- ev.New.DBPassword }), ) if err != nil { t.Fatalf("Watch: %v", err) } defer func() { _ = w.Close() }() p.Set("db/password", "rotated") mamoritest.WaitForSnapshot(t, w, 2) // 1 = initial load, 2 = first change // WaitForSnapshot guarantees Get() already reflects the rotated value. if w.Get().DBPassword != "rotated" { t.Fatalf("Get().DBPassword = %q, want rotated", w.Get().DBPassword) } // OnChange may land just after the snapshot; give it a bounded wait. select { case got := <-changed: if got != "rotated" { t.Fatalf("OnChange saw DBPassword = %q, want rotated", got) } case <-time.After(2 * time.Second): t.Fatal("OnChange did not fire after Set") } } func TestBackendFailureReachesOnError(t *testing.T) { p := mamoritest.NewProvider("mt") p.Set("db/password", "hunter2") onErr, errs := mamoritest.CaptureErrors() w, err := mamori.Watch[Config](context.Background(), mamori.WithProvider(p), onErr) if err != nil { t.Fatalf("Watch: %v", err) } defer func() { _ = w.Close() }() p.Fail("db/password", mamori.ErrPermissionDenied) got := mamoritest.WaitForError(t, errs, mamori.KindPermissionDenied) if mamori.ErrorKind(got) != mamori.KindPermissionDenied { t.Fatalf("WaitForError returned kind %q, want permission_denied", mamori.ErrorKind(got)) } // The watcher keeps serving the last good value; a failure never blanks it. if w.Get().DBPassword != "hunter2" { t.Fatalf("Get().DBPassword after Fail = %q, want last-good hunter2", w.Get().DBPassword) } } ``` ## Fail the build when config cannot resolve `Doctor` resolves every field of `T` once against your real provider wiring and reports every failure without starting a watcher. Run it as a build-tagged CI test that fails on any unhealthy field, so a rotated-away secret, a missing IAM permission, or a typo'd ref is caught before it ships: ```go //go:build preflight func TestConfigPreflight(t *testing.T) { rep, err := mamori.Doctor[Config](context.Background(), appProviders()...) if err != nil { t.Fatal(err) } for _, f := range rep.Fields { if f.LastKind != "" { t.Errorf("%s (%s): %s: %s", f.Path, f.Ref, f.LastKind, f.LastError) } } } ``` Gate it behind the tag so it only runs where real credentials and network access exist: ```bash go test -tags preflight ./... ``` Unlike `Load`, `Doctor` never aborts on the first failure, so one run reports every misconfigured ref. See [Doctor](/docs/observability/doctor/) for the full `Report` shape. ## Drive poll intervals with the fake clock `mamoritest.Provider` delivers changes natively, not on a poll timer. To drive time itself (for example, testing `mamori.WithPollInterval` against a provider that only polls), inject `mamori.NewFakeClock` with `mamori.WithClock` and advance it manually: ```go clk := mamori.NewFakeClock(time.Time{}) w, err := mamori.Watch[Config](context.Background(), mamori.WithProvider(p), mamori.WithClock(clk), mamori.WithPollInterval(30*time.Second), ) // ... err check, defer w.Close() clk.Advance(30 * time.Second) // fires the next poll tick deterministically ``` ## How it works A `Watch` against the fake delivers changes over a native Go channel, not by polling. Each `Watch` replays the key's current state as an immediate baseline and delivers a delete as `ErrNotFound` (not a channel close), mirroring real native-watch backends (Kubernetes informers, Firestore, Redis keyspace notifications). The subscription is torn down when the context is done, leaving no goroutine behind (goleak-checked). The snapshot versions `WaitForSnapshot` uses are the reconciler's own: `Watcher.Status().Snapshot` starts at 1 for the initial load and increments by one per applied change, so the first change lands on 2. `NewFakeClock` and `WithClock` live in the core `mamori` package, not `mamoritest`, since they are a general-purpose seam for any time-dependent test. ## See also - [Write a provider](/docs/writing-a-provider/) covers `providertest`, the counterpart kit for provider authors. - [Doctor](/docs/observability/doctor/) covers `Doctor` and the `Report` shape in full. - [Error kinds](/docs/concepts/error-kinds/) lists the `Kind` values `WaitForError` matches against. --- > Source: https://mamorigo.dev/docs/providers/unleash # Unleash Load an [Unleash](https://www.getunleash.io) feature toggle's state (or a variant) as config. | | | | --- | --- | | Scheme | `unleash://` | | Module | `github.com/xavidop/mamori/providers/unleash` | | Sensitive | no | | Watch | poll | | Auth | `UNLEASH_URL` + `UNLEASH_API_TOKEN` + app name | ## Install ```bash go get github.com/xavidop/mamori/providers/unleash ``` ```go import _ "github.com/xavidop/mamori/providers/unleash" ``` ## Using the ref A `unleash://` ref points at one feature toggle. Without a fragment it resolves to the toggle's enabled state; a fragment selects a variant instead. ```text unleash://[#variant | #payload] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Unleash feature toggle name. | | `#variant` | no | Resolve to the matched variant's name instead of the on/off state. | | `#payload` | no | Resolve to the matched variant's payload value. | A toggle that does not exist resolves to not-found. **Examples** - `unleash://new-dashboard` resolves to `true` / `false` - pair it with a `bool` field. - `unleash://checkout-experiment#variant` resolves to the assigned variant name, e.g. `blue`. - `unleash://checkout-experiment#payload` resolves to that variant's payload (often JSON, which you can `flatten:"json"`). ## Watch The Unleash client refreshes its toggle state on its own interval; mamori polls (`WithPollInterval` + jitter) on top of that. ## Error classification Unleash's client surface returns bool/string values, not errors, so this provider has no error vocabulary beyond not-found. It is conformance-exempt from the `ErrorClassification` case via `providertest.Config.NoResolveErrors`. ## Configuration ```go import unleashprov "github.com/xavidop/mamori/providers/unleash" mamori.WithProvider(unleashprov.New( unleashprov.WithURL(os.Getenv("UNLEASH_URL")), unleashprov.WithToken(os.Getenv("UNLEASH_API_TOKEN")), unleashprov.WithAppName("my-service"), )) ``` Verified with an injected fake (un-seeded toggles resolve to not-found); live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/validation # Validation Add a `validate` tag to any field and mamori enforces it - on the initial `Load` **and on every reconciled update**. If a new value would make the struct invalid, the update is rejected atomically: `Get()` keeps returning the last good config and `OnError` receives a `*ValidationError`. ```go type Config struct { 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"` AdminURL string `source:"env:ADMIN_URL" validate:"omitempty,url"` Port int `source:"env:PORT" validate:"required,min=1,max=65535"` } ``` ## Which validators are available The `validate` tag is [go-playground/validator/v10](https://github.com/go-playground/validator) syntax - the same library used by Gin, Echo, and many others. Any validator it ships works here. The most common ones: | Tag | Meaning | | --- | --- | | `required` | Value must be non-zero. | | `omitempty` | Skip the remaining rules if the value is empty. | | `gte=` / `lte=` / `gt=` / `lt=` | Numeric (or length) bounds, e.g. `gte=1,lte=256`. | | `min=` / `max=` / `len=` | Length for strings/slices/maps, value for numbers. | | `oneof=a b c` | Must equal one of the space-separated values. | | `eq=` / `ne=` | Equal / not equal to a value. | | `email` | Valid email address. | | `url` / `uri` / `http_url` | Valid URL / URI. | | `hostname` / `fqdn` | Valid host name. | | `ip` / `ipv4` / `ipv6` / `cidr` | IP address / CIDR. | | `uuid` / `uuid4` | Valid UUID. | | `alpha` / `alphanum` / `numeric` | Character-class checks. | | `contains=` / `startswith=` / `endswith=` | Substring checks. | | `e164` | Phone number in E.164 form. | | `datetime=2006-01-02` | Parseable with the given Go time layout. | | `dive` | Descend into a slice/map/array and apply the following rules to each element. | | `eqfield=Other` / `nefield=Other` | Compare against another field on the struct. | Combine rules with commas (AND): `validate:"required,gte=1,lte=256"`. The full catalogue is in the [validator docs](https://pkg.go.dev/github.com/go-playground/validator/v10#hdr-Baked_In_Validators_and_Tags). ## Nested structs and slices Validation runs over the whole decoded struct, so nested structs are validated too. Use `dive` to validate slice or map elements: ```go type Config struct { Redis RedisConfig `source:"aws-sm://prod/redis" flatten:"json"` // RedisConfig's own validate tags run Origins []string `source:"env:ORIGINS" validate:"required,dive,url"` // each origin must be a URL } ``` ## Custom validation Swap the validator entirely with `WithValidator`. Any type implementing `Validate(any) error` works, so you can register custom go-playground validators, or plug in a different engine: ```go v := validator.New(validator.WithRequiredStructEnabled()) _ = v.RegisterValidation("dns1123", func(fl validator.FieldLevel) bool { return isDNS1123Label(fl.Field().String()) }) cfg, err := mamori.Load[Config](ctx, mamori.WithValidator(myAdapter{v}), // Validate(any) error wrapping v.Struct ) ``` ## What failure looks like - **At load**: `Load` / `Watch` return a `*ValidationError` and the zero value. Startup fails fast. - **At runtime**: the update is discarded, `Get()` is unchanged, and `OnError` receives a `*ValidationError`. Unwrap it to reach the underlying validator error: ```go mamori.OnError(func(err error) { var ve *mamori.ValidationError if errors.As(err, &ve) { log.Printf("rejected invalid config update: %v", ve) } }) ``` --- > Source: https://mamorigo.dev/docs/providers/vault # Vault HashiCorp Vault KV v2, with lease-aware refresh, built on `hashicorp/vault/api`. | | | | --- | --- | | Scheme | `vault://` | | Module | `github.com/xavidop/mamori/providers/vault` | | Sensitive | yes | | Watch | lease-aware poll | | Auth | `VAULT_ADDR`, `VAULT_TOKEN` | ## Install ```bash go get github.com/xavidop/mamori/providers/vault ``` ```go import _ "github.com/xavidop/mamori/providers/vault" ``` ## Using the ref A `vault://` ref points at one secret in a KV v2 engine (or a dynamic/leased secret path). ```text vault:///[#key][?renew=true] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The KV v2 mount point, e.g. `secret`. | | `` | yes | The secret path within that mount. mamori reads the physical `/data/` for you; a leading `data/` is tolerated, so `vault://secret/app` and `vault://secret/data/app` are equivalent. | | `#key` | no | Return one field of the secret's data map. Without it, the whole data map is returned as JSON. | | `?renew=true` | no | Renew a renewable lease on read (dynamic secrets), so the refresh deadline follows the renewed lease. | **Examples** - `vault://secret/app#password` selects the `password` field of the KV v2 secret at `secret/app`. - `vault://secret/app` returns the whole `secret/app` data map as JSON - decode it with `flatten:"json"`. - `vault://database/creds/readonly#password?renew=true` reads a dynamic database credential and keeps its lease renewed. ```go type Config struct { // KV v2 at mount "secret", path "app"; select the "password" field DBPassword secret.String `source:"vault://secret/app#password"` // dynamic/leased secret, renewed automatically Lease secret.String `source:"vault://database/creds/readonly#password?renew=true"` } ``` Values are always `Sensitive`, and `Value.Version` is the KV v2 metadata version (a content hash for reads without version metadata). ## Leases and refresh When the read secret carries a lease (`LeaseDuration > 0`), the provider sets `Value.NotAfter = now + LeaseDuration`, and mamori schedules a refresh **before** the lease expires rather than waiting for the poll interval. `?renew=true` renews a renewable lease via `Sys().Renew` and derives `NotAfter` from the renewed lease. Vault KV has no native push, so the provider does not implement `Watch`; mamori polls, and `NotAfter` drives lease-aware refresh. ## Explicit configuration ```go import vaultprov "github.com/xavidop/mamori/providers/vault" mamori.WithProvider(vaultprov.New( vaultprov.WithAddress("https://vault.internal:8200"), vaultprov.WithToken(os.Getenv("VAULT_TOKEN")), vaultprov.WithNamespace("team-a"), )) ``` ## Error classification | Vault response | mamori kind | |---|---| | 404, `api.ErrSecretNotFound` | `not_found` | | 403 | `permission_denied` | | 429 | `rate_limited` | | 5xx (including a sealed vault's 503) | `unavailable` | | 400 | `invalid` | | Malformed ref (not `/`) | `invalid` | | anything else | `unknown` | Vault answers 403 both for a policy that doesn't cover the path and for a missing or expired token, and the status code can't separate them, so both report `permission_denied`. If you're investigating one, check token validity as well as policy. Verified by unit tests (with/without `#key`, lease `NotAfter`) and the conformance kit against an in-memory fake; a dev-Vault integration test is provided behind `//go:build integration`. --- > Source: https://mamorigo.dev/docs/cli/vet # mamori vet `mamori vet` is a `go vet` analyzer that catches a specific, easy-to-make mistake: pulling a secret-bearing source into a plain `string` or `[]byte`. It flags any config field wired to a secret backend but typed as a plain value instead of the redacting `secret.String` / `secret.Bytes` types, so the plaintext cannot slip out through logs, `fmt`, or JSON. It ships inside the same `mamori` binary as the rest of the CLI, so there is nothing extra to install. ## Run it The analyzer runs two ways from the one binary. Use whichever fits your workflow, both report the same findings and exit non-zero when something is wrong: ```bash # As a mamori subcommand (patterns default to ./...): mamori vet ./... # As a go vet tool: go vet -vettool=$(which mamori) ./... ``` Given a config struct where one field stores a Vault secret in a plain `string`: ```go type Config struct { Password string `source:"vault://kv/db#password"` // flagged APIKey secret.String `source:"aws-sm://prod/api-key"` // ok } ``` `mamori vet` reports the plain field and points you at the fix: ```text config.go:2:2: field "Password" has a secret-bearing source scheme "vault" but stores it in a plain string; use secret.String or secret.Bytes to keep the value redacted ``` ## What it flags A field is flagged when both of these are true: 1. It has a `source:"..."` struct tag whose scheme is one of the secret-bearing schemes below. 2. Its Go type is a plain `string` or `[]byte`, not `secret.String` or `secret.Bytes`. The secret-bearing schemes are the ones that resolve to secret material: | Scheme | Backend | Always a secret? | | ------------ | --------------------------- | ----------------------------- | | `aws-sm` | AWS Secrets Manager | yes | | `gcp-sm` | Google Cloud Secret Manager | yes | | `azure-kv` | Azure Key Vault | yes | | `vault` | HashiCorp Vault | yes | | `op` | 1Password | yes | | `sops` | Mozilla SOPS | yes | | `doppler` | Doppler | yes | | `k8s-secret` | Kubernetes Secret | yes | | `aws-ps` | AWS SSM Parameter Store | only `SecureString` params | | `exec` | Command output | mamori marks all of it secret | | `mamori` | A mamori config server | whatever the server marks | The last three carry secrets only sometimes, and are flagged anyway. A false positive costs you a `secret.String` on a value that did not need one; a miss costs a leaked credential. For a security check, the cheap mistake is the right one to make. Config-style schemes (`env`, `file`, `consul`, `k8s-cm`, and the like) never carry secret material, so fields using them are left alone. Fields with no `source` tag, and fields that already use `secret.String` / `secret.Bytes`, are considered correct. Note `k8s-secret` is listed but `k8s-cm` is not: a ConfigMap is not a secret. ## Covering a custom provider The built-in list only knows the schemes mamori ships. If you [wrote your own provider](/docs/writing-a-provider/), its scheme is unknown to the analyzer, and a secret of yours sitting in a plain `string` would pass unreported. Add your scheme with `--secret-schemes`: ```bash mamori vet --secret-schemes=mysecrets ./... ``` Pass several as a comma-separated list. The flag **adds to** the built-in set rather than replacing it, so `aws-sm`, `vault`, and the rest stay covered: ```bash mamori vet --secret-schemes=mysecrets,corp-kv ./... ``` As a `go vet` tool the flag is namespaced by the analyzer: ```bash go vet -vettool=$(which mamori) -mamorivet.secret-schemes=mysecrets ./... ``` The value is a bare scheme token, the part before `://`. A full ref such as `mysecrets://prod` is rejected with an error rather than silently matching nothing. ## Chains are checked end to end A `source` tag may hold a [precedence chain](/docs/concepts/source-chains/) of comma-separated refs rather than a single one, for example `env:TOKEN,vault://kv/token`. `mamori vet` checks every ref in the chain, not just the first, so a sensitive ref anywhere in the chain is flagged even when it sits behind a non-sensitive primary as a lower-priority fallback: ```go type Config struct { Token string `source:"env:TOKEN,vault://kv/token"` // flagged: vault is sensitive, even as the fallback } ``` ```text config.go:2:2: field "Token" has a secret-bearing source scheme "vault" but stores it in a plain string; use secret.String or secret.Bytes to keep the value redacted ``` The chain is split the same way mamori splits it at runtime, so what the analyzer calls "the refs in this tag" matches what your program actually resolves. ## Fixing a finding Change the field type to the matching redacting wrapper: `secret.String` for a `string`, `secret.Bytes` for a `[]byte`. Import `github.com/xavidop/mamori/secret` and swap the type; the tag stays the same. ```go type Config struct { Password secret.String `source:"vault://kv/db#password"` // fixed } ``` The wrapper keeps the plaintext from leaking through `String()`, `fmt`, `encoding/json`, or `log/slog`. See [Secret types](/docs/concepts/secret-types/) for what the wrappers redact. ## In CI Wire the analyzer into CI so a leaked secret type can never merge. Install the CLI and run it over your packages as a build step, in either mode: ```bash go install github.com/xavidop/mamori/cmd/mamori@latest mamori vet ./... # or, as a go vet tool: go vet -vettool=$(which mamori) ./... ``` Both modes exit non-zero when a finding is reported, which fails the step. ## How it works The check is purely structural. `secret.String` and `secret.Bytes` are named struct types, so they never look like a plain `string` or a plain `[]byte`. The analyzer flags a field only when its type matches one of those two plain shapes, which is exactly what tells an unprotected field apart from a redacted one. Because the match is structural, it needs no name matching and does not import the mamori core, so the analyzer stays fast and dependency-light while shipping inside the `mamori` CLI. The scheme list is declared rather than derived, and it has to be. A provider marks its values secret at resolve time, by returning `Value{Sensitive: true}`, which a static analyzer can never observe: `go vet` does not run your program, and the CLI deliberately imports no provider modules so that no cloud SDK reaches its dependency graph. That is why the built-in set is a fixed list, and why `--secret-schemes` exists for the schemes it cannot know about. ## See also - [CLI overview](/docs/cli/) - the rest of the `mamori` command set. - [Concepts: Secret types](/docs/concepts/secret-types/) - the redacting wrappers this analyzer steers you toward. - [Concepts: Source chains and precedence](/docs/concepts/source-chains/) - how a `source` tag chain is parsed. --- > Source: https://mamorigo.dev/docs/writing-a-provider/capabilities # Watch and batch Implement these optional interfaces only when the backend supports them. mamori type-asserts each at runtime and uses it automatically: a `WatchableProvider` is watched natively, one without it is wrapped in a polling adapter that calls `Resolve` on a schedule (so never fake a `Watch`); a `BatchProvider` gets `ResolveBatch`, otherwise mamori falls back to individual `Resolve` calls. Adding a capability later never requires a consumer change. ## Watch for native changes Implement `WatchableProvider` **only** for backends with native change notification (Vault leases, Kubernetes informers, Consul blocking queries, `fsnotify`). Providers without native watch are wrapped in the polling adapter, so never fake a `Watch` with an internal ticker. ```go // Optional. The channel MUST close on ctx cancel, with no goroutine leaks. func (p *Provider) Watch(ctx context.Context, ref mamori.Ref) (<-chan mamori.Update, error) { ch := make(chan mamori.Update, 1) go func() { defer close(ch) // ...subscribe; on each change emit mamori.Update{Value: v}... // ...on a transient error emit mamori.Update{Err: err} and keep the channel open... }() return ch, nil } ``` Deliver a new value as `mamori.Update{Value: v}` and a transient error as `mamori.Update{Err: err}` (the channel stays open; mamori keeps the last-good value). Closing the channel signals termination, which must happen on `ctx` cancellation. ## Resolve batches in one call Implement `BatchProvider` when the backend can fetch many refs in one round trip (Secrets Manager `BatchGetSecretValue`, one file read for many keys). Key the result map by each input `Ref.Raw`, and omit not-found refs so mamori applies their defaults instead of failing the whole batch: ```go // Optional. mamori calls this automatically when the interface is present. func (p *Provider) ResolveBatch(ctx context.Context, refs []mamori.Ref) (map[string]mamori.Value, error) { fetched, err := p.client.getMany(ctx, paths(refs)) if err != nil { return nil, err } out := make(map[string]mamori.Value, len(refs)) for _, ref := range refs { raw, ok := fetched[ref.Path] if !ok { continue // omit not-found refs; mamori applies the default } out[ref.Raw] = mamori.Value{Bytes: raw, Version: mamori.VersionHash(raw), Sensitive: true} } return out, nil } ``` Next: prove it all with the [Conformance](/docs/writing-a-provider/conformance/) kit. --- > Source: https://mamorigo.dev/docs/usage/watching # Watch for changes `Watch` does the same fail-fast initial load as `Load`, then keeps the config reconciled in the background. It returns once the initial config is resolved; `OnChange` fires only on later changes. Read the current config any time with `Get()`. ```go w, err := mamori.Watch[Config](ctx, mamori.WithPollInterval(30*time.Second), mamori.OnChange(func(ev mamori.Change[Config]) { if ev.Changed("DBPassword") { pool.Rotate(ev.New.DBPassword.Reveal()) } for _, f := range ev.Fields { log.Printf("%s changed: %s -> %s", f.Path, f.OldVersion, f.NewVersion) } }), mamori.OnError(func(err error) { metrics.Inc("config_error") }), ) if err != nil { log.Fatal(err) } defer w.Close() cfg := w.Get() // lock-free atomic snapshot; always the last valid config ``` ## Read the current config with Get `Get()` returns a lock-free atomic snapshot of the last valid config. It is safe to call from any goroutine on every request; there is no need to cache the result. ## React to a field with Change and Changed `OnChange` receives a `Change[T]` carrying `Old` and `New` full snapshots plus `Fields []FieldChange{Path, OldVersion, NewVersion}`. Use `Changed(path string) bool` to react to one field: ```go mamori.OnChange(func(ev mamori.Change[Config]) { if ev.Changed("DBPassword") { pool.Rotate(ev.New.DBPassword.Reveal()) } }) ``` ## Handle errors with OnError `OnError` receives runtime resolve and validation errors without stopping the watcher. Use it for metrics and alerting; `Get()` keeps serving the last good config. ```go mamori.OnError(func(err error) { var verr *mamori.ValidationError if errors.As(err, &verr) { metrics.Inc("config_validation_error") return } metrics.Inc("config_error") }) ``` ## What you can rely on These behaviors are guaranteed and covered by the conformance kit. - **Validated, all-or-nothing updates.** `OnChange` fires with a fully re-validated snapshot. If a new value fails validation the update is rejected: `Get()` keeps returning the last good config and `OnError` receives a `*ValidationError`. - **OnChange is called one at a time.** Callbacks are serialized, so your callback never runs concurrently with itself. A slow callback delays the next event but never drops it in normal operation. - **Coalesced events.** Field changes within a debounce window (default 500ms, override per field with `?debounce=`) produce a single `Change`. A JSON secret with five keys rotating is one event, not five. - **Last-good on failure.** On a runtime resolve failure the last-good value is retained, `OnError` receives a `*ProviderError`, and the ref is retried with per-ref exponential backoff. `WithStale(maxAge)` escalates prolonged staleness to a hard `*StaleError`. - **Clean shutdown.** `Close()` cancels provider watches, drains the callback queue, and returns. ## Tuning the dispatch queue `OnChange` dispatch goes through a bounded queue. `WithQueueDepth(n)` sets its depth (default 16); when a slow consumer fills it, the oldest queued event is dropped rather than blocking the reconciler. Because each `Change` carries full `Old` / `New` snapshots, a dropped notification never leaves `Get()` wrong. ## See also - [Source chains](/docs/concepts/source-chains/) for comma-separated precedence and `onfail`. - [Snapshots and pinning](/docs/usage/snapshots/) for `Status`, history, and pinning. - [Observability](/docs/observability/) for `Status`, `Health`, and the HTTP surface. --- > Source: https://mamorigo.dev/docs/comparison # Why mamori The primitives already exist. `gocloud.dev/runtimevar` watches a single variable. Viper and koanf do multi-source config. The AWS caching client and Vault's `LifetimeWatcher` each refresh one backend. Nobody composed them into typed, validated, watchable config with a provider ecosystem, so every production service ends up hand-rolling a `ConfigManager` with a ticker, a mutex, and a prayer. mamori is that glue, done once. It is the External Secrets Operator provider model one layer down: a library **inside your process**, not an operator inside your cluster. ## Compared to the alternatives | | Typed struct + tags | Multi-source | Secrets first-class | Runtime watch | Diff-aware callback | Provider ecosystem | | --- | --- | --- | --- | --- | --- | --- | | **mamori** | yes | yes | yes | native + poll | yes | yes, with a conformance kit | | `runtimevar` | no | one var at a time | weak | yes | no | driver matrix | | Viper / koanf | yes | yes | bolted on | afterthought | no | config-first | | AWS SM cache / Vault `LifetimeWatcher` | no | single backend | native | native, per backend | no | siloed | | envconfig / caarlos0/env | yes | env only | no | load-once | no | no | The operational layer is a second axis the alternatives mostly leave to you: knowing whether config is healthy, expressing precedence between sources, and classifying why a resolve failed. | | Precedence chains | Health introspection | Error classification | Pre-deploy check | Fan-out server | | --- | --- | --- | --- | --- | --- | | **mamori** | per-field, ordered, with `onfail` | `Status` / `Health` / HTTP admin endpoint | `errors.Is` to a typed kind, conformance-enforced | `Doctor` (library) and `mamori doctor` (live) | optional `mamori://` config server | | `runtimevar` | no | per-variable value only | driver-specific | no | no | | Viper / koanf | key override order, no per-key policy | no | no | no | no | | AWS SM cache / Vault `LifetimeWatcher` | no | per backend | backend-specific | no | no | | envconfig / caarlos0/env | no | no | no | no | no | - **Precedence chains**: a `source` tag can list several refs (`env:X,aws-sm://x`); the first that resolves wins, and `onfail` (keeplast / useDefault / fail) governs what happens when the winner later errors. Viper and koanf resolve a merged key space rather than an ordered per-field chain, and neither carries a failure policy. - **Health introspection and error classification**: every resolve error maps to a typed `Kind` (`permission_denied`, `unavailable`, ...) that survives `errors.Is`, and `Status`/`Health` expose it live. `mamori.Doctor` runs the same wiring in CI before a deploy; `mamori doctor` queries a running process's admin endpoint. The alternatives surface a raw backend error, if any, and leave "is my config healthy" to you. ## Where mamori fits - **gocloud.dev/runtimevar** is the closest primitive. mamori adds struct composition, tags, validation, diff callbacks, and secret hygiene. A `runtimevar` bridge provider could even inherit its driver matrix. - **External Secrets Operator** solves the same provider problem at the cluster layer by materializing Kubernetes Secrets. mamori is complementary: it is for apps that want to skip the Kubernetes Secret hop, or that do not run on Kubernetes at all. It keeps no persistent external state, so there is no finalizer lifecycle to manage. - **Viper / koanf** are config-first with secrets bolted on. mamori is secrets-first with config included. - **spring-cloud-config** and **.NET `IOptionsMonitor`** are the developer-experience benchmark from other ecosystems. `Watch().Get()` is mamori's `IOptionsMonitor.CurrentValue`. ## What mamori is not - Not a secrets store: no encryption at rest, and no persistent state of its own. The optional [config server](/docs/server/) is a read-through fan-out in front of your existing backends, not a place secrets live, so it does not make mamori a store. - Not a sync engine between stores (that is ESO / vals / teller territory). - Not a general feature-flag system, though a flags provider could be built on top. - Not cross-language: it is deliberately Go-idiomatic. --- > Source: https://mamorigo.dev/docs/writing-a-provider # Write a provider A provider is a self-contained Go module that resolves one URL scheme into values. The minimum interface is a type with `Scheme()` and `Resolve()` that registers itself; native watch and batching are optional. ```go type Provider interface { Scheme() string Resolve(ctx context.Context, ref mamori.Ref) (mamori.Value, error) } ``` ## Quick start A provider is a `Scheme()`, a `Resolve()`, and an `init` that calls `mamori.Register`: ```go package myprovider import ( "context" "github.com/xavidop/mamori" ) // Provider resolves refs of the "myscheme" scheme. It is safe for concurrent use. type Provider struct { client backend // your backend client } // Option configures a Provider. type Option func(*Provider) // New builds a provider. With no options it uses a default client, so it is safe // to Register from init before any credentials are present. func New(opts ...Option) *Provider { p := &Provider{client: defaultClient()} for _, o := range opts { o(p) } return p } // Scheme returns the single URL scheme this provider handles. func (p *Provider) Scheme() string { return "myscheme" } // Resolve fetches the current Value for ref. func (p *Provider) Resolve(ctx context.Context, ref mamori.Ref) (mamori.Value, error) { raw, err := p.client.get(ctx, ref.Path) // your backend call, honoring ctx if isNotFound(err) { return mamori.Value{}, mamori.ErrNotFound // MUST satisfy errors.Is } if err != nil { return mamori.Value{}, err } return mamori.Value{ Bytes: raw, Version: mamori.VersionHash(raw), // or a native revision id Sensitive: true, // set on secret-bearing values }, nil } func init() { mamori.Register(New()) } // panics on a duplicate scheme ``` A consumer refers to it from a struct tag, no extra wiring: ```go type Config struct { APIKey string `source:"myscheme://backend/prd#STRIPE_API_KEY"` } ``` ## Set up the module Each provider is its **own module** so its backend SDK never leaks into the core or other providers. Create it under `providers/`: ```text providers// go.mod module github.com/xavidop/mamori/providers/ .go the provider _test.go unit tests + providertest.Run against a fake README.md scheme, ref grammar, auth, what is verified ``` The `go.mod` requires the core module and, in the monorepo, points at it with a replace directive: ```text module github.com/xavidop/mamori/providers/ go 1.26 require github.com/xavidop/mamori v0.1.0 replace github.com/xavidop/mamori => ../.. ``` Consumers install it on its own, so the SDK dependency is opt-in: ```bash go get github.com/xavidop/mamori/providers/ ``` ## Next - [Resolve and errors](/docs/writing-a-provider/resolve/) - implement `Resolve` and map backend errors to kinds. - [Watch and batch](/docs/writing-a-provider/capabilities/) - the optional `WatchableProvider` and `BatchProvider`. - [Conformance](/docs/writing-a-provider/conformance/) - the required `providertest.Run` case and the acceptance checklist. - [Providers](/docs/providers/) - the built-in provider catalog. If your provider resolves secrets, tell the analyzer about its scheme so a plain `string` holding one still gets flagged: [`mamori vet --secret-schemes=`](/docs/cli/vet/).