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