Skip to main content

dynamic_config/remote/
source.rs

1//! What a store hands back, and the two traits a store implements.
2//!
3//! Two traits rather than one because a client is either async to begin
4//! with or it is not, and making the wrong half pretend costs a `block_on`
5//! in somebody's runtime. Both are object-safe: a configuration type holds
6//! one without being generic over it.
7
8use std::time::Duration;
9
10use crate::error::Error;
11use crate::source::Format;
12
13use super::watch::{Pace, Watching};
14
15/// How a store finds out that its document changed.
16///
17/// What a store answers here is a fact about the protocol, not a promise
18/// about the implementation: it tells a caller what a watch is going to
19/// cost, so an agent can decide whether to run one at all and an operator
20/// can read why a change took as long as it did.
21///
22/// ```text
23/// Native       the store says so         a blocking query, a stream, a subscription
24/// Conditional  the store answers cheaply a version, an ETag, a revision — a header, not a document
25/// Interval     nothing but re-reading    the whole document, on a timer
26/// ```
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28#[non_exhaustive]
29pub enum WatchCapability {
30    /// The store pushes. A change is delivered as soon as it happens, and
31    /// the interval is only a resync — a stream can stall without saying so.
32    Native,
33    /// The store answers "has it changed?" without sending the document.
34    /// A poll costs a round trip and almost no bytes.
35    Conditional,
36    /// Nothing but re-reading the whole document on a timer.
37    Interval,
38}
39
40impl std::fmt::Display for WatchCapability {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.write_str(match self {
43            Self::Native => "native",
44            Self::Conditional => "conditional",
45            Self::Interval => "interval",
46        })
47    }
48}
49
50/// Which version of a document a store handed back.
51///
52/// Two shapes, because stores answer this question in two genuinely
53/// different ways and flattening them would be a lie:
54///
55/// ```text
56/// Counter  etcd revisions, Consul indices, Vault KV versions   ordered
57/// Opaque   ETags, object hashes, commit ids                    equal or not
58/// ```
59///
60/// A `Counter` can be compared — a lower one is older, and installing it
61/// over a higher one moves a configuration backwards. An `Opaque` can only
62/// be compared for equality: an ETag carries no order at all, and
63/// pretending it does would invent a fact the store never stated.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum Revision {
66    /// A number the store increments. Higher is newer.
67    Counter(u64),
68    /// A token that only ever means "the same" or "not the same".
69    Opaque(String),
70}
71
72impl Revision {
73    /// Whether this is a version the caller is not already serving.
74    ///
75    /// `installed` is what is being served now. A `Counter` supersedes a
76    /// lower one; an `Opaque` supersedes anything it does not equal; and a
77    /// pair of different shapes supersedes, because a store that changed
78    /// how it answers is telling you its new answer.
79    #[must_use]
80    pub fn supersedes(&self, installed: Option<&Self>) -> bool {
81        match (self, installed) {
82            (_, None) => true,
83            (Self::Counter(fresh), Some(Self::Counter(installed))) => fresh > installed,
84            (Self::Opaque(fresh), Some(Self::Opaque(installed))) => fresh != installed,
85            _ => true,
86        }
87    }
88}
89
90impl std::fmt::Display for Revision {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            Self::Counter(number) => write!(f, "{number}"),
94            Self::Opaque(token) => f.write_str(token),
95        }
96    }
97}
98
99/// The lease a *document* was issued under.
100///
101/// Not the credential the client authenticated with — that is the store
102/// crates' `Cached`, and the two lifetimes are separate on purpose. This
103/// one belongs to a dynamic secret: a database credential issued for an
104/// hour, renewable, revocable, and held by exactly one reader.
105#[derive(Clone, PartialEq, Eq)]
106pub struct Lease {
107    /// What the store calls this lease when renewing or revoking it.
108    pub id: String,
109    /// How long from issue until it expires.
110    pub ttl: Duration,
111    /// Whether renewing is possible at all, or a new one has to be issued.
112    pub renewable: bool,
113}
114
115// A lease id is a capability: it names a credential precisely enough to
116// renew or revoke it, and it arrives in the same response as the generated
117// username and password. It gets the treatment the document gets.
118impl std::fmt::Debug for Lease {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        f.debug_struct("Lease")
121            .field("ttl", &self.ttl)
122            .field("renewable", &self.renewable)
123            .finish_non_exhaustive()
124    }
125}
126
127/// A document a remote store handed back.
128///
129/// Built with [`Fetched::new`] and widened by [`Fetched::with_revision`] and
130/// [`Fetched::with_lease`]. `#[non_exhaustive]` so a later release can
131/// describe something else a store said without breaking every store that
132/// already compiles.
133#[derive(Clone, PartialEq, Eq)]
134#[non_exhaustive]
135pub struct Fetched {
136    /// The document text, in `format`.
137    pub text: String,
138    /// How to parse it.
139    pub format: Format,
140    /// Which version this is, when the store names one.
141    pub revision: Option<Revision>,
142    /// The lease it was issued under, for a store that issues them.
143    pub lease: Option<Lease>,
144}
145
146impl Fetched {
147    /// A document and the format it is written in.
148    #[must_use]
149    pub fn new(text: impl Into<String>, format: Format) -> Self {
150        Self {
151            text: text.into(),
152            format,
153            revision: None,
154            lease: None,
155        }
156    }
157
158    /// The same document, with the version the store named for it.
159    ///
160    /// Worth supplying wherever a store has one: it is what lets a sink
161    /// refuse a document older than the one it is already serving.
162    #[must_use]
163    pub fn with_revision(mut self, revision: Revision) -> Self {
164        self.revision = Some(revision);
165        self
166    }
167
168    /// The same document, with the lease it was issued under.
169    #[must_use]
170    pub fn with_lease(mut self, lease: Lease) -> Self {
171        self.lease = Some(lease);
172        self
173    }
174}
175
176// The document is the one thing a `Debug` of this type must never print:
177// a remote store's flagship use case is serving secrets, and `Fetched` is
178// what every watch callback receives — one `tracing::debug!(?document)` away
179// from a log. The length is enough to debug with.
180impl std::fmt::Debug for Fetched {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("Fetched")
183            .field("format", &self.format)
184            .field("bytes", &self.text.len())
185            // A revision names a version, not a value — it is the field
186            // somebody reading a log is actually looking for.
187            .field("revision", &self.revision)
188            .field("lease", &self.lease)
189            .finish()
190    }
191}
192
193/// A remote store that can be read without an async runtime.
194///
195/// The right trait for anything with a plain HTTP API — Consul and Vault both
196/// are — because implementing it needs no runtime and using it needs no
197/// runtime either. `fetch` may block; it is called from
198/// `refresh_remote()`, never from `load()`.
199pub trait RemoteSource: Send + Sync + 'static {
200    /// Reads the current document.
201    ///
202    /// # Errors
203    ///
204    /// Whatever going wrong looks like for this store. Use
205    /// [`Error::remote`](crate::Error::remote) so the failure is categorised
206    /// consistently, or [`Error::auth`](crate::Error::auth) for a credential
207    /// the store itself refused — that is the distinction a watch loop backs
208    /// off on rather than stopping.
209    fn fetch(&self) -> Result<Fetched, Error>;
210
211    /// How to name this source in an error or a report.
212    fn describe(&self) -> String;
213
214    /// How this store learns that its document changed.
215    ///
216    /// [`Interval`](WatchCapability::Interval) unless a store says
217    /// otherwise, which is the honest default: a store that has not been
218    /// asked the question has no push to offer.
219    fn watch_capability(&self) -> WatchCapability {
220        WatchCapability::Interval
221    }
222
223    /// Watches until the handle is dropped, calling `on_change` with every
224    /// document that differs from the last one delivered.
225    ///
226    /// **Override this** with the store's own mechanism — a blocking query,
227    /// a stream, a subscription — and say so in
228    /// [`watch_capability`](Self::watch_capability). An override may ignore
229    /// `interval`: a store that reports
230    /// [`Native`](WatchCapability::Native) gets its resync from
231    /// [`Remote::watch`](crate::Remote::watch), which reads on the interval
232    /// alongside the store's own watch. That is not belt and braces — the
233    /// failure mode of a stream is *silence*, and a subscription the broker
234    /// forgot looks exactly like a store where nothing has changed.
235    ///
236    /// The default polls: fetch, deliver anything new, wait, repeat. The
237    /// waits are spread so a fleet does not poll in lockstep, and they grow
238    /// after a failure so a store that is down is not hammered by everything
239    /// that depends on it — [`Pace`] is that policy, and an implementation
240    /// with its own loop should use it rather than sleep a flat interval.
241    ///
242    /// Called from a thread the caller owns. It returns when the watch is
243    /// stopped, or when `on_change` refuses.
244    ///
245    /// # Errors
246    ///
247    /// If `on_change` refuses a document. A *fetch* failing is not an error
248    /// here: a watch outlives an outage by design, so it is backed off from
249    /// rather than returned.
250    ///
251    /// **Nothing here records it.** A source is handed a store and a
252    /// callback; the status a [`Remote`](crate::Remote) keeps is not
253    /// reachable from either, so a loop that wants
254    /// `status().reachable()` to tell the truth through an outage reports
255    /// failures itself — [`RemoteSink::failed`](crate::RemoteSink::failed)
256    /// is that call, and the store crates' `reporting_to` wires it. Said
257    /// here because the alternative reading is expensive: a watch that has
258    /// been failing for an hour while its status says the store is fine.
259    fn watch(
260        &self,
261        watching: &Watching,
262        interval: Duration,
263        on_change: &mut dyn FnMut(Fetched) -> Result<(), Error>,
264    ) -> Result<(), Error> {
265        let mut pace = Pace::new(interval);
266        let mut last: Option<Fetched> = None;
267
268        while watching.keep_going() {
269            match self.fetch() {
270                Ok(fetched) => {
271                    pace.succeeded();
272
273                    if last.as_ref() != Some(&fetched) {
274                        last = Some(fetched.clone());
275                        on_change(fetched)?;
276                    }
277                }
278                // Swallowed on purpose: a watch is what keeps a program
279                // running through an outage, and a store that is down is a
280                // reason to wait longer rather than to stop watching.
281                Err(_) => pace.failed(),
282            }
283
284            pace.wait(watching);
285        }
286
287        Ok(())
288    }
289}
290
291/// A store whose documents are issued under a lease that can be extended
292/// or handed back.
293///
294/// Implemented by a store that issues *dynamic* credentials — Vault's
295/// `database/creds`, `pki/issue`, `aws/creds` — where the document is not a
296/// value somebody wrote but a credential the store minted for this reader
297/// alone, with an expiry.
298///
299/// Two lifetimes are in play and conflating them is the mistake this trait
300/// exists to avoid. The credential the *client* authenticates with is
301/// already handled by the store crates' `Cached`, refreshed before it
302/// expires and re-obtained when it is refused. The lease *the document was
303/// issued under* is this one: a caller renews it on a timer whether or not
304/// anybody reads, and hands it back when it stops needing it.
305///
306/// Blocking, with no async twin, because the stores that issue leases are
307/// the blocking ones — a caller with a runtime already drives them through
308/// its own blocking pool. If an async store ever grows leases, that is the
309/// moment to add the twin, and not before.
310pub trait RenewableSource: RemoteSource {
311    /// Extends a lease, answering with what the store granted.
312    ///
313    /// A store may grant less than was asked for, and the answer is
314    /// authoritative: schedule the next renewal from what came back, never
315    /// from what was requested.
316    ///
317    /// # Errors
318    ///
319    /// If the store refuses or cannot be reached. A refusal is terminal for
320    /// *this* lease — the credential has to be fetched afresh — while an
321    /// unreachable store is worth retrying inside the remaining life.
322    fn renew(&self, lease: &Lease) -> Result<Lease, Error>;
323
324    /// Hands a lease back, so the credential stops working now rather than
325    /// at expiry.
326    ///
327    /// Best-effort by nature: a caller doing this on the way out has
328    /// somewhere else to be, and an unreachable store must not keep a
329    /// process alive. The lease expires on its own regardless; revoking
330    /// only shortens the window.
331    ///
332    /// # Errors
333    ///
334    /// If the store refuses or cannot be reached.
335    fn revoke(&self, lease: &Lease) -> Result<(), Error>;
336}
337
338/// A remote store that is read asynchronously.
339///
340/// The right trait for a client that is async to begin with — etcd speaks gRPC
341/// and NATS is a streaming protocol, so both are. Used through
342/// `refresh_remote_async().await`.
343///
344/// The lifetime-bound boxed future rather than `async fn`: this trait is
345/// object-safe on purpose, so a configuration type can hold one without being
346/// generic over it.
347#[cfg(feature = "async")]
348#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
349pub trait AsyncRemoteSource: Send + Sync + 'static {
350    /// Reads the current document.
351    ///
352    /// # Errors
353    ///
354    /// As [`RemoteSource::fetch`].
355    fn fetch(
356        &self,
357    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Fetched, Error>> + Send + '_>>;
358
359    /// How to name this source in an error or a report.
360    fn describe(&self) -> String;
361
362    /// How this store learns that its document changed.
363    ///
364    /// As [`RemoteSource::watch_capability`].
365    fn watch_capability(&self) -> WatchCapability {
366        WatchCapability::Interval
367    }
368
369    /// Watches until the future is dropped, calling `on_change` with every
370    /// document that differs from the last one delivered.
371    ///
372    /// As [`RemoteSource::watch`], with two differences that matter.
373    /// Cancellation is dropping the future, so a `Watching` is accepted but
374    /// an async watch does not need one. And the resync a native store gets
375    /// for free on the blocking side is the caller's here: an async caller
376    /// has a runtime, and racing a timer against this future is a line of
377    /// its own code rather than a thread this crate would have to spawn.
378    ///
379    /// **The default polls only with the `tokio` feature on.** This crate
380    /// picks no runtime, and a poll needs a timer — so with the feature off
381    /// the default refuses, naming the store and saying what to do about
382    /// it. That is rarely the interesting case: a store is async because
383    /// its protocol is, and a streaming protocol has a watch of its own to
384    /// override this with.
385    ///
386    /// # Errors
387    ///
388    /// If `on_change` refuses a document, or if this build has no timer and
389    /// the store did not override this.
390    fn watch<'a>(
391        &'a self,
392        watching: &'a Watching,
393        interval: Duration,
394        on_change: &'a mut (dyn FnMut(Fetched) -> Result<(), Error> + Send),
395    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + 'a>> {
396        Box::pin(async move {
397            #[cfg(not(feature = "tokio"))]
398            {
399                let _ = (watching, interval, on_change);
400
401                Err(Error::new(
402                    crate::ErrorKind::Remote,
403                    format!(
404                        "`{}` has no watch of its own, and this build has no timer to poll it \
405                         with; add features = [\"tokio\"] to your dynamic-config dependency, \
406                         or call `refresh_remote_async` on a timer of your own",
407                        self.describe()
408                    ),
409                ))
410            }
411
412            #[cfg(feature = "tokio")]
413            {
414                let mut pace = Pace::new(interval);
415                let mut last: Option<Fetched> = None;
416
417                while watching.keep_going() {
418                    match self.fetch().await {
419                        Ok(fetched) => {
420                            pace.succeeded();
421
422                            if last.as_ref() != Some(&fetched) {
423                                last = Some(fetched.clone());
424                                on_change(fetched)?;
425                            }
426                        }
427                        // Swallowed on purpose, as in the blocking twin.
428                        Err(_) => pace.failed(),
429                    }
430
431                    tokio::time::sleep(pace.next_wait()).await;
432                }
433
434                Ok(())
435            }
436        })
437    }
438}