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//! # 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}