1use 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
48pub 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 #[serde(skip)]
62 found: bool,
63
64 #[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 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 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 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 #[serde(default)]
211 pub color: Option<ColorChoice>,
212
213 #[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 #[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 #[serde(default)]
233 pub compute_url: Option<Url>,
234
235 #[serde(default)]
237 pub identity_url: Option<Url>,
238
239 #[serde(default)]
241 pub metadata_url: Option<Url>,
242
243 #[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 #[serde(default)]
253 #[cfg(feature = "allow_insecure_urls")]
254 pub allow_insecure_urls: bool,
255
256 #[serde(default)]
258 #[cfg(feature = "allow_invalid_certs")]
259 pub allow_invalid_certs: bool,
260}
261
262impl RawDangerZoneConfig {
263 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}