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