dynamic-config-vault
Read dynamic-config configuration from HashiCorp Vault's KV v2 store.
[]
= "0.2.0"
= "0.2.0"
use ;
set_remote;
// Fetching is explicit; the load that follows touches no network.
refresh_remote?;
builder.init?;
Vault's API is plain HTTP, so this implements the blocking RemoteSource
trait: nothing here needs an async runtime, and neither does using it.
What it reads
GET {address}/v1/{mount}/data/{path}, and takes data.data — the value half
of a KV v2 response. That object becomes the configuration document, so a secret
stored as {"host": "db", "port": 5432} maps onto a struct with those fields.
The document is handed over with the section key wrapped around it, because
Vault stores a section's contents rather than a whole configuration file.
That is the opposite of dynamic-config-consul and
dynamic-config-nats, and the difference is not a whim: Vault stores a map of
named fields, a KV bucket stores an opaque blob, and each is easiest to use as
what it already is.
| Crate | Stores | Natural unit |
|---|---|---|
| this one | a map of named fields | the field |
| Consul, NATS | opaque bytes | the whole document |
Logging in
Every Vault auth method ends in the same place — a client token with a lease — so
that is all Auth models.
| Method | Constructor | For |
|---|---|---|
| Token | Auth::token(..) |
a token somebody already obtained |
| AppRole | Auth::app_role(role_id, secret_id) |
a service outside Kubernetes |
| Kubernetes | Auth::kubernetes(role) |
a pod, with no secret to distribute |
| JWT / OIDC | Auth::jwt(token) |
anything with a signed identity token |
| Userpass | Auth::userpass(user, password) |
operators, and development |
| LDAP | Auth::ldap(user, password) |
a directory that already exists |
| TLS certificate | Auth::certificate() |
a client certificate, presented by your own agent |
Mount the method wherever it lives, and name a role when the mount needs one:
app_role.at_mount
jwt.with_role
Logging in is lazy. Building a Vault reaches nothing; the first read logs
in. Constructing a source is not I/O, and configuration that hits the network on
a call nobody expected to block is how a startup ends up mysteriously slow.
Kubernetes tokens are re-read at every login, not cached at startup — the kubelet rotates projected service-account tokens, and a copy taken at startup expires with the pod still running.
Expiry is handled twice, on purpose
Before the request, a token within thirty seconds of its expiry is renewed, or replaced by a fresh login if it cannot be renewed. This is the path that should normally fire.
After the request, a 403 is treated as the token stopped working and
triggers exactly one fresh login and retry. Clocks skew, Vault revokes, a lease
turns out shorter than it said — the proactive path cannot catch all of that, and
a configuration reader that gives up on the first 403 will eventually do so at
three in the morning.
Once, not in a loop: if a fresh token is also refused, the problem is the policy rather than the lease, and retrying would only turn a clear failure into a hang.
Auth::Token is the one variant that cannot recover on its own — there are no
credentials to log in again with. A renewable token is still renewed.
Watching
Vault is the one store dynamic-config talks to that cannot say when
something changed: no watch, no blocking query, no stream. So watch polls, and
says so rather than dressing a timer up as a subscription.
What it does not do is pull the secret every tick. KV v2 keeps a version
counter in its metadata, so each tick reads {mount}/metadata/{path} for
current_version and only reads the secret when that number moves. A secret that
has not changed is never transferred, never decrypted, and never written to an
audit log as a read.
let watch = new;
let watching = watch.watching;
spawn;
// Dropping `watch` — or calling `watch.stop()` — ends the loop.
- The current value is not delivered at startup: a watch reports changes, and announcing the value the caller already has would make every restart look like an edit. Fetch first if the starting value matters.
- A failed check does not end the watch. An expired token, a sealed Vault, a network blip — that is what a watch is there to survive.
- Stopping is noticed within a quarter second whatever the interval is, so a sixty-second poll does not mean a sixty-second exit.
Bringing your own HTTP client
new.with_agent
For a caller with its own proxy settings, a private CA, a client certificate, or
a connection pool it would rather not have a second copy of. This is also how
Auth::Certificate gets its certificate: the agent presents it, and the Auth
variant only tells Vault to log in with it.
Builders
| Method | Default |
|---|---|
with_key(..) |
"db" — must match the key given to builder(..) |
with_auth(..) / with_token(..) |
none; the first read says so |
with_namespace(..) |
none (Vault Enterprise) |
with_timeout(..) |
10 seconds |
with_agent(..) |
one built per source |
Example
| Example | Shows |
|---|---|
vault_kubernetes |
Logging in the way a pod does, reading a secret, and watching it by version rather than by re-reading it. |
It needs a server, and its own doc comment says how to start one in a container and put a document in it.
Testing
The test suite drives a real Vault in a container — no mocks. That is how
three of the behaviours above got pinned down rather than assumed: role-id is a
GET while secret-id is a POST, a destroyed secret id does not invalidate a
token already issued, and a lease shorter than the refresh window exercises the
whole expiry path without waiting for a real token to age out.
MSRV
1.85 — higher than dynamic-config's own 1.71, because an HTTP client stack
and a container-driving test harness both move faster than that crate wants to.
A companion pays for what it pulls in; the core stays where it is.
License
MIT