Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Read the [design](docs/PACKFILE_URI_DESIGN.md) and
| **events** | A small bridge tails the WAL and POSTs ref events to a webhook, exactly-once per (repo, seq, ref) with a durable cursor. `docs/EVENTS.md`. |
| **maintenance** | Checkpoints, geometric compaction, connectivity audits and repairs — one loop that computes the desired state from (config, WAL) every pass and does one bounded unit of the most important missing work. Manual `compact --base` rebuilds the base on a host with sufficient disk. |
| **auth** | `none` (loopback), `token` (static tokens), `oidc` (any OpenID Connect issuer: browser sign-in, ID tokens, and walgit-issued access tokens for git). `/services/public/install.sh` sets a developer's machine up in one idempotent command. |
| **stores** | S3 and S3-compatible (AWS, MinIO, rustfs, R2, Ceph, …) and GCS, first class; an in-memory store for tests. An S3-compatible store must honour `If-Match`/`If-None-Match` on PUT, DELETE and CompleteMultipartUpload: walgit sends no fallback check, so a store that ignores them can overwrite or delete another writer's object. `just test-s3` runs the contract suite that checks this. |
| **stores** | S3 and S3-compatible (AWS, MinIO, rustfs, R2, Ceph, …) and GCS, first class; an in-memory store for tests. An S3-compatible store must honour `If-Match`/`If-None-Match` on PUT, DELETE and CompleteMultipartUpload: walgit sends no fallback check, so a store that ignores them can overwrite or delete another writer's object. `just test-s3` runs the contract suite that checks this. S3 credentials come from the explicit key variables when both are set, otherwise from the AWS default chain (profiles, web identity / IRSA, container and instance roles), which refreshes them. |

## How it works, briefly

Expand Down
2 changes: 1 addition & 1 deletion crates/walgit-store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ pub async fn open_store(cfg: &walgit_config::Config) -> anyhow::Result<DynStore>
walgit_config::StoreBackend::S3 => {
#[cfg(feature = "s3")]
{
Arc::new(s3::S3Store::new(&cfg.store)?)
Arc::new(s3::S3Store::new(&cfg.store).await?)
}
#[cfg(not(feature = "s3"))]
{
Expand Down
102 changes: 66 additions & 36 deletions crates/walgit-store/src/s3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ use std::ops::Range;
use std::time::Duration;

use aws_sdk_s3::Client as S3Client;
use aws_sdk_s3::config::Credentials;
use aws_sdk_s3::config::{Credentials, ProvideCredentials, SharedCredentialsProvider};
use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream as S3ByteStream;
use bytes::Bytes;
Expand Down Expand Up @@ -68,46 +68,76 @@ pub struct S3Store {
impl S3Store {
/// Build a store from `walgit-config::StoreConfig`.
///
/// Credentials are read from the env vars named in
/// `cfg.s3.access_key_env` / `cfg.s3.secret_key_env`
/// (defaults `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`), plus
/// `AWS_SESSION_TOKEN` when present.
pub fn new(cfg: &walgit_config::StoreConfig) -> anyhow::Result<Self> {
let access_key = std::env::var(&cfg.s3.access_key_env).map_err(|_| {
anyhow::anyhow!("s3: env var {} not set (access key)", cfg.s3.access_key_env)
})?;
let secret_key = std::env::var(&cfg.s3.secret_key_env).map_err(|_| {
anyhow::anyhow!("s3: env var {} not set (secret key)", cfg.s3.secret_key_env)
})?;

let creds = static_credentials(
&access_key,
&secret_key,
std::env::var("AWS_SESSION_TOKEN").ok(),
);
/// The env vars named in `cfg.s3.access_key_env` / `cfg.s3.secret_key_env`
/// (defaults `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) override, plus
/// `AWS_SESSION_TOKEN` when present. When both key variables are unset,
/// the AWS default chain resolves and refreshes credentials (including
/// projected service-account tokens / IRSA). A partial or empty explicit
/// key pair is an error, not a fallback to another identity.
pub async fn new(cfg: &walgit_config::StoreConfig) -> anyhow::Result<Self> {
let region = aws_sdk_s3::config::Region::new(cfg.s3.region.clone());

let build_config = |retry_config| {
let mut s3_config = aws_sdk_s3::Config::builder()
.region(region.clone())
.credentials_provider(creds.clone())
.force_path_style(cfg.s3.force_path_style)
.retry_config(retry_config)
.behavior_version_latest();

if !cfg.s3.endpoint.is_empty() {
s3_config = s3_config.endpoint_url(&cfg.s3.endpoint);
let credentials = match (
std::env::var(&cfg.s3.access_key_env),
std::env::var(&cfg.s3.secret_key_env),
) {
(Ok(access_key), Ok(secret_key))
if !access_key.is_empty() && !secret_key.is_empty() =>
{
SharedCredentialsProvider::new(static_credentials(
&access_key,
&secret_key,
std::env::var("AWS_SESSION_TOKEN").ok(),
))
}
s3_config.build()
(Err(std::env::VarError::NotPresent), Err(std::env::VarError::NotPresent)) => {
let chain =
aws_config::default_provider::credentials::DefaultCredentialsChain::builder()
.region(region.clone())
.build()
.await;
// Resolve once now, so a host with no AWS identity fails at startup
// with the chain's own reason rather than on the first request,
// after the profile and IMDS lookups have timed out.
chain.provide_credentials().await.map_err(|e| {
anyhow::anyhow!("s3: the AWS default credential chain resolved nothing: {e}")
})?;
SharedCredentialsProvider::new(chain)
}
_ => anyhow::bail!(
"set both {} and {} to non-empty credentials, or leave both unset for the AWS default credential chain",
cfg.s3.access_key_env,
cfg.s3.secret_key_env,
),
};

let client = S3Client::from_conf(build_config(
aws_sdk_s3::config::retry::RetryConfig::disabled(),
));
let read_client = S3Client::from_conf(build_config(
aws_sdk_s3::config::retry::RetryConfig::standard()
.with_max_attempts(cfg.max_retries.saturating_add(1)),
));
let identity_cache = aws_sdk_s3::config::IdentityCache::lazy().build();
let mut s3_config = aws_sdk_s3::Config::builder()
.region(region)
.credentials_provider(credentials)
.identity_cache(identity_cache.clone())
.force_path_style(cfg.s3.force_path_style)
.retry_config(aws_sdk_s3::config::retry::RetryConfig::disabled())
.behavior_version_latest();

if !cfg.s3.endpoint.is_empty() {
s3_config = s3_config.endpoint_url(&cfg.s3.endpoint);
}
let s3_config = s3_config.build();

// Derived from the same config and given the same identity cache, so both
// clients share one resolver and one cache: separate caches would each
// exchange and refresh the same role credentials.
let read_config = s3_config
.to_builder()
.identity_cache(identity_cache)
.retry_config(
aws_sdk_s3::config::retry::RetryConfig::standard()
.with_max_attempts(cfg.max_retries.saturating_add(1)),
)
.build();
let client = S3Client::from_conf(s3_config);
let read_client = S3Client::from_conf(read_config);
let http = reqwest::Client::builder().build()?;

Ok(S3Store {
Expand Down
4 changes: 3 additions & 1 deletion crates/walgit-store/tests/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,9 @@ async fn s3_contract() {
..Default::default()
};

let store = walgit_store::s3::S3Store::new(&cfg).expect("S3Store::new");
let store = walgit_store::s3::S3Store::new(&cfg)
.await
.expect("S3Store::new");
let store: DynStore = Arc::new(store);

run_contract(store.clone(), &prefix).await;
Expand Down
Loading
Loading