Skip to main content

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