S3
dynamic-config-s3 reads
configuration from an S3 object — on AWS, or on anything speaking its
API. The AWS SDK is async throughout, so this implements
AsyncRemoteSource.
[dependencies]
dynamic-config = { version = "<version>", features = ["async"] }
dynamic-config-s3 = "<version>"
#![allow(unused)] fn main() { use dynamic_config_s3::S3; DbConfig::set_remote_async(S3::new("myapp-config", "prod/db.json").await?); DbConfig::refresh_remote_async().await?; }
What it reads: one object whose body is a whole configuration
document; format from the key's extension, or with_format.
Several objects, one document: Keys::several(["prod/base.json", "prod/local.json"]) merges in call order — later wins — and
Keys::prefix("prod/") folds every object under the prefix into one
document, refusing an overlap between two of them. Neither is atomic and S3
offers nothing that would make one so: there is no batch read, so a list is
one GetObject per key and a prefix is one ListObjectsV2 and then one
GetObject each. AWS made listings strongly consistent in December 2020,
so the listing itself is no longer the hole it was — but another
implementation of this API is free not to be, and the gap between the
listing and the reads is there in every case.
The 512-key bound is applied to the listing, not to what it fetched: a page is asked for one key more than the budget allows, so a prefix pointed at a whole bucket is refused after one request rather than after a million bodies. Keys the store answers with are checked against the literal prefix, and the zero-byte "folder" object a console leaves behind is skipped.
Credentials: through aws-config — the chain every AWS tool uses
(environment, shared profile, instance role, task role, IRSA).
Deliberately not re-implemented: a second credential chain in a program
that already has one is a bug waiting for a rotation.
Not only AWS: with_config takes an SdkConfig the program built,
which is how MinIO, Ceph, Cloudflare R2 and Backblaze B2 are reached —
path-style addressing is forced, because virtual-hosted buckets need DNS
only AWS has. The test suite runs against MinIO, so this is checked
rather than claimed.
TLS as data, with one gap: S3::with_tls(&config, bucket, key, tls)
takes a private certificate authority — which is what MinIO, Ceph and a
company's own gateway actually need — but not a client certificate.
The AWS SDK's TLS context is a trust store and nothing else, so mTLS is
refused rather than ignored, pointing at from_client. The CA is parsed
here purely in order to refuse: the SDK's connector calls
.expect("cert parsable") on it, so an unreadable certificate would
otherwise be a panic at the first connection. TLS, and the one vocabulary all eight speak
Watching: S3 cannot push without a notification pipeline (SNS, SQS,
EventBridge), and that is a deployment's decision rather than a
library's — so watch polls, and says so. It does not download the
object every tick: HEAD returns the ETag, so an unchanged configuration
costs one small request and no transfer — which matters on a bucket that
charges per gigabyte. The ETag is taken from the read, not the check
before it, so a write landing between the two is not delivered twice. A
multi-key source refuses to be watched: an ETag belongs to an object,
and a set of objects has none.
A failing poll says so: S3::new(bucket, key).await?.reporting_to(sink)
takes the same remote_sink() the loop already pushes documents through, and
reports the failures inside the loop to it — a HEAD that did not answer,
and a GET that did not answer after the ETag moved. Surviving a failure is
what makes this necessary: a poll loop that retries forever is a loop that
reports nothing forever, so dynamic_config_remote_up reports the last
delivery rather than the last attempt, and a bucket that stopped
answering on Tuesday is indistinguishable from a configuration nobody has
changed since Tuesday. A reported failure moves the failure streak and nothing
else, so dynamic_config_remote_last_fetch_seconds keeps ageing while
dynamic_config_remote_up goes to zero — which is the pair an alert wants:
down, and stale for how long. Only the failure's kind and key path are
recorded; the bucket, the key and the endpoint stay out of it.
What is deliberately not reported is a refusal at the door — no format, or a
source naming several keys — because watch() returns those to the caller
standing there, before there is a loop to be silent in, and they are
deployment mistakes rather than a store that stopped answering.
The README carries the full story; MSRV 1.88.