seaplane_cli/
config.rs

1//! Config handles loading of, and updating the Context from, a configuration file.
2//!
3//! The config will look in several pre-determined (platform specific) locations. If a valid
4//! configuration file is found, it's values are loaded. Note that later layers may override values
5//! from previous layers.
6//!
7//! - System configuration files (currently none are defined)
8//! - User configuration files
9//!   - Linux
10//!     - `$XDG_CONFIG_HOME/seaplane/`
11//!     - `$HOME/.config/seaplane/`
12//!     - `$HOME/.seaplane/`
13//!   - macOS
14//!     - `$HOME/Library/ApplicationSupport/io.Seaplane.seaplane/`
15//!     - `$HOME/.config/seaplane/`
16//!     - `$HOME/.seaplane/`
17//!   - Windows
18//!     - `%RoamingAppData%/Seaplane/seaplane/config/`
19//!     - `$HOME/.config/seaplane/`
20//!     - `$HOME/.seaplane/`
21//! - The CLI's `--config` flag
22//!
23//! Note the CLI also provides a `--no-override` flag that prevents later configuration files from
24//! overriding previously discovered configuration layers. In this case the final layer "wins" and
25//! all previous layers are ignored. i.e. using `--config` will cause only that CLI provided
26//! configuration to be considered and not any of those in the filesystem.
27//!
28//! See also the CONFIGURATION_SPEC.md in this repository
29
30use std::{
31    fs,
32    path::{Path, PathBuf},
33};
34
35use reqwest::Url;
36use serde::{de::DeserializeOwned, Deserialize, Serialize};
37
38use crate::{
39    cli::{CliCommand, SeaplaneInit},
40    context::Ctx,
41    error::{CliError, CliErrorKind, Result},
42    fs::{conf_dirs, AtomicFile, FromDisk, ToDisk},
43    printer::ColorChoice,
44};
45
46static SEAPLANE_CONFIG_FILE: &str = "seaplane.toml";
47
48/// Extends a configuration instance with overriding config
49pub trait ExtendConfig {
50    fn extend(&mut self, other: &Self);
51}
52
53#[derive(Debug, Default, Serialize, Deserialize)]
54#[cfg_attr(test, derive(PartialEq, Eq))]
55#[serde(rename_all = "kebab-case", deny_unknown_fields)]
56pub struct RawConfig {
57    #[serde(skip)]
58    pub loaded_from: Vec<PathBuf>,
59
60    // Used to signal we already found a valid config and to warn the user we will be overriding
61    #[serde(skip)]
62    found: bool,
63
64    /// Did we run initialization automatically or not on startup?
65    #[serde(skip)]
66    pub did_init: bool,
67
68    #[serde(default)]
69    pub seaplane: RawSeaplaneConfig,
70
71    #[serde(default)]
72    pub account: RawAccountConfig,
73
74    #[serde(default)]
75    pub api: RawApiConfig,
76
77    #[serde(default, skip_serializing_if = "RawDangerZoneConfig::is_empty")]
78    pub danger_zone: RawDangerZoneConfig,
79}
80
81impl RawConfig {
82    /// Loads the Raw configuration file (not de-conflicted with the CLI or ENV yet)
83    ///
84    /// Loads configs from all platform specific locations, overriding values at each step
85    pub fn load_all() -> Result<Self> {
86        let mut cfg = RawConfig::default();
87
88        for dir in conf_dirs() {
89            let maybe_file = dir.join(SEAPLANE_CONFIG_FILE);
90
91            let new_cfg = match RawConfig::load(&maybe_file) {
92                Ok(cfg) => cfg,
93                Err(e) => {
94                    if e.kind() == &CliErrorKind::MissingPath {
95                        continue;
96                    }
97                    return Err(e);
98                }
99            };
100
101            if cfg.found {
102                cli_warn!(@Yellow, "warn: ");
103                cli_warnln!(@noprefix,
104                    "overriding previous configuration options with {:?}",
105                    maybe_file
106                );
107                cli_warn!("(hint: use ");
108                cli_warn!(@Green, "--verbose ");
109                cli_warnln!(@noprefix, "for more info)");
110            }
111
112            cfg.update(new_cfg)?;
113            cfg.found = true;
114        }
115
116        if !cfg.found {
117            let mut ctx = Ctx::default();
118            ctx.internal_run = true;
119            SeaplaneInit.run(&mut ctx)?;
120            cfg.did_init = true;
121        }
122
123        Ok(cfg)
124    }
125
126    fn update(&mut self, new_cfg: RawConfig) -> Result<()> {
127        // TODO: as we get more keys and tables we'll need a better way to do this
128        if let Some(key) = new_cfg.account.api_key {
129            self.account.api_key = Some(key);
130        }
131        if let Some(choice) = new_cfg.seaplane.color {
132            self.seaplane.color = Some(choice);
133        }
134        if let Some(registry) = new_cfg.seaplane.default_registry_url {
135            self.seaplane.default_registry_url = Some(registry);
136        }
137        if let Some(url) = new_cfg.api.compute_url {
138            self.api.compute_url = Some(url);
139        }
140        if let Some(url) = new_cfg.api.identity_url {
141            self.api.identity_url = Some(url);
142        }
143        if let Some(url) = new_cfg.api.metadata_url {
144            self.api.metadata_url = Some(url);
145        }
146        if let Some(url) = new_cfg.api.locks_url {
147            self.api.locks_url = Some(url);
148        }
149        #[cfg(feature = "allow_insecure_urls")]
150        {
151            self.danger_zone.allow_insecure_urls = new_cfg.danger_zone.allow_insecure_urls;
152        }
153        #[cfg(feature = "allow_invalid_certs")]
154        {
155            self.danger_zone.allow_invalid_certs = new_cfg.danger_zone.allow_invalid_certs;
156        }
157        self.loaded_from.extend(new_cfg.loaded_from);
158        Ok(())
159    }
160}
161
162impl FromDisk for RawConfig {
163    fn set_loaded_from<P: AsRef<Path>>(&mut self, p: P) {
164        self.loaded_from.push(p.as_ref().into());
165    }
166
167    fn loaded_from(&self) -> Option<&Path> { self.loaded_from.get(0).map(|p| &**p) }
168
169    fn load<P: AsRef<Path>>(p: P) -> Result<Self>
170    where
171        Self: Sized + DeserializeOwned,
172    {
173        let path = p.as_ref();
174
175        cli_traceln!("Looking for configuration file at {path:?}");
176        if !path.exists() {
177            return Err(CliErrorKind::MissingPath.into_err());
178        }
179
180        cli_traceln!("Found configuration file {path:?}");
181        let mut cfg: RawConfig = toml::from_str(&fs::read_to_string(&p)?)?;
182        cfg.set_loaded_from(p);
183        Ok(cfg)
184    }
185}
186
187impl ToDisk for RawConfig {
188    fn persist(&self) -> Result<()>
189    where
190        Self: Sized + Serialize,
191    {
192        if let Some(path) = self.loaded_from.get(0) {
193            let file = AtomicFile::new(path)?;
194            let toml_str = toml::to_string_pretty(self)?;
195
196            // TODO: make atomic so that we don't lose or corrupt data
197            // TODO: long term consider something like SQLite
198            fs::write(file.temp_path(), toml_str).map_err(CliError::from)
199        } else {
200            Err(CliErrorKind::MissingPath.into_err())
201        }
202    }
203}
204
205#[derive(Clone, Debug, Default, Serialize, Deserialize)]
206#[cfg_attr(test, derive(PartialEq, Eq))]
207#[serde(rename_all = "kebab-case", deny_unknown_fields)]
208pub struct RawSeaplaneConfig {
209    /// Whether to color output or not
210    #[serde(default)]
211    pub color: Option<ColorChoice>,
212
213    /// The default container image registry to infer if not provided
214    #[serde(default)]
215    pub default_registry_url: Option<String>,
216}
217
218#[derive(Debug, Default, Serialize, Deserialize)]
219#[cfg_attr(test, derive(PartialEq, Eq))]
220#[serde(rename_all = "kebab-case", deny_unknown_fields)]
221pub struct RawAccountConfig {
222    /// The user's API key
223    #[serde(default)]
224    pub api_key: Option<String>,
225}
226
227#[derive(Debug, Default, Serialize, Deserialize)]
228#[cfg_attr(test, derive(PartialEq, Eq))]
229#[serde(rename_all = "kebab-case", deny_unknown_fields)]
230pub struct RawApiConfig {
231    /// The URL of Compute Service
232    #[serde(default)]
233    pub compute_url: Option<Url>,
234
235    /// The URL of Token Identity Service
236    #[serde(default)]
237    pub identity_url: Option<Url>,
238
239    /// The URL of Metadata KVS Service
240    #[serde(default)]
241    pub metadata_url: Option<Url>,
242
243    /// The URL of Locks Service
244    #[serde(default)]
245    pub locks_url: Option<Url>,
246}
247
248#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
249#[serde(rename_all = "kebab-case", deny_unknown_fields)]
250pub struct RawDangerZoneConfig {
251    /// Allow HTTP in URLs pointing to services
252    #[serde(default)]
253    #[cfg(feature = "allow_insecure_urls")]
254    pub allow_insecure_urls: bool,
255
256    /// Allow invalid or self signed HTTPS certs
257    #[serde(default)]
258    #[cfg(feature = "allow_invalid_certs")]
259    pub allow_invalid_certs: bool,
260}
261
262impl RawDangerZoneConfig {
263    // Returns `true` if config table is all default values
264    pub fn is_empty(&self) -> bool { self == &RawDangerZoneConfig::default() }
265}
266
267#[cfg(test)]
268mod test {
269    use super::*;
270
271    #[test]
272    fn deser_empty_config() {
273        let cfg_str = r#"
274        "#;
275
276        let cfg: RawConfig = toml::from_str(cfg_str).unwrap();
277        assert_eq!(cfg, RawConfig::default())
278    }
279
280    #[test]
281    fn deser_empty_account_config() {
282        let cfg_str = r#"
283        [account]
284        "#;
285
286        let cfg: RawConfig = toml::from_str(cfg_str).unwrap();
287        assert_eq!(cfg, RawConfig::default())
288    }
289
290    #[test]
291    fn deser_empty_seaplane_config() {
292        let cfg_str = r#"
293        [seaplane]
294        "#;
295
296        let cfg: RawConfig = toml::from_str(cfg_str).unwrap();
297        assert_eq!(cfg, RawConfig::default())
298    }
299
300    #[test]
301    fn deser_empty_api_config() {
302        let cfg_str = r#"
303        [api]
304        "#;
305
306        let cfg: RawConfig = toml::from_str(cfg_str).unwrap();
307        assert_eq!(cfg, RawConfig::default())
308    }
309
310    #[test]
311    fn deser_empty_danger_zone_config() {
312        let cfg_str = r#"
313        [danger-zone]
314        "#;
315
316        let cfg: RawConfig = toml::from_str(cfg_str).unwrap();
317        assert_eq!(cfg, RawConfig::default())
318    }
319
320    #[test]
321    fn deser_api_key() {
322        let cfg_str = r#"
323        [account]
324        api-key = "abc123def456"
325        "#;
326
327        let cfg: RawConfig = toml::from_str(cfg_str).unwrap();
328
329        assert_eq!(
330            cfg,
331            RawConfig {
332                account: RawAccountConfig { api_key: Some("abc123def456".into()) },
333                ..Default::default()
334            }
335        )
336    }
337
338    #[test]
339    fn deser_color_key() {
340        let cfg_str = r#"
341        [seaplane]
342        color = "always"
343        "#;
344
345        let cfg: RawConfig = toml::from_str(cfg_str).unwrap();
346
347        assert_eq!(
348            cfg,
349            RawConfig {
350                seaplane: RawSeaplaneConfig {
351                    color: Some(ColorChoice::Always),
352                    default_registry_url: None
353                },
354                ..Default::default()
355            }
356        )
357    }
358
359    #[test]
360    fn deser_default_registry_key() {
361        let cfg_str = r#"
362        [seaplane]
363        default-registry-url = "quay.io/"
364        "#;
365
366        let cfg: RawConfig = toml::from_str(cfg_str).unwrap();
367
368        assert_eq!(
369            cfg,
370            RawConfig {
371                seaplane: RawSeaplaneConfig {
372                    color: None,
373                    default_registry_url: Some("quay.io/".into())
374                },
375                ..Default::default()
376            }
377        )
378    }
379
380    #[test]
381    fn deser_api_urls() {
382        let cfg_str = r#"
383        [api]
384        compute-url = "https://compute.local/"
385        identity-url = "https://identity.local/"
386        metadata-url = "https://metadata.local/"
387        locks-url = "https://locks.local/"
388        "#;
389
390        let cfg: RawConfig = toml::from_str(cfg_str).unwrap();
391
392        assert_eq!(
393            cfg,
394            RawConfig {
395                api: RawApiConfig {
396                    compute_url: Some("https://compute.local/".parse().unwrap()),
397                    identity_url: Some("https://identity.local/".parse().unwrap()),
398                    metadata_url: Some("https://metadata.local/".parse().unwrap()),
399                    locks_url: Some("https://locks.local/".parse().unwrap()),
400                },
401                ..Default::default()
402            }
403        )
404    }
405
406    #[cfg(feature = "allow_insecure_urls")]
407    #[test]
408    fn deser_insecure_urls() {
409        let cfg_str = r#"
410        [danger-zone]
411        allow-insecure-urls = true
412        "#;
413
414        let cfg: RawConfig = toml::from_str(cfg_str).unwrap();
415
416        assert_eq!(
417            cfg,
418            RawConfig {
419                danger_zone: RawDangerZoneConfig {
420                    allow_insecure_urls: true,
421                    ..Default::default()
422                },
423                ..Default::default()
424            }
425        )
426    }
427
428    #[cfg(feature = "allow_invalid_certs")]
429    #[test]
430    fn deser_invalid_certs() {
431        let cfg_str = r#"
432        [danger-zone]
433        allow-invalid-certs = true
434        "#;
435
436        let cfg: RawConfig = toml::from_str(cfg_str).unwrap();
437
438        assert_eq!(
439            cfg,
440            RawConfig {
441                danger_zone: RawDangerZoneConfig {
442                    allow_invalid_certs: true,
443                    ..Default::default()
444                },
445                ..Default::default()
446            }
447        )
448    }
449}