dynamic-config-etcd 0.0.1

Read dynamic-config configuration from an etcd v3 key/value store.
Documentation

dynamic-config-etcd

Read dynamic-config configuration from an etcd v3 key/value store.

[dependencies]
dynamic-config = { version = "0.0.1", features = ["async"] }
dynamic-config-etcd = "0.0.1"
use dynamic_config_etcd::Etcd;

DbConfig::set_remote_async(
    Etcd::new(["http://etcd.internal:2379"], "myapp/db.json").await?,
);

// Fetching is explicit; the load that follows touches no network.
DbConfig::refresh_remote_async().await?;

etcd speaks gRPC, so its client is async — which is why this implements the async AsyncRemoteSource trait rather than the blocking one. Pretending otherwise would only hide a block_on inside the crate.

What it reads

One key, whose value is a whole configuration document — the same bytes that would be in a config file. The format comes from the key's extension, or from with_format.

Connecting

Credentials and TLS go through etcd's own ConnectOptions, re-exported here so using them needs no direct dependency on etcd-client. There is no second vocabulary to learn, and options this crate has never heard of keep working.

use dynamic_config_etcd::{ConnectOptions, Etcd};

let etcd = Etcd::with_options(
    ["https://etcd.internal:2379"],
    "myapp/db.json",
    ConnectOptions::new()
        .with_user("myapp", std::env::var("ETCD_PASSWORD")?)
        .with_keep_alive(Duration::from_secs(30), Duration::from_secs(5)),
)
.await?;

TLS types — TlsOptions, Identity, Certificate — are behind this crate's tls feature, because TLS pulls a whole stack in and a program talking to etcd over a private network inside a cluster has no use for it. tls-roots also trusts the platform's root store, which is what a public CA needs.

new does not prove the server is reachable. The client connects lazily, so an unreachable etcd surfaces on the first read rather than at construction. That is the client's behaviour, not a choice made here, and papering over it with an eager round trip would make every construction cost one.

Sharing a client

let etcd = Etcd::from_client(client, "myapp/db.json");

For a caller that already talks to etcd and would rather not open a second connection. The client is Clone — cheaply, it is a handle — so sharing one costs nothing, and a shared client recovers from an expired token like any other.

Expiry

etcd issues simple auth tokens with a TTL, five minutes by default, and a long-lived configuration reader outlives one. On invalid auth token this asks for a new one and retries, once.

Not a reconnect: the gRPC channel looks after itself, and the client kept the credentials, so the thing that actually expired is the only thing replaced. That is also what lets a shared client recover — replacing a client the caller owns would not be this crate's to do.

Once, not in a loop: if a fresh token is refused too, the credentials are wrong, and retrying would turn a clear failure into a hang.

Watching

etcd's watch is a real push stream, so watch is a future the caller spawns and cancels by dropping — no runtime is imposed and no flag is polled.

let task = tokio::spawn(async move { etcd.watch(DbConfig::apply_remote).await });

// Dropping or aborting the task stops the watch.
task.abort();
  • 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 deletion is not a change. No configuration is not a configuration, and neither replaying the last one nor pushing emptiness beats leaving the running snapshot alone.
  • It never returns Ok. A watch either runs or has failed; a silent success would leave a spawned task finished and a configuration frozen with nothing said about either. A cancelled watch — compaction is the usual reason — and a closed connection are both reported. Loop around it to reconnect.

Builders

Method Default
with_format(..) from the key's extension
with_options(..) ConnectOptions::new() — no credentials, no TLS
from_client(..) opens its own connection

Example

Example Shows
etcd_watching Connecting with ConnectOptions, watching a push stream, and a task awaiting reloads alongside it.

It needs a server, and its own doc comment says how to start one in a container and put a document in it.

cargo run -p dynamic-config-etcd --example etcd_watching

Testing

The test suite drives a real etcd in a container — no mocks, including one started with authentication enabled and a one-second token TTL. That is how the lazy-connect behaviour above was found: a mock would have confirmed what we already believed instead.

cargo test -p dynamic-config-etcd    # needs a working Docker daemon

The image comes from quay.io, which has no anonymous pull limits.

MSRV

1.85 — higher than dynamic-config's own 1.71, because a gRPC stack moves faster than that crate wants to. A companion pays for what it pulls in; the core stays where it is.

License

MIT