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}