use crate::error::SailError;
#[derive(Debug, Clone)]
pub struct Config {
pub api_key: String,
pub api_url: String,
pub sailbox_api_url: String,
pub imagebuilder_url: String,
pub ingress_base: String,
pub ingress_scheme: IngressScheme,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IngressScheme {
Path,
Subdomain,
}
#[derive(Debug)]
struct EnvDefaults {
api_url: &'static str,
sailbox_api_url: &'static str,
imagebuilder_url: &'static str,
ingress_base: &'static str,
ingress_subdomain: bool,
}
const PROD: EnvDefaults = EnvDefaults {
api_url: "https://api.sailresearch.com",
sailbox_api_url: "https://sailbox-api.sailresearch.com",
imagebuilder_url: "sailbox-imagebuilder-dispatcher.sailresearch.com:443",
ingress_base: "https://api.sailresearch.com",
ingress_subdomain: true,
};
const DEV: EnvDefaults = EnvDefaults {
api_url: "https://dev.sailresearch.com",
sailbox_api_url: "https://sailbox-api.dev.sailresearch.com",
imagebuilder_url: "sailbox-imagebuilder-dispatcher.dev.sailresearch.com:443",
ingress_base: "https://dev.sailresearch.com",
ingress_subdomain: true,
};
const STAGING: EnvDefaults = EnvDefaults {
api_url: "https://staging.sailresearch.com",
sailbox_api_url: "https://sailbox-api.staging.sailresearch.com",
imagebuilder_url: "sailbox-imagebuilder-dispatcher.staging.sailresearch.com:443",
ingress_base: "https://beta.sailresearch.com",
ingress_subdomain: true,
};
const LOCAL: EnvDefaults = EnvDefaults {
api_url: "http://localhost:8080",
sailbox_api_url: "http://localhost:8080",
imagebuilder_url: "localhost:50061",
ingress_base: "http://localhost:18080",
ingress_subdomain: false,
};
fn env_or_empty(name: &str) -> String {
std::env::var(name).unwrap_or_default()
}
fn env_trimmed(name: &str) -> String {
env_or_empty(name).trim().to_string()
}
fn mode_defaults(mode: &str) -> Result<&'static EnvDefaults, SailError> {
match mode.trim().to_lowercase().as_str() {
"" | "prod" => Ok(&PROD),
"dev" => Ok(&DEV),
"staging" => Ok(&STAGING),
"local" => Ok(&LOCAL),
other => Err(SailError::Config {
message: format!(
"SAIL_MODE={other} is not recognized; use SAIL_MODE=prod|dev|staging|local"
),
}),
}
}
pub fn api_url_for_mode(mode: &str) -> Option<&'static str> {
mode_defaults(mode).ok().map(|defaults| defaults.api_url)
}
impl Config {
pub fn resolve(
mode: Option<&str>,
api_key: String,
api_url: Option<String>,
sailbox_api_url: Option<String>,
imagebuilder_url: Option<String>,
sailbox_ingress_url: Option<String>,
) -> Result<Config, SailError> {
let api_key = api_key.trim().to_string();
if api_key.is_empty() {
return Err(SailError::Config {
message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
});
}
Config::build(
mode,
api_key,
api_url,
sailbox_api_url,
imagebuilder_url,
sailbox_ingress_url,
)
}
fn build(
mode: Option<&str>,
api_key: String,
api_url: Option<String>,
sailbox_api_url: Option<String>,
imagebuilder_url: Option<String>,
sailbox_ingress_url: Option<String>,
) -> Result<Config, SailError> {
let defaults = mode_defaults(mode.unwrap_or(""))?;
fn or_default(override_value: Option<String>, default: &str) -> String {
match override_value {
Some(value) if !value.trim().is_empty() => value.trim().to_string(),
_ => default.to_string(),
}
}
let has_override = sailbox_ingress_url
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
let ingress_scheme = if !has_override && defaults.ingress_subdomain {
IngressScheme::Subdomain
} else {
IngressScheme::Path
};
Ok(Config {
api_key,
api_url: or_default(api_url, defaults.api_url),
sailbox_api_url: or_default(sailbox_api_url, defaults.sailbox_api_url),
imagebuilder_url: or_default(imagebuilder_url, defaults.imagebuilder_url),
ingress_base: or_default(sailbox_ingress_url, defaults.ingress_base),
ingress_scheme,
})
}
pub fn from_env() -> Result<Config, SailError> {
Config::from_env_inner(true)
}
pub fn from_env_optional_api_key() -> Result<Config, SailError> {
Config::from_env_inner(false)
}
fn from_env_inner(require_api_key: bool) -> Result<Config, SailError> {
let settings = crate::credentials::load_settings()?;
let pick = |env_name: &str, stored_key: &str| -> String {
pick_setting(std::env::var(env_name).ok(), settings.get(stored_key))
};
let mode = pick("SAIL_MODE", "mode");
let api_url = pick("SAIL_API_URL", "api_url");
let sailbox_api_url = pick("SAILBOX_API_URL", "sailbox_api_url");
let imagebuilder_url = pick("SAIL_IMAGEBUILDER_URL", "imagebuilder_url");
let sailbox_ingress_url = env_trimmed("SAILBOX_INGRESS_URL");
let mut api_key = env_trimmed("SAIL_API_KEY");
if api_key.is_empty() {
let target = crate::credentials::resolve_target_api_url(&api_url, &mode);
if crate::credentials::stored_key_matches_target(&settings, &target) {
if let Some(stored) = crate::credentials::auth_key_best_effort() {
api_key = stored;
}
}
}
if require_api_key && api_key.is_empty() {
return Err(SailError::Config {
message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
});
}
Config::build(
Some(&mode),
api_key,
opt(api_url),
opt(sailbox_api_url),
opt(imagebuilder_url),
opt(sailbox_ingress_url),
)
}
}
fn opt(value: String) -> Option<String> {
if value.is_empty() {
None
} else {
Some(value)
}
}
fn pick_setting(env_value: Option<String>, stored: Option<&String>) -> String {
match env_value {
Some(value) => value.trim().to_string(),
None => stored
.map(|value| value.trim().to_string())
.unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_modes_resolve() {
assert_eq!(mode_defaults("").unwrap().api_url, PROD.api_url);
assert_eq!(mode_defaults(" PROD ").unwrap().api_url, PROD.api_url);
assert_eq!(mode_defaults("dev").unwrap().api_url, DEV.api_url);
assert_eq!(mode_defaults("local").unwrap().api_url, LOCAL.api_url);
}
#[test]
fn unknown_mode_is_config_error() {
match mode_defaults("production") {
Err(SailError::Config { message }) => {
assert!(message.contains("not recognized"), "message={message}");
}
other => panic!("expected Config error, got {other:?}"),
}
}
#[test]
fn pick_setting_present_env_masks_stored() {
let stored = "https://stored.example".to_string();
assert_eq!(
pick_setting(Some("https://env.example".to_string()), Some(&stored)),
"https://env.example"
);
assert_eq!(pick_setting(Some(String::new()), Some(&stored)), "");
assert_eq!(pick_setting(Some(" ".to_string()), Some(&stored)), "");
assert_eq!(pick_setting(None, Some(&stored)), "https://stored.example");
assert_eq!(pick_setting(None, None), "");
}
#[test]
fn override_wins_blank_falls_back_to_mode_default() {
let config = Config::resolve(
Some("dev"),
"k".to_string(),
Some("https://override.example".to_string()),
None, Some(" ".to_string()), None,
)
.unwrap();
assert_eq!(config.api_url, "https://override.example");
assert_eq!(config.sailbox_api_url, DEV.sailbox_api_url);
assert_eq!(config.imagebuilder_url, DEV.imagebuilder_url);
}
#[test]
fn api_key_is_required_and_trimmed() {
assert!(matches!(
Config::resolve(None, " ".to_string(), None, None, None, None),
Err(SailError::Config { .. })
));
let config = Config::resolve(None, " sk_k ".to_string(), None, None, None, None).unwrap();
assert_eq!(config.api_key, "sk_k");
assert_eq!(config.api_url, PROD.api_url);
}
#[test]
fn ingress_defaults_per_mode_and_override_forces_path() {
let prod = Config::resolve(None, "k".to_string(), None, None, None, None).unwrap();
assert_eq!(prod.ingress_scheme, IngressScheme::Subdomain);
assert_eq!(prod.ingress_base, PROD.ingress_base);
let local =
Config::resolve(Some("local"), "k".to_string(), None, None, None, None).unwrap();
assert_eq!(local.ingress_scheme, IngressScheme::Path);
let overridden = Config::resolve(
None,
"k".to_string(),
None,
None,
None,
Some("https://ingress.example".to_string()),
)
.unwrap();
assert_eq!(overridden.ingress_scheme, IngressScheme::Path);
assert_eq!(overridden.ingress_base, "https://ingress.example");
}
}