dynamic_config_s3/lib.rs
1//! Read [`dynamic-config`] configuration from an S3 object.
2//!
3//! The AWS SDK is async throughout, so this implements the **async**
4//! [`AsyncRemoteSource`] trait rather than the blocking one.
5//!
6//! ```no_run
7//! use dynamic_config_s3::S3;
8//!
9//! # struct DbConfig;
10//! # impl DbConfig {
11//! # fn set_remote_async(_: S3) {}
12//! # async fn refresh_remote_async() -> Result<(), dynamic_config::Error> { Ok(()) }
13//! # }
14//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
15//! // Credentials come from the environment the way every other AWS tool finds
16//! // them: variables, the profile, the instance role, IRSA.
17//! DbConfig::set_remote_async(S3::new("myapp-config", "prod/db.json").await?);
18//!
19//! // Fetching is explicit; the load that follows touches no network.
20//! DbConfig::refresh_remote_async().await?;
21//! # Ok(())
22//! # }
23//! ```
24//!
25//! # What it reads
26//!
27//! One object, whose body is **a whole configuration document** — the same
28//! bytes that would be in a config file. The format comes from the key's
29//! extension, or from [`with_format`](S3::with_format).
30//!
31//! # Several objects as one document
32//!
33//! A deployment that splits its configuration across a prefix —
34//! `prod/db.json`, `prod/server.json` — can have one source read the lot, and
35//! [`Keys`] says which:
36//!
37//! ```no_run
38//! # use dynamic_config_s3::{Keys, S3};
39//! # async fn example() -> Result<(), dynamic_config::Error> {
40//! // Named keys: a list of layers, merged in the order given, later wins.
41//! let s3 = S3::new("myapp-config", Keys::several(["prod/base.json", "prod/local.json"])).await?;
42//!
43//! // A prefix: disjoint sections, and an overlap between two of them is an error.
44//! let s3 = S3::new("myapp-config", Keys::prefix("prod/"))
45//! .await?
46//! .with_format(dynamic_config::Format::Json);
47//! # Ok(())
48//! # }
49//! ```
50//!
51//! **Neither shape is atomic, and S3 offers nothing that would make one so.**
52//! There is no batch read: a named list is one `GetObject` per key, and a
53//! prefix is one `ListObjectsV2` and then one `GetObject` per key it named. A
54//! write landing between two of those requests can produce a document that
55//! never existed as a whole. AWS made `ListObjectsV2` strongly consistent in
56//! December 2020, so the listing itself is not the hole it once was — but
57//! another implementation of this API is free to be eventually consistent, and
58//! the gap between the listing and the reads is there in every case.
59//!
60//! **The 512-key bound is applied to the listing, not to what it fetched.**
61//! `ListObjectsV2` is paginated, so the count is checked as each page arrives
62//! and a prefix over a bucket of a million objects is refused after one
63//! request rather than after a million bodies. Every key the store answers
64//! with is checked against the literal prefix, and a key ending in `/` — the
65//! zero-byte object the console makes when somebody creates a "folder" — is
66//! not a document and is skipped.
67//!
68//! Three consequences that belong here rather than in an incident:
69//!
70//! - **Provenance becomes store-grained.** The merged document is one layer,
71//! so `source_of` answers "from s3 … keys a, b" rather than naming which key
72//! supplied a value. [`describe`](AsyncRemoteSource::describe) names the
73//! whole set, which is as close as one layer gets.
74//! - **One unreadable key fails the whole fetch.** A configuration quietly
75//! missing a section is worse than a refresh that failed and left the last
76//! document serving.
77//! - **A multi-key source cannot be watched.** What a watch delivers is the
78//! object that changed, and a merged document has no one ETag; it refuses at
79//! [`watch`](S3::watch) and points at polling `refresh_remote_async()`.
80//!
81//! # Credentials
82//!
83//! Through `aws-config`, which is the chain every AWS tool uses:
84//! `AWS_ACCESS_KEY_ID`, the shared profile, the EC2 instance role, the ECS task
85//! role, and IRSA on EKS. That is deliberately not re-implemented here — a
86//! second credential chain in a program that already has one is a bug waiting
87//! for a rotation.
88//!
89//! [`with_config`](S3::with_config) takes an `SdkConfig` the program already
90//! built, which is also how a non-AWS endpoint is reached: MinIO, Ceph,
91//! Cloudflare R2, Backblaze B2 all speak this API.
92//!
93//! # Watching
94//!
95//! S3 cannot tell you when an object changes without a notification pipeline —
96//! SNS, SQS, EventBridge — that is a deployment's decision, not a library's. So
97//! [`watch`](S3::watch) polls, and says so.
98//!
99//! What it does not do is download the object every tick. `HEAD` returns the
100//! ETag, which changes when the body does, so an unchanged configuration costs
101//! one small request and no transfer.
102//!
103//! **A failing poll says so, if it is asked to.**
104//! [`reporting_to`](S3::reporting_to) hands the loop the same sink it delivers
105//! through, and the failures inside it — a `HEAD` that did not answer, and a
106//! `GET` that did not answer after the ETag moved — are reported to the
107//! `RemoteStatus` as they happen. Surviving a failure is what makes that
108//! necessary: a loop that retries forever is a loop that reports nothing
109//! forever, so `dynamic_config_remote_up` would describe the last *delivery*
110//! rather than the last *attempt*.
111//!
112//! A credential the store will not accept — `AccessDenied`,
113//! `InvalidAccessKeyId`, `SignatureDoesNotMatch`, an expired session token —
114//! is reported as `ErrorKind::Auth` rather than `Remote`, because no amount of
115//! waiting persuades S3 otherwise. A clock too far out of step
116//! (`RequestTimeTooSkewed`) shares the same 403 and stays `Remote`: that one
117//! does come right.
118//!
119//! # Timeouts
120//!
121//! [`S3::with_timeout`] is the deadline for a single fetch attempt, excluding
122//! retries the underlying client performs. Here that exclusion has teeth: the
123//! SDK retries, so **a five-second timeout with three attempts is a
124//! fifteen-second call**. See the README's Timeouts section.
125//!
126//! # This crate needs a tokio runtime
127//!
128//! Not this crate's choice: the AWS SDK it is built on is tokio-based
129//! (`rt-tokio`), and [`watch`](S3::watch) sleeps on tokio's timer. Driving
130//! it from another executor panics inside the SDK. The etcd and NATS
131//! companions are executor-agnostic; this one is honest about not being.
132//!
133//!
134//! # Every failure branch of the watch loop, and what it reports
135//!
136//! A watch is the half of a store `dynamic-config` cannot see, and
137//! [`reporting_to`](S3::reporting_to) is what lets it speak: the sink the
138//! loop already holds is told about every attempt that came back with
139//! nothing. Which attempts those are is a table rather than prose, because
140//! the question an operator asks is *which* silence is deliberate.
141//!
142//! Three rules decide the column, and they are the same three in all seven
143//! store crates:
144//!
145//! 1. **A failure the loop survives by retrying reports.** That is the case
146//! the whole feature exists for: the stream is down, the last delivery is
147//! old, and nothing else would ever say so out loud.
148//! 2. **A recovery that worked stays silent.** Only a delivery or a fetch
149//! clears the streak, so reporting a five-minute token turning over on a
150//! healthy cluster would drive `remote_up` to zero and leave it there.
151//! 3. **A refusal that never asked the store reports nowhere.** No format, a
152//! key shape that cannot be watched, material that will not build a
153//! client: `RemoteStatus::reachable()` is *whether the store answered the
154//! last time it was asked*, and these never ask. They are returned to the
155//! caller, who is the one holding the mistake — and a status cannot
156//! correct them, since it carries a kind and a path and no message.
157//!
158//! | Branch | Reports |
159//! |---|---|
160//! | the format is missing, or the source names several keys | no — rule 3: nothing has been asked of the bucket |
161//! | the `HEAD` that reads the ETag fails — an expired credential, a bucket briefly unreachable | **yes**, and the loop waits out the interval |
162//! | the `GET` after a moved tag fails | **yes**, and `seen` is left where it was so the next tick tries the same tag again |
163//! | the first tick, or an ETag that has not moved | no — the bucket answered |
164//! | `on_change` refuses the document | no — the bucket answered; `apply` counted the delivery, and what the document did next is `ConfigStatus`'s half |
165//!
166//! [`dynamic-config`]: https://docs.rs/dynamic-config
167
168#![forbid(unsafe_code)]
169#![deny(missing_docs)]
170#![cfg_attr(docsrs, feature(doc_cfg))]
171
172use std::future::Future;
173use std::pin::Pin;
174use std::time::Duration;
175
176use aws_sdk_s3::config::timeout::TimeoutConfig;
177use dynamic_config::{AsyncRemoteSource, Error, Fetched, Format, RemoteSink, Watching};
178use dynamic_config_store_core::attempts::Attempts;
179use dynamic_config_store_core::documents::{self, Overlap, MOST_KEYS};
180use dynamic_config_store_core::guarded;
181
182/// The AWS types a caller needs to configure this, re-exported so using them
183/// needs no direct dependency on the SDK.
184pub use aws_config::SdkConfig;
185pub use aws_sdk_s3::Client;
186
187use dynamic_config_store_core::tls as tls_core;
188/// A private certificate authority and a client certificate, as data.
189///
190/// The shared vocabulary all seven store crates take, so that reaching TLS
191/// never means naming an SDK type — see [`S3::with_tls`]. S3 is one of two
192/// stores here that cannot express the whole of it: the SDK's TLS context has
193/// a trust store and no client-certificate slot, so mTLS is refused rather
194/// than ignored.
195pub use dynamic_config_store_core::tls::TlsConfig;
196use rustls_pki_types::pem::PemObject;
197
198use aws_sdk_s3::error::ProvideErrorMetadata;
199
200/// The error codes S3 uses for a credential it will not accept.
201///
202/// Matched on the code rather than the 403 that carries them, because a 403
203/// is also what `RequestTimeTooSkewed` arrives as — a clock problem that NTP
204/// does fix, and so not something to stop a watch loop over.
205const AUTH_CODES: [&str; 6] = [
206 "AccessDenied",
207 "InvalidAccessKeyId",
208 "SignatureDoesNotMatch",
209 "ExpiredToken",
210 "InvalidToken",
211 "TokenRefreshRequired",
212];
213
214/// The most `ListObjectsV2` pages one prefix read will ask for.
215///
216/// The key budget already stops a listing that is merely large: one page is
217/// asked for `MOST_KEYS + 1` keys, so a prefix over anything bigger than the
218/// budget is refused on the first or second page. This is the other failure —
219/// a store that keeps answering "truncated" with a continuation token and no
220/// keys, which the budget on the keys cannot see because the count never
221/// moves.
222///
223/// Thirty-two rather than the two a well-behaved store needs: `max-keys` is a
224/// *maximum*, and an implementation of this API is free to answer with fewer
225/// than it was asked for. At sixteen keys a page this still reaches the whole
226/// budget, which puts the cap well clear of a small page size and still well
227/// short of a loop.
228const MOST_LIST_PAGES: usize = 32;
229
230/// What a source reads: one object, several named ones, or a prefix.
231///
232/// Every constructor takes one, and a bare `&str` or `String` is
233/// [`Keys::one`] — so the single-key spelling every caller already wrote keeps
234/// working unchanged.
235#[derive(Clone, Debug, PartialEq, Eq)]
236pub enum Keys {
237 /// One object, whose body is the whole document.
238 One(String),
239 /// Several named objects, merged **in the order given — later wins**.
240 ///
241 /// The rule a list of `.file(..)` calls already teaches: the caller wrote
242 /// the list, so the list is the precedence. One `GetObject` per key,
243 /// because S3 has no batch read — so the set is **not** read atomically.
244 Several(Vec<String>),
245 /// Every object under a literal prefix, merged as **disjoint sections**.
246 ///
247 /// A caller naming a prefix is not expressing an order — S3 lists keys in
248 /// UTF-8 order, which is nobody's precedence — so two objects under it
249 /// supplying the same path is a deployment bug, and reported as one rather
250 /// than resolved. One `ListObjectsV2` (paginated) and then one
251 /// `GetObject` per key.
252 Prefix(String),
253}
254
255impl Keys {
256 /// One object, whose body is the whole document.
257 #[must_use]
258 pub fn one(key: impl Into<String>) -> Self {
259 Self::One(key.into())
260 }
261
262 /// Several named objects, merged in the order given — later wins.
263 #[must_use]
264 pub fn several<I, S>(keys: I) -> Self
265 where
266 I: IntoIterator<Item = S>,
267 S: Into<String>,
268 {
269 Self::Several(keys.into_iter().map(Into::into).collect())
270 }
271
272 /// Every object under `prefix`, merged as disjoint sections.
273 #[must_use]
274 pub fn prefix(prefix: impl Into<String>) -> Self {
275 Self::Prefix(prefix.into())
276 }
277
278 /// The keys as a slice, for the diagnostics and the format inference.
279 ///
280 /// A prefix has none to list — the set is not known until the store
281 /// answers.
282 fn named(&self) -> &[String] {
283 match self {
284 Self::One(key) => std::slice::from_ref(key),
285 Self::Several(keys) => keys,
286 Self::Prefix(_) => &[],
287 }
288 }
289
290 /// How a diagnostic names what this source reads.
291 ///
292 /// One key renders as the key itself, so every message a single-key source
293 /// has ever produced is unchanged.
294 fn describe(&self) -> String {
295 match self {
296 Self::One(key) => key.clone(),
297 Self::Several(keys) => format!("keys {}", keys.join(", ")),
298 Self::Prefix(prefix) => format!("prefix {prefix}"),
299 }
300 }
301}
302
303impl From<&str> for Keys {
304 fn from(key: &str) -> Self {
305 Self::one(key)
306 }
307}
308
309impl From<String> for Keys {
310 fn from(key: String) -> Self {
311 Self::One(key)
312 }
313}
314
315impl From<&String> for Keys {
316 fn from(key: &String) -> Self {
317 Self::one(key)
318 }
319}
320
321/// An object in S3, as a configuration source.
322pub struct S3 {
323 client: Client,
324 bucket: String,
325 keys: Keys,
326 format: Option<Format>,
327 /// Why the keys' own extensions could not settle the format between them.
328 ///
329 /// Kept rather than reported at construction because `from_client` cannot
330 /// fail and because `with_format` is allowed to settle it afterwards.
331 disagreement: Option<String>,
332 /// The endpoint override, when the construction path knew one. Only for
333 /// `describe()`: the endpoint tells MinIO apart from AWS in an error.
334 endpoint: Option<String>,
335 /// Where the watch loop reports a poll that came back with nothing.
336 ///
337 /// Nobody, unless [`reporting_to`](S3::reporting_to) said otherwise: a
338 /// fetch records itself through `refresh_remote_async`, and a poll is
339 /// the half of this store `dynamic-config` cannot see on its own.
340 attempts: Attempts,
341}
342
343impl S3 {
344 /// The object `key` in `bucket`, with credentials from the environment.
345 ///
346 /// `key` is a key — `"prod/db.json"` — or a [`Keys`], for the
347 /// several-objects and prefix forms.
348 ///
349 /// The format is taken from the key's extension — `prod/db.json` is JSON. A
350 /// key without one, and every prefix, needs
351 /// [`with_format`](Self::with_format).
352 ///
353 /// This resolves credentials, which may read a file or call the instance
354 /// metadata service — the one constructor in this family that does I/O,
355 /// because the credential chain is what it is.
356 pub async fn new(bucket: impl Into<String>, key: impl Into<Keys>) -> Result<Self, Error> {
357 let config = aws_config::load_from_env().await;
358
359 Ok(Self::with_config(&config, bucket, key))
360 }
361
362 /// Uses an `SdkConfig` the program already built.
363 ///
364 /// For a caller that already talks to AWS, and for anything that is not
365 /// AWS: MinIO, Ceph, R2 and B2 all speak this API, and all of them need an
366 /// endpoint override the environment cannot express.
367 ///
368 /// ```no_run
369 /// # use dynamic_config_s3::S3;
370 /// # async fn example() {
371 /// let config = aws_config::from_env()
372 /// .endpoint_url("http://minio.internal:9000")
373 /// .load()
374 /// .await;
375 ///
376 /// let s3 = S3::with_config(&config, "myapp-config", "prod/db.json");
377 /// # }
378 /// ```
379 #[must_use]
380 pub fn with_config(
381 config: &SdkConfig,
382 bucket: impl Into<String>,
383 key: impl Into<Keys>,
384 ) -> Self {
385 // `force_path_style` is what makes every S3-compatible server work:
386 // `http://host/bucket/key` rather than `http://bucket.host/key`, which
387 // needs DNS entries only AWS has.
388 let s3 = aws_sdk_s3::config::Builder::from(config)
389 .force_path_style(true)
390 .build();
391
392 let mut source = Self::from_client(Client::from_conf(s3), bucket, key);
393 source.endpoint = config.endpoint_url().map(str::to_owned);
394
395 source
396 }
397
398 /// As [`with_config`](Self::with_config), with a private certificate
399 /// authority.
400 ///
401 /// The same vocabulary as the other six store crates, spelled as *data* —
402 /// nothing here names an SDK or a `rustls` type:
403 ///
404 /// ```no_run
405 /// # use dynamic_config_s3::{S3, TlsConfig};
406 /// # async fn example() -> Result<(), dynamic_config::Error> {
407 /// let config = aws_config::from_env()
408 /// .endpoint_url("https://minio.internal:9000")
409 /// .load()
410 /// .await;
411 ///
412 /// let s3 = S3::with_tls(
413 /// &config,
414 /// "myapp-config",
415 /// "prod/db.json",
416 /// &TlsConfig::new().with_ca_certificate_file("/etc/ssl/private-ca.pem"),
417 /// )?;
418 /// # Ok(())
419 /// # }
420 /// ```
421 ///
422 /// This is for the S3-compatible servers, which is where a private
423 /// authority actually turns up: MinIO, Ceph and a company's own gateway all
424 /// present certificates AWS' public chain has never heard of.
425 ///
426 /// # What S3 cannot express
427 ///
428 /// **A client certificate.** The SDK reaches TLS through
429 /// `aws-smithy-http-client`, whose `TlsContext` has a trust store and
430 /// nothing else — there is no client-certificate slot to fill, at any
431 /// version this crate can depend on. So
432 /// [`with_client_certificate_files`] and [`with_client_certificate_pem`]
433 /// are **refused here**, naming the call and pointing at
434 /// [`from_client`](Self::from_client) — not ignored, because a caller who
435 /// asked to present a certificate and did not would discover it as an
436 /// authentication failure a long way from the cause.
437 ///
438 /// A caller who needs mTLS to an S3-compatible server builds the connector
439 /// themselves and hands over the finished `Client`. That is what the escape
440 /// hatch is for, and it is untouched.
441 ///
442 /// **The CA replaces the platform trust store** rather than adding to it,
443 /// which is what naming a private authority means. A deployment that needs
444 /// both puts both in the file.
445 ///
446 /// There is no way to turn verification off; [`TlsConfig`]'s own
447 /// documentation argues that one, and the SDK's TLS context offers no such
448 /// switch to forward even if this crate wanted to.
449 ///
450 /// # Errors
451 ///
452 /// If the configuration names a client certificate, if a PEM file cannot be
453 /// read, or if the TLS context will not build.
454 ///
455 /// [`with_client_certificate_files`]: TlsConfig::with_client_certificate_files
456 /// [`with_client_certificate_pem`]: TlsConfig::with_client_certificate_pem
457 pub fn with_tls(
458 config: &SdkConfig,
459 bucket: impl Into<String>,
460 key: impl Into<Keys>,
461 tls: &TlsConfig,
462 ) -> Result<Self, Error> {
463 let bucket = bucket.into();
464 let described = format!("s3 {bucket}");
465
466 if tls.client_certificate().is_some() {
467 return Err(tls_core::unsupported(
468 &described,
469 "a client certificate",
470 "the AWS SDK's TLS context has a trust store and no \
471 client-certificate slot; build the connector yourself and use \
472 `from_client`",
473 ));
474 }
475
476 let mut trust_store = aws_smithy_http_client::tls::TrustStore::empty();
477
478 if let Some(pem) = tls.ca_certificate_pem(&described)? {
479 // Parsed here and thrown away, purely to refuse. The SDK's rustls
480 // connector calls `.expect("cert parsable")` on this material, so
481 // a certificate it cannot read is a *panic* at the first
482 // connection — a long way from the call that supplied it, and in a
483 // library whose whole job is to not take a process down. The
484 // parser's own message is dropped for the reason it is dropped
485 // everywhere in this family: it renders the line it choked on.
486 let readable = rustls_pki_types::CertificateDer::pem_slice_iter(&pem)
487 .collect::<Result<Vec<_>, _>>()
488 .map_err(|_| {
489 Error::remote(format!(
490 "{described}: the CA certificate is not PEM-encoded \
491 certificate material"
492 ))
493 })?;
494
495 if readable.is_empty() {
496 return Err(Error::remote(format!(
497 "{described}: the CA certificate holds no certificate; it \
498 is refused rather than ignored"
499 )));
500 }
501
502 trust_store = trust_store.with_pem_certificate(pem);
503 }
504
505 let context = aws_smithy_http_client::tls::TlsContext::builder()
506 .with_trust_store(trust_store)
507 .build()
508 // The SDK renders the PEM parse failure underneath, and a parse
509 // failure renders the line it choked on — so the upstream text is
510 // dropped rather than wrapped, the same rule the whole family
511 // holds to.
512 .map_err(|_| {
513 Error::remote(format!(
514 "{described}: the CA certificate was refused; check that it \
515 is PEM-encoded certificate material"
516 ))
517 })?;
518
519 let http = aws_smithy_http_client::Builder::new()
520 .tls_provider(aws_smithy_http_client::tls::Provider::Rustls(
521 aws_smithy_http_client::tls::rustls_provider::CryptoMode::AwsLc,
522 ))
523 .tls_context(context)
524 .build_https();
525
526 // `force_path_style` for the same reason `with_config` sets it: it is
527 // what makes every S3-compatible server work, and those are exactly the
528 // servers a private authority belongs to.
529 let s3 = aws_sdk_s3::config::Builder::from(config)
530 .force_path_style(true)
531 .http_client(http)
532 .build();
533
534 let mut source = Self::from_client(Client::from_conf(s3), bucket, key);
535 source.endpoint = config.endpoint_url().map(str::to_owned);
536
537 Ok(source)
538 }
539
540 /// Uses a client the program already has.
541 ///
542 /// The escape hatch, and it stays one: a connector this crate has no
543 /// spelling for — mTLS, a proxy, a DNS resolver — is built here and handed
544 /// over finished.
545 #[must_use]
546 pub fn from_client(client: Client, bucket: impl Into<String>, key: impl Into<Keys>) -> Self {
547 let keys = key.into();
548
549 let (format, disagreement) = match documents::agreed_format(keys.named()) {
550 Ok(format) => (format, None),
551 Err(complaint) => (None, Some(complaint)),
552 };
553
554 Self {
555 client,
556 bucket: bucket.into(),
557 keys,
558 format,
559 disagreement,
560 endpoint: None,
561 attempts: Attempts::default(),
562 }
563 }
564
565 /// States the format, for a key whose name does not.
566 ///
567 /// Required for [`Keys::Prefix`] — a prefix has no extension — and it also
568 /// settles a list whose keys name two different formats.
569 #[must_use]
570 pub fn with_format(mut self, format: Format) -> Self {
571 self.format = Some(format);
572 // The caller has now said which format wins, so the keys no longer
573 // have to agree between themselves.
574 self.disagreement = None;
575 self
576 }
577
578 /// Reports this source's **watch** failures to `sink`.
579 ///
580 /// A poll loop is the half of a store `dynamic-config` cannot see. A
581 /// delivery keeps `RemoteStatus` current because
582 /// [`RemoteSink::apply`] records one — but a poll that keeps failing
583 /// delivers nothing and would otherwise say nothing: an expired
584 /// credential, a bucket policy that changed under the process, a
585 /// gateway that went away. `dynamic_config_remote_up` would report the
586 /// last *delivery* rather than the last *attempt*, and a bucket that
587 /// stopped answering an hour ago would look healthy until something
588 /// called `refresh_remote_async()`.
589 ///
590 /// ```no_run
591 /// # use dynamic_config_s3::S3;
592 /// # struct DbConfig;
593 /// # impl DbConfig {
594 /// # fn remote_sink() -> dynamic_config::RemoteSink { unimplemented!() }
595 /// # }
596 /// # async fn example() -> Result<(), dynamic_config::Error> {
597 /// // Taken once, where the loop is wired: a sink captures the generation
598 /// // of the source installed at that moment, which is what stops a loop
599 /// // winding down from charging its failures to its replacement.
600 /// let sink = DbConfig::remote_sink();
601 ///
602 /// let watcher = S3::new("myapp-config", "prod/db.json").await?.reporting_to(sink);
603 /// # Ok(())
604 /// # }
605 /// ```
606 ///
607 /// **A failure moves the failure streak and nothing else.** The fetch
608 /// count and the clock are left alone, so
609 /// `dynamic_config_remote_last_fetch_seconds` keeps ageing while
610 /// `dynamic_config_remote_up` goes to zero — the pair an alert wants.
611 /// Only the failure's kind and key path are recorded; a bucket, an
612 /// endpoint and a key never reach a `RemoteStatus`.
613 ///
614 /// It changes nothing about what [`watch`](Self::watch) *returns*, and
615 /// nothing about [`fetch`](AsyncRemoteSource::fetch), which already
616 /// records itself through `refresh_remote_async()`.
617 ///
618 /// [`RemoteSink::apply`]: dynamic_config::RemoteSink::apply
619 #[must_use]
620 pub fn reporting_to(mut self, sink: RemoteSink) -> Self {
621 self.attempts = Attempts::to(sink);
622 self
623 }
624
625 /// The format, or an error naming the call that supplies one.
626 fn format(&self) -> Result<Format, Error> {
627 if let Some(complaint) = &self.disagreement {
628 return Err(Error::remote(format!("{}: {complaint}", self.describe())));
629 }
630
631 self.format.ok_or_else(|| {
632 Error::remote(format!(
633 "{}: the key names no format; call `with_format`",
634 self.describe()
635 ))
636 })
637 }
638
639 /// The one key this source reads, or an error saying it reads several.
640 ///
641 /// A watch here compares ETags, and an ETag belongs to an object: a set of
642 /// objects has none, and following one member's would fire on that member
643 /// and miss every other.
644 fn single_key(&self) -> Result<&str, Error> {
645 match &self.keys {
646 Keys::One(key) => Ok(key),
647 _ => Err(Error::remote(format!(
648 "{}: a source that reads several keys cannot be watched; \
649 poll `refresh_remote_async()` on a timer instead",
650 self.describe()
651 ))),
652 }
653 }
654
655 /// What two of this source's keys supplying one path means.
656 ///
657 /// The distinction the feature turns on: a caller who wrote the list wrote
658 /// the precedence with it, and a caller who wrote a prefix wrote no order
659 /// at all — so the first merges and the second refuses.
660 fn overlap(&self) -> Overlap {
661 match self.keys {
662 Keys::One(_) | Keys::Several(_) => Overlap::LaterWins,
663 Keys::Prefix(_) => Overlap::Refused,
664 }
665 }
666
667 /// How long a single fetch **attempt** may take before it is given up on.
668 ///
669 /// The deadline for one attempt, excluding retries the underlying client
670 /// performs — the sentence every store in this family answers to, and the
671 /// one place in the family where the exclusion is not a technicality.
672 ///
673 /// The AWS SDK retries on its own. So this maps onto
674 /// `operation_attempt_timeout`, which is per attempt, and a fetch can take
675 /// this multiplied by the attempt count — three, by default. That is
676 /// documented rather than tuned away: the SDK's retry policy is a
677 /// deployment's decision, and silently disabling it here would be this
678 /// crate overruling it. Set `operation_timeout` on the `SdkConfig` for a
679 /// ceiling on the whole call, or a retry policy for a different multiplier.
680 ///
681 /// The SDK has no timeout set at all by default, so this is additive:
682 /// nothing that worked before starts failing, and a fetch that used to
683 /// hang now stops.
684 #[must_use]
685 pub fn with_timeout(mut self, timeout: Duration) -> Self {
686 // Built from what the config already carries, so a caller's own
687 // connect or read timeouts survive setting this one.
688 let timeouts = self
689 .client
690 .config()
691 .timeout_config()
692 .map_or_else(TimeoutConfig::builder, TimeoutConfig::to_builder)
693 .operation_attempt_timeout(timeout)
694 .build();
695
696 let config = self
697 .client
698 .config()
699 .to_builder()
700 .timeout_config(timeouts)
701 .build();
702
703 self.client = Client::from_conf(config);
704 self
705 }
706
707 /// Calls `on_change` when the object's ETag moves, checking every
708 /// `interval`.
709 ///
710 /// Polling, because S3 offers nothing better without a notification
711 /// pipeline — and *ETag* polling, because downloading an object every
712 /// thirty seconds to discover it has not changed is a poor thing to do to a
713 /// bucket that charges per gigabyte. Each tick is a `HEAD`; only a new ETag
714 /// costs a `GET`.
715 ///
716 /// The current value is **not** delivered at startup, for the same reason a
717 /// file watcher does not report an edit when it starts. Fetch first if the
718 /// starting value matters.
719 ///
720 /// A failed check does not end the watch — an expired credential, a network
721 /// blip, a bucket briefly unreachable — it waits out the interval and tries
722 /// again. `stop` is noticed within a quarter second whatever `interval` is.
723 ///
724 /// # What a failing loop reports
725 ///
726 /// Nothing, unless [`reporting_to`](Self::reporting_to) was given a sink.
727 /// With one, both failures **inside** the loop are reported to the
728 /// `RemoteStatus` as they happen: a `HEAD` that did not answer, and a
729 /// `GET` that did not answer after the ETag moved. Surviving a failure is
730 /// exactly what makes this necessary — a loop that retries forever is a
731 /// loop that reports nothing forever, and a poll silently failing since
732 /// Tuesday is indistinguishable from a configuration nobody has changed.
733 ///
734 /// The refusals **at the door** — no format, several keys — are not
735 /// reported: they are returned to the caller by this very call, before
736 /// there is a loop to be silent in, and they are deployment mistakes
737 /// rather than a store that stopped answering.
738 ///
739 /// # Errors
740 ///
741 /// If the key names no format and none was stated — a watch that cannot
742 /// parse what it fetches would poll forever and deliver nothing, so it
743 /// refuses at the start instead. If the source reads several keys: an ETag
744 /// belongs to an object, and a set of objects has none. Or if `on_change`
745 /// returns an error, which ends the watch. Transport failures do not
746 /// surface here; they are retried.
747 pub async fn watch<F>(
748 &self,
749 watching: &Watching,
750 interval: Duration,
751 mut on_change: F,
752 ) -> Result<(), Error>
753 where
754 F: FnMut(Fetched) -> Result<(), Error> + Send,
755 {
756 // Checked before the first tick: with no format every `read` inside
757 // the loop fails, and the `if let Ok` there — right for a transient
758 // network failure — would swallow a permanent configuration mistake.
759 // Both are returned and recorded nowhere: nothing has been asked of
760 // the bucket yet, and `reachable()` is *whether the store answered the
761 // last time it was asked*.
762 self.format()?;
763 // Refused up front for the same reason, so a multi-key source fails at
764 // `watch` rather than on the first change, hours later.
765 self.single_key()?;
766
767 let mut seen: Option<String> = None;
768
769 while watching.keep_going() {
770 match self.etag().await {
771 // The first tick records the tag without firing: the object it
772 // names is the one the caller already has.
773 Ok(tag) if seen.is_none() => seen = Some(tag),
774
775 Ok(tag) if seen.as_ref() != Some(&tag) => {
776 // The tag is taken from the read itself rather than from
777 // the check, so a write landing between the two is not
778 // delivered twice.
779 match self.read().await {
780 Ok((document, current)) => {
781 seen = current.or(Some(tag));
782
783 guarded(&mut on_change, document, &self.describe())?;
784 }
785 // The `HEAD` answered and the `GET` did not, so the
786 // object this loop exists to deliver has changed and
787 // has not been delivered. `seen` is deliberately left
788 // where it was, so the next tick tries the same tag
789 // again rather than treating a failed read as read.
790 Err(error) => self.attempts.failed(&error),
791 }
792 }
793
794 // Unchanged: the object is what it was, which is a store
795 // answering. Nothing to report and nothing to deliver.
796 Ok(_) => {}
797
798 // The check itself failed — an expired credential, a bucket
799 // briefly unreachable. It does not end the watch, which is
800 // what makes reporting it the only way anyone learns that
801 // this loop has been polling into the void.
802 Err(error) => self.attempts.failed(&error),
803 }
804
805 sleep_while(interval, watching).await;
806 }
807
808 Ok(())
809 }
810
811 /// Sorts one of the SDK's failures into a kind.
812 ///
813 /// The code, not the status: S3 answers `AccessDenied` and
814 /// `RequestTimeTooSkewed` with the same 403, and only one of them is
815 /// something waiting cannot cure.
816 fn classified<E: ProvideErrorMetadata + std::fmt::Display>(&self, error: &E) -> Error {
817 let described = format!("{}: {error}", self.describe());
818
819 match error.code() {
820 Some(code) if AUTH_CODES.contains(&code) => Error::auth(described),
821 _ => Error::remote(described),
822 }
823 }
824
825 /// The object's ETag, which changes when its body does.
826 async fn etag(&self) -> Result<String, Error> {
827 let key = self.single_key()?;
828
829 let head = self
830 .client
831 .head_object()
832 .bucket(&self.bucket)
833 .key(key)
834 .send()
835 .await
836 .map_err(|error| self.classified(&error))?;
837
838 head.e_tag()
839 .map(str::to_owned)
840 .ok_or_else(|| Error::remote(format!("{}: the object has no ETag", self.describe())))
841 }
842
843 /// The one object a watch follows, and the ETag it was read at.
844 async fn read(&self) -> Result<(Fetched, Option<String>), Error> {
845 let format = self.format()?;
846 let key = self.single_key()?;
847
848 let (text, tag) = self.object(key).await?;
849
850 Ok((Fetched::new(text, format), tag))
851 }
852
853 /// One object's body, and its ETag.
854 async fn object(&self, key: &str) -> Result<(String, Option<String>), Error> {
855 let object = self
856 .client
857 .get_object()
858 .bucket(&self.bucket)
859 .key(key)
860 .send()
861 .await
862 .map_err(|error| self.classified(&error))?;
863
864 let tag = object.e_tag().map(str::to_owned);
865
866 let bytes = object
867 .body
868 .collect()
869 .await
870 .map_err(|error| Error::remote(format!("{}: `{key}`: {error}", self.describe())))?
871 .into_bytes();
872
873 let text = String::from_utf8(bytes.to_vec()).map_err(|error| {
874 Error::remote(format!(
875 "{}: `{key}` is not UTF-8: {error}",
876 self.describe()
877 ))
878 })?;
879
880 Ok((text, tag))
881 }
882
883 /// The `(key, document)` pairs this source reads, in merge order.
884 ///
885 /// Every key must answer: merging the four that did would leave a process
886 /// running a configuration with a section quietly missing from it.
887 async fn documents(&self) -> Result<Vec<(String, String)>, Error> {
888 let keys = match &self.keys {
889 Keys::One(key) => vec![key.clone()],
890 Keys::Several(keys) => keys.clone(),
891 Keys::Prefix(prefix) => self.listed(prefix).await?,
892 };
893
894 // A prefix that matched nothing is a missing configuration rather than
895 // an empty one, and saying so here beats an empty merge's vaguer word.
896 if keys.is_empty() {
897 return Err(Error::remote(format!(
898 "{}: nothing matched, so there is nothing to load",
899 self.describe()
900 )));
901 }
902
903 let mut documents = Vec::with_capacity(keys.len());
904
905 for key in keys {
906 let (text, _tag) = self.object(&key).await?;
907
908 documents.push((key, text));
909 }
910
911 Ok(documents)
912 }
913
914 /// Every key under `prefix`, from `ListObjectsV2`.
915 ///
916 /// The budget is applied **to the listing**: a page is asked for one key
917 /// more than the budget allows, so a prefix pointed at a whole bucket is
918 /// refused after one request rather than after a million bodies. S3 lists
919 /// in UTF-8 order and pages continue where the last left off, so the
920 /// result is already sorted — which the prefix rule needs, because the
921 /// same set of keys has to produce the same document and the same
922 /// diagnostic every time.
923 async fn listed(&self, prefix: &str) -> Result<Vec<String>, Error> {
924 // One more than the budget, so the refusal happens on the count rather
925 // than on a page boundary: a bucket holding exactly the budget is
926 // allowed, and the first key past it is not.
927 let per_page = i32::try_from(MOST_KEYS + 1).unwrap_or(i32::MAX);
928
929 let mut found: Vec<String> = Vec::new();
930 let mut token: Option<String> = None;
931
932 for _ in 0..MOST_LIST_PAGES {
933 let page = self
934 .client
935 .list_objects_v2()
936 .bucket(&self.bucket)
937 .prefix(prefix)
938 .max_keys(per_page)
939 .set_continuation_token(token)
940 .send()
941 .await
942 .map_err(|error| self.classified(&error))?;
943
944 for object in page.contents() {
945 let Some(key) = object.key() else {
946 continue;
947 };
948
949 // The store is not trusted to have honoured the prefix it was
950 // given: a proxy in front of it could rewrite the request, and
951 // the check is one comparison.
952 documents::under_prefix(key, prefix, &self.describe())?;
953
954 // The zero-byte object the console creates when somebody makes
955 // a "folder". It is not a missing document; it is not a
956 // document.
957 if key.ends_with('/') {
958 continue;
959 }
960
961 found.push(key.to_owned());
962 }
963
964 // Checked per page, so the refusal costs one listing rather than
965 // every listing plus every body.
966 documents::within_key_budget(found.len(), &self.describe())?;
967
968 token = page.next_continuation_token().map(str::to_owned);
969
970 if token.is_none() {
971 return Ok(found);
972 }
973 }
974
975 Err(Error::remote(format!(
976 "{}: the listing did not finish in {MOST_LIST_PAGES} pages; \
977 the store is not advancing the continuation token",
978 self.describe()
979 )))
980 }
981}
982
983impl AsyncRemoteSource for S3 {
984 fn fetch(&self) -> Pin<Box<dyn Future<Output = Result<Fetched, Error>> + Send + '_>> {
985 Box::pin(async move {
986 let format = self.format()?;
987
988 let documents = self.documents().await?;
989
990 // A named list is read in call order and a listing arrives in
991 // UTF-8 order, which is what each rule wants — so nothing is
992 // reordered here.
993 documents::merged(&documents, format, self.overlap(), &self.describe())
994 })
995 }
996
997 fn describe(&self) -> String {
998 // The endpoint tells MinIO apart from AWS, and one MinIO from
999 // another — the detail that matters when an error says "no such
1000 // bucket" and there are three object stores it could mean.
1001 match &self.endpoint {
1002 Some(endpoint) => format!("s3 {endpoint} {}/{}", self.bucket, self.keys.describe()),
1003 None => format!("s3 {}/{}", self.bucket, self.keys.describe()),
1004 }
1005 }
1006}
1007
1008impl std::fmt::Debug for S3 {
1009 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1010 f.debug_struct("S3")
1011 .field("bucket", &self.bucket)
1012 .field("keys", &self.keys)
1013 .field("format", &self.format)
1014 .finish_non_exhaustive()
1015 }
1016}
1017
1018/// Sleeps in slices, so a stop is noticed inside the interval rather than after
1019/// it — a thirty-second poll should not mean a thirty-second exit.
1020async fn sleep_while(total: Duration, watching: &Watching) {
1021 const SLICE: Duration = Duration::from_millis(250);
1022
1023 let mut slept = Duration::ZERO;
1024
1025 while slept < total && watching.keep_going() {
1026 // `min`, so an interval below the slice sleeps what was asked, not a
1027 // silently rounded-up quarter second.
1028 tokio::time::sleep(SLICE.min(total - slept)).await;
1029 slept += SLICE;
1030 }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035 use std::io::{Read, Write};
1036 use std::net::TcpListener;
1037
1038 use aws_sdk_s3::config::retry::RetryConfig;
1039 use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
1040
1041 use super::*;
1042
1043 /// An S3 pointed at `endpoint`, with credentials that exist only here and
1044 /// the retry policy `retries` asks for.
1045 ///
1046 /// Static credentials rather than the environment's: a test that reads the
1047 /// ambient credential chain passes or fails according to whose laptop it
1048 /// is on. The retry policy is explicit for the same reason — the real
1049 /// construction paths inherit `aws-config`'s standard three attempts, and
1050 /// a test should say which number it is asserting about.
1051 fn against(endpoint: &str, retries: RetryConfig) -> S3 {
1052 let config = aws_sdk_s3::config::Builder::new()
1053 .behavior_version(BehaviorVersion::latest())
1054 .region(Region::new("us-east-1"))
1055 .endpoint_url(endpoint)
1056 .force_path_style(true)
1057 .retry_config(retries)
1058 .credentials_provider(Credentials::for_tests())
1059 .build();
1060
1061 S3::from_client(Client::from_conf(config), "myapp-config", "prod/db.json")
1062 }
1063
1064 /// Answers every request with `status` and `body`, and counts them.
1065 fn scripted(
1066 status: &'static str,
1067 body: impl Into<String>,
1068 ) -> (
1069 String,
1070 std::sync::Arc<std::sync::atomic::AtomicUsize>,
1071 std::thread::JoinHandle<()>,
1072 ) {
1073 use std::sync::atomic::{AtomicUsize, Ordering};
1074
1075 let body = body.into();
1076
1077 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1078 let endpoint = format!("http://{}", listener.local_addr().unwrap());
1079 let requests = std::sync::Arc::new(AtomicUsize::new(0));
1080
1081 let counter = std::sync::Arc::clone(&requests);
1082 let server = std::thread::spawn(move || {
1083 // Bounded: a refusal the SDK does not retry costs one request, and
1084 // the loop must not outlive the test either way. Above
1085 // `MOST_LIST_PAGES`, so the page cap is what ends a listing test
1086 // rather than the server running out of turns.
1087 for _ in 0..MOST_LIST_PAGES + 4 {
1088 let Ok((mut stream, _)) = listener.accept() else {
1089 return;
1090 };
1091
1092 let mut seen = Vec::new();
1093 let mut byte = [0u8; 1];
1094
1095 while !seen.ends_with(b"\r\n\r\n") && stream.read(&mut byte).is_ok_and(|n| n == 1) {
1096 seen.push(byte[0]);
1097 }
1098
1099 counter.fetch_add(1, Ordering::SeqCst);
1100
1101 let response = format!(
1102 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nContent-Type: application/xml\r\nConnection: close\r\n\r\n{body}",
1103 body.len()
1104 );
1105 let _ = stream.write_all(response.as_bytes());
1106 }
1107 });
1108
1109 (endpoint, requests, server)
1110 }
1111
1112 /// A `ListObjectsV2` answer naming `keys`, and truncated if `token` says
1113 /// where to carry on from.
1114 fn listing(keys: &[String], token: Option<&str>) -> String {
1115 let contents: String = keys
1116 .iter()
1117 .map(|key| format!("<Contents><Key>{key}</Key><Size>1</Size></Contents>"))
1118 .collect();
1119
1120 let truncation = match token {
1121 Some(token) => format!(
1122 "<IsTruncated>true</IsTruncated><NextContinuationToken>{token}</NextContinuationToken>"
1123 ),
1124 None => "<IsTruncated>false</IsTruncated>".to_owned(),
1125 };
1126
1127 format!(
1128 r#"<?xml version="1.0" encoding="UTF-8"?><ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>myapp-config</Name>{truncation}{contents}</ListBucketResult>"#
1129 )
1130 }
1131
1132 /// An S3 reading `keys`, against `endpoint`, with no retries.
1133 fn reading(endpoint: &str, keys: Keys) -> S3 {
1134 let config = aws_sdk_s3::config::Builder::new()
1135 .behavior_version(BehaviorVersion::latest())
1136 .region(Region::new("us-east-1"))
1137 .endpoint_url(endpoint)
1138 .force_path_style(true)
1139 .retry_config(RetryConfig::disabled())
1140 .credentials_provider(Credentials::for_tests())
1141 .build();
1142
1143 S3::from_client(Client::from_conf(config), "myapp-config", keys).with_format(Format::Json)
1144 }
1145
1146 /// The budget has to bite on the *listing*. A prefix pointed at a whole
1147 /// bucket must cost one request, not one request and half a million
1148 /// bodies — which is the difference between a refusal and an outage.
1149 #[tokio::test]
1150 async fn a_prefix_over_the_budget_is_refused_after_one_listing() {
1151 let keys: Vec<String> = (0..=MOST_KEYS)
1152 .map(|n| format!("prod/section-{n:04}.json"))
1153 .collect();
1154
1155 let (endpoint, requests, server) = scripted("200 OK", listing(&keys, None));
1156
1157 let error = reading(&endpoint, Keys::prefix("prod/"))
1158 .fetch()
1159 .await
1160 .expect_err("the prefix matches more keys than the budget allows");
1161
1162 drop(server);
1163
1164 assert!(error.to_string().contains("narrow the prefix"), "{error}");
1165 assert_eq!(
1166 requests.load(std::sync::atomic::Ordering::SeqCst),
1167 1,
1168 "the budget is checked on the listing, so not one body is fetched"
1169 );
1170 }
1171
1172 /// A store is not trusted to have honoured the prefix it was given: a
1173 /// proxy in front of it can rewrite a request, and a key from outside the
1174 /// prefix would put somebody else's document into this configuration.
1175 #[tokio::test]
1176 async fn a_key_the_store_answers_with_from_outside_the_prefix_is_refused() {
1177 let (endpoint, _requests, server) = scripted(
1178 "200 OK",
1179 listing(&["other-tenant/db.json".to_owned()], None),
1180 );
1181
1182 let error = reading(&endpoint, Keys::prefix("prod/"))
1183 .fetch()
1184 .await
1185 .expect_err("that key is not under the prefix that was asked for");
1186
1187 drop(server);
1188
1189 assert!(
1190 error.to_string().contains("other-tenant/db.json"),
1191 "{error}"
1192 );
1193 assert!(
1194 error.to_string().contains("not under the prefix"),
1195 "{error}"
1196 );
1197 }
1198
1199 /// A continuation token that never clears is a loop inside a fetch, and
1200 /// the budget on the keys cannot see it: the count never moves.
1201 #[tokio::test]
1202 async fn a_listing_that_never_finishes_is_given_up_on() {
1203 // Every page: no keys, and always another page to come.
1204 let (endpoint, requests, server) = scripted("200 OK", listing(&[], Some("always-more")));
1205
1206 let error = reading(&endpoint, Keys::prefix("prod/"))
1207 .fetch()
1208 .await
1209 .expect_err("the token never clears");
1210
1211 drop(server);
1212
1213 assert!(
1214 error.to_string().contains("not advancing the continuation"),
1215 "{error}"
1216 );
1217 assert_eq!(
1218 requests.load(std::sync::atomic::Ordering::SeqCst),
1219 MOST_LIST_PAGES,
1220 "the page budget is what ends it"
1221 );
1222 }
1223
1224 /// A watch compares ETags, and a set of objects has none. Refused at
1225 /// `watch`, so it fails now rather than in six hours by never firing.
1226 #[tokio::test]
1227 async fn a_multi_key_source_refuses_to_be_watched_and_says_what_to_do_instead() {
1228 let source = reading("http://127.0.0.1:9", Keys::several(["a.json", "b.json"]));
1229
1230 let watch = dynamic_config::RemoteWatch::new();
1231 let watching = watch.watching();
1232
1233 let error = source
1234 .watch(&watching, Duration::from_millis(50), |_| Ok(()))
1235 .await
1236 .expect_err("a merged document has no one ETag");
1237
1238 assert!(error.to_string().contains("several keys"), "{error}");
1239 assert!(
1240 error.to_string().contains("refresh_remote_async"),
1241 "{error}"
1242 );
1243 }
1244
1245 /// Two keys naming two formats is the confusing failure worth catching by
1246 /// name: `server.toml` parsed as JSON is a syntax error about a file that
1247 /// has no syntax error in it.
1248 #[tokio::test]
1249 async fn keys_naming_two_formats_are_reported_rather_than_guessed() {
1250 let config = aws_sdk_s3::config::Builder::new()
1251 .behavior_version(BehaviorVersion::latest())
1252 .region(Region::new("us-east-1"))
1253 .endpoint_url("http://127.0.0.1:9")
1254 .force_path_style(true)
1255 .retry_config(RetryConfig::disabled())
1256 .credentials_provider(Credentials::for_tests())
1257 .build();
1258
1259 let source = S3::from_client(
1260 Client::from_conf(config),
1261 "myapp-config",
1262 Keys::several(["prod/db.json", "prod/server.toml"]),
1263 );
1264
1265 let error = source.fetch().await.expect_err("two formats, one source");
1266
1267 assert!(error.to_string().contains("prod/db.json"), "{error}");
1268 assert!(error.to_string().contains("prod/server.toml"), "{error}");
1269 assert!(error.to_string().contains("with_format"), "{error}");
1270 }
1271
1272 /// The store saying no to the credentials, which no amount of waiting
1273 /// changes — so a watch loop should stop rather than back off.
1274 #[tokio::test]
1275 async fn access_denied_is_an_auth_failure() {
1276 let (endpoint, _requests, server) = scripted(
1277 "403 Forbidden",
1278 r#"<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>"#,
1279 );
1280
1281 let error = against(&endpoint, RetryConfig::disabled())
1282 .fetch()
1283 .await
1284 .expect_err("the store refused the credentials");
1285
1286 drop(server);
1287
1288 assert_eq!(error.kind(), dynamic_config::ErrorKind::Auth, "{error}");
1289 assert!(error.to_string().contains("prod/db.json"), "{error}");
1290 }
1291
1292 /// The same 403, a different code, and the opposite verdict: a clock too
1293 /// far out of step does come right, so classifying it `Auth` would stop a
1294 /// watch loop that NTP was about to fix.
1295 #[tokio::test]
1296 async fn a_skewed_clock_shares_the_403_and_stays_remote() {
1297 let (endpoint, _requests, server) = scripted(
1298 "403 Forbidden",
1299 r#"<?xml version="1.0" encoding="UTF-8"?><Error><Code>RequestTimeTooSkewed</Code><Message>The difference between the request time and the current time is too large.</Message></Error>"#,
1300 );
1301
1302 let error = against(&endpoint, RetryConfig::disabled())
1303 .fetch()
1304 .await
1305 .expect_err("the store refused the request");
1306
1307 drop(server);
1308
1309 assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote, "{error}");
1310 }
1311
1312 /// A store that is simply not there is `Remote`, never `Auth`.
1313 #[tokio::test]
1314 async fn an_unreachable_store_is_remote_rather_than_auth() {
1315 // Port 9 is discard; nothing listens there.
1316 let error = against("http://127.0.0.1:9", RetryConfig::disabled())
1317 .with_timeout(Duration::from_millis(200))
1318 .fetch()
1319 .await
1320 .expect_err("nothing is listening");
1321
1322 assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote, "{error}");
1323 }
1324
1325 /// `with_timeout` is per *attempt*, so the README's arithmetic — a
1326 /// timeout times the attempt count — is a tested claim rather than a
1327 /// hopeful one.
1328 #[tokio::test]
1329 async fn the_deadline_is_per_attempt_and_the_sdk_retries_underneath() {
1330 // Accepts and never answers: the failure only a per-attempt deadline
1331 // catches, and one the SDK treats as worth retrying.
1332 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1333 let endpoint = format!("http://{}", listener.local_addr().unwrap());
1334
1335 let silent = std::thread::spawn(move || {
1336 let mut held = Vec::new();
1337
1338 while held.len() < 3 {
1339 let Ok(accepted) = listener.accept() else {
1340 return;
1341 };
1342 held.push(accepted);
1343 }
1344
1345 std::thread::sleep(Duration::from_secs(1));
1346 });
1347
1348 const ATTEMPT: Duration = Duration::from_millis(300);
1349
1350 // Three attempts, the number `aws-config` gives the real
1351 // construction paths, with the backoff shortened so the test spends
1352 // its time on the attempts rather than between them.
1353 let source = against(
1354 &endpoint,
1355 RetryConfig::standard()
1356 .with_max_attempts(3)
1357 .with_initial_backoff(Duration::from_millis(1)),
1358 )
1359 .with_timeout(ATTEMPT);
1360
1361 assert_eq!(
1362 source
1363 .client
1364 .config()
1365 .timeout_config()
1366 .and_then(aws_sdk_s3::config::timeout::TimeoutConfig::operation_attempt_timeout),
1367 Some(ATTEMPT),
1368 "the value has to reach the SDK, not merely be remembered here"
1369 );
1370
1371 let started = std::time::Instant::now();
1372 let error = source.fetch().await.expect_err("nothing ever answers");
1373 let elapsed = started.elapsed();
1374
1375 assert!(
1376 elapsed > ATTEMPT * 2,
1377 "the SDK retries beneath the per-attempt deadline, so the call \
1378 outlasts one attempt — that is the README's multiplier: {elapsed:?}"
1379 );
1380 assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote, "{error}");
1381
1382 let _ = silent.join();
1383 }
1384
1385 // -----------------------------------------------------------------------
1386 // Reporting a failing watch
1387 //
1388 // One `#[dynamic_config]` type per test: the snapshot, the remote slot and
1389 // the sink's generation all live in statics keyed by the type, so two
1390 // tests sharing one would race and — worse — pass alone.
1391 // -----------------------------------------------------------------------
1392
1393 /// The failure nobody notices: a poll loop *survives* its failures by
1394 /// design, so a bucket that stopped answering on Tuesday looks exactly
1395 /// like a configuration nobody has changed since Tuesday.
1396 ///
1397 /// What the status must say afterwards is a *pair*: `reachable()` goes to
1398 /// `Some(false)` while `last_fetch` keeps the instant the last document
1399 /// really arrived — so an alert can ask "down, and stale for how long".
1400 /// A failure that reset the clock would hide the second half.
1401 #[tokio::test]
1402 async fn a_failing_poll_reports_the_store_as_down_and_leaves_the_clock_running() {
1403 use dynamic_config::dynamic_config;
1404
1405 #[dynamic_config]
1406 #[derive(Debug, serde::Deserialize)]
1407 struct Polled {
1408 // Never read: this test is about the status the store records,
1409 // not about the document, which never gets as far as a snapshot.
1410 #[allow(dead_code)]
1411 host: String,
1412 }
1413
1414 let (answering, _requests, answered) = scripted("200 OK", r#"{"db": {"host": "base"}}"#);
1415
1416 Polled::set_remote_async(against(&answering, RetryConfig::disabled()));
1417 Polled::refresh_remote_async()
1418 .await
1419 .expect("the store answers the first read");
1420
1421 // Taken after the source is installed, which is what fences it.
1422 let sink = Polled::remote_sink();
1423 let before = sink.status();
1424
1425 assert_eq!(before.reachable(), Some(true), "one fetch, and it answered");
1426 assert!(before.last_fetch.is_some());
1427
1428 // A second endpoint rather than a second mood: the store the watch
1429 // polls has started refusing, which is what an expired credential or
1430 // a gateway that went away looks like from inside the loop.
1431 let (refusing, _polls, refused) = scripted(
1432 "500 Internal Server Error",
1433 "<Error><Code>Internal</Code></Error>",
1434 );
1435
1436 let watcher = against(&refusing, RetryConfig::disabled())
1437 .with_timeout(Duration::from_millis(500))
1438 .reporting_to(sink);
1439
1440 let watch = dynamic_config::RemoteWatch::new();
1441 let watching = watch.watching();
1442
1443 let polling = tokio::spawn(async move {
1444 watcher
1445 .watch(&watching, Duration::from_millis(50), |_| Ok(()))
1446 .await
1447 });
1448
1449 let deadline = std::time::Instant::now() + Duration::from_secs(20);
1450
1451 while sink.status().consecutive_failures == 0 && std::time::Instant::now() < deadline {
1452 tokio::time::sleep(Duration::from_millis(20)).await;
1453 }
1454
1455 let after = sink.status();
1456
1457 assert!(
1458 !polling.is_finished(),
1459 "a failed check does not end the watch — which is exactly why \
1460 reporting it is the only way anyone hears about it"
1461 );
1462 assert_eq!(
1463 after.reachable(),
1464 Some(false),
1465 "a loop polling into the void is a store that is down"
1466 );
1467 assert_eq!(
1468 after.last_fetch, before.last_fetch,
1469 "the staleness clock keeps running: `last_fetch` is when a document \
1470 last arrived, and a failed attempt is not one"
1471 );
1472 assert_eq!(
1473 after.fetches, before.fetches,
1474 "a failure is not a fetch, however it is counted elsewhere"
1475 );
1476 assert_eq!(
1477 after
1478 .last_failure
1479 .as_ref()
1480 .expect("a failure was recorded")
1481 .kind,
1482 dynamic_config::ErrorKind::Remote,
1483 "a store answering 500 may yet come back"
1484 );
1485
1486 // The recorded failure is a kind and a path, and the bucket, the key
1487 // and the endpoint are in none of them.
1488 let recorded = format!("{:?}", after.last_failure);
1489
1490 assert!(!recorded.contains("myapp-config"), "{recorded}");
1491 assert!(!recorded.contains("prod/db.json"), "{recorded}");
1492
1493 watch.stop();
1494 let _ = polling.await;
1495 drop((answered, refused));
1496 }
1497
1498 /// Answers every `HEAD` with an ETag — a new one after the first, so the
1499 /// object has changed — and every `GET` with a refusal.
1500 ///
1501 /// The shape of a bucket whose read policy went away, or of an object
1502 /// large enough that the transfer is the thing failing: the check the loop
1503 /// makes every tick keeps working, and the read it exists to make does
1504 /// not. Nothing about that reaches the caller, so nothing about it reaches
1505 /// anyone.
1506 fn heads_but_refuses_the_body() -> (String, std::thread::JoinHandle<()>) {
1507 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1508 let endpoint = format!("http://{}", listener.local_addr().unwrap());
1509
1510 let server = std::thread::spawn(move || {
1511 let mut heads = 0;
1512
1513 // Bounded, so the thread cannot outlive the test whatever the
1514 // loop does.
1515 for _ in 0..64 {
1516 let Ok((mut stream, _)) = listener.accept() else {
1517 return;
1518 };
1519
1520 let mut seen = Vec::new();
1521 let mut byte = [0u8; 1];
1522
1523 while !seen.ends_with(b"\r\n\r\n") && stream.read(&mut byte).is_ok_and(|n| n == 1) {
1524 seen.push(byte[0]);
1525 }
1526
1527 let response = if String::from_utf8_lossy(&seen).starts_with("HEAD") {
1528 heads += 1;
1529
1530 // The first tick records the tag without reading. The
1531 // second is the change this loop exists for.
1532 let tag = if heads > 1 { "second" } else { "first" };
1533
1534 format!(
1535 "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nETag: \"{tag}\"\r\nConnection: close\r\n\r\n"
1536 )
1537 } else {
1538 let body = "<Error><Code>Internal</Code></Error>";
1539
1540 format!(
1541 "HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1542 body.len()
1543 )
1544 };
1545
1546 if stream.write_all(response.as_bytes()).is_err() {
1547 return;
1548 }
1549 }
1550 });
1551
1552 (endpoint, server)
1553 }
1554
1555 /// The second failure site, and the easier one to miss: the ETag moved, so
1556 /// the object *did* change, and the read that would have delivered it
1557 /// failed. The loop swallows that by design — it is the same `if let Ok`
1558 /// that makes a network blip survivable — so without a report the store
1559 /// looks healthy while the configuration it serves is frozen.
1560 #[tokio::test]
1561 async fn a_read_that_fails_after_the_tag_moved_is_reported_too() {
1562 use dynamic_config::dynamic_config;
1563
1564 #[dynamic_config]
1565 #[derive(Debug, serde::Deserialize)]
1566 struct Torn {
1567 // Never read, for the reason the test above gives.
1568 #[allow(dead_code)]
1569 host: String,
1570 }
1571
1572 let sink = Torn::remote_sink();
1573 let (endpoint, server) = heads_but_refuses_the_body();
1574
1575 let watcher = against(&endpoint, RetryConfig::disabled())
1576 .with_timeout(Duration::from_millis(500))
1577 .reporting_to(sink);
1578
1579 let watch = dynamic_config::RemoteWatch::new();
1580 let watching = watch.watching();
1581
1582 let polling = tokio::spawn(async move {
1583 watcher
1584 .watch(&watching, Duration::from_millis(50), |_| Ok(()))
1585 .await
1586 });
1587
1588 let deadline = std::time::Instant::now() + Duration::from_secs(20);
1589
1590 while sink.status().consecutive_failures == 0 && std::time::Instant::now() < deadline {
1591 tokio::time::sleep(Duration::from_millis(20)).await;
1592 }
1593
1594 assert!(
1595 !polling.is_finished(),
1596 "a failed read does not end the watch either"
1597 );
1598 assert_eq!(
1599 sink.status().reachable(),
1600 Some(false),
1601 "the store answered the check and not the read, which is a store \
1602 this loop cannot get a document out of"
1603 );
1604 assert_eq!(
1605 sink.status().fetches,
1606 0,
1607 "nothing was delivered, so nothing is counted as a fetch"
1608 );
1609
1610 watch.stop();
1611 let _ = polling.await;
1612 // Dropped rather than joined: the server is parked in `accept` and no
1613 // connection is coming, so joining it would hang the suite.
1614 drop(server);
1615 }
1616
1617 /// A watch refused at the door is *not* a store that stopped answering.
1618 ///
1619 /// The reasoning is written out beside the same test in
1620 /// `dynamic-config-redis`, which is where 0.6.1's audit settled it for all
1621 /// seven stores: `reachable()` is *whether the store answered the last
1622 /// time it was asked*, and a source that names several keys never asks.
1623 #[tokio::test]
1624 async fn a_watch_refused_at_the_door_is_not_a_store_that_stopped_answering() {
1625 use dynamic_config::dynamic_config;
1626
1627 #[dynamic_config]
1628 #[derive(Debug, serde::Deserialize)]
1629 struct Doorstep {
1630 // Never read, for the reason the test above gives.
1631 #[allow(dead_code)]
1632 host: String,
1633 }
1634
1635 // No source is installed: a sink does not need one, and this is the
1636 // state a program that only ever watches is in.
1637 let sink = Doorstep::remote_sink();
1638
1639 let source =
1640 reading("http://127.0.0.1:9", Keys::several(["a.json", "b.json"])).reporting_to(sink);
1641
1642 let watch = dynamic_config::RemoteWatch::new();
1643 let error = source
1644 .watch(&watch.watching(), Duration::from_millis(50), |_| Ok(()))
1645 .await
1646 .expect_err("an ETag belongs to an object, and a set has none");
1647
1648 assert!(error.to_string().contains("cannot be watched"), "{error}");
1649 assert_eq!(
1650 sink.status().reachable(),
1651 None,
1652 "nothing has been asked of this store, so it is neither up nor down"
1653 );
1654 }
1655
1656 // -----------------------------------------------------------------------
1657 // TLS: the shared vocabulary, and the half the AWS SDK cannot express.
1658 // -----------------------------------------------------------------------
1659
1660 /// A certificate authority, generated here. A committed fixture expires,
1661 /// and a suite that fails on a date nobody chose is worse than one that
1662 /// costs a millisecond.
1663 fn authority() -> String {
1664 use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair};
1665
1666 let key = KeyPair::generate().unwrap();
1667 let mut params = CertificateParams::new(Vec::new()).unwrap();
1668 params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
1669
1670 params.self_signed(&key).unwrap().pem()
1671 }
1672
1673 /// A trust store is what the SDK's TLS context has, so a certificate
1674 /// authority goes through — and construction still reaches no network.
1675 #[tokio::test]
1676 async fn a_private_authority_builds_a_client_and_touches_nothing() {
1677 let config = aws_config::SdkConfig::builder()
1678 .behavior_version(aws_config::BehaviorVersion::latest())
1679 .endpoint_url("https://minio.internal:9000")
1680 .build();
1681
1682 let source = S3::with_tls(
1683 &config,
1684 "myapp-config",
1685 "prod/db.json",
1686 &TlsConfig::new().with_ca_certificate_pem(authority()),
1687 )
1688 .expect("a trust store is what the SDK's TLS context holds");
1689
1690 assert!(
1691 source.describe().contains("minio.internal"),
1692 "the endpoint tells MinIO apart from AWS in an error: {}",
1693 source.describe()
1694 );
1695 }
1696
1697 /// The SDK's connector calls `.expect("cert parsable")` on this material,
1698 /// so a certificate it cannot read would be a panic at the first
1699 /// connection — a long way from the call that supplied it. Refused at
1700 /// construction instead, and without quoting what it choked on.
1701 #[tokio::test]
1702 async fn a_ca_certificate_the_sdk_would_panic_on_is_refused_at_construction() {
1703 let config = aws_config::SdkConfig::builder()
1704 .behavior_version(aws_config::BehaviorVersion::latest())
1705 .build();
1706
1707 let error = S3::with_tls(
1708 &config,
1709 "myapp-config",
1710 "prod/db.json",
1711 &TlsConfig::new().with_ca_certificate_pem(
1712 "-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n",
1713 ),
1714 )
1715 .expect_err("the SDK would have panicked on this");
1716
1717 assert!(error.to_string().contains("not PEM-encoded"), "{error}");
1718 }
1719
1720 /// The one thing S3 cannot express. Refused rather than ignored: a caller
1721 /// who asked to present a certificate and did not would discover it as an
1722 /// authentication failure a long way from the cause.
1723 #[tokio::test]
1724 async fn a_client_certificate_is_refused_and_points_at_the_escape_hatch() {
1725 let config = aws_config::SdkConfig::builder()
1726 .behavior_version(aws_config::BehaviorVersion::latest())
1727 .build();
1728
1729 let error = S3::with_tls(
1730 &config,
1731 "myapp-config",
1732 "prod/db.json",
1733 &TlsConfig::new().with_client_certificate_files("/etc/ssl/app.crt", "/etc/ssl/app.key"),
1734 )
1735 .expect_err("the SDK's TLS context has no client-certificate slot");
1736
1737 assert!(error.to_string().contains("client certificate"), "{error}");
1738 assert!(error.to_string().contains("from_client"), "{error}");
1739 assert!(
1740 error.to_string().contains("refused rather than ignored"),
1741 "{error}"
1742 );
1743 }
1744
1745 /// The private key is the sharpest secret here even where it is refused:
1746 /// the refusal must name the call and not the material.
1747 #[tokio::test]
1748 async fn the_client_certificate_refusal_never_quotes_the_key() {
1749 const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
1750
1751 let config = aws_config::SdkConfig::builder()
1752 .behavior_version(aws_config::BehaviorVersion::latest())
1753 .build();
1754
1755 let error = S3::with_tls(
1756 &config,
1757 "myapp-config",
1758 "prod/db.json",
1759 &TlsConfig::new().with_client_certificate_pem("cert", PLANTED),
1760 )
1761 .expect_err("the SDK's TLS context has no client-certificate slot");
1762
1763 assert!(!error.to_string().contains(PLANTED), "{error}");
1764 assert!(!format!("{error:?}").contains(PLANTED), "{error:?}");
1765 }
1766
1767 /// A CA file that is not there names the path, from the constructor
1768 /// rather than from a panic in a builder chain.
1769 #[tokio::test]
1770 async fn a_missing_ca_file_names_the_path_and_the_material() {
1771 let config = aws_config::SdkConfig::builder()
1772 .behavior_version(aws_config::BehaviorVersion::latest())
1773 .build();
1774
1775 let error = S3::with_tls(
1776 &config,
1777 "myapp-config",
1778 "prod/db.json",
1779 &TlsConfig::new().with_ca_certificate_file("/nonexistent/private-ca.pem"),
1780 )
1781 .expect_err("the CA file is not there");
1782
1783 assert!(
1784 error.to_string().contains("/nonexistent/private-ca.pem"),
1785 "{error}"
1786 );
1787 assert!(error.to_string().contains("the CA certificate"), "{error}");
1788 }
1789}