Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file.
- `Box<T: Configurable>` is now `Configurable` ([#262]).
- `node_selector` to `PodBuilder` ([#267]).
- `role_utils::RoleGroupRef` ([#272]).
- Add support for managing CLI commands via `StructOpt` ([#273]).

### Changed
- BREAKING: `ObjectMetaBuilder::build` is no longer fallible ([#259]).
Expand All @@ -29,6 +30,7 @@ All notable changes to this project will be documented in this file.
[#269]: https://github.com/stackabletech/operator-rs/pull/269
[#270]: https://github.com/stackabletech/operator-rs/pull/270
[#272]: https://github.com/stackabletech/operator-rs/pull/272
[#273]: https://github.com/stackabletech/operator-rs/pull/273

## [0.4.0] - 2021-11-05

Expand Down
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ json-patch = "0.2.6"
k8s-openapi = { version = "0.13.1", default-features = false, features = ["schemars", "v1_22"] }
kube = { version = "0.63.1", features = ["jsonpatch", "runtime", "derive"] }
lazy_static = "1.4.0"
product-config = { git = "https://github.com/stackabletech/product-config.git", tag = "0.2.0" }
product-config = { git = "https://github.com/stackabletech/product-config.git", tag = "0.3.0" }
rand = "0.8.4"
regex = "1.5.4"
schemars = "0.8.6"
Expand All @@ -35,6 +35,7 @@ tracing-subscriber = { version = "0.3.1", features = ["env-filter"] }
uuid = { version = "0.8.2", features = ["v4"] }
backoff = "0.3.0"
derivative = "2.2.0"
structopt = "0.3.25"

[dev-dependencies]
rstest = "0.11.0"
Expand Down
184 changes: 128 additions & 56 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,99 @@ use crate::error;
use crate::error::OperatorResult;
use crate::CustomResourceExt;
use clap::{App, AppSettings, Arg, ArgMatches, SubCommand};
use std::path::Path;
use product_config::ProductConfigManager;
use std::{
ffi::OsStr,
path::{Path, PathBuf},
};
use structopt::StructOpt;

pub const AUTHOR: &str = "Stackable GmbH - info@stackable.de";

/// Framework-standardized commands
///
/// If you need operator-specific commands then you can flatten [`Command`] into your own command enum. For example:
/// ```rust
/// #[derive(structopt::StructOpt)]
/// enum Command {
/// /// Print hello world message
/// Hello,
/// #[structopt(flatten)]
/// Framework(stackable_operator::cli::Command)
/// }
/// ```
#[derive(StructOpt)]
pub enum Command {
/// Print CRD objects
Crd,
/// Run operator
Run {
/// Provides the path to a product-config file
#[structopt(
long,
short = "p",
value_name = "FILE",
default_value = "",
parse(from_os_str)
)]
product_config: ProductConfigPath,
},
}

/// A path to a [`ProductConfigManager`] spec file
pub struct ProductConfigPath {
path: Option<PathBuf>,
}

impl From<&OsStr> for ProductConfigPath {
fn from(s: &OsStr) -> Self {
Self {
// StructOpt doesn't let us hook in to see the underlying `Option<&str>`, so we treat the
// otherwise-invalid `""` as a sentinel for using the default instead.
path: if s.is_empty() { None } else { Some(s.into()) },
}
}
}

impl ProductConfigPath {
/// Load the [`ProductConfigManager`] from the given path, falling back to the first
/// path that exists from `default_search_paths` if none is given by the user.
pub fn load(
&self,
default_search_paths: &[impl AsRef<Path>],
) -> OperatorResult<ProductConfigManager> {
ProductConfigManager::from_yaml_file(resolve_path(
self.path.as_deref(),
default_search_paths,
)?)
.map_err(|source| error::Error::ProductConfigLoadError { source })
}
}

/// Check if the path can be found anywhere:
/// 1) User provides path `user_provided_path` to file -> 'Error' if not existing.
/// 2) User does not provide path to file -> search in `default_paths` and
/// take the first existing file.
/// 3) `Error` if nothing was found.
fn resolve_path<'a>(
user_provided_path: Option<&'a Path>,
default_paths: &'a [impl AsRef<Path> + 'a],
) -> OperatorResult<&'a Path> {
// Use override if specified by the user, otherwise search through defaults given
let search_paths = if let Some(path) = user_provided_path {
vec![path]
} else {
default_paths.iter().map(|path| path.as_ref()).collect()
};
for path in &search_paths {
if path.exists() {
return Ok(path);
}
}
Err(error::Error::RequiredFileMissing {
search_path: search_paths.into_iter().map(PathBuf::from).collect(),
})
}

const PRODUCT_CONFIG_ARG: &str = "product-config";

Expand All @@ -103,6 +195,7 @@ const PRODUCT_CONFIG_ARG: &str = "product-config";
/// Meant to be handled by [`handle_productconfig_arg`].
///
/// See the module level documentation for a complete example.
#[deprecated(note = "use ProductConfigPath (or Command) instead")]
pub fn generate_productconfig_arg<'a, 'b>() -> Arg<'a, 'b> {
Arg::with_name(PRODUCT_CONFIG_ARG)
.short("p")
Expand All @@ -119,50 +212,19 @@ pub fn generate_productconfig_arg<'a, 'b>() -> Arg<'a, 'b> {
/// # Arguments
///
/// * `default_locations`: These locations will be checked for the existence of a config file if the user doesn't provide one
#[deprecated(note = "use ProductConfigPath (or Command) instead")]
pub fn handle_productconfig_arg(
matches: &ArgMatches,
default_locations: Vec<&str>,
) -> OperatorResult<String> {
check_path(matches.value_of(PRODUCT_CONFIG_ARG), default_locations)
}

/// Check if the product-config can be found anywhere:
/// 1) User provides path `user_provided_file_path` to product-config file -> Error if not existing.
/// 2) User does not provide path to product-config-file -> search in default_locations and
/// take the first existing file.
/// 3) Error if nothing was found.
fn check_path(
user_provided_file_path: Option<&str>,
default_locations: Vec<&str>,
) -> OperatorResult<String> {
let mut search_paths = vec![];

// 1) User provides path to product-config file -> Error if not existing
if let Some(path) = user_provided_file_path {
return if Path::new(path).exists() {
Ok(path.to_string())
} else {
search_paths.push(path.to_string());
Err(error::Error::RequiredFileMissing {
search_path: search_paths,
})
};
}

// 2) User does not provide path to product-config-file -> search in default_locations and
// take the first existing file.
for loc in default_locations {
if Path::new(loc).exists() {
return Ok(loc.to_string());
} else {
search_paths.push(loc.to_string())
}
}

// 3) Error if nothing was found
Err(error::Error::RequiredFileMissing {
search_path: search_paths,
})
Ok(resolve_path(
matches.value_of(PRODUCT_CONFIG_ARG).map(str::as_ref),
&default_locations,
)?
.to_str()
// ArgMatches::value_of and `str` both already validate UTF-8, so this should never be possible
.expect("product-config path must be UTF-8")
.to_owned())
}

/// This will generate a clap subcommand ([`App`]) that can be used for operations on CRDs.
Expand All @@ -180,6 +242,7 @@ fn check_path(
/// * `name`: Name of the CRD
///
/// returns: App
#[deprecated(note = "use Command instead")]
pub fn generate_crd_subcommand<'a, 'b, T>() -> App<'a, 'b>
where
T: CustomResourceExt,
Expand Down Expand Up @@ -217,6 +280,7 @@ where
///
/// returns: A boolean wrapped in a result indicating whether the this method did handle the argument.
/// If it returns `Ok(true)` the program should abort.
#[deprecated(note = "use Command instead")]
pub fn handle_crd_subcommand<T>(matches: &ArgMatches) -> OperatorResult<bool>
where
T: CustomResourceExt,
Expand Down Expand Up @@ -260,16 +324,15 @@ mod tests {
DEPLOY_FILE_PATH
)]
#[case(None, vec!["bad", DEFAULT_FILE_PATH], DEFAULT_FILE_PATH, DEFAULT_FILE_PATH)]
fn test_check_path_good(
fn test_resolve_path_good(
#[case] user_provided_path: Option<&str>,
#[case] default_locations: Vec<&str>,
#[case] path_to_create: &str,
#[case] expected: &str,
) -> OperatorResult<()> {
let temp_dir = tempdir()?;
let full_path_to_create = temp_dir.path().join(path_to_create);
let full_user_provided_path =
user_provided_path.map(|p| temp_dir.path().join(p).to_str().unwrap().to_string());
let full_user_provided_path = user_provided_path.map(|p| temp_dir.path().join(p));
let expected_path = temp_dir.path().join(expected);

let mut full_default_locations = vec![];
Expand All @@ -279,17 +342,19 @@ mod tests {
full_default_locations.push(temp.as_path().display().to_string());
}

let full_default_locations_ref =
full_default_locations.iter().map(String::as_str).collect();
let full_default_locations_ref = full_default_locations
.iter()
.map(String::as_str)
.collect::<Vec<_>>();

let file = File::create(full_path_to_create)?;

let found_path = check_path(
let found_path = resolve_path(
full_user_provided_path.as_deref(),
full_default_locations_ref,
&full_default_locations_ref,
)?;

assert_eq!(&found_path, expected_path.to_str().unwrap());
assert_eq!(found_path, expected_path);

drop(file);
temp_dir.close()?;
Expand All @@ -299,17 +364,24 @@ mod tests {

#[test]
#[should_panic]
fn test_check_path_user_path_not_existing() {
check_path(Some(USER_PROVIDED_PATH), vec![DEPLOY_FILE_PATH]).unwrap();
fn test_resolve_path_user_path_not_existing() {
resolve_path(Some(USER_PROVIDED_PATH.as_ref()), &[DEPLOY_FILE_PATH]).unwrap();
}

#[test]
fn test_check_path_nothing_found_errors() {
if let Err(error::Error::RequiredFileMissing {
search_path: errors,
}) = check_path(None, vec![DEPLOY_FILE_PATH, DEFAULT_FILE_PATH])
fn test_resolve_path_nothing_found_errors() {
if let Err(error::Error::RequiredFileMissing { search_path }) =
resolve_path(None, &[DEPLOY_FILE_PATH, DEFAULT_FILE_PATH])
{
assert_eq!(errors, vec![DEPLOY_FILE_PATH, DEFAULT_FILE_PATH])
assert_eq!(
search_path,
vec![
PathBuf::from(DEPLOY_FILE_PATH),
PathBuf::from(DEFAULT_FILE_PATH)
]
)
} else {
panic!("must return RequiredFileMissing when file was not found")
}
}
}
9 changes: 8 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::name_utils;
use crate::product_config_utils;
use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf;

#[derive(Debug, thiserror::Error)]
pub enum Error {
Expand Down Expand Up @@ -80,7 +81,13 @@ pub enum Error {
#[error(
"A required File is missing. Not found in any of the following locations: {search_path:?}"
)]
RequiredFileMissing { search_path: Vec<String> },
RequiredFileMissing { search_path: Vec<PathBuf> },

#[error("Failed to load ProductConfig: {source}")]
ProductConfigLoadError {
#[source]
source: product_config::error::Error,
},

#[error("ProductConfig Framework reported error: {source}")]
ProductConfigError {
Expand Down