use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "backend")]
pub enum StorageBackendConfig {
#[serde(rename = "s3")]
S3 {
bucket: String,
#[serde(default)]
region: Option<String>,
#[serde(default)]
endpoint: Option<String>,
#[serde(default)]
access_key: Option<String>,
#[serde(default)]
secret_key: Option<String>,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
path_style: bool,
#[serde(default)]
allow_http: bool,
},
#[serde(rename = "azure")]
Azure {
account_name: String,
container_name: String,
#[serde(default)]
account_key: Option<String>,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
endpoint: Option<String>,
#[serde(default)]
use_workload_identity: Option<bool>,
#[serde(default)]
client_id: Option<String>,
#[serde(default)]
tenant_id: Option<String>,
#[serde(default)]
client_secret: Option<String>,
#[serde(default)]
sas_token: Option<String>,
},
#[serde(rename = "gcs")]
Gcs {
bucket: String,
#[serde(default)]
service_account_path: Option<String>,
#[serde(default)]
prefix: Option<String>,
},
#[serde(rename = "filesystem")]
Filesystem {
path: PathBuf,
},
#[serde(rename = "memory")]
Memory,
}
impl StorageBackendConfig {
pub fn from_url(url: &str) -> crate::Result<Self> {
let parsed = url::Url::parse(url)
.map_err(|e| crate::Error::Config(format!("Invalid storage URL: {}", e)))?;
match parsed.scheme() {
"s3" | "s3a" => {
let bucket = parsed.host_str().unwrap_or_default().to_string();
let prefix = path_prefix(&parsed);
let region = parsed
.query_pairs()
.find(|(k, _)| k == "region")
.map(|(_, v)| v.to_string());
let endpoint = parsed
.query_pairs()
.find(|(k, _)| k == "endpoint")
.map(|(_, v)| v.to_string());
let path_style = parsed
.query_pairs()
.find(|(k, _)| k == "path_style")
.map(|(_, v)| v == "true")
.unwrap_or(false);
let allow_http = parsed
.query_pairs()
.find(|(k, _)| k == "allow_http")
.map(|(_, v)| v == "true")
.unwrap_or(false);
Ok(Self::S3 {
bucket,
region,
endpoint,
access_key: std::env::var("AWS_ACCESS_KEY_ID").ok(),
secret_key: std::env::var("AWS_SECRET_ACCESS_KEY").ok(),
prefix,
path_style,
allow_http,
})
}
"azure" | "az" => {
let host = parsed.host_str().unwrap_or_default();
let account_name = host.split('.').next().unwrap_or(host).to_string();
let path = parsed.path().trim_start_matches('/');
let (container_name, prefix) = split_first_path_component(path);
let has_workload_identity = std::env::var("AZURE_FEDERATED_TOKEN_FILE").is_ok();
Ok(Self::Azure {
account_name,
container_name,
account_key: std::env::var("AZURE_STORAGE_KEY")
.ok()
.or_else(|| std::env::var("AZURE_STORAGE_ACCOUNT_KEY").ok()),
prefix,
endpoint: None,
use_workload_identity: if has_workload_identity {
Some(true)
} else {
None
},
client_id: std::env::var("AZURE_CLIENT_ID").ok(),
tenant_id: std::env::var("AZURE_TENANT_ID").ok(),
client_secret: std::env::var("AZURE_CLIENT_SECRET").ok(),
sas_token: std::env::var("AZURE_STORAGE_SAS_TOKEN").ok(),
})
}
"gcs" | "gs" => {
let bucket = parsed.host_str().unwrap_or_default().to_string();
let prefix = path_prefix(&parsed);
Ok(Self::Gcs {
bucket,
service_account_path: std::env::var("GOOGLE_APPLICATION_CREDENTIALS").ok(),
prefix,
})
}
"file" => Ok(Self::Filesystem {
path: PathBuf::from(parsed.path()),
}),
"memory" => Ok(Self::Memory),
scheme => Err(crate::Error::Config(format!(
"Unknown storage scheme: {}",
scheme
))),
}
}
pub fn prefix(&self) -> Option<&str> {
match self {
Self::S3 { prefix, .. } => prefix.as_deref(),
Self::Azure { prefix, .. } => prefix.as_deref(),
Self::Gcs { prefix, .. } => prefix.as_deref(),
Self::Filesystem { .. } => None,
Self::Memory => None,
}
}
}
fn path_prefix(parsed: &url::Url) -> Option<String> {
let prefix = parsed.path().trim_matches('/');
if prefix.is_empty() {
None
} else {
Some(prefix.to_string())
}
}
fn split_first_path_component(path: &str) -> (String, Option<String>) {
let trimmed = path.trim_matches('/');
match trimmed.split_once('/') {
Some((first, rest)) => (first.to_string(), Some(rest.trim_matches('/').to_string())),
None => (trimmed.to_string(), None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_s3_url_parsing() {
let config = StorageBackendConfig::from_url("s3://my-bucket?region=us-west-2").unwrap();
match config {
StorageBackendConfig::S3 { bucket, region, .. } => {
assert_eq!(bucket, "my-bucket");
assert_eq!(region, Some("us-west-2".to_string()));
}
_ => panic!("Expected S3 config"),
}
}
#[test]
fn test_s3_url_parsing_with_prefix_and_flags() {
let config = StorageBackendConfig::from_url(
"s3://my-bucket/backups/prod?region=us-west-2&path_style=true&allow_http=true",
)
.unwrap();
match config {
StorageBackendConfig::S3 {
bucket,
region,
prefix,
path_style,
allow_http,
..
} => {
assert_eq!(bucket, "my-bucket");
assert_eq!(region, Some("us-west-2".to_string()));
assert_eq!(prefix, Some("backups/prod".to_string()));
assert!(path_style);
assert!(allow_http);
}
_ => panic!("Expected S3 config"),
}
}
#[test]
fn test_gcs_url_parsing_with_prefix() {
let config = StorageBackendConfig::from_url("gcs://my-bucket/backups/prod").unwrap();
match config {
StorageBackendConfig::Gcs { bucket, prefix, .. } => {
assert_eq!(bucket, "my-bucket");
assert_eq!(prefix, Some("backups/prod".to_string()));
}
_ => panic!("Expected GCS config"),
}
}
#[test]
fn test_azure_url_parsing_with_container_and_prefix() {
let config =
StorageBackendConfig::from_url("azure://account.blob.core.windows.net/container/path")
.unwrap();
match config {
StorageBackendConfig::Azure {
account_name,
container_name,
prefix,
..
} => {
assert_eq!(account_name, "account");
assert_eq!(container_name, "container");
assert_eq!(prefix, Some("path".to_string()));
}
_ => panic!("Expected Azure config"),
}
}
#[test]
fn test_filesystem_url_parsing() {
let config = StorageBackendConfig::from_url("file:///var/rabbitmq-backups").unwrap();
match config {
StorageBackendConfig::Filesystem { path } => {
assert_eq!(path, PathBuf::from("/var/rabbitmq-backups"));
}
_ => panic!("Expected Filesystem config"),
}
}
#[test]
fn test_memory_url_parsing() {
let config = StorageBackendConfig::from_url("memory://").unwrap();
assert!(matches!(config, StorageBackendConfig::Memory));
}
#[test]
fn test_yaml_deserialization_s3() {
let yaml = r#"
backend: s3
bucket: rabbitmq-backups
region: us-east-1
endpoint: http://localhost:9000
path_style: true
allow_http: true
"#;
let config: StorageBackendConfig = serde_yaml::from_str(yaml).unwrap();
match config {
StorageBackendConfig::S3 {
bucket,
region,
endpoint,
path_style,
allow_http,
..
} => {
assert_eq!(bucket, "rabbitmq-backups");
assert_eq!(region, Some("us-east-1".to_string()));
assert_eq!(endpoint, Some("http://localhost:9000".to_string()));
assert!(path_style);
assert!(allow_http);
}
_ => panic!("Expected S3 config"),
}
}
#[test]
fn test_yaml_deserialization_filesystem() {
let yaml = r#"
backend: filesystem
path: /tmp/backups
"#;
let config: StorageBackendConfig = serde_yaml::from_str(yaml).unwrap();
match config {
StorageBackendConfig::Filesystem { path } => {
assert_eq!(path, PathBuf::from("/tmp/backups"));
}
_ => panic!("Expected Filesystem config"),
}
}
#[test]
fn test_yaml_deserialization_memory() {
let yaml = "backend: memory\n";
let config: StorageBackendConfig = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(config, StorageBackendConfig::Memory));
}
#[test]
fn test_yaml_deserialization_azure() {
let yaml = r#"
backend: azure
account_name: myaccount
container_name: backups
"#;
let config: StorageBackendConfig = serde_yaml::from_str(yaml).unwrap();
match config {
StorageBackendConfig::Azure {
account_name,
container_name,
..
} => {
assert_eq!(account_name, "myaccount");
assert_eq!(container_name, "backups");
}
_ => panic!("Expected Azure config"),
}
}
#[test]
fn test_yaml_deserialization_gcs() {
let yaml = r#"
backend: gcs
bucket: my-gcs-bucket
prefix: backups/
"#;
let config: StorageBackendConfig = serde_yaml::from_str(yaml).unwrap();
match config {
StorageBackendConfig::Gcs { bucket, prefix, .. } => {
assert_eq!(bucket, "my-gcs-bucket");
assert_eq!(prefix, Some("backups/".to_string()));
}
_ => panic!("Expected GCS config"),
}
}
#[test]
fn test_unknown_scheme() {
let result = StorageBackendConfig::from_url("ftp://example.com");
assert!(result.is_err());
}
}