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//! # Watching
37//!
38//! Consul cannot push, but it can hold a request open until something changes —
39//! a *blocking query*. [`Consul::watch`] is that loop, and it is genuinely
40//! change-driven rather than a poll with extra steps: the agent answers the
41//! moment the key moves.
42//!
43//! It blocks, so it belongs on a thread, and a thread cannot be cancelled from
44//! outside — hence the [`Watching`] token.
45//!
46//! ```no_run
47//! # use dynamic_config::RemoteWatch;
48//! # use dynamic_config_consul::Consul;
49//! # struct DbConfig;
50//! # impl DbConfig {
51//! # fn apply_remote(_: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
52//! # }
53//! # fn example(consul: Consul) {
54//! let watch = RemoteWatch::new();
55//! let watching = watch.watching();
56//!
57//! std::thread::spawn(move || consul.watch(&watching, DbConfig::apply_remote));
58//!
59//! // Dropping `watch` — or calling `watch.stop()` — ends the loop.
60//! # }
61//! ```
62//!
63//! [`dynamic-config`]: https://docs.rs/dynamic-config
64//! [`dynamic-config-vault`]: https://docs.rs/dynamic-config-vault
65
66#![forbid(unsafe_code)]
67#![deny(missing_docs)]
68
69use std::time::Duration;
70
71use base64::Engine;
72use dynamic_config::{Error, Fetched, Format, RemoteSource, Watching};
73
74pub mod auth;
75
76pub use auth::{Auth, Bearer};
77use auth::{Session, Token};
78
79/// How long to wait for Consul before giving up.
80const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
81
82/// How long a blocking query is allowed to hold the connection open.
83///
84/// Consul's own default is five minutes; this is shorter because it is also the
85/// worst case for noticing a stop, and five minutes of that is a long time to
86/// wait for a thread to go away. Consul's ceiling is ten minutes.
87const DEFAULT_WAIT: Duration = Duration::from_secs(60);
88
89/// How long to pause after a failed blocking query before trying again.
90///
91/// A restarting agent should not be met with a tight retry loop.
92const RETRY_AFTER: Duration = Duration::from_secs(5);
93
94/// A key in Consul's KV store, as a configuration source.
95///
96/// Not `Clone`: the session holds the current token, and two clones logging in
97/// separately would double the login traffic. Wrap it in an `Arc` if two places
98/// need one.
99pub struct Consul {
100 address: String,
101 key: String,
102 format: Option<Format>,
103 auth: Auth,
104 session: Session,
105 datacenter: Option<String>,
106 timeout: Duration,
107 wait: Duration,
108 agent: Option<ureq::Agent>,
109}
110
111impl Consul {
112 /// The key `key`, served by the Consul agent at `address`.
113 ///
114 /// The format is taken from the key's extension — `myapp/db.json` is JSON.
115 /// A key without one needs [`with_format`](Self::with_format).
116 pub fn new(address: impl Into<String>, key: impl Into<String>) -> Self {
117 let key = key.into();
118
119 let format = Format::from_key(&key);
120
121 Self {
122 address: address.into().trim_end_matches('/').to_owned(),
123 key,
124 format,
125 auth: Auth::Anonymous,
126 session: Session::new(),
127 datacenter: None,
128 timeout: DEFAULT_TIMEOUT,
129 wait: DEFAULT_WAIT,
130 agent: None,
131 }
132 }
133
134 /// States the format, for a key whose name does not.
135 #[must_use]
136 pub fn with_format(mut self, format: Format) -> Self {
137 self.format = Some(format);
138 self
139 }
140
141 /// The ACL token to authenticate with.
142 ///
143 /// Shorthand for `with_auth(Auth::token(..))`. A token that stops working
144 /// cannot be replaced, because there are no credentials here to log in
145 /// again with; [`Auth::kubernetes`] and [`Auth::jwt`] can.
146 #[must_use]
147 pub fn with_token(self, token: impl Into<String>) -> Self {
148 self.with_auth(Auth::token(token))
149 }
150
151 /// How to obtain an ACL token.
152 ///
153 /// ```no_run
154 /// # use dynamic_config_consul::{Auth, Consul};
155 /// // In Kubernetes, with no secret to distribute at all.
156 /// let consul = Consul::new("http://consul:8500", "myapp/db.json")
157 /// .with_auth(Auth::kubernetes("kubernetes"));
158 ///
159 /// // Or whatever the operator put in the environment.
160 /// let consul = Consul::new("http://consul:8500", "myapp/db.json")
161 /// .with_auth(Auth::from_environment());
162 /// ```
163 ///
164 /// Logging in is lazy: this reaches nothing, and the first read does it.
165 #[must_use]
166 pub fn with_auth(mut self, auth: Auth) -> Self {
167 self.auth = auth;
168 self.session.invalidate();
169 self
170 }
171
172 /// Uses an HTTP client the program already has.
173 ///
174 /// For a caller with its own proxy settings, a private CA, a client
175 /// certificate, or a connection pool it would rather not have a second copy
176 /// of. The agent's own timeout applies instead of
177 /// [`with_timeout`](Self::with_timeout) — including for the long blocking
178 /// query [`watch`](Self::watch) issues, so an agent used for watching needs
179 /// a timeout above [`with_wait`](Self::with_wait).
180 #[must_use]
181 pub fn with_agent(mut self, agent: ureq::Agent) -> Self {
182 self.agent = Some(agent);
183 self
184 }
185
186 /// The datacenter to read from, when it is not the agent's own.
187 #[must_use]
188 pub fn with_datacenter(mut self, datacenter: impl Into<String>) -> Self {
189 self.datacenter = Some(datacenter.into());
190 self
191 }
192
193 /// How long to wait before giving up. Ten seconds by default.
194 #[must_use]
195 pub fn with_timeout(mut self, timeout: Duration) -> Self {
196 self.timeout = timeout;
197 self
198 }
199
200 /// How long a blocking query may hold the connection open, when
201 /// [`watch`](Self::watch) is used. One minute by default.
202 ///
203 /// This is also how long a stopped watch can take to notice, so it trades
204 /// one against the other: longer means fewer requests, and a slower exit.
205 /// Consul's own ceiling is ten minutes, so anything above it is clamped
206 /// there — the agent would cap it silently anyway, and this way the
207 /// client-side timeout stays sized to what the agent will actually do.
208 #[must_use]
209 pub fn with_wait(mut self, wait: Duration) -> Self {
210 /// Consul rejects (well: caps) waits over ten minutes.
211 const CEILING: Duration = Duration::from_secs(600);
212
213 self.wait = wait.min(CEILING);
214 self
215 }
216
217 /// Calls `on_change` whenever the key's value changes.
218 ///
219 /// Uses Consul's blocking queries: each request carries the index the last
220 /// one returned, and the agent holds it open until that index moves or
221 /// [`with_wait`](Self::with_wait) expires. So this is change-driven, not a
222 /// poll — the callback runs when the value actually moves.
223 ///
224 /// The current value is **not** delivered at startup, for the same reason a
225 /// file watcher does not report an edit when it starts. Fetch first if the
226 /// starting value matters, which it usually does:
227 ///
228 /// ```no_run
229 /// # use dynamic_config::{RemoteSource, RemoteWatch};
230 /// # use dynamic_config_consul::Consul;
231 /// # struct DbConfig;
232 /// # impl DbConfig {
233 /// # fn apply_remote(_: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
234 /// # }
235 /// # fn example(consul: Consul, watching: dynamic_config::Watching) -> Result<(), dynamic_config::Error> {
236 /// DbConfig::apply_remote(consul.fetch()?)?;
237 /// consul.watch(&watching, DbConfig::apply_remote)
238 /// # }
239 /// ```
240 ///
241 /// A failed query does not end the watch: the agent restarting, a network
242 /// blip, or a key that does not exist *yet* are all exactly what a watch is
243 /// supposed to survive. It pauses briefly and tries again, and gives up only
244 /// when `watching` says to. A document identical to the last one is not
245 /// reported — Consul bumps the index on every write, including one that
246 /// changed nothing.
247 ///
248 /// # Errors
249 ///
250 /// If `on_change` returns an error, which ends the watch — so a caller that
251 /// wants to survive a bad document should log it and return `Ok`. Transport
252 /// failures do not surface here; they are retried.
253 pub fn watch<F>(&self, watching: &Watching, mut on_change: F) -> Result<(), Error>
254 where
255 F: FnMut(Fetched) -> Result<(), Error>,
256 {
257 let format = self.format.ok_or_else(|| {
258 Error::remote(format!(
259 "{}: the key names no format; call `with_format`",
260 self.describe()
261 ))
262 })?;
263
264 // A blocking query must be allowed to outlast its own wait plus the
265 // jitter Consul adds — up to a sixteenth of it — or every query would
266 // end as a client timeout instead of an answer. An eighth, with the
267 // ordinary timeout on top, leaves room for a slow answer as well.
268 // Saturating: both terms are caller input, and a caller who says
269 // `Duration::MAX` deserves a very long timeout, not a panic.
270 let agent = self.agent(
271 self.wait
272 .saturating_add(self.wait / 8)
273 .saturating_add(self.timeout),
274 );
275
276 let mut index = 0;
277 let mut last: Option<String> = None;
278 // The first query carries index 0, which Consul answers immediately
279 // with whatever is stored. That is the value the caller already has —
280 // it primes the index and the comparison, and reports nothing, the same
281 // way a file watcher does not announce an edit when it starts.
282 let mut priming = true;
283
284 while watching.keep_going() {
285 let answered = match self.blocking_read(&agent, index) {
286 Ok(answered) => answered,
287 Err(_) => {
288 // Retried rather than reported: the loop's whole job is to
289 // survive the store going away for a while. Sleeping in
290 // slices keeps a stop from waiting out the whole pause.
291 watching.sleep_for(RETRY_AFTER);
292 continue;
293 }
294 };
295
296 // Consul resets its index on a restart or a key being recreated; a
297 // stale one would then park the query forever.
298 index = if answered.index < index {
299 0
300 } else {
301 answered.index
302 };
303
304 let Some(text) = answered.text else {
305 // The key holds nothing. Consul will still block on the next
306 // query, but only while its index is meaningful — a pause here
307 // is what stops a degenerate index from becoming a hot loop.
308 watching.sleep_for(RETRY_AFTER);
309
310 continue;
311 };
312
313 let unchanged = last.as_ref() == Some(&text);
314
315 last = Some(text.clone());
316
317 if std::mem::take(&mut priming) || unchanged {
318 continue;
319 }
320
321 guarded(&mut on_change, Fetched::new(text, format), &self.describe())?;
322 }
323
324 Ok(())
325 }
326
327 /// The HTTP client: the caller's if they supplied one, otherwise ours.
328 fn agent(&self, timeout: Duration) -> ureq::Agent {
329 self.agent.clone().unwrap_or_else(|| {
330 ureq::Agent::config_builder()
331 .timeout_global(Some(timeout))
332 .build()
333 .new_agent()
334 })
335 }
336
337 /// The token to present, logging in if it is time.
338 ///
339 /// `Ok(None)` when there is nothing to present, which is the right answer
340 /// for a Consul with ACLs disabled.
341 fn token(&self) -> Result<Option<String>, Error> {
342 match &self.auth {
343 Auth::Anonymous => Ok(None),
344 Auth::Token(supplied) => Ok(Some(supplied.clone())),
345 Auth::Login { .. } => self.session.token(|| self.login()).map(Some),
346 }
347 }
348
349 /// Exchanges a bearer token for an ACL token.
350 fn login(&self) -> Result<Token, Error> {
351 let Some(body) = self.auth.login_body()? else {
352 // Unreachable: `token()` handles the other variants before this.
353 return Err(Error::remote(format!(
354 "{}: {} needs no login",
355 self.describe(),
356 self.auth.describe()
357 )));
358 };
359
360 let url = format!("{}/v1/acl/login", self.address);
361
362 let response: serde_json::Value = self
363 .agent(self.timeout)
364 .post(&url)
365 .send_json(&body)
366 .map_err(|error| {
367 Error::remote(format!(
368 "{}: logging in with {} failed: {error}",
369 self.describe(),
370 self.auth.describe()
371 ))
372 })?
373 .body_mut()
374 .read_json()
375 .map_err(|error| {
376 Error::remote(format!(
377 "{}: the login response was not JSON: {error}",
378 self.describe()
379 ))
380 })?;
381
382 let secret = response
383 .get("SecretID")
384 .and_then(serde_json::Value::as_str)
385 .ok_or_else(|| {
386 Error::remote(format!(
387 "{}: the login response has no `SecretID`",
388 self.describe()
389 ))
390 })?
391 .to_owned();
392
393 // Consul reports the expiry as a duration in nanoseconds, and omits it
394 // for a token the auth method did not put one on.
395 let ttl = response
396 .get("ExpirationTTL")
397 .and_then(serde_json::Value::as_u64)
398 .filter(|nanos| *nanos > 0)
399 .map(Duration::from_nanos);
400
401 Ok(Token::new(secret, ttl))
402 }
403
404 /// Adds the ACL token to a request, if there is one.
405 fn authenticated(
406 &self,
407 request: ureq::RequestBuilder<ureq::typestate::WithoutBody>,
408 ) -> Result<ureq::RequestBuilder<ureq::typestate::WithoutBody>, Error> {
409 match self.token()? {
410 Some(token) => Ok(request.header("X-Consul-Token", &token)),
411 None => Ok(request),
412 }
413 }
414
415 /// One blocking query. `index` of zero returns immediately.
416 fn blocking_read(&self, agent: &ureq::Agent, index: u64) -> Result<Answered, Error> {
417 let mut url = self.url();
418
419 url.push(if url.contains('?') { '&' } else { '?' });
420 url.push_str(&format!(
421 "index={index}&wait={}s",
422 self.wait.as_secs().max(1)
423 ));
424
425 let mut response = match self.call(agent.get(&url)) {
426 Err(CallError::Forbidden(_)) if self.can_relogin() => {
427 // The token stopped working. One fresh login and one retry, not
428 // a loop: if a new token is also refused, the policy is wrong
429 // and retrying would turn a clear failure into a hang.
430 self.session.invalidate();
431
432 self.call(agent.get(&url)).map_err(CallError::into_error)?
433 }
434 outcome => outcome.map_err(CallError::into_error)?,
435 };
436
437 let index = response
438 .headers()
439 .get("X-Consul-Index")
440 .and_then(|value| value.to_str().ok())
441 .and_then(|value| value.parse().ok())
442 .unwrap_or(index);
443
444 let entries: Vec<serde_json::Value> = response.body_mut().read_json().map_err(|error| {
445 Error::remote(format!(
446 "{}: the response was not JSON: {error}",
447 self.describe()
448 ))
449 })?;
450
451 Ok(Answered {
452 index,
453 text: self.decode(&entries).ok(),
454 })
455 }
456
457 /// Sends a request with the current token.
458 fn call(
459 &self,
460 request: ureq::RequestBuilder<ureq::typestate::WithoutBody>,
461 ) -> Result<ureq::http::Response<ureq::Body>, CallError> {
462 self.authenticated(request)
463 .map_err(CallError::Other)?
464 .call()
465 .map_err(|error| {
466 let rendered = Error::remote(format!("{}: {error}", self.describe()));
467
468 match error {
469 ureq::Error::StatusCode(403) => CallError::Forbidden(rendered),
470 _ => CallError::Other(rendered),
471 }
472 })
473 }
474
475 /// Whether a refused token can be traded for a fresh one.
476 ///
477 /// Only a login can: `Auth::Token` was handed in from outside, and
478 /// invalidating it would just retry the identical string — one wasted
479 /// request per read against a broken ACL.
480 fn can_relogin(&self) -> bool {
481 matches!(self.auth, Auth::Login { .. })
482 }
483
484 /// Turns Consul's one-element array into the document it holds.
485 fn decode(&self, entries: &[serde_json::Value]) -> Result<String, Error> {
486 // Consul answers a single-key read with a one-element array. An empty
487 // one means the key is not there, which is a missing configuration
488 // rather than a transport failure — but still nothing to load.
489 let encoded = entries
490 .first()
491 .and_then(|entry| entry.get("Value"))
492 .and_then(serde_json::Value::as_str)
493 .ok_or_else(|| Error::remote(format!("{}: the key holds no value", self.describe())))?;
494
495 let decoded = base64::engine::general_purpose::STANDARD
496 .decode(encoded)
497 .map_err(|error| {
498 Error::remote(format!(
499 "{}: the value is not valid base64: {error}",
500 self.describe()
501 ))
502 })?;
503
504 String::from_utf8(decoded).map_err(|error| {
505 Error::remote(format!(
506 "{}: the value is not UTF-8: {error}",
507 self.describe()
508 ))
509 })
510 }
511
512 fn url(&self) -> String {
513 let mut url = format!(
514 "{}/v1/kv/{}",
515 self.address,
516 self.key.trim_start_matches('/')
517 );
518
519 if let Some(datacenter) = &self.datacenter {
520 url.push_str("?dc=");
521 url.push_str(datacenter);
522 }
523
524 url
525 }
526}
527
528/// A failed call, sorted by what a caller can do about it.
529///
530/// The sorting happens on `ureq`'s *typed* error, before anything becomes a
531/// string. The old string test — does the message contain `"403"`? — read
532/// true for any error mentioning a key like `myapp/403.json`, and a retry
533/// decision should not depend on what somebody named their key.
534enum CallError {
535 /// The agent said 403: the token is the problem, and a fresh login might
536 /// be the cure.
537 Forbidden(Error),
538 /// Everything else — network, timeouts, 500s. A new token fixes none of
539 /// it.
540 Other(Error),
541}
542
543impl CallError {
544 fn into_error(self) -> Error {
545 match self {
546 Self::Forbidden(error) | Self::Other(error) => error,
547 }
548 }
549}
550
551/// What one blocking query came back with.
552struct Answered {
553 index: u64,
554 /// `None` when the key holds nothing — a deleted key is not a change worth
555 /// reporting, because no configuration is not a configuration.
556 text: Option<String>,
557}
558
559// Hand-written, never derived: a derive would print every field, and the
560// fields include credentials. `{:?}` reaching a log is an ordinary accident —
561// a `dbg!`, a `tracing::debug!(?source)` — and an accident must not disclose
562// a secret. The other store crates follow the same rule.
563impl std::fmt::Debug for Consul {
564 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565 f.debug_struct("Consul")
566 .field("address", &self.address)
567 .field("key", &self.key)
568 .field("format", &self.format)
569 .field("datacenter", &self.datacenter)
570 .field("auth", &self.auth)
571 .finish_non_exhaustive()
572 }
573}
574
575impl RemoteSource for Consul {
576 fn fetch(&self) -> Result<Fetched, Error> {
577 let format = self.format.ok_or_else(|| {
578 Error::remote(format!(
579 "{}: the key names no format; call `with_format`",
580 self.describe()
581 ))
582 })?;
583
584 let agent = self.agent(self.timeout);
585
586 let mut response = match self.call(agent.get(&self.url())) {
587 Err(CallError::Forbidden(_)) if self.can_relogin() => {
588 self.session.invalidate();
589
590 self.call(agent.get(&self.url()))
591 .map_err(CallError::into_error)?
592 }
593 outcome => outcome.map_err(CallError::into_error)?,
594 };
595
596 let entries: Vec<serde_json::Value> = response.body_mut().read_json().map_err(|error| {
597 Error::remote(format!(
598 "{}: the response was not JSON: {error}",
599 self.describe()
600 ))
601 })?;
602
603 Ok(Fetched::new(self.decode(&entries)?, format))
604 }
605
606 fn describe(&self) -> String {
607 // The address too: "the key holds no value" helps nobody who has a
608 // staging Consul and a production Consul and a wrong environment
609 // variable.
610 format!("consul {} kv/{}", self.address, self.key)
611 }
612}
613
614/// Runs the watch callback with a panic net.
615///
616/// The callback is the caller's code on the caller's thread; a panic in it
617/// used to unwind through the watch loop and kill that thread with the
618/// `RemoteWatch` handle still looking alive. Caught, it becomes an orderly
619/// error: the watch ends, and the caller is told why.
620fn guarded<F>(on_change: &mut F, document: Fetched, described: &str) -> Result<(), Error>
621where
622 F: FnMut(Fetched) -> Result<(), Error>,
623{
624 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| on_change(document))).unwrap_or_else(
625 |_| {
626 Err(Error::remote(format!(
627 "{described}: the watch callback panicked; the watch is stopped"
628 )))
629 },
630 )
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 #[test]
638 fn debug_never_prints_a_credential() {
639 let source = Consul::new("http://consul:8500", "myapp/db.json")
640 .with_auth(Auth::token("hunter2-consul-token"));
641
642 let printed = format!("{source:?} {:?}", Auth::token("hunter2-consul-token"));
643
644 assert!(!printed.contains("hunter2"), "{printed}");
645 assert!(printed.contains("Token(***)"), "{printed}");
646 }
647}