dynamic_config_etcd/lib.rs
1//! Read [`dynamic-config`] configuration from an etcd v3 key/value store.
2//!
3//! etcd speaks gRPC, so its client is async — which is why this implements the
4//! **async** [`AsyncRemoteSource`] trait rather than the blocking one.
5//!
6//! ```no_run
7//! use dynamic_config_etcd::Etcd;
8//!
9//! # struct DbConfig;
10//! # impl DbConfig {
11//! # fn set_remote_async(_: Etcd) {}
12//! # async fn refresh_remote_async() -> Result<(), dynamic_config::Error> { Ok(()) }
13//! # }
14//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
15//! DbConfig::set_remote_async(
16//! Etcd::new(["http://etcd.internal:2379"], "myapp/db.json").await?,
17//! );
18//!
19//! // Fetching is explicit; the load that follows touches no network.
20//! DbConfig::refresh_remote_async().await?;
21//! # Ok(())
22//! # }
23//! ```
24//!
25//! # What it reads
26//!
27//! One key, whose value is **a whole configuration document** — the same bytes
28//! that would be in a config file. The format comes from the key's extension,
29//! or from [`with_format`](Etcd::with_format).
30//!
31//! # The connection is made once, and lazily
32//!
33//! [`Etcd::new`] builds the client and [`fetch`](AsyncRemoteSource::fetch)
34//! reuses it — a source that reconnected on every read would turn a refresh
35//! loop into a connection storm.
36//!
37//! The underlying client connects *lazily*, so `new` succeeding does not mean
38//! the endpoints are reachable: an unreachable etcd surfaces on the first
39//! `fetch`, not at construction. That is the client's behaviour rather than a
40//! choice made here, and papering over it with an eager round trip would make
41//! every construction cost one.
42//!
43//! # Watching
44//!
45//! etcd's watch is a real push stream, so [`Etcd::watch`] is a future the caller
46//! spawns and cancels by dropping — no runtime is imposed and no flag is polled.
47//!
48//! ```no_run
49//! # use dynamic_config_etcd::Etcd;
50//! # struct DbConfig;
51//! # impl DbConfig {
52//! # fn apply_remote(_: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
53//! # }
54//! # async fn example(etcd: Etcd) {
55//! let task = tokio::spawn(async move {
56//! etcd.watch(DbConfig::apply_remote).await
57//! });
58//!
59//! // Dropping or aborting the task stops the watch.
60//! task.abort();
61//! # }
62//! ```
63//!
64//! [`dynamic-config`]: https://docs.rs/dynamic-config
65
66#![forbid(unsafe_code)]
67#![deny(missing_docs)]
68#![cfg_attr(docsrs, feature(doc_cfg))]
69
70use std::future::Future;
71use std::pin::Pin;
72
73use dynamic_config::{AsyncRemoteSource, Error, Fetched, Format};
74use etcd_client::EventType;
75
76/// etcd's own connection options, re-exported so authenticating needs no direct
77/// dependency on `etcd-client`.
78pub use etcd_client::{Client, ConnectOptions};
79
80/// etcd's TLS types, behind this crate's `tls` feature.
81///
82/// A separate feature because TLS pulls a whole stack in, and a program talking
83/// to etcd over a private network inside a cluster has no use for it.
84#[cfg(feature = "tls")]
85#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
86pub use etcd_client::{Certificate, Identity, TlsOptions};
87
88/// What an expired auth token looks like in etcd's error text.
89///
90/// etcd issues simple tokens with a TTL — five minutes by default — and refuses
91/// requests carrying an expired one. The gRPC channel reconnects on its own;
92/// the token does not, so this is the one failure worth recognising by hand.
93const INVALID_TOKEN: &str = "invalid auth token";
94use tokio::sync::Mutex;
95
96/// A key in etcd, as a configuration source.
97pub struct Etcd {
98 // etcd's client needs `&mut` to issue a request, so it is behind a lock —
99 // a tokio one, because it is held across an await.
100 client: Mutex<Client>,
101 key: String,
102 format: Option<Format>,
103 endpoints: String,
104}
105
106impl Etcd {
107 /// Connects to `endpoints` and reads `key`.
108 ///
109 /// The format is taken from the key's extension — `myapp/db.json` is JSON.
110 /// A key without one needs [`with_format`](Self::with_format).
111 ///
112 /// # Errors
113 ///
114 /// If the endpoints cannot be parsed. **Not** if they are unreachable: the
115 /// client connects lazily, so that surfaces on the first
116 /// [`fetch`](AsyncRemoteSource::fetch).
117 pub async fn new<E, S>(endpoints: E, key: impl Into<String>) -> Result<Self, Error>
118 where
119 E: IntoIterator<Item = S>,
120 S: Into<String>,
121 {
122 Self::with_options(endpoints, key, ConnectOptions::new()).await
123 }
124
125 /// As [`new`](Self::new), with etcd's own connection options.
126 ///
127 /// This is where authentication and TLS live, because that is where
128 /// `etcd-client` puts them — there is no second vocabulary to learn, and
129 /// options this crate has never heard of keep working.
130 ///
131 /// ```no_run
132 /// # use dynamic_config_etcd::{ConnectOptions, Etcd};
133 /// # async fn example() -> Result<(), dynamic_config::Error> {
134 /// let etcd = Etcd::with_options(
135 /// ["https://etcd.internal:2379"],
136 /// "myapp/db.json",
137 /// ConnectOptions::new()
138 /// .with_user("myapp", std::env::var("ETCD_PASSWORD").unwrap())
139 /// .with_keep_alive(
140 /// std::time::Duration::from_secs(30),
141 /// std::time::Duration::from_secs(5),
142 /// ),
143 /// )
144 /// .await?;
145 /// # Ok(())
146 /// # }
147 /// ```
148 ///
149 /// The credentials live in the client afterwards, which is what lets an
150 /// expired auth token be replaced without rebuilding anything.
151 ///
152 /// # Errors
153 ///
154 /// As [`new`](Self::new).
155 pub async fn with_options<E, S>(
156 endpoints: E,
157 key: impl Into<String>,
158 options: ConnectOptions,
159 ) -> Result<Self, Error>
160 where
161 E: IntoIterator<Item = S>,
162 S: Into<String>,
163 {
164 // Collected once: the client wants a slice, and the description wants
165 // the same strings.
166 let endpoints: Vec<String> = endpoints.into_iter().map(Into::into).collect();
167 let described = endpoints.join(", ");
168
169 let client = connect(&endpoints, &options, &described).await?;
170
171 Ok(Self {
172 client: Mutex::new(client),
173 key: key.into(),
174 format: None,
175 endpoints: described,
176 }
177 .with_format_from_key())
178 }
179
180 /// Uses a client the program already has.
181 ///
182 /// For a caller that already talks to etcd and would rather not open a
183 /// second connection to it. The client is `Clone` — cheaply, it is a
184 /// handle — so sharing one costs nothing.
185 ///
186 /// ```no_run
187 /// # use dynamic_config_etcd::{Client, Etcd};
188 /// # fn example(client: Client) {
189 /// let etcd = Etcd::from_client(client, "myapp/db.json");
190 /// # }
191 /// ```
192 ///
193 /// A shared client recovers from an expired auth token like any other: the
194 /// credentials live in the client, so refreshing the token needs nothing
195 /// this source would have to own.
196 #[must_use]
197 pub fn from_client(client: Client, key: impl Into<String>) -> Self {
198 Self {
199 client: Mutex::new(client),
200 key: key.into(),
201 format: None,
202 endpoints: "<an existing client>".to_owned(),
203 }
204 .with_format_from_key()
205 }
206
207 /// Fills in the format from the key's extension, if it has a known one.
208 fn with_format_from_key(mut self) -> Self {
209 self.format = Format::from_key(&self.key);
210
211 self
212 }
213
214 /// States the format, for a key whose name does not.
215 #[must_use]
216 pub fn with_format(mut self, format: Format) -> Self {
217 self.format = Some(format);
218 self
219 }
220
221 /// Calls `on_change` every time the key's value moves, forever.
222 ///
223 /// The first call happens when the *first change* arrives, not at startup:
224 /// a watch reports changes, and reporting the current value as one would
225 /// make every restart look like an edit. Fetch first if the starting value
226 /// matters, which it usually does:
227 ///
228 /// ```no_run
229 /// # use dynamic_config::AsyncRemoteSource;
230 /// # use dynamic_config_etcd::Etcd;
231 /// # struct DbConfig;
232 /// # impl DbConfig {
233 /// # fn apply_remote(_: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
234 /// # }
235 /// # async fn example(etcd: Etcd) -> Result<(), dynamic_config::Error> {
236 /// DbConfig::apply_remote(etcd.fetch().await?)?;
237 /// etcd.watch(DbConfig::apply_remote).await
238 /// # }
239 /// ```
240 ///
241 /// **Cancellation is dropping the future.** There is no stop flag, because
242 /// there is nothing to poll one between: this suspends on the stream, so
243 /// any executor's cancellation already ends it immediately.
244 ///
245 /// A deletion is not a change this reports. The key holding no value is not
246 /// a configuration, and calling back with the last one — or with nothing —
247 /// would both be worse than leaving the running snapshot alone.
248 ///
249 /// # Errors
250 ///
251 /// If the watch cannot be established, if the connection fails or ends, if
252 /// etcd cancels the watch — compaction is the usual reason — or if
253 /// `on_change` returns an error, which ends the watch, so a caller that
254 /// wants to survive a bad document should log it and return `Ok`.
255 ///
256 /// This never returns `Ok`: a watch either runs or has failed, and a silent
257 /// success would leave a spawned task finished and a configuration frozen
258 /// with nothing said about either. Callers that want to reconnect should
259 /// loop around it.
260 pub async fn watch<F>(&self, mut on_change: F) -> Result<(), Error>
261 where
262 F: FnMut(Fetched) -> Result<(), Error> + Send,
263 {
264 let format = self.format.ok_or_else(|| {
265 Error::remote(format!(
266 "{}: the key names no format; call `with_format`",
267 self.describe()
268 ))
269 })?;
270
271 let mut stream = match self.watch_once(None).await {
272 Err(error) if is_expired_token(&error) => {
273 self.refresh_token().await?;
274
275 self.watch_once(None).await?
276 }
277 outcome => outcome?,
278 };
279
280 // Consecutive is what matters: any successfully received message
281 // proves the refreshed token worked and resets the count.
282 const MOST_TOKEN_RECOVERIES: u32 = 3;
283 let mut token_recoveries = 0_u32;
284 // Where a re-established stream picks up: just past the last batch
285 // this loop was handed.
286 let mut resume_from: Option<i64> = None;
287
288 loop {
289 let response = match stream.message().await {
290 Ok(Some(response)) => {
291 token_recoveries = 0;
292
293 if let Some(header) = response.header() {
294 resume_from = Some(header.revision() + 1);
295 }
296
297 response
298 }
299 Ok(None) => break,
300 Err(error) => {
301 let wrapped =
302 Error::remote(format!("{}: the watch failed: {error}", self.describe()));
303
304 // The single most predictable failure of a long-lived
305 // watch: etcd's simple tokens default to a five-minute
306 // TTL, and a watch is long-lived by definition. Refresh
307 // and re-establish instead of handing the caller a
308 // terminal error for something the credentials can cure.
309 // The new stream resumes just past the last delivered
310 // revision, so a write that lands while the stream is
311 // down is replayed rather than lost; if that revision
312 // has been compacted away meanwhile, etcd cancels the
313 // resumed watch and the cancel branch below makes that a
314 // clean error.
315 //
316 // Bounded twice over: a refresh that fails propagates,
317 // and a server that keeps *accepting* the login while
318 // failing the stream — an auth-enabled proxy in front of
319 // a member without auth, say — hits the recovery cap
320 // instead of hammering the login endpoint forever.
321 if is_expired_token(&wrapped) {
322 token_recoveries += 1;
323
324 if token_recoveries > MOST_TOKEN_RECOVERIES {
325 return Err(wrapped);
326 }
327
328 self.refresh_token().await?;
329 stream = self.watch_once(resume_from).await?;
330
331 continue;
332 }
333
334 return Err(wrapped);
335 }
336 };
337
338 // etcd cancels a watch it can no longer serve — most often because
339 // the revision it started from has been compacted away. Returning
340 // `Ok` here would leave the caller's task finished, the
341 // configuration frozen, and nothing said about either.
342 if response.canceled() {
343 return Err(Error::remote(format!(
344 "{}: the store cancelled the watch: {}",
345 self.describe(),
346 response.cancel_reason()
347 )));
348 }
349
350 for event in response.events() {
351 if event.event_type() != EventType::Put {
352 continue;
353 }
354
355 let Some(value) = event.kv() else { continue };
356
357 let text = value.value_str().map_err(|error| {
358 Error::remote(format!(
359 "{}: the value is not UTF-8: {error}",
360 self.describe()
361 ))
362 })?;
363
364 guarded(&mut on_change, Fetched::new(text, format), &self.describe())?;
365 }
366 }
367
368 // The stream ended without an error and without being cancelled: the
369 // connection went away. Also a failure, for the same reason — a watch
370 // that stops quietly is a configuration that stops updating quietly.
371 Err(Error::remote(format!(
372 "{}: the watch ended; the connection was closed",
373 self.describe()
374 )))
375 }
376
377 /// Asks etcd for a new auth token, using the credentials the client holds.
378 ///
379 /// Not a reconnect: the gRPC channel looks after itself, and the client
380 /// kept the credentials, so the thing that actually expired is the only
381 /// thing replaced. This works for a shared client too, which a reconnect
382 /// would not — replacing a client the caller owns is not this crate's to
383 /// do.
384 ///
385 /// # Errors
386 ///
387 /// If etcd refuses the credentials.
388 async fn refresh_token(&self) -> Result<(), Error> {
389 self.client
390 .lock()
391 .await
392 .refresh_token()
393 .await
394 .map_err(|error| {
395 Error::remote(format!(
396 "{}: the auth token expired and could not be replaced: {error}",
397 self.describe()
398 ))
399 })
400 }
401}
402
403impl Etcd {
404 /// One attempt at establishing the watch, with no recovery.
405 ///
406 /// The client guard is taken to establish the stream and released
407 /// immediately. Holding it for the watch's lifetime would block every
408 /// `fetch` on this source until the watch ended — which, for a watch, is
409 /// never.
410 async fn watch_once(
411 &self,
412 from_revision: Option<i64>,
413 ) -> Result<etcd_client::WatchStream, Error> {
414 // Resuming replays every event after the one last delivered, so a
415 // write that lands while the stream is down is caught up rather than
416 // lost. A fresh watch starts at the current revision instead — the
417 // startup contract is "changes only".
418 let options = from_revision
419 .map(|revision| etcd_client::WatchOptions::new().with_start_revision(revision));
420
421 self.client
422 .lock()
423 .await
424 .watch(self.key.as_str(), options)
425 .await
426 .map_err(|error| Error::remote(format!("{}: cannot watch: {error}", self.describe())))
427 }
428
429 /// One read, with no recovery.
430 async fn get_once(&self) -> Result<etcd_client::GetResponse, Error> {
431 self.client
432 .lock()
433 .await
434 .get(self.key.as_str(), None)
435 .await
436 .map_err(|error| Error::remote(format!("{}: {error}", self.describe())))
437 }
438}
439
440/// Whether a failure is etcd saying the auth token has expired.
441///
442/// Matched on the message because `etcd-client` reports it as a generic gRPC
443/// status, and the alternative — treating *every* failure as a reason to
444/// refresh — would hide a wrong password behind a refresh loop.
445fn is_expired_token(error: &Error) -> bool {
446 error.to_string().contains(INVALID_TOKEN)
447}
448
449/// One connection attempt, with the endpoints named in any failure.
450async fn connect(
451 endpoints: &[String],
452 options: &ConnectOptions,
453 described: &str,
454) -> Result<Client, Error> {
455 Client::connect(endpoints, Some(options.clone()))
456 .await
457 .map_err(|error| Error::remote(format!("etcd {described}: {error}")))
458}
459
460impl AsyncRemoteSource for Etcd {
461 fn fetch(&self) -> Pin<Box<dyn Future<Output = Result<Fetched, Error>> + Send + '_>> {
462 Box::pin(async move {
463 let format = self.format.ok_or_else(|| {
464 Error::remote(format!(
465 "{}: the key names no format; call `with_format`",
466 self.describe()
467 ))
468 })?;
469
470 let response = match self.get_once().await {
471 Err(error) if is_expired_token(&error) => {
472 // etcd's simple tokens have a TTL — five minutes by
473 // default — and a long-lived reader outlives one. The gRPC
474 // channel looks after itself; the token does not, so this
475 // is the one failure worth recovering from by hand.
476 //
477 // Once, not in a loop: if a fresh token is refused too, the
478 // credentials are wrong and retrying would turn a clear
479 // failure into a hang.
480 self.refresh_token().await?;
481
482 self.get_once().await?
483 }
484 outcome => outcome?,
485 };
486
487 let value = response.kvs().first().ok_or_else(|| {
488 Error::remote(format!("{}: the key holds no value", self.describe()))
489 })?;
490
491 let text = value.value_str().map_err(|error| {
492 Error::remote(format!(
493 "{}: the value is not UTF-8: {error}",
494 self.describe()
495 ))
496 })?;
497
498 Ok(Fetched::new(text, format))
499 })
500 }
501
502 fn describe(&self) -> String {
503 format!("etcd {} key {}", self.endpoints, self.key)
504 }
505}
506
507impl std::fmt::Debug for Etcd {
508 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509 f.debug_struct("Etcd")
510 .field("endpoints", &self.endpoints)
511 .field("key", &self.key)
512 .field("format", &self.format)
513 .finish_non_exhaustive()
514 }
515}
516
517/// Runs the watch callback with a panic net.
518///
519/// The callback is the caller's code on the caller's thread; a panic in it
520/// used to unwind through the watch loop and kill that thread with the
521/// `RemoteWatch` handle still looking alive. Caught, it becomes an orderly
522/// error: the watch ends, and the caller is told why.
523fn guarded<F>(on_change: &mut F, document: Fetched, described: &str) -> Result<(), Error>
524where
525 F: FnMut(Fetched) -> Result<(), Error>,
526{
527 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| on_change(document))).unwrap_or_else(
528 |_| {
529 Err(Error::remote(format!(
530 "{described}: the watch callback panicked; the watch is stopped"
531 )))
532 },
533 )
534}