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//! # 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().await {
272            Err(error) if is_expired_token(&error) => {
273                self.refresh_token().await?;
274
275                self.watch_once().await?
276            }
277            outcome => outcome?,
278        };
279
280        while let Some(response) = stream.message().await.map_err(|error| {
281            Error::remote(format!("{}: the watch failed: {error}", self.describe()))
282        })? {
283            // etcd cancels a watch it can no longer serve — most often because
284            // the revision it started from has been compacted away. Returning
285            // `Ok` here would leave the caller's task finished, the
286            // configuration frozen, and nothing said about either.
287            if response.canceled() {
288                return Err(Error::remote(format!(
289                    "{}: the store cancelled the watch: {}",
290                    self.describe(),
291                    response.cancel_reason()
292                )));
293            }
294
295            for event in response.events() {
296                if event.event_type() != EventType::Put {
297                    continue;
298                }
299
300                let Some(value) = event.kv() else { continue };
301
302                let text = value.value_str().map_err(|error| {
303                    Error::remote(format!(
304                        "{}: the value is not UTF-8: {error}",
305                        self.describe()
306                    ))
307                })?;
308
309                on_change(Fetched::new(text, format))?;
310            }
311        }
312
313        // The stream ended without an error and without being cancelled: the
314        // connection went away. Also a failure, for the same reason — a watch
315        // that stops quietly is a configuration that stops updating quietly.
316        Err(Error::remote(format!(
317            "{}: the watch ended; the connection was closed",
318            self.describe()
319        )))
320    }
321
322    /// Asks etcd for a new auth token, using the credentials the client holds.
323    ///
324    /// Not a reconnect: the gRPC channel looks after itself, and the client
325    /// kept the credentials, so the thing that actually expired is the only
326    /// thing replaced. This works for a shared client too, which a reconnect
327    /// would not — replacing a client the caller owns is not this crate's to
328    /// do.
329    ///
330    /// # Errors
331    ///
332    /// If etcd refuses the credentials.
333    async fn refresh_token(&self) -> Result<(), Error> {
334        self.client
335            .lock()
336            .await
337            .refresh_token()
338            .await
339            .map_err(|error| {
340                Error::remote(format!(
341                    "{}: the auth token expired and could not be replaced: {error}",
342                    self.describe()
343                ))
344            })
345    }
346}
347
348impl Etcd {
349    /// One attempt at establishing the watch, with no recovery.
350    ///
351    /// The client guard is taken to establish the stream and released
352    /// immediately. Holding it for the watch's lifetime would block every
353    /// `fetch` on this source until the watch ended — which, for a watch, is
354    /// never.
355    async fn watch_once(&self) -> Result<etcd_client::WatchStream, Error> {
356        self.client
357            .lock()
358            .await
359            .watch(self.key.as_str(), None)
360            .await
361            .map_err(|error| Error::remote(format!("{}: cannot watch: {error}", self.describe())))
362    }
363
364    /// One read, with no recovery.
365    async fn get_once(&self) -> Result<etcd_client::GetResponse, Error> {
366        self.client
367            .lock()
368            .await
369            .get(self.key.as_str(), None)
370            .await
371            .map_err(|error| Error::remote(format!("{}: {error}", self.describe())))
372    }
373}
374
375/// Whether a failure is etcd saying the auth token has expired.
376///
377/// Matched on the message because `etcd-client` reports it as a generic gRPC
378/// status, and the alternative — treating *every* failure as a reason to
379/// refresh — would hide a wrong password behind a refresh loop.
380fn is_expired_token(error: &Error) -> bool {
381    error.to_string().contains(INVALID_TOKEN)
382}
383
384/// One connection attempt, with the endpoints named in any failure.
385async fn connect(
386    endpoints: &[String],
387    options: &ConnectOptions,
388    described: &str,
389) -> Result<Client, Error> {
390    Client::connect(endpoints, Some(options.clone()))
391        .await
392        .map_err(|error| Error::remote(format!("etcd {described}: {error}")))
393}
394
395impl AsyncRemoteSource for Etcd {
396    fn fetch(&self) -> Pin<Box<dyn Future<Output = Result<Fetched, Error>> + Send + '_>> {
397        Box::pin(async move {
398            let format = self.format.ok_or_else(|| {
399                Error::remote(format!(
400                    "{}: the key names no format; call `with_format`",
401                    self.describe()
402                ))
403            })?;
404
405            let response = match self.get_once().await {
406                Err(error) if is_expired_token(&error) => {
407                    // etcd's simple tokens have a TTL — five minutes by
408                    // default — and a long-lived reader outlives one. The gRPC
409                    // channel looks after itself; the token does not, so this
410                    // is the one failure worth recovering from by hand.
411                    //
412                    // Once, not in a loop: if a fresh token is refused too, the
413                    // credentials are wrong and retrying would turn a clear
414                    // failure into a hang.
415                    self.refresh_token().await?;
416
417                    self.get_once().await?
418                }
419                outcome => outcome?,
420            };
421
422            let value = response.kvs().first().ok_or_else(|| {
423                Error::remote(format!("{}: the key holds no value", self.describe()))
424            })?;
425
426            let text = value.value_str().map_err(|error| {
427                Error::remote(format!(
428                    "{}: the value is not UTF-8: {error}",
429                    self.describe()
430                ))
431            })?;
432
433            Ok(Fetched::new(text, format))
434        })
435    }
436
437    fn describe(&self) -> String {
438        format!("etcd {} key {}", self.endpoints, self.key)
439    }
440}
441
442impl std::fmt::Debug for Etcd {
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        f.debug_struct("Etcd")
445            .field("endpoints", &self.endpoints)
446            .field("key", &self.key)
447            .field("format", &self.format)
448            .finish_non_exhaustive()
449    }
450}