Skip to main content

Crate dynamic_config_s3

Crate dynamic_config_s3 

Source
Expand description

Read dynamic-config configuration from an S3 object.

The AWS SDK is async throughout, so this implements the async AsyncRemoteSource trait rather than the blocking one.

use dynamic_config_s3::S3;

// Credentials come from the environment the way every other AWS tool finds
// them: variables, the profile, the instance role, IRSA.
DbConfig::set_remote_async(S3::new("myapp-config", "prod/db.json").await?);

// Fetching is explicit; the load that follows touches no network.
DbConfig::refresh_remote_async().await?;

§What it reads

One object, whose body is a whole configuration document — the same bytes that would be in a config file. The format comes from the key’s extension, or from with_format.

§Several objects as one document

A deployment that splits its configuration across a prefix — prod/db.json, prod/server.json — can have one source read the lot, and Keys says which:

// Named keys: a list of layers, merged in the order given, later wins.
let s3 = S3::new("myapp-config", Keys::several(["prod/base.json", "prod/local.json"])).await?;

// A prefix: disjoint sections, and an overlap between two of them is an error.
let s3 = S3::new("myapp-config", Keys::prefix("prod/"))
    .await?
    .with_format(dynamic_config::Format::Json);

Neither shape is atomic, and S3 offers nothing that would make one so. There is no batch read: a named list is one GetObject per key, and a prefix is one ListObjectsV2 and then one GetObject per key it named. A write landing between two of those requests can produce a document that never existed as a whole. AWS made ListObjectsV2 strongly consistent in December 2020, so the listing itself is not the hole it once was — but another implementation of this API is free to be eventually consistent, 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. ListObjectsV2 is paginated, so the count is checked as each page arrives and a prefix over a bucket of a million objects is refused after one request rather than after a million bodies. Every key the store answers with is checked against the literal prefix, and a key ending in / — the zero-byte object the console makes when somebody creates a “folder” — is not a document and is skipped.

Three consequences that belong here rather than in an incident:

  • Provenance becomes store-grained. The merged document is one layer, so source_of answers “from s3 … keys a, b” rather than naming which key supplied a value. describe names the whole set, which is as close as one layer gets.
  • One unreadable key fails the whole fetch. A configuration quietly missing a section is worse than a refresh that failed and left the last document serving.
  • A multi-key source cannot be watched. What a watch delivers is the object that changed, and a merged document has no one ETag; it refuses at watch and points at polling refresh_remote_async().

§Credentials

Through aws-config, which is the chain every AWS tool uses: AWS_ACCESS_KEY_ID, the shared profile, the EC2 instance role, the ECS task role, and IRSA on EKS. That is deliberately not re-implemented here — a second credential chain in a program that already has one is a bug waiting for a rotation.

with_config takes an SdkConfig the program already built, which is also how a non-AWS endpoint is reached: MinIO, Ceph, Cloudflare R2, Backblaze B2 all speak this API.

§Watching

S3 cannot tell you when an object changes without a notification pipeline — SNS, SQS, EventBridge — that is a deployment’s decision, not a library’s. So watch polls, and says so.

What it does not do is download the object every tick. HEAD returns the ETag, which changes when the body does, so an unchanged configuration costs one small request and no transfer.

A failing poll says so, if it is asked to. reporting_to hands the loop the same sink it delivers through, and the failures inside it — a HEAD that did not answer, and a GET that did not answer after the ETag moved — are reported to the RemoteStatus as they happen. Surviving a failure is what makes that necessary: a loop that retries forever is a loop that reports nothing forever, so dynamic_config_remote_up would describe the last delivery rather than the last attempt.

A credential the store will not accept — AccessDenied, InvalidAccessKeyId, SignatureDoesNotMatch, an expired session token — is reported as ErrorKind::Auth rather than Remote, because no amount of waiting persuades S3 otherwise. A clock too far out of step (RequestTimeTooSkewed) shares the same 403 and stays Remote: that one does come right.

§Timeouts

S3::with_timeout is the deadline for a single fetch attempt, excluding retries the underlying client performs. Here that exclusion has teeth: the SDK retries, so a five-second timeout with three attempts is a fifteen-second call. See the README’s Timeouts section.

§This crate needs a tokio runtime

Not this crate’s choice: the AWS SDK it is built on is tokio-based (rt-tokio), and watch sleeps on tokio’s timer. Driving it from another executor panics inside the SDK. The etcd and NATS companions are executor-agnostic; this one is honest about not being.

§Every failure branch of the watch loop, and what it reports

A watch is the half of a store dynamic-config cannot see, and reporting_to is what lets it speak: the sink the loop already holds is told about every attempt that came back with nothing. Which attempts those are is a table rather than prose, because the question an operator asks is which silence is deliberate.

Three rules decide the column, and they are the same three in all seven store crates:

  1. A failure the loop survives by retrying reports. That is the case the whole feature exists for: the stream is down, the last delivery is old, and nothing else would ever say so out loud.
  2. A recovery that worked stays silent. Only a delivery or a fetch clears the streak, so reporting a five-minute token turning over on a healthy cluster would drive remote_up to zero and leave it there.
  3. A refusal that never asked the store reports nowhere. No format, a key shape that cannot be watched, material that will not build a client: RemoteStatus::reachable() is whether the store answered the last time it was asked, and these never ask. They are returned to the caller, who is the one holding the mistake — and a status cannot correct them, since it carries a kind and a path and no message.
BranchReports
the format is missing, or the source names several keysno — rule 3: nothing has been asked of the bucket
the HEAD that reads the ETag fails — an expired credential, a bucket briefly unreachableyes, and the loop waits out the interval
the GET after a moved tag failsyes, and seen is left where it was so the next tick tries the same tag again
the first tick, or an ETag that has not movedno — the bucket answered
on_change refuses the documentno — the bucket answered; apply counted the delivery, and what the document did next is ConfigStatus’s half

Structs§

Client
Client for Amazon Simple Storage Service
S3
An object in S3, as a configuration source.
SdkConfig
The AWS types a caller needs to configure this, re-exported so using them needs no direct dependency on the SDK. AWS Shared Configuration
TlsConfig
A private certificate authority and a client certificate, as data.

Enums§

Keys
What a source reads: one object, several named ones, or a prefix.