1use secrecy::{ExposeSecret, SecretString};
46use std::fmt;
47use std::path::{Path, PathBuf};
48use tracing::{error, trace, warn};
49
50use serde::Deserialize;
51use std::collections::hash_map::DefaultHasher;
52use std::collections::HashMap;
53use std::collections::HashSet;
54use std::env;
55use std::hash::{Hash, Hasher};
56
57use thiserror::Error;
58
59#[derive(Debug, Error)]
62#[non_exhaustive]
63pub enum ConfigError {
64 #[error("Cloud {0} not found")]
65 CloudNotFound(String),
66
67 #[error("Profile {} not found", profile_name)]
68 MissingProfile { profile_name: String },
69
70 #[error("unknown error")]
71 Unknown,
72
73 #[error("failed to deserialize config: {}", source)]
74 Parse {
75 #[from]
77 source: config::ConfigError,
78 },
79}
80
81impl ConfigError {
82 pub fn parse(source: config::ConfigError) -> Self {
83 ConfigError::Parse { source }
84 }
85}
86
87#[derive(Error)]
89#[non_exhaustive]
90pub enum ConfigFileBuilderError {
91 #[error("Failed to parse file {path:?}: {source}")]
92 FileParse {
93 source: Box<config::ConfigError>,
94 builder: ConfigFileBuilder,
95 path: PathBuf,
96 },
97 #[error("Failed to deserialize config {path:?}: {source}")]
98 ConfigDeserialize {
99 source: Box<config::ConfigError>,
100 builder: ConfigFileBuilder,
101 path: PathBuf,
102 },
103}
104
105impl fmt::Debug for ConfigFileBuilderError {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 match self {
108 ConfigFileBuilderError::FileParse {
109 ref source,
110 ref path,
111 ..
112 } => f
113 .debug_struct("FileParse")
114 .field("source", source)
115 .field("path", path)
116 .finish_non_exhaustive(),
117 ConfigFileBuilderError::ConfigDeserialize {
118 ref source,
119 ref path,
120 ..
121 } => f
122 .debug_struct("ConfigDeserialize")
123 .field("source", source)
124 .field("path", path)
125 .finish_non_exhaustive(),
126 }
127 }
128}
129
130pub struct ConfigFileBuilder {
132 sources: Vec<config::Config>,
133}
134
135impl ConfigFileBuilder {
136 pub fn add_source(mut self, source: impl AsRef<Path>) -> Result<Self, ConfigFileBuilderError> {
141 let config = match config::Config::builder()
142 .add_source(config::File::from(source.as_ref()))
143 .build()
144 {
145 Ok(config) => config,
146 Err(error) => {
147 return Err(ConfigFileBuilderError::FileParse {
148 source: Box::new(error),
149 builder: self,
150 path: source.as_ref().to_owned(),
151 });
152 }
153 };
154
155 if let Err(error) = config.clone().try_deserialize::<ConfigFile>() {
156 return Err(ConfigFileBuilderError::ConfigDeserialize {
157 source: Box::new(error),
158 builder: self,
159 path: source.as_ref().to_owned(),
160 });
161 }
162
163 self.sources.push(config);
164 Ok(self)
165 }
166
167 pub fn build(self) -> ConfigFile {
170 let mut config = config::Config::builder();
171
172 for source in self.sources {
173 config = config.add_source(source);
174 }
175
176 config.build().unwrap().try_deserialize().unwrap()
177 }
178}
179
180#[derive(Deserialize, Debug, Clone)]
182pub struct CacheConfig {
183 pub auth: Option<bool>,
184}
185
186#[derive(Deserialize, Debug, Clone)]
188pub struct ConfigFile {
189 pub cache: Option<CacheConfig>,
191 pub clouds: Option<HashMap<String, CloudConfig>>,
193 #[serde(rename = "public-clouds")]
195 pub public_clouds: Option<HashMap<String, CloudConfig>>,
196}
197
198#[derive(Clone, Default, Deserialize)]
204pub struct Auth {
205 pub auth_url: Option<String>,
207 pub endpoint: Option<String>,
209 pub token: Option<SecretString>,
211
212 pub username: Option<String>,
214 pub user_id: Option<String>,
216 pub user_domain_name: Option<String>,
218 pub user_domain_id: Option<String>,
220 pub password: Option<SecretString>,
222
223 pub passcode: Option<SecretString>,
225
226 pub domain_id: Option<String>,
228 pub domain_name: Option<String>,
230 pub project_id: Option<String>,
232 pub project_name: Option<String>,
234 pub project_domain_id: Option<String>,
236 pub project_domain_name: Option<String>,
238
239 pub protocol: Option<String>,
241 pub identity_provider: Option<String>,
243
244 pub application_credential_id: Option<String>,
246 pub application_credential_name: Option<String>,
248 pub application_credential_secret: Option<SecretString>,
250
251 pub system_scope: Option<String>,
253}
254
255impl fmt::Debug for Auth {
256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257 f.debug_struct("Auth")
258 .field("auth_url", &self.auth_url)
259 .field("domain_id", &self.domain_id)
260 .field("domain_name", &self.domain_name)
261 .field("project_id", &self.project_id)
262 .field("project_name", &self.project_name)
263 .field("project_domain_id", &self.project_domain_id)
264 .field("project_domain_name", &self.project_domain_name)
265 .field("username", &self.username)
266 .field("user_domain_id", &self.user_domain_id)
267 .field("user_domain_name", &self.user_domain_name)
268 .field("protocol", &self.protocol)
269 .field("identity_provider", &self.identity_provider)
270 .field("application_credential_id", &self.application_credential_id)
271 .field(
272 "application_credential_name",
273 &self.application_credential_name,
274 )
275 .field("system_scope", &self.system_scope)
276 .finish()
277 }
278}
279
280#[derive(Deserialize, Default, Clone)]
282pub struct CloudConfig {
283 pub auth: Option<Auth>,
285 pub auth_type: Option<String>,
287 pub auth_methods: Option<Vec<String>>,
289
290 pub profile: Option<String>,
292 pub interface: Option<String>,
294 pub region_name: Option<String>,
296
297 pub cacert: Option<String>,
299 pub verify: Option<bool>,
301
302 #[serde(flatten)]
304 pub options: HashMap<String, config::Value>,
305}
306
307impl fmt::Debug for CloudConfig {
308 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309 f.debug_struct("CloudConfig")
310 .field("auth", &self.auth)
311 .finish()
312 }
313}
314
315pub fn get_config_identity_hash(config: &CloudConfig) -> u64 {
317 let mut s = DefaultHasher::new();
319 if let Some(auth) = &config.auth {
320 if let Some(data) = &auth.auth_url {
321 data.hash(&mut s);
322 }
323 if let Some(data) = &auth.username {
324 data.hash(&mut s);
325 }
326 if let Some(data) = &auth.user_id {
327 data.hash(&mut s);
328 }
329 if let Some(data) = &auth.user_domain_id {
330 data.hash(&mut s);
331 }
332 if let Some(data) = &auth.user_domain_name {
333 data.hash(&mut s);
334 }
335 if let Some(data) = &auth.identity_provider {
336 data.hash(&mut s);
337 }
338 if let Some(data) = &auth.protocol {
339 data.hash(&mut s);
340 }
341 if let Some(data) = &auth.application_credential_name {
342 data.hash(&mut s);
343 }
344 if let Some(data) = &auth.application_credential_id {
345 data.hash(&mut s);
346 }
347 if let Some(data) = &auth.system_scope {
348 data.hash(&mut s);
349 }
350 }
351 if let Some(data) = &config.profile {
352 data.hash(&mut s);
353 }
354 s.finish()
355}
356
357impl CloudConfig {
359 pub fn update(&mut self, update: &CloudConfig) {
361 if let Some(update_auth) = &update.auth {
362 let auth = self.auth.get_or_insert(Auth::default());
363 if auth.auth_url.is_none() && update_auth.auth_url.is_some() {
364 auth.auth_url.clone_from(&update_auth.auth_url);
365 }
366 if auth.domain_id.is_none() && update_auth.domain_id.is_some() {
367 auth.domain_id.clone_from(&update_auth.domain_id);
368 }
369 if auth.domain_name.is_none() && update_auth.domain_name.is_some() {
370 auth.domain_name.clone_from(&update_auth.domain_name);
371 }
372 if auth.endpoint.is_none() && update_auth.endpoint.is_some() {
373 auth.endpoint.clone_from(&update_auth.endpoint);
374 }
375 if auth.password.is_none() && update_auth.password.is_some() {
376 auth.password.clone_from(&update_auth.password);
377 }
378 if auth.project_id.is_none() && update_auth.project_id.is_some() {
379 auth.project_id.clone_from(&update_auth.project_id);
380 }
381 if auth.project_name.is_none() && update_auth.project_name.is_some() {
382 auth.project_name.clone_from(&update_auth.project_name);
383 }
384 if auth.project_domain_id.is_none() && update_auth.project_domain_id.is_some() {
385 auth.project_domain_id
386 .clone_from(&update_auth.project_domain_id);
387 }
388 if auth.project_domain_name.is_none() && update_auth.project_domain_name.is_some() {
389 auth.project_domain_name
390 .clone_from(&update_auth.project_domain_name);
391 }
392 if auth.token.is_none() && update_auth.token.is_some() {
393 auth.token.clone_from(&update_auth.token);
394 }
395 if auth.username.is_none() && update_auth.username.is_some() {
396 auth.username.clone_from(&update_auth.username);
397 }
398 if auth.user_domain_name.is_none() && update_auth.user_domain_name.is_some() {
399 auth.user_domain_name
400 .clone_from(&update_auth.user_domain_name);
401 }
402 if auth.user_domain_id.is_none() && update_auth.user_domain_id.is_some() {
403 auth.user_domain_id.clone_from(&update_auth.user_domain_id);
404 }
405 if auth.protocol.is_none() && update_auth.protocol.is_some() {
406 auth.protocol.clone_from(&update_auth.protocol);
407 }
408 if auth.identity_provider.is_none() && update_auth.identity_provider.is_some() {
409 auth.identity_provider
410 .clone_from(&update_auth.identity_provider);
411 }
412 if auth.application_credential_id.is_none()
413 && update_auth.application_credential_id.is_some()
414 {
415 auth.application_credential_id
416 .clone_from(&update_auth.application_credential_id);
417 }
418 if auth.application_credential_name.is_none()
419 && update_auth.application_credential_name.is_some()
420 {
421 auth.application_credential_name
422 .clone_from(&update_auth.application_credential_name);
423 }
424 if auth.application_credential_secret.is_none()
425 && update_auth.application_credential_secret.is_some()
426 {
427 auth.application_credential_secret
428 .clone_from(&update_auth.application_credential_secret);
429 }
430 if auth.system_scope.is_none() && update_auth.system_scope.is_some() {
431 auth.system_scope.clone_from(&update_auth.system_scope);
432 }
433 }
434 if self.auth_type.is_none() && update.auth_type.is_some() {
435 self.auth_type.clone_from(&update.auth_type);
436 }
437 if self.profile.is_none() && update.profile.is_some() {
438 self.profile.clone_from(&update.profile);
439 }
440 if self.interface.is_none() && update.interface.is_some() {
441 self.interface.clone_from(&update.interface);
442 }
443 if self.region_name.is_none() && update.region_name.is_some() {
444 self.region_name.clone_from(&update.region_name);
445 }
446 if self.cacert.is_none() && update.cacert.is_some() {
447 self.cacert.clone_from(&update.cacert);
448 }
449 if self.verify.is_none() && update.verify.is_some() {
450 self.verify.clone_from(&update.verify);
451 }
452 let current_keys: HashSet<String> = self.options.keys().cloned().collect();
453 self.options.extend(
454 update
455 .options
456 .clone()
457 .into_iter()
458 .filter(|x| !current_keys.contains(&x.0)),
459 );
460 }
461
462 pub(crate) fn get_sensitive_values(&self) -> Vec<&str> {
468 let mut res = Vec::new();
469 if let Some(auth) = &self.auth {
470 if let Some(val) = &auth.password {
471 res.push(val.expose_secret());
472 }
473 if let Some(val) = &auth.application_credential_secret {
474 res.push(val.expose_secret());
475 }
476 if let Some(val) = &auth.token {
477 res.push(val.expose_secret());
478 }
479 if let Some(val) = &auth.passcode {
480 res.push(val.expose_secret());
481 }
482 }
483 res
484 }
485}
486
487const CONFIG_SUFFIXES: &[&str] = &[".yaml", ".yml", ".json"];
488
489fn get_config_file_search_paths<S: AsRef<str>>(filename: S) -> Vec<PathBuf> {
491 let paths: Vec<PathBuf> = vec![
492 env::current_dir().expect("Cannot determine current workdir"),
493 dirs::config_dir()
494 .expect("Cannot determine users XDG_CONFIG_HOME")
495 .join("openstack"),
496 dirs::home_dir()
497 .expect("Cannot determine users XDG_HOME")
498 .join(".config/openstack"),
499 PathBuf::from("/etc/openstack"),
500 ];
501
502 paths
503 .iter()
504 .flat_map(|x| {
505 CONFIG_SUFFIXES
506 .iter()
507 .map(|y| x.join(format!("{}{}", filename.as_ref(), y)))
508 })
509 .collect()
510}
511
512pub fn find_vendor_file() -> Option<PathBuf> {
521 get_config_file_search_paths("clouds-public")
522 .into_iter()
523 .find(|path| path.is_file())
524}
525
526pub fn find_clouds_file() -> Option<PathBuf> {
535 get_config_file_search_paths("clouds")
536 .into_iter()
537 .find(|path| path.is_file())
538}
539
540pub fn find_secure_file() -> Option<PathBuf> {
549 get_config_file_search_paths("secure")
550 .into_iter()
551 .find(|path| path.is_file())
552}
553
554impl ConfigFile {
555 pub fn builder() -> ConfigFileBuilder {
557 ConfigFileBuilder {
558 sources: Vec::new(),
559 }
560 }
561
562 pub fn new_with_user_specified_configs(
574 clouds: Option<impl AsRef<Path>>,
575 secure: Option<impl AsRef<Path>>,
576 ) -> Result<Self, ConfigError> {
577 let mut builder = Self::builder();
578
579 for path in find_vendor_file()
580 .into_iter()
581 .chain(find_clouds_file())
582 .chain(clouds.map(|path| path.as_ref().to_owned()))
583 .chain(find_secure_file())
584 .chain(secure.map(|path| path.as_ref().to_owned()))
585 {
586 builder = match builder.add_source(&path) {
587 Ok(builder) => {
588 trace!("Using config file {path:?}");
589 builder
590 }
591 Err(ConfigFileBuilderError::FileParse { source, .. }) => {
592 return Err(ConfigError::parse(*source));
593 }
594 Err(ConfigFileBuilderError::ConfigDeserialize {
595 source,
596 builder,
597 path,
598 }) => {
599 error!(
600 "The file {path:?} could not be deserialized and will be ignored: {source}"
601 );
602 builder
603 }
604 };
605 }
606
607 Ok(builder.build())
608 }
609
610 pub fn new() -> Result<Self, ConfigError> {
613 Self::new_with_user_specified_configs(None::<PathBuf>, None::<PathBuf>)
614 }
615
616 pub fn get_cloud_config<S: AsRef<str>>(
621 &self,
622 cloud_name: S,
623 ) -> Result<Option<CloudConfig>, ConfigError> {
624 if let Some(clouds) = &self.clouds {
625 if let Some(cfg) = clouds.get(cloud_name.as_ref()) {
626 let mut config = cfg.clone();
627 if let Some(ref profile_name) = config.profile {
628 let mut profile_definition: Option<&CloudConfig> = None;
629 match &self.public_clouds {
632 Some(profiles) => {
633 profile_definition = profiles.get(profile_name);
634 }
635 None => {
636 warn!("Cannot find profiles definition");
637 }
638 }
639 if let Some(profile) = profile_definition {
640 config.update(profile);
641 }
642 }
643
644 return Ok(Some(config));
645 }
646 }
647 Ok(None)
648 }
649
650 pub fn is_auth_cache_enabled(&self) -> bool {
652 self.cache.as_ref().and_then(|c| c.auth).unwrap_or(true)
653 }
654
655 pub fn get_available_clouds(&self) -> Vec<String> {
657 if let Some(clouds) = &self.clouds {
658 return clouds.keys().cloned().collect();
659 }
660 Vec::new()
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use crate::config;
667 use secrecy::ExposeSecret;
668 use std::env;
669 use std::io::Write;
670 use std::path::PathBuf;
671 use tempfile::Builder;
672
673 use super::ConfigFile;
674
675 #[test]
676 fn test_get_search_paths() {
677 let fname = "clouds";
678 let cwd = env::current_dir().unwrap();
679 let conf_dir = dirs::config_dir().unwrap().join("openstack");
680 let unix_conf_home = dirs::home_dir().unwrap().join(".config/openstack");
681 let site_conf = PathBuf::from("/etc/openstack");
682 assert_eq!(
683 vec![
684 PathBuf::from(format!("{}/{}.yaml", cwd.display(), fname)),
685 PathBuf::from(format!("{}/{}.yml", cwd.display(), fname)),
686 PathBuf::from(format!("{}/{}.json", cwd.display(), fname)),
687 PathBuf::from(format!("{}/{}.yaml", conf_dir.display(), fname)),
688 PathBuf::from(format!("{}/{}.yml", conf_dir.display(), fname)),
689 PathBuf::from(format!("{}/{}.json", conf_dir.display(), fname)),
690 PathBuf::from(format!("{}/{}.yaml", unix_conf_home.display(), fname)),
691 PathBuf::from(format!("{}/{}.yml", unix_conf_home.display(), fname)),
692 PathBuf::from(format!("{}/{}.json", unix_conf_home.display(), fname)),
693 PathBuf::from(format!("{}/{}.yaml", site_conf.display(), fname)),
694 PathBuf::from(format!("{}/{}.yml", site_conf.display(), fname)),
695 PathBuf::from(format!("{}/{}.json", site_conf.display(), fname)),
696 ],
697 config::get_config_file_search_paths(fname)
698 );
699 }
700
701 #[test]
702 fn test_default_auth_cache_enabled() {
703 let cfg = ConfigFile::new().unwrap();
704 assert!(cfg.is_auth_cache_enabled());
705 }
706
707 #[test]
708 fn test_get_available_clouds() {
709 let cfg = ConfigFile::new().unwrap();
710 let _ = cfg.get_available_clouds();
711 }
712
713 #[test]
714 fn test_from_custom_files() {
715 let mut cloud_file = Builder::new().suffix(".yaml").tempfile().unwrap();
716 let mut secure_file = Builder::new().suffix(".yaml").tempfile().unwrap();
717
718 const CLOUD_DATA: &str = r#"
719 clouds:
720 fake_cloud:
721 auth:
722 auth_url: http://fake.com
723 username: override_me
724 "#;
725 const SECURE_DATA: &str = r#"
726 clouds:
727 fake_cloud:
728 auth:
729 username: foo
730 password: bar
731 "#;
732
733 write!(cloud_file, "{}", CLOUD_DATA).unwrap();
734 write!(secure_file, "{}", SECURE_DATA).unwrap();
735
736 let cfg = ConfigFile::builder()
737 .add_source(cloud_file.path())
738 .unwrap()
739 .add_source(secure_file.path())
740 .unwrap()
741 .build();
742
743 let profile = cfg
744 .get_cloud_config("fake_cloud")
745 .unwrap()
746 .expect("Profile exists");
747 let auth = profile.auth.expect("Auth defined");
748
749 assert_eq!(auth.auth_url, Some(String::from("http://fake.com")));
750 assert_eq!(auth.username, Some(String::from("foo")));
751 assert_eq!(auth.password.unwrap().expose_secret(), String::from("bar"));
752 }
753
754 #[test]
755 fn test_sensitive_values() {
756 let mut cloud_file = Builder::new().suffix(".yaml").tempfile().unwrap();
757
758 const CLOUD_DATA: &str = r#"
759 clouds:
760 fake_cloud:
761 auth:
762 auth_url: http://fake.com
763 username: override_me
764 password: pwd
765 application_credential_secret: app_cred_secret
766 token: tkn
767 passcode: pcd
768 "#;
769
770 write!(cloud_file, "{}", CLOUD_DATA).unwrap();
771
772 let cfg = ConfigFile::builder()
773 .add_source(cloud_file.path())
774 .unwrap()
775 .build();
776
777 let profile = cfg
778 .get_cloud_config("fake_cloud")
779 .unwrap()
780 .expect("Profile exists");
781 assert_eq!(
782 vec!["pwd", "app_cred_secret", "tkn", "pcd"],
783 profile.get_sensitive_values()
784 );
785 }
786}