use crate::secrets::ProtectedString;
use serde::{Deserialize, Serialize};
use std::env;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum StorageMode {
#[default]
Local,
S3,
Gcs,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
#[serde(default)]
pub mode: StorageMode,
#[serde(default = "default_storage_path")]
pub path: String,
#[serde(default = "default_s3_url")]
pub s3_url: String,
#[serde(default = "default_bucket")]
pub bucket: String,
#[serde(default, skip_serializing)]
pub s3_access_key: Option<ProtectedString>,
#[serde(default, skip_serializing)]
pub s3_secret_key: Option<ProtectedString>,
#[serde(default = "default_s3_region")]
pub s3_region: String,
#[serde(default)]
pub s3_virtual_hosted: bool,
#[serde(default)]
pub gcs_service_account_path: Option<String>,
#[serde(default)]
pub gcs_base_url: Option<String>,
}
pub(super) fn default_s3_region() -> String {
"us-east-1".to_string()
}
pub(super) fn default_storage_path() -> String {
"data/storage".to_string()
}
pub(super) fn default_s3_url() -> String {
"http://127.0.0.1:9000".to_string()
}
pub(super) fn default_bucket() -> String {
"registry".to_string()
}
impl Default for StorageConfig {
fn default() -> Self {
Self {
mode: StorageMode::Local,
path: default_storage_path(),
s3_url: default_s3_url(),
bucket: default_bucket(),
s3_access_key: None,
s3_secret_key: None,
s3_region: default_s3_region(),
s3_virtual_hosted: false,
gcs_service_account_path: None,
gcs_base_url: None,
}
}
}
impl StorageConfig {
pub(super) fn apply_env_overrides(&mut self) -> Result<(), String> {
if let Ok(val) = env::var("NORA_STORAGE_MODE") {
self.mode = match val.to_lowercase().as_str() {
"local" | "filesystem" => StorageMode::Local,
"s3" => StorageMode::S3,
"gcs" => StorageMode::Gcs,
other => {
return Err(format!(
"NORA_STORAGE_MODE={:?} is invalid — valid values: local, s3, gcs",
other
))
}
};
}
if let Ok(val) = env::var("NORA_STORAGE_PATH") {
self.path = val;
}
if let Ok(val) = env::var("NORA_STORAGE_S3_URL") {
self.s3_url = val;
}
if let Ok(val) = env::var("NORA_STORAGE_BUCKET") {
self.bucket = val;
}
if let Ok(val) = env::var("NORA_STORAGE_GCS_SERVICE_ACCOUNT_PATH") {
self.gcs_service_account_path = if val.is_empty() { None } else { Some(val) };
}
if let Ok(val) = env::var("NORA_STORAGE_GCS_BASE_URL") {
self.gcs_base_url = if val.is_empty() { None } else { Some(val) };
}
if let Ok(val) = env::var("NORA_STORAGE_S3_ACCESS_KEY") {
self.s3_access_key = if val.is_empty() {
None
} else {
Some(ProtectedString::new(val))
};
}
if let Ok(val) = env::var("NORA_STORAGE_S3_SECRET_KEY") {
self.s3_secret_key = if val.is_empty() {
None
} else {
Some(ProtectedString::new(val))
};
}
if let Ok(val) = env::var("NORA_STORAGE_S3_REGION") {
self.s3_region = val;
}
if let Ok(val) = env::var("NORA_STORAGE_S3_VIRTUAL_HOSTED") {
self.s3_virtual_hosted = val.to_lowercase() == "true" || val == "1";
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_gcs_mode_parses_from_toml_and_env() {
let cfg: StorageConfig = toml::from_str("mode = \"gcs\"\nbucket = \"artifacts\"").unwrap();
assert_eq!(cfg.mode, StorageMode::Gcs);
assert_eq!(cfg.bucket, "artifacts");
assert_eq!(cfg.gcs_service_account_path, None);
assert_eq!(cfg.gcs_base_url, None);
let mut cfg = StorageConfig::default();
std::env::set_var("NORA_STORAGE_MODE", "gcs");
std::env::set_var("NORA_STORAGE_GCS_SERVICE_ACCOUNT_PATH", "/sa.json");
std::env::set_var("NORA_STORAGE_GCS_BASE_URL", "http://127.0.0.1:4443");
let r = cfg.apply_env_overrides();
std::env::remove_var("NORA_STORAGE_MODE");
std::env::remove_var("NORA_STORAGE_GCS_SERVICE_ACCOUNT_PATH");
std::env::remove_var("NORA_STORAGE_GCS_BASE_URL");
r.unwrap();
assert_eq!(cfg.mode, StorageMode::Gcs);
assert_eq!(cfg.gcs_service_account_path.as_deref(), Some("/sa.json"));
assert_eq!(cfg.gcs_base_url.as_deref(), Some("http://127.0.0.1:4443"));
}
#[test]
fn test_unknown_mode_still_fails_closed() {
let mut cfg = StorageConfig::default();
std::env::set_var("NORA_STORAGE_MODE", "azure");
let r = cfg.apply_env_overrides();
std::env::remove_var("NORA_STORAGE_MODE");
let err = r.unwrap_err();
assert!(err.contains("local, s3, gcs"), "{err}");
}
}