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 }} diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 315b40d32..2a3543651 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### 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. + +### 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]). + +[#1096]: https://github.com/stackabletech/operator-rs/pull/1096 + ## [0.98.0] - 2025-09-22 ### Added diff --git a/crates/stackable-operator/Cargo.toml b/crates/stackable-operator/Cargo.toml index 757cb52e0..ca3b68a50 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/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..f11a37422 --- /dev/null +++ b/crates/stackable-operator/src/cli/mod.rs @@ -0,0 +1,131 @@ +//! 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; + +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 CustomCommand { +/// /// 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() + .expect("help message should be printed to stdout"); + 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") + } + } +} diff --git a/crates/stackable-operator/src/eos/mod.rs b/crates/stackable-operator/src/eos/mod.rs new file mode 100644 index 000000000..3b63584c8 --- /dev/null +++ b/crates/stackable-operator/src/eos/mod.rs @@ -0,0 +1,140 @@ +use chrono::{DateTime, Utc}; +use snafu::{ResultExt, Snafu}; +use stackable_shared::time::Duration; +use tracing::{Level, instrument}; + +/// 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 { + if cfg!(debug_assertions) { + Duration::from_secs(30) + } else { + 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!`] 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()); + + loop { + // 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() { + continue; + } + + self.emit_warning(); + } + } + + /// Emits the end-of-support warning. + #[instrument(level = Level::DEBUG, skip(self))] + 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. + #[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 + } +} 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; 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-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; 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. 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 = [ /// (