use bytesize::ByteSize;
use object_store::ObjectStoreScheme;
use object_store::aws::AmazonS3ConfigKey;
use serde::{Deserialize, Serialize};
use spate_core::config::{ComponentConfig, ConfigError, redact};
use std::collections::BTreeMap;
use url::Url;
const DENYLIST: &[(&str, &str)] = &[
("bucket", "owned by the typed `url` field"),
("bucket_name", "owned by the typed `url` field"),
("aws_bucket", "owned by the typed `url` field"),
("aws_bucket_name", "owned by the typed `url` field"),
];
fn default_prefetch_bytes() -> ByteSize {
ByteSize::mib(8)
}
fn default_chunk_bytes() -> ByteSize {
ByteSize::kib(512)
}
fn default_split_target_bytes() -> ByteSize {
ByteSize::mib(64)
}
const SPLIT_TARGET_FLOOR: u64 = 1024 * 1024;
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Compression {
#[default]
Auto,
None,
Gzip,
Zstd,
}
#[derive(Clone, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct S3SourceConfig {
pub url: String,
#[serde(default)]
pub compression: Compression,
#[serde(default = "default_split_target_bytes")]
pub split_target_bytes: ByteSize,
#[serde(default = "default_prefetch_bytes")]
pub prefetch_bytes: ByteSize,
#[serde(default = "default_chunk_bytes")]
pub chunk_bytes: ByteSize,
#[serde(default)]
pub store: BTreeMap<String, String>,
}
impl std::fmt::Debug for S3SourceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let S3SourceConfig {
url,
compression,
split_target_bytes,
prefetch_bytes,
chunk_bytes,
store,
} = self;
f.debug_struct("S3SourceConfig")
.field("url", url)
.field("compression", compression)
.field("split_target_bytes", split_target_bytes)
.field("prefetch_bytes", prefetch_bytes)
.field("chunk_bytes", chunk_bytes)
.field("store", &redact::map(store.keys()))
.finish()
}
}
impl S3SourceConfig {
#[must_use]
pub fn new(url: impl Into<String>) -> S3SourceConfig {
S3SourceConfig {
url: url.into(),
compression: Compression::default(),
split_target_bytes: default_split_target_bytes(),
prefetch_bytes: default_prefetch_bytes(),
chunk_bytes: default_chunk_bytes(),
store: BTreeMap::new(),
}
}
pub fn from_component_config(section: &ComponentConfig) -> Result<Self, ConfigError> {
let cfg: S3SourceConfig = section.deserialize_into()?;
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> Result<(), ConfigError> {
let url = parse_url("source.s3.url", &self.url)?;
if self.chunk_bytes.as_u64() == 0 {
return Err(ConfigError::Validation(
"source.s3.chunk_bytes must not be zero".into(),
));
}
if self.prefetch_bytes.as_u64() < self.chunk_bytes.as_u64() {
return Err(ConfigError::Validation(format!(
"source.s3.prefetch_bytes ({}) must be at least chunk_bytes ({})",
self.prefetch_bytes, self.chunk_bytes
)));
}
if self.split_target_bytes.as_u64() < SPLIT_TARGET_FLOOR {
return Err(ConfigError::Validation(format!(
"source.s3.split_target_bytes ({}) must be at least 1MiB",
self.split_target_bytes
)));
}
for key in self.store.keys() {
let lowered = key.to_ascii_lowercase();
if let Some((_, why)) = DENYLIST.iter().find(|(denied, _)| *denied == lowered) {
return Err(ConfigError::Validation(format!(
"source.s3.store.\"{key}\" cannot be overridden: {why}"
)));
}
}
if matches!(
ObjectStoreScheme::parse(&url),
Ok((ObjectStoreScheme::AmazonS3, _))
) && let Some(key) = self.store.keys().find(|k| s3_store_key(k).is_none())
{
return Err(ConfigError::Validation(format!(
"source.s3.store.\"{key}\" is not an object_store S3 option"
)));
}
Ok(())
}
}
pub(crate) fn s3_store_key(key: &str) -> Option<AmazonS3ConfigKey> {
key.to_ascii_lowercase().parse().ok()
}
fn parse_url(field: &str, value: &str) -> Result<Url, ConfigError> {
if value.trim().is_empty() {
return Err(ConfigError::Validation(format!(
"{field} must not be empty"
)));
}
let url = Url::parse(value)
.map_err(|e| ConfigError::Validation(format!("{field} is not a valid URL: {e}")))?;
if url.scheme() == "memory" {
return Err(ConfigError::Validation(format!(
"{field}: memory:// creates a new empty store per component and cannot be \
shared; use file:// for infrastructure-free runs"
)));
}
Ok(url)
}
#[cfg(test)]
mod tests {
use super::*;
fn section(body: &str) -> ComponentConfig {
spate_test::component_config("s3", body)
}
fn minimal() -> String {
" url: s3://bucket/exports/\n".to_string()
}
#[test]
fn minimal_config_gets_documented_defaults() {
let cfg = S3SourceConfig::from_component_config(§ion(&minimal())).unwrap();
assert_eq!(cfg.compression, Compression::Auto);
assert_eq!(cfg.prefetch_bytes, ByteSize::mib(8));
assert_eq!(cfg.chunk_bytes, ByteSize::kib(512));
assert_eq!(cfg.split_target_bytes, ByteSize::mib(64));
assert!(cfg.store.is_empty());
}
#[test]
fn new_matches_the_yaml_defaults() {
assert_eq!(
S3SourceConfig::new("s3://bucket/exports/"),
S3SourceConfig::from_component_config(§ion(&minimal())).unwrap()
);
}
#[test]
fn split_target_parses_and_floors() {
let body = format!("{} split_target_bytes: 32MB\n", minimal());
let cfg = S3SourceConfig::from_component_config(§ion(&body)).unwrap();
assert_eq!(cfg.split_target_bytes, ByteSize::mb(32));
let low = format!("{} split_target_bytes: 64KB\n", minimal());
let err = S3SourceConfig::from_component_config(§ion(&low)).unwrap_err();
assert!(err.to_string().contains("split_target_bytes"), "{err}");
}
#[test]
fn removed_knobs_are_rejected() {
for extra in [
" lanes: 4\n",
" refresh_listing: true\n",
" checkpoint:\n url: s3://bucket/_etl/b.json\n",
" coordination:\n nats:\n servers: [\"nats://n:4222\"]\n",
] {
let body = format!("{}{extra}", minimal());
assert!(
S3SourceConfig::from_component_config(§ion(&body)).is_err(),
"must reject removed knob: {extra}"
);
}
}
#[test]
fn debug_never_prints_store_values() {
let body = format!(
"{} store:\n aws_secret_access_key: hunter2\n",
minimal()
);
let cfg = S3SourceConfig::from_component_config(§ion(&body)).unwrap();
let printed = format!("{cfg:?}");
assert!(!printed.contains("hunter2"), "{printed}");
assert!(
printed.contains("aws_secret_access_key") && printed.contains("<redacted>"),
"keys stay visible for debugging: {printed}"
);
}
#[test]
fn bucket_aliases_are_rejected_in_the_passthrough() {
for key in [
"bucket",
"bucket_name",
"aws_bucket",
"aws_bucket_name",
"Bucket",
"AWS_BUCKET_NAME",
] {
let body = format!("{} store:\n {key}: other\n", minimal());
let err = S3SourceConfig::from_component_config(§ion(&body)).unwrap_err();
assert!(err.to_string().contains(key), "error names the key: {err}");
}
}
#[test]
fn unrecognized_store_keys_are_rejected() {
for key in ["endpont", "endpoint_unused", "Endpont"] {
let body = format!("{} store:\n {key}: http://127.0.0.1:9000\n", minimal());
let err = S3SourceConfig::from_component_config(§ion(&body)).unwrap_err();
assert!(
err.to_string().contains(&format!("store.\"{key}\"")),
"error names the key as written: {err}"
);
}
}
#[test]
fn an_unrecognized_key_after_recognized_ones_is_rejected() {
let body = format!(
"{} store:\n access_key_id: k\n allow_http: \"true\"\n endpont: x\n",
minimal()
);
let err = S3SourceConfig::from_component_config(§ion(&body)).unwrap_err();
assert!(err.to_string().contains("store.\"endpont\""), "{err}");
}
#[test]
fn file_url_store_keys_are_not_checked() {
let body = " url: file:///tmp/objects/\n store:\n endpont: x\n";
S3SourceConfig::from_component_config(§ion(body)).unwrap();
}
#[test]
fn https_s3_host_store_keys_are_checked() {
for url in [
"https://s3.us-east-1.amazonaws.com/bucket/exports/",
"https://acct.r2.cloudflarestorage.com/bucket/exports/",
] {
let body = format!(" url: {url}\n store:\n endpont: x\n");
let err = S3SourceConfig::from_component_config(§ion(&body)).unwrap_err();
assert!(
err.to_string().contains("store.\"endpont\""),
"{url}: {err}"
);
}
}
#[test]
fn recognized_store_spellings_load() {
for key in [
"endpoint",
"AWS_ENDPOINT_URL",
"aws_region",
"allow_http",
"pool_idle_timeout",
"aws_container_credentials_full_uri",
"aws_container_authorization_token_file",
] {
let body = format!("{} store:\n {key}: x\n", minimal());
S3SourceConfig::from_component_config(§ion(&body))
.unwrap_or_else(|e| panic!("{key} is an object_store S3 option: {e}"));
}
}
#[test]
fn memory_scheme_is_rejected_with_guidance() {
let err = S3SourceConfig::from_component_config(§ion(" url: memory:///data/\n"))
.unwrap_err();
assert!(err.to_string().contains("file://"), "actionable: {err}");
}
#[test]
fn bad_buffer_sizes_are_rejected() {
for extra in [
" chunk_bytes: 0\n",
" prefetch_bytes: 4KiB\n chunk_bytes: 1MiB\n",
] {
let body = format!("{}{extra}", minimal());
assert!(
S3SourceConfig::from_component_config(§ion(&body)).is_err(),
"must reject: {extra}"
);
}
}
#[test]
fn unknown_fields_and_unknown_variants_are_rejected() {
for extra in [" prefix: also-here\n", " compression: xz\n"] {
let body = format!("{}{extra}", minimal());
assert!(
S3SourceConfig::from_component_config(§ion(&body)).is_err(),
"must reject: {extra}"
);
}
}
#[test]
fn file_urls_are_accepted() {
S3SourceConfig::from_component_config(§ion(" url: file:///tmp/objects/\n")).unwrap();
}
}