Skip to main content

sail/
config.rs

1//! SDK configuration loaded from environment variables.
2//!
3//! Endpoints default to the Sail service and can be overridden individually
4//! via `SAIL_API_URL`, `SAILBOX_API_URL`, `SAIL_IMAGEBUILDER_URL`, and
5//! `SAILBOX_INGRESS_URL`.
6
7use crate::error::SailError;
8
9/// Resolved SDK configuration: credentials plus the three service endpoints.
10///
11/// Usually produced from the environment and `~/.sail` via
12/// [`ClientBuilder`](crate::ClientBuilder) or [`Client::from_env`](crate::Client::from_env),
13/// which also fill the derived fields consistently.
14///
15/// `Debug` redacts the API key, so a logged `Config` never leaks the
16/// credential.
17#[derive(Clone)]
18pub struct Config {
19    /// The environment mode these endpoints were resolved from, or `None` for
20    /// the default. Retained so a caller that rebuilds a config from an explicit
21    /// key can reselect the same environment (including its ingress scheme)
22    /// rather than fall back to the default.
23    pub mode: Option<String>,
24    /// Bearer API key sent on every request (trimmed of surrounding whitespace).
25    pub api_key: String,
26    /// Base URL of the public Sail REST API (no trailing path).
27    pub api_url: String,
28    /// Base URL of the Sailbox lifecycle/exec API.
29    pub sailbox_api_url: String,
30    /// `host:port` endpoint that image builds are submitted to.
31    pub imagebuilder_url: String,
32    /// Base URL that listener URLs are built from when the server does not
33    /// return one, e.g. `https://api.sailresearch.com`.
34    pub ingress_base: String,
35    /// How a listener's URL is addressed under [`ingress_base`](Config::ingress_base).
36    pub ingress_scheme: IngressScheme,
37}
38
39impl std::fmt::Debug for Config {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("Config")
42            .field("mode", &self.mode)
43            .field("api_key", &redact_key(&self.api_key))
44            .field("api_url", &self.api_url)
45            .field("sailbox_api_url", &self.sailbox_api_url)
46            .field("imagebuilder_url", &self.imagebuilder_url)
47            .field("ingress_base", &self.ingress_base)
48            .field("ingress_scheme", &self.ingress_scheme)
49            .finish()
50    }
51}
52
53/// The redacted `Debug` form of an API key: present-or-absent, never the value.
54pub(crate) fn redact_key(api_key: &str) -> &'static str {
55    if api_key.is_empty() {
56        "<unset>"
57    } else {
58        "<redacted>"
59    }
60}
61
62/// How the SDK addresses a listener's URL under the ingress base, the Sailbox
63/// id, and the port, when the server does not return one.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum IngressScheme {
66    /// One host, path-addressed: `<base>/_sailbox/{sailbox_id}/{guest_port}`
67    /// (self-hosted stacks, or an explicit ingress URL).
68    Path,
69    /// Per-listener subdomain of the base: `<sailbox>-<port>.<base host>`
70    /// (the Sail service, served by wildcard DNS).
71    Subdomain,
72}
73
74#[derive(Debug)]
75struct EnvDefaults {
76    api_url: &'static str,
77    sailbox_api_url: &'static str,
78    imagebuilder_url: &'static str,
79    ingress_base: &'static str,
80    /// True when listeners are subdomain-addressed (deployed wildcard DNS);
81    /// false for path-addressed (local). Built into [`IngressScheme`].
82    ingress_subdomain: bool,
83}
84
85const PROD: EnvDefaults = EnvDefaults {
86    api_url: "https://api.sailresearch.com",
87    sailbox_api_url: "https://sailbox-api.sailresearch.com",
88    imagebuilder_url: "sailbox-imagebuilder-dispatcher.sailresearch.com:443",
89    ingress_base: "https://api.sailresearch.com",
90    ingress_subdomain: true,
91};
92
93const DEV: EnvDefaults = EnvDefaults {
94    api_url: "https://dev.sailresearch.com",
95    sailbox_api_url: "https://sailbox-api.dev.sailresearch.com",
96    imagebuilder_url: "sailbox-imagebuilder-dispatcher.dev.sailresearch.com:443",
97    ingress_base: "https://dev.sailresearch.com",
98    ingress_subdomain: true,
99};
100
101const STAGING: EnvDefaults = EnvDefaults {
102    api_url: "https://staging.sailresearch.com",
103    sailbox_api_url: "https://sailbox-api.staging.sailresearch.com",
104    imagebuilder_url: "sailbox-imagebuilder-dispatcher.staging.sailresearch.com:443",
105    ingress_base: "https://beta.sailresearch.com",
106    ingress_subdomain: true,
107};
108
109// Local backend nginx serves the public HTTP API on :8080 (see
110// backend/docker-compose.yml NGINX_PORT). All sailbox lifecycle
111// operations go through this endpoint; the imagebuilder dispatcher listens on
112// :50061; listener ingress is served by path on :18080.
113const LOCAL: EnvDefaults = EnvDefaults {
114    api_url: "http://localhost:8080",
115    sailbox_api_url: "http://localhost:8080",
116    imagebuilder_url: "localhost:50061",
117    ingress_base: "http://localhost:18080",
118    ingress_subdomain: false,
119};
120
121fn env_or_empty(name: &str) -> String {
122    std::env::var(name).unwrap_or_default()
123}
124
125fn env_trimmed(name: &str) -> String {
126    env_or_empty(name).trim().to_string()
127}
128
129/// Resolve the env defaults for a SAIL_MODE value. Empty means "no mode
130/// declared" and is treated as prod; any other unrecognized value raises.
131fn mode_defaults(mode: &str) -> Result<&'static EnvDefaults, SailError> {
132    match mode.trim().to_lowercase().as_str() {
133        "" | "prod" => Ok(&PROD),
134        "dev" => Ok(&DEV),
135        "staging" => Ok(&STAGING),
136        "local" => Ok(&LOCAL),
137        other => Err(SailError::Config {
138            message: format!(
139                "SAIL_MODE={other} is not recognized; use SAIL_MODE=prod|dev|staging|local"
140            ),
141        }),
142    }
143}
144
145/// The central public-API URL for a named mode (`prod`/`dev`/`staging`/`local`,
146/// empty means prod), or `None` for an unrecognized mode. Lets bindings resolve
147/// a mode's endpoint from this single source of truth instead of restating it.
148#[doc(hidden)]
149pub fn api_url_for_mode(mode: &str) -> Option<&'static str> {
150    mode_defaults(mode).ok().map(|defaults| defaults.api_url)
151}
152
153/// The canonical API-key environment variable for a named mode. Dev and
154/// staging credentials are intentionally separate from the production key so
155/// one shell can hold all three without sending a key to the wrong endpoint.
156#[doc(hidden)]
157pub fn api_key_env_var_for_mode(mode: &str) -> Option<&'static str> {
158    match mode.trim().to_lowercase().as_str() {
159        "" | "prod" | "local" => Some("SAIL_API_KEY"),
160        "dev" => Some("SAIL_DEV_API_KEY"),
161        "staging" => Some("SAIL_STAGING_API_KEY"),
162        _ => None,
163    }
164}
165
166fn missing_api_key_message(mode: &str) -> String {
167    match api_key_env_var_for_mode(mode) {
168        Some("SAIL_API_KEY") => "Set SAIL_API_KEY or run `sail auth login`.".to_string(),
169        Some(env_name) => format!(
170            "Set {env_name} for SAIL_MODE={}.",
171            mode.trim().to_lowercase()
172        ),
173        None => "Set the API key environment variable for the selected SAIL_MODE.".to_string(),
174    }
175}
176
177impl Config {
178    /// Resolve a config from a `mode` plus explicit overrides. `mode` selects
179    /// the endpoint defaults (empty/None means prod); any non-empty override
180    /// wins over its default. The API key is required and trimmed.
181    pub(crate) fn resolve(
182        mode: Option<&str>,
183        api_key: String,
184        api_url: Option<String>,
185        sailbox_api_url: Option<String>,
186        imagebuilder_url: Option<String>,
187        sailbox_ingress_url: Option<String>,
188    ) -> Result<Config, SailError> {
189        let api_key = api_key.trim().to_string();
190        if api_key.is_empty() {
191            return Err(SailError::Config {
192                message: missing_api_key_message(mode.unwrap_or("")),
193            });
194        }
195        Config::build(
196            mode,
197            api_key,
198            api_url,
199            sailbox_api_url,
200            imagebuilder_url,
201            sailbox_ingress_url,
202        )
203    }
204
205    /// Build a config, resolving each unset endpoint from the mode defaults. Does
206    /// not require an API key; the caller decides whether an empty key is valid.
207    fn build(
208        mode: Option<&str>,
209        api_key: String,
210        api_url: Option<String>,
211        sailbox_api_url: Option<String>,
212        imagebuilder_url: Option<String>,
213        sailbox_ingress_url: Option<String>,
214    ) -> Result<Config, SailError> {
215        let defaults = mode_defaults(mode.unwrap_or(""))?;
216        fn or_default(override_value: Option<String>, default: &str) -> String {
217            match override_value {
218                Some(value) if !value.trim().is_empty() => value.trim().to_string(),
219                _ => default.to_string(),
220            }
221        }
222        // An explicit ingress URL is always addressed by path; the subdomain
223        // scheme only applies to the deployed defaults.
224        let has_override = sailbox_ingress_url
225            .as_deref()
226            .map(str::trim)
227            .is_some_and(|value| !value.is_empty());
228        let ingress_scheme = if !has_override && defaults.ingress_subdomain {
229            IngressScheme::Subdomain
230        } else {
231            IngressScheme::Path
232        };
233        Ok(Config {
234            mode: mode
235                .map(str::trim)
236                .filter(|value| !value.is_empty())
237                .map(str::to_string),
238            api_key,
239            api_url: or_default(api_url, defaults.api_url),
240            sailbox_api_url: or_default(sailbox_api_url, defaults.sailbox_api_url),
241            imagebuilder_url: or_default(imagebuilder_url, defaults.imagebuilder_url),
242            ingress_base: or_default(sailbox_ingress_url, defaults.ingress_base),
243            ingress_scheme,
244        })
245    }
246
247    /// Resolve a config from the environment, selecting the credential variable
248    /// for the configured mode. Production and local retain the stored login
249    /// fallback. The key is trimmed so one sourced with a trailing newline can't
250    /// produce a malformed bearer token.
251    pub fn from_env() -> Result<Config, SailError> {
252        Config::from_env_inner(/* require_api_key */ true)
253    }
254
255    /// Like [`from_env`](Self::from_env) but does not require an API key, for
256    /// telemetry that degrades to a no-op when unauthenticated. Endpoints still
257    /// resolve from the environment and `~/.sail`.
258    #[doc(hidden)]
259    pub fn from_env_optional_api_key() -> Result<Config, SailError> {
260        Config::from_env_inner(/* require_api_key */ false)
261    }
262
263    fn from_env_inner(require_api_key: bool) -> Result<Config, SailError> {
264        // Strict: a malformed config.toml or one with an unrecognized key is an
265        // error here, so a typo'd setting fails loudly instead of being silently
266        // dropped. Telemetry callers (voyage) catch this and degrade rather than
267        // crash; see `_core_client.resolved_api_key`.
268        let settings = crate::credentials::load_settings()?;
269        let pick = |env_name: &str, stored_key: &str| -> String {
270            pick_setting(std::env::var(env_name).ok(), settings.get(stored_key))
271        };
272        let mode = pick("SAIL_MODE", "mode");
273        let api_url = pick("SAIL_API_URL", "api_url");
274        let sailbox_api_url = pick("SAILBOX_API_URL", "sailbox_api_url");
275        let imagebuilder_url = pick("SAIL_IMAGEBUILDER_URL", "imagebuilder_url");
276        // Ingress is an env-only override; otherwise it follows the mode default.
277        let sailbox_ingress_url = env_trimmed("SAILBOX_INGRESS_URL");
278
279        let api_key_env = api_key_env_var_for_mode(&mode).ok_or_else(|| SailError::Config {
280            message: format!(
281                "SAIL_MODE={} is not recognized; use SAIL_MODE=prod|dev|staging|local",
282                mode.trim().to_lowercase()
283            ),
284        })?;
285        let mut api_key = env_trimmed(api_key_env);
286        let stored_login_allowed = api_key_env == "SAIL_API_KEY";
287        if api_key.is_empty() && stored_login_allowed {
288            let target = crate::credentials::resolve_target_api_url(&api_url, &mode);
289            if crate::credentials::stored_key_matches_target(&settings, &target) {
290                if let Some(stored) = crate::credentials::auth_key_best_effort() {
291                    api_key = stored;
292                }
293            }
294        }
295        if require_api_key && api_key.is_empty() {
296            return Err(SailError::Config {
297                message: missing_api_key_message(&mode),
298            });
299        }
300
301        Config::build(
302            Some(&mode),
303            api_key,
304            opt(api_url),
305            opt(sailbox_api_url),
306            opt(imagebuilder_url),
307            opt(sailbox_ingress_url),
308        )
309    }
310}
311
312fn opt(value: String) -> Option<String> {
313    if value.is_empty() {
314        None
315    } else {
316        Some(value)
317    }
318}
319
320/// Resolve one endpoint/mode setting, trimmed. The environment wins whenever the
321/// variable is present, including when it is empty: an explicit empty value masks
322/// any stored override, which is how `sail --mode <env>` forces that mode's
323/// endpoint defaults instead of inheriting a stored `~/.sail` endpoint. Only a
324/// fully unset variable falls back to the stored value.
325fn pick_setting(env_value: Option<String>, stored: Option<&String>) -> String {
326    match env_value {
327        Some(value) => value.trim().to_string(),
328        None => stored
329            .map(|value| value.trim().to_string())
330            .unwrap_or_default(),
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn known_modes_resolve() {
340        assert_eq!(mode_defaults("").unwrap().api_url, PROD.api_url);
341        assert_eq!(mode_defaults(" PROD ").unwrap().api_url, PROD.api_url);
342        assert_eq!(mode_defaults("dev").unwrap().api_url, DEV.api_url);
343        assert_eq!(mode_defaults("local").unwrap().api_url, LOCAL.api_url);
344    }
345
346    #[test]
347    fn api_key_environment_is_scoped_by_mode() {
348        assert_eq!(api_key_env_var_for_mode(""), Some("SAIL_API_KEY"));
349        assert_eq!(api_key_env_var_for_mode("prod"), Some("SAIL_API_KEY"));
350        assert_eq!(api_key_env_var_for_mode("local"), Some("SAIL_API_KEY"));
351        assert_eq!(api_key_env_var_for_mode("dev"), Some("SAIL_DEV_API_KEY"));
352        assert_eq!(
353            api_key_env_var_for_mode(" staging "),
354            Some("SAIL_STAGING_API_KEY")
355        );
356        assert_eq!(api_key_env_var_for_mode("production"), None);
357    }
358
359    #[test]
360    fn unknown_mode_is_config_error() {
361        match mode_defaults("production") {
362            Err(SailError::Config { message }) => {
363                assert!(message.contains("not recognized"), "message={message}");
364            }
365            other => panic!("expected Config error, got {other:?}"),
366        }
367    }
368
369    #[test]
370    fn pick_setting_present_env_masks_stored() {
371        let stored = "https://stored.example".to_string();
372        // A present env var wins, even empty: an empty value masks the stored
373        // override (how `--mode` forces mode defaults), rather than falling back.
374        assert_eq!(
375            pick_setting(Some("https://env.example".to_string()), Some(&stored)),
376            "https://env.example"
377        );
378        assert_eq!(pick_setting(Some(String::new()), Some(&stored)), "");
379        assert_eq!(pick_setting(Some("  ".to_string()), Some(&stored)), "");
380        // Only a fully unset var falls back to the stored value.
381        assert_eq!(
382            pick_setting(/* env_value */ None, Some(&stored)),
383            "https://stored.example"
384        );
385        assert_eq!(pick_setting(/* env_value */ None, /* stored */ None), "");
386    }
387
388    #[test]
389    fn override_wins_blank_falls_back_to_mode_default() {
390        let config = Config::resolve(
391            Some("dev"),
392            "k".to_string(),
393            Some("https://override.example".to_string()),
394            /* sailbox_api_url */ None, // unset → mode default
395            /* imagebuilder_url */ Some("   ".to_string()), // blank → mode default
396            /* sailbox_ingress_url */ None,
397        )
398        .unwrap();
399        assert_eq!(config.api_url, "https://override.example");
400        assert_eq!(config.sailbox_api_url, DEV.sailbox_api_url);
401        assert_eq!(config.imagebuilder_url, DEV.imagebuilder_url);
402    }
403
404    #[test]
405    fn api_key_is_required_and_trimmed() {
406        assert!(matches!(
407            Config::resolve(
408                /* mode */ None,
409                "   ".to_string(),
410                /* api_url */ None,
411                /* sailbox_api_url */ None,
412                /* imagebuilder_url */ None,
413                /* sailbox_ingress_url */ None,
414            ),
415            Err(SailError::Config { .. })
416        ));
417        let config = Config::resolve(
418            /* mode */ None,
419            "  sk_k  ".to_string(),
420            /* api_url */ None,
421            /* sailbox_api_url */ None,
422            /* imagebuilder_url */ None,
423            /* sailbox_ingress_url */ None,
424        )
425        .unwrap();
426        assert_eq!(config.api_key, "sk_k");
427        // No mode declared resolves to the prod defaults.
428        assert_eq!(config.api_url, PROD.api_url);
429    }
430
431    #[test]
432    fn ingress_defaults_per_mode_and_override_forces_path() {
433        // Deployed modes default to a subdomain-addressed ingress.
434        let prod = Config::resolve(
435            /* mode */ None,
436            "k".to_string(),
437            /* api_url */ None,
438            /* sailbox_api_url */ None,
439            /* imagebuilder_url */ None,
440            /* sailbox_ingress_url */ None,
441        )
442        .unwrap();
443        assert_eq!(prod.ingress_scheme, IngressScheme::Subdomain);
444        assert_eq!(prod.ingress_base, PROD.ingress_base);
445        // Local defaults to path-addressed ingress.
446        let local = Config::resolve(
447            Some("local"),
448            "k".to_string(),
449            /* api_url */ None,
450            /* sailbox_api_url */ None,
451            /* imagebuilder_url */ None,
452            /* sailbox_ingress_url */ None,
453        )
454        .unwrap();
455        assert_eq!(local.ingress_scheme, IngressScheme::Path);
456        // An explicit ingress URL is always path-addressed.
457        let overridden = Config::resolve(
458            /* mode */ None,
459            "k".to_string(),
460            /* api_url */ None,
461            /* sailbox_api_url */ None,
462            /* imagebuilder_url */ None,
463            Some("https://ingress.example".to_string()),
464        )
465        .unwrap();
466        assert_eq!(overridden.ingress_scheme, IngressScheme::Path);
467        assert_eq!(overridden.ingress_base, "https://ingress.example");
468    }
469}