Skip to main content

dynamic_config_consul/
lib.rs

1//! Read [`dynamic-config`] configuration from Consul's key/value store.
2//!
3//! Consul's KV API is plain HTTP, so this implements the **blocking**
4//! [`RemoteSource`] trait: nothing here needs an async runtime, and neither
5//! does using it.
6//!
7//! ```no_run
8//! use dynamic_config_consul::Consul;
9//!
10//! # struct DbConfig;
11//! # impl DbConfig {
12//! #     fn set_remote(_: Consul) {}
13//! #     fn refresh_remote() -> Result<(), dynamic_config::Error> { Ok(()) }
14//! # }
15//! DbConfig::set_remote(
16//!     Consul::new("http://consul.internal:8500", "myapp/db.json")
17//!         .with_token(std::env::var("CONSUL_HTTP_TOKEN")?),
18//! );
19//!
20//! DbConfig::refresh_remote()?;
21//! # Ok::<(), Box<dyn std::error::Error>>(())
22//! ```
23//!
24//! # What it reads
25//!
26//! `GET {address}/v1/kv/{key}`, and base64-decodes the single `Value` Consul
27//! returns. **The stored value is a whole configuration document** — the same
28//! bytes that would be in a config file — so the format comes from the key's
29//! extension, or from [`with_format`](Consul::with_format).
30//!
31//! That is the opposite of [`dynamic-config-vault`], which wraps a secret's
32//! fields under a section key. The difference is not a whim: Vault stores a map
33//! of named secrets, Consul stores an opaque blob, and each is easiest to use
34//! as what it already is.
35//!
36//! # Several keys as one document
37//!
38//! A deployment that splits its configuration across a subtree — `myapp/db`,
39//! `myapp/server` — can have one source read the lot, and [`Keys`] says which:
40//!
41//! ```no_run
42//! # use dynamic_config_consul::{Consul, Keys};
43//! # let address = "http://consul.internal:8500";
44//! // Named keys: a list of layers, merged in the order given, later wins.
45//! let consul = Consul::new(address, Keys::several(["myapp/base.json", "myapp/local.json"]));
46//!
47//! // A prefix: disjoint sections, and an overlap between two of them is an error.
48//! let consul = Consul::new(address, Keys::prefix("myapp/"))
49//!     .with_format(dynamic_config::Format::Json);
50//! ```
51//!
52//! The two forms cost different things, and the difference is the agent's, not
53//! this crate's:
54//!
55//! - **A prefix is one request** — `?recurse`, which Consul answers with the
56//!   whole subtree at one index. So the set is consistent: a write landing
57//!   mid-read cannot produce a document that never existed.
58//! - **A named list is one request per key.** Consul's KV API reads one key or
59//!   one subtree and has no batch read of a caller-chosen set, so a list is
60//!   **not** read atomically. Its transaction endpoint could do it in one, at
61//!   the price of a write-shaped request and a sixty-four operation ceiling;
62//!   that trade is recorded rather than taken. Prefer a prefix where the keys
63//!   are disjoint anyway.
64//!
65//! Three consequences that belong here rather than in an incident:
66//!
67//! - **A prefix that matches more than 512 keys is refused.** A prefix is
68//!   caller input and the answer to it is server input.
69//! - **Provenance becomes store-grained.** The merged document is one layer, so
70//!   `source_of` answers "from consul … keys a, b" rather than naming which key
71//!   supplied a value. [`describe`](RemoteSource::describe) names the whole set,
72//!   which is as close as one layer gets.
73//! - **One unreadable key fails the whole fetch.** A configuration quietly
74//!   missing a section is worse than a refresh that failed and left the last
75//!   document serving.
76//!
77//! # Watching
78//!
79//! Consul cannot push, but it can hold a request open until something changes —
80//! a *blocking query*. [`Consul::watch`] is that loop, and it is genuinely
81//! change-driven rather than a poll with extra steps: the agent answers the
82//! moment the key moves.
83//!
84//! It blocks, so it belongs on a thread, and a thread cannot be cancelled from
85//! outside — hence the [`Watching`] token.
86//!
87//! **A prefix can be watched; a named list cannot.** A watch on a set is only
88//! honest if the store says *the set* changed and the set can then be read as
89//! of one instant. A recursive blocking query answers both at once, and is the
90//! only watch in this family that needs no re-read at all: the agent holds the
91//! request open until the subtree's index moves, and what it then sends back
92//! **is the subtree at that index**. The document the callback receives is
93//! folded from those exact bytes, so there is no window between noticing and
94//! reading for a second write to land in. A named list has no such query —
95//! Consul reads one key or one subtree, never a caller-chosen set — so it
96//! refuses at [`watch`](Consul::watch), before the first change; poll
97//! `refresh_remote()` on a timer instead.
98//!
99//! ```no_run
100//! # use dynamic_config::RemoteWatch;
101//! # use dynamic_config_consul::Consul;
102//! # fn example(consul: Consul) {
103//! # let sink = |_: dynamic_config::Fetched| -> Result<(), dynamic_config::Error> { Ok(()) };
104//! let watch = RemoteWatch::new();
105//! let watching = watch.watching();
106//!
107//! std::thread::spawn(move || consul.watch(&watching, move |document| sink(document)));
108//!
109//! // Dropping `watch` — or calling `watch.stop()` — ends the loop.
110//! # }
111//! ```
112//!
113//!
114//! # Every failure branch of the watch loop, and what it reports
115//!
116//! A watch is the half of a store `dynamic-config` cannot see, and
117//! [`reporting_to`](Consul::reporting_to) is what lets it speak: the sink the
118//! loop already holds is told about every attempt that came back with
119//! nothing. Which attempts those are is a table rather than prose, because
120//! the question an operator asks is *which* silence is deliberate.
121//!
122//! Three rules decide the column, and they are the same three in all seven
123//! store crates:
124//!
125//! 1. **A failure the loop survives by retrying reports.** That is the case
126//!    the whole feature exists for: the stream is down, the last delivery is
127//!    old, and nothing else would ever say so out loud.
128//! 2. **A recovery that worked stays silent.** Only a delivery or a fetch
129//!    clears the streak, so reporting a five-minute token turning over on a
130//!    healthy cluster would drive `remote_up` to zero and leave it there.
131//! 3. **A refusal that never asked the store reports nowhere.** No format, a
132//!    key shape that cannot be watched, material that will not build a
133//!    client: `RemoteStatus::reachable()` is *whether the store answered the
134//!    last time it was asked*, and these never ask. They are returned to the
135//!    caller, who is the one holding the mistake — and a status cannot
136//!    correct them, since it carries a kind and a path and no message.
137//!
138//! | Branch | Reports |
139//! |---|---|
140//! | the format is missing, the source cannot be watched, or the agent cannot be built | no — rule 3: nothing has been asked of the agent |
141//! | the blocking query fails | **yes**, and the loop waits and retries |
142//! | the subtree cannot be folded into one document | **yes**, and the watch ends — a deployment bug, not a blip |
143//! | the watched key holds no value | **yes**, and the loop waits and retries |
144//! | the index reset, the document is unchanged, or this is the priming query | no — the agent answered |
145//! | `on_change` refuses the document | no — the agent answered; `apply` counted the delivery, and what the document did next is `ConfigStatus`'s half |
146//!
147//! The empty-key row is a **difference between stores**, deliberately left
148//! standing: `dynamic-config-etcd` and `dynamic-config-redis` leave the
149//! running snapshot alone and say nothing there, because only a delivery
150//! clears a streak and a deleted key would park `remote_up` at zero. This
151//! crate records it, on the argument that a `fetch` of the same key fails.
152//! Both are written down at the branch, and neither moves in a patch release.
153//!
154//! [`dynamic-config`]: https://docs.rs/dynamic-config
155//! [`dynamic-config-vault`]: https://docs.rs/dynamic-config-vault
156
157#![forbid(unsafe_code)]
158#![deny(missing_docs)]
159
160use std::time::Duration;
161
162use base64::Engine;
163use dynamic_config::{Error, Fetched, Format, RemoteSink, RemoteSource, Watching};
164use dynamic_config_store_core::attempts::Attempts;
165use dynamic_config_store_core::credential::{Cached, Issued};
166use dynamic_config_store_core::documents::{self, Overlap};
167use dynamic_config_store_core::guarded;
168
169pub mod auth;
170mod tls;
171
172pub use auth::{Auth, Bearer};
173
174/// A private certificate authority and a client certificate, as data.
175///
176/// The shared vocabulary all seven store crates take, so that reaching TLS
177/// never means naming `ureq`'s types — see [`with_tls`](Consul::with_tls).
178pub use dynamic_config_store_core::tls::TlsConfig;
179
180/// How long to wait for Consul before giving up.
181const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
182
183/// How long a blocking query is allowed to hold the connection open.
184///
185/// Consul's own default is five minutes; this is shorter because it is also the
186/// worst case for noticing a stop, and five minutes of that is a long time to
187/// wait for a thread to go away. Consul's ceiling is ten minutes.
188const DEFAULT_WAIT: Duration = Duration::from_secs(60);
189
190/// How long to pause after a failed blocking query before trying again.
191///
192/// A restarting agent should not be met with a tight retry loop.
193const RETRY_AFTER: Duration = Duration::from_secs(5);
194
195/// What a source reads: one key, several named keys, or a subtree.
196///
197/// Every constructor takes one, and a bare `&str` or `String` is
198/// [`Keys::one`] — so the single-key spelling every caller already wrote keeps
199/// working unchanged.
200#[derive(Clone, Debug, PartialEq, Eq)]
201pub enum Keys {
202    /// One key, whose value is the whole document.
203    One(String),
204    /// Several named keys, merged **in the order given — later wins**.
205    ///
206    /// The rule a list of `.file(..)` calls already teaches: the caller wrote
207    /// the list, so the list is the precedence. One request per key, because
208    /// Consul's KV API has no batch read of a caller-chosen set.
209    ///
210    /// **Cannot be watched**, for that same reason: a blocking query blocks on
211    /// one key or one subtree, so watching a list would mean waking on one key
212    /// and then reading the rest — collecting the new value of one beside the
213    /// old value of another, a document that never existed.
214    Several(Vec<String>),
215    /// Every key under a prefix, merged as **disjoint sections**.
216    ///
217    /// A caller naming a prefix is not expressing an order — the order is
218    /// whatever the agent lists — so two keys under it supplying the same path
219    /// is a deployment bug, and reported as one rather than resolved. One
220    /// `?recurse` request, so the subtree is read at a single index.
221    ///
222    /// **Can be watched**, and needs no re-read to do it: a recursive blocking
223    /// query's answer *is* the subtree at the index it woke on. See
224    /// [`Consul::watch`].
225    Prefix(String),
226}
227
228impl Keys {
229    /// One key, whose value is the whole document.
230    #[must_use]
231    pub fn one(key: impl Into<String>) -> Self {
232        Self::One(key.into())
233    }
234
235    /// Several named keys, merged in the order given — later wins.
236    #[must_use]
237    pub fn several<I, S>(keys: I) -> Self
238    where
239        I: IntoIterator<Item = S>,
240        S: Into<String>,
241    {
242        Self::Several(keys.into_iter().map(Into::into).collect())
243    }
244
245    /// Every key under `prefix`, merged as disjoint sections.
246    #[must_use]
247    pub fn prefix(prefix: impl Into<String>) -> Self {
248        Self::Prefix(prefix.into())
249    }
250
251    /// The keys as a slice, for the diagnostics and the format inference.
252    ///
253    /// A prefix has none to list — the set is not known until the agent
254    /// answers.
255    fn named(&self) -> &[String] {
256        match self {
257            Self::One(key) => std::slice::from_ref(key),
258            Self::Several(keys) => keys,
259            Self::Prefix(_) => &[],
260        }
261    }
262
263    /// How a diagnostic names what this source reads.
264    fn describe(&self) -> String {
265        match self {
266            Self::One(key) => format!("kv/{key}"),
267            Self::Several(keys) => format!("keys {}", keys.join(", ")),
268            Self::Prefix(prefix) => format!("prefix {prefix}"),
269        }
270    }
271}
272
273impl From<&str> for Keys {
274    fn from(key: &str) -> Self {
275        Self::one(key)
276    }
277}
278
279impl From<String> for Keys {
280    fn from(key: String) -> Self {
281        Self::One(key)
282    }
283}
284
285impl From<&String> for Keys {
286    fn from(key: &String) -> Self {
287        Self::one(key)
288    }
289}
290
291/// A key in Consul's KV store, as a configuration source.
292///
293/// Not `Clone`: the session holds the current token, and two clones logging in
294/// separately would double the login traffic. Wrap it in an `Arc` if two places
295/// need one.
296pub struct Consul {
297    address: String,
298    keys: Keys,
299    format: Option<Format>,
300    /// Why the keys' own extensions could not settle the format between them.
301    ///
302    /// Kept rather than reported at construction because `new` cannot fail and
303    /// because `with_format` is allowed to settle it afterwards.
304    disagreement: Option<String>,
305    auth: Auth,
306    /// The token the current login produced, and when it needs another.
307    session: Cached<String>,
308    datacenter: Option<String>,
309    timeout: Duration,
310    wait: Duration,
311    agent: Option<ureq::Agent>,
312    /// What [`with_tls`](Consul::with_tls) was given, translated into an agent
313    /// per call.
314    ///
315    /// Not cached the way Vault's and Firestore's are: this crate already
316    /// builds an agent per call, because a blocking query needs a timeout the
317    /// ordinary read must not have.
318    tls: Option<TlsConfig>,
319    /// Where [`watch`](Consul::watch) reports a query that came back with
320    /// nothing; see [`reporting_to`](Consul::reporting_to). Nobody, by
321    /// default, which is what makes reporting free for a caller who never
322    /// asked for it.
323    attempts: Attempts,
324}
325
326impl Consul {
327    /// The key `keys`, served by the Consul agent at `address`.
328    ///
329    /// `keys` is a key — `"myapp/db.json"` — or a [`Keys`], for the several-keys
330    /// and prefix forms.
331    ///
332    /// The format is taken from the key's extension — `myapp/db.json` is JSON.
333    /// A key without one, and every prefix, needs
334    /// [`with_format`](Self::with_format).
335    pub fn new(address: impl Into<String>, keys: impl Into<Keys>) -> Self {
336        let keys = keys.into();
337
338        let (format, disagreement) = match documents::agreed_format(keys.named()) {
339            Ok(format) => (format, None),
340            Err(complaint) => (None, Some(complaint)),
341        };
342
343        Self {
344            address: address.into().trim_end_matches('/').to_owned(),
345            keys,
346            format,
347            disagreement,
348            auth: Auth::Anonymous,
349            session: Cached::new(),
350            datacenter: None,
351            timeout: DEFAULT_TIMEOUT,
352            wait: DEFAULT_WAIT,
353            agent: None,
354            tls: None,
355            attempts: Attempts::default(),
356        }
357    }
358
359    /// States the format, for a key whose name does not.
360    ///
361    /// Required for [`Keys::Prefix`] — a prefix has no extension — and it also
362    /// settles a list whose keys name two different formats.
363    #[must_use]
364    pub fn with_format(mut self, format: Format) -> Self {
365        self.format = Some(format);
366        // The caller has now said which format wins, so the keys no longer
367        // have to agree between themselves.
368        self.disagreement = None;
369        self
370    }
371
372    /// The format, or an error naming the call that supplies one.
373    fn format(&self) -> Result<Format, Error> {
374        if let Some(complaint) = &self.disagreement {
375            return Err(Error::remote(format!("{}: {complaint}", self.describe())));
376        }
377
378        self.format.ok_or_else(|| {
379            Error::remote(format!(
380                "{}: the key names no format; call `with_format`",
381                self.describe()
382            ))
383        })
384    }
385
386    /// What a blocking query should ask for, and whether it recurses — or an
387    /// error saying this source cannot be watched at all.
388    ///
389    /// One key and one prefix are both **one** blocking query, which is the
390    /// property that decides this: the query's answer is what the callback
391    /// gets, so there is no second read for a write to slip between. A named
392    /// list is not one query — Consul's KV API has no batch read of a
393    /// caller-chosen set — so watching it would mean blocking on one key and
394    /// then reading the others, which is exactly how a document that never
395    /// existed gets delivered.
396    fn watched(&self) -> Result<(&str, bool), Error> {
397        match &self.keys {
398            Keys::One(key) => Ok((key, false)),
399            Keys::Prefix(prefix) => Ok((prefix, true)),
400            Keys::Several(_) => Err(Error::remote(format!(
401                "{}: a source that reads a named list of keys cannot be \
402                 watched; Consul has no batch read, so the set would be \
403                 blocked on one key and then read key by key, which can \
404                 deliver a document that never existed — watch a prefix, or \
405                 poll `refresh_remote()` on a timer instead",
406                self.describe()
407            ))),
408        }
409    }
410
411    /// The ACL token to authenticate with.
412    ///
413    /// Shorthand for `with_auth(Auth::token(..))`. A token that stops working
414    /// cannot be replaced, because there are no credentials here to log in
415    /// again with; [`Auth::kubernetes`] and [`Auth::jwt`] can.
416    #[must_use]
417    pub fn with_token(self, token: impl Into<String>) -> Self {
418        self.with_auth(Auth::token(token))
419    }
420
421    /// How to obtain an ACL token.
422    ///
423    /// ```no_run
424    /// # use dynamic_config_consul::{Auth, Consul};
425    /// // In Kubernetes, with no secret to distribute at all.
426    /// let consul = Consul::new("http://consul:8500", "myapp/db.json")
427    ///     .with_auth(Auth::kubernetes("kubernetes"));
428    ///
429    /// // Or whatever the operator put in the environment.
430    /// let consul = Consul::new("http://consul:8500", "myapp/db.json")
431    ///     .with_auth(Auth::from_environment());
432    /// ```
433    ///
434    /// Logging in is lazy: this reaches nothing, and the first read does it.
435    #[must_use]
436    pub fn with_auth(mut self, auth: Auth) -> Self {
437        self.auth = auth;
438        self.session.invalidate();
439        self
440    }
441
442    /// Uses an HTTP client the program already has.
443    ///
444    /// For a caller with its own proxy settings, a private CA, a client
445    /// certificate, or a connection pool it would rather not have a second copy
446    /// of. The agent's own timeout applies instead of
447    /// [`with_timeout`](Self::with_timeout) — including for the long blocking
448    /// query [`watch`](Self::watch) issues, so an agent used for watching needs
449    /// a timeout above [`with_wait`](Self::with_wait).
450    ///
451    /// The escape hatch, and it stays one: [`with_tls`](Self::with_tls) covers
452    /// a private CA and a client certificate, and everything else — a proxy, a
453    /// connection pool, an option this crate has never heard of — still lives
454    /// here. Setting both is refused rather than resolved; see
455    /// [`with_tls`](Self::with_tls).
456    #[must_use]
457    pub fn with_agent(mut self, agent: ureq::Agent) -> Self {
458        self.agent = Some(agent);
459        self
460    }
461
462    /// A private certificate authority, a client certificate, or both.
463    ///
464    /// The same three settings, spelled the same way, in all seven store
465    /// crates — and spelled as *data*, so nothing here names a `ureq` type:
466    ///
467    /// ```no_run
468    /// # use dynamic_config_consul::{Consul, TlsConfig};
469    /// let consul = Consul::new("https://consul.internal:8501", "myapp/db.json")
470    ///     .with_tls(
471    ///         TlsConfig::new()
472    ///             .with_ca_certificate_file("/etc/consul.d/consul-agent-ca.pem")
473    ///             .with_client_certificate_files(
474    ///                 "/etc/consul.d/client.crt",
475    ///                 "/etc/consul.d/client.key",
476    ///             ),
477    ///     );
478    /// ```
479    ///
480    /// Consul expresses all of it: a CA from a file or from bytes, and a client
481    /// certificate from either. A CA replaces the platform trust store rather
482    /// than adding to it — naming a private authority is saying the public ones
483    /// do not apply to this host — so a deployment that needs both puts both in
484    /// the file. Consul's own agent CA is exactly this case: `consul tls ca
485    /// create` mints an authority no public store has heard of.
486    ///
487    /// There is no way to turn verification off; [`TlsConfig`]'s own
488    /// documentation argues that one.
489    ///
490    /// **Nothing is read here.** The files are opened when a request builds its
491    /// client, so a missing CA is an error naming the path rather than a panic
492    /// in a builder chain.
493    ///
494    /// # With `with_agent`
495    ///
496    /// Setting both is **refused**, at the first request, naming both calls.
497    /// An agent already carries a complete TLS configuration, so "apply this
498    /// too" has no meaning that is not a guess — and the guess that loses
499    /// silently discards a CA, which is the failure this whole surface exists
500    /// to prevent. Put the CA on the agent, or drop the agent.
501    #[must_use]
502    pub fn with_tls(mut self, tls: TlsConfig) -> Self {
503        self.tls = Some(tls);
504        self
505    }
506
507    /// The datacenter to read from, when it is not the agent's own.
508    #[must_use]
509    pub fn with_datacenter(mut self, datacenter: impl Into<String>) -> Self {
510        self.datacenter = Some(datacenter.into());
511        self
512    }
513
514    /// How long a single fetch may take before it is given up on. Ten seconds
515    /// by default.
516    ///
517    /// The deadline for **one fetch attempt**, excluding retries the
518    /// underlying client performs — the same sentence every store in this
519    /// family answers to. `ureq` performs none of its own, so here the
520    /// deadline is the whole story.
521    ///
522    /// The blocking query [`watch`](Self::watch) issues is the exception, and
523    /// deliberately so: it is one fetch that is *meant* to be held open, so
524    /// its client-side timeout is sized from [`with_wait`](Self::with_wait)
525    /// plus this value plus the jitter Consul adds.
526    #[must_use]
527    pub fn with_timeout(mut self, timeout: Duration) -> Self {
528        self.timeout = timeout;
529        self
530    }
531
532    /// How long a blocking query may hold the connection open, when
533    /// [`watch`](Self::watch) is used. One minute by default.
534    ///
535    /// This is also how long a stopped watch can take to notice, so it trades
536    /// one against the other: longer means fewer requests, and a slower exit.
537    /// Consul's own ceiling is ten minutes, so anything above it is clamped
538    /// there — the agent would cap it silently anyway, and this way the
539    /// client-side timeout stays sized to what the agent will actually do.
540    #[must_use]
541    pub fn with_wait(mut self, wait: Duration) -> Self {
542        /// Consul rejects (well: caps) waits over ten minutes.
543        const CEILING: Duration = Duration::from_secs(600);
544
545        self.wait = wait.min(CEILING);
546        self
547    }
548
549    /// Reports the watch loop's *failed* attempts to `sink`.
550    ///
551    /// A watch loop is the half of a store `dynamic-config` cannot otherwise
552    /// see. [`RemoteSink::apply`] records a delivery, so a working watch keeps
553    /// [`RemoteStatus`] current — but a loop whose blocking query is erroring,
554    /// whose key was deleted or whose ACL token was refused delivers nothing,
555    /// and without this says nothing: `dynamic_config_remote_up` would report
556    /// the last *delivery* rather than the last *attempt*, and an agent that
557    /// stopped answering an hour ago would look healthy until something called
558    /// `refresh_remote`.
559    ///
560    /// ```no_run
561    /// # use dynamic_config::{RemoteWatch, Watching};
562    /// # use dynamic_config_consul::Consul;
563    /// # struct DbConfig;
564    /// # impl DbConfig {
565    /// #     fn remote_sink() -> dynamic_config::RemoteSink { unimplemented!() }
566    /// # }
567    /// # fn example(address: &str, watching: Watching) -> Result<(), dynamic_config::Error> {
568    /// let sink = DbConfig::remote_sink();
569    ///
570    /// Consul::new(address, "myapp/db.json")
571    ///     .reporting_to(sink)
572    ///     .watch(&watching, move |document| sink.apply(document))
573    /// # }
574    /// ```
575    ///
576    /// One sink serves both halves, and it is taken **once, where the loop is
577    /// wired**: a sink is `Copy`, and the generation it captures there is what
578    /// fences a loop winding down after its source was replaced from charging
579    /// its failures to the replacement.
580    ///
581    /// A failure to report a failure never reaches the loop — reporting is
582    /// infallible and silent — and what it moves is deliberately narrow: the
583    /// failure streak and the last failure, never the fetch clock. So
584    /// `dynamic_config_remote_last_fetch_seconds` keeps ageing while
585    /// `dynamic_config_remote_up` goes to zero, which is the pair that says
586    /// both *the store is not answering* and *how stale what it last said has
587    /// become*.
588    ///
589    /// A [`fetch`](RemoteSource::fetch) needs none of this: a fetch records
590    /// itself, through the `Remote` that performed it.
591    ///
592    /// [`RemoteStatus`]: dynamic_config::RemoteStatus
593    #[must_use]
594    pub fn reporting_to(mut self, sink: RemoteSink) -> Self {
595        self.attempts = Attempts::to(sink);
596        self
597    }
598
599    /// Calls `on_change` whenever what this source reads changes.
600    ///
601    /// Uses Consul's blocking queries: each request carries the index the last
602    /// one returned, and the agent holds it open until that index moves or
603    /// [`with_wait`](Self::with_wait) expires. So this is change-driven, not a
604    /// poll — the callback runs when the value actually moves.
605    ///
606    /// **One key or a prefix.** A prefix watch is the cheapest correct watch on
607    /// a set anywhere in this family, because it needs no re-read at all: a
608    /// recursive blocking query's *answer is the subtree at one index*, so the
609    /// document handed to `on_change` is folded from the very bytes the agent
610    /// blocked to send. There is no window between "the set changed" and
611    /// "read the set" for a second write to land in. A **named list** is
612    /// refused; the reason is on [`Keys::Several`].
613    ///
614    /// The current value is **not** delivered at startup, for the same reason a
615    /// file watcher does not report an edit when it starts. Fetch first if the
616    /// starting value matters, which it usually does:
617    ///
618    /// ```no_run
619    /// # use dynamic_config::{RemoteSource, RemoteWatch};
620    /// # use dynamic_config_consul::Consul;
621    /// # struct Sink;
622    /// # impl Sink {
623    /// #     fn apply(&self, _: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
624    /// # }
625    /// # fn example(consul: Consul, watching: dynamic_config::Watching) -> Result<(), dynamic_config::Error> {
626    /// # let sink = Sink;
627    /// sink.apply(consul.fetch()?)?;
628    /// consul.watch(&watching, move |document| sink.apply(document))
629    /// # }
630    /// ```
631    ///
632    /// A failed query does not end the watch: the agent restarting, a network
633    /// blip, or a key that does not exist *yet* are all exactly what a watch is
634    /// supposed to survive. It pauses briefly and tries again, and gives up only
635    /// when `watching` says to. A document identical to the last one is not
636    /// reported — Consul bumps the index on every write, including one that
637    /// changed nothing.
638    ///
639    /// Surviving a failure quietly is not the same as hiding it:
640    /// [`reporting_to`](Self::reporting_to) hands each failed attempt to a
641    /// [`RemoteSink`], so a loop that has been erroring for an hour stops
642    /// reporting the store as healthy.
643    ///
644    /// # Errors
645    ///
646    /// If `on_change` returns an error, which ends the watch — so a caller that
647    /// wants to survive a bad document should log it and return `Ok`. Transport
648    /// failures do not surface here; they are retried.
649    ///
650    /// Under a prefix, also if the subtree cannot be folded into a document:
651    /// two keys supplying the same path, a key the agent answered with that is
652    /// not under the prefix, or more keys than the budget. None of those is a
653    /// blip a retry cures, and retrying them forever with nothing said would
654    /// leave the configuration frozen and silent — the failure this crate's
655    /// watch loops are shaped to avoid.
656    pub fn watch<F>(&self, watching: &Watching, mut on_change: F) -> Result<(), Error>
657    where
658        F: FnMut(Fetched) -> Result<(), Error>,
659    {
660        // Returned and recorded nowhere, all three of them: nothing has been
661        // asked of the agent yet, and `reachable()` is *whether the store
662        // answered the last time it was asked*. See the table in this crate's
663        // documentation for where that line falls.
664        let format = self.format()?;
665        // Refused up front, so a source that cannot be watched fails at
666        // `watch` rather than on the first change, hours later.
667        let (watched, recurse) = self.watched()?;
668
669        // A blocking query must be allowed to outlast its own wait plus the
670        // jitter Consul adds — up to a sixteenth of it — or every query would
671        // end as a client timeout instead of an answer. An eighth, with the
672        // ordinary timeout on top, leaves room for a slow answer as well.
673        // Saturating: both terms are caller input, and a caller who says
674        // `Duration::MAX` deserves a very long timeout, not a panic.
675        let agent = self.agent(
676            self.wait
677                .saturating_add(self.wait / 8)
678                .saturating_add(self.timeout),
679        )?;
680
681        let mut index = 0;
682        let mut last: Option<String> = None;
683        // The first query carries index 0, which Consul answers immediately
684        // with whatever is stored. That is the value the caller already has —
685        // it primes the index and the comparison, and reports nothing, the same
686        // way a file watcher does not announce an edit when it starts.
687        let mut priming = true;
688
689        while watching.keep_going() {
690            let answered = match self.blocking_read(&agent, watched, recurse, index) {
691                Ok(answered) => answered,
692                Err(error) => {
693                    // Retried rather than reported *to the caller*: the loop's
694                    // whole job is to survive the store going away for a
695                    // while. Recorded all the same, because surviving it
696                    // silently is how an agent that stopped answering an hour
697                    // ago goes on looking healthy. Sleeping in slices keeps a
698                    // stop from waiting out the whole pause.
699                    self.attempts.failed(&error);
700
701                    watching.sleep_for(RETRY_AFTER);
702                    continue;
703                }
704            };
705
706            // Consul resets its index on a restart or a key being recreated; a
707            // stale one would then park the query forever.
708            index = if answered.index < index {
709                0
710            } else {
711                answered.index
712            };
713
714            // The fold happens *here* rather than inside the read, because the
715            // two failures must be told apart: a read that failed is retried,
716            // and a subtree that cannot become a document is reported. The
717            // second is a deployment bug, and a loop that retried it would be
718            // a configuration that stopped updating with nothing said.
719            let folded = self
720                .watched_document(&answered.entries, recurse, format)
721                // A subtree that cannot become a document ends the watch, so
722                // this is the last thing that will ever be recorded about this
723                // store — which is exactly why it is recorded. A watch that
724                // stopped is a configuration that has stopped updating.
725                .inspect_err(|error| self.attempts.failed(error))?;
726
727            let Some(text) = folded else {
728                // The key holds nothing, or the subtree is empty. Consul will
729                // still block on the next query, but only while its index is
730                // meaningful — a pause here is what stops a degenerate index
731                // from becoming a hot loop.
732                //
733                // Reported as a failed attempt, because that is what a fetch
734                // of the same key would record: `fetch` calls an empty answer
735                // a failure and leaves the last document serving, and a watch
736                // that called it health would have a deleted key read as a
737                // healthy store for as long as nobody recreated it. The
738                // message is built only when somebody is listening.
739                if self.attempts.is_reporting() {
740                    self.attempts.failed(&Error::remote(format!(
741                        "{}: the watched key holds no value",
742                        self.describe()
743                    )));
744                }
745
746                watching.sleep_for(RETRY_AFTER);
747
748                continue;
749            };
750
751            let unchanged = last.as_ref() == Some(&text);
752
753            last = Some(text.clone());
754
755            if std::mem::take(&mut priming) || unchanged {
756                continue;
757            }
758
759            // Deliberately not reported as a failed attempt: the agent answered
760            // and the document arrived. What the callback then did with it —
761            // `apply`, a reload, a validation that refused — is
762            // `ConfigStatus`'s business, and `RemoteSink::apply` already
763            // records it there. Charging it to the store would make a bad
764            // document read as an unreachable agent.
765            guarded(&mut on_change, Fetched::new(text, format), &self.describe())?;
766        }
767
768        Ok(())
769    }
770
771    /// The HTTP client: the caller's if they supplied one, otherwise ours.
772    ///
773    /// # Errors
774    ///
775    /// If both `with_agent` and `with_tls` were called, or if the TLS material
776    /// cannot be read or parsed.
777    fn agent(&self, timeout: Duration) -> Result<ureq::Agent, Error> {
778        if let Some(agent) = &self.agent {
779            // Refused rather than resolved: an agent is already a complete TLS
780            // configuration, so applying a second one on top would mean
781            // silently dropping one of them, and the one that would be dropped
782            // is a CA the caller believes is pinned.
783            if self.tls.is_some() {
784                return Err(Error::remote(format!(
785                    "{}: `with_agent` and `with_tls` were both called; \
786                     an agent already carries its own TLS configuration, so \
787                     this is refused rather than resolved — put the certificate \
788                     authority on the agent, or drop the agent",
789                    self.describe()
790                )));
791            }
792
793            return Ok(agent.clone());
794        }
795
796        match &self.tls {
797            Some(tls) => tls::agent(tls, timeout, &self.describe()),
798            None => Ok(ureq::Agent::config_builder()
799                .timeout_global(Some(timeout))
800                .build()
801                .new_agent()),
802        }
803    }
804
805    /// The token to present, logging in if it is time.
806    ///
807    /// `Ok(None)` when there is nothing to present, which is the right answer
808    /// for a Consul with ACLs disabled.
809    fn token(&self) -> Result<Option<String>, Error> {
810        match &self.auth {
811            Auth::Anonymous => Ok(None),
812            Auth::Token(supplied) => Ok(Some(supplied.clone())),
813            // The previous token is ignored: Consul does not extend a
814            // token, it issues another.
815            Auth::Login { .. } => self.session.get(|_previous| self.login()).map(Some),
816        }
817    }
818
819    /// Exchanges a bearer token for an ACL token.
820    fn login(&self) -> Result<Issued<String>, Error> {
821        let Some(body) = self.auth.login_body()? else {
822            // Unreachable: `token()` handles the other variants before this.
823            return Err(Error::remote(format!(
824                "{}: {} needs no login",
825                self.describe(),
826                self.auth.describe()
827            )));
828        };
829
830        let url = format!("{}/v1/acl/login", self.address);
831
832        let response: serde_json::Value = self
833            .agent(self.timeout)?
834            .post(&url)
835            .send_json(&body)
836            .map_err(|error| {
837                let described = format!(
838                    "{}: logging in with {} failed: {error}",
839                    self.describe(),
840                    self.auth.describe()
841                );
842
843                // On the *login* endpoint the request shape is ours and
844                // correct, so a 403 is the agent rejecting the bearer token —
845                // a token that could not be obtained, which waiting does not
846                // fix. Everything else may yet come good and stays `Remote`.
847                match error {
848                    ureq::Error::StatusCode(403) => Error::auth(described),
849                    _ => Error::remote(described),
850                }
851            })?
852            .body_mut()
853            .read_json()
854            .map_err(|error| {
855                Error::remote(format!(
856                    "{}: the login response was not JSON: {error}",
857                    self.describe()
858                ))
859            })?;
860
861        let secret = response
862            .get("SecretID")
863            .and_then(serde_json::Value::as_str)
864            .ok_or_else(|| {
865                Error::remote(format!(
866                    "{}: the login response has no `SecretID`",
867                    self.describe()
868                ))
869            })?
870            .to_owned();
871
872        // Consul reports the expiry as a duration in nanoseconds, and omits it
873        // for a token the auth method did not put one on.
874        let ttl = response
875            .get("ExpirationTTL")
876            .and_then(serde_json::Value::as_u64)
877            .filter(|nanos| *nanos > 0)
878            .map(Duration::from_nanos);
879
880        Ok(Issued { value: secret, ttl })
881    }
882
883    /// Adds the ACL token to a request, if there is one.
884    fn authenticated(
885        &self,
886        request: ureq::RequestBuilder<ureq::typestate::WithoutBody>,
887    ) -> Result<ureq::RequestBuilder<ureq::typestate::WithoutBody>, Error> {
888        match self.token()? {
889            Some(token) => Ok(request.header("X-Consul-Token", &token)),
890            None => Ok(request),
891        }
892    }
893
894    /// One blocking query on what this source watches — a key, or a subtree
895    /// with `recurse`. `index` of zero returns immediately.
896    ///
897    /// It returns the agent's answer undecoded. That is the whole reason a
898    /// prefix watch can be honest here: **this response *is* the subtree at one
899    /// index**, so nothing is read back afterwards and there is no window for a
900    /// second write to land in.
901    fn blocking_read(
902        &self,
903        agent: &ureq::Agent,
904        key: &str,
905        recurse: bool,
906        index: u64,
907    ) -> Result<Answered, Error> {
908        let mut url = self.url(key, recurse);
909
910        url.push(if url.contains('?') { '&' } else { '?' });
911        url.push_str(&format!(
912            "index={index}&wait={}s",
913            self.wait.as_secs().max(1)
914        ));
915
916        let mut response = self.get(agent, &url)?;
917
918        let index = response
919            .headers()
920            .get("X-Consul-Index")
921            .and_then(|value| value.to_str().ok())
922            .and_then(|value| value.parse().ok())
923            .unwrap_or(index);
924
925        let entries: Vec<serde_json::Value> = response.body_mut().read_json().map_err(|error| {
926            Error::remote(format!(
927                "{}: the response was not JSON: {error}",
928                self.describe()
929            ))
930        })?;
931
932        Ok(Answered { index, entries })
933    }
934
935    /// The document one blocking query's answer folds into, if it holds one.
936    ///
937    /// `Ok(None)` is *no configuration* — a key holding nothing, or a subtree
938    /// with nothing under it — which the loop waits through rather than
939    /// reporting, because no configuration is not a configuration.
940    ///
941    /// The two shapes differ in what an error means, and that is deliberate:
942    ///
943    /// - **One key.** Every failure here is dominated by the deleted-key case,
944    ///   which is the thing the loop exists to wait through, so it becomes
945    ///   `None` exactly as it always has.
946    /// - **A prefix.** The failures are an overlap between two sections, a key
947    ///   the agent answered with that is not under the prefix, and a subtree
948    ///   over the budget. No retry cures any of them, and a silent retry loop
949    ///   is the failure mode this crate's watches are shaped to avoid — so
950    ///   they are reported and the watch ends.
951    fn watched_document(
952        &self,
953        entries: &[serde_json::Value],
954        recurse: bool,
955        format: Format,
956    ) -> Result<Option<String>, Error> {
957        if !recurse {
958            return Ok(self
959                .pairs_of(entries, self.keys.named().first().map(String::as_str))
960                .ok()
961                .and_then(|mut pairs| pairs.pop())
962                .map(|(_, text)| text));
963        }
964
965        documents::within_key_budget(entries.len(), &self.describe())?;
966
967        let pairs = self.pairs_of(entries, None)?;
968
969        if pairs.is_empty() {
970            return Ok(None);
971        }
972
973        // `Overlap::Refused`, the same rule `fetch` applies: a prefix is a set
974        // of disjoint sections, and the order the agent lists them in is
975        // nobody's precedence.
976        Ok(Some(
977            documents::merged(&pairs, format, Overlap::Refused, &self.describe())?.text,
978        ))
979    }
980
981    /// One authenticated GET, with the one-fresh-login retry every read gets.
982    fn get(
983        &self,
984        agent: &ureq::Agent,
985        url: &str,
986    ) -> Result<ureq::http::Response<ureq::Body>, Error> {
987        match self.call(agent.get(url)) {
988            Err(CallError::Forbidden(_)) if self.can_relogin() => {
989                // The token stopped working. One fresh login and one retry, not
990                // a loop: if a new token is also refused, the policy is wrong
991                // and retrying would turn a clear failure into a hang.
992                self.session.invalidate();
993
994                self.call(agent.get(url)).map_err(CallError::into_error)
995            }
996            outcome => outcome.map_err(CallError::into_error),
997        }
998    }
999
1000    /// The `(key, document)` pairs this source reads, in merge order.
1001    ///
1002    /// A named list is one request per key and **every one of them must
1003    /// answer**: merging the four that did would leave a process running a
1004    /// configuration with a section quietly missing from it.
1005    fn documents(&self, agent: &ureq::Agent) -> Result<Vec<(String, String)>, Error> {
1006        match &self.keys {
1007            Keys::One(key) => self.read(agent, key, false),
1008            Keys::Several(keys) => {
1009                let mut documents = Vec::with_capacity(keys.len());
1010
1011                for key in keys {
1012                    documents.extend(self.read(agent, key, false)?);
1013                }
1014
1015                Ok(documents)
1016            }
1017            // `?recurse`: one request for the whole subtree, at one index.
1018            Keys::Prefix(prefix) => self.read(agent, prefix, true),
1019        }
1020    }
1021
1022    /// One request, and the pairs it answered with.
1023    fn read(
1024        &self,
1025        agent: &ureq::Agent,
1026        key: &str,
1027        recurse: bool,
1028    ) -> Result<Vec<(String, String)>, Error> {
1029        let mut response = self.get(agent, &self.url(key, recurse))?;
1030
1031        let entries: Vec<serde_json::Value> = response.body_mut().read_json().map_err(|error| {
1032            Error::remote(format!(
1033                "{}: the response was not JSON: {error}",
1034                self.describe()
1035            ))
1036        })?;
1037
1038        if recurse {
1039            // Checked before anything is decoded: the whole subtree is already
1040            // in memory by now — `?recurse` is one response — but base64
1041            // decoding and parsing every entry on top of it is the part worth
1042            // not doing.
1043            documents::within_key_budget(entries.len(), &self.describe())?;
1044        }
1045
1046        self.pairs_of(&entries, (!recurse).then_some(key))
1047    }
1048
1049    /// Sends a request with the current token.
1050    fn call(
1051        &self,
1052        request: ureq::RequestBuilder<ureq::typestate::WithoutBody>,
1053    ) -> Result<ureq::http::Response<ureq::Body>, CallError> {
1054        self.authenticated(request)
1055            .map_err(CallError::Other)?
1056            .call()
1057            .map_err(|error| {
1058                // The message is the same either way; only the kind differs,
1059                // and only the typed status decides it. `{error}` here is
1060                // `ureq`'s own rendering of the status — never the request,
1061                // so the `X-Consul-Token` header cannot ride along.
1062                let described = format!("{}: {error}", self.describe());
1063
1064                match error {
1065                    ureq::Error::StatusCode(403) => CallError::Forbidden(Error::auth(described)),
1066                    _ => CallError::Other(Error::remote(described)),
1067                }
1068            })
1069    }
1070
1071    /// Whether a refused token can be traded for a fresh one.
1072    ///
1073    /// Only a login can: `Auth::Token` was handed in from outside, and
1074    /// invalidating it would just retry the identical string — one wasted
1075    /// request per read against a broken ACL.
1076    fn can_relogin(&self) -> bool {
1077        matches!(self.auth, Auth::Login { .. })
1078    }
1079
1080    /// Turns Consul's array of entries into `(key, document)` pairs.
1081    ///
1082    /// `expected` is the key that was asked for, when a single key was: Consul
1083    /// answers a single-key read with a one-element array, and an empty one
1084    /// means the key is not there — a missing configuration rather than a
1085    /// transport failure, but still nothing to load, and still a whole fetch
1086    /// that fails.
1087    fn pairs_of(
1088        &self,
1089        entries: &[serde_json::Value],
1090        expected: Option<&str>,
1091    ) -> Result<Vec<(String, String)>, Error> {
1092        if let Some(key) = expected {
1093            if entries.is_empty() {
1094                return Err(Error::remote(format!(
1095                    "{}: `{key}` holds no value",
1096                    self.describe()
1097                )));
1098            }
1099        }
1100
1101        let mut pairs = Vec::with_capacity(entries.len());
1102
1103        for entry in entries {
1104            let key = entry
1105                .get("Key")
1106                .and_then(serde_json::Value::as_str)
1107                .or(expected)
1108                .unwrap_or_default()
1109                .to_owned();
1110
1111            if let Keys::Prefix(prefix) = &self.keys {
1112                // Trimmed on both sides of the comparison: the URL drops a
1113                // leading slash and Consul answers without one, so a caller who
1114                // wrote `/myapp/` must not be told their own keys escaped it.
1115                documents::under_prefix(&key, prefix.trim_start_matches('/'), &self.describe())?;
1116
1117                // Consul's own spelling of a folder: a key ending in `/` with
1118                // no value, which the UI creates when somebody makes a
1119                // directory. It is not a missing document; it is not a
1120                // document.
1121                if key.ends_with('/') {
1122                    continue;
1123                }
1124            }
1125
1126            let encoded = entry
1127                .get("Value")
1128                .and_then(serde_json::Value::as_str)
1129                .ok_or_else(|| {
1130                    Error::remote(format!("{}: `{key}` holds no value", self.describe()))
1131                })?;
1132
1133            let decoded = base64::engine::general_purpose::STANDARD
1134                .decode(encoded)
1135                .map_err(|error| {
1136                    Error::remote(format!(
1137                        "{}: `{key}` is not valid base64: {error}",
1138                        self.describe()
1139                    ))
1140                })?;
1141
1142            let text = String::from_utf8(decoded).map_err(|error| {
1143                Error::remote(format!(
1144                    "{}: `{key}` is not UTF-8: {error}",
1145                    self.describe()
1146                ))
1147            })?;
1148
1149            pairs.push((key, text));
1150        }
1151
1152        Ok(pairs)
1153    }
1154
1155    /// What two of this source's keys supplying one path means.
1156    ///
1157    /// The distinction the feature turns on: a caller who wrote the list wrote
1158    /// the precedence with it, and a caller who wrote a prefix wrote no order
1159    /// at all — so the first merges and the second refuses.
1160    fn overlap(&self) -> Overlap {
1161        match self.keys {
1162            Keys::One(_) | Keys::Several(_) => Overlap::LaterWins,
1163            Keys::Prefix(_) => Overlap::Refused,
1164        }
1165    }
1166
1167    fn url(&self, key: &str, recurse: bool) -> String {
1168        let mut url = format!("{}/v1/kv/{}", self.address, key.trim_start_matches('/'));
1169
1170        if recurse {
1171            url.push_str("?recurse=true");
1172        }
1173
1174        if let Some(datacenter) = &self.datacenter {
1175            url.push(if url.contains('?') { '&' } else { '?' });
1176            url.push_str("dc=");
1177            url.push_str(datacenter);
1178        }
1179
1180        url
1181    }
1182}
1183
1184/// A failed call, sorted by what a caller can do about it.
1185///
1186/// The sorting happens on `ureq`'s *typed* error, before anything becomes a
1187/// string. The old string test — does the message contain `"403"`? — read
1188/// true for any error mentioning a key like `myapp/403.json`, and a retry
1189/// decision should not depend on what somebody named their key.
1190enum CallError {
1191    /// The agent said 403: the token is the problem, and a fresh login might
1192    /// be the cure. Carries an [`ErrorKind::Auth`] error, because if the
1193    /// fresh login is refused too then waiting will not help — which is the
1194    /// one thing a watch loop needs to know.
1195    ///
1196    /// [`ErrorKind::Auth`]: dynamic_config::ErrorKind::Auth
1197    Forbidden(Error),
1198    /// Everything else — network, timeouts, 500s. A new token fixes none of
1199    /// it, and any of it may fix itself, so it stays `Remote`. Consul answers
1200    /// an ACL refusal with 403 and nothing else, so there is no 401 arm to
1201    /// write here; a 401 in front of a Consul is a proxy, and a proxy's
1202    /// verdict is not the store's.
1203    Other(Error),
1204}
1205
1206impl CallError {
1207    fn into_error(self) -> Error {
1208        match self {
1209            Self::Forbidden(error) | Self::Other(error) => error,
1210        }
1211    }
1212}
1213
1214/// What one blocking query came back with.
1215struct Answered {
1216    index: u64,
1217    /// The agent's entries, undecoded.
1218    ///
1219    /// Kept in this shape on purpose: for a `?recurse` query these *are* the
1220    /// subtree as of `index`, so folding them is the whole of the watch's
1221    /// re-read. Decoding them here would put the two failures a watch has to
1222    /// tell apart — a query that failed, and an answer that is not a
1223    /// configuration — behind one `Result`.
1224    entries: Vec<serde_json::Value>,
1225}
1226
1227// Hand-written, never derived: a derive would print every field, and the
1228// fields include credentials. `{:?}` reaching a log is an ordinary accident —
1229// a `dbg!`, a `tracing::debug!(?source)` — and an accident must not disclose
1230// a secret. The other store crates follow the same rule.
1231impl std::fmt::Debug for Consul {
1232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1233        f.debug_struct("Consul")
1234            .field("address", &self.address)
1235            .field("keys", &self.keys)
1236            .field("format", &self.format)
1237            .field("datacenter", &self.datacenter)
1238            .field("auth", &self.auth)
1239            .finish_non_exhaustive()
1240    }
1241}
1242
1243impl RemoteSource for Consul {
1244    fn fetch(&self) -> Result<Fetched, Error> {
1245        let format = self.format()?;
1246
1247        let documents = self.documents(&self.agent(self.timeout)?)?;
1248
1249        // Consul lists a subtree in key order and a named list is read in call
1250        // order, which is exactly the order each rule wants — so nothing is
1251        // sorted here.
1252        documents::merged(&documents, format, self.overlap(), &self.describe())
1253    }
1254
1255    fn describe(&self) -> String {
1256        // The address too: "the key holds no value" helps nobody who has a
1257        // staging Consul and a production Consul and a wrong environment
1258        // variable.
1259        format!("consul {} {}", self.address, self.keys.describe())
1260    }
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265    use super::*;
1266
1267    #[test]
1268    fn debug_never_prints_a_credential() {
1269        let source = Consul::new("http://consul:8500", "myapp/db.json")
1270            .with_auth(Auth::token("hunter2-consul-token"));
1271
1272        let printed = format!("{source:?} {:?}", Auth::token("hunter2-consul-token"));
1273
1274        assert!(!printed.contains("hunter2"), "{printed}");
1275        assert!(printed.contains("Token(***)"), "{printed}");
1276    }
1277}