Skip to main content

sail/
config.rs

1//! SDK configuration loaded from environment variables.
2//!
3//! SAIL_MODE selects a named environment (prod|dev|staging|local); when
4//! unset the prod defaults are used. Endpoints can be overridden via
5//! SAIL_API_URL and SAILBOX_API_URL.
6
7use crate::error::SailError;
8
9/// Resolved SDK configuration: credentials plus the three service endpoints.
10#[derive(Debug, Clone)]
11pub struct Config {
12    /// Bearer API key sent on every request (trimmed of surrounding whitespace).
13    pub api_key: String,
14    /// Base URL of the public Sail REST API (no trailing path).
15    pub api_url: String,
16    /// Base URL of the sailbox lifecycle/exec API.
17    pub sailbox_api_url: String,
18    /// `host:port` target of the imagebuilder dispatcher gRPC service.
19    pub imagebuilder_url: String,
20    /// Base URL that listener URLs are built from when the server does not
21    /// return one, e.g. `https://api.sailresearch.com`.
22    pub ingress_base: String,
23    /// How a listener's URL is addressed under [`ingress_base`](Config::ingress_base).
24    pub ingress_scheme: IngressScheme,
25}
26
27/// How the SDK addresses a listener's URL under the ingress base, the sailbox
28/// id, and the port, when the server does not return one.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum IngressScheme {
31    /// One host, path-addressed: `<base>/_sailbox/{sailbox_id}/{guest_port}`
32    /// (local stacks, or an explicit ingress URL).
33    Path,
34    /// Per-listener subdomain of the base: `<sailbox>-<port>.<base host>`
35    /// (deployed environments, served by wildcard DNS).
36    Subdomain,
37}
38
39#[derive(Debug)]
40struct EnvDefaults {
41    api_url: &'static str,
42    sailbox_api_url: &'static str,
43    imagebuilder_url: &'static str,
44    ingress_base: &'static str,
45    /// True when listeners are subdomain-addressed (deployed wildcard DNS);
46    /// false for path-addressed (local). Built into [`IngressScheme`].
47    ingress_subdomain: bool,
48}
49
50const PROD: EnvDefaults = EnvDefaults {
51    api_url: "https://api.sailresearch.com",
52    sailbox_api_url: "https://sailbox-api.sailresearch.com",
53    imagebuilder_url: "sailbox-imagebuilder-dispatcher.sailresearch.com:443",
54    ingress_base: "https://api.sailresearch.com",
55    ingress_subdomain: true,
56};
57
58const DEV: EnvDefaults = EnvDefaults {
59    api_url: "https://dev.sailresearch.com",
60    sailbox_api_url: "https://sailbox-api.dev.sailresearch.com",
61    imagebuilder_url: "sailbox-imagebuilder-dispatcher.dev.sailresearch.com:443",
62    ingress_base: "https://dev.sailresearch.com",
63    ingress_subdomain: true,
64};
65
66const STAGING: EnvDefaults = EnvDefaults {
67    api_url: "https://staging.sailresearch.com",
68    sailbox_api_url: "https://sailbox-api.staging.sailresearch.com",
69    imagebuilder_url: "sailbox-imagebuilder-dispatcher.staging.sailresearch.com:443",
70    ingress_base: "https://beta.sailresearch.com",
71    ingress_subdomain: true,
72};
73
74// Local backend nginx serves the public HTTP API on :8080 (see
75// backend/docker-compose.yml NGINX_PORT). All sailbox lifecycle
76// operations go through this endpoint; the imagebuilder dispatcher listens on
77// :50061; listener ingress is served by path on :18080.
78const LOCAL: EnvDefaults = EnvDefaults {
79    api_url: "http://localhost:8080",
80    sailbox_api_url: "http://localhost:8080",
81    imagebuilder_url: "localhost:50061",
82    ingress_base: "http://localhost:18080",
83    ingress_subdomain: false,
84};
85
86fn env_or_empty(name: &str) -> String {
87    std::env::var(name).unwrap_or_default()
88}
89
90fn env_trimmed(name: &str) -> String {
91    env_or_empty(name).trim().to_string()
92}
93
94/// Resolve the env defaults for a SAIL_MODE value. Empty means "no mode
95/// declared" and is treated as prod; any other unrecognized value raises.
96fn mode_defaults(mode: &str) -> Result<&'static EnvDefaults, SailError> {
97    match mode.trim().to_lowercase().as_str() {
98        "" | "prod" => Ok(&PROD),
99        "dev" => Ok(&DEV),
100        "staging" => Ok(&STAGING),
101        "local" => Ok(&LOCAL),
102        other => Err(SailError::Config {
103            message: format!(
104                "SAIL_MODE={other} is not recognized; use SAIL_MODE=prod|dev|staging|local"
105            ),
106        }),
107    }
108}
109
110/// The central public-API URL for a named mode (`prod`/`dev`/`staging`/`local`,
111/// empty means prod), or `None` for an unrecognized mode. Lets bindings resolve
112/// a mode's endpoint from this single source of truth instead of restating it.
113pub fn api_url_for_mode(mode: &str) -> Option<&'static str> {
114    mode_defaults(mode).ok().map(|defaults| defaults.api_url)
115}
116
117impl Config {
118    /// Resolve a config from a `mode` plus explicit overrides. `mode` selects
119    /// the endpoint defaults (empty/None means prod); any non-empty override
120    /// wins over its default. The API key is required and trimmed.
121    pub fn resolve(
122        mode: Option<&str>,
123        api_key: String,
124        api_url: Option<String>,
125        sailbox_api_url: Option<String>,
126        imagebuilder_url: Option<String>,
127        sailbox_ingress_url: Option<String>,
128    ) -> Result<Config, SailError> {
129        let api_key = api_key.trim().to_string();
130        if api_key.is_empty() {
131            return Err(SailError::Config {
132                message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
133            });
134        }
135        Config::build(
136            mode,
137            api_key,
138            api_url,
139            sailbox_api_url,
140            imagebuilder_url,
141            sailbox_ingress_url,
142        )
143    }
144
145    /// Build a config, resolving each unset endpoint from the mode defaults. Does
146    /// not require an API key; the caller decides whether an empty key is valid.
147    fn build(
148        mode: Option<&str>,
149        api_key: String,
150        api_url: Option<String>,
151        sailbox_api_url: Option<String>,
152        imagebuilder_url: Option<String>,
153        sailbox_ingress_url: Option<String>,
154    ) -> Result<Config, SailError> {
155        let defaults = mode_defaults(mode.unwrap_or(""))?;
156        fn or_default(override_value: Option<String>, default: &str) -> String {
157            match override_value {
158                Some(value) if !value.trim().is_empty() => value.trim().to_string(),
159                _ => default.to_string(),
160            }
161        }
162        // An explicit ingress URL is always addressed by path; the subdomain
163        // scheme only applies to the deployed defaults.
164        let has_override = sailbox_ingress_url
165            .as_deref()
166            .map(str::trim)
167            .is_some_and(|value| !value.is_empty());
168        let ingress_scheme = if !has_override && defaults.ingress_subdomain {
169            IngressScheme::Subdomain
170        } else {
171            IngressScheme::Path
172        };
173        Ok(Config {
174            api_key,
175            api_url: or_default(api_url, defaults.api_url),
176            sailbox_api_url: or_default(sailbox_api_url, defaults.sailbox_api_url),
177            imagebuilder_url: or_default(imagebuilder_url, defaults.imagebuilder_url),
178            ingress_base: or_default(sailbox_ingress_url, defaults.ingress_base),
179            ingress_scheme,
180        })
181    }
182
183    /// Resolve a config from the environment, falling back to the stored
184    /// `~/.sail` credential and settings for any value the environment does not
185    /// set. Environment variables always win. The stored API key is applied only
186    /// when its tagged target matches the resolved one, so a key minted for one
187    /// environment is never sent to another. The key is trimmed so one sourced
188    /// with a trailing newline can't produce a malformed bearer token.
189    pub fn from_env() -> Result<Config, SailError> {
190        Config::from_env_inner(true)
191    }
192
193    /// Like [`from_env`](Self::from_env) but does not require an API key, for
194    /// telemetry that degrades to a no-op when unauthenticated. Endpoints still
195    /// resolve from the environment and `~/.sail`.
196    pub fn from_env_optional_api_key() -> Result<Config, SailError> {
197        Config::from_env_inner(false)
198    }
199
200    fn from_env_inner(require_api_key: bool) -> Result<Config, SailError> {
201        // Strict: a malformed config.toml or one with an unrecognized key is an
202        // error here, so a typo'd setting fails loudly instead of being silently
203        // dropped. Telemetry callers (voyage) catch this and degrade rather than
204        // crash; see `_core_client.resolved_api_key`.
205        let settings = crate::credentials::load_settings()?;
206        let pick = |env_name: &str, stored_key: &str| -> String {
207            pick_setting(std::env::var(env_name).ok(), settings.get(stored_key))
208        };
209        let mode = pick("SAIL_MODE", "mode");
210        let api_url = pick("SAIL_API_URL", "api_url");
211        let sailbox_api_url = pick("SAILBOX_API_URL", "sailbox_api_url");
212        let imagebuilder_url = pick("SAIL_IMAGEBUILDER_URL", "imagebuilder_url");
213        // Ingress is an env-only override; otherwise it follows the mode default.
214        let sailbox_ingress_url = env_trimmed("SAILBOX_INGRESS_URL");
215
216        let mut api_key = env_trimmed("SAIL_API_KEY");
217        if api_key.is_empty() {
218            let target = crate::credentials::resolve_target_api_url(&api_url, &mode);
219            if crate::credentials::stored_key_matches_target(&settings, &target) {
220                if let Some(stored) = crate::credentials::auth_key_best_effort() {
221                    api_key = stored;
222                }
223            }
224        }
225        if require_api_key && api_key.is_empty() {
226            return Err(SailError::Config {
227                message: "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
228            });
229        }
230
231        Config::build(
232            Some(&mode),
233            api_key,
234            opt(api_url),
235            opt(sailbox_api_url),
236            opt(imagebuilder_url),
237            opt(sailbox_ingress_url),
238        )
239    }
240}
241
242fn opt(value: String) -> Option<String> {
243    if value.is_empty() {
244        None
245    } else {
246        Some(value)
247    }
248}
249
250/// Resolve one endpoint/mode setting, trimmed. The environment wins whenever the
251/// variable is present, including when it is empty: an explicit empty value masks
252/// any stored override, which is how `sail --mode <env>` forces that mode's
253/// endpoint defaults instead of inheriting a stored `~/.sail` endpoint. Only a
254/// fully unset variable falls back to the stored value.
255fn pick_setting(env_value: Option<String>, stored: Option<&String>) -> String {
256    match env_value {
257        Some(value) => value.trim().to_string(),
258        None => stored
259            .map(|value| value.trim().to_string())
260            .unwrap_or_default(),
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn known_modes_resolve() {
270        assert_eq!(mode_defaults("").unwrap().api_url, PROD.api_url);
271        assert_eq!(mode_defaults(" PROD ").unwrap().api_url, PROD.api_url);
272        assert_eq!(mode_defaults("dev").unwrap().api_url, DEV.api_url);
273        assert_eq!(mode_defaults("local").unwrap().api_url, LOCAL.api_url);
274    }
275
276    #[test]
277    fn unknown_mode_is_config_error() {
278        match mode_defaults("production") {
279            Err(SailError::Config { message }) => {
280                assert!(message.contains("not recognized"), "message={message}");
281            }
282            other => panic!("expected Config error, got {other:?}"),
283        }
284    }
285
286    #[test]
287    fn pick_setting_present_env_masks_stored() {
288        let stored = "https://stored.example".to_string();
289        // A present env var wins, even empty: an empty value masks the stored
290        // override (how `--mode` forces mode defaults), rather than falling back.
291        assert_eq!(
292            pick_setting(Some("https://env.example".to_string()), Some(&stored)),
293            "https://env.example"
294        );
295        assert_eq!(pick_setting(Some(String::new()), Some(&stored)), "");
296        assert_eq!(pick_setting(Some("  ".to_string()), Some(&stored)), "");
297        // Only a fully unset var falls back to the stored value.
298        assert_eq!(pick_setting(None, Some(&stored)), "https://stored.example");
299        assert_eq!(pick_setting(None, None), "");
300    }
301
302    #[test]
303    fn override_wins_blank_falls_back_to_mode_default() {
304        let config = Config::resolve(
305            Some("dev"),
306            "k".to_string(),
307            Some("https://override.example".to_string()),
308            None,                    // unset → mode default
309            Some("   ".to_string()), // blank → mode default
310            None,
311        )
312        .unwrap();
313        assert_eq!(config.api_url, "https://override.example");
314        assert_eq!(config.sailbox_api_url, DEV.sailbox_api_url);
315        assert_eq!(config.imagebuilder_url, DEV.imagebuilder_url);
316    }
317
318    #[test]
319    fn api_key_is_required_and_trimmed() {
320        assert!(matches!(
321            Config::resolve(None, "   ".to_string(), None, None, None, None),
322            Err(SailError::Config { .. })
323        ));
324        let config = Config::resolve(None, "  sk_k  ".to_string(), None, None, None, None).unwrap();
325        assert_eq!(config.api_key, "sk_k");
326        // No mode declared resolves to the prod defaults.
327        assert_eq!(config.api_url, PROD.api_url);
328    }
329
330    #[test]
331    fn ingress_defaults_per_mode_and_override_forces_path() {
332        // Deployed modes default to a subdomain-addressed ingress.
333        let prod = Config::resolve(None, "k".to_string(), None, None, None, None).unwrap();
334        assert_eq!(prod.ingress_scheme, IngressScheme::Subdomain);
335        assert_eq!(prod.ingress_base, PROD.ingress_base);
336        // Local defaults to path-addressed ingress.
337        let local =
338            Config::resolve(Some("local"), "k".to_string(), None, None, None, None).unwrap();
339        assert_eq!(local.ingress_scheme, IngressScheme::Path);
340        // An explicit ingress URL is always path-addressed.
341        let overridden = Config::resolve(
342            None,
343            "k".to_string(),
344            None,
345            None,
346            None,
347            Some("https://ingress.example".to_string()),
348        )
349        .unwrap();
350        assert_eq!(overridden.ingress_scheme, IngressScheme::Path);
351        assert_eq!(overridden.ingress_base, "https://ingress.example");
352    }
353}