From 4455bcd8152cef45ff84f794cf6b96bb1d5c2392 Mon Sep 17 00:00:00 2001 From: dmbuil Date: Sun, 26 Jul 2026 19:41:35 +0200 Subject: [PATCH] feat(config): Add `clouds add` command Add `osc config clouds add`, a hand-written command that reads the JSON of an application credential from stdin and merges a ready-to-use cloud entry into clouds.yaml. Connection settings (auth_url, region, TLS options) are inherited from the cloud selected with --os-cloud; no authentication is performed. Supports --split to keep the credential in a separate secure.yaml, --overwrite to replace an existing entry, and --file to target a specific path. Files carrying the secret are written with mode 0600 and the credential is zeroized in memory after use. The command is dispatched in entry_point before a session is established, as it edits local files only. Closes #1323 Signed-off-by: dmbuil --- Cargo.lock | 33 ++ Cargo.toml | 2 + cli/config/Cargo.toml | 28 ++ cli/config/src/clouds.rs | 49 +++ cli/config/src/clouds/add.rs | 608 +++++++++++++++++++++++++++ cli/config/src/lib.rs | 53 +++ openstack_cli/Cargo.toml | 2 + openstack_cli/src/cli.rs | 4 + openstack_cli/src/lib.rs | 6 + openstack_cli/tests/config/clouds.rs | 143 +++++++ openstack_cli/tests/config/mod.rs | 15 + openstack_cli/tests/main.rs | 1 + 12 files changed, 944 insertions(+) create mode 100644 cli/config/Cargo.toml create mode 100644 cli/config/src/clouds.rs create mode 100644 cli/config/src/clouds/add.rs create mode 100644 cli/config/src/lib.rs create mode 100644 openstack_cli/tests/config/clouds.rs create mode 100644 openstack_cli/tests/config/mod.rs diff --git a/Cargo.lock b/Cargo.lock index be22a477e..a05da9208 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2941,6 +2941,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "openstack-cli-config" +version = "0.13.7" +dependencies = [ + "clap", + "dirs", + "eyre", + "openstack-cli-core", + "openstack_sdk_core", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "tracing", + "zeroize", +] + [[package]] name = "openstack-cli-container-infrastructure-management" version = "0.13.7" @@ -3693,6 +3710,7 @@ dependencies = [ "openstack-cli-block-storage", "openstack-cli-catalog", "openstack-cli-compute", + "openstack-cli-config", "openstack-cli-container-infrastructure-management", "openstack-cli-core", "openstack-cli-dns", @@ -3706,6 +3724,7 @@ dependencies = [ "rand 0.10.2", "reqwest", "serde_json", + "serde_yaml", "strip-ansi-escapes", "tempfile", "tokio", @@ -6852,6 +6871,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 7974b3d21..5f796d6af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ openstack-cli-auth = { path="cli/auth", version = "0.13.7" } openstack-cli-block-storage = { path="cli/block-storage/", version = "0.13.7" } openstack-cli-catalog = { path="cli/catalog/", version = "0.13.7" } openstack-cli-compute = { path="cli/compute/", version = "0.13.7" } +openstack-cli-config = { path="cli/config/", version = "0.13.7" } openstack-cli-container-infrastructure-management = { path="cli/container-infrastructure-management/", version = "0.13.7" } openstack-cli-core = { path="cli/core", version = "0.13.7" } openstack-cli-dns = { path="cli/dns/", version = "0.13.7" } @@ -103,6 +104,7 @@ schemars = { version = "^1.2" } secrecy = { version = "^0.10", features = ["serde"] } serde = { version="^1.0", features=["derive"] } serde_json = "^1.0" +serde_yaml = "^0.9" serde_bytes = "^0.11" serde_urlencoded = "^0.7" sha2 = { version = "^0.11", default-features = false } diff --git a/cli/config/Cargo.toml b/cli/config/Cargo.toml new file mode 100644 index 000000000..c1f24b62c --- /dev/null +++ b/cli/config/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "openstack-cli-config" +description = "OpenStack CLI config commands" +version = "0.13.7" +license.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +clap.workspace = true +dirs.workspace = true +eyre.workspace = true +openstack-cli-core.workspace = true +openstack_sdk_core.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true +tracing.workspace = true +zeroize = { workspace = true, features = ["derive"] } + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/cli/config/src/clouds.rs b/cli/config/src/clouds.rs new file mode 100644 index 000000000..0916a4690 --- /dev/null +++ b/cli/config/src/clouds.rs @@ -0,0 +1,49 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Cloud entry operations on clouds.yaml/secure.yaml + +use clap::{Parser, Subcommand}; + +use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; +use openstack_sdk_core::config::CloudConfig; + +pub mod add; + +/// Manage cloud entries in clouds.yaml/secure.yaml +#[derive(Parser)] +pub struct CloudsCommand { + /// Cloud entry commands + #[command(subcommand)] + pub command: CloudsCommands, +} + +#[allow(missing_docs)] +#[derive(Subcommand)] +pub enum CloudsCommands { + Add(add::AddCommand), +} + +impl CloudsCommand { + /// Perform command action + pub fn take_action( + &self, + parsed_args: &C, + cloud_config: &CloudConfig, + ) -> Result<(), OpenStackCliError> { + match &self.command { + CloudsCommands::Add(cmd) => cmd.take_action(parsed_args, cloud_config), + } + } +} diff --git a/cli/config/src/clouds/add.rs b/cli/config/src/clouds/add.rs new file mode 100644 index 000000000..d745d08c4 --- /dev/null +++ b/cli/config/src/clouds/add.rs @@ -0,0 +1,608 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Add a cloud entry built from an application credential. +//! +//! Hand-written command implementing +//! . + +use std::io::{IsTerminal, Read, Write}; +use std::path::{Path, PathBuf}; + +use clap::Args; +use eyre::{OptionExt, WrapErr, eyre}; +use serde::Serialize; +use tracing::info; +use zeroize::{ZeroizeOnDrop, Zeroizing}; + +use openstack_cli_core::cli::CliArgs; +use openstack_cli_core::error::OpenStackCliError; +use openstack_sdk_core::config::{CloudConfig, find_clouds_file, find_secure_file}; + +/// Add a cloud entry built from an application credential. +/// +/// Reads the JSON printed by `osc identity user application-credential +/// create -o json` from stdin and merges a ready-to-use cloud entry into +/// clouds.yaml (or, with --split, the credential into secure.yaml). The +/// entry inherits connection settings (auth_url, region, TLS options) from +/// the cloud selected with `--os-cloud`; no authentication is performed. +/// +/// An existing target file is merged into, but rewritten: comments and +/// formatting are not preserved. +#[derive(Args)] +#[command(about = "Add a cloud entry from an application credential")] +pub struct AddCommand { + /// Name of the cloud entry to add. + #[arg(default_value = "openstack", long)] + cloud_name: String, + + /// Target clouds.yaml path. Defaults to `--os-client-config-file`, else + /// the discovered standard clouds.yaml, else + /// `$XDG_CONFIG_HOME/openstack/clouds.yaml`. + #[arg(long, value_name = "PATH")] + file: Option, + + /// Write the credential id and secret into a separate secure.yaml + /// instead of clouds.yaml. + #[arg(action = clap::ArgAction::SetTrue, long)] + split: bool, + + /// Replace an existing cloud entry of the same name. + #[arg(action = clap::ArgAction::SetTrue, long)] + overwrite: bool, +} + +impl AddCommand { + /// Perform command action + pub fn take_action( + &self, + parsed_args: &C, + cloud_config: &CloudConfig, + ) -> Result<(), OpenStackCliError> { + info!("Add cloud entry to clouds.yaml"); + + let input = read_stdin()?; + let (credential_id, secret) = extract_credential(&input)?; + + let connection = &parsed_args.global_opts().connection; + let clouds_path = resolve_clouds_path( + self.file.as_deref(), + connection.os_client_config_file.as_deref(), + ); + let secure_path = self.split.then(|| { + resolve_secure_path( + self.file.as_deref(), + connection.os_client_secure_file.as_deref(), + &clouds_path, + ) + }); + + // Merge is the default: an existing target is read and the entry + // added to it. + let clouds_existing = read_existing(&clouds_path)?; + let secure_existing = secure_path + .as_deref() + .map(read_existing) + .transpose()? + .flatten(); + + let (clouds_entry, secure_entry) = + build_entries(cloud_config, &credential_id, &secret, self.split)?; + + // Render everything before writing anything: a same-name collision + // or malformed target aborts with no file touched. The rendered + // contents carry the credential; wipe them on drop. + let clouds_content = Zeroizing::new(render_target( + clouds_existing.as_deref(), + &self.cloud_name, + &clouds_entry, + self.overwrite, + )?); + let secure_content = secure_entry + .as_ref() + .map(|entry| { + render_target( + secure_existing.as_deref(), + &self.cloud_name, + entry, + self.overwrite, + ) + .map(Zeroizing::new) + }) + .transpose()?; + + write_yaml_file(&clouds_path, &clouds_content, !self.split)?; + println!( + "Added cloud `{}` to {}", + self.cloud_name, + clouds_path.display() + ); + if let (Some(path), Some(content)) = (&secure_path, &secure_content) { + write_yaml_file(path, content, true)?; + println!("Added cloud `{}` to {}", self.cloud_name, path.display()); + } + Ok(()) + } +} + +/// Read the application credential JSON from stdin. +fn read_stdin() -> Result, eyre::Report> { + let mut stdin = std::io::stdin(); + if stdin.is_terminal() { + return Err(eyre!( + "the application credential JSON is expected on stdin, e.g. `osc identity user application-credential create --name foo -o json | osc config clouds add`" + )); + } + let mut buf = Zeroizing::new(String::new()); + stdin + .read_to_string(&mut buf) + .wrap_err("cannot read stdin")?; + Ok(buf) +} + +/// Extract the credential id and secret from the piped create response. +/// Both the bare resource (the `-o json` output) and the +/// `{"application_credential": {...}}` wrapped API form are accepted. +fn extract_credential(input: &str) -> Result<(Zeroizing, Zeroizing), eyre::Report> { + let doc: serde_json::Value = serde_json::from_str(input).wrap_err("stdin is not valid JSON")?; + let resource = doc.get("application_credential").unwrap_or(&doc); + let id = resource + .get("id") + .and_then(|v| v.as_str()) + .map(|v| Zeroizing::new(v.to_string())) + .ok_or_eyre("the input is missing the credential `id`")?; + let secret = resource + .get("secret") + .and_then(|v| v.as_str()) + .map(|v| Zeroizing::new(v.to_string())) + .ok_or_eyre( + "the input is missing the credential `secret` (the secret is only returned by the create call)", + )?; + Ok((id, secret)) +} + +/// Resolve the target clouds.yaml: `--file` > `--os-client-config-file` > +/// the discovered standard file > the XDG default location. +fn resolve_clouds_path(file: Option<&Path>, os_client_config_file: Option<&str>) -> PathBuf { + if let Some(path) = file { + path.to_path_buf() + } else if let Some(path) = os_client_config_file { + PathBuf::from(path) + } else if let Some(path) = find_clouds_file() { + path + } else { + default_config_dir().join("clouds.yaml") + } +} + +/// Resolve the secure.yaml target. With `--file` the sibling secure.yaml is +/// used so the pair stays together; otherwise `--os-client-secure-file` > +/// the discovered standard file > sibling of the resolved clouds.yaml. +fn resolve_secure_path( + file: Option<&Path>, + os_client_secure_file: Option<&str>, + clouds_path: &Path, +) -> PathBuf { + if file.is_none() { + if let Some(path) = os_client_secure_file { + return PathBuf::from(path); + } + if let Some(path) = find_secure_file() { + return path; + } + } + clouds_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")) + .join("secure.yaml") +} + +/// `$XDG_CONFIG_HOME/openstack`, matching the SDK config file discovery. +fn default_config_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("openstack") +} + +/// One entry under the `clouds:` key of a clouds.yaml/secure.yaml file. +#[derive(Debug, Default, Serialize)] +struct CloudEntry { + #[serde(skip_serializing_if = "Option::is_none")] + auth_type: Option, + auth: AuthBlock, + #[serde(skip_serializing_if = "Option::is_none")] + region_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + interface: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cacert: Option, + #[serde(skip_serializing_if = "Option::is_none")] + verify: Option, +} + +/// The `auth` block of a cloud entry. The block holds the credential; +/// [`ZeroizeOnDrop`] wipes it from memory on drop. +#[derive(Debug, Default, Serialize, ZeroizeOnDrop)] +struct AuthBlock { + #[serde(skip_serializing_if = "Option::is_none")] + auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + application_credential_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + application_credential_secret: Option, +} + +/// Build the clouds.yaml entry and, in split mode, the secure.yaml entry +/// carrying the credential id and secret. +fn build_entries( + config: &CloudConfig, + credential_id: &str, + secret: &str, + split: bool, +) -> Result<(CloudEntry, Option), eyre::Report> { + let auth_url = config + .auth + .as_ref() + .and_then(|auth| auth.auth_url.clone()) + .ok_or_eyre("cannot determine the auth_url of the current cloud")?; + + let clouds_entry = CloudEntry { + auth_type: Some("v3applicationcredential".into()), + auth: AuthBlock { + auth_url: Some(auth_url), + application_credential_id: (!split).then(|| credential_id.into()), + application_credential_secret: (!split).then(|| secret.into()), + }, + region_name: config.region_name.clone(), + // `interface` is serde-defaulted to "public" on config load; only a + // non-default value is worth exporting. + interface: config.interface.clone().filter(|i| i != "public"), + cacert: config.cacert.clone(), + verify: config.verify, + }; + let secure_entry = split.then(|| CloudEntry { + auth: AuthBlock { + auth_url: None, + application_credential_id: Some(credential_id.into()), + application_credential_secret: Some(secret.into()), + }, + ..Default::default() + }); + Ok((clouds_entry, secure_entry)) +} + +/// Produce the final YAML for a target file. `existing` carries the current +/// file content when merging into it; comments in it are not preserved. +fn render_target( + existing: Option<&str>, + cloud_name: &str, + entry: &CloudEntry, + overwrite_entry: bool, +) -> Result { + match existing { + None => { + let mut clouds = serde_yaml::Mapping::new(); + clouds.insert(cloud_name.into(), serde_yaml::to_value(entry)?); + let mut root = serde_yaml::Mapping::new(); + root.insert("clouds".into(), serde_yaml::Value::Mapping(clouds)); + Ok(serde_yaml::to_string(&serde_yaml::Value::Mapping(root))?) + } + Some(current) => { + let mut doc: serde_yaml::Value = + serde_yaml::from_str(current).wrap_err("the target file is not valid YAML")?; + let root = doc + .as_mapping_mut() + .ok_or_eyre("the target file is not a YAML mapping")?; + let clouds = root + .entry("clouds".into()) + .or_insert_with(|| serde_yaml::Value::Mapping(Default::default())) + .as_mapping_mut() + .ok_or_eyre("`clouds` in the target file is not a mapping")?; + let name_key: serde_yaml::Value = cloud_name.into(); + if clouds.contains_key(&name_key) && !overwrite_entry { + return Err(eyre!( + "cloud `{cloud_name}` already exists in the target file; pass --overwrite to replace it" + )); + } + clouds.insert(name_key, serde_yaml::to_value(entry)?); + Ok(serde_yaml::to_string(&doc)?) + } + } +} + +/// Read the current content of a target file; `None` when it does not +/// exist. +fn read_existing(path: &Path) -> Result, eyre::Report> { + if !path.exists() { + return Ok(None); + } + Ok(Some(std::fs::read_to_string(path).wrap_err_with(|| { + format!("cannot read {}", path.display()) + })?)) +} + +/// Write a YAML file, creating parent directories; files carrying the +/// credential secret get 0600. +fn write_yaml_file(path: &Path, content: &str, contains_secret: bool) -> Result<(), eyre::Report> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .wrap_err_with(|| format!("cannot create {}", parent.display()))?; + } + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + if contains_secret { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(path) + .wrap_err_with(|| format!("cannot open {} for writing", path.display()))?; + file.write_all(content.as_bytes()) + .wrap_err_with(|| format!("cannot write {}", path.display()))?; + // The mode above only applies on creation; harden pre-existing files too. + #[cfg(unix)] + if contains_secret { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .wrap_err_with(|| format!("cannot set permissions on {}", path.display()))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config_with(auth_url: Option<&str>) -> CloudConfig { + CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: auth_url.map(Into::into), + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn extract_bare_and_wrapped_input() { + let bare = r#"{"id": "cid", "secret": "sec", "name": "foo"}"#; + let (id, secret) = extract_credential(bare).unwrap(); + assert_eq!(id.as_str(), "cid"); + assert_eq!(secret.as_str(), "sec"); + + let wrapped = r#"{"application_credential": {"id": "cid", "secret": "sec"}}"#; + let (id, secret) = extract_credential(wrapped).unwrap(); + assert_eq!(id.as_str(), "cid"); + assert_eq!(secret.as_str(), "sec"); + } + + #[test] + fn extract_rejects_bad_input() { + assert!(extract_credential("not json").is_err()); + assert!(extract_credential(r#"{"secret": "sec"}"#).is_err()); + assert!(extract_credential(r#"{"id": "cid"}"#).is_err()); + assert!(extract_credential(r#"{"id": "cid", "secret": null}"#).is_err()); + } + + #[test] + fn clouds_path_resolution_order() { + assert_eq!( + resolve_clouds_path(Some(Path::new("/tmp/c.yaml")), Some("/tmp/os.yaml")), + PathBuf::from("/tmp/c.yaml") + ); + assert_eq!( + resolve_clouds_path(None, Some("/tmp/os.yaml")), + PathBuf::from("/tmp/os.yaml") + ); + } + + #[test] + fn secure_path_sibling_of_explicit_file() { + // With --file the secure.yaml must live next to it, even when a + // standard secure file would be discovered. + assert_eq!( + resolve_secure_path( + Some(Path::new("/tmp/foo/clouds.yaml")), + Some("/other/secure.yaml"), + Path::new("/tmp/foo/clouds.yaml"), + ), + PathBuf::from("/tmp/foo/secure.yaml") + ); + // Without --file the explicit secure file option wins. + assert_eq!( + resolve_secure_path(None, Some("/other/secure.yaml"), Path::new("/tmp/c.yaml")), + PathBuf::from("/other/secure.yaml") + ); + } + + #[test] + fn single_file_entry_carries_secret() { + let (clouds, secure) = build_entries( + &config_with(Some("https://keystone:5000")), + "cid", + "sec", + false, + ) + .unwrap(); + assert_eq!(clouds.auth_type.as_deref(), Some("v3applicationcredential")); + assert_eq!( + clouds.auth.auth_url.as_deref(), + Some("https://keystone:5000") + ); + assert_eq!( + clouds.auth.application_credential_id.as_deref(), + Some("cid") + ); + assert_eq!( + clouds.auth.application_credential_secret.as_deref(), + Some("sec") + ); + assert!(secure.is_none()); + } + + #[test] + fn split_moves_credentials_to_secure_entry() { + let (clouds, secure) = build_entries( + &config_with(Some("https://keystone:5000")), + "cid", + "sec", + true, + ) + .unwrap(); + assert!(clouds.auth.application_credential_secret.is_none()); + assert!(clouds.auth.application_credential_id.is_none()); + let secure = secure.expect("secure entry in split mode"); + assert_eq!( + secure.auth.application_credential_secret.as_deref(), + Some("sec") + ); + assert_eq!( + secure.auth.application_credential_id.as_deref(), + Some("cid") + ); + assert!(secure.auth.auth_url.is_none()); + assert!(secure.auth_type.is_none()); + } + + #[test] + fn inherits_allowlist_but_skips_default_interface() { + let mut config = config_with(Some("https://keystone:5000")); + config.region_name = Some("RegionOne".into()); + config.cacert = Some("/etc/ssl/custom.pem".into()); + config.verify = Some(false); + config.interface = Some("public".into()); + let (clouds, _) = build_entries(&config, "cid", "sec", false).unwrap(); + assert_eq!(clouds.region_name.as_deref(), Some("RegionOne")); + assert_eq!(clouds.cacert.as_deref(), Some("/etc/ssl/custom.pem")); + assert_eq!(clouds.verify, Some(false)); + assert!( + clouds.interface.is_none(), + "default interface must be omitted" + ); + + config.interface = Some("internal".into()); + let (clouds, _) = build_entries(&config, "cid", "sec", false).unwrap(); + assert_eq!(clouds.interface.as_deref(), Some("internal")); + } + + #[test] + fn missing_auth_url_is_an_error() { + assert!(build_entries(&config_with(None), "cid", "sec", false).is_err()); + assert!(build_entries(&CloudConfig::default(), "cid", "sec", false).is_err()); + } + + #[test] + fn render_fresh_file() { + let (entry, _) = build_entries( + &config_with(Some("https://keystone:5000")), + "cid", + "sec", + false, + ) + .unwrap(); + let out = render_target(None, "mycloud", &entry, false).unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&out).unwrap(); + assert_eq!( + doc["clouds"]["mycloud"]["auth"]["application_credential_id"], + serde_yaml::Value::String("cid".into()) + ); + assert_eq!( + doc["clouds"]["mycloud"]["auth_type"], + serde_yaml::Value::String("v3applicationcredential".into()) + ); + } + + #[test] + fn merge_preserves_other_clouds() { + let existing = "clouds:\n other:\n auth:\n auth_url: https://other:5000\n"; + let (entry, _) = build_entries( + &config_with(Some("https://keystone:5000")), + "cid", + "sec", + false, + ) + .unwrap(); + let out = render_target(Some(existing), "mycloud", &entry, false).unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&out).unwrap(); + assert_eq!( + doc["clouds"]["other"]["auth"]["auth_url"], + serde_yaml::Value::String("https://other:5000".into()) + ); + assert!(doc["clouds"]["mycloud"]["auth"]["application_credential_id"].is_string()); + } + + #[test] + fn merge_same_name_errors_without_overwrite() { + let existing = "clouds:\n mycloud:\n auth:\n auth_url: https://old:5000\n"; + let (entry, _) = build_entries( + &config_with(Some("https://keystone:5000")), + "cid", + "sec", + false, + ) + .unwrap(); + assert!(render_target(Some(existing), "mycloud", &entry, false).is_err()); + let out = render_target(Some(existing), "mycloud", &entry, true).unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&out).unwrap(); + assert_eq!( + doc["clouds"]["mycloud"]["auth"]["application_credential_id"], + serde_yaml::Value::String("cid".into()) + ); + } + + #[test] + fn merge_into_malformed_yaml_errors() { + let (entry, _) = build_entries( + &config_with(Some("https://keystone:5000")), + "cid", + "sec", + false, + ) + .unwrap(); + assert!(render_target(Some(": not yaml : ["), "mycloud", &entry, false).is_err()); + assert!(render_target(Some("- a\n- list\n"), "mycloud", &entry, false).is_err()); + } + + #[test] + fn read_existing_semantics() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("clouds.yaml"); + assert!(read_existing(&path).unwrap().is_none()); + + std::fs::write(&path, "clouds: {}\n").unwrap(); + assert_eq!( + read_existing(&path).unwrap().as_deref(), + Some("clouds: {}\n") + ); + } + + #[test] + fn write_creates_parents_and_sets_mode() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/dir/clouds.yaml"); + write_yaml_file(&path, "clouds: {}\n", true).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "clouds: {}\n"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + } +} diff --git a/cli/config/src/lib.rs b/cli/config/src/lib.rs new file mode 100644 index 000000000..9b2ec8c69 --- /dev/null +++ b/cli/config/src/lib.rs @@ -0,0 +1,53 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Local client configuration file operations + +use clap::{Parser, Subcommand}; + +use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; +use openstack_sdk_core::config::CloudConfig; + +pub mod clouds; + +/// Manage local OpenStack client configuration files +/// +/// This command provides operations editing the local `clouds.yaml` and +/// `secure.yaml` files. The commands operate on local files only and never +/// authenticate against the cloud. +#[derive(Parser)] +pub struct ConfigCommand { + /// Configuration commands + #[command(subcommand)] + pub command: ConfigCommands, +} + +#[allow(missing_docs)] +#[derive(Subcommand)] +pub enum ConfigCommands { + Clouds(clouds::CloudsCommand), +} + +impl ConfigCommand { + /// Perform command action + pub fn take_action( + &self, + parsed_args: &C, + cloud_config: &CloudConfig, + ) -> Result<(), OpenStackCliError> { + match &self.command { + ConfigCommands::Clouds(cmd) => cmd.take_action(parsed_args, cloud_config), + } + } +} diff --git a/openstack_cli/Cargo.toml b/openstack_cli/Cargo.toml index ba3a2d322..93576520d 100644 --- a/openstack_cli/Cargo.toml +++ b/openstack_cli/Cargo.toml @@ -69,6 +69,7 @@ openstack-cli-auth.workspace = true openstack-cli-block-storage.workspace = true openstack-cli-catalog.workspace = true openstack-cli-compute.workspace = true +openstack-cli-config.workspace = true openstack-cli-container-infrastructure-management.workspace = true openstack-cli-core.workspace = true openstack-cli-dns.workspace = true @@ -92,6 +93,7 @@ md5 = "^0.8.1" rand = "^0.10" reqwest.workspace = true serde_json.workspace = true +serde_yaml.workspace = true tempfile.workspace = true [[test]] diff --git a/openstack_cli/src/cli.rs b/openstack_cli/src/cli.rs index eb802e095..adff631a3 100644 --- a/openstack_cli/src/cli.rs +++ b/openstack_cli/src/cli.rs @@ -120,6 +120,7 @@ pub enum TopLevelCommands { BlockStorage(openstack_cli_block_storage::BlockStorageCommand), Catalog(openstack_cli_catalog::CatalogCommand), Compute(openstack_cli_compute::ComputeCommand), + Config(openstack_cli_config::ConfigCommand), #[command(aliases = ["container-infrastructure-management", "container"])] ContainerInfrastructure( openstack_cli_container_infrastructure_management::ContainerInfrastructureCommand, @@ -152,6 +153,9 @@ impl Cli { TopLevelCommands::BlockStorage(args) => args.take_action(self, client).await, TopLevelCommands::Catalog(args) => args.take_action(self, client).await, TopLevelCommands::Compute(args) => args.take_action(self, client).await, + // `osc config` edits local files without authentication and is + // dispatched in `entry_point` before the session is established. + TopLevelCommands::Config(_) => unimplemented!(), TopLevelCommands::ContainerInfrastructure(args) => args.take_action(self, client).await, TopLevelCommands::Dns(args) => args.take_action(self, client).await, TopLevelCommands::Identity(args) => args.take_action(self, client).await, diff --git a/openstack_cli/src/lib.rs b/openstack_cli/src/lib.rs index caa68b865..9435089ad 100644 --- a/openstack_cli/src/lib.rs +++ b/openstack_cli/src/lib.rs @@ -136,6 +136,12 @@ pub async fn entry_point() -> Result<(), OpenStackCliError> { cloud_config.region_name = Some(region_name.clone()); } + // `osc config` commands edit local configuration files of the selected + // cloud and must not trigger authentication. + if let TopLevelCommands::Config(args) = &cli.command { + return args.take_action(&cli, &cloud_config); + } + // Certain commands (e.g. `auth login --renew`, `auth status`) need different // pre-connect behavior than the default "connect with a live authenticated // session". See `ConnectionRequirementsProvider`. diff --git a/openstack_cli/tests/config/clouds.rs b/openstack_cli/tests/config/clouds.rs new file mode 100644 index 000000000..205ceadc7 --- /dev/null +++ b/openstack_cli/tests/config/clouds.rs @@ -0,0 +1,143 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use assert_cmd::Command; + +/// Source config the command inherits connection settings from. +const SOURCE_CLOUDS: &str = r#"clouds: + src: + auth: + auth_url: https://keystone:5000/v3 + region_name: RegionOne +"#; + +const CREATE_RESPONSE: &str = r#"{"id": "cid", "secret": "sec", "name": "deploy"}"#; + +fn add_cmd(source: &std::path::Path) -> Command { + let mut cmd = Command::cargo_bin("osc").expect("osc binary"); + cmd.arg("--os-cloud") + .arg("src") + .arg("--os-client-config-file") + .arg(source) + .arg("config") + .arg("clouds") + .arg("add"); + cmd +} + +#[test] +fn help() -> Result<(), Box> { + let mut cmd = Command::cargo_bin("osc")?; + + cmd.arg("config").arg("clouds").arg("add").arg("--help"); + cmd.assert().success(); + + Ok(()) +} + +#[test] +fn add_writes_new_file() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let source = dir.path().join("src-clouds.yaml"); + std::fs::write(&source, SOURCE_CLOUDS)?; + let target = dir.path().join("out/clouds.yaml"); + + add_cmd(&source) + .arg("--cloud-name") + .arg("prod") + .arg("--file") + .arg(&target) + .write_stdin(CREATE_RESPONSE) + .assert() + .success(); + + let doc: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(&target)?)?; + let entry = &doc["clouds"]["prod"]; + assert_eq!( + entry["auth"]["application_credential_id"].as_str(), + Some("cid") + ); + assert_eq!( + entry["auth"]["application_credential_secret"].as_str(), + Some("sec") + ); + assert_eq!( + entry["auth"]["auth_url"].as_str(), + Some("https://keystone:5000/v3") + ); + assert_eq!(entry["auth_type"].as_str(), Some("v3applicationcredential")); + assert_eq!(entry["region_name"].as_str(), Some("RegionOne")); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&target)?.permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "file with secret must be 0600"); + } + + Ok(()) +} + +#[test] +fn add_split_writes_secure_sibling() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let source = dir.path().join("src-clouds.yaml"); + std::fs::write(&source, SOURCE_CLOUDS)?; + let target = dir.path().join("out/clouds.yaml"); + + add_cmd(&source) + .arg("--split") + .arg("--file") + .arg(&target) + .write_stdin(CREATE_RESPONSE) + .assert() + .success(); + + let clouds = std::fs::read_to_string(&target)?; + assert!(!clouds.contains("sec"), "secret must not be in clouds.yaml"); + let secure = std::fs::read_to_string(dir.path().join("out/secure.yaml"))?; + assert!(secure.contains("application_credential_secret: sec")); + + Ok(()) +} + +#[test] +fn add_same_name_needs_overwrite() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let source = dir.path().join("src-clouds.yaml"); + std::fs::write(&source, SOURCE_CLOUDS)?; + let target = dir.path().join("clouds.yaml"); + std::fs::write(&target, "clouds:\n openstack:\n auth: {}\n")?; + + let output = add_cmd(&source) + .arg("--file") + .arg(&target) + .write_stdin(CREATE_RESPONSE) + .output()?; + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("--overwrite"), + "collision error must point at --overwrite" + ); + + add_cmd(&source) + .arg("--file") + .arg(&target) + .arg("--overwrite") + .write_stdin(CREATE_RESPONSE) + .assert() + .success(); + + Ok(()) +} diff --git a/openstack_cli/tests/config/mod.rs b/openstack_cli/tests/config/mod.rs new file mode 100644 index 000000000..1c2c590ce --- /dev/null +++ b/openstack_cli/tests/config/mod.rs @@ -0,0 +1,15 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +mod clouds; diff --git a/openstack_cli/tests/main.rs b/openstack_cli/tests/main.rs index f050347ae..7efa37a11 100644 --- a/openstack_cli/tests/main.rs +++ b/openstack_cli/tests/main.rs @@ -20,6 +20,7 @@ mod block_storage; mod catalog; #[cfg(feature = "compute")] mod compute; +mod config; #[cfg(feature = "container_infra")] mod container_infrastructure_management; #[cfg(feature = "dns")]