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}