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