From 4b8eb51e8ca4778ca3e3330969fee9a0f2917e89 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 9 Apr 2026 14:04:36 +0200 Subject: [PATCH 1/8] chore!: Change Scaler.status.state (and others) to accept PascalCase variants --- .../stackable-operator/src/crd/scaler/mod.rs | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/crates/stackable-operator/src/crd/scaler/mod.rs b/crates/stackable-operator/src/crd/scaler/mod.rs index ecc730907..3722dfe9e 100644 --- a/crates/stackable-operator/src/crd/scaler/mod.rs +++ b/crates/stackable-operator/src/crd/scaler/mod.rs @@ -21,7 +21,7 @@ pub mod versioned { ), namespaced ))] - #[derive(Clone, Debug, PartialEq, CustomResource, Deserialize, Serialize, JsonSchema)] + #[derive(Clone, Debug, PartialEq, Eq, CustomResource, Deserialize, Serialize, JsonSchema)] pub struct ScalerSpec { /// Desired replica count. /// @@ -40,7 +40,7 @@ pub mod versioned { } /// Status of a StackableScaler. -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ScalerStatus { /// The current total number of replicas targeted by the managed StatefulSet. @@ -64,14 +64,14 @@ pub struct ScalerStatus { // and others to be typed as objects. We therefore encode the variant data in a separate details // key/object. With this, all variants can be encoded as strings, while the status can still contain // additional data in an extra field when needed. -#[derive(Clone, Debug, Deserialize, Serialize, strum::Display)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, strum::Display)] #[serde( tag = "state", content = "details", - rename_all = "camelCase", + rename_all = "PascalCase", rename_all_fields = "camelCase" )] -#[strum(serialize_all = "camelCase")] +#[strum(serialize_all = "PascalCase")] pub enum ScalerState { /// No scaling operation is in progress. Idle, @@ -140,8 +140,8 @@ impl JsonSchema for ScalerState { } /// In which state the scaling operation failed. -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "PascalCase")] pub enum FailedInState { /// The `pre_scale` hook returned an error. PreScaling, @@ -152,3 +152,51 @@ pub enum FailedInState { /// The `post_scale` hook returned an error. PostScaling, } + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::utils::yaml_from_str_singleton_map; + + #[rstest] + #[case::idle("state: Idle", ScalerState::Idle { })] + #[case::pre_scaling("state: PreScaling", ScalerState::PreScaling { })] + #[case::scaling("state: Scaling +details: + previousReplicas: 42", ScalerState::Scaling { previous_replicas: 42 })] + #[case::post_scaling("state: PostScaling +details: + previousReplicas: 42", ScalerState::PostScaling { previous_replicas: 42 })] + #[case::failed("state: Failed +details: + failedIn: PreScaling + reason: bruh moment", ScalerState::Failed { + failed_in: FailedInState::PreScaling, + reason: "bruh moment".to_owned() + } )] + fn parse_state(#[case] input: &str, #[case] expected: ScalerState) { + let parsed: ScalerState = + yaml_from_str_singleton_map(input).expect("invalid test YAML input"); + assert_eq!(parsed, expected); + } + + #[rstest] + #[case::idle(ScalerState::Idle { }, "state: Idle\n")] + #[case::pre_scaling(ScalerState::PreScaling { }, "state: PreScaling\n")] + #[case::scaling(ScalerState::Scaling { previous_replicas: 42 }, "state: Scaling +details: + previousReplicas: 42\n")] + #[case::post_scaling(ScalerState::PostScaling { previous_replicas: 42 }, "state: PostScaling +details: + previousReplicas: 42\n")] + #[case::failed(ScalerState::Failed { failed_in: FailedInState::PreScaling, reason: "bruh moment".to_owned() }, "state: Failed +details: + failedIn: PreScaling + reason: bruh moment\n")] + fn serialize_state(#[case] input: ScalerState, #[case] expected: &str) { + let serialized = serde_yaml::to_string(&input).expect("serialization always passes"); + assert_eq!(serialized, expected); + } +} From 638887a99d66d2df336b8cb568734a6ec57332cf Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 9 Apr 2026 14:43:59 +0200 Subject: [PATCH 2/8] Rework Scaler status to complex enum --- .../stackable-operator/src/crd/scaler/mod.rs | 86 +++++-------------- crates/stackable-operator/src/lib.rs | 1 + crates/stackable-operator/src/test_utils.rs | 17 ++++ 3 files changed, 39 insertions(+), 65 deletions(-) create mode 100644 crates/stackable-operator/src/test_utils.rs diff --git a/crates/stackable-operator/src/crd/scaler/mod.rs b/crates/stackable-operator/src/crd/scaler/mod.rs index 3722dfe9e..68299e916 100644 --- a/crates/stackable-operator/src/crd/scaler/mod.rs +++ b/crates/stackable-operator/src/crd/scaler/mod.rs @@ -1,5 +1,3 @@ -use std::borrow::Cow; - use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; use kube::CustomResource; use schemars::JsonSchema; @@ -64,20 +62,15 @@ pub struct ScalerStatus { // and others to be typed as objects. We therefore encode the variant data in a separate details // key/object. With this, all variants can be encoded as strings, while the status can still contain // additional data in an extra field when needed. -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, strum::Display)] -#[serde( - tag = "state", - content = "details", - rename_all = "PascalCase", - rename_all_fields = "camelCase" -)] -#[strum(serialize_all = "PascalCase")] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema, strum::Display)] +#[serde(rename_all = "camelCase", rename_all_fields = "camelCase")] +#[strum(serialize_all = "camelCase")] pub enum ScalerState { /// No scaling operation is in progress. - Idle, + Idle {}, /// Running the `pre_scale` hook (e.g. data offload). - PreScaling, + PreScaling {}, /// Waiting for the StatefulSet to converge to the new replica count. /// @@ -104,41 +97,6 @@ pub enum ScalerState { }, } -// We manually implement the JSON schema instead of deriving it, because kube's schema transformer -// cannot handle the derived JsonSchema and proceeds to hit the following error: "Property "state" -// has the schema ... but was already defined as ... in another subschema. The schemas for a -// property used in multiple subschemas must be identical". -impl JsonSchema for ScalerState { - fn schema_name() -> Cow<'static, str> { - "ScalerState".into() - } - - fn json_schema(generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { - schemars::json_schema!({ - "type": "object", - "required": ["state"], - "properties": { - "state": { - "type": "string", - "enum": ["idle", "preScaling", "scaling", "postScaling", "failed"] - }, - "details": { - "type": "object", - "properties": { - "failedIn": generator.subschema_for::(), - "previous_replicas": { - "type": "uint16", - "minimum": u16::MIN, - "maximum": u16::MAX - }, - "reason": { "type": "string" } - } - } - } - }) - } -} - /// In which state the scaling operation failed. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "PascalCase")] @@ -158,19 +116,18 @@ mod tests { use rstest::rstest; use super::*; - use crate::utils::yaml_from_str_singleton_map; + use crate::{ + test_utils::serialize_to_yaml_with_singleton_map, utils::yaml_from_str_singleton_map, + }; #[rstest] - #[case::idle("state: Idle", ScalerState::Idle { })] - #[case::pre_scaling("state: PreScaling", ScalerState::PreScaling { })] - #[case::scaling("state: Scaling -details: + #[case::idle("idle: {}", ScalerState::Idle { })] + #[case::pre_scaling("preScaling: {}", ScalerState::PreScaling { })] + #[case::scaling("scaling: previousReplicas: 42", ScalerState::Scaling { previous_replicas: 42 })] - #[case::post_scaling("state: PostScaling -details: + #[case::post_scaling("postScaling: previousReplicas: 42", ScalerState::PostScaling { previous_replicas: 42 })] - #[case::failed("state: Failed -details: + #[case::failed("failed: failedIn: PreScaling reason: bruh moment", ScalerState::Failed { failed_in: FailedInState::PreScaling, @@ -183,20 +140,19 @@ details: } #[rstest] - #[case::idle(ScalerState::Idle { }, "state: Idle\n")] - #[case::pre_scaling(ScalerState::PreScaling { }, "state: PreScaling\n")] - #[case::scaling(ScalerState::Scaling { previous_replicas: 42 }, "state: Scaling -details: + #[case::idle(ScalerState::Idle { }, "idle: {}\n")] + #[case::pre_scaling(ScalerState::PreScaling { }, "preScaling: {}\n")] + #[case::scaling(ScalerState::Scaling { previous_replicas: 42 }, "scaling: previousReplicas: 42\n")] - #[case::post_scaling(ScalerState::PostScaling { previous_replicas: 42 }, "state: PostScaling -details: + #[case::post_scaling(ScalerState::PostScaling { previous_replicas: 42 }, "postScaling: previousReplicas: 42\n")] - #[case::failed(ScalerState::Failed { failed_in: FailedInState::PreScaling, reason: "bruh moment".to_owned() }, "state: Failed -details: + #[case::failed(ScalerState::Failed { failed_in: FailedInState::PreScaling, reason: "bruh moment".to_owned() }, "failed: failedIn: PreScaling reason: bruh moment\n")] fn serialize_state(#[case] input: ScalerState, #[case] expected: &str) { - let serialized = serde_yaml::to_string(&input).expect("serialization always passes"); + let serialized = + serialize_to_yaml_with_singleton_map(&input).expect("serialization always passes"); + assert_eq!(serialized, expected); } } diff --git a/crates/stackable-operator/src/lib.rs b/crates/stackable-operator/src/lib.rs index 4b8292adc..bacfa3e9e 100644 --- a/crates/stackable-operator/src/lib.rs +++ b/crates/stackable-operator/src/lib.rs @@ -31,6 +31,7 @@ pub mod product_config_utils; pub mod product_logging; pub mod role_utils; pub mod status; +pub mod test_utils; pub mod utils; pub mod validation; diff --git a/crates/stackable-operator/src/test_utils.rs b/crates/stackable-operator/src/test_utils.rs new file mode 100644 index 000000000..7c98a45ea --- /dev/null +++ b/crates/stackable-operator/src/test_utils.rs @@ -0,0 +1,17 @@ +/// Please use only in tests, as we have non-ideal error handling in case serde_yaml produced +/// non-utf8 output. +pub fn serialize_to_yaml_with_singleton_map(input: &S) -> Result +where + S: serde::Serialize, +{ + use serde::ser::Error as _; + + let mut buffer = Vec::new(); + let mut serializer = serde_yaml::Serializer::new(&mut buffer); + serde_yaml::with::singleton_map_recursive::serialize(input, &mut serializer)?; + String::from_utf8(buffer).map_err(|err| { + serde_yaml::Error::custom(format!( + "For *some* reason, serde_yaml produced non-utf8 output: {err}" + )) + }) +} From a8e732109687cc27bd9a85186543a42590eb9ffa Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 9 Apr 2026 14:47:16 +0200 Subject: [PATCH 3/8] changelog --- crates/stackable-operator/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index cf0a7efbd..31a01facf 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Add generic database connection mechanism ([#1163]). - Add `config_overrides` module with `KeyValueOverridesProvider` trait, enabling structured config file formats (e.g. JSON) in addition to key-value overrides ([#1177]). +- Add `Scaler` CRD ([#1190], [#1195]). ### Changed @@ -28,9 +29,11 @@ All notable changes to this project will be documented in this file. [#1163]: https://github.com/stackabletech/operator-rs/pull/1163 [#1177]: https://github.com/stackabletech/operator-rs/pull/1177 +[#1190]: https://github.com/stackabletech/operator-rs/pull/1190 [#1191]: https://github.com/stackabletech/operator-rs/pull/1191 [#1192]: https://github.com/stackabletech/operator-rs/pull/1192 [#1194]: https://github.com/stackabletech/operator-rs/pull/1194 +[#1195]: https://github.com/stackabletech/operator-rs/pull/1195 ## [0.109.0] - 2026-04-07 From 6a538525268d36e43332933a0cd5e5ea39f6ffe9 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 9 Apr 2026 15:04:34 +0200 Subject: [PATCH 4/8] Update CRD preview --- crates/stackable-operator/crds/Scaler.yaml | 82 ++++++++++++++++------ 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/crates/stackable-operator/crds/Scaler.yaml b/crates/stackable-operator/crds/Scaler.yaml index 13a0650a3..d764f36f2 100644 --- a/crates/stackable-operator/crds/Scaler.yaml +++ b/crates/stackable-operator/crds/Scaler.yaml @@ -65,38 +65,80 @@ spec: type: string state: description: The current state of the scaler state machine. + oneOf: + - required: + - idle + - required: + - preScaling + - required: + - scaling + - required: + - postScaling + - required: + - failed properties: - details: + failed: + description: |- + A hook returned an error. + + The scaler stays here until the user applies the [`Annotation::autoscaling_retry`] annotation + to trigger a reset to [`ScalerState::Idle`]. properties: failedIn: - description: In which state the scaling operation failed. + description: Which stage produced the error. enum: - - preScaling - - scaling - - postScaling + - PreScaling + - Scaling + - PostScaling type: string - previous_replicas: - maximum: 65535.0 - minimum: 0.0 - type: uint16 reason: + description: Human-readable error message from the hook. type: string + required: + - failedIn + - reason + type: object + idle: + description: No scaling operation is in progress. + type: object + postScaling: + description: |- + Running the `post_scale` hook (e.g. cluster rebalance). + + This stage additionally tracks the previous replica count to be able derive the direction + of the scaling operation. + properties: + previousReplicas: + format: uint16 + maximum: 65535.0 + minimum: 0.0 + type: integer + required: + - previousReplicas + type: object + preScaling: + description: Running the `pre_scale` hook (e.g. data offload). + type: object + scaling: + description: |- + Waiting for the StatefulSet to converge to the new replica count. + + This stage additionally tracks the previous replica count to be able derive the direction + of the scaling operation. + properties: + previousReplicas: + format: uint16 + maximum: 65535.0 + minimum: 0.0 + type: integer + required: + - previousReplicas type: object - state: - enum: - - idle - - preScaling - - scaling - - postScaling - - failed - type: string - required: - - state type: object required: + - lastTransitionTime - replicas - state - - lastTransitionTime type: object required: - spec From b37c416384b69778655b299fe1cc1f7168fcbf85 Mon Sep 17 00:00:00 2001 From: Techassi Date: Thu, 9 Apr 2026 15:05:53 +0200 Subject: [PATCH 5/8] chore: Remove outdated dev comment --- crates/stackable-operator/src/crd/scaler/mod.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/stackable-operator/src/crd/scaler/mod.rs b/crates/stackable-operator/src/crd/scaler/mod.rs index 68299e916..591b4c3ec 100644 --- a/crates/stackable-operator/src/crd/scaler/mod.rs +++ b/crates/stackable-operator/src/crd/scaler/mod.rs @@ -57,11 +57,6 @@ pub struct ScalerStatus { pub last_transition_time: Time, } -// We use `#[serde(tag)]` and `#[serde(content)]` here to circumvent Kubernetes restrictions in their -// structural schema subset of OpenAPI schemas. They don't allow one variant to be typed as a string -// and others to be typed as objects. We therefore encode the variant data in a separate details -// key/object. With this, all variants can be encoded as strings, while the status can still contain -// additional data in an extra field when needed. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema, strum::Display)] #[serde(rename_all = "camelCase", rename_all_fields = "camelCase")] #[strum(serialize_all = "camelCase")] From 67ddfec56b53e1c92d70286e4b7f95be1e008145 Mon Sep 17 00:00:00 2001 From: Techassi Date: Thu, 9 Apr 2026 15:13:55 +0200 Subject: [PATCH 6/8] feat: Add test-utils feature gate --- crates/stackable-operator/Cargo.toml | 3 ++- crates/stackable-operator/src/lib.rs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/stackable-operator/Cargo.toml b/crates/stackable-operator/Cargo.toml index 56e02f2d6..b666d85ac 100644 --- a/crates/stackable-operator/Cargo.toml +++ b/crates/stackable-operator/Cargo.toml @@ -9,13 +9,14 @@ repository.workspace = true [features] default = ["crds"] -full = ["crds", "certs", "time", "webhook", "kube-ws"] +full = ["crds", "certs", "time", "webhook", "kube-ws", "test-utils"] crds = ["dep:stackable-versioned"] certs = ["dep:stackable-certs"] time = ["stackable-shared/time"] webhook = ["dep:stackable-webhook"] kube-ws = ["kube/ws"] +test-utils = [] [dependencies] stackable-certs = { path = "../stackable-certs", optional = true } diff --git a/crates/stackable-operator/src/lib.rs b/crates/stackable-operator/src/lib.rs index bacfa3e9e..9d2e527b4 100644 --- a/crates/stackable-operator/src/lib.rs +++ b/crates/stackable-operator/src/lib.rs @@ -31,6 +31,7 @@ pub mod product_config_utils; pub mod product_logging; pub mod role_utils; pub mod status; +#[cfg(feature = "test-utils")] pub mod test_utils; pub mod utils; pub mod validation; From 4f72097b7cd2f1e9c8ce91e2d18e6e26fbee731c Mon Sep 17 00:00:00 2001 From: Techassi Date: Thu, 9 Apr 2026 15:15:22 +0200 Subject: [PATCH 7/8] Revert "feat: Add test-utils feature gate" This reverts commit 67ddfec56b53e1c92d70286e4b7f95be1e008145. --- crates/stackable-operator/Cargo.toml | 3 +-- crates/stackable-operator/src/lib.rs | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/stackable-operator/Cargo.toml b/crates/stackable-operator/Cargo.toml index b666d85ac..56e02f2d6 100644 --- a/crates/stackable-operator/Cargo.toml +++ b/crates/stackable-operator/Cargo.toml @@ -9,14 +9,13 @@ repository.workspace = true [features] default = ["crds"] -full = ["crds", "certs", "time", "webhook", "kube-ws", "test-utils"] +full = ["crds", "certs", "time", "webhook", "kube-ws"] crds = ["dep:stackable-versioned"] certs = ["dep:stackable-certs"] time = ["stackable-shared/time"] webhook = ["dep:stackable-webhook"] kube-ws = ["kube/ws"] -test-utils = [] [dependencies] stackable-certs = { path = "../stackable-certs", optional = true } diff --git a/crates/stackable-operator/src/lib.rs b/crates/stackable-operator/src/lib.rs index 9d2e527b4..bacfa3e9e 100644 --- a/crates/stackable-operator/src/lib.rs +++ b/crates/stackable-operator/src/lib.rs @@ -31,7 +31,6 @@ pub mod product_config_utils; pub mod product_logging; pub mod role_utils; pub mod status; -#[cfg(feature = "test-utils")] pub mod test_utils; pub mod utils; pub mod validation; From 8c71c5b56f0a8561c85d12cbdca3af0db565e99d Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Fri, 10 Apr 2026 10:04:01 +0200 Subject: [PATCH 8/8] Remove tests --- .../stackable-operator/src/crd/scaler/mod.rs | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/crates/stackable-operator/src/crd/scaler/mod.rs b/crates/stackable-operator/src/crd/scaler/mod.rs index 591b4c3ec..4770013e7 100644 --- a/crates/stackable-operator/src/crd/scaler/mod.rs +++ b/crates/stackable-operator/src/crd/scaler/mod.rs @@ -105,49 +105,3 @@ pub enum FailedInState { /// The `post_scale` hook returned an error. PostScaling, } - -#[cfg(test)] -mod tests { - use rstest::rstest; - - use super::*; - use crate::{ - test_utils::serialize_to_yaml_with_singleton_map, utils::yaml_from_str_singleton_map, - }; - - #[rstest] - #[case::idle("idle: {}", ScalerState::Idle { })] - #[case::pre_scaling("preScaling: {}", ScalerState::PreScaling { })] - #[case::scaling("scaling: - previousReplicas: 42", ScalerState::Scaling { previous_replicas: 42 })] - #[case::post_scaling("postScaling: - previousReplicas: 42", ScalerState::PostScaling { previous_replicas: 42 })] - #[case::failed("failed: - failedIn: PreScaling - reason: bruh moment", ScalerState::Failed { - failed_in: FailedInState::PreScaling, - reason: "bruh moment".to_owned() - } )] - fn parse_state(#[case] input: &str, #[case] expected: ScalerState) { - let parsed: ScalerState = - yaml_from_str_singleton_map(input).expect("invalid test YAML input"); - assert_eq!(parsed, expected); - } - - #[rstest] - #[case::idle(ScalerState::Idle { }, "idle: {}\n")] - #[case::pre_scaling(ScalerState::PreScaling { }, "preScaling: {}\n")] - #[case::scaling(ScalerState::Scaling { previous_replicas: 42 }, "scaling: - previousReplicas: 42\n")] - #[case::post_scaling(ScalerState::PostScaling { previous_replicas: 42 }, "postScaling: - previousReplicas: 42\n")] - #[case::failed(ScalerState::Failed { failed_in: FailedInState::PreScaling, reason: "bruh moment".to_owned() }, "failed: - failedIn: PreScaling - reason: bruh moment\n")] - fn serialize_state(#[case] input: ScalerState, #[case] expected: &str) { - let serialized = - serialize_to_yaml_with_singleton_map(&input).expect("serialization always passes"); - - assert_eq!(serialized, expected); - } -}