Skip to content

Commit e89a2f0

Browse files
authored
feat: Introduce CLI configuration (#1177)
Prepare for making CLI output configurable using config files (similarly to the TUI configuration). The config is searched in the `$XDG_CONFIG_DIR/osc/[config.yaml, views.yaml]`. This change is only preparing for the next step of passing additional information from the codegenerator, so it is not feature complete.
1 parent af4d074 commit e89a2f0

File tree

7 files changed

+320
-9
lines changed

7 files changed

+320
-9
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openstack_cli/.config/config.yaml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
views:
3+
# block storage
4+
block_storage.backup:
5+
fields: [id, name, az, size, status, created_at]
6+
block_storage.snapshots:
7+
fields: [id, name, status, created_at]
8+
block_storage.volume:
9+
fields: [id, name, az, size, status, updated_at]
10+
# compute
11+
compute.aggregate:
12+
fields: [name, uuid, az, updated_at]
13+
compute.flavor:
14+
fields: [id, name, vcpus, ram, disk]
15+
compute.hypervisor:
16+
fields: [ip, hostname, status, state]
17+
compute.server/instance_action/event:
18+
fields: [event, result, start_time, finish_time, host]
19+
compute.server/instance_action:
20+
fields: [id, action, message, start_time, user_id]
21+
compute.server:
22+
fields: [id, name, status, created, address, image, flavor, security_groups]
23+
# dns
24+
dns.recordset:
25+
fields: [id, name, status, created, updated]
26+
dns.zone:
27+
fields: [id, name, status, created, updated]
28+
# identity
29+
identity.group:
30+
fields: [id, name, domain, description]
31+
identity.project:
32+
fields: [id, name, "parent id", enabled, "domain id"]
33+
identity.user/application_credential:
34+
fields: [id, name, "expires at", "unrestricted"]
35+
identity.user:
36+
fields: [name, domain, enabled, email, "pwd expiry"]
37+
# image
38+
image.image:
39+
fields: [id, name, distro, version, arch, visibility]
40+
# load balancer
41+
load-balancer.healthmonitor:
42+
fields: [id, name, status, type]
43+
load-balancer.listener:
44+
fields: [id, name, status, protocol, port]
45+
load-balancer.loadbalancer:
46+
fields: [id, name, status, address]
47+
load-balancer.pool/member:
48+
fields: [id, name, status, port]
49+
load-balancer.pool:
50+
fields: [id, name, status, protocol]
51+
# network
52+
network.network:
53+
fields: [id, name, status, created_at, updated_at]
54+
network.router:
55+
fields: [id, name, status, created_at, updated_at]
56+
network.subnet:
57+
fields: [id, name, cidr, description, created_at]
58+
network.security_group_rule:
59+
fields: [id, ethertype, direction, protocol, "range min", "range max"]
60+
network.security_group:
61+
fields: [id, name, created_at, updated_at]

openstack_cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ thiserror = { workspace = true }
8181
tracing = { workspace = true}
8282
tracing-subscriber = { workspace = true }
8383
url = { workspace = true }
84+
config.workspace = true
85+
dirs.workspace = true
8486

8587
[dev-dependencies]
8688
assert_cmd = "^2.0"

openstack_cli/src/cli.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use clap::builder::{
1919
};
2020
use clap::{Args, Parser, ValueEnum};
2121
use clap_complete::Shell;
22+
use std::error::Error;
2223

2324
use openstack_sdk::AsyncOpenStack;
2425

@@ -29,6 +30,7 @@ use crate::auth;
2930
use crate::block_storage::v3 as block_storage;
3031
use crate::catalog;
3132
use crate::compute::v2 as compute;
33+
use crate::config::Config;
3234
use crate::container_infrastructure_management::v1 as container_infra;
3335
use crate::dns::v2 as dns;
3436
use crate::identity::v3 as identity;
@@ -99,6 +101,18 @@ pub struct Cli {
99101
/// subcommand
100102
#[command(subcommand)]
101103
pub command: TopLevelCommands,
104+
105+
/// CLI configuration
106+
///
107+
/// This does not accept parameters at the moment and will always get config from default
108+
/// location.
109+
#[arg(hide = true, long("cli-config"), value_parser = parse_config, default_value_t = Config::new().expect("invalid config"))]
110+
pub config: Config,
111+
}
112+
113+
/// Parse config file
114+
pub fn parse_config(_s: &str) -> Result<Config, Box<dyn Error + Send + Sync + 'static>> {
115+
Ok(Config::new()?)
102116
}
103117

