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}