diff --git a/.env.example b/.env.example index 3dc54856e7..b9bfcada0e 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,19 @@ RELAY_URL=ws://localhost:3000 # BUZZ_GIT_PACK_CACHE_MAX_BYTES=5368709120 # BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS=2 +# ----------------------------------------------------------------------------- +# S3-Compatible Object Storage (media + Git/CAS) +# ----------------------------------------------------------------------------- +# The local MinIO container is reachable from host processes at localhost:9000. +# Path style keeps the bucket in the URL path and is required by this local DNS +# setup. Use `virtual` only when the provider requires bucket-as-subdomain URLs. +BUZZ_S3_ENDPOINT=http://localhost:9000 +BUZZ_S3_ACCESS_KEY=buzz_dev +BUZZ_S3_SECRET_KEY=buzz_dev_secret +BUZZ_S3_BUCKET=buzz-media +BUZZ_S3_REGION=us-east-1 +BUZZ_S3_ADDRESSING_STYLE=path + # ----------------------------------------------------------------------------- # Media Upload Admission # ----------------------------------------------------------------------------- diff --git a/Cargo.lock b/Cargo.lock index 3b60dc4579..c3ea86d6b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1239,6 +1239,7 @@ dependencies = [ "anyhow", "base64", "buzz-core", + "buzz-media", "buzz-sdk", "buzz-ws-client", "chrono", diff --git a/crates/buzz-media/src/config.rs b/crates/buzz-media/src/config.rs index 047c08475e..3c70e4afe1 100644 --- a/crates/buzz-media/src/config.rs +++ b/crates/buzz-media/src/config.rs @@ -1,5 +1,38 @@ //! Media storage configuration. +use std::str::FromStr; + +/// S3 URL addressing style shared by media and Git/CAS storage. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum S3AddressingStyle { + /// Put the bucket in the request path (`https://endpoint/bucket/key`). + /// + /// This preserves compatibility with the bundled MinIO deployments, whose + /// internal DNS only resolves the endpoint hostname. + #[default] + Path, + /// Put the bucket in the hostname (`https://bucket.endpoint/key`). + /// + /// This is the standard S3 form and is required by providers such as new + /// Railway Storage Buckets. + Virtual, +} + +impl FromStr for S3AddressingStyle { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "path" => Ok(Self::Path), + "virtual" => Ok(Self::Virtual), + _ => Err(format!( + "BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual', got {value:?}" + )), + } + } +} + fn default_max_video_bytes() -> u64 { 524_288_000 // 500 MB } @@ -31,6 +64,9 @@ pub struct MediaConfig { /// the value is not meaningfully checked. #[serde(default = "default_s3_region")] pub s3_region: String, + /// S3 URL addressing style. Defaults to path style for MinIO compatibility. + #[serde(default)] + pub s3_addressing_style: S3AddressingStyle, /// Maximum upload size for images (bytes). Default: 50 MB. pub max_image_bytes: u64, /// Maximum upload size for animated GIFs (bytes). Default: 10 MB. @@ -123,7 +159,8 @@ impl MediaConfig { #[cfg(test)] mod tests { - use super::MediaConfig; + use super::{MediaConfig, S3AddressingStyle}; + use std::str::FromStr; fn valid_config() -> MediaConfig { MediaConfig { @@ -132,6 +169,7 @@ mod tests { s3_secret_key: "s".to_string(), s3_bucket: "buzz-media".to_string(), s3_region: "us-east-1".to_string(), + s3_addressing_style: S3AddressingStyle::Path, max_image_bytes: 1, max_gif_bytes: 1, max_video_bytes: 1, @@ -143,6 +181,35 @@ mod tests { } } + #[test] + fn addressing_style_parses_supported_values() { + assert_eq!( + S3AddressingStyle::from_str("path"), + Ok(S3AddressingStyle::Path) + ); + assert_eq!( + S3AddressingStyle::from_str("virtual"), + Ok(S3AddressingStyle::Virtual) + ); + } + + #[test] + fn addressing_style_defaults_to_path() { + assert_eq!(S3AddressingStyle::default(), S3AddressingStyle::Path); + } + + #[test] + fn addressing_style_rejects_unknown_or_ambiguous_values() { + for invalid in ["", "auto", "PATH", "virtual-hosted"] { + let error = + S3AddressingStyle::from_str(invalid).expect_err("must reject invalid style"); + assert!( + error.contains("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'"), + "unexpected error for {invalid:?}: {error}" + ); + } + } + #[test] fn upload_record_knobs_default_off_and_validate() { assert!(valid_config().validate().is_ok()); diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index ac05ea6d51..67896d4ef2 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -17,7 +17,7 @@ pub use bucket_index::{ classify_key, fold_bucket_listing, BucketAggregate, BucketSnapshot, CommunityStorage, KeyClass, Page, SweepError, }; -pub use config::MediaConfig; +pub use config::{MediaConfig, S3AddressingStyle}; pub use error::MediaError; pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage}; pub use types::BlobDescriptor; diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index 0e9809af2f..cbf980201f 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -5,7 +5,7 @@ use std::pin::Pin; use buzz_core::tenant::{CommunityId, TenantContext}; -use crate::config::MediaConfig; +use crate::config::{MediaConfig, S3AddressingStyle}; use crate::error::MediaError; use bytes::Bytes; use s3::creds::Credentials; @@ -61,8 +61,11 @@ impl MediaStorage { } .map_err(|e| MediaError::StorageError(e.to_string()))?; let bucket = Bucket::new(&config.s3_bucket, region, creds) - .map_err(|e| MediaError::StorageError(e.to_string()))? - .with_path_style(); + .map_err(|e| MediaError::StorageError(e.to_string()))?; + let bucket = match config.s3_addressing_style { + S3AddressingStyle::Path => bucket.with_path_style(), + S3AddressingStyle::Virtual => bucket, + }; Ok(Self { bucket }) } @@ -285,6 +288,7 @@ mod tests { s3_secret_key: secret.to_string(), s3_bucket: "buzz-media".to_string(), s3_region: "us-west-2".to_string(), + s3_addressing_style: S3AddressingStyle::Path, max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, @@ -309,6 +313,23 @@ mod tests { } } + #[test] + fn client_constructor_applies_both_addressing_styles() { + let path = MediaStorage::new(&storage_config("buzz_dev", "buzz_dev_secret")) + .expect("path-style client"); + assert!(path.bucket.is_path_style()); + assert_eq!(path.bucket.url(), "http://localhost:9000/buzz-media"); + + let mut virtual_config = storage_config("buzz_dev", "buzz_dev_secret"); + virtual_config.s3_addressing_style = S3AddressingStyle::Virtual; + let virtual_hosted = MediaStorage::new(&virtual_config).expect("virtual-hosted client"); + assert!(virtual_hosted.bucket.is_subdomain_style()); + assert_eq!( + virtual_hosted.bucket.url(), + "http://buzz-media.localhost:9000" + ); + } + #[test] fn partial_static_keys_are_rejected() { let err = match MediaStorage::new(&storage_config("buzz_dev", "")) { diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 478ac114ef..524b033280 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -570,6 +570,7 @@ mod tests { s3_secret_key: String::new(), s3_bucket: String::new(), s3_region: "us-east-1".to_string(), + s3_addressing_style: crate::config::S3AddressingStyle::Path, max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index ee940dfb24..f1387fc9d6 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -949,6 +949,7 @@ mod tests { s3_secret_key: String::new(), s3_bucket: String::new(), s3_region: "us-east-1".to_string(), + s3_addressing_style: crate::config::S3AddressingStyle::Path, max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, diff --git a/crates/buzz-media/tests/static_creds_minio.rs b/crates/buzz-media/tests/static_creds_minio.rs index d7591238c2..4c8c10702c 100644 --- a/crates/buzz-media/tests/static_creds_minio.rs +++ b/crates/buzz-media/tests/static_creds_minio.rs @@ -1,5 +1,5 @@ -//! Live round-trip test for the **static-credentials** S3 path against a local -//! MinIO, guarded by `#[ignore]`. +//! Live round-trip test for the **static-credentials** S3 path against an +//! S3-compatible service. It is guarded by `#[ignore]`. //! //! This is the path local/dev and any static-key deployment uses //! (`s3_access_key`/`s3_secret_key` both non-empty -> `Credentials::new`). It @@ -15,7 +15,8 @@ //! ``` //! //! Overridable via `BUZZ_S3_ENDPOINT` / `BUZZ_S3_ACCESS_KEY` / -//! `BUZZ_S3_SECRET_KEY` / `BUZZ_S3_BUCKET`. +//! `BUZZ_S3_SECRET_KEY` / `BUZZ_S3_BUCKET` / `BUZZ_S3_REGION` / +//! `BUZZ_S3_ADDRESSING_STYLE`. The default remains `path` for MinIO. use buzz_media::config::MediaConfig; use buzz_media::storage::MediaStorage; @@ -29,7 +30,11 @@ fn minio_config() -> MediaConfig { s3_secret_key: std::env::var("BUZZ_S3_SECRET_KEY") .unwrap_or_else(|_| "buzz_dev_secret".to_string()), s3_bucket: std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-media".to_string()), - s3_region: "us-east-1".to_string(), + s3_region: std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string()), + s3_addressing_style: std::env::var("BUZZ_S3_ADDRESSING_STYLE") + .unwrap_or_else(|_| "path".to_string()) + .parse() + .expect("BUZZ_S3_ADDRESSING_STYLE must be path or virtual"), max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, diff --git a/crates/buzz-relay/src/api/git/cas_publish.rs b/crates/buzz-relay/src/api/git/cas_publish.rs index 635dcf2a67..c213e2913e 100644 --- a/crates/buzz-relay/src/api/git/cas_publish.rs +++ b/crates/buzz-relay/src/api/git/cas_publish.rs @@ -1583,22 +1583,15 @@ mod tests { } fn live_store() -> GitStore { - let endpoint = std::env::var("BUZZ_GIT_S3_ENDPOINT") - .or_else(|_| std::env::var("BUZZ_S3_ENDPOINT")) - .unwrap_or_else(|_| "http://localhost:9000".into()); - let access_key = std::env::var("BUZZ_GIT_S3_ACCESS_KEY") - .or_else(|_| std::env::var("BUZZ_S3_ACCESS_KEY")) - .unwrap_or_else(|_| "buzz_dev".into()); - let secret_key = std::env::var("BUZZ_GIT_S3_SECRET_KEY") - .or_else(|_| std::env::var("BUZZ_S3_SECRET_KEY")) - .unwrap_or_else(|_| "buzz_dev_secret".into()); - let bucket = std::env::var("BUZZ_GIT_S3_BUCKET") - .or_else(|_| std::env::var("BUZZ_S3_BUCKET")) - .unwrap_or_else(|_| "buzz-media".into()); - let region = std::env::var("BUZZ_GIT_S3_REGION") - .or_else(|_| std::env::var("BUZZ_S3_REGION")) - .unwrap_or_else(|_| "us-east-1".into()); - GitStore::new(&endpoint, &access_key, &secret_key, &bucket, ®ion).expect("connect minio") + GitStore::new( + "http://localhost:9000", + "buzz_dev", + "buzz_dev_secret", + "buzz-media", + "us-east-1", + buzz_media::config::S3AddressingStyle::Path, + ) + .expect("connect local MinIO") } fn tenant() -> TenantContext { diff --git a/crates/buzz-relay/src/api/git/hydrate.rs b/crates/buzz-relay/src/api/git/hydrate.rs index 064d01923e..3ce809d18f 100644 --- a/crates/buzz-relay/src/api/git/hydrate.rs +++ b/crates/buzz-relay/src/api/git/hydrate.rs @@ -543,8 +543,15 @@ mod tests { #[tokio::test] async fn materialized_repo_is_created_under_configured_scratch_dir() { let scratch = TempDir::new().unwrap(); - let store = GitStore::new("http://localhost:9000", "x", "x", "x", "us-east-1") - .expect("construct store"); + let store = GitStore::new( + "http://localhost:9000", + "x", + "x", + "x", + "us-east-1", + buzz_media::config::S3AddressingStyle::Path, + ) + .expect("construct store"); let manifest = Manifest { version: 1, head: "refs/heads/main".into(), @@ -587,8 +594,9 @@ mod tests { "buzz_dev_secret", "buzz-git", "us-east-1", + buzz_media::config::S3AddressingStyle::Path, ) - .expect("connect minio") + .expect("connect local MinIO") } /// Build a tiny on-disk repo, return (pack bytes, head_oid). diff --git a/crates/buzz-relay/src/api/git/store.rs b/crates/buzz-relay/src/api/git/store.rs index 43d210e648..bdfca8dcf2 100644 --- a/crates/buzz-relay/src/api/git/store.rs +++ b/crates/buzz-relay/src/api/git/store.rs @@ -174,7 +174,9 @@ pub struct GitStore { impl GitStore { /// Build a client against an S3-compatible endpoint (e.g. MinIO). /// - /// Uses path-style addressing for MinIO compatibility; AWS S3 accepts both. + /// `addressing_style` is shared with media storage so both paths sign and + /// route requests consistently. Path style supports the bundled MinIO DNS; + /// virtual-hosted style supports standard S3 and providers such as Railway. /// /// Credential selection mirrors [`buzz_media::MediaStorage::new`]: /// - both `access_key` and `secret_key` non-empty → static credentials @@ -190,6 +192,7 @@ impl GitStore { secret_key: &str, bucket_name: &str, region: &str, + addressing_style: buzz_media::config::S3AddressingStyle, ) -> Result { let region = Region::Custom { region: region.into(), @@ -209,9 +212,11 @@ impl GitStore { } } .map_err(|e| StoreError::Backend(S3Error::Credentials(e)))?; - let bucket = Bucket::new(bucket_name, region, creds) - .map_err(StoreError::Backend)? - .with_path_style(); + let bucket = Bucket::new(bucket_name, region, creds).map_err(StoreError::Backend)?; + let bucket = match addressing_style { + buzz_media::config::S3AddressingStyle::Path => bucket.with_path_style(), + buzz_media::config::S3AddressingStyle::Virtual => bucket, + }; Ok(Self { bucket: Arc::from(bucket), }) @@ -950,6 +955,7 @@ mod tests { "buzz_dev_secret", "buzz-git", "us-west-2", + buzz_media::config::S3AddressingStyle::Path, ) .expect("static creds should build a git store"); match store.bucket.region { @@ -958,6 +964,34 @@ mod tests { } } + #[test] + fn constructor_applies_both_addressing_styles() { + for (style, expected_url, path_style) in [ + ( + buzz_media::config::S3AddressingStyle::Path, + "https://storage.example/buzz-git", + true, + ), + ( + buzz_media::config::S3AddressingStyle::Virtual, + "https://buzz-git.storage.example", + false, + ), + ] { + let store = GitStore::new( + "https://storage.example", + "buzz_dev", + "buzz_dev_secret", + "buzz-git", + "us-east-1", + style, + ) + .expect("construct git store"); + assert_eq!(store.bucket.url(), expected_url); + assert_eq!(store.bucket.is_path_style(), path_style); + } + } + #[test] fn partial_static_keys_are_rejected() { for (access, secret) in [("buzz_dev", ""), ("", "buzz_dev_secret")] { @@ -967,6 +1001,7 @@ mod tests { secret, "buzz-git", "us-east-1", + buzz_media::config::S3AddressingStyle::Path, ) { Ok(_) => { panic!("partial static creds must not silently use the credential chain") @@ -998,14 +1033,29 @@ mod probe { } fn store() -> GitStore { + // This is the dedicated backend conformance path, so all connection and + // signing inputs are overridable for a real provider such as Railway. + // The hydrate/CAS live tests use explicit local MinIO fixtures instead. + let endpoint = + std::env::var("BUZZ_S3_ENDPOINT").unwrap_or_else(|_| "http://localhost:9000".into()); + let access_key = std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_else(|_| "buzz_dev".into()); + let secret_key = + std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_else(|_| "buzz_dev_secret".into()); + let bucket = std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-git".into()); + let region = std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".into()); + let addressing_style = std::env::var("BUZZ_S3_ADDRESSING_STYLE") + .unwrap_or_else(|_| "path".into()) + .parse() + .expect("BUZZ_S3_ADDRESSING_STYLE must be path or virtual"); GitStore::new( - "http://localhost:9000", - "buzz_dev", - "buzz_dev_secret", - "buzz-git", - "us-east-1", + &endpoint, + &access_key, + &secret_key, + &bucket, + ®ion, + addressing_style, ) - .expect("connect minio") + .expect("connect S3-compatible storage") } fn sha256_hex(b: &[u8]) -> String { diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index a1691349d6..e494355736 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -630,6 +630,16 @@ impl Config { .and_then(|v| v.parse().ok()) .unwrap_or(9102); + let s3_addressing_style = match std::env::var("BUZZ_S3_ADDRESSING_STYLE") { + Ok(value) => value.parse().map_err(ConfigError::InvalidValue)?, + Err(std::env::VarError::NotPresent) => buzz_media::config::S3AddressingStyle::default(), + Err(std::env::VarError::NotUnicode(_)) => { + return Err(ConfigError::InvalidValue( + "BUZZ_S3_ADDRESSING_STYLE must be valid Unicode and one of 'path' or 'virtual'" + .to_string(), + )); + } + }; let media = buzz_media::MediaConfig { s3_endpoint: std::env::var("BUZZ_S3_ENDPOINT") .unwrap_or_else(|_| "http://localhost:9000".to_string()), @@ -641,6 +651,7 @@ impl Config { s3_region: std::env::var("BUZZ_S3_REGION") .or_else(|_| std::env::var("AWS_REGION")) .unwrap_or_else(|_| "us-east-1".to_string()), + s3_addressing_style, max_image_bytes: std::env::var("BUZZ_MAX_IMAGE_BYTES") .ok() .and_then(|v| v.parse().ok()) @@ -990,6 +1001,11 @@ mod tests { !config.require_media_get_auth, "require_media_get_auth should default to false for staged client rollout" ); + assert_eq!( + config.media.s3_addressing_style, + buzz_media::config::S3AddressingStyle::Path, + "S3 addressing must default to path style for bundled MinIO compatibility" + ); assert!( config.join_policy.is_none(), "join_policy should default to None so policy prompts and acceptance receipts are opt-in" @@ -1000,6 +1016,61 @@ mod tests { ); } + #[test] + fn s3_addressing_style_env_accepts_virtual_and_rejects_invalid_values() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_S3_ADDRESSING_STYLE"); + + std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", "virtual"); + let configured = Config::from_env() + .expect("virtual style config") + .media + .s3_addressing_style; + + std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", "auto"); + let invalid = Config::from_env(); + + if let Some(value) = previous { + std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", value); + } else { + std::env::remove_var("BUZZ_S3_ADDRESSING_STYLE"); + } + + assert_eq!(configured, buzz_media::config::S3AddressingStyle::Virtual); + assert!(matches!( + invalid, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'") + )); + } + + #[cfg(unix)] + #[test] + fn s3_addressing_style_env_rejects_non_unicode_values() { + use std::os::unix::ffi::OsStringExt; + + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_S3_ADDRESSING_STYLE"); + std::env::set_var( + "BUZZ_S3_ADDRESSING_STYLE", + std::ffi::OsString::from_vec(vec![0xff]), + ); + + let invalid = Config::from_env(); + + if let Some(value) = previous { + std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", value); + } else { + std::env::remove_var("BUZZ_S3_ADDRESSING_STYLE"); + } + + assert!(matches!( + invalid, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("must be valid Unicode") + )); + } + #[test] fn redis_pool_size_env_override_and_invalid_fallback() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 758c001b96..58a869a995 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -697,6 +697,7 @@ impl AppState { &config.media.s3_secret_key, &config.media.s3_bucket, &config.media.s3_region, + config.media.s3_addressing_style, ) .expect("media storage was already constructed with this S3 config"); let git_pack_cache = Arc::new( diff --git a/crates/buzz-test-client/Cargo.toml b/crates/buzz-test-client/Cargo.toml index 40a08f3d19..e495c16300 100644 --- a/crates/buzz-test-client/Cargo.toml +++ b/crates/buzz-test-client/Cargo.toml @@ -36,6 +36,7 @@ sha2 = { workspace = true } sqlx = { workspace = true } chrono = { workspace = true } s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] } +buzz-media = { workspace = true } buzz-sdk = { workspace = true } [[bin]] diff --git a/crates/buzz-test-client/tests/e2e_git.rs b/crates/buzz-test-client/tests/e2e_git.rs index 63281fd18f..f543e56229 100644 --- a/crates/buzz-test-client/tests/e2e_git.rs +++ b/crates/buzz-test-client/tests/e2e_git.rs @@ -21,6 +21,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; +use buzz_media::S3AddressingStyle; use nostr::{EventBuilder, Keys, Kind, Tag}; use s3::creds::Credentials; use s3::{Bucket, Region}; @@ -115,29 +116,55 @@ struct PointerSnapshot { } impl GitS3Probe { - fn from_env() -> Self { - let endpoint = std::env::var("BUZZ_GIT_S3_ENDPOINT") - .or_else(|_| std::env::var("BUZZ_S3_ENDPOINT")) - .unwrap_or_else(|_| "http://localhost:9000".to_string()); - let access_key = std::env::var("BUZZ_GIT_S3_ACCESS_KEY") - .or_else(|_| std::env::var("BUZZ_S3_ACCESS_KEY")) - .unwrap_or_else(|_| "buzz_dev".to_string()); - let secret_key = std::env::var("BUZZ_GIT_S3_SECRET_KEY") - .or_else(|_| std::env::var("BUZZ_S3_SECRET_KEY")) - .unwrap_or_else(|_| "buzz_dev_secret".to_string()); - let bucket = std::env::var("BUZZ_GIT_S3_BUCKET") - .or_else(|_| std::env::var("BUZZ_S3_BUCKET")) - .unwrap_or_else(|_| "buzz-media".to_string()); - + fn bucket( + endpoint: String, + access_key: &str, + secret_key: &str, + bucket_name: &str, + region_name: String, + addressing_style: S3AddressingStyle, + ) -> Box { let region = Region::Custom { - region: "us-east-1".into(), + region: region_name, endpoint, }; - let creds = Credentials::new(Some(&access_key), Some(&secret_key), None, None, None) + let creds = Credentials::new(Some(access_key), Some(secret_key), None, None, None) .expect("S3 credentials"); - let bucket = Bucket::new(&bucket, region, creds) - .expect("S3 bucket") - .with_path_style(); + let bucket = Bucket::new(bucket_name, region, creds).expect("S3 bucket"); + match addressing_style { + S3AddressingStyle::Path => bucket.with_path_style(), + S3AddressingStyle::Virtual => bucket, + } + } + + fn from_env() -> Self { + // These E2E assertions inspect the relay's backing bucket directly, so + // they must receive the same provider connection and URL style as the + // relay. Unit/live MinIO probes in buzz-relay keep explicit local + // fixtures and do not need provider overrides. + let endpoint = std::env::var("BUZZ_S3_ENDPOINT") + .unwrap_or_else(|_| "http://localhost:9000".to_string()); + let access_key = + std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_else(|_| "buzz_dev".to_string()); + let secret_key = + std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_else(|_| "buzz_dev_secret".to_string()); + let bucket_name = + std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-media".to_string()); + let region_name = + std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string()); + let addressing_style = std::env::var("BUZZ_S3_ADDRESSING_STYLE") + .unwrap_or_else(|_| "path".to_string()) + .parse::() + .expect("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'"); + + let bucket = Self::bucket( + endpoint, + &access_key, + &secret_key, + &bucket_name, + region_name, + addressing_style, + ); Self { bucket } } @@ -192,6 +219,31 @@ impl GitS3Probe { } } +#[test] +fn git_s3_probe_builds_both_addressing_styles() { + let path = GitS3Probe::bucket( + "https://storage.example".to_string(), + "access", + "secret", + "buzz-media", + "us-east-1".to_string(), + S3AddressingStyle::Path, + ); + assert!(path.is_path_style()); + assert_eq!(path.url(), "https://storage.example/buzz-media"); + + let virtual_hosted = GitS3Probe::bucket( + "https://storage.example".to_string(), + "access", + "secret", + "buzz-media", + "auto".to_string(), + S3AddressingStyle::Virtual, + ); + assert!(virtual_hosted.is_subdomain_style()); + assert_eq!(virtual_hosted.url(), "https://buzz-media.storage.example"); +} + #[tokio::test] #[ignore = "requires live relay + MinIO + git"] async fn git_clone_push_fetch_force_roundtrip() { diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index a7c4bcf63b..b2778df28b 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -52,6 +52,48 @@ See: The chart fails at `helm install` / `helm template` time with a clear message if any of these are missing or malformed (see `templates/_validate.tpl`). +## S3 URL addressing + +Buzz uses one URL style for both media and Git/CAS object-store requests: + +| `s3.addressingStyle` | Request shape | Use for | +|---|---|---| +| `path` (default) | `https://endpoint/bucket/key` | Bundled MinIO and endpoints whose DNS does not resolve bucket subdomains | +| `virtual` | `https://bucket.endpoint/key` | AWS-style providers and new Railway Storage Buckets | + +The chart always renders `s3.addressingStyle` as +`BUZZ_S3_ADDRESSING_STYLE`. It renders `s3.region` as `BUZZ_S3_REGION` only +when explicitly set, preserving the relay's existing `AWS_REGION` fallback for +upgrades. Only `path` and `virtual` addressing styles are accepted; invalid +values fail chart rendering and relay startup. The bundled MinIO quickstart +deliberately keeps `path` because its Service DNS resolves one endpoint +hostname, not arbitrary `.` names. + +For a Railway Storage Bucket, map its variables to chart values in the service +or generated Helm configuration: + +```yaml +s3: + endpoint: "${{Object Storage.ENDPOINT}}" + bucket: "${{Object Storage.BUCKET}}" + region: "${{Object Storage.REGION}}" + addressingStyle: virtual +``` + +Store `BUZZ_S3_ACCESS_KEY=${{Object Storage.ACCESS_KEY_ID}}` and +`BUZZ_S3_SECRET_KEY=${{Object Storage.SECRET_ACCESS_KEY}}` in the Secret named by +`secrets.existingSecret`. Railway's Credentials tab is authoritative for older +buckets, which may still require `path`. The setting changes request routing and +SigV4 signing, so do not put the bucket into `s3.endpoint`; pass Railway's base +`ENDPOINT` and `BUCKET` separately. + +Object storage is contacted during relay startup only when +`BUZZ_GIT_CONFORMANCE_PROBE` is enabled (the relay default). A probe failure is +startup-fatal, so Kubernetes readiness never opens. If an operator explicitly +disables that probe through `relay.extraEnv`, `/_readiness` does not test object +storage; configuration is still parsed strictly, but reachability and addressing +errors surface on the first storage operation. + ## Relay Pod extensions The chart exposes narrow extension points for init containers, volumes, relay diff --git a/deploy/charts/buzz/examples/argocd-app.yaml b/deploy/charts/buzz/examples/argocd-app.yaml index 7e90f64bd7..8f6cb76228 100644 --- a/deploy/charts/buzz/examples/argocd-app.yaml +++ b/deploy/charts/buzz/examples/argocd-app.yaml @@ -41,6 +41,8 @@ spec: s3: endpoint: "https://s3.us-east-1.amazonaws.com" bucket: "buzz-media" + region: "us-east-1" + addressingStyle: virtual # accessKey / secretKey live in buzz-secrets persistence: diff --git a/deploy/charts/buzz/examples/flux-helmrelease.yaml b/deploy/charts/buzz/examples/flux-helmrelease.yaml index 16754c0fcb..09a6bfeb6a 100644 --- a/deploy/charts/buzz/examples/flux-helmrelease.yaml +++ b/deploy/charts/buzz/examples/flux-helmrelease.yaml @@ -41,6 +41,8 @@ spec: s3: endpoint: "https://s3.us-east-1.amazonaws.com" bucket: "buzz-media" + region: "us-east-1" + addressingStyle: virtual persistence: git: diff --git a/deploy/charts/buzz/templates/_validate.tpl b/deploy/charts/buzz/templates/_validate.tpl index 946424f9a3..aa7f7ac13c 100644 --- a/deploy/charts/buzz/templates/_validate.tpl +++ b/deploy/charts/buzz/templates/_validate.tpl @@ -75,10 +75,12 @@ surface at template time regardless of which manifest helm renders first. {{- fail "Postgres source missing: enable postgresql.enabled=true, set externalPostgresql.url, or provide secrets.existingSecret with key DATABASE_URL." -}} {{- end -}} -{{/* S3 / object-storage source must exist somewhere (relay hard-fails its - startup conformance probe without a reachable bucket). */}} +{{/* S3 / object-storage source must exist somewhere. With the default + BUZZ_GIT_CONFORMANCE_PROBE behavior, an unreachable bucket is detected + before the relay opens its listener; operators can explicitly disable that + startup gate. */}} {{- if not (or .Values.minio.enabled .Values.s3.endpoint .Values.secrets.existingSecret) -}} - {{- fail "S3/object-storage source missing: enable minio.enabled=true (quickstart in-cluster), set s3.endpoint + s3.bucket + credentials, or provide secrets.existingSecret with keys BUZZ_S3_ACCESS_KEY + BUZZ_S3_SECRET_KEY. The relay runs a startup S3 conformance probe and exits if storage is unreachable." -}} + {{- fail "S3/object-storage source missing: enable minio.enabled=true (quickstart in-cluster), set s3.endpoint + s3.bucket + credentials, or provide secrets.existingSecret with keys BUZZ_S3_ACCESS_KEY + BUZZ_S3_SECRET_KEY. By default the relay runs a startup S3 conformance probe and exits if storage is unreachable; disabling BUZZ_GIT_CONFORMANCE_PROBE also removes that startup storage check." -}} {{- end -}} {{- end -}} diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index bf2df4c2c8..67a93138c5 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -170,6 +170,10 @@ spec: - { name: BUZZ_S3_ENDPOINT, value: {{ $s3Endpoint | quote }} } {{- end }} - { name: BUZZ_S3_BUCKET, value: {{ .Values.s3.bucket | quote }} } + {{- if .Values.s3.region }} + - { name: BUZZ_S3_REGION, value: {{ .Values.s3.region | quote }} } + {{- end }} + - { name: BUZZ_S3_ADDRESSING_STYLE, value: {{ .Values.s3.addressingStyle | quote }} } # ── Secrets (from chart-managed or existing) ───────────── - name: BUZZ_RELAY_PRIVATE_KEY diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 3e044f5d7c..cf08210781 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -30,6 +30,18 @@ tests: path: kind value: Service template: templates/service.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_S3_REGION + any: true + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_S3_ADDRESSING_STYLE + value: "path" + template: templates/deployment.yaml - contains: path: spec.template.spec.containers[0].env content: @@ -47,6 +59,32 @@ tests: value: "true" template: templates/deployment.yaml + - it: renders virtual-hosted S3 addressing for providers that require it + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: https://storage.railway.app + s3.bucket: buzz-media-example + s3.region: auto + s3.addressingStyle: virtual + s3.accessKey: a + s3.secretKey: s + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_S3_REGION + value: "auto" + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_S3_ADDRESSING_STYLE + value: "virtual" + template: templates/deployment.yaml + - it: lets an explicit value opt out of media read auth for dev/public deployments set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml index f0a3869795..a5a0050a86 100644 --- a/deploy/charts/buzz/tests/validation_test.yaml +++ b/deploy/charts/buzz/tests/validation_test.yaml @@ -58,6 +58,17 @@ tests: - failedTemplate: errorPattern: "Postgres source missing" + - it: rejects an invalid S3 addressing style + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + s3.endpoint: http://minio:9000 + s3.addressingStyle: auto + asserts: + - failedTemplate: + errorPattern: "s3.addressingStyle: s3.addressingStyle must be one of the following:.*path.*virtual" + - it: fails when S3/object-storage source is missing set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 53bb29bb60..9cb6a02c9b 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -198,6 +198,15 @@ "properties": { "endpoint": { "type": "string", "pattern": "^(https?://.+)?$" }, "bucket": { "type": "string", "minLength": 1 }, + "region": { + "type": "string", + "description": "Optional S3 region used for SigV4 signing. When empty, BUZZ_S3_REGION is omitted so the relay can use AWS_REGION or its own default." + }, + "addressingStyle": { + "type": "string", + "enum": ["path", "virtual"], + "description": "S3 URL style shared by media and Git/CAS clients. Defaults to path for bundled MinIO compatibility." + }, "accessKey": { "type": "string" }, "secretKey": { "type": "string" } } diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 8ac5086e27..810f8a9658 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -338,6 +338,12 @@ externalRedis: s3: endpoint: "" bucket: "buzz-media" + # Optional SigV4 signing region. Leave empty to preserve the relay's + # AWS_REGION fallback; set the provider's credential value when needed. + region: "" + # path: https://endpoint/bucket/key (bundled MinIO-compatible default) + # virtual: https://bucket.endpoint/key (standard S3; required by new Railway buckets) + addressingStyle: path accessKey: "" secretKey: "" diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 838824c17f..f6ab4fcab9 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -33,6 +33,8 @@ REDIS_PASSWORD=CHANGE_ME_RANDOM_PASSWORD BUZZ_S3_ACCESS_KEY=CHANGE_ME_RANDOM_ACCESS_KEY BUZZ_S3_SECRET_KEY=CHANGE_ME_RANDOM_SECRET_KEY BUZZ_S3_BUCKET=buzz-media +# Bundled MinIO uses path-style URLs; deploy/compose/compose.yml pins this. +BUZZ_S3_ADDRESSING_STYLE=path # Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT. BUZZ_HTTP_PORT=3000 diff --git a/deploy/compose/README.md b/deploy/compose/README.md index 0de524fb5b..bb0e63fe15 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -38,6 +38,11 @@ keypair. migrations. - The stack uses Postgres, Redis, MinIO, and a git data volume because those are real Buzz dependencies today. Minimal mode can simplify this later. +- The bundled Compose stack fixes the relay endpoint to `http://minio:9000` and + `BUZZ_S3_ADDRESSING_STYLE=path`: Docker DNS resolves `minio`, not + `.minio`. It is not configurable for an external S3 provider through + `.env`; use the Helm chart or a custom Compose configuration for providers + such as new Railway Storage Buckets that require `virtual` addressing. Run `./run.sh backup-hint` for the backup checklist. diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index bc3c27501e..15337c92a2 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -12,6 +12,8 @@ services: DATABASE_URL: postgres://${POSTGRES_USER:-buzz}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-buzz} REDIS_URL: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379 BUZZ_S3_ENDPOINT: http://minio:9000 + # Docker DNS resolves `minio`, not arbitrary `.minio` hosts. + BUZZ_S3_ADDRESSING_STYLE: path BUZZ_S3_ACCESS_KEY: ${BUZZ_S3_ACCESS_KEY:?set BUZZ_S3_ACCESS_KEY} BUZZ_S3_SECRET_KEY: ${BUZZ_S3_SECRET_KEY:?set BUZZ_S3_SECRET_KEY} BUZZ_S3_BUCKET: ${BUZZ_S3_BUCKET:-buzz-media} diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs index 734d8f5dc8..bcaec6a592 100644 --- a/desktop/src-tauri/src/commands/media_snapshot_png.rs +++ b/desktop/src-tauri/src/commands/media_snapshot_png.rs @@ -204,6 +204,7 @@ mod tests { s3_secret_key: String::new(), s3_bucket: String::new(), s3_region: "us-east-1".to_string(), + s3_addressing_style: buzz_media_pkg::S3AddressingStyle::Path, max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000,