diff --git a/Cargo.lock b/Cargo.lock index 8bc558c3..b991f326 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3438,6 +3438,7 @@ name = "stackable-cockpit" version = "0.0.0-dev" dependencies = [ "bcrypt", + "clap", "futures", "helm-sys", "indexmap 2.11.4", diff --git a/rust/stackable-cockpit/Cargo.toml b/rust/stackable-cockpit/Cargo.toml index 1decc06f..dcd0c3eb 100644 --- a/rust/stackable-cockpit/Cargo.toml +++ b/rust/stackable-cockpit/Cargo.toml @@ -16,6 +16,7 @@ openapi = ["dep:utoipa"] helm-sys = { path = "../helm-sys" } bcrypt.workspace = true +clap.workspace = true indexmap.workspace = true k8s-openapi.workspace = true kube.workspace = true diff --git a/rust/stackable-cockpit/src/platform/operator/listener_operator.rs b/rust/stackable-cockpit/src/platform/operator/listener_operator.rs new file mode 100644 index 00000000..81cec862 --- /dev/null +++ b/rust/stackable-cockpit/src/platform/operator/listener_operator.rs @@ -0,0 +1,105 @@ +use clap::ValueEnum; +use snafu::ResultExt; +use stackable_operator::{ + k8s_openapi::api::core::v1::Node, + kube::{Api, Client, api::ListParams}, +}; +use tokio::sync::OnceCell; +use tracing::{debug, info, instrument}; + +pub static LISTENER_CLASS_PRESET: OnceCell = OnceCell::const_new(); + +/// Represents the `preset` value in the Listener Operator Helm Chart +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum ListenerClassPreset { + None, + StableNodes, + EphemeralNodes, +} + +impl ListenerClassPreset { + pub fn as_helm_values(&self) -> String { + let preset_value = match self { + Self::None => "none", + Self::StableNodes => "stable-nodes", + Self::EphemeralNodes => "ephemeral-nodes", + }; + format!("preset: {preset_value}") + } +} + +#[instrument] +pub async fn determine_and_store_listener_class_preset(from_cli: Option<&ListenerClassPreset>) { + if let Some(from_cli) = from_cli { + LISTENER_CLASS_PRESET + .set(*from_cli) + .expect("LISTENER_CLASS_PRESET should be unset"); + return; + } + + let kubernetes_environment = guess_kubernetes_environment().await.unwrap_or_else(|err| { + info!("failed to determine Kubernetes environment, using defaults: {err:#?}"); + KubernetesEnvironment::Unknown + }); + let listener_class_preset = match kubernetes_environment { + // Kind does not support LoadBalancers out of the box, so avoid that + KubernetesEnvironment::Kind => ListenerClassPreset::StableNodes, + // LoadBalancer support in k3s is optional, so let's be better safe than sorry and not use + // them + KubernetesEnvironment::K3s => ListenerClassPreset::StableNodes, + // Weekly node rotations and LoadBalancer support + KubernetesEnvironment::Ionos => ListenerClassPreset::EphemeralNodes, + // Don't pin nodes and assume we have LoadBalancer support + KubernetesEnvironment::Unknown => ListenerClassPreset::EphemeralNodes, + }; + debug!( + preset = ?listener_class_preset, + kubernetes.environment = ?kubernetes_environment, + "Using ListenerClass preset" + ); + + LISTENER_CLASS_PRESET + .set(listener_class_preset) + .expect("LISTENER_CLASS_PRESET should be unset"); +} + +#[derive(Debug)] +enum KubernetesEnvironment { + Kind, + K3s, + Ionos, + Unknown, +} + +/// Tries to guess what Kubernetes environment stackablectl is connecting to. +/// +/// Returns an error in case anything goes wrong. This could e.g. be the case in case no +/// Kubernetes context is configured, stackablectl is missing RBAC permission to retrieve nodes or +/// simply a network error. +#[instrument] +async fn guess_kubernetes_environment() -> Result { + let client = Client::try_default() + .await + .whatever_context("failed to construct Kubernetes client")?; + let node_api: Api = Api::all(client); + let nodes = node_api + .list(&ListParams::default()) + .await + .whatever_context("failed to list Kubernetes nodes")?; + + for node in nodes { + if let Some(spec) = node.spec { + if let Some(provider_id) = spec.provider_id { + if provider_id.starts_with("kind://") { + return Ok(KubernetesEnvironment::Kind); + } else if provider_id.starts_with("k3s://") { + return Ok(KubernetesEnvironment::K3s); + } else if provider_id.starts_with("ionos://") { + return Ok(KubernetesEnvironment::Ionos); + } + } + } + } + + Ok(KubernetesEnvironment::Unknown) +} diff --git a/rust/stackable-cockpit/src/platform/operator/mod.rs b/rust/stackable-cockpit/src/platform/operator/mod.rs index 540616ed..1e7dc64b 100644 --- a/rust/stackable-cockpit/src/platform/operator/mod.rs +++ b/rust/stackable-cockpit/src/platform/operator/mod.rs @@ -1,5 +1,6 @@ use std::{fmt::Display, str::FromStr}; +use listener_operator::LISTENER_CLASS_PRESET; use semver::Version; use serde::Serialize; use snafu::{ResultExt, Snafu, ensure}; @@ -14,6 +15,8 @@ use crate::{ utils::operator_chart_name, }; +pub mod listener_operator; + pub const VALID_OPERATORS: &[&str] = &[ "airflow", "commons", @@ -93,10 +96,9 @@ impl FromStr for OperatorSpec { ensure!(len <= 2, InvalidEqualSignCountSnafu); // Check if the provided operator name is in the list of valid operators - ensure!( - VALID_OPERATORS.contains(&parts[0]), - InvalidNameSnafu { name: parts[0] } - ); + ensure!(VALID_OPERATORS.contains(&parts[0]), InvalidNameSnafu { + name: parts[0] + }); // If there is only one part, the input didn't include // the optional version identifier @@ -208,6 +210,15 @@ impl OperatorSpec { ChartSourceType::Repo => self.helm_repo_name(), }; + let mut helm_values = None; + if self.name == "listener" { + helm_values = Some( + LISTENER_CLASS_PRESET.get() + .expect("At this point LISTENER_CLASS_PRESET must be set by determine_and_store_listener_class_preset") + .as_helm_values() + ); + }; + // Install using Helm helm::install_release_from_repo_or_registry( &helm_name, @@ -216,7 +227,7 @@ impl OperatorSpec { chart_name: &helm_name, chart_source: &chart_source, }, - None, + helm_values.as_deref(), namespace, true, )?; diff --git a/rust/stackablectl/CHANGELOG.md b/rust/stackablectl/CHANGELOG.md index 356d88a8..d095589c 100644 --- a/rust/stackablectl/CHANGELOG.md +++ b/rust/stackablectl/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Automatically detect Kubernetes environment (e.g. kind, k3s or IONOS) and choose a sensible [ListenerClass preset] by default ([#414]). +- Support configuring the [ListenerClass preset] using `--listener-class-preset` ([#414]). + +[#414]: https://github.com/stackabletech/stackable-cockpit/pull/414 +[ListenerClass preset]: https://docs.stackable.tech/home/nightly/listener-operator/listenerclass/#presets + ## [1.1.0] - 2025-07-16 ### Added diff --git a/rust/stackablectl/src/args/mod.rs b/rust/stackablectl/src/args/mod.rs index e6467ff8..cb31577f 100644 --- a/rust/stackablectl/src/args/mod.rs +++ b/rust/stackablectl/src/args/mod.rs @@ -1,9 +1,11 @@ mod cluster; mod file; mod namespace; +mod operator_configs; mod repo; pub use cluster::*; pub use file::*; pub use namespace::*; +pub use operator_configs::*; pub use repo::*; diff --git a/rust/stackablectl/src/args/operator_configs.rs b/rust/stackablectl/src/args/operator_configs.rs new file mode 100644 index 00000000..f0bbbef4 --- /dev/null +++ b/rust/stackablectl/src/args/operator_configs.rs @@ -0,0 +1,14 @@ +use clap::Args; +use stackable_cockpit::platform::operator::listener_operator::ListenerClassPreset; + +#[derive(Debug, Args)] +#[command(next_help_heading = "Operator specific configurations")] +pub struct CommonOperatorConfigsArgs { + /// Choose the ListenerClass preset (`none`, `ephemeral-nodes` or `stable-nodes`). + /// + /// This maps to the listener-operator Helm Chart preset value, see + /// [the listener-operator documentation](https://docs.stackable.tech/home/nightly/listener-operator/listenerclass/#presets) + /// for details. + #[arg(long, global = true)] + pub listener_class_preset: Option, +} diff --git a/rust/stackablectl/src/cli/mod.rs b/rust/stackablectl/src/cli/mod.rs index a5a3d53f..bad3bdd8 100644 --- a/rust/stackablectl/src/cli/mod.rs +++ b/rust/stackablectl/src/cli/mod.rs @@ -6,7 +6,9 @@ use snafu::{ResultExt, Snafu}; use stackable_cockpit::{ constants::{HELM_REPO_NAME_DEV, HELM_REPO_NAME_STABLE, HELM_REPO_NAME_TEST}, helm, - platform::operator::ChartSourceType, + platform::operator::{ + ChartSourceType, listener_operator::determine_and_store_listener_class_preset, + }, utils::path::{ IntoPathOrUrl, IntoPathsOrUrls, ParsePathsOrUrls, PathOrUrl, PathOrUrlParseError, }, @@ -15,7 +17,7 @@ use stackable_cockpit::{ use tracing::{Level, instrument}; use crate::{ - args::{CommonFileArgs, CommonRepoArgs}, + args::{CommonFileArgs, CommonOperatorConfigsArgs, CommonRepoArgs}, cmds::{cache, completions, debug, demo, operator, release, stack, stacklet}, constants::{ DEMOS_REPOSITORY_DEMOS_SUBPATH, DEMOS_REPOSITORY_STACKS_SUBPATH, DEMOS_REPOSITORY_URL_BASE, @@ -79,6 +81,9 @@ Cached files are saved at '$XDG_CACHE_HOME/stackablectl', which is usually #[command(flatten)] pub repos: CommonRepoArgs, + #[command(flatten)] + pub operator_configs: CommonOperatorConfigsArgs, + #[command(subcommand)] pub subcommand: Commands, } @@ -186,6 +191,11 @@ impl Cli { // TODO (Techassi): Do we still want to auto purge when running cache commands? cache.auto_purge().await.unwrap(); + determine_and_store_listener_class_preset( + self.operator_configs.listener_class_preset.as_ref(), + ) + .await; + match &self.subcommand { Commands::Operator(args) => args.run(self).await.context(OperatorSnafu), Commands::Release(args) => args.run(self, cache).await.context(ReleaseSnafu),