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