104118
/// Global CLI options

openstack_cli/src/config.rs

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Licensed under the Apache License, Version 2.0 (the "License");
2+
// you may not use this file except in compliance with the License.
3+
// You may obtain a copy of the License at
4+
//
5+
// http://www.apache.org/licenses/LICENSE-2.0
6+
//
7+
// Unless required by applicable law or agreed to in writing, software
8+
// distributed under the License is distributed on an "AS IS" BASIS,
9+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
// See the License for the specific language governing permissions and
11+
// limitations under the License.
12+
//
13+
// SPDX-License-Identifier: Apache-2.0
14+
//! OpenStackClient configuration
15+
//!
16+
17+
use eyre::Result;
18+
use serde::Deserialize;
19+
use std::{
20+
collections::HashMap,
21+
fmt,
22+
path::{Path, PathBuf},
23+
};
24+
use structable::OutputConfig;
25+
use thiserror::Error;
26+
use tracing::error;
27+
28+
const CONFIG: &str = include_str!("../.config/config.yaml");
29+
30+
/// Errors which may occur when dealing with OpenStack connection
31+
/// configuration data.
32+
#[derive(Debug, Error)]
33+
#[non_exhaustive]
34+
pub enum ConfigError {
35+
/// Parsing error
36+
#[error("failed to parse config: {}", source)]
37+
Parse {
38+
/// The source of the error.
39+
#[from]
40+
source: config::ConfigError,
41+
},
42+
}
43+
44+
impl ConfigError {
45+
/// Build a `[ConfigError::Parse]` error from `[config::ConfigError]`
46+
pub fn parse(source: config::ConfigError) -> Self {
47+
ConfigError::Parse { source }
48+
}
49+
}
50+
51+
/// Errors which may occur when adding sources to the [`ConfigFileBuilder`].
52+
#[derive(Error)]
53+
#[non_exhaustive]
54+
pub enum ConfigFileBuilderError {
55+
/// File parsing error
56+
#[error("failed to parse file {path:?}: {source}")]
57+
FileParse {
58+
/// Error source
59+
source: Box<config::ConfigError>,
60+
/// Builder object
61+
builder: ConfigFileBuilder,
62+
/// Error file path
63+
path: PathBuf,
64+
},
65+
/// Config file deserialization error
66+
#[error("failed to deserialize config {path:?}: {source}")]
67+
ConfigDeserialize {
68+
/// Error source
69+
source: Box<config::ConfigError>,
70+
/// Builder object
71+
builder: ConfigFileBuilder,
72+
/// Error file path
73+
path: PathBuf,
74+
},
75+
}
76+
77+
/// OpenStackClient configuration
78+
#[derive(Clone, Debug, Default, Deserialize)]
79+
pub struct Config {
80+
/// Map of views with the key being the resource key (<SERVICE_NAME>/<RESOURCE>[/<SUBRESOURCE>)
81+
/// and the value being an `[OutputConfig]`
82+
#[serde(default)]
83+
pub views: HashMap<String, OutputConfig>,
84+
}
85+
86+
/// A builder to create a [`ConfigFile`] by specifying which files to load.
87+
pub struct ConfigFileBuilder {
88+
/// Config source files
89+
sources: Vec<config::Config>,
90+
}
91+
92+
impl ConfigFileBuilder {
93+
/// Add a source to the builder. This will directly parse the config and check if it is valid.
94+
/// Values of sources added first will be overridden by later added sources, if the keys match.
95+
/// In other words, the sources will be merged, with the later taking precedence over the
96+
/// earlier ones.
97+
pub fn add_source(mut self, source: impl AsRef<Path>) -> Result<Self, ConfigFileBuilderError> {
98+
let config = match config::Config::builder()
99+
.add_source(config::File::from(source.as_ref()))
100+
.build()
101+
{
102+
Ok(config) => config,
103+
Err(error) => {
104+
return Err(ConfigFileBuilderError::FileParse {
105+
source: Box::new(error),
106+
builder: self,
107+
path: source.as_ref().to_owned(),
108+
});
109+
}
110+
};
111+
112+
if let Err(error) = config.clone().try_deserialize::<Config>() {
113+
return Err(ConfigFileBuilderError::ConfigDeserialize {
114+
source: Box::new(error),
115+
builder: self,
116+
path: source.as_ref().to_owned(),
117+
});
118+
}
119+
120+
self.sources.push(config);
121+
Ok(self)
122+
}
123+
124+
/// This will build a [`ConfigFile`] with the previously specified sources. Since
125+
/// the sources have already been checked on errors, this will not fail.
126+
pub fn build(self) -> Config {
127+
let mut config = config::Config::builder();
128+
129+
for source in self.sources {
130+
config = config.add_source(source);
131+
}
132+
133+
config.build().unwrap().try_deserialize().unwrap()
134+
}
135+
}
136+
137+
impl fmt::Debug for ConfigFileBuilderError {
138+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139+
match self {
140+
ConfigFileBuilderError::FileParse { source, path, .. } => f
141+
.debug_struct("FileParse")
142+
.field("source", source)
143+
.field("path", path)
144+
.finish_non_exhaustive(),
145+
ConfigFileBuilderError::ConfigDeserialize { source, path, .. } => f
146+
.debug_struct("ConfigDeserialize")
147+
.field("source", source)
148+
.field("path", path)
149+
.finish_non_exhaustive(),
150+
}
151+
}
152+
}
153+
154+
impl Config {
155+
/// Instantiate new config reading default config updating it with local configuration
156+
pub fn new() -> Result<Self, ConfigError> {
157+
let default_config: config::Config = config::Config::builder()
158+
.add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml))
159+
.build()?;
160+
161+
let config_dir = get_config_dir();
162+
let mut builder = ConfigFileBuilder {
163+
sources: Vec::from([default_config]),
164+
};
165+
166+
let config_files = [
167+
("config.yaml", config::FileFormat::Yaml),
168+
("views.yaml", config::FileFormat::Yaml),
169+
];
170+
let mut found_config = false;
171+
for (file, _format) in &config_files {
172+
if config_dir.join(file).exists() {
173+
found_config = true;
174+
175+
builder = match builder.add_source(config_dir.join(file)) {
176+
Ok(builder) => builder,
177+
Err(ConfigFileBuilderError::FileParse { source, .. }) => {
178+
return Err(ConfigError::parse(*source));
179+
}
180+
Err(ConfigFileBuilderError::ConfigDeserialize {
181+
source,
182+
builder,
183+
path,
184+
}) => {
185+
error!(
186+
"The file {path:?} could not be deserialized and will be ignored: {source}"
187+
);
188+
builder
189+
}
190+
}
191+
}
192+
}
193+
if !found_config {
194+
tracing::error!("No configuration file found. Application may not behave as expected");
195+
}
196+
197+
Ok(builder.build())
198+
}
199+
}
200+
201+
fn get_config_dir() -> PathBuf {
202+
dirs::config_dir()
203+
.expect("Cannot determine users XDG_CONFIG_HOME")
204+
.join("osc")
205+
}
206+
207+
impl fmt::Display for Config {
208+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
209+
write!(f, "")
210+
}
211+
}

openstack_cli/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub mod block_storage;
4242
pub mod catalog;
4343
mod common;
4444
pub mod compute;
45+
pub mod config;
4546
pub mod container_infrastructure_management;
4647
pub mod dns;
4748
pub mod identity;

0 commit comments

Comments
 (0)