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//! # Several keys as one document
32//!
33//! A deployment that splits its configuration across a range —  `myapp/db.json`,
34//! `myapp/server.json` — can have one source read the lot, and [`Keys`] says
35//! which:
36//!
37//! ```no_run
38//! # use dynamic_config_etcd::{Etcd, Keys};
39//! # async fn example() -> Result<(), dynamic_config::Error> {
40//! # let endpoints = ["http://etcd.internal:2379"];
41//! // Named keys: a list of layers, merged in the order given, later wins.
42//! let etcd = Etcd::new(endpoints, Keys::several(["myapp/base.json", "myapp/local.json"])).await?;
43//!
44//! // A prefix: disjoint sections, and an overlap between two of them is an error.
45//! let etcd = Etcd::new(endpoints, Keys::prefix("myapp/"))
46//!     .await?
47//!     .with_format(dynamic_config::Format::Json);
48//! # Ok(())
49//! # }
50//! ```
51//!
52//! Both are **one round trip**: a list is a transaction of range reads and a
53//! prefix is one range read, so either way the keys are read at a single etcd
54//! revision and a write landing mid-read cannot tear the document in half.
55//!
56//! Three consequences, each of which belongs here rather than in an incident:
57//!
58//! - **A prefix that matches more than 512 keys is refused.** A prefix is
59//!   caller input and the answer to it is server input; an empty prefix
60//!   matches a whole cluster.
61//! - **Provenance becomes store-grained.** The merged document is one layer,
62//!   so `source_of` answers "from etcd … keys a, b" and not which of them
63//!   supplied a given value. [`describe`](AsyncRemoteSource::describe) names
64//!   every key in the set, which is as close as one layer gets.
65//! - **One unreadable key fails the whole fetch.** A configuration quietly
66//!   missing a section is worse than a refresh that failed and left the last
67//!   document serving.
68//!
69//! # The connection is made once, and lazily
70//!
71//! [`Etcd::new`] builds the client and [`fetch`](AsyncRemoteSource::fetch)
72//! reuses it — a source that reconnected on every read would turn a refresh
73//! loop into a connection storm.
74//!
75//! The underlying client connects *lazily*, so `new` succeeding does not mean
76//! the endpoints are reachable: an unreachable etcd surfaces on the first
77//! `fetch`, not at construction. That is the client's behaviour rather than a
78//! choice made here, and papering over it with an eager round trip would make
79//! every construction cost one.
80//!
81//! # Timeouts
82//!
83//! [`Etcd::with_timeout`] is the deadline for a single fetch attempt,
84//! excluding retries the underlying client performs — the sentence every
85//! store in this family answers to. Ten seconds by default.
86//!
87//! etcd's own `ConnectOptions::with_timeout` bounds *connecting*, which is a
88//! different thing and does not help a connection established minutes ago, so
89//! the deadline here wraps the request. Both can be set; they cover different
90//! halves. Neither applies to [`Etcd::watch`], which is long-lived on purpose.
91//!
92//! # Watching
93//!
94//! etcd's watch is a real push stream, so [`Etcd::watch`] is a future the caller
95//! spawns and cancels by dropping — no runtime is imposed and no flag is polled.
96//!
97//! **A prefix can be watched; a named list cannot.** A watch on a set is only
98//! honest if the store says *the set* changed and the set can then be re-read
99//! as of one instant. A prefix answers both: one stream over the range says the
100//! set moved and carries the revision it moved at, and one range read at that
101//! revision is the whole subtree as one instant had it — so a delivered
102//! document is a state the cluster really was in, never one key's new value
103//! merged with another's old one. A named list answers neither: etcd
104//! establishes a watch on a key or a range, so a list is N independent streams,
105//! and none of them is about the set. That shape refuses at
106//! [`watch`](Etcd::watch), before the first event; poll
107//! `refresh_remote_async()` on a timer instead — it is the same one round trip
108//! the fetch always was.
109//!
110//! ```no_run
111//! # use dynamic_config_etcd::Etcd;
112//! # async fn example(etcd: Etcd) {
113//! # let sink = |_: dynamic_config::Fetched| -> Result<(), dynamic_config::Error> { Ok(()) };
114//! let task = tokio::spawn(async move {
115//!     etcd.watch(move |document| sink(document)).await
116//! });
117//!
118//! // Dropping or aborting the task stops the watch.
119//! task.abort();
120//! # }
121//! ```
122//!
123//! # A watch that is failing says so
124//!
125//! A watch is the half of a store `dynamic-config` cannot see: a delivery keeps
126//! `RemoteStatus` current, and a stream that broke delivers nothing and would
127//! otherwise report nothing — so `dynamic_config_remote_up` would describe the
128//! last *delivery* rather than the last *attempt*.
129//! [`reporting_to`](Etcd::reporting_to) closes that: the sink the loop already
130//! holds is told about every attempt that came back with nothing, and a store
131//! that stopped answering an hour ago reads as down without anything having to
132//! call `refresh_remote_async()`.
133//!
134//! [`dynamic-config`]: https://docs.rs/dynamic-config
135
136#![forbid(unsafe_code)]
137#![deny(missing_docs)]
138#![cfg_attr(docsrs, feature(doc_cfg))]
139
140use std::future::Future;
141use std::pin::Pin;
142use std::time::Duration;
143
144use dynamic_config::{AsyncRemoteSource, Error, Fetched, Format, RemoteSink};
145use dynamic_config_store_core::attempts::Attempts;
146use dynamic_config_store_core::documents::{self, Overlap};
147use dynamic_config_store_core::guarded;
148use etcd_client::EventType;
149use tokio::sync::Mutex;
150
151/// etcd's own connection options, re-exported so authenticating needs no direct
152/// dependency on `etcd-client`.
153pub use etcd_client::{Client, ConnectOptions};
154
155/// etcd's TLS types, behind this crate's `tls` feature.
156///
157/// A separate feature because TLS pulls a whole stack in, and a program talking
158/// to etcd over a private network inside a cluster has no use for it.
159#[cfg(feature = "tls")]
160#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
161pub use etcd_client::{Certificate, Identity, TlsOptions};
162
163/// A private certificate authority and a client certificate, as data.
164///
165/// The shared vocabulary all seven store crates take, so that reaching TLS
166/// never means naming a `tonic` type — see [`Etcd::with_tls`]. Visible without
167/// the `tls` feature so that the *type* is nameable everywhere; the
168/// constructor that consumes it is not, because a TLS stack is what the
169/// feature buys.
170pub use dynamic_config_store_core::tls::TlsConfig;
171
172/// What an expired auth token looks like in etcd's error text.
173///
174/// etcd issues simple tokens with a TTL — five minutes by default — and refuses
175/// requests carrying an expired one. The gRPC channel reconnects on its own;
176/// the token does not, so this is the one failure worth recognising by hand.
177const INVALID_TOKEN: &str = "invalid auth token";
178
179/// How etcd words the refusals that no amount of waiting will cure.
180///
181/// Matched on the message rather than the gRPC code because etcd does not use
182/// one code for them: `authentication failed` arrives as `InvalidArgument`,
183/// `permission denied` as `PermissionDenied`, `invalid auth token` as
184/// `Unauthenticated`. The message is the part that is stable across all three.
185const AUTH_REFUSALS: [&str; 5] = [
186    INVALID_TOKEN,
187    "authentication failed",
188    "permission denied",
189    "user name is empty",
190    "user name not found",
191];
192
193/// The longest named key list one transaction can carry.
194///
195/// etcd's own `--max-txn-ops`, whose default is 128 and which is a server-side
196/// limit rather than a client one. A longer list would have to go as several
197/// transactions at several revisions — which is precisely the torn document
198/// reading them in one transaction exists to prevent — so it is refused
199/// instead, and says so.
200const MOST_TRANSACTION_KEYS: usize = 128;
201
202/// How long one request may take before it is given up on.
203///
204/// Ten seconds, matching the three HTTP stores. A configuration fetch that
205/// hangs is worse than one that fails: the caller can retry a failure.
206const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
207
208/// What a source reads: one key, several named keys, or a range.
209///
210/// Every constructor takes one, and a bare `&str` or `String` is
211/// [`Keys::one`] — so the single-key spelling every caller already wrote keeps
212/// working unchanged.
213#[derive(Clone, Debug, PartialEq, Eq)]
214pub enum Keys {
215    /// One key, whose value is the whole document.
216    One(String),
217    /// Several named keys, merged **in the order given — later wins**.
218    ///
219    /// The rule a list of `.file(..)` calls already teaches: the caller wrote
220    /// the list, so the list is the precedence. Read as one etcd transaction,
221    /// so every key is read at the same revision.
222    ///
223    /// **Cannot be watched.** etcd establishes a watch on a key or on a range,
224    /// so an arbitrary list is one stream per key, and N independent streams
225    /// never say *the set* moved — they say one key did, N times. Watch a
226    /// prefix, or poll `refresh_remote_async()`.
227    Several(Vec<String>),
228    /// Every key under a prefix, merged as **disjoint sections**.
229    ///
230    /// A caller naming a prefix is not expressing an order — the order is
231    /// whatever etcd lists, which is nobody's decision — so two keys under the
232    /// prefix supplying the same path is a deployment bug, and reported as one
233    /// rather than resolved. Read as a single range request.
234    ///
235    /// **Can be watched**, and is the only multi-key shape here that can: one
236    /// stream over the range says the set moved and carries the revision it
237    /// moved at, and one range read at that revision is the set as of one
238    /// instant. See [`Etcd::watch`].
239    Prefix(String),
240}
241
242impl Keys {
243    /// One key, whose value is the whole document.
244    #[must_use]
245    pub fn one(key: impl Into<String>) -> Self {
246        Self::One(key.into())
247    }
248
249    /// Several named keys, merged in the order given — later wins.
250    #[must_use]
251    pub fn several<I, S>(keys: I) -> Self
252    where
253        I: IntoIterator<Item = S>,
254        S: Into<String>,
255    {
256        Self::Several(keys.into_iter().map(Into::into).collect())
257    }
258
259    /// Every key under `prefix`, merged as disjoint sections.
260    #[must_use]
261    pub fn prefix(prefix: impl Into<String>) -> Self {
262        Self::Prefix(prefix.into())
263    }
264
265    /// The keys as a slice, for the diagnostics and the format inference.
266    ///
267    /// A prefix has none to list — the set is not known until etcd answers.
268    fn named(&self) -> &[String] {
269        match self {
270            Self::One(key) => std::slice::from_ref(key),
271            Self::Several(keys) => keys,
272            Self::Prefix(_) => &[],
273        }
274    }
275
276    /// How a diagnostic names what this source reads.
277    fn describe(&self) -> String {
278        match self {
279            Self::One(key) => format!("key {key}"),
280            Self::Several(keys) => format!("keys {}", keys.join(", ")),
281            Self::Prefix(prefix) => format!("prefix {prefix}"),
282        }
283    }
284}
285
286impl From<&str> for Keys {
287    fn from(key: &str) -> Self {
288        Self::one(key)
289    }
290}
291
292impl From<String> for Keys {
293    fn from(key: String) -> Self {
294        Self::One(key)
295    }
296}
297
298impl From<&String> for Keys {
299    fn from(key: &String) -> Self {
300        Self::one(key)
301    }
302}
303
304/// A key in etcd, as a configuration source.
305pub struct Etcd {
306    // etcd's client needs `&mut` to issue a request, so it is behind a lock —
307    // a tokio one, because it is held across an await.
308    client: Mutex<Client>,
309    keys: Keys,
310    format: Option<Format>,
311    /// Why the keys' own extensions could not settle the format between them.
312    ///
313    /// Kept rather than reported at construction because `with_format` is
314    /// allowed to settle it afterwards — and because `new` is not the only
315    /// door in, so a store whose constructor cannot fail would have nowhere
316    /// to report it.
317    disagreement: Option<String>,
318    endpoints: String,
319    timeout: Duration,
320    /// Where the watch loop reports an attempt that came back with nothing.
321    ///
322    /// Nobody, unless [`reporting_to`](Etcd::reporting_to) said otherwise —
323    /// which is what makes reporting free for a caller who never asked for it.
324    attempts: Attempts,
325}
326
327impl Etcd {
328    /// Connects to `endpoints` and reads `keys`.
329    ///
330    /// `keys` is a key — `"myapp/db.json"` — or a [`Keys`], for the several-keys
331    /// and prefix forms.
332    ///
333    /// The format is taken from the key's extension — `myapp/db.json` is JSON.
334    /// A key without one, and every prefix, needs
335    /// [`with_format`](Self::with_format).
336    ///
337    /// # Errors
338    ///
339    /// If the endpoints cannot be parsed. **Not** if they are unreachable: the
340    /// client connects lazily, so that surfaces on the first
341    /// [`fetch`](AsyncRemoteSource::fetch).
342    pub async fn new<E, S>(endpoints: E, keys: impl Into<Keys>) -> Result<Self, Error>
343    where
344        E: IntoIterator<Item = S>,
345        S: Into<String>,
346    {
347        Self::with_options(endpoints, keys, ConnectOptions::new()).await
348    }
349
350    /// As [`new`](Self::new), with etcd's own connection options.
351    ///
352    /// This is where authentication and TLS live, because that is where
353    /// `etcd-client` puts them — there is no second vocabulary to learn, and
354    /// options this crate has never heard of keep working.
355    ///
356    /// ```no_run
357    /// # use dynamic_config_etcd::{ConnectOptions, Etcd};
358    /// # async fn example() -> Result<(), dynamic_config::Error> {
359    /// let etcd = Etcd::with_options(
360    ///     ["https://etcd.internal:2379"],
361    ///     "myapp/db.json",
362    ///     ConnectOptions::new()
363    ///         .with_user("myapp", std::env::var("ETCD_PASSWORD").unwrap())
364    ///         .with_keep_alive(
365    ///             std::time::Duration::from_secs(30),
366    ///             std::time::Duration::from_secs(5),
367    ///         ),
368    /// )
369    /// .await?;
370    /// # Ok(())
371    /// # }
372    /// ```
373    ///
374    /// The credentials live in the client afterwards, which is what lets an
375    /// expired auth token be replaced without rebuilding anything.
376    ///
377    /// # Errors
378    ///
379    /// As [`new`](Self::new).
380    pub async fn with_options<E, S>(
381        endpoints: E,
382        keys: impl Into<Keys>,
383        options: ConnectOptions,
384    ) -> Result<Self, Error>
385    where
386        E: IntoIterator<Item = S>,
387        S: Into<String>,
388    {
389        // Collected once: the client wants a slice, and the description wants
390        // the same strings.
391        let endpoints: Vec<String> = endpoints.into_iter().map(Into::into).collect();
392        let described = endpoints.join(", ");
393
394        let client = connect(&endpoints, &options, &described).await?;
395
396        Ok(Self::build(client, keys, described))
397    }
398
399    /// As [`with_options`](Self::with_options), with a private certificate
400    /// authority or a client certificate from the shared vocabulary.
401    ///
402    /// The same three settings, spelled the same way, in all seven store
403    /// crates — and spelled as *data*, so nothing here names a `tonic` type:
404    ///
405    /// ```no_run
406    /// # use dynamic_config_etcd::{ConnectOptions, Etcd, TlsConfig};
407    /// # async fn example() -> Result<(), dynamic_config::Error> {
408    /// let etcd = Etcd::with_tls(
409    ///     ["https://etcd.internal:2379"],
410    ///     "myapp/db.json",
411    ///     ConnectOptions::new().with_user("myapp", std::env::var("ETCD_PASSWORD").unwrap()),
412    ///     &TlsConfig::new()
413    ///         .with_ca_certificate_file("/etc/etcd/ca.pem")
414    ///         .with_client_certificate_files("/etc/etcd/client.crt", "/etc/etcd/client.key"),
415    /// )
416    /// .await?;
417    /// # Ok(())
418    /// # }
419    /// ```
420    ///
421    /// etcd expresses all of it: a CA from a file or from bytes, and a client
422    /// certificate from either. mTLS is not an afterthought here the way it is
423    /// for the HTTP stores — an etcd cluster with `--client-cert-auth` is the
424    /// ordinary hardened deployment.
425    ///
426    /// `options` carries everything that is *not* TLS: the user and password,
427    /// keep-alive, whatever `etcd-client` grows next. **The `tls` argument owns
428    /// the TLS slot.** If `options` also carries a
429    /// [`TlsOptions`] of its own, this one replaces it — `etcd-client` exposes
430    /// no way to ask whether that slot is already filled, so the interaction is
431    /// documented rather than refused. Use one door or the other, never both.
432    ///
433    /// There is no way to turn verification off; [`TlsConfig`]'s own
434    /// documentation argues that one, and `tonic` offers no such switch to
435    /// forward even if this crate wanted to.
436    ///
437    /// # Errors
438    ///
439    /// If a PEM file cannot be read, if what was read is not PEM, or as
440    /// [`new`](Self::new).
441    #[cfg(feature = "tls")]
442    #[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
443    pub async fn with_tls<E, S>(
444        endpoints: E,
445        keys: impl Into<Keys>,
446        options: ConnectOptions,
447        tls: &TlsConfig,
448    ) -> Result<Self, Error>
449    where
450        E: IntoIterator<Item = S>,
451        S: Into<String>,
452    {
453        let endpoints: Vec<String> = endpoints.into_iter().map(Into::into).collect();
454        let described = endpoints.join(", ");
455
456        let options = options.with_tls(tls_options(tls, &format!("etcd {described}"))?);
457
458        let client = connect(&endpoints, &options, &described).await?;
459
460        Ok(Self::build(client, keys, described))
461    }
462
463    /// Uses a client the program already has.
464    ///
465    /// For a caller that already talks to etcd and would rather not open a
466    /// second connection to it. The client is `Clone` — cheaply, it is a
467    /// handle — so sharing one costs nothing.
468    ///
469    /// ```no_run
470    /// # use dynamic_config_etcd::{Client, Etcd};
471    /// # fn example(client: Client) {
472    /// let etcd = Etcd::from_client(client, "myapp/db.json");
473    /// # }
474    /// ```
475    ///
476    /// A shared client recovers from an expired auth token like any other: the
477    /// credentials live in the client, so refreshing the token needs nothing
478    /// this source would have to own.
479    #[must_use]
480    pub fn from_client(client: Client, keys: impl Into<Keys>) -> Self {
481        Self::build(client, keys, "<an existing client>".to_owned())
482    }
483
484    /// The one place a source is assembled, so the format inference and its
485    /// disagreement cannot drift between the three doors in.
486    fn build(client: Client, keys: impl Into<Keys>, endpoints: String) -> Self {
487        let keys = keys.into();
488
489        let (format, disagreement) = match documents::agreed_format(keys.named()) {
490            Ok(format) => (format, None),
491            Err(complaint) => (None, Some(complaint)),
492        };
493
494        Self {
495            client: Mutex::new(client),
496            keys,
497            format,
498            disagreement,
499            endpoints,
500            timeout: DEFAULT_TIMEOUT,
501            attempts: Attempts::default(),
502        }
503    }
504
505    /// How long a single fetch may take before it is given up on. Ten seconds
506    /// by default.
507    ///
508    /// The deadline for **one fetch attempt**, excluding retries the
509    /// underlying client performs — the same sentence every store in this
510    /// family answers to.
511    ///
512    /// It is applied here as a `tokio::time::timeout` around the request
513    /// rather than through `ConnectOptions::with_timeout`, and the difference
514    /// is the whole point: etcd's own option bounds *connecting*, and a
515    /// connection that was established minutes ago cannot be bounded by it. A
516    /// member that accepts the request and then never answers is the failure
517    /// worth having a deadline for, and only the wrap catches it.
518    ///
519    /// It does not cover [`watch`](Self::watch), which is long-lived by
520    /// definition; a watch that stops after ten seconds would be a watch that
521    /// does not work. It *does* bound each range read a prefix watch performs
522    /// in answer to an event — that is a request like any other, and one that
523    /// hangs would wedge the loop for good.
524    #[must_use]
525    pub fn with_timeout(mut self, timeout: Duration) -> Self {
526        self.timeout = timeout;
527        self
528    }
529
530    /// Reports the watch loop's failed attempts to `sink`.
531    ///
532    /// Without this a watch is the half of a store `dynamic-config` cannot
533    /// see. [`RemoteSink::apply`] records a delivery, so a *working* watch
534    /// keeps the status current — but a loop whose stream broke, whose watch
535    /// was cancelled or whose credential was refused delivers nothing, and so
536    /// says nothing: `dynamic_config_remote_up` reports the last delivery
537    /// rather than the last attempt, and a store that stopped answering an hour
538    /// ago looks healthy until something calls `refresh_remote_async()`.
539    ///
540    /// ```no_run
541    /// # use dynamic_config_etcd::Etcd;
542    /// # struct DbConfig;
543    /// # impl DbConfig {
544    /// #     fn remote_sink() -> dynamic_config::RemoteSink { unimplemented!() }
545    /// # }
546    /// # async fn example(etcd: Etcd) -> Result<(), dynamic_config::Error> {
547    /// let sink = DbConfig::remote_sink();
548    ///
549    /// // The same sink delivers and reports: one generation, one fence.
550    /// etcd.reporting_to(sink)
551    ///     .watch(move |document| sink.apply(document))
552    ///     .await
553    /// # }
554    /// ```
555    ///
556    /// A sink is `Copy` and captures its source's generation when it is taken,
557    /// which is what keeps a loop winding down after its source was replaced
558    /// from charging its failures to the replacement — so take it once, where
559    /// the watch is wired, exactly as the delivering half already does.
560    ///
561    /// **Only the watch.** A [`fetch`](AsyncRemoteSource::fetch) records itself
562    /// through `refresh_remote_async()` already, and what is reported here is
563    /// the failure streak and the last failure and nothing else: the staleness
564    /// clock keeps ageing while `remote_up` goes to zero, which is the pair an
565    /// alert wants.
566    ///
567    /// The error's kind is what travels. Nothing that names this store — no
568    /// endpoint, no key, no credential — enters a `RemoteStatus`.
569    #[must_use]
570    pub fn reporting_to(mut self, sink: RemoteSink) -> Self {
571        self.attempts = Attempts::to(sink);
572        self
573    }
574
575    /// Reports `error` to whatever asked to hear about failed attempts, and
576    /// hands it straight back.
577    ///
578    /// Every failure the watch loop ends on goes through here, so reporting is
579    /// one word at each site rather than a branch that can be left out of the
580    /// next one. It cannot fail and it does not touch the error: a loop must
581    /// never have to handle a failure to report a failure, and the caller sees
582    /// exactly what it always saw.
583    fn failing(&self, error: Error) -> Error {
584        self.attempts.failed(&error);
585
586        error
587    }
588
589    /// States the format, for a key whose name does not.
590    ///
591    /// Required for [`Keys::Prefix`] — a prefix has no extension — and it also
592    /// settles a list whose keys name two different formats.
593    #[must_use]
594    pub fn with_format(mut self, format: Format) -> Self {
595        self.format = Some(format);
596        // The caller has now said which format wins, so the keys no longer
597        // have to agree between themselves.
598        self.disagreement = None;
599        self
600    }
601
602    /// The format, or an error naming the call that supplies one.
603    fn format(&self) -> Result<Format, Error> {
604        if let Some(complaint) = &self.disagreement {
605            return Err(Error::remote(format!("{}: {complaint}", self.describe())));
606        }
607
608        self.format.ok_or_else(|| {
609            Error::remote(format!(
610                "{}: the key names no format; call `with_format`",
611                self.describe()
612            ))
613        })
614    }
615
616    /// Calls `on_change` every time what this source reads moves, forever.
617    ///
618    /// **One key or a prefix.** A prefix watch is the multi-key case that can
619    /// be answered honestly: etcd's watch says the *range* moved and carries
620    /// the revision it moved at, and one range read at that revision is the
621    /// whole set as of one instant. So the document delivered is a state the
622    /// cluster really was in, never a merge of one key's new value with
623    /// another's old one. A **named list** is still refused; the reason is on
624    /// [`Keys::Several`].
625    ///
626    /// The first call happens when the *first change* arrives, not at startup:
627    /// a watch reports changes, and reporting the current value as one would
628    /// make every restart look like an edit. Fetch first if the starting value
629    /// matters, which it usually does:
630    ///
631    /// ```no_run
632    /// # use dynamic_config::AsyncRemoteSource;
633    /// # use dynamic_config_etcd::Etcd;
634    /// # struct Sink;
635    /// # impl Sink {
636    /// #     fn apply(&self, _: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
637    /// # }
638    /// # async fn example(etcd: Etcd) -> Result<(), dynamic_config::Error> {
639    /// # let sink = Sink;
640    /// sink.apply(etcd.fetch().await?)?;
641    /// etcd.watch(move |document| sink.apply(document)).await
642    /// # }
643    /// ```
644    ///
645    /// **Cancellation is dropping the future.** There is no stop flag, because
646    /// there is nothing to poll one between: this suspends on the stream, so
647    /// any executor's cancellation already ends it immediately.
648    ///
649    /// A deletion is not a change this reports **for a single key**. The key
650    /// holding no value is not a configuration, and calling back with the last
651    /// one — or with nothing — would both be worse than leaving the running
652    /// snapshot alone. Under a **prefix** a deletion is a change like any
653    /// other: the set is what it is after the delete, and the re-read reports
654    /// it — unless nothing is left under the prefix, which is the same
655    /// no-configuration case and is skipped for the same reason.
656    ///
657    /// # Errors
658    ///
659    /// If the watch cannot be established, if the connection fails or ends, if
660    /// etcd cancels the watch — compaction is the usual reason — or if
661    /// `on_change` returns an error, which ends the watch, so a caller that
662    /// wants to survive a bad document should log it and return `Ok`.
663    ///
664    /// Under a prefix, also if the range read at an event's revision fails, or
665    /// if two keys under the prefix supply the same path — that is a
666    /// deployment bug rather than a blip, and retrying it forever with nothing
667    /// said would leave the configuration frozen and silent.
668    ///
669    /// This never returns `Ok`: a watch either runs or has failed, and a silent
670    /// success would leave a spawned task finished and a configuration frozen
671    /// with nothing said about either. Callers that want to reconnect should
672    /// loop around it.
673    ///
674    /// Every one of those failures is also reported to the sink
675    /// [`reporting_to`](Self::reporting_to) was given, if one was — because a
676    /// watch is normally spawned and its `JoinHandle` dropped, so the error
677    /// returned here has nowhere else to go. The one failure not charged to the
678    /// store is `on_change`'s own refusal: the store answered, `apply` recorded
679    /// the delivery, and whether the document then installs is
680    /// `ConfigStatus`'s business.
681    pub async fn watch<F>(&self, mut on_change: F) -> Result<(), Error>
682    where
683        F: FnMut(Fetched) -> Result<(), Error> + Send,
684    {
685        let format = self.format().map_err(|error| self.failing(error))?;
686
687        if let Keys::Several(_) = &self.keys {
688            // Refused rather than approximated. etcd's watch is established on
689            // a key or a range, so a caller's arbitrary list would need one
690            // stream per key — and nothing about N independent streams says
691            // *the set* moved, which is the first of the two things a watch on
692            // a set has to answer. A prefix is one stream over one range and
693            // answers it, which is why that shape is the one that landed.
694            //
695            // Reported like every other end of a watch: this one is spawned
696            // and its handle usually dropped, so a refusal nobody is holding
697            // would leave a configuration frozen with nothing said.
698            return Err(self.failing(Error::remote(format!(
699                "{}: a source that reads a named list of keys cannot be \
700                 watched; etcd establishes a watch on a key or a range, so a \
701                 list would be one stream per key and none of them would say \
702                 the set moved together — watch a prefix, or poll \
703                 `refresh_remote_async()` on a timer, which is one round trip",
704                self.describe()
705            ))));
706        }
707
708        let mut stream = match self.watch_once(None).await {
709            // A token that expired before the stream existed is not a failure
710            // an operator needs woken for while it can still be cured — only
711            // the cure failing is. See `reporting_to`.
712            Err(error) if is_expired_token(&error) => {
713                self.refresh_token()
714                    .await
715                    .map_err(|error| self.failing(error))?;
716
717                self.watch_once(None)
718                    .await
719                    .map_err(|error| self.failing(error))?
720            }
721            outcome => outcome.map_err(|error| self.failing(error))?,
722        };
723
724        // Consecutive is what matters: any successfully received message
725        // proves the refreshed token worked and resets the count.
726        const MOST_TOKEN_RECOVERIES: u32 = 3;
727        let mut token_recoveries = 0_u32;
728        // Where a re-established stream picks up: just past the last batch
729        // this loop was handed.
730        let mut resume_from: Option<i64> = None;
731
732        loop {
733            let response = match stream.message().await {
734                Ok(Some(response)) => {
735                    token_recoveries = 0;
736
737                    if let Some(header) = response.header() {
738                        resume_from = Some(header.revision() + 1);
739                    }
740
741                    response
742                }
743                Ok(None) => break,
744                Err(error) => {
745                    let wrapped = classified(
746                        &format!("{}: the watch failed: {error}", self.describe()),
747                        &error,
748                    );
749
750                    // The single most predictable failure of a long-lived
751                    // watch: etcd's simple tokens default to a five-minute
752                    // TTL, and a watch is long-lived by definition. Refresh
753                    // and re-establish instead of handing the caller a
754                    // terminal error for something the credentials can cure.
755                    // The new stream resumes just past the last delivered
756                    // revision, so a write that lands while the stream is
757                    // down is replayed rather than lost; if that revision
758                    // has been compacted away meanwhile, etcd cancels the
759                    // resumed watch and the cancel branch below makes that a
760                    // clean error.
761                    //
762                    // Bounded twice over: a refresh that fails propagates,
763                    // and a server that keeps *accepting* the login while
764                    // failing the stream — an auth-enabled proxy in front of
765                    // a member without auth, say — hits the recovery cap
766                    // instead of hammering the login endpoint forever.
767                    //
768                    // Nothing is reported for a recovery that *works*: the
769                    // store answered, the credential was replaced, and the
770                    // resumed stream lost no event. Reporting it would drive
771                    // `remote_up` to zero every time a five-minute token
772                    // turned over on a cluster that is perfectly healthy —
773                    // and, because only a delivery or a fetch clears the
774                    // streak, it would stay there until the next change. The
775                    // three failures around it do report: a refusal to
776                    // re-authenticate, a stream that will not re-establish,
777                    // and a recovery cap that ran out.
778                    if is_expired_token(&wrapped) {
779                        token_recoveries += 1;
780
781                        if token_recoveries > MOST_TOKEN_RECOVERIES {
782                            return Err(self.failing(wrapped));
783                        }
784
785                        self.refresh_token()
786                            .await
787                            .map_err(|error| self.failing(error))?;
788                        stream = self
789                            .watch_once(resume_from)
790                            .await
791                            .map_err(|error| self.failing(error))?;
792
793                        continue;
794                    }
795
796                    return Err(self.failing(wrapped));
797                }
798            };
799
800            // etcd cancels a watch it can no longer serve — most often because
801            // the revision it started from has been compacted away. Returning
802            // `Ok` here would leave the caller's task finished, the
803            // configuration frozen, and nothing said about either.
804            if response.canceled() {
805                return Err(self.failing(Error::remote(format!(
806                    "{}: the store cancelled the watch: {}",
807                    self.describe(),
808                    response.cancel_reason()
809                ))));
810            }
811
812            // A prefix watch answers with *the set moved*, not with a
813            // document: the events name the keys that changed, and the
814            // configuration is every key under the prefix. So the document is
815            // read back — once per batch rather than once per event, because a
816            // batch is one revision — as a range read **at the revision the
817            // event carries**. One range read is evaluated at one revision, so
818            // that read is the atomic half a watch on a set needs; pinning it
819            // to the event's own revision rather than to `now` is what makes
820            // the delivered document the state the event announced instead of
821            // whatever has landed since.
822            if let Keys::Prefix(prefix) = &self.keys {
823                // A progress notification carries no events and no change.
824                if response.events().is_empty() {
825                    continue;
826                }
827
828                let Some(revision) = response.header().map(etcd_client::ResponseHeader::revision)
829                else {
830                    continue;
831                };
832
833                let documents = match self.range_at(prefix, revision).await {
834                    // The stream was established with a token that has since
835                    // expired: the stream itself survives, and only the read
836                    // this batch needs is refused. One refresh, one retry —
837                    // the same bound `watch_once` uses.
838                    Err(error) if is_expired_token(&error) => {
839                        self.refresh_token()
840                            .await
841                            .map_err(|error| self.failing(error))?;
842
843                        self.range_at(prefix, revision)
844                            .await
845                            .map_err(|error| self.failing(error))?
846                    }
847                    outcome => outcome.map_err(|error| self.failing(error))?,
848                };
849
850                // The last key under the prefix went away. No configuration is
851                // not a configuration, so the running snapshot stays — the same
852                // rule a deleted single key gets.
853                if documents.is_empty() {
854                    continue;
855                }
856
857                // The merge is the last of the reading: two keys under the
858                // prefix supplying one path is a deployment bug, and it is
859                // reported here for the same reason a fetch that hit it would
860                // be — the read is what failed, not the callback.
861                let document =
862                    documents::merged(&documents, format, Overlap::Refused, &self.describe())
863                        .map_err(|error| self.failing(error))?;
864
865                // `on_change`'s own refusal is deliberately *not* reported:
866                // the store answered, `apply` already counted the delivery,
867                // and whether the document installs is `ConfigStatus`'s half
868                // of the picture.
869                guarded(&mut on_change, document, &self.describe())?;
870
871                continue;
872            }
873
874            for event in response.events() {
875                if event.event_type() != EventType::Put {
876                    continue;
877                }
878
879                let Some(value) = event.kv() else { continue };
880
881                // What the store put in the key is not a document, which is
882                // the same failure a `fetch` of it would have recorded.
883                let text = value.value_str().map_err(|error| {
884                    self.failing(Error::remote(format!(
885                        "{}: the value is not UTF-8: {error}",
886                        self.describe()
887                    )))
888                })?;
889
890                guarded(&mut on_change, Fetched::new(text, format), &self.describe())?;
891            }
892        }
893
894        // The stream ended without an error and without being cancelled: the
895        // connection went away. Also a failure, for the same reason — a watch
896        // that stops quietly is a configuration that stops updating quietly.
897        Err(self.failing(Error::remote(format!(
898            "{}: the watch ended; the connection was closed",
899            self.describe()
900        ))))
901    }
902
903    /// Asks etcd for a new auth token, using the credentials the client holds.
904    ///
905    /// Not a reconnect: the gRPC channel looks after itself, and the client
906    /// kept the credentials, so the thing that actually expired is the only
907    /// thing replaced. This works for a shared client too, which a reconnect
908    /// would not — replacing a client the caller owns is not this crate's to
909    /// do.
910    ///
911    /// # Errors
912    ///
913    /// If etcd refuses the credentials.
914    async fn refresh_token(&self) -> Result<(), Error> {
915        self.client
916            .lock()
917            .await
918            .refresh_token()
919            .await
920            .map_err(|error| {
921                classified(
922                    &format!(
923                        "{}: the auth token expired and could not be replaced: {error}",
924                        self.describe()
925                    ),
926                    &error,
927                )
928            })
929    }
930}
931
932impl Etcd {
933    /// One attempt at establishing the watch, with no recovery.
934    ///
935    /// The client guard is taken to establish the stream and released
936    /// immediately. Holding it for the watch's lifetime would block every
937    /// `fetch` on this source until the watch ended — which, for a watch, is
938    /// never.
939    async fn watch_once(
940        &self,
941        from_revision: Option<i64>,
942    ) -> Result<etcd_client::WatchStream, Error> {
943        // Resuming replays every event after the one last delivered, so a
944        // write that lands while the stream is down is caught up rather than
945        // lost. A fresh watch starts at the current revision instead — the
946        // startup contract is "changes only".
947        let mut options = etcd_client::WatchOptions::new();
948
949        if let Some(revision) = from_revision {
950            options = options.with_start_revision(revision);
951        }
952
953        // `watch` refuses a named list before reaching here, so only the two
954        // shapes etcd can establish one stream for arrive.
955        let key = match &self.keys {
956            Keys::One(key) => key.as_str(),
957            Keys::Prefix(prefix) => {
958                options = options.with_prefix();
959
960                prefix.as_str()
961            }
962            Keys::Several(_) => {
963                return Err(Error::remote(format!(
964                    "{}: only a single key or a prefix can be watched",
965                    self.describe()
966                )))
967            }
968        };
969
970        self.client
971            .lock()
972            .await
973            .watch(key, Some(options))
974            .await
975            .map_err(|error| {
976                classified(
977                    &format!("{}: cannot watch: {error}", self.describe()),
978                    &error,
979                )
980            })
981    }
982
983    /// One range read of `prefix`, evaluated at `revision`.
984    ///
985    /// The re-read half of a prefix watch, and the reason that watch can be
986    /// honest: etcd evaluates a range read at a single revision, so the pairs
987    /// this returns are the subtree as one instant had it. Bounded by
988    /// [`with_timeout`](Self::with_timeout), like every other request — a
989    /// member that accepts the read and never answers would otherwise wedge
990    /// the loop for good.
991    async fn range_at(&self, prefix: &str, revision: i64) -> Result<Vec<(String, String)>, Error> {
992        let read = async {
993            let response = self
994                .client
995                .lock()
996                .await
997                .get(prefix, Some(prefix_options(Some(revision))))
998                .await
999                .map_err(|error| self.wrapped(&error))?;
1000
1001            documents::within_key_budget(response.kvs().len(), &self.describe())?;
1002
1003            self.pairs_of(&response, None)
1004        };
1005
1006        tokio::time::timeout(self.timeout, read)
1007            .await
1008            .unwrap_or_else(|_| {
1009                Err(Error::remote(format!(
1010                    "{}: timed out after {:?} re-reading the range the watch \
1011                     reported a change to",
1012                    self.describe(),
1013                    self.timeout
1014                )))
1015            })
1016    }
1017
1018    /// One read of whatever this source reads, with no recovery, bounded by
1019    /// [`with_timeout`](Self::with_timeout).
1020    ///
1021    /// One round trip in all three shapes. A named list goes as a transaction
1022    /// of range reads rather than as N gets, which is not merely fewer packets:
1023    /// a transaction is evaluated at one revision, so a write landing between
1024    /// two of the keys cannot produce a document that never existed.
1025    async fn get_once(&self) -> Result<Vec<(String, String)>, Error> {
1026        if let Keys::Several(keys) = &self.keys {
1027            if keys.len() > MOST_TRANSACTION_KEYS {
1028                return Err(Error::remote(format!(
1029                    "{}: {} keys is more than the {MOST_TRANSACTION_KEYS} one etcd \
1030                     transaction carries (`--max-txn-ops`); reading them would take \
1031                     several round trips at several revisions, which is the torn \
1032                     document this avoids — read a prefix, or install a source per \
1033                     group",
1034                    self.describe(),
1035                    keys.len()
1036                )));
1037            }
1038        }
1039
1040        let read = async {
1041            let mut client = self.client.lock().await;
1042
1043            match &self.keys {
1044                Keys::One(key) => {
1045                    let response = client
1046                        .get(key.as_str(), None)
1047                        .await
1048                        .map_err(|error| self.wrapped(&error))?;
1049
1050                    self.pairs_of(&response, Some(key))
1051                }
1052                Keys::Several(keys) => {
1053                    let transaction = etcd_client::Txn::new().and_then(
1054                        keys.iter()
1055                            .map(|key| etcd_client::TxnOp::get(key.as_str(), None))
1056                            .collect::<Vec<_>>(),
1057                    );
1058
1059                    let answered = client
1060                        .txn(transaction)
1061                        .await
1062                        .map_err(|error| self.wrapped(&error))?;
1063
1064                    let mut documents = Vec::with_capacity(keys.len());
1065
1066                    // Zipped with the request, not read out of the response:
1067                    // a range read for a key that is not there answers with an
1068                    // empty range, so the response alone cannot say which key
1069                    // was missing.
1070                    for (key, answer) in keys.iter().zip(answered.op_responses()) {
1071                        let etcd_client::TxnOpResponse::Get(response) = answer else {
1072                            return Err(Error::remote(format!(
1073                                "{}: the store answered a read with something else",
1074                                self.describe()
1075                            )));
1076                        };
1077
1078                        documents.extend(self.pairs_of(&response, Some(key))?);
1079                    }
1080
1081                    Ok(documents)
1082                }
1083                Keys::Prefix(prefix) => {
1084                    let response = client
1085                        .get(prefix.as_str(), Some(prefix_options(None)))
1086                        .await
1087                        .map_err(|error| self.wrapped(&error))?;
1088
1089                    documents::within_key_budget(response.kvs().len(), &self.describe())?;
1090
1091                    self.pairs_of(&response, None)
1092                }
1093            }
1094        };
1095
1096        // The lock is inside the deadline on purpose: waiting behind another
1097        // request is time the caller waited for this fetch, and a deadline
1098        // that excluded it would be a deadline the caller cannot rely on.
1099        tokio::time::timeout(self.timeout, read)
1100            .await
1101            .unwrap_or_else(|_| {
1102                Err(Error::remote(format!(
1103                    "{}: timed out after {:?}",
1104                    self.describe(),
1105                    self.timeout
1106                )))
1107            })
1108    }
1109
1110    /// The `(key, document)` pairs in one range response.
1111    ///
1112    /// `expected` is the key that was asked for, when one was: a range read
1113    /// answering with nothing means that key holds no value, and **that fails
1114    /// the whole fetch**. Merging the four keys that did answer would leave a
1115    /// process running a configuration with a section quietly missing from it,
1116    /// which is worse than a refresh that failed and left the last document in
1117    /// place.
1118    fn pairs_of(
1119        &self,
1120        response: &etcd_client::GetResponse,
1121        expected: Option<&str>,
1122    ) -> Result<Vec<(String, String)>, Error> {
1123        if let Some(key) = expected {
1124            if response.kvs().is_empty() {
1125                return Err(Error::remote(format!(
1126                    "{}: `{key}` holds no value",
1127                    self.describe()
1128                )));
1129            }
1130        }
1131
1132        response
1133            .kvs()
1134            .iter()
1135            .map(|value| {
1136                let key = value.key_str().map_err(|error| {
1137                    Error::remote(format!("{}: a key is not UTF-8: {error}", self.describe()))
1138                })?;
1139
1140                // Only ever reachable through a proxy that rewrote the range,
1141                // and one comparison is cheaper than finding out the hard way
1142                // that a prefix meant something else.
1143                if let Keys::Prefix(prefix) = &self.keys {
1144                    documents::under_prefix(key, prefix, &self.describe())?;
1145                }
1146
1147                let text = value.value_str().map_err(|error| {
1148                    Error::remote(format!(
1149                        "{}: `{key}` is not UTF-8: {error}",
1150                        self.describe()
1151                    ))
1152                })?;
1153
1154                Ok((key.to_owned(), text.to_owned()))
1155            })
1156            .collect()
1157    }
1158
1159    /// One of etcd's failures, named and classified.
1160    fn wrapped(&self, error: &etcd_client::Error) -> Error {
1161        classified(&format!("{}: {error}", self.describe()), error)
1162    }
1163}
1164
1165/// How a prefix is read, whether by a fetch or by a watch's re-read.
1166///
1167/// One over the budget, so a range that is too big is *known* to be too big
1168/// rather than silently truncated — a truncated prefix read is a configuration
1169/// missing a section, which is the failure this whole feature is about not
1170/// having.
1171///
1172/// `revision` pins the read to one the caller already knows: a watch reads at
1173/// the revision its event carried, and a fetch reads at whatever is current.
1174/// Both are one revision, which is the property that matters; only the watch
1175/// needs to name which.
1176fn prefix_options(revision: Option<i64>) -> etcd_client::GetOptions {
1177    let options = etcd_client::GetOptions::new()
1178        .with_prefix()
1179        .with_limit(i64::try_from(documents::MOST_KEYS.saturating_add(1)).unwrap_or(i64::MAX));
1180
1181    match revision {
1182        Some(revision) => options.with_revision(revision),
1183        None => options,
1184    }
1185}
1186
1187/// Sorts one of etcd's failures into a kind, without stringifying first.
1188///
1189/// The `GRpcStatus` guard is what keeps this honest: `permission denied` is
1190/// also what an OS says about a file it will not open, and `etcd-client`
1191/// reports that as `IoError`. Only a refusal that came back over the wire can
1192/// be an auth refusal.
1193fn classified(message: &str, error: &etcd_client::Error) -> Error {
1194    match error {
1195        etcd_client::Error::GRpcStatus(status) if is_auth_refusal(status.message()) => {
1196            Error::auth(message)
1197        }
1198        _ => Error::remote(message),
1199    }
1200}
1201
1202/// Whether a gRPC status message is etcd refusing the credentials.
1203fn is_auth_refusal(message: &str) -> bool {
1204    AUTH_REFUSALS
1205        .iter()
1206        .any(|refusal| message.contains(refusal))
1207}
1208
1209/// Whether a failure is etcd saying the auth token has expired.
1210///
1211/// Matched on the message because `etcd-client` reports it as a generic gRPC
1212/// status, and the alternative — treating *every* failure as a reason to
1213/// refresh — would hide a wrong password behind a refresh loop.
1214fn is_expired_token(error: &Error) -> bool {
1215    error.to_string().contains(INVALID_TOKEN)
1216}
1217
1218/// One connection attempt, with the endpoints named in any failure.
1219async fn connect(
1220    endpoints: &[String],
1221    options: &ConnectOptions,
1222    described: &str,
1223) -> Result<Client, Error> {
1224    Client::connect(endpoints, Some(options.clone()))
1225        .await
1226        .map_err(|error| classified(&format!("etcd {described}: {error}"), &error))
1227}
1228
1229impl AsyncRemoteSource for Etcd {
1230    fn fetch(&self) -> Pin<Box<dyn Future<Output = Result<Fetched, Error>> + Send + '_>> {
1231        Box::pin(async move {
1232            let format = self.format()?;
1233
1234            let documents = match self.get_once().await {
1235                Err(error) if is_expired_token(&error) => {
1236                    // etcd's simple tokens have a TTL — five minutes by
1237                    // default — and a long-lived reader outlives one. The gRPC
1238                    // channel looks after itself; the token does not, so this
1239                    // is the one failure worth recovering from by hand.
1240                    //
1241                    // Once, not in a loop: if a fresh token is refused too, the
1242                    // credentials are wrong and retrying would turn a clear
1243                    // failure into a hang.
1244                    self.refresh_token().await?;
1245
1246                    self.get_once().await?
1247                }
1248                outcome => outcome?,
1249            };
1250
1251            // etcd lists a range in key order, and a transaction answers in
1252            // request order — which is exactly the order each rule wants, so
1253            // nothing is sorted here.
1254            documents::merged(&documents, format, self.overlap(), &self.describe())
1255        })
1256    }
1257
1258    fn describe(&self) -> String {
1259        format!("etcd {} {}", self.endpoints, self.keys.describe())
1260    }
1261}
1262
1263impl Etcd {
1264    /// What two of this source's keys supplying one path means.
1265    ///
1266    /// The distinction the feature turns on: a caller who wrote the list wrote
1267    /// the precedence with it, and a caller who wrote a prefix wrote no order
1268    /// at all — so the first merges and the second refuses.
1269    fn overlap(&self) -> Overlap {
1270        match self.keys {
1271            Keys::One(_) | Keys::Several(_) => Overlap::LaterWins,
1272            Keys::Prefix(_) => Overlap::Refused,
1273        }
1274    }
1275}
1276
1277/// The shared vocabulary, translated into `tonic`'s own TLS types.
1278///
1279/// `described` is what an error names the source by; the material itself never
1280/// appears in one. In particular the PEM parse failures underneath are *not*
1281/// wrapped: `rustls-pki-types` renders the line it choked on, and the line it
1282/// choked on in a private key file is private key material — so the failure is
1283/// reported here, at construction, only as far as which file was wrong.
1284///
1285/// tonic validates the PEM lazily, when the channel is built, which is why
1286/// there is nothing to fail on for a malformed certificate until `connect`.
1287#[cfg(feature = "tls")]
1288fn tls_options(tls: &TlsConfig, described: &str) -> Result<TlsOptions, Error> {
1289    let mut options = TlsOptions::new();
1290
1291    if let Some(pem) = tls.ca_certificate_pem(described)? {
1292        // Replaces the trust store rather than adding to it, which is what
1293        // pinning a private authority means. `tls-roots` is the feature that
1294        // says "the platform's roots as well", and it applies when no CA is
1295        // named here.
1296        options = options.ca_certificate(Certificate::from_pem(pem));
1297    }
1298
1299    if let Some((certificate, key)) = tls.client_certificate_pem(described)? {
1300        options = options.identity(Identity::from_pem(certificate, key));
1301    }
1302
1303    Ok(options)
1304}
1305
1306impl std::fmt::Debug for Etcd {
1307    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1308        f.debug_struct("Etcd")
1309            .field("endpoints", &self.endpoints)
1310            .field("keys", &self.keys)
1311            .field("format", &self.format)
1312            .finish_non_exhaustive()
1313    }
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319
1320    /// The refusals a running etcd actually sends, verbatim. If etcd ever
1321    /// rewords one, this is the test that says so rather than a watch loop
1322    /// quietly retrying a wrong password forever.
1323    #[test]
1324    fn etcds_own_refusals_are_recognised() {
1325        for message in [
1326            "etcdserver: invalid auth token",
1327            "etcdserver: authentication failed, invalid user ID or password",
1328            "etcdserver: permission denied",
1329            "etcdserver: user name is empty",
1330            "etcdserver: user name not found",
1331        ] {
1332            assert!(is_auth_refusal(message), "{message}");
1333        }
1334    }
1335
1336    /// The over-classification this guard exists to prevent: a store that is
1337    /// merely down must stay `Remote`, because a watch loop backs off on that
1338    /// and stops on `Auth`.
1339    #[test]
1340    fn an_ordinary_failure_is_not_an_auth_refusal() {
1341        for message in [
1342            "etcdserver: request timed out",
1343            "etcdserver: too many requests",
1344            "transport error: connection refused",
1345            // The trap the `GRpcStatus` guard covers from the other side: a
1346            // file etcd's TLS setup could not open says this too, with a
1347            // capital P and no gRPC status behind it.
1348            "Permission denied (os error 13)",
1349        ] {
1350            assert!(!is_auth_refusal(message), "{message}");
1351        }
1352    }
1353
1354    #[tokio::test]
1355    async fn a_fetch_from_a_server_that_never_answers_ends_at_the_deadline() {
1356        // Accepts the connection and then says nothing at all — the failure
1357        // a connect timeout cannot see, and the reason the deadline wraps the
1358        // request rather than the connection.
1359        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1360        let address = format!("http://{}", listener.local_addr().unwrap());
1361
1362        let silent = std::thread::spawn(move || {
1363            let held = listener.accept();
1364            // Held until the test is done with it: dropping the socket here
1365            // would answer the client with a close, which is an answer.
1366            std::thread::sleep(Duration::from_secs(2));
1367            drop(held);
1368        });
1369
1370        let source = Etcd::new([address], "myapp/db.json")
1371            .await
1372            .expect("the endpoint parses; connecting is lazy")
1373            .with_timeout(Duration::from_millis(200));
1374
1375        let started = std::time::Instant::now();
1376        let error = source.fetch().await.expect_err("nothing ever answers");
1377        let elapsed = started.elapsed();
1378
1379        assert!(
1380            elapsed < Duration::from_secs(1),
1381            "the deadline must bound the fetch, not merely the connect: {elapsed:?}"
1382        );
1383        assert!(error.to_string().contains("timed out"), "{error}");
1384        assert_eq!(
1385            error.kind(),
1386            dynamic_config::ErrorKind::Remote,
1387            "a store that went quiet may yet come back; that is not an auth failure"
1388        );
1389
1390        let _ = silent.join();
1391    }
1392
1393    /// The password goes in through `ConnectOptions`, and supplying one makes
1394    /// `connect` log in there and then — so the construction error is where a
1395    /// leak would surface, and `{:?}` where it would surface next.
1396    #[tokio::test]
1397    async fn neither_an_error_nor_debug_prints_a_credential() {
1398        // Port 9 is discard; nothing listens there.
1399        let error = Etcd::with_options(
1400            ["http://127.0.0.1:9"],
1401            "myapp/db.json",
1402            ConnectOptions::new().with_user("myapp", "hunter2-etcd-password"),
1403        )
1404        .await
1405        .expect_err("nothing is listening");
1406
1407        let printed = format!("{error} {error:?}");
1408
1409        assert!(!printed.contains("hunter2"), "{printed}");
1410        assert!(printed.contains("127.0.0.1:9"), "{printed}");
1411        assert_eq!(
1412            error.kind(),
1413            dynamic_config::ErrorKind::Remote,
1414            "a refused connection is the store being unreachable, not the \
1415             credentials being wrong — a watch loop backs off on one and \
1416             stops on the other"
1417        );
1418    }
1419
1420    /// And the same for the ordinary path, where the endpoint is all this
1421    /// type holds: a `Debug` of it names the store, never a secret.
1422    #[tokio::test]
1423    async fn debug_names_the_store_and_nothing_else() {
1424        let source = Etcd::new(["http://127.0.0.1:9"], "myapp/db.json")
1425            .await
1426            .expect("the endpoint parses; connecting is lazy");
1427
1428        let printed = format!("{source:?}");
1429
1430        assert!(printed.contains("myapp/db.json"), "{printed}");
1431        assert!(printed.contains("127.0.0.1:9"), "{printed}");
1432    }
1433
1434    /// A bare string still means one key, which is what keeps every caller
1435    /// who wrote the single-key spelling compiling.
1436    #[test]
1437    fn a_bare_key_is_still_one_key() {
1438        assert_eq!(Keys::from("myapp/db.json"), Keys::one("myapp/db.json"));
1439        assert_eq!(
1440            Keys::from("myapp/db.json".to_owned()),
1441            Keys::one("myapp/db.json")
1442        );
1443    }
1444
1445    /// Provenance is store-grained once several keys become one document, so
1446    /// the one thing `describe()` can still do is name the whole set — it is
1447    /// what `source_of` reports and what every error quotes.
1448    #[tokio::test]
1449    async fn describe_names_every_key_in_the_set() {
1450        let several = Etcd::new(
1451            ["http://127.0.0.1:9"],
1452            Keys::several(["myapp/base.json", "myapp/local.json"]),
1453        )
1454        .await
1455        .expect("the endpoint parses");
1456
1457        assert!(
1458            several.describe().contains("myapp/base.json")
1459                && several.describe().contains("myapp/local.json"),
1460            "{}",
1461            several.describe()
1462        );
1463
1464        let prefix = Etcd::new(["http://127.0.0.1:9"], Keys::prefix("myapp/"))
1465            .await
1466            .expect("the endpoint parses");
1467
1468        assert!(
1469            prefix.describe().contains("prefix myapp/"),
1470            "{}",
1471            prefix.describe()
1472        );
1473    }
1474
1475    /// A prefix has no extension to infer from, so the refusal has to name the
1476    /// call that settles it — before any round trip, so a misconfiguration
1477    /// fails at once rather than against a server.
1478    #[tokio::test]
1479    async fn a_prefix_with_no_format_says_which_call_supplies_one() {
1480        let source = Etcd::new(["http://127.0.0.1:9"], Keys::prefix("myapp/"))
1481            .await
1482            .expect("the endpoint parses");
1483
1484        let error = source.fetch().await.expect_err("no format was ever named");
1485
1486        assert!(error.to_string().contains("with_format"), "{error}");
1487    }
1488
1489    /// Two keys naming two formats is caught by name rather than parsed as
1490    /// whichever came first — which would be a syntax error about a document
1491    /// that has no syntax error in it.
1492    #[tokio::test]
1493    async fn keys_naming_two_formats_are_refused_until_one_is_chosen() {
1494        let source = Etcd::new(
1495            ["http://127.0.0.1:9"],
1496            Keys::several(["myapp/db.json", "myapp/server.toml"]),
1497        )
1498        .await
1499        .expect("the endpoint parses");
1500
1501        let error = source
1502            .fetch()
1503            .await
1504            .expect_err("json and toml cannot both be the format");
1505
1506        assert!(error.to_string().contains("myapp/db.json"), "{error}");
1507        assert!(error.to_string().contains("myapp/server.toml"), "{error}");
1508
1509        // ...and `with_format` settles it, which is what makes the refusal a
1510        // signpost rather than a dead end. The fetch then fails on the network
1511        // instead, which is the next honest thing to fail on.
1512        let settled = Etcd::new(
1513            ["http://127.0.0.1:9"],
1514            Keys::several(["myapp/db.json", "myapp/server.toml"]),
1515        )
1516        .await
1517        .expect("the endpoint parses")
1518        .with_format(Format::Json);
1519
1520        let error = settled.fetch().await.expect_err("nothing is listening");
1521
1522        assert!(!error.to_string().contains("with_format"), "{error}");
1523    }
1524
1525    /// etcd caps a transaction at `--max-txn-ops`, so a longer list would have
1526    /// to be read at several revisions — the torn document the transaction was
1527    /// chosen to prevent. Refused with the number in it rather than silently
1528    /// split.
1529    #[tokio::test]
1530    async fn a_named_list_longer_than_one_transaction_is_refused() {
1531        let keys: Vec<String> = (0..=MOST_TRANSACTION_KEYS)
1532            .map(|n| format!("myapp/{n:04}.json"))
1533            .collect();
1534
1535        let source = Etcd::new(["http://127.0.0.1:9"], Keys::several(keys))
1536            .await
1537            .expect("the endpoint parses");
1538
1539        let error = source.fetch().await.expect_err("one key too many");
1540
1541        assert!(error.to_string().contains("max-txn-ops"), "{error}");
1542        assert!(
1543            error.to_string().contains("129"),
1544            "the count belongs in the message: {error}"
1545        );
1546    }
1547
1548    /// Refused, not approximated: etcd establishes a watch on a key or a
1549    /// range, so a caller's list is N streams and none of them is about the
1550    /// set. The refusal has to arrive at `watch`, before the first event —
1551    /// hours later is not a refusal.
1552    #[tokio::test]
1553    async fn a_named_list_refuses_to_be_watched_and_says_what_to_do() {
1554        let source = Etcd::new(
1555            ["http://127.0.0.1:9"],
1556            Keys::several(["myapp/db.json", "myapp/server.json"]),
1557        )
1558        .await
1559        .expect("the endpoint parses");
1560
1561        let error = source
1562            .watch(|_| Ok(()))
1563            .await
1564            .expect_err("a named list cannot be watched");
1565
1566        assert!(error.to_string().contains("cannot be watched"), "{error}");
1567        assert!(
1568            error.to_string().contains("refresh_remote_async"),
1569            "{error}"
1570        );
1571        assert!(
1572            error.to_string().contains("watch a prefix"),
1573            "the refusal must name the shape that does work: {error}"
1574        );
1575    }
1576
1577    /// The other half of the same decision: a prefix is *not* refused, so the
1578    /// only way it can fail here is by failing to reach the endpoint. This is
1579    /// what keeps the refusal above from quietly widening back over the shape
1580    /// that was built.
1581    #[tokio::test]
1582    async fn a_prefix_is_not_refused_at_the_door() {
1583        let source = Etcd::new(["http://127.0.0.1:9"], Keys::prefix("myapp/"))
1584            .await
1585            .expect("the endpoint parses")
1586            .with_format(Format::Json);
1587
1588        let error = source
1589            .watch(|_| Ok(()))
1590            .await
1591            .expect_err("nothing is listening on port 9");
1592
1593        assert!(
1594            !error.to_string().contains("cannot be watched"),
1595            "a prefix watch must fail on the connection, not on a refusal: {error}"
1596        );
1597    }
1598
1599    // -----------------------------------------------------------------------
1600    // Reporting a watch that is failing.
1601    //
1602    // The half of a store `dynamic-config` cannot see: a watch that never
1603    // reaches etcd delivers nothing, so without this the status would still
1604    // describe the last delivery. What can be asserted without a cluster is
1605    // the wiring — that a failure arrives, once, and that a source nobody
1606    // wired behaves exactly as it did. Breaking a *running* stream needs a
1607    // real server and lives in `tests/against_etcd.rs`.
1608    // -----------------------------------------------------------------------
1609
1610    /// A watch that cannot be established is the case this exists for: it
1611    /// delivers nothing, and the caller has usually spawned it and dropped the
1612    /// handle, so nothing else would ever say so.
1613    #[tokio::test]
1614    async fn a_watch_that_cannot_be_established_reports_the_store_as_unreachable() {
1615        use dynamic_config::{Remote, RemoteSink};
1616
1617        // Its own `static`, because a `RemoteSink` needs one and two tests
1618        // sharing one would race.
1619        static UNREACHABLE: Remote = Remote::new();
1620
1621        fn reloaded() -> Result<(), Error> {
1622            Ok(())
1623        }
1624
1625        // Port 9 is discard; nothing listens there.
1626        let source = Etcd::new(["http://127.0.0.1:9"], Keys::prefix("myapp/"))
1627            .await
1628            .expect("the endpoint parses; connecting is lazy")
1629            .with_format(Format::Json)
1630            .reporting_to(RemoteSink::new(&UNREACHABLE, reloaded, "etcd"));
1631
1632        source
1633            .watch(|_| Ok(()))
1634            .await
1635            .expect_err("nothing is listening on port 9");
1636
1637        let status = UNREACHABLE.status();
1638
1639        assert_eq!(
1640            status.consecutive_failures, 1,
1641            "one attempt, reported exactly once — a site reported twice would \
1642             make the streak a count of branches rather than of attempts"
1643        );
1644        assert_eq!(status.reachable(), Some(false));
1645        assert_eq!(
1646            status.fetches, 0,
1647            "an attempt that returned nothing is not a fetch"
1648        );
1649        assert_eq!(
1650            status.last_fetch, None,
1651            "and it must not invent a read that never happened"
1652        );
1653
1654        let failure = status.last_failure.as_ref().expect("the attempt failed");
1655
1656        assert_eq!(failure.kind, dynamic_config::ErrorKind::Remote);
1657        assert!(
1658            !format!("{status:?}").contains("127.0.0.1"),
1659            "a store's address never enters a status: {status:?}"
1660        );
1661    }
1662
1663    /// A refusal that arrives before the first round trip is reported too, and
1664    /// for the same reason: the watch was spawned, the handle was dropped, and
1665    /// a configuration that will never update again should not read as healthy.
1666    #[tokio::test]
1667    async fn a_refusal_at_the_door_is_reported_rather_than_left_to_a_dropped_handle() {
1668        use dynamic_config::{Remote, RemoteSink};
1669
1670        static REFUSED: Remote = Remote::new();
1671
1672        fn reloaded() -> Result<(), Error> {
1673            Ok(())
1674        }
1675
1676        let source = Etcd::new(
1677            ["http://127.0.0.1:9"],
1678            Keys::several(["myapp/db.json", "myapp/server.json"]),
1679        )
1680        .await
1681        .expect("the endpoint parses")
1682        .reporting_to(RemoteSink::new(&REFUSED, reloaded, "etcd"));
1683
1684        let error = source
1685            .watch(|_| Ok(()))
1686            .await
1687            .expect_err("a named list cannot be watched");
1688
1689        assert!(error.to_string().contains("cannot be watched"), "{error}");
1690        assert_eq!(REFUSED.status().reachable(), Some(false));
1691    }
1692
1693    /// The default every source carries: reporting nowhere changes nothing a
1694    /// caller can see, including the error they get back.
1695    #[tokio::test]
1696    async fn a_source_that_reports_nowhere_fails_exactly_as_it_always_did() {
1697        let source = Etcd::new(["http://127.0.0.1:9"], Keys::prefix("myapp/"))
1698            .await
1699            .expect("the endpoint parses")
1700            .with_format(Format::Json);
1701
1702        let error = source
1703            .watch(|_| Ok(()))
1704            .await
1705            .expect_err("nothing is listening on port 9");
1706
1707        assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote);
1708        assert!(error.to_string().contains("cannot watch"), "{error}");
1709    }
1710
1711    // -----------------------------------------------------------------------
1712    // TLS: the shared vocabulary, translated into tonic's own types.
1713    //
1714    // tonic validates PEM lazily — a `Certificate::from_pem` keeps the bytes
1715    // and the channel decides later — so what can be asserted without a
1716    // cluster is the half this crate owns: reading the files, and refusing
1717    // loudly when it cannot.
1718    // -----------------------------------------------------------------------
1719
1720    /// A CA file that is not there is an error naming the path, from the
1721    /// constructor rather than from a panic somewhere in a builder chain.
1722    #[cfg(feature = "tls")]
1723    #[tokio::test]
1724    async fn a_missing_ca_file_names_the_path_and_the_material() {
1725        let error = Etcd::with_tls(
1726            ["https://127.0.0.1:9"],
1727            "myapp/db.json",
1728            ConnectOptions::new(),
1729            &TlsConfig::new().with_ca_certificate_file("/nonexistent/etcd-ca.pem"),
1730        )
1731        .await
1732        .expect_err("the CA file is not there");
1733
1734        assert!(
1735            error.to_string().contains("/nonexistent/etcd-ca.pem"),
1736            "{error}"
1737        );
1738        assert!(error.to_string().contains("the CA certificate"), "{error}");
1739    }
1740
1741    /// The private key is the sharpest secret in this feature. The file is
1742    /// read here, so a read failure must name the path and nothing that was
1743    /// in it — and a key that *was* read must not travel into a diagnostic
1744    /// either.
1745    #[cfg(feature = "tls")]
1746    #[tokio::test]
1747    async fn a_private_key_never_reaches_an_error_or_a_debug() {
1748        const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
1749
1750        let tls = TlsConfig::new()
1751            .with_ca_certificate_pem("-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n")
1752            .with_client_certificate_pem(
1753                "-----BEGIN CERTIFICATE-----\ncert\n-----END CERTIFICATE-----\n",
1754                format!("-----BEGIN PRIVATE KEY-----\n{PLANTED}\n-----END PRIVATE KEY-----\n"),
1755            );
1756
1757        assert!(!format!("{tls:?}").contains(PLANTED), "{tls:?}");
1758
1759        // tonic builds the channel eagerly enough to reject the material, and
1760        // the message it produces for that is the one this crate quotes. It
1761        // must carry nothing of what it choked on — which is exactly the
1762        // hazard `rustls-pki-types`' own renderer has, and the reason no PEM
1763        // error in this family is wrapped.
1764        let error = Etcd::with_tls(
1765            ["https://127.0.0.1:9"],
1766            "myapp/db.json",
1767            ConnectOptions::new(),
1768            &tls,
1769        )
1770        .await
1771        .expect_err("that is not a certificate");
1772
1773        assert!(!error.to_string().contains(PLANTED), "{error}");
1774        assert!(!format!("{error:?}").contains(PLANTED), "{error:?}");
1775    }
1776
1777    /// Both halves of a client certificate have to arrive, so a missing key
1778    /// file fails naming the key rather than quietly presenting a certificate
1779    /// with nothing to prove it.
1780    #[cfg(feature = "tls")]
1781    #[tokio::test]
1782    async fn a_client_certificate_with_no_readable_key_is_refused() {
1783        let error = Etcd::with_tls(
1784            ["https://127.0.0.1:9"],
1785            "myapp/db.json",
1786            ConnectOptions::new(),
1787            &TlsConfig::new().with_client_certificate_files(
1788                "/nonexistent/client.crt",
1789                "/nonexistent/client.key",
1790            ),
1791        )
1792        .await
1793        .expect_err("neither file is there");
1794
1795        assert!(
1796            error.to_string().contains("the client certificate"),
1797            "{error}"
1798        );
1799    }
1800}