From 03f9f599f0ebcdc711dba54b3e98639901f03285 Mon Sep 17 00:00:00 2001 From: Techassi Date: Thu, 18 Sep 2025 14:05:40 +0200 Subject: [PATCH 01/14] refactor!(stackable-operator): Split/move CLI args/options --- crates/stackable-operator/src/cli.rs | 442 ------------------ .../stackable-operator/src/cli/environment.rs | 16 + .../stackable-operator/src/cli/maintenance.rs | 23 + crates/stackable-operator/src/cli/mod.rs | 234 ++++++++++ .../src/cli/product_config.rs | 167 +++++++ 5 files changed, 440 insertions(+), 442 deletions(-) delete mode 100644 crates/stackable-operator/src/cli.rs create mode 100644 crates/stackable-operator/src/cli/environment.rs create mode 100644 crates/stackable-operator/src/cli/maintenance.rs create mode 100644 crates/stackable-operator/src/cli/mod.rs create mode 100644 crates/stackable-operator/src/cli/product_config.rs diff --git a/crates/stackable-operator/src/cli.rs b/crates/stackable-operator/src/cli.rs deleted file mode 100644 index d3d244edb..000000000 --- a/crates/stackable-operator/src/cli.rs +++ /dev/null @@ -1,442 +0,0 @@ -//! This module provides helper methods to deal with common CLI options using the `clap` crate. -//! -//! In particular it currently supports handling two kinds of options: -//! * CRD printing -//! * Product config location -//! -//! # Example -//! -//! This example show the usage of the CRD functionality. -//! -//! ```no_run -//! // Handle CLI arguments -//! use clap::{crate_version, Parser}; -//! use kube::CustomResource; -//! use schemars::JsonSchema; -//! use serde::{Deserialize, Serialize}; -//! use stackable_operator::{CustomResourceExt, cli, shared::crd}; -//! -//! const OPERATOR_VERSION: &str = "23.1.1"; -//! -//! #[derive(Clone, CustomResource, Debug, JsonSchema, Serialize, Deserialize)] -//! #[kube( -//! group = "foo.stackable.tech", -//! version = "v1", -//! kind = "FooCluster", -//! namespaced -//! )] -//! pub struct FooClusterSpec { -//! pub name: String, -//! } -//! -//! #[derive(Clone, CustomResource, Debug, JsonSchema, Serialize, Deserialize)] -//! #[kube( -//! group = "bar.stackable.tech", -//! version = "v1", -//! kind = "BarCluster", -//! namespaced -//! )] -//! pub struct BarClusterSpec { -//! pub name: String, -//! } -//! -//! #[derive(clap::Parser)] -//! #[command( -//! name = "Foobar Operator", -//! author, -//! version, -//! about = "Stackable Operator for Foobar" -//! )] -//! struct Opts { -//! #[clap(subcommand)] -//! command: cli::Command, -//! } -//! -//! # fn main() -> Result<(), crd::Error> { -//! let opts = Opts::parse(); -//! -//! match opts.command { -//! cli::Command::Crd => { -//! FooCluster::print_yaml_schema(OPERATOR_VERSION)?; -//! BarCluster::print_yaml_schema(OPERATOR_VERSION)?; -//! }, -//! cli::Command::Run { .. } => { -//! // Run the operator -//! } -//! } -//! # Ok(()) -//! # } -//! ``` -//! -//! Product config handling works similarly: -//! -//! ```no_run -//! use clap::{crate_version, Parser}; -//! use stackable_operator::cli; -//! -//! #[derive(clap::Parser)] -//! #[command( -//! name = "Foobar Operator", -//! author, -//! version, -//! about = "Stackable Operator for Foobar" -//! )] -//! struct Opts { -//! #[clap(subcommand)] -//! command: cli::Command, -//! } -//! -//! # fn main() -> Result<(), cli::Error> { -//! let opts = Opts::parse(); -//! -//! match opts.command { -//! cli::Command::Crd => { -//! // Print CRD objects -//! } -//! cli::Command::Run(cli::ProductOperatorRun { product_config, watch_namespace, .. }) => { -//! let product_config = product_config.load(&[ -//! "deploy/config-spec/properties.yaml", -//! "/etc/stackable/spark-operator/config-spec/properties.yaml", -//! ])?; -//! } -//! } -//! # Ok(()) -//! # } -//! -//! ``` -//! -//! -use std::{ - ffi::OsStr, - path::{Path, PathBuf}, -}; - -use clap::Args; -use product_config::ProductConfigManager; -use snafu::{ResultExt, Snafu}; -use stackable_telemetry::tracing::TelemetryOptions; - -use crate::{namespace::WatchNamespace, utils::cluster_info::KubernetesClusterInfoOptions}; - -pub const AUTHOR: &str = "Stackable GmbH - info@stackable.tech"; - -type Result = std::result::Result; - -#[derive(Debug, PartialEq, Snafu)] -pub enum Error { - #[snafu(display("failed to load product config"))] - ProductConfigLoad { - source: product_config::error::Error, - }, - - #[snafu(display( - "failed to locate a required file in any of the following locations: {search_path:?}" - ))] - RequiredFileMissing { search_path: Vec }, -} - -/// Framework-standardized commands -/// -/// If you need operator-specific commands then you can flatten [`Command`] into your own command enum. For example: -/// ```rust -/// #[derive(clap::Parser)] -/// enum Command { -/// /// Print hello world message -/// Hello, -/// #[clap(flatten)] -/// Framework(stackable_operator::cli::Command) -/// } -/// ``` -#[derive(clap::Parser, Debug, PartialEq, Eq)] -// The enum-level doccomment is intended for developers, not end users -// so supress it from being included in --help -#[command(long_about = "")] -pub enum Command { - /// Print CRD objects - Crd, - /// Run operator - Run(Run), -} - -/// Default parameters that all product operators take when running -/// -/// Can be embedded into an extended argument set: -/// -/// ```rust -/// # use stackable_operator::cli::{Command, CommonOptions, OperatorEnvironmentOptions, ProductOperatorRun, ProductConfigPath}; -/// # use stackable_operator::{namespace::WatchNamespace, utils::cluster_info::KubernetesClusterInfoOptions}; -/// # use stackable_telemetry::tracing::TelemetryOptions; -/// use clap::Parser; -/// -/// #[derive(clap::Parser, Debug, PartialEq, Eq)] -/// struct Run { -/// #[clap(long)] -/// name: String, -/// #[clap(flatten)] -/// common: ProductOperatorRun, -/// } -/// -/// let opts = Command::::parse_from([ -/// "foobar-operator", -/// "run", -/// "--name", -/// "foo", -/// "--product-config", -/// "bar", -/// "--watch-namespace", -/// "foobar", -/// "--operator-namespace", -/// "stackable-operators", -/// "--operator-service-name", -/// "foo-operator", -/// "--kubernetes-node-name", -/// "baz", -/// ]); -/// assert_eq!(opts, Command::Run(Run { -/// name: "foo".to_string(), -/// common: ProductOperatorRun { -/// common: CommonOptions { -/// telemetry: TelemetryOptions::default(), -/// cluster_info: KubernetesClusterInfoOptions { -/// kubernetes_cluster_domain: None, -/// kubernetes_node_name: "baz".to_string(), -/// }, -/// }, -/// product_config: ProductConfigPath::from("bar".as_ref()), -/// watch_namespace: WatchNamespace::One("foobar".to_string()), -/// operator_environment: OperatorEnvironmentOptions { -/// operator_namespace: "stackable-operators".to_string(), -/// operator_service_name: "foo-operator".to_string(), -/// }, -/// disable_crd_maintenance: false, -/// }, -/// })); -/// ``` -/// -/// or replaced entirely -/// -/// ```rust -/// # use stackable_operator::cli::{Command, ProductOperatorRun}; -/// use clap::Parser; -/// -/// #[derive(clap::Parser, Debug, PartialEq, Eq)] -/// struct Run { -/// #[arg(long)] -/// name: String, -/// } -/// -/// let opts = Command::::parse_from(["foobar-operator", "run", "--name", "foo"]); -/// assert_eq!(opts, Command::Run(Run { -/// name: "foo".to_string(), -/// })); -/// ``` -#[derive(clap::Parser, Debug, PartialEq, Eq)] -#[command(long_about = "")] -pub struct ProductOperatorRun { - #[command(flatten)] - pub common: CommonOptions, - - #[command(flatten)] - pub operator_environment: OperatorEnvironmentOptions, - - /// Provides the path to a product-config file - #[arg(long, short = 'p', value_name = "FILE", default_value = "", env)] - pub product_config: ProductConfigPath, - - /// Provides a specific namespace to watch (instead of watching all namespaces) - #[arg(long, env, default_value = "")] - pub watch_namespace: WatchNamespace, - - /// Don't maintain the CustomResourceDefinitions (CRDs) the operator is responsible for. - /// - /// Maintenance includes creating the CRD initially, adding new versions and keeping the TLS - /// certificate of webhooks up to date. Turning this off can be desirable to reduce the RBAC - /// permissions of the operator. - /// - /// WARNING: If you disable CRD maintenance you are responsible for maintaining it, including, - /// but not limited to, the points above. - #[arg(long, env)] - pub disable_crd_maintenance: bool, -} - -/// All the CLI arguments that all (or at least most) Stackable applications use. -/// -/// [`ProductOperatorRun`] is intended for operators, but it has fields that are not needed for -/// utilities such as `user-info-fetcher` or `opa-bundle-builder`. So this struct offers a limited -/// set, that should be shared across all Stackable tools running on Kubernetes. -#[derive(clap::Parser, Debug, PartialEq, Eq)] -pub struct CommonOptions { - #[command(flatten)] - pub telemetry: TelemetryOptions, - - #[command(flatten)] - pub cluster_info: KubernetesClusterInfoOptions, -} - -/// A path to a [`ProductConfigManager`] spec file -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ProductConfigPath { - path: Option, -} - -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]) -> Result { - let resolved_path = Self::resolve_path(self.path.as_deref(), default_search_paths)?; - ProductConfigManager::from_yaml_file(resolved_path).context(ProductConfigLoadSnafu) - } - - /// Check if the path can be found anywhere - /// - /// 1. User provides path `user_provided_path` to file. Return [`Error`] if not existing. - /// 2. User does not provide path to file -> search in `default_paths` and - /// take the first existing file. - /// 3. Return [`Error`] if nothing was found. - fn resolve_path<'a>( - user_provided_path: Option<&'a Path>, - default_paths: &'a [impl AsRef + 'a], - ) -> Result<&'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); - } - } - RequiredFileMissingSnafu { - search_path: search_paths - .into_iter() - .map(PathBuf::from) - .collect::>(), - } - .fail() - } -} - -#[derive(clap::Parser, Debug, PartialEq, Eq)] -pub struct OperatorEnvironmentOptions { - /// The namespace the operator is running in, usually `stackable-operators`. - /// - /// Note that when running the operator on Kubernetes we recommend to use the - /// [downward API](https://kubernetes.io/docs/concepts/workloads/pods/downward-api/) - /// to let Kubernetes project the namespace as the `OPERATOR_NAMESPACE` env variable. - #[arg(long, env)] - pub operator_namespace: String, - - /// The name of the service the operator is reachable at, usually - /// something like `-operator`. - #[arg(long, env)] - pub operator_service_name: String, -} - -#[cfg(test)] -mod tests { - use std::fs::File; - - use rstest::*; - use tempfile::tempdir; - - use super::*; - - const USER_PROVIDED_PATH: &str = "user_provided_path_properties.yaml"; - const DEPLOY_FILE_PATH: &str = "deploy_config_spec_properties.yaml"; - const DEFAULT_FILE_PATH: &str = "default_file_path_properties.yaml"; - - #[test] - fn verify_cli() { - use clap::CommandFactory; - ProductOperatorRun::command().print_long_help().unwrap(); - ProductOperatorRun::command().debug_assert() - } - - #[rstest] - #[case( - Some(USER_PROVIDED_PATH), - vec![], - USER_PROVIDED_PATH, - USER_PROVIDED_PATH - )] - #[case( - None, - vec![DEPLOY_FILE_PATH, DEFAULT_FILE_PATH], - DEPLOY_FILE_PATH, - DEPLOY_FILE_PATH - )] - #[case(None, vec!["bad", DEFAULT_FILE_PATH], DEFAULT_FILE_PATH, DEFAULT_FILE_PATH)] - fn resolve_path_good( - #[case] user_provided_path: Option<&str>, - #[case] default_locations: Vec<&str>, - #[case] path_to_create: &str, - #[case] expected: &str, - ) -> Result<()> { - let temp_dir = tempdir().expect("create temporary directory"); - 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)); - let expected_path = temp_dir.path().join(expected); - - let mut full_default_locations = vec![]; - - for loc in default_locations { - let temp = temp_dir.path().join(loc); - 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 file = File::create(full_path_to_create).expect("create temporary file"); - - let found_path = ProductConfigPath::resolve_path( - full_user_provided_path.as_deref(), - &full_default_locations_ref, - )?; - - assert_eq!(found_path, expected_path); - - drop(file); - temp_dir.close().expect("clean up temporary directory"); - - Ok(()) - } - - #[test] - #[should_panic] - fn resolve_path_user_path_not_existing() { - ProductConfigPath::resolve_path(Some(USER_PROVIDED_PATH.as_ref()), &[DEPLOY_FILE_PATH]) - .unwrap(); - } - - #[test] - fn resolve_path_nothing_found_errors() { - if let Err(Error::RequiredFileMissing { search_path }) = - ProductConfigPath::resolve_path(None, &[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") - } - } -} diff --git a/crates/stackable-operator/src/cli/environment.rs b/crates/stackable-operator/src/cli/environment.rs new file mode 100644 index 000000000..bf6d28f22 --- /dev/null +++ b/crates/stackable-operator/src/cli/environment.rs @@ -0,0 +1,16 @@ +#[derive(Debug, PartialEq, Eq, clap::Parser)] +#[command(next_help_heading = "Environment Options")] +pub struct OperatorEnvironmentOptions { + /// The namespace the operator is running in, usually `stackable-operators`. + /// + /// Note that when running the operator on Kubernetes we recommend to use the + /// [downward API](https://kubernetes.io/docs/concepts/workloads/pods/downward-api/) + /// to let Kubernetes project the namespace as the `OPERATOR_NAMESPACE` env variable. + #[arg(long, env)] + pub operator_namespace: String, + + /// The name of the service the operator is reachable at, usually + /// something like `-operator`. + #[arg(long, env)] + pub operator_service_name: String, +} diff --git a/crates/stackable-operator/src/cli/maintenance.rs b/crates/stackable-operator/src/cli/maintenance.rs new file mode 100644 index 000000000..13533f319 --- /dev/null +++ b/crates/stackable-operator/src/cli/maintenance.rs @@ -0,0 +1,23 @@ +use clap::Args; + +use crate::eos::EndOfSupportOptions; + +#[derive(Debug, PartialEq, Eq, Args)] +#[command(next_help_heading = "Maintenance Options")] +pub struct MaintenanceOptions { + /// Don't maintain the CustomResourceDefinitions (CRDs) the operator is responsible for. + /// + /// Maintenance includes creating the CRD initially, adding new versions and keeping the TLS + /// certificate of webhooks up to date. Turning this off can be desirable to reduce the RBAC + /// permissions of the operator. + /// + /// WARNING: If you disable CRD maintenance you are responsible for maintaining it, including, + /// but not limited to, the points above. + #[arg(long, env)] + pub disable_crd_maintenance: bool, + + // IMPORTANT: All (flattened) sub structs should be placed at the end to ensure the help + // headings are correct. + #[command(flatten)] + pub end_of_support: EndOfSupportOptions, +} diff --git a/crates/stackable-operator/src/cli/mod.rs b/crates/stackable-operator/src/cli/mod.rs new file mode 100644 index 000000000..3ea461d82 --- /dev/null +++ b/crates/stackable-operator/src/cli/mod.rs @@ -0,0 +1,234 @@ +//! This module provides helper methods to deal with common CLI options using the `clap` crate. +//! +//! In particular it currently supports handling two kinds of options: +//! * CRD printing +//! * Product config location +//! +//! # Example +//! +//! This example show the usage of the CRD functionality. +//! +//! ```no_run +//! // Handle CLI arguments +//! use clap::{crate_version, Parser}; +//! use kube::CustomResource; +//! use schemars::JsonSchema; +//! use serde::{Deserialize, Serialize}; +//! use stackable_operator::{CustomResourceExt, cli, shared::crd}; +//! +//! const OPERATOR_VERSION: &str = "23.1.1"; +//! +//! #[derive(Clone, CustomResource, Debug, JsonSchema, Serialize, Deserialize)] +//! #[kube( +//! group = "foo.stackable.tech", +//! version = "v1", +//! kind = "FooCluster", +//! namespaced +//! )] +//! pub struct FooClusterSpec { +//! pub name: String, +//! } +//! +//! #[derive(Clone, CustomResource, Debug, JsonSchema, Serialize, Deserialize)] +//! #[kube( +//! group = "bar.stackable.tech", +//! version = "v1", +//! kind = "BarCluster", +//! namespaced +//! )] +//! pub struct BarClusterSpec { +//! pub name: String, +//! } +//! +//! #[derive(clap::Parser)] +//! #[command( +//! name = "Foobar Operator", +//! author, +//! version, +//! about = "Stackable Operator for Foobar" +//! )] +//! struct Opts { +//! #[clap(subcommand)] +//! command: cli::Command, +//! } +//! +//! # fn main() -> Result<(), crd::Error> { +//! let opts = Opts::parse(); +//! +//! match opts.command { +//! cli::Command::Crd => { +//! FooCluster::print_yaml_schema(OPERATOR_VERSION)?; +//! BarCluster::print_yaml_schema(OPERATOR_VERSION)?; +//! }, +//! cli::Command::Run { .. } => { +//! // Run the operator +//! } +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! Product config handling works similarly: +//! +//! ```no_run +//! use clap::{crate_version, Parser}; +//! use stackable_operator::cli; +//! +//! #[derive(clap::Parser)] +//! #[command( +//! name = "Foobar Operator", +//! author, +//! version, +//! about = "Stackable Operator for Foobar" +//! )] +//! struct Opts { +//! #[clap(subcommand)] +//! command: cli::Command, +//! } +//! +//! # fn main() -> Result<(), cli::Error> { +//! let opts = Opts::parse(); +//! +//! match opts.command { +//! cli::Command::Crd => { +//! // Print CRD objects +//! } +//! cli::Command::Run(cli::ProductOperatorRun { product_config, watch_namespace, .. }) => { +//! let product_config = product_config.load(&[ +//! "deploy/config-spec/properties.yaml", +//! "/etc/stackable/spark-operator/config-spec/properties.yaml", +//! ])?; +//! } +//! } +//! # Ok(()) +//! # } +//! +//! ``` +//! +//! +use clap::{Args, Parser}; +use stackable_telemetry::tracing::TelemetryOptions; + +use crate::{namespace::WatchNamespace, utils::cluster_info::KubernetesClusterInfoOptions}; + +mod environment; +mod maintenance; +mod product_config; + +pub use environment::*; +pub use maintenance::*; +pub use product_config::*; + +// NOTE (@Techassi): Why the hell is this here? Let's get rid of it. +pub const AUTHOR: &str = "Stackable GmbH - info@stackable.tech"; + +/// A common set of commands used by operators. +/// +/// This enum is generic over the arguments available to the [`Command::Run`] subcommand. By default, +/// [`RunArguments`] is used, but a custom type can be used. +/// +/// ```rust +/// use stackable_operator::cli::Command; +/// use clap::Parser; +/// +/// #[derive(Parser)] +/// struct Run { +/// #[arg(long)] +/// name: String, +/// } +/// +/// let _ = Command::::parse_from(["foobar-operator", "run", "--name", "foo"]); +/// ``` +/// +/// If you need operator-specific commands then you can flatten [`Command`] into your own command +/// enum. +/// +/// ```rust +/// use stackable_operator::cli::Command; +/// use clap::Parser; +/// +/// #[derive(Parser)] +/// enum Command { +/// /// Print hello world message +/// Hello, +/// +/// #[clap(flatten)] +/// Framework(Command) +/// } +/// ``` +#[derive(Debug, PartialEq, Eq, Parser)] +pub enum Command { + /// Print CRD objects. + Crd, + + /// Run the operator. + Run(Run), +} + +/// Default CLI arguments that most operators take when running. +/// +/// ### Embed into an extended argument set +/// +/// ```rust +/// use stackable_operator::cli::RunArguments; +/// use clap::Parser; +/// +/// #[derive(clap::Parser, Debug, PartialEq, Eq)] +/// struct Run { +/// #[clap(long)] +/// name: String, +/// +/// #[clap(flatten)] +/// common: RunArguments, +/// } +/// ``` +#[derive(Debug, PartialEq, Eq, Parser)] +#[command(long_about = "")] +pub struct RunArguments { + /// Provides the path to a product-config file + #[arg(long, short = 'p', value_name = "FILE", default_value = "", env)] + pub product_config: ProductConfigPath, + + // TODO (@Techassi): This should be moved into the environment options + /// Provides a specific namespace to watch (instead of watching all namespaces) + #[arg(long, env, default_value = "")] + pub watch_namespace: WatchNamespace, + + // IMPORTANT: All (flattened) sub structs should be placed at the end to ensure the help + // headings are correct. + #[command(flatten)] + pub common: CommonOptions, + + #[command(flatten)] + pub maintenance: MaintenanceOptions, + + #[command(flatten)] + pub operator_environment: OperatorEnvironmentOptions, +} + +/// A set of CLI arguments that all (or at least most) Stackable applications use. +/// +/// [`RunArguments`] is intended for operators, but it has fields that are not needed for utilities +/// such as `user-info-fetcher` or `opa-bundle-builder`. So this struct offers a limited set, that +/// should be shared across all Stackable tools running on Kubernetes. +#[derive(Debug, PartialEq, Eq, Args)] +pub struct CommonOptions { + #[command(flatten)] + pub telemetry: TelemetryOptions, + + #[command(flatten)] + pub cluster_info: KubernetesClusterInfoOptions, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_cli() { + use clap::CommandFactory; + + RunArguments::command().print_long_help().unwrap(); + RunArguments::command().debug_assert() + } +} diff --git a/crates/stackable-operator/src/cli/product_config.rs b/crates/stackable-operator/src/cli/product_config.rs new file mode 100644 index 000000000..70c372b39 --- /dev/null +++ b/crates/stackable-operator/src/cli/product_config.rs @@ -0,0 +1,167 @@ +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +use product_config::ProductConfigManager; +use snafu::{ResultExt, Snafu}; + +type Result = std::result::Result; + +#[derive(Debug, PartialEq, Snafu)] +pub enum Error { + #[snafu(display("failed to load product config"))] + ProductConfigLoad { + source: product_config::error::Error, + }, + + #[snafu(display( + "failed to locate a required file in any of the following locations: {search_path:?}" + ))] + RequiredFileMissing { search_path: Vec }, +} + +/// A path to a [`ProductConfigManager`] spec file +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProductConfigPath { + path: Option, +} + +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]) -> Result { + let resolved_path = Self::resolve_path(self.path.as_deref(), default_search_paths)?; + ProductConfigManager::from_yaml_file(resolved_path).context(ProductConfigLoadSnafu) + } + + /// Check if the path can be found anywhere + /// + /// 1. User provides path `user_provided_path` to file. Return [`Error`] if not existing. + /// 2. User does not provide path to file -> search in `default_paths` and + /// take the first existing file. + /// 3. Return [`Error`] if nothing was found. + fn resolve_path<'a>( + user_provided_path: Option<&'a Path>, + default_paths: &'a [impl AsRef + 'a], + ) -> Result<&'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); + } + } + RequiredFileMissingSnafu { + search_path: search_paths + .into_iter() + .map(PathBuf::from) + .collect::>(), + } + .fail() + } +} + +#[cfg(test)] +mod tests { + use std::fs::File; + + use rstest::*; + use tempfile::tempdir; + + use super::*; + + const USER_PROVIDED_PATH: &str = "user_provided_path_properties.yaml"; + const DEPLOY_FILE_PATH: &str = "deploy_config_spec_properties.yaml"; + const DEFAULT_FILE_PATH: &str = "default_file_path_properties.yaml"; + + #[rstest] + #[case( + Some(USER_PROVIDED_PATH), + vec![], + USER_PROVIDED_PATH, + USER_PROVIDED_PATH + )] + #[case( + None, + vec![DEPLOY_FILE_PATH, DEFAULT_FILE_PATH], + DEPLOY_FILE_PATH, + DEPLOY_FILE_PATH + )] + #[case(None, vec!["bad", DEFAULT_FILE_PATH], DEFAULT_FILE_PATH, DEFAULT_FILE_PATH)] + fn resolve_path_good( + #[case] user_provided_path: Option<&str>, + #[case] default_locations: Vec<&str>, + #[case] path_to_create: &str, + #[case] expected: &str, + ) -> Result<()> { + let temp_dir = tempdir().expect("create temporary directory"); + 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)); + let expected_path = temp_dir.path().join(expected); + + let mut full_default_locations = vec![]; + + for loc in default_locations { + let temp = temp_dir.path().join(loc); + 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 file = File::create(full_path_to_create).expect("create temporary file"); + + let found_path = ProductConfigPath::resolve_path( + full_user_provided_path.as_deref(), + &full_default_locations_ref, + )?; + + assert_eq!(found_path, expected_path); + + drop(file); + temp_dir.close().expect("clean up temporary directory"); + + Ok(()) + } + + #[test] + #[should_panic] + fn resolve_path_user_path_not_existing() { + ProductConfigPath::resolve_path(Some(USER_PROVIDED_PATH.as_ref()), &[DEPLOY_FILE_PATH]) + .unwrap(); + } + + #[test] + fn resolve_path_nothing_found_errors() { + if let Err(Error::RequiredFileMissing { search_path }) = + ProductConfigPath::resolve_path(None, &[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") + } + } +} From ee44001748ba83c1de4ebe6c59f95546ffd78f26 Mon Sep 17 00:00:00 2001 From: Techassi Date: Thu, 18 Sep 2025 14:07:01 +0200 Subject: [PATCH 02/14] refactor!(stackable-operator): Gate KubernetesClusterInfoOptions' clap integration --- crates/stackable-operator/Cargo.toml | 5 +++-- crates/stackable-operator/src/utils/cluster_info.rs | 11 ++++++++--- crates/stackable-telemetry/src/tracing/mod.rs | 6 +++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/stackable-operator/Cargo.toml b/crates/stackable-operator/Cargo.toml index 3e5a52e74..387fd5b35 100644 --- a/crates/stackable-operator/Cargo.toml +++ b/crates/stackable-operator/Cargo.toml @@ -8,9 +8,10 @@ edition.workspace = true repository.workspace = true [features] -full = ["certs", "telemetry", "versioned", "time", "webhook"] -default = ["telemetry", "versioned"] +full = ["certs", "telemetry", "versioned", "time", "webhook", "clap"] +default = ["telemetry", "versioned", "clap"] +clap = [] certs = ["dep:stackable-certs"] telemetry = ["dep:stackable-telemetry"] time = ["stackable-shared/time"] diff --git a/crates/stackable-operator/src/utils/cluster_info.rs b/crates/stackable-operator/src/utils/cluster_info.rs index d8c64976b..a7d7a813f 100644 --- a/crates/stackable-operator/src/utils/cluster_info.rs +++ b/crates/stackable-operator/src/utils/cluster_info.rs @@ -15,11 +15,16 @@ pub struct KubernetesClusterInfo { pub cluster_domain: DomainName, } -#[derive(clap::Parser, Debug, PartialEq, Eq)] +#[cfg_attr( + feature = "clap", + derive(clap::Parser), + command(next_help_heading = "Cluster Options") +)] +#[derive(Debug, PartialEq, Eq)] pub struct KubernetesClusterInfoOptions { /// Kubernetes cluster domain, usually this is `cluster.local`. // We are not using a default value here, as we query the cluster if it is not specified. - #[arg(long, env)] + #[cfg_attr(feature = "clap", arg(long, env))] pub kubernetes_cluster_domain: Option, /// Name of the Kubernetes Node that the operator is running on. @@ -27,7 +32,7 @@ pub struct KubernetesClusterInfoOptions { /// Note that when running the operator on Kubernetes we recommend to use the /// [downward API](https://kubernetes.io/docs/concepts/workloads/pods/downward-api/) /// to let Kubernetes project the namespace as the `KUBERNETES_NODE_NAME` env variable. - #[arg(long, env)] + #[cfg_attr(feature = "clap", arg(long, env))] pub kubernetes_node_name: String, } diff --git a/crates/stackable-telemetry/src/tracing/mod.rs b/crates/stackable-telemetry/src/tracing/mod.rs index 499e11d95..96016ecf1 100644 --- a/crates/stackable-telemetry/src/tracing/mod.rs +++ b/crates/stackable-telemetry/src/tracing/mod.rs @@ -786,7 +786,11 @@ struct Cli { ``` "# )] -#[cfg_attr(feature = "clap", derive(clap::Args, PartialEq, Eq))] +#[cfg_attr( + feature = "clap", + derive(clap::Args, PartialEq, Eq), + command(next_help_heading = "Telemetry Options") +)] #[derive(Debug, Default)] pub struct TelemetryOptions { /// Disable console logs. From 9c893e0b4db6b8823744260685197ada2a1105a0 Mon Sep 17 00:00:00 2001 From: Techassi Date: Thu, 18 Sep 2025 14:08:48 +0200 Subject: [PATCH 03/14] feat(stackable-shared): Support Duration to std Duration conversion --- crates/stackable-shared/src/time/duration.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/stackable-shared/src/time/duration.rs b/crates/stackable-shared/src/time/duration.rs index f464d77b0..a5b2dcd15 100644 --- a/crates/stackable-shared/src/time/duration.rs +++ b/crates/stackable-shared/src/time/duration.rs @@ -220,6 +220,12 @@ impl From for Duration { } } +impl From for std::time::Duration { + fn from(value: Duration) -> Self { + value.0 + } +} + impl Add for Duration { type Output = Self; From 762f2b78418e4a22a247321e57d77b1101e78af6 Mon Sep 17 00:00:00 2001 From: Techassi Date: Thu, 18 Sep 2025 14:10:06 +0200 Subject: [PATCH 04/14] feat(stackable-operator): Add EoS checker --- crates/stackable-operator/src/eos/mod.rs | 125 +++++++++++++++++++++++ crates/stackable-operator/src/lib.rs | 1 + 2 files changed, 126 insertions(+) create mode 100644 crates/stackable-operator/src/eos/mod.rs diff --git a/crates/stackable-operator/src/eos/mod.rs b/crates/stackable-operator/src/eos/mod.rs new file mode 100644 index 000000000..1130d1728 --- /dev/null +++ b/crates/stackable-operator/src/eos/mod.rs @@ -0,0 +1,125 @@ +use chrono::{DateTime, Utc}; +use snafu::{ResultExt, Snafu}; +use stackable_shared::time::Duration; + +/// Available options to configure a [`EndOfSupportChecker`]. +/// +/// Additionally, this struct can be used as operator CLI arguments. This functionality is only +/// available if the feature `clap` is enabled. +#[cfg_attr(feature = "clap", derive(clap::Args))] +#[derive(Debug, PartialEq, Eq)] +pub struct EndOfSupportOptions { + /// The end-of-support check mode. Currently, only "offline" is supported. + #[cfg_attr(feature = "clap", arg( + long = "eos-check-mode", + env = "EOS_CHECK_MODE", + default_value_t = EndOfSupportCheckMode::default(), + value_enum + ))] + pub check_mode: EndOfSupportCheckMode, + + /// The interval in which the end-of-support check should run. + #[cfg_attr(feature = "clap", arg( + long = "eos-interval", + env = "EOS_INTERVAL", + default_value_t = Self::default_interval() + ))] + pub interval: Duration, + + /// The support duration (how long the operator should be considered supported after + /// it's built-date). + /// + /// This field is currently not exposed as a CLI argument or environment variable. + #[cfg_attr(feature = "clap", arg(skip = Self::default_support_duration()))] + pub support_duration: Duration, +} + +impl EndOfSupportOptions { + fn default_interval() -> Duration { + Duration::from_days_unchecked(1) + } + + fn default_support_duration() -> Duration { + Duration::from_days_unchecked(365) + } +} + +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum EndOfSupportCheckMode { + #[default] + Offline, +} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("failed to parse built-time"))] + ParseBuiltTime { source: chrono::ParseError }, +} + +pub struct EndOfSupportChecker { + datetime: DateTime, + interval: Duration, +} + +impl EndOfSupportChecker { + /// Creates and returns a new end-of-support checker. + /// + /// - The `built_time` string indicates when a specific operator was built. It is recommended + /// to use `built`'s `BUILT_TIME_UTC` constant. + /// - The `options` allow customizing the checker. It is recommended to use values provided by + /// CLI args, see [`EndOfSupportOptions`], [`MaintenanceOptions`](crate::cli::MaintenanceOptions), + /// and [`RunArguments`](crate::cli::RunArguments). + pub fn new(built_time: &str, options: EndOfSupportOptions) -> Result { + let EndOfSupportOptions { + interval, + support_duration, + .. + } = options; + + // Parse the built-time from the RFC2822-encoded string and add the support duration to it. + // This is datetime marks the end-of-support date. + let datetime = DateTime::parse_from_rfc2822(built_time) + .context(ParseBuiltTimeSnafu)? + .to_utc() + + *support_duration; + + Ok(Self { datetime, interval }) + } + + /// Run the end-of-support checker. + /// + /// It is recommended to run the end-of-support checker via [`futures::try_join!`]. + pub async fn run(self) { + // Construct an interval which can be polled. + let mut interval = tokio::time::interval(self.interval.into()); + + loop { + // TODO: Add way to stop from the outside + // The first tick ticks immediately. + interval.tick().await; + + // Continue the loop and wait for the next tick to run the check again. + if !self.is_eos() { + continue; + } + + self.emit_warning(); + } + } + + /// Emits the end-of-support warning. + fn emit_warning(&self) { + tracing::warn!( + eos.date = self.datetime.to_rfc3339(), + "the operator reached end-of-support" + ); + } + + /// Returns if the operator is considered as end-of-support based on the built-time and the + /// support duration. + fn is_eos(&self) -> bool { + let now = Utc::now(); + now > self.datetime + } +} diff --git a/crates/stackable-operator/src/lib.rs b/crates/stackable-operator/src/lib.rs index 2ad7e67ab..5e08ddcab 100644 --- a/crates/stackable-operator/src/lib.rs +++ b/crates/stackable-operator/src/lib.rs @@ -15,6 +15,7 @@ pub mod config; pub mod constants; pub mod cpu; pub mod crd; +pub mod eos; pub mod helm; pub mod iter; pub mod kvp; From ce8c53bd42d7edaa3a4d386b840d43018b023454 Mon Sep 17 00:00:00 2001 From: Techassi Date: Fri, 19 Sep 2025 09:58:54 +0200 Subject: [PATCH 05/14] docs: Remove outdated and long winded doc comments --- crates/stackable-operator/src/cli/mod.rs | 111 +---------------------- 1 file changed, 3 insertions(+), 108 deletions(-) diff --git a/crates/stackable-operator/src/cli/mod.rs b/crates/stackable-operator/src/cli/mod.rs index 3ea461d82..501ea68eb 100644 --- a/crates/stackable-operator/src/cli/mod.rs +++ b/crates/stackable-operator/src/cli/mod.rs @@ -1,111 +1,6 @@ -//! This module provides helper methods to deal with common CLI options using the `clap` crate. -//! -//! In particular it currently supports handling two kinds of options: -//! * CRD printing -//! * Product config location -//! -//! # Example -//! -//! This example show the usage of the CRD functionality. -//! -//! ```no_run -//! // Handle CLI arguments -//! use clap::{crate_version, Parser}; -//! use kube::CustomResource; -//! use schemars::JsonSchema; -//! use serde::{Deserialize, Serialize}; -//! use stackable_operator::{CustomResourceExt, cli, shared::crd}; -//! -//! const OPERATOR_VERSION: &str = "23.1.1"; -//! -//! #[derive(Clone, CustomResource, Debug, JsonSchema, Serialize, Deserialize)] -//! #[kube( -//! group = "foo.stackable.tech", -//! version = "v1", -//! kind = "FooCluster", -//! namespaced -//! )] -//! pub struct FooClusterSpec { -//! pub name: String, -//! } -//! -//! #[derive(Clone, CustomResource, Debug, JsonSchema, Serialize, Deserialize)] -//! #[kube( -//! group = "bar.stackable.tech", -//! version = "v1", -//! kind = "BarCluster", -//! namespaced -//! )] -//! pub struct BarClusterSpec { -//! pub name: String, -//! } -//! -//! #[derive(clap::Parser)] -//! #[command( -//! name = "Foobar Operator", -//! author, -//! version, -//! about = "Stackable Operator for Foobar" -//! )] -//! struct Opts { -//! #[clap(subcommand)] -//! command: cli::Command, -//! } -//! -//! # fn main() -> Result<(), crd::Error> { -//! let opts = Opts::parse(); -//! -//! match opts.command { -//! cli::Command::Crd => { -//! FooCluster::print_yaml_schema(OPERATOR_VERSION)?; -//! BarCluster::print_yaml_schema(OPERATOR_VERSION)?; -//! }, -//! cli::Command::Run { .. } => { -//! // Run the operator -//! } -//! } -//! # Ok(()) -//! # } -//! ``` -//! -//! Product config handling works similarly: -//! -//! ```no_run -//! use clap::{crate_version, Parser}; -//! use stackable_operator::cli; -//! -//! #[derive(clap::Parser)] -//! #[command( -//! name = "Foobar Operator", -//! author, -//! version, -//! about = "Stackable Operator for Foobar" -//! )] -//! struct Opts { -//! #[clap(subcommand)] -//! command: cli::Command, -//! } -//! -//! # fn main() -> Result<(), cli::Error> { -//! let opts = Opts::parse(); -//! -//! match opts.command { -//! cli::Command::Crd => { -//! // Print CRD objects -//! } -//! cli::Command::Run(cli::ProductOperatorRun { product_config, watch_namespace, .. }) => { -//! let product_config = product_config.load(&[ -//! "deploy/config-spec/properties.yaml", -//! "/etc/stackable/spark-operator/config-spec/properties.yaml", -//! ])?; -//! } -//! } -//! # Ok(()) -//! # } -//! -//! ``` -//! -//! +//! Contains various types for composing the CLI interface for operators and other applications +//! running in a Kubernetes cluster. + use clap::{Args, Parser}; use stackable_telemetry::tracing::TelemetryOptions; From 6135a6cbb5b21251fef066bda647dd02404556bd Mon Sep 17 00:00:00 2001 From: Techassi Date: Fri, 19 Sep 2025 10:02:11 +0200 Subject: [PATCH 06/14] test: Fix various doc tests --- crates/stackable-operator/src/cli/mod.rs | 2 +- crates/stackable-webhook/src/servers/conversion.rs | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/stackable-operator/src/cli/mod.rs b/crates/stackable-operator/src/cli/mod.rs index 501ea68eb..0d09880f0 100644 --- a/crates/stackable-operator/src/cli/mod.rs +++ b/crates/stackable-operator/src/cli/mod.rs @@ -43,7 +43,7 @@ pub const AUTHOR: &str = "Stackable GmbH - info@stackable.tech"; /// use clap::Parser; /// /// #[derive(Parser)] -/// enum Command { +/// enum CustomCommand { /// /// Print hello world message /// Hello, /// diff --git a/crates/stackable-webhook/src/servers/conversion.rs b/crates/stackable-webhook/src/servers/conversion.rs index baa79c241..ca83f6078 100644 --- a/crates/stackable-webhook/src/servers/conversion.rs +++ b/crates/stackable-webhook/src/servers/conversion.rs @@ -126,18 +126,21 @@ impl ConversionWebhookServer { /// use stackable_operator::{ /// kube::Client, /// crd::s3::{S3Connection, S3ConnectionVersion}, - /// cli::ProductOperatorRun, + /// cli::{RunArguments, MaintenanceOptions}, /// }; /// /// # async fn test() { /// // Things that should already be in you operator: /// const OPERATOR_NAME: &str = "product-operator"; /// let client = Client::try_default().await.expect("failed to create Kubernetes client"); - /// let ProductOperatorRun { + /// let RunArguments { /// operator_environment, - /// disable_crd_maintenance, + /// maintenance: MaintenanceOptions { + /// disable_crd_maintenance, + /// .. + /// }, /// .. - /// } = ProductOperatorRun::parse(); + /// } = RunArguments::parse(); /// /// let crds_and_handlers = [ /// ( From 3febb8832697c9ad210dbdb1bedbfde93a7d7fe5 Mon Sep 17 00:00:00 2001 From: Techassi Date: Fri, 19 Sep 2025 10:02:52 +0200 Subject: [PATCH 07/14] feat(stackable-operator): Default EoS interval based on debug/release build --- crates/stackable-operator/src/eos/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/stackable-operator/src/eos/mod.rs b/crates/stackable-operator/src/eos/mod.rs index 1130d1728..212d2fc01 100644 --- a/crates/stackable-operator/src/eos/mod.rs +++ b/crates/stackable-operator/src/eos/mod.rs @@ -36,7 +36,11 @@ pub struct EndOfSupportOptions { impl EndOfSupportOptions { fn default_interval() -> Duration { - Duration::from_days_unchecked(1) + if cfg!(debug_assertions) { + Duration::from_secs(30) + } else { + Duration::from_days_unchecked(1) + } } fn default_support_duration() -> Duration { From b3ee52da76f1121871bd4089cf1b1acc5593ed75 Mon Sep 17 00:00:00 2001 From: Techassi Date: Fri, 19 Sep 2025 11:17:26 +0200 Subject: [PATCH 08/14] docs(stackable-operator): Add more detail on how to run EoS checker --- crates/stackable-operator/src/eos/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/stackable-operator/src/eos/mod.rs b/crates/stackable-operator/src/eos/mod.rs index 212d2fc01..ee0af20e1 100644 --- a/crates/stackable-operator/src/eos/mod.rs +++ b/crates/stackable-operator/src/eos/mod.rs @@ -93,7 +93,8 @@ impl EndOfSupportChecker { /// Run the end-of-support checker. /// - /// It is recommended to run the end-of-support checker via [`futures::try_join!`]. + /// It is recommended to run the end-of-support checker via [`futures::try_join!`] or + /// [`tokio::join`] alongside other futures (eg. for controllers). pub async fn run(self) { // Construct an interval which can be polled. let mut interval = tokio::time::interval(self.interval.into()); From 16d26edbe7b4d82b12ecd565af5a51b9fd927a73 Mon Sep 17 00:00:00 2001 From: Techassi Date: Fri, 19 Sep 2025 11:18:10 +0200 Subject: [PATCH 09/14] feat(stackable-operator): Add instrumentation to EoS checker --- crates/stackable-operator/src/eos/mod.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/stackable-operator/src/eos/mod.rs b/crates/stackable-operator/src/eos/mod.rs index ee0af20e1..3b63584c8 100644 --- a/crates/stackable-operator/src/eos/mod.rs +++ b/crates/stackable-operator/src/eos/mod.rs @@ -1,6 +1,7 @@ use chrono::{DateTime, Utc}; use snafu::{ResultExt, Snafu}; use stackable_shared::time::Duration; +use tracing::{Level, instrument}; /// Available options to configure a [`EndOfSupportChecker`]. /// @@ -103,6 +104,10 @@ impl EndOfSupportChecker { // TODO: Add way to stop from the outside // The first tick ticks immediately. interval.tick().await; + tracing::info_span!( + "checking end-of-support state", + eos.interval = self.interval.to_string(), + ); // Continue the loop and wait for the next tick to run the check again. if !self.is_eos() { @@ -114,6 +119,7 @@ impl EndOfSupportChecker { } /// Emits the end-of-support warning. + #[instrument(level = Level::DEBUG, skip(self))] fn emit_warning(&self) { tracing::warn!( eos.date = self.datetime.to_rfc3339(), @@ -123,8 +129,12 @@ impl EndOfSupportChecker { /// Returns if the operator is considered as end-of-support based on the built-time and the /// support duration. + #[instrument(level = Level::DEBUG, skip(self), fields(eos.now))] fn is_eos(&self) -> bool { let now = Utc::now(); + + tracing::Span::current().record("eos.now", now.to_rfc3339()); + now > self.datetime } } From 62cd791105e00c228b02ea9147c58c5bfc4fb452 Mon Sep 17 00:00:00 2001 From: Techassi Date: Fri, 19 Sep 2025 11:37:46 +0200 Subject: [PATCH 10/14] chore(stackable-operator): Add changelog entry --- crates/stackable-operator/CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index d54ef2860..158aeed2d 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -6,14 +6,26 @@ All notable changes to this project will be documented in this file. ### Added +- Add end-of-support checker ([#1096]). + - The EoS checker can be constructed using `EndOfSupportChecker::new()`. + - Add new `MaintenanceOptions` and `EndOfSupportOptions` structs. + - Add new CLI arguments and env vars: + - `EOS_CHECK_MODE` (`--eos-check-mode`) to set the EoS check mode. Currently, only "offline" is supported. + - `EOS_INTERVAL` (`--eos-interval`) to set the interval in which the operator checks if it is EoS. - Extend `ObjectMetaBuilder` with `finalizers` ([#1094]). ### Changed +- BREAKING: `ProductOperatorRun` was renamed to `RunArguments` ([#1096]). +- BREAKING: The `disable_crd_maintenance` field was moved from `RunArguments` into `MaintenanceOptions`. + The CLI interface is unchanged ([#1096]). +- BREAKING: Integration of `KubernetesClusterInfoOptions` with `clap` is now gated behind the `clap` feature flag. + This is only breaking if default features for `stackable-operator` are disabled ([#1096]). - BREAKING: Upgrade to `schemars` 1.0, `kube` 2.0 and `k8s-openapi` 0.26 (using Kubernetes 1.34) ([#1091]). [#1091]: https://github.com/stackabletech/operator-rs/pull/1091 [#1094]: https://github.com/stackabletech/operator-rs/pull/1094 +[#1096]: https://github.com/stackabletech/operator-rs/pull/1096 ## [0.97.0] - 2025-09-09 From 66931d5d8f6d49ecb1bd771c85c680079a1758b8 Mon Sep 17 00:00:00 2001 From: Techassi Date: Mon, 22 Sep 2025 10:23:41 +0200 Subject: [PATCH 11/14] chore: Apply suggestion Co-authored-by: Nick <10092581+NickLarsenNZ@users.noreply.github.com> --- crates/stackable-operator/src/cli/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/stackable-operator/src/cli/mod.rs b/crates/stackable-operator/src/cli/mod.rs index 0d09880f0..170272aca 100644 --- a/crates/stackable-operator/src/cli/mod.rs +++ b/crates/stackable-operator/src/cli/mod.rs @@ -123,7 +123,7 @@ mod tests { fn verify_cli() { use clap::CommandFactory; - RunArguments::command().print_long_help().unwrap(); + RunArguments::command().print_long_help().expect("help message should be printed to stdout"); RunArguments::command().debug_assert() } } From 0e8787f442cb00bf85dace151c330603444f9b9a Mon Sep 17 00:00:00 2001 From: Techassi Date: Mon, 22 Sep 2025 10:30:53 +0200 Subject: [PATCH 12/14] chore: Apply formatting to previous code suggestion --- crates/stackable-operator/src/cli/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/stackable-operator/src/cli/mod.rs b/crates/stackable-operator/src/cli/mod.rs index 170272aca..f11a37422 100644 --- a/crates/stackable-operator/src/cli/mod.rs +++ b/crates/stackable-operator/src/cli/mod.rs @@ -123,7 +123,9 @@ mod tests { fn verify_cli() { use clap::CommandFactory; - RunArguments::command().print_long_help().expect("help message should be printed to stdout"); + RunArguments::command() + .print_long_help() + .expect("help message should be printed to stdout"); RunArguments::command().debug_assert() } } From db294c0f5fc25693c00239ed8affdab7ddfcc3e6 Mon Sep 17 00:00:00 2001 From: Techassi Date: Mon, 22 Sep 2025 10:32:43 +0200 Subject: [PATCH 13/14] ci(fix): Install Rust toolchain for cargo-deny --- .github/workflows/build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 57be15607..b89455e99 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,6 +60,9 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} - uses: EmbarkStudios/cargo-deny-action@30f817c6f72275c6d54dc744fbca09ebc958599f # v2.0.12 with: command: check ${{ matrix.checks }} From 520515aee663b195c71248843fba8545fb5a99f0 Mon Sep 17 00:00:00 2001 From: Techassi Date: Mon, 22 Sep 2025 10:45:13 +0200 Subject: [PATCH 14/14] chore: Adjust changelog --- crates/stackable-operator/CHANGELOG.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 2660438b5..2a3543651 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -4,8 +4,6 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -## [0.98.0] - 2025-09-22 - ### Added - Add end-of-support checker ([#1096]). @@ -14,7 +12,6 @@ All notable changes to this project will be documented in this file. - Add new CLI arguments and env vars: - `EOS_CHECK_MODE` (`--eos-check-mode`) to set the EoS check mode. Currently, only "offline" is supported. - `EOS_INTERVAL` (`--eos-interval`) to set the interval in which the operator checks if it is EoS. -- Extend `ObjectMetaBuilder` with `finalizers` ([#1094]). ### Changed @@ -23,6 +20,17 @@ All notable changes to this project will be documented in this file. The CLI interface is unchanged ([#1096]). - BREAKING: Integration of `KubernetesClusterInfoOptions` with `clap` is now gated behind the `clap` feature flag. This is only breaking if default features for `stackable-operator` are disabled ([#1096]). + +[#1096]: https://github.com/stackabletech/operator-rs/pull/1096 + +## [0.98.0] - 2025-09-22 + +### Added + +- Extend `ObjectMetaBuilder` with `finalizers` ([#1094]). + +### Changed + - BREAKING: Upgrade to `schemars` 1.0, `kube` 2.0 and `k8s-openapi` 0.26 (using Kubernetes 1.34) ([#1091]). ### Fixed @@ -32,7 +40,6 @@ All notable changes to this project will be documented in this file. [#1091]: https://github.com/stackabletech/operator-rs/pull/1091 [#1094]: https://github.com/stackabletech/operator-rs/pull/1094 [#1095]: https://github.com/stackabletech/operator-rs/pull/1095 -[#1096]: https://github.com/stackabletech/operator-rs/pull/1096 ## [0.97.0] - 2025-09-09