From 4605a2fd0b8dabf6185f0aeb2ccdd4b2b52b83f8 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Fri, 12 Sep 2025 18:11:59 +0200 Subject: [PATCH] feat: Enable `OS_CLIENT_CONFIG_PATH` environment variable In a way similar to `KUBECONFIG` environment variable add possibility to predefine locations at which configurations are searched and all merged into the single "virtual" config. ``` export OS_CLIENT_CONFIG_PATH=c1.yaml:~/.config/openstack/c3.yaml:/etc/openstack/secure.yaml" ``` When entry point to the file it is tried to be parsed as is. When an entry is a directory a regular clouds.yaml/secure.yaml files are being searched in it. --- doc/src/auth.md | 16 +++++ openstack_sdk/src/config.rs | 126 +++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/doc/src/auth.md b/doc/src/auth.md index 43c870161..25fd8bd11 100644 --- a/doc/src/auth.md +++ b/doc/src/auth.md @@ -39,6 +39,22 @@ Rust based tools support typical [`clouds.yaml`/`secure.yaml`](https://docs.openstack.org/openstacksdk/latest/user/config/configuration.html) files for configuration. +Using traditional configuration files may become challenging when many +connections need to be configured. For example there might be few lists of +connections from different providers, some of them may be distributed through +source control. It is possible to simplify such situation and let them be merged +together into the single "virtual" configuration instead of explicitly +specifying them as runtime parameters. The `OS_CLIENT_CONFIG_PATH` environment +variable can point at a single file or list of files and directories. + +```console +export OS_CLIENT_CONFIG_PATH=./clouds.yaml:~/.config/clouds1.yaml:~/.config/clouds2.yaml:~/.config/secure.yaml:~/.config/openstack/" +``` +Being set like that every tool of the project will merge all individual elements +together. When an entry is a directory traditionally `clouds.yaml`/`secure.yaml` +files are being searched in the directory and merged into the resulting +configuration. + Most authentication methods support interactive data provisioning. When certain required auth attributes are not provided in the configuration file or through the supported cli arguments (or environment variables) clients that implement diff --git a/openstack_sdk/src/config.rs b/openstack_sdk/src/config.rs index eec09e331..3d96e1a64 100644 --- a/openstack_sdk/src/config.rs +++ b/openstack_sdk/src/config.rs @@ -54,7 +54,7 @@ use secrecy::{ExposeSecret, SecretString}; use std::fmt; use std::path::{Path, PathBuf}; -use tracing::{error, trace, warn}; +use tracing::{debug, error, trace, warn}; use serde::Deserialize; use std::collections::hash_map::DefaultHasher; @@ -631,6 +631,40 @@ pub fn find_secure_file() -> Option { .find(|path| path.is_file()) } +/// Returns list of all configuration files pointed at with `OS_CLIENT_CONFIG_PATH` environment +/// variable. +/// +/// The variable can point to the concrete file result or be a ":" separated list of the search +/// items. Example: "~/clouds.yaml:~/secure.yaml:/etc/clouds.yaml:~/.config/openstack". When an +/// item is a file it is being added into the resulting list. An item being a directory is used as +/// a base to search regular `clouds.yaml` and `secure.yaml` files which are being add into the list +/// when existing in the directory. +/// +pub fn find_config_files_specified_in_env() -> impl IntoIterator { + let mut results: Vec = Vec::new(); + if let Ok(configs) = env::var("OS_CLIENT_CONFIG_PATH") { + debug!( + "Searching for the OpenStack client config files in {}.", + configs + ); + for candidate in configs.split(":") { + let path = PathBuf::from(candidate); + if path.is_file() { + results.push(path); + } else if path.is_dir() { + for config_prefix in ["clouds", "secure"] { + CONFIG_SUFFIXES + .iter() + .map(|y| path.join(format!("{}{}", config_prefix, y))) + .find(|path| path.is_file()) + .inspect(|path| results.push(path.to_owned())); + } + } + } + } + results +} + impl ConfigFile { /// A builder to create a `ConfigFile` by specifying which files to load. pub fn builder() -> ConfigFileBuilder { @@ -656,8 +690,9 @@ impl ConfigFile { ) -> Result { let mut builder = Self::builder(); - for path in find_vendor_file() + for path in find_config_files_specified_in_env() .into_iter() + .chain(find_vendor_file()) .chain(find_clouds_file()) .chain(clouds.map(|path| path.as_ref().to_owned())) .chain(find_secure_file()) @@ -747,9 +782,10 @@ mod tests { use crate::config; use secrecy::ExposeSecret; use std::env; + use std::fs::File; use std::io::Write; use std::path::PathBuf; - use tempfile::Builder; + use tempfile::{tempdir, Builder}; use super::*; @@ -935,4 +971,88 @@ mod tests { assert_eq!("auth_type", cc.auth_type.unwrap()); assert_eq!("region_name", cc.region_name.unwrap()); } + + #[test] + fn test_from_os_client_config_path_env() { + let mut cloud_file = Builder::new().suffix(".yaml").tempfile().unwrap(); + let mut secure_file = Builder::new().suffix(".yaml").tempfile().unwrap(); + + const CLOUD_DATA: &str = r#" + clouds: + fake_cloud: + auth: + auth_url: http://fake.com + username: override_me + "#; + const SECURE_DATA: &str = r#" + clouds: + fake_cloud: + auth: + username: foo + password: bar + "#; + + write!(cloud_file, "{CLOUD_DATA}").unwrap(); + write!(secure_file, "{SECURE_DATA}").unwrap(); + let cfg = ConfigFile::new().unwrap(); + + assert!(cfg.get_cloud_config("fake_cloud").unwrap().is_none()); + + // now add both files explicitly into the env var and verify all data is fetched + env::set_var( + "OS_CLIENT_CONFIG_PATH", + format!( + "{}:{}", + cloud_file.path().display(), + secure_file.path().display() + ), + ); + + let cfg = ConfigFile::new().unwrap(); + let profile = cfg + .get_cloud_config("fake_cloud") + .unwrap() + .expect("Profile exists"); + let auth = profile.auth.expect("Auth defined"); + + assert_eq!(auth.auth_url, Some(String::from("http://fake.com"))); + assert_eq!(auth.username, Some(String::from("foo"))); + assert_eq!(auth.password.unwrap().expose_secret(), String::from("bar")); + assert_eq!(profile.name, Some(String::from("fake_cloud"))); + // with only directory containing those files they should not be used unless they are named + // properly + env::set_var( + "OS_CLIENT_CONFIG_PATH", + format!( + "{}:", + cloud_file.path().parent().expect("no parent").display(), + ), + ); + let cfg = ConfigFile::new().unwrap(); + assert!( + cfg.get_cloud_config("fake_cloud").unwrap().is_none(), + "Nothing should be found in {:?}", + env::var("OS_CLIENT_CONFIG_PATH") + ); + + // env points at the dir and there is clouds.yaml file there + let tmp_dir = tempdir().unwrap(); + env::set_var( + "OS_CLIENT_CONFIG_PATH", + format!("{}:", tmp_dir.path().display(),), + ); + let file_path = tmp_dir.path().join("clouds.yml"); + let mut tmp_file = File::create(file_path).unwrap(); + write!(tmp_file, "{CLOUD_DATA}").unwrap(); + + let cfg = ConfigFile::new().unwrap(); + let profile = cfg + .get_cloud_config("fake_cloud") + .unwrap() + .expect("Profile exists"); + let auth = profile.auth.expect("Auth defined"); + + assert_eq!(auth.auth_url, Some(String::from("http://fake.com"))); + assert_eq!(auth.username, Some(String::from("override_me"))); + } }