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