From 00fd1b7f3fc1a6bc8ee2fc330a1b72cb5fe70222 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Mon, 10 Nov 2025 16:03:09 +0100 Subject: [PATCH 01/29] feat: Support `objectOverrides` --- .../src/cluster_resources.rs | 31 +- .../crd/listener/listeners/v1alpha1_impl.rs | 89 +++- crates/stackable-shared/src/lib.rs | 2 +- .../stackable-shared/src/patchinator/mod.rs | 504 ++++++++++++++++++ 4 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 crates/stackable-shared/src/patchinator/mod.rs diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index a7b0c03c1..e33fd5ac5 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -8,7 +8,7 @@ use std::{ #[cfg(doc)] use k8s_openapi::api::core::v1::{NodeSelector, Pod}; use k8s_openapi::{ - NamespaceResourceScope, + DeepMerge, NamespaceResourceScope, api::{ apps::v1::{ DaemonSet, DaemonSetSpec, Deployment, DeploymentSpec, StatefulSet, StatefulSetSpec, @@ -22,9 +22,10 @@ use k8s_openapi::{ }, apimachinery::pkg::apis::meta::v1::{LabelSelector, LabelSelectorRequirement}, }; -use kube::{Resource, ResourceExt, core::ErrorResponse}; +use kube::{Resource, ResourceExt, api::DynamicObject, core::ErrorResponse}; use serde::{Serialize, de::DeserializeOwned}; use snafu::{OptionExt, ResultExt, Snafu}; +use stackable_shared::patchinator::{self, apply_patches, parse_patches}; use strum::Display; use tracing::{debug, info, warn}; @@ -87,6 +88,12 @@ pub enum Error { #[snafu(source(from(crate::client::Error, Box::new)))] source: Box, }, + + #[snafu(display("failed to parse user-provided object overrides"))] + ParseObjectOverrides { source: patchinator::Error }, + + #[snafu(display("failed to apply user-provided object overrides"))] + ApplyObjectOverrides { source: patchinator::Error }, } /// A cluster resource handled by [`ClusterResources`]. @@ -97,6 +104,7 @@ pub enum Error { /// it must be added to [`ClusterResources::delete_orphaned_resources`] as well. pub trait ClusterResource: Clone + + DeepMerge + Debug + DeserializeOwned + Resource @@ -413,7 +421,7 @@ impl ClusterResource for Deployment { /// Ok(Action::await_change()) /// } /// ``` -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug)] pub struct ClusterResources { /// The namespace of the cluster namespace: String, @@ -442,6 +450,9 @@ pub struct ClusterResources { /// Strategy to manage how cluster resources are applied. Resources could be patched, merged /// or not applied at all depending on the strategy. apply_strategy: ClusterResourceApplyStrategy, + + /// Arbitrary Kubernetes object overrides specified by the user via the CRD. + object_overrides: Vec, } impl ClusterResources { @@ -470,6 +481,7 @@ impl ClusterResources { controller_name: &str, cluster: &ObjectReference, apply_strategy: ClusterResourceApplyStrategy, + object_overrides: Option>, ) -> Result { let namespace = cluster .namespace @@ -483,6 +495,12 @@ impl ClusterResources { .uid .clone() .context(MissingObjectKeySnafu { key: "uid" })?; + let object_overrides = match object_overrides { + Some(object_overrides) => { + parse_patches(object_overrides).context(ParseObjectOverridesSnafu)? + } + None => vec![], + }; Ok(ClusterResources { namespace, @@ -494,6 +512,7 @@ impl ClusterResources { manager: format_full_controller_name(operator_name, controller_name), resource_ids: Default::default(), apply_strategy, + object_overrides, }) } @@ -563,7 +582,11 @@ impl ClusterResources { .unwrap_or_else(|err| warn!("{}", err)); } - let mutated = resource.maybe_mutate(&self.apply_strategy); + let mut mutated = resource.maybe_mutate(&self.apply_strategy); + + // We apply the object overrides of the user at the very last to offer maximum flexibility. + apply_patches(&mut mutated, self.object_overrides.iter()) + .context(ApplyObjectOverridesSnafu)?; let patched_resource = self .apply_strategy diff --git a/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs index b9351cf32..cf03f0522 100644 --- a/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs +++ b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs @@ -1,7 +1,94 @@ -use crate::crd::listener::listeners::v1alpha1::ListenerSpec; +use crate::crd::listener::listeners::v1alpha1::{ + Listener, ListenerIngress, ListenerPort, ListenerSpec, ListenerStatus, +}; impl ListenerSpec { pub(super) const fn default_publish_not_ready_addresses() -> Option { Some(true) } } + +impl k8s_openapi::DeepMerge for Listener { + fn merge_from(&mut self, other: Self) { + k8s_openapi::DeepMerge::merge_from(&mut self.metadata, other.metadata); + k8s_openapi::DeepMerge::merge_from(&mut self.spec, other.spec); + k8s_openapi::DeepMerge::merge_from(&mut self.status, other.status); + } +} + +impl k8s_openapi::DeepMerge for ListenerSpec { + fn merge_from(&mut self, other: Self) { + k8s_openapi::DeepMerge::merge_from(&mut self.class_name, other.class_name); + k8s_openapi::merge_strategies::map::granular( + &mut self.extra_pod_selector_labels, + other.extra_pod_selector_labels, + |current_item, other_item| { + k8s_openapi::DeepMerge::merge_from(current_item, other_item); + }, + ); + k8s_openapi::merge_strategies::list::map( + &mut self.ports, + other.ports, + &[|lhs, rhs| lhs.name == rhs.name], + |current_item, other_item| { + k8s_openapi::DeepMerge::merge_from(current_item, other_item); + }, + ); + k8s_openapi::DeepMerge::merge_from( + &mut self.publish_not_ready_addresses, + other.publish_not_ready_addresses, + ); + todo!() + } +} + +impl k8s_openapi::DeepMerge for ListenerStatus { + fn merge_from(&mut self, other: Self) { + k8s_openapi::DeepMerge::merge_from(&mut self.service_name, other.service_name); + k8s_openapi::merge_strategies::list::map( + &mut self.ingress_addresses, + other.ingress_addresses, + &[|lhs, rhs| lhs.address == rhs.address], + |current_item, other_item| { + k8s_openapi::DeepMerge::merge_from(current_item, other_item); + }, + ); + k8s_openapi::merge_strategies::map::granular( + &mut self.node_ports, + other.node_ports, + |current_item, other_item| { + k8s_openapi::DeepMerge::merge_from(current_item, other_item); + }, + ); + } +} + +impl k8s_openapi::DeepMerge for ListenerIngress { + fn merge_from(&mut self, other: Self) { + k8s_openapi::DeepMerge::merge_from(&mut self.address, other.address); + self.address_type = other.address_type; + k8s_openapi::merge_strategies::map::granular( + &mut self.ports, + other.ports, + |current_item, other_item| { + k8s_openapi::DeepMerge::merge_from(current_item, other_item); + }, + ); + } +} + +impl k8s_openapi::DeepMerge for ListenerPort { + fn merge_from(&mut self, other: Self) { + k8s_openapi::DeepMerge::merge_from(&mut self.name, other.name); + k8s_openapi::DeepMerge::merge_from(&mut self.port, other.port); + k8s_openapi::DeepMerge::merge_from(&mut self.protocol, other.protocol); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn deep_merge_listener() { + todo!("Add some basic tests for merging"); + } +} diff --git a/crates/stackable-shared/src/lib.rs b/crates/stackable-shared/src/lib.rs index 2f9b8ae93..bb49166fe 100644 --- a/crates/stackable-shared/src/lib.rs +++ b/crates/stackable-shared/src/lib.rs @@ -2,7 +2,7 @@ //! workspace. pub mod crd; +pub mod patchinator; pub mod secret; - pub mod time; pub mod yaml; diff --git a/crates/stackable-shared/src/patchinator/mod.rs b/crates/stackable-shared/src/patchinator/mod.rs new file mode 100644 index 000000000..6beb58a46 --- /dev/null +++ b/crates/stackable-shared/src/patchinator/mod.rs @@ -0,0 +1,504 @@ +use k8s_openapi::DeepMerge; +use kube::core::DynamicObject; +use serde::{Deserialize, de::DeserializeOwned}; +use snafu::{ResultExt, Snafu}; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("failed to deserialize dynamic object"))] + DeserializeDynamicObject { source: serde_yaml::Error }, + + #[snafu(display( + "failed to parse dynamic object as apiVersion {target_api_version:?} and kind {target_kind:?}" + ))] + ParseDynamicObject { + source: kube::core::dynamic::ParseDynamicObjectError, + target_api_version: String, + target_kind: String, + }, +} + +pub fn parse_patches(patch: impl AsRef) -> Result, Error> { + serde_yaml::Deserializer::from_str(patch.as_ref()) + .map(|manifest| DynamicObject::deserialize(manifest).context(DeserializeDynamicObjectSnafu)) + .collect() +} + +pub fn apply_patches<'a, R>( + base: &mut R, + patches: impl Iterator, +) -> Result<(), Error> +where + R: kube::Resource + DeepMerge + DeserializeOwned, +{ + for patch in patches { + apply_patch(base, patch)?; + } + Ok(()) +} + +pub fn apply_patch(base: &mut R, patch: &DynamicObject) -> Result<(), Error> +where + R: kube::Resource + DeepMerge + DeserializeOwned, +{ + use kube::ResourceExt; + + let Some(patch_type) = &patch.types else { + return Ok(()); + }; + if patch_type.api_version != R::api_version(&()) || patch_type.kind != R::kind(&()) { + return Ok(()); + } + let Some(patch_name) = &patch.metadata.name else { + return Ok(()); + }; + + // The name always needs to match + if &base.name_any() != patch_name { + return Ok(()); + } + + // If there is a namespace on the base object, it needs to match as well + // Note that it is not set for cluster-scoped objects. + if base.namespace() != patch.metadata.namespace { + return Ok(()); + } + + let deserialized_patch = + patch + .clone() + .try_parse() + .with_context(|_| ParseDynamicObjectSnafu { + target_api_version: R::api_version(&()), + target_kind: R::kind(&()), + })?; + base.merge_from(deserialized_patch); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, vec}; + + use k8s_openapi::{ + ByteString, Metadata, + api::{ + apps::v1::{ + RollingUpdateStatefulSetStrategy, StatefulSet, StatefulSetSpec, + StatefulSetUpdateStrategy, + }, + core::v1::{ + ConfigMap, Container, ContainerPort, PodSpec, PodTemplateSpec, Secret, + ServiceAccount, + }, + storage::v1::StorageClass, + }, + apimachinery::pkg::util::intstr::IntOrString, + }; + use kube::api::ObjectMeta; + + use super::*; + + fn generate_service_account() -> ServiceAccount { + serde_yaml::from_str( + " +apiVersion: v1 +kind: ServiceAccount +metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/instance: trino + app.kubernetes.io/managed-by: trino.stackable.tech_trinocluster + app.kubernetes.io/name: trino + ownerReferences: + - apiVersion: trino.stackable.tech/v1alpha1 + controller: true + kind: TrinoCluster + name: trino + uid: c85bfb53-a28e-4782-baaf-3c218a25f192 +", + ) + .unwrap() + } + + fn generate_stateful_set() -> StatefulSet { + StatefulSet { + metadata: generate_metadata("trino-coordinator-default"), + spec: Some(StatefulSetSpec { + service_name: Some("trino-coordinator-default".to_owned()), + update_strategy: Some(StatefulSetUpdateStrategy { + rolling_update: Some(RollingUpdateStatefulSetStrategy { + max_unavailable: Some(IntOrString::Int(42)), + ..Default::default() + }), + ..Default::default() + }), + template: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some(generate_labels()), + ..Default::default() + }), + spec: Some(PodSpec { + containers: vec![Container { + name: "trino".to_owned(), + image: Some("trino-image".to_owned()), + ports: Some(vec![ContainerPort { + container_port: 8443, + name: Some("https".to_owned()), + protocol: Some("https".to_owned()), + ..Default::default() + }]), + ..Default::default() + }], + service_account_name: Some("trino-serviceaccount".to_owned()), + ..Default::default() + }), + }, + ..Default::default() + }), + ..Default::default() + } + } + + fn generate_metadata(name: impl Into) -> ObjectMeta { + ObjectMeta { + name: Some(name.into()), + namespace: Some("default".to_owned()), + labels: Some(generate_labels()), + ..Default::default() + } + } + + fn generate_labels() -> BTreeMap { + BTreeMap::from([("app.kubernetes.io/name".to_owned(), "trino".to_owned())]) + } + + #[test] + fn service_account_patched() { + let mut sa = generate_service_account(); + let patches = parse_patches( + " +apiVersion: v1 +kind: ServiceAccount +metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar +", + ) + .unwrap(); + + assert_has_label(&sa, "app.kubernetes.io/name", "trino"); + apply_patches(&mut sa, patches.iter()).unwrap(); + assert_has_label(&sa, "app.kubernetes.io/name", "overwritten"); + } + + #[test] + fn service_account_not_patched_as_different_name() { + let mut sa = generate_service_account(); + let patches = parse_patches( + " +apiVersion: v1 +kind: ServiceAccount +metadata: + name: other-sa + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar +", + ) + .unwrap(); + + let original = sa.clone(); + apply_patches(&mut sa, patches.iter()).unwrap(); + assert_eq!(sa, original, "The patch shouldn't have changed anything"); + } + + #[test] + fn service_account_not_patched_as_different_namespace() { + let mut sa = generate_service_account(); + let patches = parse_patches( + " +apiVersion: v1 +kind: ServiceAccount +metadata: + name: trino-serviceaccount + namespace: other-namespace + labels: + app.kubernetes.io/name: overwritten + foo: bar +", + ) + .unwrap(); + + let original = sa.clone(); + apply_patches(&mut sa, patches.iter()).unwrap(); + assert_eq!(sa, original, "The patch shouldn't have changed anything"); + } + + #[test] + fn service_account_not_patched_as_different_api_version() { + let mut sa = generate_service_account(); + let patches = parse_patches( + " +apiVersion: v42 +kind: ServiceAccount +metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar +", + ) + .unwrap(); + + let original = sa.clone(); + apply_patches(&mut sa, patches.iter()).unwrap(); + assert_eq!(sa, original, "The patch shouldn't have changed anything"); + } + + #[test] + fn statefulset_patched_multiple_patches() { + let mut sts = generate_stateful_set(); + + let patches = parse_patches( + " +apiVersion: v1 +kind: ServiceAccount +metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: trino-coordinator-default + namespace: default +spec: + template: + metadata: + labels: + foo: bar + spec: + containers: + - name: trino + image: custom-image +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: trino-coordinator-default + namespace: default +spec: + replicas: 3 +", + ) + .unwrap(); + + let get_replicas = |sts: &StatefulSet| sts.spec.as_ref().unwrap().replicas; + let get_trino_container = |sts: &StatefulSet| { + sts.spec + .as_ref() + .unwrap() + .template + .spec + .as_ref() + .unwrap() + .containers + .iter() + .find(|c| c.name == "trino") + .unwrap() + .clone() + }; + let get_trino_container_image = |sts: &StatefulSet| get_trino_container(sts).image; + + assert_eq!(get_replicas(&sts), None); + assert_eq!( + get_trino_container_image(&sts).as_deref(), + Some("trino-image") + ); + apply_patches(&mut sts, patches.iter()).unwrap(); + assert_eq!(get_replicas(&sts), Some(3)); + assert_eq!( + get_trino_container_image(&sts).as_deref(), + Some("custom-image") + ); + } + + #[test] + fn configmap_patched() { + let mut cm: ConfigMap = serde_yaml::from_str( + " +apiVersion: v1 +kind: ConfigMap +metadata: + name: game-demo +data: + foo: bar + config.properties: |- + coordinator=true + http-server.https.enabled=true + log.properties: |- + =info +", + ) + .unwrap(); + let patches = parse_patches( + " +apiVersion: v1 +kind: ConfigMap +metadata: + name: game-demo +data: + foo: overwritten + log.properties: |- + =info,tech.stackable=debug +", + ) + .unwrap(); + + assert_eq!( + cm.data.as_ref().unwrap(), + &BTreeMap::from([ + ("foo".to_owned(), "bar".to_owned()), + ( + "config.properties".to_owned(), + "coordinator=true\nhttp-server.https.enabled=true".to_owned() + ), + ("log.properties".to_owned(), "=info".to_owned()), + ]) + ); + apply_patches(&mut cm, patches.iter()).unwrap(); + assert_eq!( + cm.data.as_ref().unwrap(), + &BTreeMap::from([ + ("foo".to_owned(), "overwritten".to_owned()), + ( + "config.properties".to_owned(), + "coordinator=true\nhttp-server.https.enabled=true".to_owned() + ), + ( + "log.properties".to_owned(), + "=info,tech.stackable=debug".to_owned() + ), + ]) + ); + } + + #[test] + fn secret_patched() { + let mut secret: Secret = serde_yaml::from_str( + " +apiVersion: v1 +kind: Secret +metadata: + name: dotfile-secret +stringData: + foo: bar +data: + raw: YmFyCg== # echo bar | base64 +", + ) + .unwrap(); + let patches = parse_patches( + " +apiVersion: v1 +kind: Secret +metadata: + name: dotfile-secret +stringData: + foo: overwritten +data: + raw: b3ZlcndyaXR0ZW4K # echo overwritten | base64 +", + ) + .unwrap(); + + assert_eq!( + secret.string_data.as_ref().unwrap(), + &BTreeMap::from([("foo".to_owned(), "bar".to_owned())]) + ); + assert_eq!( + secret.data.as_ref().unwrap(), + &BTreeMap::from([("raw".to_owned(), ByteString(b"bar\n".to_vec()))]) + ); + + apply_patches(&mut secret, patches.iter()).unwrap(); + assert_eq!( + secret.string_data.as_ref().unwrap(), + &BTreeMap::from([("foo".to_owned(), "overwritten".to_owned()),]) + ); + assert_eq!( + secret.data.as_ref().unwrap(), + &BTreeMap::from([("raw".to_owned(), ByteString(b"overwritten\n".to_vec()))]) + ); + } + + #[test] + fn cluster_scoped_object_patched() { + let mut storage_class: StorageClass = serde_yaml::from_str( + " +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: low-latency + labels: + foo: original + annotations: + storageclass.kubernetes.io/is-default-class: \"false\" +provisioner: csi-driver.example-vendor.example +", + ) + .unwrap(); + let patches = parse_patches( + " +--- +apiVersion: v1 +kind: ServiceAccount +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: low-latency + labels: + foo: overwritten + annotations: + new: annotation +provisioner: custom-provisioner +--- +foo: bar +", + ) + .unwrap(); + + assert_has_label(&storage_class, "foo", "original"); + apply_patches(&mut storage_class, patches.iter()).unwrap(); + assert_has_label(&storage_class, "foo", "overwritten"); + } + + fn assert_has_label>( + object: &O, + key: impl AsRef, + value: impl AsRef, + ) { + assert_eq!( + object + .metadata() + .labels + .as_ref() + .expect("labels missing") + .get(key.as_ref()) + .expect("key missing from labels"), + value.as_ref() + ); + } +} From 38a09962d74f0f446510fc1dcaa8c0ec03726d96 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Tue, 11 Nov 2025 10:48:40 +0100 Subject: [PATCH 02/29] refactor: Switch to a lis of objects (as opposed to a big string field) --- .../src/cluster_resources.rs | 21 +- crates/stackable-operator/src/lib.rs | 1 + .../stackable-operator/src/patchinator/crd.rs | 13 + .../src/patchinator/mod.rs | 312 +++++++++--------- crates/stackable-shared/src/lib.rs | 1 - 5 files changed, 175 insertions(+), 173 deletions(-) create mode 100644 crates/stackable-operator/src/patchinator/crd.rs rename crates/{stackable-shared => stackable-operator}/src/patchinator/mod.rs (68%) diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index e33fd5ac5..f02fdf2d2 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -22,10 +22,9 @@ use k8s_openapi::{ }, apimachinery::pkg::apis::meta::v1::{LabelSelector, LabelSelectorRequirement}, }; -use kube::{Resource, ResourceExt, api::DynamicObject, core::ErrorResponse}; +use kube::{Resource, ResourceExt, core::ErrorResponse}; use serde::{Serialize, de::DeserializeOwned}; use snafu::{OptionExt, ResultExt, Snafu}; -use stackable_shared::patchinator::{self, apply_patches, parse_patches}; use strum::Display; use tracing::{debug, info, warn}; @@ -43,6 +42,7 @@ use crate::{ Label, LabelError, Labels, consts::{K8S_APP_INSTANCE_KEY, K8S_APP_MANAGED_BY_KEY, K8S_APP_NAME_KEY}, }, + patchinator::{self, ObjectOverrides, apply_patches}, utils::format_full_controller_name, }; @@ -422,7 +422,7 @@ impl ClusterResource for Deployment { /// } /// ``` #[derive(Debug)] -pub struct ClusterResources { +pub struct ClusterResources<'a> { /// The namespace of the cluster namespace: String, @@ -452,10 +452,10 @@ pub struct ClusterResources { apply_strategy: ClusterResourceApplyStrategy, /// Arbitrary Kubernetes object overrides specified by the user via the CRD. - object_overrides: Vec, + object_overrides: &'a ObjectOverrides, } -impl ClusterResources { +impl<'a> ClusterResources<'a> { /// Constructs new `ClusterResources`. /// /// # Arguments @@ -481,7 +481,7 @@ impl ClusterResources { controller_name: &str, cluster: &ObjectReference, apply_strategy: ClusterResourceApplyStrategy, - object_overrides: Option>, + object_overrides: &'a ObjectOverrides, ) -> Result { let namespace = cluster .namespace @@ -495,12 +495,6 @@ impl ClusterResources { .uid .clone() .context(MissingObjectKeySnafu { key: "uid" })?; - let object_overrides = match object_overrides { - Some(object_overrides) => { - parse_patches(object_overrides).context(ParseObjectOverridesSnafu)? - } - None => vec![], - }; Ok(ClusterResources { namespace, @@ -585,8 +579,7 @@ impl ClusterResources { let mut mutated = resource.maybe_mutate(&self.apply_strategy); // We apply the object overrides of the user at the very last to offer maximum flexibility. - apply_patches(&mut mutated, self.object_overrides.iter()) - .context(ApplyObjectOverridesSnafu)?; + apply_patches(&mut mutated, self.object_overrides).context(ApplyObjectOverridesSnafu)?; let patched_resource = self .apply_strategy diff --git a/crates/stackable-operator/src/lib.rs b/crates/stackable-operator/src/lib.rs index 5e08ddcab..49295c6eb 100644 --- a/crates/stackable-operator/src/lib.rs +++ b/crates/stackable-operator/src/lib.rs @@ -22,6 +22,7 @@ pub mod kvp; pub mod logging; pub mod memory; pub mod namespace; +pub mod patchinator; pub mod pod_utils; pub mod product_config_utils; pub mod product_logging; diff --git a/crates/stackable-operator/src/patchinator/crd.rs b/crates/stackable-operator/src/patchinator/crd.rs new file mode 100644 index 000000000..47b3c9f1d --- /dev/null +++ b/crates/stackable-operator/src/patchinator/crd.rs @@ -0,0 +1,13 @@ +use kube::api::DynamicObject; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::utils::crds::raw_object_list_schema; + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ObjectOverrides { + #[serde(default)] + #[schemars(schema_with = "raw_object_list_schema")] + pub object_overrides: Vec, +} diff --git a/crates/stackable-shared/src/patchinator/mod.rs b/crates/stackable-operator/src/patchinator/mod.rs similarity index 68% rename from crates/stackable-shared/src/patchinator/mod.rs rename to crates/stackable-operator/src/patchinator/mod.rs index 6beb58a46..04e7cda81 100644 --- a/crates/stackable-shared/src/patchinator/mod.rs +++ b/crates/stackable-operator/src/patchinator/mod.rs @@ -1,13 +1,13 @@ use k8s_openapi::DeepMerge; use kube::core::DynamicObject; -use serde::{Deserialize, de::DeserializeOwned}; +use serde::de::DeserializeOwned; use snafu::{ResultExt, Snafu}; +mod crd; +pub use crd::ObjectOverrides; + #[derive(Debug, Snafu)] pub enum Error { - #[snafu(display("failed to deserialize dynamic object"))] - DeserializeDynamicObject { source: serde_yaml::Error }, - #[snafu(display( "failed to parse dynamic object as apiVersion {target_api_version:?} and kind {target_kind:?}" ))] @@ -18,20 +18,11 @@ pub enum Error { }, } -pub fn parse_patches(patch: impl AsRef) -> Result, Error> { - serde_yaml::Deserializer::from_str(patch.as_ref()) - .map(|manifest| DynamicObject::deserialize(manifest).context(DeserializeDynamicObjectSnafu)) - .collect() -} - -pub fn apply_patches<'a, R>( - base: &mut R, - patches: impl Iterator, -) -> Result<(), Error> +pub fn apply_patches(base: &mut R, patches: &ObjectOverrides) -> Result<(), Error> where R: kube::Resource + DeepMerge + DeserializeOwned, { - for patch in patches { + for patch in &patches.object_overrides { apply_patch(base, patch)?; } Ok(()) @@ -100,6 +91,7 @@ mod tests { use super::*; + /// Using [`serde_yaml`] to generate the test data fn generate_service_account() -> ServiceAccount { serde_yaml::from_str( " @@ -123,6 +115,7 @@ metadata: .unwrap() } + /// Generate the test data programmatically (as operators would normally do) fn generate_stateful_set() -> StatefulSet { StatefulSet { metadata: generate_metadata("trino-coordinator-default"), @@ -178,131 +171,133 @@ metadata: #[test] fn service_account_patched() { let mut sa = generate_service_account(); - let patches = parse_patches( + let object_overrides: ObjectOverrides = serde_yaml::from_str( " -apiVersion: v1 -kind: ServiceAccount -metadata: - name: trino-serviceaccount - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar +objectOverrides: + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar ", ) - .unwrap(); + .expect("test input is valid YAML"); assert_has_label(&sa, "app.kubernetes.io/name", "trino"); - apply_patches(&mut sa, patches.iter()).unwrap(); + apply_patches(&mut sa, &object_overrides).unwrap(); assert_has_label(&sa, "app.kubernetes.io/name", "overwritten"); } #[test] fn service_account_not_patched_as_different_name() { let mut sa = generate_service_account(); - let patches = parse_patches( + let object_overrides: ObjectOverrides = serde_yaml::from_str( " -apiVersion: v1 -kind: ServiceAccount -metadata: - name: other-sa - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar +objectOverrides: + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: other-sa + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar ", ) - .unwrap(); + .expect("test input is valid YAML"); let original = sa.clone(); - apply_patches(&mut sa, patches.iter()).unwrap(); + apply_patches(&mut sa, &object_overrides).unwrap(); assert_eq!(sa, original, "The patch shouldn't have changed anything"); } #[test] fn service_account_not_patched_as_different_namespace() { let mut sa = generate_service_account(); - let patches = parse_patches( + let object_overrides: ObjectOverrides = serde_yaml::from_str( " -apiVersion: v1 -kind: ServiceAccount -metadata: - name: trino-serviceaccount - namespace: other-namespace - labels: - app.kubernetes.io/name: overwritten - foo: bar +objectOverrides: + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: other-namespace + labels: + app.kubernetes.io/name: overwritten + foo: bar ", ) - .unwrap(); + .expect("test input is valid YAML"); let original = sa.clone(); - apply_patches(&mut sa, patches.iter()).unwrap(); + apply_patches(&mut sa, &object_overrides).unwrap(); assert_eq!(sa, original, "The patch shouldn't have changed anything"); } #[test] fn service_account_not_patched_as_different_api_version() { let mut sa = generate_service_account(); - let patches = parse_patches( + let object_overrides: ObjectOverrides = serde_yaml::from_str( " -apiVersion: v42 -kind: ServiceAccount -metadata: - name: trino-serviceaccount - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar +objectOverrides: + - apiVersion: v42 + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar ", ) - .unwrap(); + .expect("test input is valid YAML"); let original = sa.clone(); - apply_patches(&mut sa, patches.iter()).unwrap(); + apply_patches(&mut sa, &object_overrides).unwrap(); assert_eq!(sa, original, "The patch shouldn't have changed anything"); } #[test] fn statefulset_patched_multiple_patches() { let mut sts = generate_stateful_set(); - - let patches = parse_patches( + let object_overrides: ObjectOverrides = serde_yaml::from_str( " -apiVersion: v1 -kind: ServiceAccount -metadata: - name: trino-serviceaccount - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: trino-coordinator-default - namespace: default -spec: - template: +objectOverrides: + - apiVersion: v1 + kind: ServiceAccount metadata: + name: trino-serviceaccount + namespace: default labels: + app.kubernetes.io/name: overwritten foo: bar + - apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: trino-coordinator-default + namespace: default spec: - containers: - - name: trino - image: custom-image ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: trino-coordinator-default - namespace: default -spec: - replicas: 3 + template: + metadata: + labels: + foo: bar + spec: + containers: + - name: trino + image: custom-image + - apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: trino-coordinator-default + namespace: default + spec: + replicas: 3 ", ) - .unwrap(); + .expect("test input is valid YAML"); let get_replicas = |sts: &StatefulSet| sts.spec.as_ref().unwrap().replicas; let get_trino_container = |sts: &StatefulSet| { @@ -326,7 +321,7 @@ spec: get_trino_container_image(&sts).as_deref(), Some("trino-image") ); - apply_patches(&mut sts, patches.iter()).unwrap(); + apply_patches(&mut sts, &object_overrides).unwrap(); assert_eq!(get_replicas(&sts), Some(3)); assert_eq!( get_trino_container_image(&sts).as_deref(), @@ -338,33 +333,34 @@ spec: fn configmap_patched() { let mut cm: ConfigMap = serde_yaml::from_str( " -apiVersion: v1 -kind: ConfigMap -metadata: - name: game-demo -data: - foo: bar - config.properties: |- - coordinator=true - http-server.https.enabled=true - log.properties: |- - =info + apiVersion: v1 + kind: ConfigMap + metadata: + name: game-demo + data: + foo: bar + config.properties: |- + coordinator=true + http-server.https.enabled=true + log.properties: |- + =info ", ) .unwrap(); - let patches = parse_patches( + let object_overrides: ObjectOverrides = serde_yaml::from_str( " -apiVersion: v1 -kind: ConfigMap -metadata: - name: game-demo -data: - foo: overwritten - log.properties: |- - =info,tech.stackable=debug +objectOverrides: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: game-demo + data: + foo: overwritten + log.properties: |- + =info,tech.stackable=debug ", ) - .unwrap(); + .expect("test input is valid YAML"); assert_eq!( cm.data.as_ref().unwrap(), @@ -377,7 +373,7 @@ data: ("log.properties".to_owned(), "=info".to_owned()), ]) ); - apply_patches(&mut cm, patches.iter()).unwrap(); + apply_patches(&mut cm, &object_overrides).unwrap(); assert_eq!( cm.data.as_ref().unwrap(), &BTreeMap::from([ @@ -398,30 +394,31 @@ data: fn secret_patched() { let mut secret: Secret = serde_yaml::from_str( " -apiVersion: v1 -kind: Secret -metadata: - name: dotfile-secret -stringData: - foo: bar -data: - raw: YmFyCg== # echo bar | base64 + apiVersion: v1 + kind: Secret + metadata: + name: dotfile-secret + stringData: + foo: bar + data: + raw: YmFyCg== # echo bar | base64 ", ) .unwrap(); - let patches = parse_patches( + let object_overrides: ObjectOverrides = serde_yaml::from_str( " -apiVersion: v1 -kind: Secret -metadata: - name: dotfile-secret -stringData: - foo: overwritten -data: - raw: b3ZlcndyaXR0ZW4K # echo overwritten | base64 +objectOverrides: + - apiVersion: v1 + kind: Secret + metadata: + name: dotfile-secret + stringData: + foo: overwritten + data: + raw: b3ZlcndyaXR0ZW4K # echo overwritten | base64 ", ) - .unwrap(); + .expect("test input is valid YAML"); assert_eq!( secret.string_data.as_ref().unwrap(), @@ -432,7 +429,7 @@ data: &BTreeMap::from([("raw".to_owned(), ByteString(b"bar\n".to_vec()))]) ); - apply_patches(&mut secret, patches.iter()).unwrap(); + apply_patches(&mut secret, &object_overrides).unwrap(); assert_eq!( secret.string_data.as_ref().unwrap(), &BTreeMap::from([("foo".to_owned(), "overwritten".to_owned()),]) @@ -447,41 +444,40 @@ data: fn cluster_scoped_object_patched() { let mut storage_class: StorageClass = serde_yaml::from_str( " -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: - name: low-latency - labels: - foo: original - annotations: - storageclass.kubernetes.io/is-default-class: \"false\" -provisioner: csi-driver.example-vendor.example + apiVersion: storage.k8s.io/v1 + kind: StorageClass + metadata: + name: low-latency + labels: + foo: original + annotations: + storageclass.kubernetes.io/is-default-class: \"false\" + provisioner: csi-driver.example-vendor.example ", ) .unwrap(); - let patches = parse_patches( + let object_overrides: ObjectOverrides = serde_yaml::from_str( " ---- -apiVersion: v1 -kind: ServiceAccount ---- -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: - name: low-latency - labels: - foo: overwritten - annotations: - new: annotation -provisioner: custom-provisioner ---- -foo: bar +objectOverrides: + - apiVersion: v1 + kind: ServiceAccount + - apiVersion: storage.k8s.io/v1 + kind: StorageClass + metadata: + name: low-latency + labels: + foo: overwritten + annotations: + new: annotation + provisioner: custom-provisioner + - foo: bar + - {} ", ) - .unwrap(); + .expect("test input is valid YAML"); assert_has_label(&storage_class, "foo", "original"); - apply_patches(&mut storage_class, patches.iter()).unwrap(); + apply_patches(&mut storage_class, &object_overrides).unwrap(); assert_has_label(&storage_class, "foo", "overwritten"); } diff --git a/crates/stackable-shared/src/lib.rs b/crates/stackable-shared/src/lib.rs index bb49166fe..767726d3d 100644 --- a/crates/stackable-shared/src/lib.rs +++ b/crates/stackable-shared/src/lib.rs @@ -2,7 +2,6 @@ //! workspace. pub mod crd; -pub mod patchinator; pub mod secret; pub mod time; pub mod yaml; From d87791f23e10c2d2420536ea42643ca44b7d061f Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Tue, 11 Nov 2025 10:59:41 +0100 Subject: [PATCH 03/29] changelog --- crates/stackable-operator/CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 53e8bbf3d..8b9a612fe 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Support `objectOverrides` ([#1118]). + +### Changed + +- BREAKING: `ClusterResources` now requires the objects added to implement `DeepMerge`. + This is very likely a stackable-operator internal change, but technically breaking ([#1118]). + +### Removed + +- BREAKING: `ClusterResources` no longer derives `Eq` and `PartialEq` ([#1118]). + +[#1118]: https://github.com/stackabletech/operator-rs/pull/1118 + ## [0.100.3] - 2025-10-31 ### Changed From aaf74c27f532f29360b63af36218ce55f0ce811f Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Tue, 11 Nov 2025 11:04:27 +0100 Subject: [PATCH 04/29] Add TODO for docs --- crates/stackable-operator/src/patchinator/crd.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/stackable-operator/src/patchinator/crd.rs b/crates/stackable-operator/src/patchinator/crd.rs index 47b3c9f1d..3f2e604c5 100644 --- a/crates/stackable-operator/src/patchinator/crd.rs +++ b/crates/stackable-operator/src/patchinator/crd.rs @@ -7,6 +7,7 @@ use crate::utils::crds::raw_object_list_schema; #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] #[serde(rename_all = "camelCase")] pub struct ObjectOverrides { + /// TODO docs #[serde(default)] #[schemars(schema_with = "raw_object_list_schema")] pub object_overrides: Vec, From 22b173273f50d800aaa2593d7bfa3d4d186e78d6 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Tue, 11 Nov 2025 12:29:52 +0100 Subject: [PATCH 05/29] Add a test for Listener merging --- .../crd/listener/listeners/v1alpha1_impl.rs | 108 +++++++++++++----- 1 file changed, 81 insertions(+), 27 deletions(-) diff --git a/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs index cf03f0522..4c2c5b602 100644 --- a/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs +++ b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs @@ -1,3 +1,5 @@ +use k8s_openapi::{DeepMerge, merge_strategies}; + use crate::crd::listener::listeners::v1alpha1::{ Listener, ListenerIngress, ListenerPort, ListenerSpec, ListenerStatus, }; @@ -8,87 +10,139 @@ impl ListenerSpec { } } -impl k8s_openapi::DeepMerge for Listener { +impl DeepMerge for Listener { fn merge_from(&mut self, other: Self) { - k8s_openapi::DeepMerge::merge_from(&mut self.metadata, other.metadata); - k8s_openapi::DeepMerge::merge_from(&mut self.spec, other.spec); - k8s_openapi::DeepMerge::merge_from(&mut self.status, other.status); + DeepMerge::merge_from(&mut self.metadata, other.metadata); + DeepMerge::merge_from(&mut self.spec, other.spec); + DeepMerge::merge_from(&mut self.status, other.status); } } -impl k8s_openapi::DeepMerge for ListenerSpec { +impl DeepMerge for ListenerSpec { fn merge_from(&mut self, other: Self) { - k8s_openapi::DeepMerge::merge_from(&mut self.class_name, other.class_name); - k8s_openapi::merge_strategies::map::granular( + DeepMerge::merge_from(&mut self.class_name, other.class_name); + merge_strategies::map::granular( &mut self.extra_pod_selector_labels, other.extra_pod_selector_labels, |current_item, other_item| { - k8s_openapi::DeepMerge::merge_from(current_item, other_item); + DeepMerge::merge_from(current_item, other_item); }, ); - k8s_openapi::merge_strategies::list::map( + merge_strategies::list::map( &mut self.ports, other.ports, &[|lhs, rhs| lhs.name == rhs.name], |current_item, other_item| { - k8s_openapi::DeepMerge::merge_from(current_item, other_item); + DeepMerge::merge_from(current_item, other_item); }, ); - k8s_openapi::DeepMerge::merge_from( + DeepMerge::merge_from( &mut self.publish_not_ready_addresses, other.publish_not_ready_addresses, ); - todo!() } } -impl k8s_openapi::DeepMerge for ListenerStatus { +impl DeepMerge for ListenerStatus { fn merge_from(&mut self, other: Self) { - k8s_openapi::DeepMerge::merge_from(&mut self.service_name, other.service_name); - k8s_openapi::merge_strategies::list::map( + DeepMerge::merge_from(&mut self.service_name, other.service_name); + merge_strategies::list::map( &mut self.ingress_addresses, other.ingress_addresses, &[|lhs, rhs| lhs.address == rhs.address], |current_item, other_item| { - k8s_openapi::DeepMerge::merge_from(current_item, other_item); + DeepMerge::merge_from(current_item, other_item); }, ); - k8s_openapi::merge_strategies::map::granular( + merge_strategies::map::granular( &mut self.node_ports, other.node_ports, |current_item, other_item| { - k8s_openapi::DeepMerge::merge_from(current_item, other_item); + DeepMerge::merge_from(current_item, other_item); }, ); } } -impl k8s_openapi::DeepMerge for ListenerIngress { +impl DeepMerge for ListenerIngress { fn merge_from(&mut self, other: Self) { - k8s_openapi::DeepMerge::merge_from(&mut self.address, other.address); + DeepMerge::merge_from(&mut self.address, other.address); self.address_type = other.address_type; - k8s_openapi::merge_strategies::map::granular( + merge_strategies::map::granular( &mut self.ports, other.ports, |current_item, other_item| { - k8s_openapi::DeepMerge::merge_from(current_item, other_item); + DeepMerge::merge_from(current_item, other_item); }, ); } } -impl k8s_openapi::DeepMerge for ListenerPort { +impl DeepMerge for ListenerPort { fn merge_from(&mut self, other: Self) { - k8s_openapi::DeepMerge::merge_from(&mut self.name, other.name); - k8s_openapi::DeepMerge::merge_from(&mut self.port, other.port); - k8s_openapi::DeepMerge::merge_from(&mut self.protocol, other.protocol); + DeepMerge::merge_from(&mut self.name, other.name); + DeepMerge::merge_from(&mut self.port, other.port); + DeepMerge::merge_from(&mut self.protocol, other.protocol); } } #[cfg(test)] mod tests { + use super::*; + #[test] fn deep_merge_listener() { - todo!("Add some basic tests for merging"); + let mut base: ListenerSpec = serde_yaml::from_str( + " +className: my-listener-class +extraPodSelectorLabels: + foo: bar +ports: + - name: http + port: 8080 + protocol: http + - name: https + port: 8080 + protocol: https +# publishNotReadyAddresses defaults to true +", + ) + .unwrap(); + + let patch: ListenerSpec = serde_yaml::from_str( + " +className: custom-listener-class +extraPodSelectorLabels: + foo: overridden + extra: label +ports: + - name: https + port: 8443 +publishNotReadyAddresses: false +", + ) + .unwrap(); + + base.merge_from(patch); + + let expected: ListenerSpec = serde_yaml::from_str( + " +className: custom-listener-class +extraPodSelectorLabels: + foo: overridden + extra: label +ports: + - name: http + port: 8080 + protocol: http + - name: https + port: 8443 # overridden + protocol: https +publishNotReadyAddresses: false +", + ) + .unwrap(); + + assert_eq!(base, expected); } } From ec5b8820af558e002a93601f18f7d92807a465f0 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Tue, 11 Nov 2025 13:10:18 +0100 Subject: [PATCH 06/29] Fix doctests --- crates/stackable-operator/src/cluster_resources.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index f02fdf2d2..087965cdd 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -340,6 +340,7 @@ impl ClusterResource for Deployment { /// use serde::{Deserialize, Serialize}; /// use stackable_operator::client::Client; /// use stackable_operator::cluster_resources::{self, ClusterResourceApplyStrategy, ClusterResources}; +/// use stackable_operator::patchinator::ObjectOverrides; /// use stackable_operator::product_config_utils::ValidatedRoleConfigByPropertyKind; /// use stackable_operator::role_utils::Role; /// use std::sync::Arc; @@ -356,7 +357,10 @@ impl ClusterResource for Deployment { /// plural = "AppClusters", /// namespaced, /// )] -/// struct AppClusterSpec {} +/// struct AppClusterSpec { +/// #[serde(flatten)] +/// pub object_overrides: ObjectOverrides, +/// } /// /// enum Error { /// CreateClusterResources { @@ -379,6 +383,7 @@ impl ClusterResource for Deployment { /// CONTROLLER_NAME, /// &app.object_ref(&()), /// ClusterResourceApplyStrategy::Default, +/// &app.spec.object_overrides, /// ) /// .map_err(|source| Error::CreateClusterResources { source })?; /// From a46cadae07b77ecdedf944df4366b98cfccce5ce Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 20 Nov 2025 15:46:02 +0100 Subject: [PATCH 07/29] Improve CRD docs --- crates/stackable-operator/src/patchinator/crd.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/stackable-operator/src/patchinator/crd.rs b/crates/stackable-operator/src/patchinator/crd.rs index 3f2e604c5..025d1a2af 100644 --- a/crates/stackable-operator/src/patchinator/crd.rs +++ b/crates/stackable-operator/src/patchinator/crd.rs @@ -7,7 +7,10 @@ use crate::utils::crds::raw_object_list_schema; #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] #[serde(rename_all = "camelCase")] pub struct ObjectOverrides { - /// TODO docs + /// A list of generic Kubernetes objects, which are merged onto the objects that the operator + /// creates. + /// + // TODO: Add link to concepts page once it exists #[serde(default)] #[schemars(schema_with = "raw_object_list_schema")] pub object_overrides: Vec, From 058a828f5b377d0d5bc19ee11514e806ccce2efc Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 20 Nov 2025 15:48:07 +0100 Subject: [PATCH 08/29] Remove unused error variant --- crates/stackable-operator/src/cluster_resources.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index 087965cdd..d470851ef 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -89,9 +89,6 @@ pub enum Error { source: Box, }, - #[snafu(display("failed to parse user-provided object overrides"))] - ParseObjectOverrides { source: patchinator::Error }, - #[snafu(display("failed to apply user-provided object overrides"))] ApplyObjectOverrides { source: patchinator::Error }, } From 58ab5cae187a4e07b980da2a3661ed3d5f79854b Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 20 Nov 2025 15:51:52 +0100 Subject: [PATCH 09/29] Add to DummyCluster --- crates/stackable-operator/crds/DummyCluster.yaml | 9 +++++++++ crates/xtask/src/crd/dummy.rs | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/stackable-operator/crds/DummyCluster.yaml b/crates/stackable-operator/crds/DummyCluster.yaml index 80ae1c35c..d0676fa0c 100644 --- a/crates/stackable-operator/crds/DummyCluster.yaml +++ b/crates/stackable-operator/crds/DummyCluster.yaml @@ -634,6 +634,15 @@ spec: required: - roleGroups type: object + objectOverrides: + default: [] + description: |- + A list of generic Kubernetes objects, which are merged onto the objects that the operator + creates. + items: + type: object + x-kubernetes-preserve-unknown-fields: true + type: array opaConfig: description: |- Configure the OPA stacklet [discovery ConfigMap](https://docs.stackable.tech/home/nightly/concepts/service_discovery) diff --git a/crates/xtask/src/crd/dummy.rs b/crates/xtask/src/crd/dummy.rs index 929fd73f6..db13cbe6a 100644 --- a/crates/xtask/src/crd/dummy.rs +++ b/crates/xtask/src/crd/dummy.rs @@ -3,6 +3,7 @@ use stackable_operator::{ commons::resources::{JvmHeapLimits, Resources}, config::fragment::Fragment, kube::CustomResource, + patchinator::ObjectOverrides, role_utils::Role, schemars::JsonSchema, status::condition::ClusterCondition, @@ -27,7 +28,7 @@ pub mod versioned { status = "v1alpha1::DummyClusterStatus", namespaced, ))] - #[derive(Clone, CustomResource, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] + #[derive(Clone, CustomResource, Debug, Deserialize, JsonSchema, Serialize)] #[schemars(crate = "stackable_operator::schemars")] #[serde(rename_all = "camelCase")] pub struct DummyClusterSpec { @@ -48,6 +49,9 @@ pub mod versioned { secret_reference: stackable_operator::shared::secret::SecretReference, tls_client_details: stackable_operator::commons::tls_verification::TlsClientDetails, + #[serde(flatten)] + pub object_overrides: ObjectOverrides, + // Already versioned client_authentication_details: stackable_operator::crd::authentication::core::v1alpha1::ClientAuthenticationDetails, From 45a2ec52510719f4c4a0cd90bc4a04711ee95b7e Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 20 Nov 2025 16:23:05 +0100 Subject: [PATCH 10/29] Improve CRD docs --- crates/stackable-operator/crds/DummyCluster.yaml | 2 ++ crates/stackable-operator/src/patchinator/crd.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/stackable-operator/crds/DummyCluster.yaml b/crates/stackable-operator/crds/DummyCluster.yaml index d0676fa0c..751b7ed3f 100644 --- a/crates/stackable-operator/crds/DummyCluster.yaml +++ b/crates/stackable-operator/crds/DummyCluster.yaml @@ -639,6 +639,8 @@ spec: description: |- A list of generic Kubernetes objects, which are merged onto the objects that the operator creates. + + List entries are arbitrary YAML objects, which need to be valid Kubernetes objects. items: type: object x-kubernetes-preserve-unknown-fields: true diff --git a/crates/stackable-operator/src/patchinator/crd.rs b/crates/stackable-operator/src/patchinator/crd.rs index 025d1a2af..c1000fd80 100644 --- a/crates/stackable-operator/src/patchinator/crd.rs +++ b/crates/stackable-operator/src/patchinator/crd.rs @@ -10,6 +10,8 @@ pub struct ObjectOverrides { /// A list of generic Kubernetes objects, which are merged onto the objects that the operator /// creates. /// + /// List entries are arbitrary YAML objects, which need to be valid Kubernetes objects. + // // TODO: Add link to concepts page once it exists #[serde(default)] #[schemars(schema_with = "raw_object_list_schema")] From 7cb1b89a11dbfb13c57d373a4786a22d6ca72b21 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Fri, 21 Nov 2025 10:45:12 +0100 Subject: [PATCH 11/29] Link to concepts page --- crates/stackable-operator/crds/DummyCluster.yaml | 3 +++ crates/stackable-operator/src/patchinator/crd.rs | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/stackable-operator/crds/DummyCluster.yaml b/crates/stackable-operator/crds/DummyCluster.yaml index 751b7ed3f..8d5b17a9f 100644 --- a/crates/stackable-operator/crds/DummyCluster.yaml +++ b/crates/stackable-operator/crds/DummyCluster.yaml @@ -641,6 +641,9 @@ spec: creates. List entries are arbitrary YAML objects, which need to be valid Kubernetes objects. + + Read the [Object overrides documentation](https://docs.stackable.tech/home/nightly/concepts/overrides#object-overrides) + for more information. items: type: object x-kubernetes-preserve-unknown-fields: true diff --git a/crates/stackable-operator/src/patchinator/crd.rs b/crates/stackable-operator/src/patchinator/crd.rs index c1000fd80..1b722d734 100644 --- a/crates/stackable-operator/src/patchinator/crd.rs +++ b/crates/stackable-operator/src/patchinator/crd.rs @@ -11,8 +11,9 @@ pub struct ObjectOverrides { /// creates. /// /// List entries are arbitrary YAML objects, which need to be valid Kubernetes objects. - // - // TODO: Add link to concepts page once it exists + /// + /// Read the [Object overrides documentation](DOCS_BASE_URL_PLACEHOLDER/concepts/overrides#object-overrides) + /// for more information. #[serde(default)] #[schemars(schema_with = "raw_object_list_schema")] pub object_overrides: Vec, From bf1d7534758b206ecf9c273b35f0cd133c8ebc74 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Sun, 23 Nov 2025 16:31:10 +0100 Subject: [PATCH 12/29] Derive PartialEq again --- crates/stackable-operator/CHANGELOG.md | 2 +- crates/stackable-operator/src/cluster_resources.rs | 2 +- crates/stackable-operator/src/patchinator/crd.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 8b9a612fe..3d6e4879c 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to this project will be documented in this file. ### Removed -- BREAKING: `ClusterResources` no longer derives `Eq` and `PartialEq` ([#1118]). +- BREAKING: `ClusterResources` no longer derives `Eq` ([#1118]). [#1118]: https://github.com/stackabletech/operator-rs/pull/1118 diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index d470851ef..52a4961ac 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -423,7 +423,7 @@ impl ClusterResource for Deployment { /// Ok(Action::await_change()) /// } /// ``` -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct ClusterResources<'a> { /// The namespace of the cluster namespace: String, diff --git a/crates/stackable-operator/src/patchinator/crd.rs b/crates/stackable-operator/src/patchinator/crd.rs index 1b722d734..92187c637 100644 --- a/crates/stackable-operator/src/patchinator/crd.rs +++ b/crates/stackable-operator/src/patchinator/crd.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::utils::crds::raw_object_list_schema; -#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ObjectOverrides { /// A list of generic Kubernetes objects, which are merged onto the objects that the operator From cb0de44a42fdd8e535b49bf69da502059e8fb584 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Sun, 23 Nov 2025 16:34:28 +0100 Subject: [PATCH 13/29] Move import --- crates/stackable-operator/src/patchinator/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/stackable-operator/src/patchinator/mod.rs b/crates/stackable-operator/src/patchinator/mod.rs index 04e7cda81..63ffc05f8 100644 --- a/crates/stackable-operator/src/patchinator/mod.rs +++ b/crates/stackable-operator/src/patchinator/mod.rs @@ -1,5 +1,5 @@ use k8s_openapi::DeepMerge; -use kube::core::DynamicObject; +use kube::{ResourceExt, core::DynamicObject}; use serde::de::DeserializeOwned; use snafu::{ResultExt, Snafu}; @@ -32,8 +32,6 @@ pub fn apply_patch(base: &mut R, patch: &DynamicObject) -> Result<(), Error> where R: kube::Resource + DeepMerge + DeserializeOwned, { - use kube::ResourceExt; - let Some(patch_type) = &patch.types else { return Ok(()); }; From 4873ac28152644981c5d7ff8fdf81baf1e3d1aae Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Sun, 23 Nov 2025 16:42:00 +0100 Subject: [PATCH 14/29] Take owned value --- .../src/cluster_resources.rs | 11 +++--- .../stackable-operator/src/patchinator/mod.rs | 36 +++++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index 52a4961ac..2b753d6fa 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -424,7 +424,7 @@ impl ClusterResource for Deployment { /// } /// ``` #[derive(Debug, PartialEq)] -pub struct ClusterResources<'a> { +pub struct ClusterResources { /// The namespace of the cluster namespace: String, @@ -454,10 +454,10 @@ pub struct ClusterResources<'a> { apply_strategy: ClusterResourceApplyStrategy, /// Arbitrary Kubernetes object overrides specified by the user via the CRD. - object_overrides: &'a ObjectOverrides, + object_overrides: ObjectOverrides, } -impl<'a> ClusterResources<'a> { +impl ClusterResources { /// Constructs new `ClusterResources`. /// /// # Arguments @@ -483,7 +483,7 @@ impl<'a> ClusterResources<'a> { controller_name: &str, cluster: &ObjectReference, apply_strategy: ClusterResourceApplyStrategy, - object_overrides: &'a ObjectOverrides, + object_overrides: ObjectOverrides, ) -> Result { let namespace = cluster .namespace @@ -581,7 +581,8 @@ impl<'a> ClusterResources<'a> { let mut mutated = resource.maybe_mutate(&self.apply_strategy); // We apply the object overrides of the user at the very last to offer maximum flexibility. - apply_patches(&mut mutated, self.object_overrides).context(ApplyObjectOverridesSnafu)?; + apply_patches(&mut mutated, self.object_overrides.clone()) + .context(ApplyObjectOverridesSnafu)?; let patched_resource = self .apply_strategy diff --git a/crates/stackable-operator/src/patchinator/mod.rs b/crates/stackable-operator/src/patchinator/mod.rs index 63ffc05f8..836ade76f 100644 --- a/crates/stackable-operator/src/patchinator/mod.rs +++ b/crates/stackable-operator/src/patchinator/mod.rs @@ -18,17 +18,17 @@ pub enum Error { }, } -pub fn apply_patches(base: &mut R, patches: &ObjectOverrides) -> Result<(), Error> +pub fn apply_patches(base: &mut R, patches: ObjectOverrides) -> Result<(), Error> where R: kube::Resource + DeepMerge + DeserializeOwned, { - for patch in &patches.object_overrides { + for patch in patches.object_overrides { apply_patch(base, patch)?; } Ok(()) } -pub fn apply_patch(base: &mut R, patch: &DynamicObject) -> Result<(), Error> +pub fn apply_patch(base: &mut R, patch: DynamicObject) -> Result<(), Error> where R: kube::Resource + DeepMerge + DeserializeOwned, { @@ -53,14 +53,12 @@ where return Ok(()); } - let deserialized_patch = - patch - .clone() - .try_parse() - .with_context(|_| ParseDynamicObjectSnafu { - target_api_version: R::api_version(&()), - target_kind: R::kind(&()), - })?; + let deserialized_patch = patch + .try_parse() + .with_context(|_| ParseDynamicObjectSnafu { + target_api_version: R::api_version(&()), + target_kind: R::kind(&()), + })?; base.merge_from(deserialized_patch); Ok(()) @@ -185,7 +183,7 @@ objectOverrides: .expect("test input is valid YAML"); assert_has_label(&sa, "app.kubernetes.io/name", "trino"); - apply_patches(&mut sa, &object_overrides).unwrap(); + apply_patches(&mut sa, object_overrides).unwrap(); assert_has_label(&sa, "app.kubernetes.io/name", "overwritten"); } @@ -208,7 +206,7 @@ objectOverrides: .expect("test input is valid YAML"); let original = sa.clone(); - apply_patches(&mut sa, &object_overrides).unwrap(); + apply_patches(&mut sa, object_overrides).unwrap(); assert_eq!(sa, original, "The patch shouldn't have changed anything"); } @@ -231,7 +229,7 @@ objectOverrides: .expect("test input is valid YAML"); let original = sa.clone(); - apply_patches(&mut sa, &object_overrides).unwrap(); + apply_patches(&mut sa, object_overrides).unwrap(); assert_eq!(sa, original, "The patch shouldn't have changed anything"); } @@ -254,7 +252,7 @@ objectOverrides: .expect("test input is valid YAML"); let original = sa.clone(); - apply_patches(&mut sa, &object_overrides).unwrap(); + apply_patches(&mut sa, object_overrides).unwrap(); assert_eq!(sa, original, "The patch shouldn't have changed anything"); } @@ -319,7 +317,7 @@ objectOverrides: get_trino_container_image(&sts).as_deref(), Some("trino-image") ); - apply_patches(&mut sts, &object_overrides).unwrap(); + apply_patches(&mut sts, object_overrides).unwrap(); assert_eq!(get_replicas(&sts), Some(3)); assert_eq!( get_trino_container_image(&sts).as_deref(), @@ -371,7 +369,7 @@ objectOverrides: ("log.properties".to_owned(), "=info".to_owned()), ]) ); - apply_patches(&mut cm, &object_overrides).unwrap(); + apply_patches(&mut cm, object_overrides).unwrap(); assert_eq!( cm.data.as_ref().unwrap(), &BTreeMap::from([ @@ -427,7 +425,7 @@ objectOverrides: &BTreeMap::from([("raw".to_owned(), ByteString(b"bar\n".to_vec()))]) ); - apply_patches(&mut secret, &object_overrides).unwrap(); + apply_patches(&mut secret, object_overrides).unwrap(); assert_eq!( secret.string_data.as_ref().unwrap(), &BTreeMap::from([("foo".to_owned(), "overwritten".to_owned()),]) @@ -475,7 +473,7 @@ objectOverrides: .expect("test input is valid YAML"); assert_has_label(&storage_class, "foo", "original"); - apply_patches(&mut storage_class, &object_overrides).unwrap(); + apply_patches(&mut storage_class, object_overrides).unwrap(); assert_has_label(&storage_class, "foo", "overwritten"); } From 5071be841f610668fd930c35d2836992d960225d Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Sun, 23 Nov 2025 16:43:29 +0100 Subject: [PATCH 15/29] Update crates/stackable-operator/src/cluster_resources.rs Co-authored-by: Techassi --- crates/stackable-operator/src/cluster_resources.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index 2b753d6fa..3d7cbe96d 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -580,7 +580,7 @@ impl ClusterResources { let mut mutated = resource.maybe_mutate(&self.apply_strategy); - // We apply the object overrides of the user at the very last to offer maximum flexibility. + // We apply the object overrides of the user at the very end to offer maximum flexibility. apply_patches(&mut mutated, self.object_overrides.clone()) .context(ApplyObjectOverridesSnafu)?; From 713ea103c2ac075c921c211636e61762c54c86c9 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Sun, 23 Nov 2025 16:59:41 +0100 Subject: [PATCH 16/29] PartialEq again --- crates/xtask/src/crd/dummy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/xtask/src/crd/dummy.rs b/crates/xtask/src/crd/dummy.rs index db13cbe6a..5672dce29 100644 --- a/crates/xtask/src/crd/dummy.rs +++ b/crates/xtask/src/crd/dummy.rs @@ -28,7 +28,7 @@ pub mod versioned { status = "v1alpha1::DummyClusterStatus", namespaced, ))] - #[derive(Clone, CustomResource, Debug, Deserialize, JsonSchema, Serialize)] + #[derive(Clone, CustomResource, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] #[schemars(crate = "stackable_operator::schemars")] #[serde(rename_all = "camelCase")] pub struct DummyClusterSpec { From 9f5019b05044020803cc6de25925b4bbf2a4ec19 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Sun, 23 Nov 2025 17:03:28 +0100 Subject: [PATCH 17/29] Improve changelog --- crates/stackable-operator/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 3d6e4879c..22429dc7c 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -6,7 +6,8 @@ All notable changes to this project will be documented in this file. ### Added -- Support `objectOverrides` ([#1118]). +- Support `objectOverrides`, which are a list of generic Kubernetes objects, which are merged onto the objects that the operator creates. + Alongside, a `patchinator` module was added, which takes a Kubernetes object and a list of patches and applies them to the object ([#1118]). ### Changed From e28d64eb6d52ad1b44b22098f2d3dee21e03626d Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Sun, 23 Nov 2025 17:32:28 +0100 Subject: [PATCH 18/29] Add a comment in DeepMerge impl --- .../src/crd/listener/listeners/v1alpha1_impl.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs index 4c2c5b602..da820b098 100644 --- a/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs +++ b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs @@ -31,6 +31,7 @@ impl DeepMerge for ListenerSpec { merge_strategies::list::map( &mut self.ports, other.ports, + // The unique thing identifying a port is it's name &[|lhs, rhs| lhs.name == rhs.name], |current_item, other_item| { DeepMerge::merge_from(current_item, other_item); @@ -49,6 +50,7 @@ impl DeepMerge for ListenerStatus { merge_strategies::list::map( &mut self.ingress_addresses, other.ingress_addresses, + // The unique thing identifying an ingress address is it's address &[|lhs, rhs| lhs.address == rhs.address], |current_item, other_item| { DeepMerge::merge_from(current_item, other_item); From 0d6c134ae28856accefb6922e37512e0c3dca79c Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Sun, 23 Nov 2025 17:38:59 +0100 Subject: [PATCH 19/29] Add some rustdocs --- crates/stackable-operator/src/patchinator/mod.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/stackable-operator/src/patchinator/mod.rs b/crates/stackable-operator/src/patchinator/mod.rs index 836ade76f..9bc8e1ae2 100644 --- a/crates/stackable-operator/src/patchinator/mod.rs +++ b/crates/stackable-operator/src/patchinator/mod.rs @@ -18,6 +18,10 @@ pub enum Error { }, } +// Takes an arbitrary Kubernetes object (`base`) and applies the given list of patches onto it. +// +// Patches are only applied to objects that have the same apiVersion, kind, name +// and namespace. pub fn apply_patches(base: &mut R, patches: ObjectOverrides) -> Result<(), Error> where R: kube::Resource + DeepMerge + DeserializeOwned, @@ -28,6 +32,10 @@ where Ok(()) } +// Takes an arbitrary Kubernetes object (`base`) and applies the patch. +// +// Patches are only applied to objects that have the same apiVersion, kind, name +// and namespace. pub fn apply_patch(base: &mut R, patch: DynamicObject) -> Result<(), Error> where R: kube::Resource + DeepMerge + DeserializeOwned, From c573b7a5252603bf052b71e2ab88d2dd8c020661 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Mon, 24 Nov 2025 11:47:48 +0100 Subject: [PATCH 20/29] patchinator -> deep_merger --- .../src/cluster_resources.rs | 12 ++-- .../src/{patchinator => deep_merger}/crd.rs | 0 .../src/{patchinator => deep_merger}/mod.rs | 71 ++++++++++--------- crates/stackable-operator/src/lib.rs | 2 +- crates/xtask/src/crd/dummy.rs | 2 +- 5 files changed, 45 insertions(+), 42 deletions(-) rename crates/stackable-operator/src/{patchinator => deep_merger}/crd.rs (100%) rename crates/stackable-operator/src/{patchinator => deep_merger}/mod.rs (86%) diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index 3d7cbe96d..2952f5e9c 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -38,11 +38,11 @@ use crate::{ }, }, crd::listener, + deep_merger::{self, ObjectOverrides, apply_object_overrides}, kvp::{ Label, LabelError, Labels, consts::{K8S_APP_INSTANCE_KEY, K8S_APP_MANAGED_BY_KEY, K8S_APP_NAME_KEY}, }, - patchinator::{self, ObjectOverrides, apply_patches}, utils::format_full_controller_name, }; @@ -90,7 +90,7 @@ pub enum Error { }, #[snafu(display("failed to apply user-provided object overrides"))] - ApplyObjectOverrides { source: patchinator::Error }, + ApplyObjectOverrides { source: deep_merger::Error }, } /// A cluster resource handled by [`ClusterResources`]. @@ -581,21 +581,21 @@ impl ClusterResources { let mut mutated = resource.maybe_mutate(&self.apply_strategy); // We apply the object overrides of the user at the very end to offer maximum flexibility. - apply_patches(&mut mutated, self.object_overrides.clone()) + apply_object_overrides(&mut mutated, self.object_overrides.clone()) .context(ApplyObjectOverridesSnafu)?; - let patched_resource = self + let merged_resource = self .apply_strategy .run(&self.manager, &mutated, client) .await?; - let resource_id = patched_resource.uid().context(MissingObjectKeySnafu { + let resource_id = merged_resource.uid().context(MissingObjectKeySnafu { key: "metadata/uid", })?; self.resource_ids.insert(resource_id); - Ok(patched_resource) + Ok(merged_resource) } /// Checks that the given `labels` contain the given `expected_label` with diff --git a/crates/stackable-operator/src/patchinator/crd.rs b/crates/stackable-operator/src/deep_merger/crd.rs similarity index 100% rename from crates/stackable-operator/src/patchinator/crd.rs rename to crates/stackable-operator/src/deep_merger/crd.rs diff --git a/crates/stackable-operator/src/patchinator/mod.rs b/crates/stackable-operator/src/deep_merger/mod.rs similarity index 86% rename from crates/stackable-operator/src/patchinator/mod.rs rename to crates/stackable-operator/src/deep_merger/mod.rs index 9bc8e1ae2..4d9ada962 100644 --- a/crates/stackable-operator/src/patchinator/mod.rs +++ b/crates/stackable-operator/src/deep_merger/mod.rs @@ -18,56 +18,59 @@ pub enum Error { }, } -// Takes an arbitrary Kubernetes object (`base`) and applies the given list of patches onto it. +// Takes an arbitrary Kubernetes object (`base`) and applies the given list of deep merges onto it. // -// Patches are only applied to objects that have the same apiVersion, kind, name +// Merges are only applied to objects that have the same apiVersion, kind, name // and namespace. -pub fn apply_patches(base: &mut R, patches: ObjectOverrides) -> Result<(), Error> +pub fn apply_object_overrides( + base: &mut R, + object_overrides: ObjectOverrides, +) -> Result<(), Error> where R: kube::Resource + DeepMerge + DeserializeOwned, { - for patch in patches.object_overrides { - apply_patch(base, patch)?; + for object_override in object_overrides.object_overrides { + apply_deep_merge(base, object_override)?; } Ok(()) } -// Takes an arbitrary Kubernetes object (`base`) and applies the patch. +// Takes an arbitrary Kubernetes object (`base`) and applies the deep merge. // -// Patches are only applied to objects that have the same apiVersion, kind, name +// Merges are only applied to objects that have the same apiVersion, kind, name // and namespace. -pub fn apply_patch(base: &mut R, patch: DynamicObject) -> Result<(), Error> +pub fn apply_deep_merge(base: &mut R, merge: DynamicObject) -> Result<(), Error> where R: kube::Resource + DeepMerge + DeserializeOwned, { - let Some(patch_type) = &patch.types else { + let Some(merge_type) = &merge.types else { return Ok(()); }; - if patch_type.api_version != R::api_version(&()) || patch_type.kind != R::kind(&()) { + if merge_type.api_version != R::api_version(&()) || merge_type.kind != R::kind(&()) { return Ok(()); } - let Some(patch_name) = &patch.metadata.name else { + let Some(merge_name) = &merge.metadata.name else { return Ok(()); }; // The name always needs to match - if &base.name_any() != patch_name { + if &base.name_any() != merge_name { return Ok(()); } // If there is a namespace on the base object, it needs to match as well // Note that it is not set for cluster-scoped objects. - if base.namespace() != patch.metadata.namespace { + if base.namespace() != merge.metadata.namespace { return Ok(()); } - let deserialized_patch = patch + let deserialized_merge = merge .try_parse() .with_context(|_| ParseDynamicObjectSnafu { target_api_version: R::api_version(&()), target_kind: R::kind(&()), })?; - base.merge_from(deserialized_patch); + base.merge_from(deserialized_merge); Ok(()) } @@ -173,7 +176,7 @@ metadata: } #[test] - fn service_account_patched() { + fn service_account_merged() { let mut sa = generate_service_account(); let object_overrides: ObjectOverrides = serde_yaml::from_str( " @@ -191,12 +194,12 @@ objectOverrides: .expect("test input is valid YAML"); assert_has_label(&sa, "app.kubernetes.io/name", "trino"); - apply_patches(&mut sa, object_overrides).unwrap(); + apply_object_overrides(&mut sa, object_overrides).unwrap(); assert_has_label(&sa, "app.kubernetes.io/name", "overwritten"); } #[test] - fn service_account_not_patched_as_different_name() { + fn service_account_not_merged_as_different_name() { let mut sa = generate_service_account(); let object_overrides: ObjectOverrides = serde_yaml::from_str( " @@ -214,12 +217,12 @@ objectOverrides: .expect("test input is valid YAML"); let original = sa.clone(); - apply_patches(&mut sa, object_overrides).unwrap(); - assert_eq!(sa, original, "The patch shouldn't have changed anything"); + apply_object_overrides(&mut sa, object_overrides).unwrap(); + assert_eq!(sa, original, "The merge shouldn't have changed anything"); } #[test] - fn service_account_not_patched_as_different_namespace() { + fn service_account_not_merged_as_different_namespace() { let mut sa = generate_service_account(); let object_overrides: ObjectOverrides = serde_yaml::from_str( " @@ -237,12 +240,12 @@ objectOverrides: .expect("test input is valid YAML"); let original = sa.clone(); - apply_patches(&mut sa, object_overrides).unwrap(); - assert_eq!(sa, original, "The patch shouldn't have changed anything"); + apply_object_overrides(&mut sa, object_overrides).unwrap(); + assert_eq!(sa, original, "The merge shouldn't have changed anything"); } #[test] - fn service_account_not_patched_as_different_api_version() { + fn service_account_not_merged_as_different_api_version() { let mut sa = generate_service_account(); let object_overrides: ObjectOverrides = serde_yaml::from_str( " @@ -260,12 +263,12 @@ objectOverrides: .expect("test input is valid YAML"); let original = sa.clone(); - apply_patches(&mut sa, object_overrides).unwrap(); - assert_eq!(sa, original, "The patch shouldn't have changed anything"); + apply_object_overrides(&mut sa, object_overrides).unwrap(); + assert_eq!(sa, original, "The merge shouldn't have changed anything"); } #[test] - fn statefulset_patched_multiple_patches() { + fn statefulset_merged_multiple_merges() { let mut sts = generate_stateful_set(); let object_overrides: ObjectOverrides = serde_yaml::from_str( " @@ -325,7 +328,7 @@ objectOverrides: get_trino_container_image(&sts).as_deref(), Some("trino-image") ); - apply_patches(&mut sts, object_overrides).unwrap(); + apply_object_overrides(&mut sts, object_overrides).unwrap(); assert_eq!(get_replicas(&sts), Some(3)); assert_eq!( get_trino_container_image(&sts).as_deref(), @@ -334,7 +337,7 @@ objectOverrides: } #[test] - fn configmap_patched() { + fn configmap_merged() { let mut cm: ConfigMap = serde_yaml::from_str( " apiVersion: v1 @@ -377,7 +380,7 @@ objectOverrides: ("log.properties".to_owned(), "=info".to_owned()), ]) ); - apply_patches(&mut cm, object_overrides).unwrap(); + apply_object_overrides(&mut cm, object_overrides).unwrap(); assert_eq!( cm.data.as_ref().unwrap(), &BTreeMap::from([ @@ -395,7 +398,7 @@ objectOverrides: } #[test] - fn secret_patched() { + fn secret_merged() { let mut secret: Secret = serde_yaml::from_str( " apiVersion: v1 @@ -433,7 +436,7 @@ objectOverrides: &BTreeMap::from([("raw".to_owned(), ByteString(b"bar\n".to_vec()))]) ); - apply_patches(&mut secret, object_overrides).unwrap(); + apply_object_overrides(&mut secret, object_overrides).unwrap(); assert_eq!( secret.string_data.as_ref().unwrap(), &BTreeMap::from([("foo".to_owned(), "overwritten".to_owned()),]) @@ -445,7 +448,7 @@ objectOverrides: } #[test] - fn cluster_scoped_object_patched() { + fn cluster_scoped_object_merged() { let mut storage_class: StorageClass = serde_yaml::from_str( " apiVersion: storage.k8s.io/v1 @@ -481,7 +484,7 @@ objectOverrides: .expect("test input is valid YAML"); assert_has_label(&storage_class, "foo", "original"); - apply_patches(&mut storage_class, object_overrides).unwrap(); + apply_object_overrides(&mut storage_class, object_overrides).unwrap(); assert_has_label(&storage_class, "foo", "overwritten"); } diff --git a/crates/stackable-operator/src/lib.rs b/crates/stackable-operator/src/lib.rs index 49295c6eb..c73e72143 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 deep_merger; pub mod eos; pub mod helm; pub mod iter; @@ -22,7 +23,6 @@ pub mod kvp; pub mod logging; pub mod memory; pub mod namespace; -pub mod patchinator; pub mod pod_utils; pub mod product_config_utils; pub mod product_logging; diff --git a/crates/xtask/src/crd/dummy.rs b/crates/xtask/src/crd/dummy.rs index 5672dce29..ff35ea2ca 100644 --- a/crates/xtask/src/crd/dummy.rs +++ b/crates/xtask/src/crd/dummy.rs @@ -2,8 +2,8 @@ use serde::{Deserialize, Serialize}; use stackable_operator::{ commons::resources::{JvmHeapLimits, Resources}, config::fragment::Fragment, + deep_merger::ObjectOverrides, kube::CustomResource, - patchinator::ObjectOverrides, role_utils::Role, schemars::JsonSchema, status::condition::ClusterCondition, From 7fde5b3d82928043bbc30470b30d43a94dc760cd Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Mon, 24 Nov 2025 11:52:17 +0100 Subject: [PATCH 21/29] Fix remaining "patch" mentions --- crates/stackable-operator/CHANGELOG.md | 2 +- crates/stackable-operator/src/cluster_resources.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 22429dc7c..177b79ce5 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to this project will be documented in this file. ### Added - Support `objectOverrides`, which are a list of generic Kubernetes objects, which are merged onto the objects that the operator creates. - Alongside, a `patchinator` module was added, which takes a Kubernetes object and a list of patches and applies them to the object ([#1118]). + Alongside, a `deep_merger` module was added, which takes a Kubernetes object and a list of depp merged and applies them to the object ([#1118]). ### Changed diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index 2952f5e9c..f625e7f60 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -337,7 +337,7 @@ impl ClusterResource for Deployment { /// use serde::{Deserialize, Serialize}; /// use stackable_operator::client::Client; /// use stackable_operator::cluster_resources::{self, ClusterResourceApplyStrategy, ClusterResources}; -/// use stackable_operator::patchinator::ObjectOverrides; +/// use stackable_operator::deep_merger::ObjectOverrides; /// use stackable_operator::product_config_utils::ValidatedRoleConfigByPropertyKind; /// use stackable_operator::role_utils::Role; /// use std::sync::Arc; @@ -380,7 +380,7 @@ impl ClusterResource for Deployment { /// CONTROLLER_NAME, /// &app.object_ref(&()), /// ClusterResourceApplyStrategy::Default, -/// &app.spec.object_overrides, +/// app.spec.object_overrides.clone(), /// ) /// .map_err(|source| Error::CreateClusterResources { source })?; /// From d8274d0a0c47fe5debb3f132f1b833371bd00d13 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Mon, 24 Nov 2025 11:54:17 +0100 Subject: [PATCH 22/29] Fixup accidential change --- crates/stackable-operator/src/cluster_resources.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index f625e7f60..6e19f540a 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -584,18 +584,18 @@ impl ClusterResources { apply_object_overrides(&mut mutated, self.object_overrides.clone()) .context(ApplyObjectOverridesSnafu)?; - let merged_resource = self + let patched_resource = self .apply_strategy .run(&self.manager, &mutated, client) .await?; - let resource_id = merged_resource.uid().context(MissingObjectKeySnafu { + let resource_id = patched_resource.uid().context(MissingObjectKeySnafu { key: "metadata/uid", })?; self.resource_ids.insert(resource_id); - Ok(merged_resource) + Ok(patched_resource) } /// Checks that the given `labels` contain the given `expected_label` with From 956d52375146ab018695f65b74479bdd47c01202 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Mon, 24 Nov 2025 11:55:54 +0100 Subject: [PATCH 23/29] Rename patch -> merge --- .../src/crd/listener/listeners/v1alpha1_impl.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs index da820b098..224c56d37 100644 --- a/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs +++ b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs @@ -111,7 +111,7 @@ ports: ) .unwrap(); - let patch: ListenerSpec = serde_yaml::from_str( + let merge: ListenerSpec = serde_yaml::from_str( " className: custom-listener-class extraPodSelectorLabels: @@ -125,7 +125,7 @@ publishNotReadyAddresses: false ) .unwrap(); - base.merge_from(patch); + base.merge_from(merge); let expected: ListenerSpec = serde_yaml::from_str( " From b4f98e037bfab3867ea8ecd6065dc026d5face5a Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Mon, 24 Nov 2025 12:11:57 +0100 Subject: [PATCH 24/29] Use indoc for tests --- Cargo.lock | 1 + crates/stackable-operator/Cargo.toml | 1 + .../crd/listener/listeners/v1alpha1_impl.rs | 89 ++-- .../stackable-operator/src/deep_merger/mod.rs | 380 +++++++++--------- 4 files changed, 223 insertions(+), 248 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9a4939bd4..be573a64f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2868,6 +2868,7 @@ dependencies = [ "futures", "http", "indexmap", + "indoc", "json-patch", "k8s-openapi", "kube", diff --git a/crates/stackable-operator/Cargo.toml b/crates/stackable-operator/Cargo.toml index 78122a815..c6787094d 100644 --- a/crates/stackable-operator/Cargo.toml +++ b/crates/stackable-operator/Cargo.toml @@ -55,5 +55,6 @@ tracing-subscriber.workspace = true url.workspace = true [dev-dependencies] +indoc.workspace = true rstest.workspace = true tempfile.workspace = true diff --git a/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs index 224c56d37..f397d0581 100644 --- a/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs +++ b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs @@ -90,60 +90,55 @@ impl DeepMerge for ListenerPort { #[cfg(test)] mod tests { + use indoc::indoc; + use super::*; #[test] fn deep_merge_listener() { - let mut base: ListenerSpec = serde_yaml::from_str( - " -className: my-listener-class -extraPodSelectorLabels: - foo: bar -ports: - - name: http - port: 8080 - protocol: http - - name: https - port: 8080 - protocol: https -# publishNotReadyAddresses defaults to true -", - ) - .unwrap(); + let mut base: ListenerSpec = serde_yaml::from_str(indoc! {" + className: my-listener-class + extraPodSelectorLabels: + foo: bar + ports: + - name: http + port: 8080 + protocol: http + - name: https + port: 8080 + protocol: https + # publishNotReadyAddresses defaults to true + "}) + .expect("test YAML is valid"); - let merge: ListenerSpec = serde_yaml::from_str( - " -className: custom-listener-class -extraPodSelectorLabels: - foo: overridden - extra: label -ports: - - name: https - port: 8443 -publishNotReadyAddresses: false -", - ) - .unwrap(); + let merge: ListenerSpec = serde_yaml::from_str(indoc! {" + className: custom-listener-class + extraPodSelectorLabels: + foo: overridden + extra: label + ports: + - name: https + port: 8443 + publishNotReadyAddresses: false + "}) + .expect("test YAML is valid"); base.merge_from(merge); - - let expected: ListenerSpec = serde_yaml::from_str( - " -className: custom-listener-class -extraPodSelectorLabels: - foo: overridden - extra: label -ports: - - name: http - port: 8080 - protocol: http - - name: https - port: 8443 # overridden - protocol: https -publishNotReadyAddresses: false -", - ) - .unwrap(); + let expected: ListenerSpec = serde_yaml::from_str(indoc! {" + className: custom-listener-class + extraPodSelectorLabels: + foo: overridden + extra: label + ports: + - name: http + port: 8080 + protocol: http + - name: https + port: 8443 # overridden + protocol: https + publishNotReadyAddresses: false + "}) + .expect("test YAML is valid"); assert_eq!(base, expected); } diff --git a/crates/stackable-operator/src/deep_merger/mod.rs b/crates/stackable-operator/src/deep_merger/mod.rs index 4d9ada962..6e8f90c2c 100644 --- a/crates/stackable-operator/src/deep_merger/mod.rs +++ b/crates/stackable-operator/src/deep_merger/mod.rs @@ -79,6 +79,7 @@ where mod tests { use std::{collections::BTreeMap, vec}; + use indoc::indoc; use k8s_openapi::{ ByteString, Metadata, api::{ @@ -100,26 +101,24 @@ mod tests { /// Using [`serde_yaml`] to generate the test data fn generate_service_account() -> ServiceAccount { - serde_yaml::from_str( - " -apiVersion: v1 -kind: ServiceAccount -metadata: - name: trino-serviceaccount - namespace: default - labels: - app.kubernetes.io/instance: trino - app.kubernetes.io/managed-by: trino.stackable.tech_trinocluster - app.kubernetes.io/name: trino - ownerReferences: - - apiVersion: trino.stackable.tech/v1alpha1 - controller: true - kind: TrinoCluster - name: trino - uid: c85bfb53-a28e-4782-baaf-3c218a25f192 -", - ) - .unwrap() + serde_yaml::from_str(indoc! {" + apiVersion: v1 + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/instance: trino + app.kubernetes.io/managed-by: trino.stackable.tech_trinocluster + app.kubernetes.io/name: trino + ownerReferences: + - apiVersion: trino.stackable.tech/v1alpha1 + controller: true + kind: TrinoCluster + name: trino + uid: c85bfb53-a28e-4782-baaf-3c218a25f192 + "}) + .expect("test YAML is valid") } /// Generate the test data programmatically (as operators would normally do) @@ -178,20 +177,19 @@ metadata: #[test] fn service_account_merged() { let mut sa = generate_service_account(); - let object_overrides: ObjectOverrides = serde_yaml::from_str( - " -objectOverrides: - - apiVersion: v1 - kind: ServiceAccount - metadata: - name: trino-serviceaccount - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar -", - ) - .expect("test input is valid YAML"); + + let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" + objectOverrides: + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar + "}) + .expect("test YAML is valid"); assert_has_label(&sa, "app.kubernetes.io/name", "trino"); apply_object_overrides(&mut sa, object_overrides).unwrap(); @@ -201,20 +199,18 @@ objectOverrides: #[test] fn service_account_not_merged_as_different_name() { let mut sa = generate_service_account(); - let object_overrides: ObjectOverrides = serde_yaml::from_str( - " -objectOverrides: - - apiVersion: v1 - kind: ServiceAccount - metadata: - name: other-sa - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar -", - ) - .expect("test input is valid YAML"); + let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" + objectOverrides: + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: other-sa # name mismatch + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar + "}) + .expect("test YAML is valid"); let original = sa.clone(); apply_object_overrides(&mut sa, object_overrides).unwrap(); @@ -224,20 +220,18 @@ objectOverrides: #[test] fn service_account_not_merged_as_different_namespace() { let mut sa = generate_service_account(); - let object_overrides: ObjectOverrides = serde_yaml::from_str( - " -objectOverrides: - - apiVersion: v1 - kind: ServiceAccount - metadata: - name: trino-serviceaccount - namespace: other-namespace - labels: - app.kubernetes.io/name: overwritten - foo: bar -", - ) - .expect("test input is valid YAML"); + let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" + objectOverrides: + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: other-namespace # namespace mismatch + labels: + app.kubernetes.io/name: overwritten + foo: bar + "}) + .expect("test YAML is valid"); let original = sa.clone(); apply_object_overrides(&mut sa, object_overrides).unwrap(); @@ -247,20 +241,18 @@ objectOverrides: #[test] fn service_account_not_merged_as_different_api_version() { let mut sa = generate_service_account(); - let object_overrides: ObjectOverrides = serde_yaml::from_str( - " -objectOverrides: - - apiVersion: v42 - kind: ServiceAccount - metadata: - name: trino-serviceaccount - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar -", - ) - .expect("test input is valid YAML"); + let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" + objectOverrides: + - apiVersion: v42 # apiVersion mismatch + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar + "}) + .expect("test YAML is valid"); let original = sa.clone(); apply_object_overrides(&mut sa, object_overrides).unwrap(); @@ -270,41 +262,39 @@ objectOverrides: #[test] fn statefulset_merged_multiple_merges() { let mut sts = generate_stateful_set(); - let object_overrides: ObjectOverrides = serde_yaml::from_str( - " -objectOverrides: - - apiVersion: v1 - kind: ServiceAccount - metadata: - name: trino-serviceaccount - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar - - apiVersion: apps/v1 - kind: StatefulSet - metadata: - name: trino-coordinator-default - namespace: default - spec: - template: - metadata: - labels: - foo: bar - spec: - containers: - - name: trino - image: custom-image - - apiVersion: apps/v1 - kind: StatefulSet - metadata: - name: trino-coordinator-default - namespace: default - spec: - replicas: 3 -", - ) - .expect("test input is valid YAML"); + let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" + objectOverrides: + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar + - apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: trino-coordinator-default + namespace: default + spec: + template: + metadata: + labels: + foo: bar + spec: + containers: + - name: trino + image: custom-image + - apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: trino-coordinator-default + namespace: default + spec: + replicas: 3 + "}) + .expect("test YAML is valid"); let get_replicas = |sts: &StatefulSet| sts.spec.as_ref().unwrap().replicas; let get_trino_container = |sts: &StatefulSet| { @@ -338,36 +328,32 @@ objectOverrides: #[test] fn configmap_merged() { - let mut cm: ConfigMap = serde_yaml::from_str( - " - apiVersion: v1 - kind: ConfigMap - metadata: - name: game-demo - data: - foo: bar - config.properties: |- - coordinator=true - http-server.https.enabled=true - log.properties: |- - =info -", - ) - .unwrap(); - let object_overrides: ObjectOverrides = serde_yaml::from_str( - " -objectOverrides: - - apiVersion: v1 - kind: ConfigMap - metadata: - name: game-demo - data: - foo: overwritten - log.properties: |- - =info,tech.stackable=debug -", - ) - .expect("test input is valid YAML"); + let mut cm: ConfigMap = serde_yaml::from_str(indoc! {" + apiVersion: v1 + kind: ConfigMap + metadata: + name: game-demo + data: + foo: bar + config.properties: |- + coordinator=true + http-server.https.enabled=true + log.properties: |- + =info + "}) + .expect("test YAML is valid"); + let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" + objectOverrides: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: game-demo + data: + foo: overwritten + log.properties: |- + =info,tech.stackable=debug + "}) + .expect("test YAML is valid"); assert_eq!( cm.data.as_ref().unwrap(), @@ -399,33 +385,29 @@ objectOverrides: #[test] fn secret_merged() { - let mut secret: Secret = serde_yaml::from_str( - " - apiVersion: v1 - kind: Secret - metadata: - name: dotfile-secret - stringData: - foo: bar - data: - raw: YmFyCg== # echo bar | base64 -", - ) - .unwrap(); - let object_overrides: ObjectOverrides = serde_yaml::from_str( - " -objectOverrides: - - apiVersion: v1 - kind: Secret - metadata: - name: dotfile-secret - stringData: - foo: overwritten - data: - raw: b3ZlcndyaXR0ZW4K # echo overwritten | base64 -", - ) - .expect("test input is valid YAML"); + let mut secret: Secret = serde_yaml::from_str(indoc! {" + apiVersion: v1 + kind: Secret + metadata: + name: dotfile-secret + stringData: + foo: bar + data: + raw: YmFyCg== # echo bar | base64 + "}) + .expect("test YAML is valid"); + let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" + objectOverrides: + - apiVersion: v1 + kind: Secret + metadata: + name: dotfile-secret + stringData: + foo: overwritten + data: + raw: b3ZlcndyaXR0ZW4K # echo overwritten | base64 + "}) + .expect("test YAML is valid"); assert_eq!( secret.string_data.as_ref().unwrap(), @@ -449,39 +431,35 @@ objectOverrides: #[test] fn cluster_scoped_object_merged() { - let mut storage_class: StorageClass = serde_yaml::from_str( - " - apiVersion: storage.k8s.io/v1 - kind: StorageClass - metadata: - name: low-latency - labels: - foo: original - annotations: - storageclass.kubernetes.io/is-default-class: \"false\" - provisioner: csi-driver.example-vendor.example -", - ) - .unwrap(); - let object_overrides: ObjectOverrides = serde_yaml::from_str( - " -objectOverrides: - - apiVersion: v1 - kind: ServiceAccount - - apiVersion: storage.k8s.io/v1 - kind: StorageClass - metadata: - name: low-latency - labels: - foo: overwritten - annotations: - new: annotation - provisioner: custom-provisioner - - foo: bar - - {} -", - ) - .expect("test input is valid YAML"); + let mut storage_class: StorageClass = serde_yaml::from_str(indoc! {" + apiVersion: storage.k8s.io/v1 + kind: StorageClass + metadata: + name: low-latency + labels: + foo: original + annotations: + storageclass.kubernetes.io/is-default-class: \"false\" + provisioner: csi-driver.example-vendor.example + "}) + .expect("test YAML is valid"); + let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" + objectOverrides: + - apiVersion: v1 + kind: ServiceAccount + - apiVersion: storage.k8s.io/v1 + kind: StorageClass + metadata: + name: low-latency + labels: + foo: overwritten + annotations: + new: annotation + provisioner: custom-provisioner + - foo: bar + - {} + "}) + .expect("test YAML is valid"); assert_has_label(&storage_class, "foo", "original"); apply_object_overrides(&mut storage_class, object_overrides).unwrap(); From fc38a7c9b9c728aaf595f1bcb7fcc8ef1d4f5d05 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 27 Nov 2025 17:44:02 +0100 Subject: [PATCH 25/29] refactor: Move stuff into ObjectOverrides::apply_to --- .../stackable-operator/crds/DummyCluster.yaml | 2 +- .../src/cluster_resources.rs | 5 +- .../stackable-operator/src/deep_merger/crd.rs | 23 ++++++- .../stackable-operator/src/deep_merger/mod.rs | 65 ++++++++++--------- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/crates/stackable-operator/crds/DummyCluster.yaml b/crates/stackable-operator/crds/DummyCluster.yaml index 8d5b17a9f..5363790e9 100644 --- a/crates/stackable-operator/crds/DummyCluster.yaml +++ b/crates/stackable-operator/crds/DummyCluster.yaml @@ -637,7 +637,7 @@ spec: objectOverrides: default: [] description: |- - A list of generic Kubernetes objects, which are merged onto the objects that the operator + A list of generic Kubernetes objects, which are merged on the objects that the operator creates. List entries are arbitrary YAML objects, which need to be valid Kubernetes objects. diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index 6e19f540a..c2c743567 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -38,7 +38,7 @@ use crate::{ }, }, crd::listener, - deep_merger::{self, ObjectOverrides, apply_object_overrides}, + deep_merger::{self, ObjectOverrides}, kvp::{ Label, LabelError, Labels, consts::{K8S_APP_INSTANCE_KEY, K8S_APP_MANAGED_BY_KEY, K8S_APP_NAME_KEY}, @@ -581,7 +581,8 @@ impl ClusterResources { let mut mutated = resource.maybe_mutate(&self.apply_strategy); // We apply the object overrides of the user at the very end to offer maximum flexibility. - apply_object_overrides(&mut mutated, self.object_overrides.clone()) + self.object_overrides + .apply_to(&mut mutated) .context(ApplyObjectOverridesSnafu)?; let patched_resource = self diff --git a/crates/stackable-operator/src/deep_merger/crd.rs b/crates/stackable-operator/src/deep_merger/crd.rs index 92187c637..f0a6a841a 100644 --- a/crates/stackable-operator/src/deep_merger/crd.rs +++ b/crates/stackable-operator/src/deep_merger/crd.rs @@ -1,13 +1,15 @@ +use k8s_openapi::DeepMerge; use kube::api::DynamicObject; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use super::apply_deep_merge; use crate::utils::crds::raw_object_list_schema; #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ObjectOverrides { - /// A list of generic Kubernetes objects, which are merged onto the objects that the operator + /// A list of generic Kubernetes objects, which are merged on the objects that the operator /// creates. /// /// List entries are arbitrary YAML objects, which need to be valid Kubernetes objects. @@ -18,3 +20,20 @@ pub struct ObjectOverrides { #[schemars(schema_with = "raw_object_list_schema")] pub object_overrides: Vec, } + +impl ObjectOverrides { + /// Takes an arbitrary Kubernetes object (`base`) and applies the configured list of deep merges + /// to it. + /// + /// Merges are only applied to objects that have the same apiVersion, kind, name + /// and namespace. + pub fn apply_to(&self, base: &mut R) -> Result<(), super::Error> + where + R: kube::Resource + DeepMerge + DeserializeOwned, + { + for object_override in &self.object_overrides { + apply_deep_merge(base, object_override)?; + } + Ok(()) + } +} diff --git a/crates/stackable-operator/src/deep_merger/mod.rs b/crates/stackable-operator/src/deep_merger/mod.rs index 6e8f90c2c..b1e9e0357 100644 --- a/crates/stackable-operator/src/deep_merger/mod.rs +++ b/crates/stackable-operator/src/deep_merger/mod.rs @@ -18,28 +18,15 @@ pub enum Error { }, } -// Takes an arbitrary Kubernetes object (`base`) and applies the given list of deep merges onto it. -// -// Merges are only applied to objects that have the same apiVersion, kind, name -// and namespace. -pub fn apply_object_overrides( - base: &mut R, - object_overrides: ObjectOverrides, -) -> Result<(), Error> -where - R: kube::Resource + DeepMerge + DeserializeOwned, -{ - for object_override in object_overrides.object_overrides { - apply_deep_merge(base, object_override)?; - } - Ok(()) -} - -// Takes an arbitrary Kubernetes object (`base`) and applies the deep merge. -// -// Merges are only applied to objects that have the same apiVersion, kind, name -// and namespace. -pub fn apply_deep_merge(base: &mut R, merge: DynamicObject) -> Result<(), Error> +/// Takes an arbitrary Kubernetes object (`base`) and applies the deep merge. +/// +/// Merges are only applied to objects that have the same apiVersion, kind, name +/// and namespace. +/// +/// In case the merge matches the base object, it will get cloned prior to merging. +/// We modeled it this way, as most of the time it won't match, so we don't need to proactively +/// clone. +pub fn apply_deep_merge(base: &mut R, merge: &DynamicObject) -> Result<(), Error> where R: kube::Resource + DeepMerge + DeserializeOwned, { @@ -65,6 +52,8 @@ where } let deserialized_merge = merge + // We only clone if needed, most cases the deep merges don't actually apply + .to_owned() .try_parse() .with_context(|_| ParseDynamicObjectSnafu { target_api_version: R::api_version(&()), @@ -192,7 +181,9 @@ mod tests { .expect("test YAML is valid"); assert_has_label(&sa, "app.kubernetes.io/name", "trino"); - apply_object_overrides(&mut sa, object_overrides).unwrap(); + object_overrides + .apply_to(&mut sa) + .expect("merging onto test object works"); assert_has_label(&sa, "app.kubernetes.io/name", "overwritten"); } @@ -213,7 +204,9 @@ mod tests { .expect("test YAML is valid"); let original = sa.clone(); - apply_object_overrides(&mut sa, object_overrides).unwrap(); + object_overrides + .apply_to(&mut sa) + .expect("merging onto test object works"); assert_eq!(sa, original, "The merge shouldn't have changed anything"); } @@ -234,7 +227,9 @@ mod tests { .expect("test YAML is valid"); let original = sa.clone(); - apply_object_overrides(&mut sa, object_overrides).unwrap(); + object_overrides + .apply_to(&mut sa) + .expect("merging onto test object works"); assert_eq!(sa, original, "The merge shouldn't have changed anything"); } @@ -255,7 +250,9 @@ mod tests { .expect("test YAML is valid"); let original = sa.clone(); - apply_object_overrides(&mut sa, object_overrides).unwrap(); + object_overrides + .apply_to(&mut sa) + .expect("merging onto test object works"); assert_eq!(sa, original, "The merge shouldn't have changed anything"); } @@ -318,7 +315,9 @@ mod tests { get_trino_container_image(&sts).as_deref(), Some("trino-image") ); - apply_object_overrides(&mut sts, object_overrides).unwrap(); + object_overrides + .apply_to(&mut sts) + .expect("merging onto test object works"); assert_eq!(get_replicas(&sts), Some(3)); assert_eq!( get_trino_container_image(&sts).as_deref(), @@ -366,7 +365,9 @@ mod tests { ("log.properties".to_owned(), "=info".to_owned()), ]) ); - apply_object_overrides(&mut cm, object_overrides).unwrap(); + object_overrides + .apply_to(&mut cm) + .expect("merging onto test object works"); assert_eq!( cm.data.as_ref().unwrap(), &BTreeMap::from([ @@ -418,7 +419,9 @@ mod tests { &BTreeMap::from([("raw".to_owned(), ByteString(b"bar\n".to_vec()))]) ); - apply_object_overrides(&mut secret, object_overrides).unwrap(); + object_overrides + .apply_to(&mut secret) + .expect("merging onto test object works"); assert_eq!( secret.string_data.as_ref().unwrap(), &BTreeMap::from([("foo".to_owned(), "overwritten".to_owned()),]) @@ -462,7 +465,9 @@ mod tests { .expect("test YAML is valid"); assert_has_label(&storage_class, "foo", "original"); - apply_object_overrides(&mut storage_class, object_overrides).unwrap(); + object_overrides + .apply_to(&mut storage_class) + .expect("merging onto test object works"); assert_has_label(&storage_class, "foo", "overwritten"); } From fe29dcb979750a7776bbd1ecc33c01e8a792b99a Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 27 Nov 2025 18:05:24 +0100 Subject: [PATCH 26/29] refactor: Switch ObjectOverrides to unit struct --- crates/stackable-operator/src/deep_merger/crd.rs | 14 +++++++------- crates/xtask/src/crd/dummy.rs | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/stackable-operator/src/deep_merger/crd.rs b/crates/stackable-operator/src/deep_merger/crd.rs index f0a6a841a..e955c197d 100644 --- a/crates/stackable-operator/src/deep_merger/crd.rs +++ b/crates/stackable-operator/src/deep_merger/crd.rs @@ -6,9 +6,8 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned}; use super::apply_deep_merge; use crate::utils::crds::raw_object_list_schema; -#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ObjectOverrides { +#[derive(Clone, Debug, Deserialize, Default, JsonSchema, Serialize, PartialEq)] +pub struct ObjectOverrides( /// A list of generic Kubernetes objects, which are merged on the objects that the operator /// creates. /// @@ -16,10 +15,11 @@ pub struct ObjectOverrides { /// /// Read the [Object overrides documentation](DOCS_BASE_URL_PLACEHOLDER/concepts/overrides#object-overrides) /// for more information. - #[serde(default)] + // + // Remember to use `#[serde(default)]` when including this into a CRD! #[schemars(schema_with = "raw_object_list_schema")] - pub object_overrides: Vec, -} + Vec, +); impl ObjectOverrides { /// Takes an arbitrary Kubernetes object (`base`) and applies the configured list of deep merges @@ -31,7 +31,7 @@ impl ObjectOverrides { where R: kube::Resource + DeepMerge + DeserializeOwned, { - for object_override in &self.object_overrides { + for object_override in &self.0 { apply_deep_merge(base, object_override)?; } Ok(()) diff --git a/crates/xtask/src/crd/dummy.rs b/crates/xtask/src/crd/dummy.rs index ff35ea2ca..ac65c4f60 100644 --- a/crates/xtask/src/crd/dummy.rs +++ b/crates/xtask/src/crd/dummy.rs @@ -49,7 +49,7 @@ pub mod versioned { secret_reference: stackable_operator::shared::secret::SecretReference, tls_client_details: stackable_operator::commons::tls_verification::TlsClientDetails, - #[serde(flatten)] + #[serde(default)] pub object_overrides: ObjectOverrides, // Already versioned From cdbde7db53cc260683d75494f0c723cd13925058 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 27 Nov 2025 18:10:30 +0100 Subject: [PATCH 27/29] fix tests --- .../src/cluster_resources.rs | 2 +- .../stackable-operator/src/deep_merger/mod.rs | 188 +++++++++--------- 2 files changed, 91 insertions(+), 99 deletions(-) diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index c2c743567..c7f5e452b 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -355,7 +355,7 @@ impl ClusterResource for Deployment { /// namespaced, /// )] /// struct AppClusterSpec { -/// #[serde(flatten)] +/// #[serde(default)] /// pub object_overrides: ObjectOverrides, /// } /// diff --git a/crates/stackable-operator/src/deep_merger/mod.rs b/crates/stackable-operator/src/deep_merger/mod.rs index b1e9e0357..f167f3a4e 100644 --- a/crates/stackable-operator/src/deep_merger/mod.rs +++ b/crates/stackable-operator/src/deep_merger/mod.rs @@ -168,15 +168,14 @@ mod tests { let mut sa = generate_service_account(); let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" - objectOverrides: - - apiVersion: v1 - kind: ServiceAccount - metadata: - name: trino-serviceaccount - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar "}) .expect("test YAML is valid"); @@ -191,15 +190,14 @@ mod tests { fn service_account_not_merged_as_different_name() { let mut sa = generate_service_account(); let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" - objectOverrides: - - apiVersion: v1 - kind: ServiceAccount - metadata: - name: other-sa # name mismatch - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: other-sa # name mismatch + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar "}) .expect("test YAML is valid"); @@ -214,15 +212,14 @@ mod tests { fn service_account_not_merged_as_different_namespace() { let mut sa = generate_service_account(); let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" - objectOverrides: - - apiVersion: v1 - kind: ServiceAccount - metadata: - name: trino-serviceaccount - namespace: other-namespace # namespace mismatch - labels: - app.kubernetes.io/name: overwritten - foo: bar + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: other-namespace # namespace mismatch + labels: + app.kubernetes.io/name: overwritten + foo: bar "}) .expect("test YAML is valid"); @@ -237,15 +234,14 @@ mod tests { fn service_account_not_merged_as_different_api_version() { let mut sa = generate_service_account(); let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" - objectOverrides: - - apiVersion: v42 # apiVersion mismatch - kind: ServiceAccount - metadata: - name: trino-serviceaccount - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar + - apiVersion: v42 # apiVersion mismatch + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar "}) .expect("test YAML is valid"); @@ -260,36 +256,35 @@ mod tests { fn statefulset_merged_multiple_merges() { let mut sts = generate_stateful_set(); let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" - objectOverrides: - - apiVersion: v1 - kind: ServiceAccount - metadata: - name: trino-serviceaccount - namespace: default - labels: - app.kubernetes.io/name: overwritten - foo: bar - - apiVersion: apps/v1 - kind: StatefulSet - metadata: - name: trino-coordinator-default - namespace: default - spec: - template: - metadata: - labels: - foo: bar - spec: - containers: - - name: trino - image: custom-image - - apiVersion: apps/v1 - kind: StatefulSet - metadata: - name: trino-coordinator-default - namespace: default - spec: - replicas: 3 + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: trino-serviceaccount + namespace: default + labels: + app.kubernetes.io/name: overwritten + foo: bar + - apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: trino-coordinator-default + namespace: default + spec: + template: + metadata: + labels: + foo: bar + spec: + containers: + - name: trino + image: custom-image + - apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: trino-coordinator-default + namespace: default + spec: + replicas: 3 "}) .expect("test YAML is valid"); @@ -342,15 +337,14 @@ mod tests { "}) .expect("test YAML is valid"); let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" - objectOverrides: - - apiVersion: v1 - kind: ConfigMap - metadata: - name: game-demo - data: - foo: overwritten - log.properties: |- - =info,tech.stackable=debug + - apiVersion: v1 + kind: ConfigMap + metadata: + name: game-demo + data: + foo: overwritten + log.properties: |- + =info,tech.stackable=debug "}) .expect("test YAML is valid"); @@ -398,15 +392,14 @@ mod tests { "}) .expect("test YAML is valid"); let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" - objectOverrides: - - apiVersion: v1 - kind: Secret - metadata: - name: dotfile-secret - stringData: - foo: overwritten - data: - raw: b3ZlcndyaXR0ZW4K # echo overwritten | base64 + - apiVersion: v1 + kind: Secret + metadata: + name: dotfile-secret + stringData: + foo: overwritten + data: + raw: b3ZlcndyaXR0ZW4K # echo overwritten | base64 "}) .expect("test YAML is valid"); @@ -447,20 +440,19 @@ mod tests { "}) .expect("test YAML is valid"); let object_overrides: ObjectOverrides = serde_yaml::from_str(indoc! {" - objectOverrides: - - apiVersion: v1 - kind: ServiceAccount - - apiVersion: storage.k8s.io/v1 - kind: StorageClass - metadata: - name: low-latency - labels: - foo: overwritten - annotations: - new: annotation - provisioner: custom-provisioner - - foo: bar - - {} + - apiVersion: v1 + kind: ServiceAccount + - apiVersion: storage.k8s.io/v1 + kind: StorageClass + metadata: + name: low-latency + labels: + foo: overwritten + annotations: + new: annotation + provisioner: custom-provisioner + - foo: bar + - {} "}) .expect("test YAML is valid"); From 22d1dc6fc81c25340996558b248674f2082a7d7a Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 27 Nov 2025 19:53:49 +0100 Subject: [PATCH 28/29] Update crates/stackable-operator/CHANGELOG.md --- crates/stackable-operator/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 177b79ce5..a62911593 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -6,8 +6,8 @@ All notable changes to this project will be documented in this file. ### Added -- Support `objectOverrides`, which are a list of generic Kubernetes objects, which are merged onto the objects that the operator creates. - Alongside, a `deep_merger` module was added, which takes a Kubernetes object and a list of depp merged and applies them to the object ([#1118]). +- Support `objectOverrides`, a list of generic Kubernetes objects, which are merged into the objects created by the operator. + Alongside, a `deep_merger` module was added, which takes a Kubernetes object and a list of overrides and merges them into the provided object ([#1118]). ### Changed From 53d9af59293ff294fb8c2218e64dc34ab5627b67 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 27 Nov 2025 19:58:33 +0100 Subject: [PATCH 29/29] Merge the merge into the onto --- crates/stackable-operator/crds/DummyCluster.yaml | 2 +- crates/stackable-operator/src/deep_merger/crd.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/stackable-operator/crds/DummyCluster.yaml b/crates/stackable-operator/crds/DummyCluster.yaml index 5363790e9..c96b4b557 100644 --- a/crates/stackable-operator/crds/DummyCluster.yaml +++ b/crates/stackable-operator/crds/DummyCluster.yaml @@ -637,7 +637,7 @@ spec: objectOverrides: default: [] description: |- - A list of generic Kubernetes objects, which are merged on the objects that the operator + A list of generic Kubernetes objects, which are merged into the objects that the operator creates. List entries are arbitrary YAML objects, which need to be valid Kubernetes objects. diff --git a/crates/stackable-operator/src/deep_merger/crd.rs b/crates/stackable-operator/src/deep_merger/crd.rs index e955c197d..d0099d835 100644 --- a/crates/stackable-operator/src/deep_merger/crd.rs +++ b/crates/stackable-operator/src/deep_merger/crd.rs @@ -8,7 +8,7 @@ use crate::utils::crds::raw_object_list_schema; #[derive(Clone, Debug, Deserialize, Default, JsonSchema, Serialize, PartialEq)] pub struct ObjectOverrides( - /// A list of generic Kubernetes objects, which are merged on the objects that the operator + /// A list of generic Kubernetes objects, which are merged into the objects that the operator /// creates. /// /// List entries are arbitrary YAML objects, which need to be valid Kubernetes objects.