Skip to content

Commit b7549cf

Browse files
sbernauerTechassi
andauthored
feat: Support objectOverrides (#1118)
* feat: Support `objectOverrides` * refactor: Switch to a lis of objects (as opposed to a big string field) * changelog * Add TODO for docs * Add a test for Listener merging * Fix doctests * Improve CRD docs * Remove unused error variant * Add to DummyCluster * Improve CRD docs * Link to concepts page * Derive PartialEq again * Move import * Take owned value * Update crates/stackable-operator/src/cluster_resources.rs Co-authored-by: Techassi <[email protected]> * PartialEq again * Improve changelog * Add a comment in DeepMerge impl * Add some rustdocs * patchinator -> deep_merger * Fix remaining "patch" mentions * Fixup accidential change * Rename patch -> merge * Use indoc for tests * refactor: Move stuff into ObjectOverrides::apply_to * refactor: Switch ObjectOverrides to unit struct * fix tests * Update crates/stackable-operator/CHANGELOG.md * Merge the merge into the onto --------- Co-authored-by: Techassi <[email protected]>
1 parent 2cdaad2 commit b7549cf

File tree

11 files changed

+721
-6
lines changed

11 files changed

+721
-6
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/stackable-operator/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- Support `objectOverrides`, a list of generic Kubernetes objects, which are merged into the objects created by the operator.
10+
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]).
11+
12+
### Changed
13+
14+
- BREAKING: `ClusterResources` now requires the objects added to implement `DeepMerge`.
15+
This is very likely a stackable-operator internal change, but technically breaking ([#1118]).
16+
17+
### Removed
18+
19+
- BREAKING: `ClusterResources` no longer derives `Eq` ([#1118]).
20+
21+
[#1118]: https://github.com/stackabletech/operator-rs/pull/1118
22+
723
## [0.100.3] - 2025-10-31
824

925
### Changed

crates/stackable-operator/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,6 @@ tracing-subscriber.workspace = true
5555
url.workspace = true
5656

5757
[dev-dependencies]
58+
indoc.workspace = true
5859
rstest.workspace = true
5960
tempfile.workspace = true

crates/stackable-operator/crds/DummyCluster.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,20 @@ spec:
634634
required:
635635
- roleGroups
636636
type: object
637+
objectOverrides:
638+
default: []
639+
description: |-
640+
A list of generic Kubernetes objects, which are merged into the objects that the operator
641+
creates.
642+
643+
List entries are arbitrary YAML objects, which need to be valid Kubernetes objects.
644+
645+
Read the [Object overrides documentation](https://docs.stackable.tech/home/nightly/concepts/overrides#object-overrides)
646+
for more information.
647+
items:
648+
type: object
649+
x-kubernetes-preserve-unknown-fields: true
650+
type: array
637651
opaConfig:
638652
description: |-
639653
Configure the OPA stacklet [discovery ConfigMap](https://docs.stackable.tech/home/nightly/concepts/service_discovery)

crates/stackable-operator/src/cluster_resources.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::{
88
#[cfg(doc)]
99
use k8s_openapi::api::core::v1::{NodeSelector, Pod};
1010
use k8s_openapi::{
11-
NamespaceResourceScope,
11+
DeepMerge, NamespaceResourceScope,
1212
api::{
1313
apps::v1::{
1414
DaemonSet, DaemonSetSpec, Deployment, DeploymentSpec, StatefulSet, StatefulSetSpec,
@@ -38,6 +38,7 @@ use crate::{
3838
},
3939
},
4040
crd::listener,
41+
deep_merger::{self, ObjectOverrides},
4142
kvp::{
4243
Label, LabelError, Labels,
4344
consts::{K8S_APP_INSTANCE_KEY, K8S_APP_MANAGED_BY_KEY, K8S_APP_NAME_KEY},
@@ -87,6 +88,9 @@ pub enum Error {
8788
#[snafu(source(from(crate::client::Error, Box::new)))]
8889
source: Box<crate::client::Error>,
8990
},
91+
92+
#[snafu(display("failed to apply user-provided object overrides"))]
93+
ApplyObjectOverrides { source: deep_merger::Error },
9094
}
9195

9296
/// A cluster resource handled by [`ClusterResources`].
@@ -97,6 +101,7 @@ pub enum Error {
97101
/// it must be added to [`ClusterResources::delete_orphaned_resources`] as well.
98102
pub trait ClusterResource:
99103
Clone
104+
+ DeepMerge
100105
+ Debug
101106
+ DeserializeOwned
102107
+ Resource<DynamicType = (), Scope = NamespaceResourceScope>
@@ -332,6 +337,7 @@ impl ClusterResource for Deployment {
332337
/// use serde::{Deserialize, Serialize};
333338
/// use stackable_operator::client::Client;
334339
/// use stackable_operator::cluster_resources::{self, ClusterResourceApplyStrategy, ClusterResources};
340+
/// use stackable_operator::deep_merger::ObjectOverrides;
335341
/// use stackable_operator::product_config_utils::ValidatedRoleConfigByPropertyKind;
336342
/// use stackable_operator::role_utils::Role;
337343
/// use std::sync::Arc;
@@ -348,7 +354,10 @@ impl ClusterResource for Deployment {
348354
/// plural = "AppClusters",
349355
/// namespaced,
350356
/// )]
351-
/// struct AppClusterSpec {}
357+
/// struct AppClusterSpec {
358+
/// #[serde(default)]
359+
/// pub object_overrides: ObjectOverrides,
360+
/// }
352361
///
353362
/// enum Error {
354363
/// CreateClusterResources {
@@ -371,6 +380,7 @@ impl ClusterResource for Deployment {
371380
/// CONTROLLER_NAME,
372381
/// &app.object_ref(&()),
373382
/// ClusterResourceApplyStrategy::Default,
383+
/// app.spec.object_overrides.clone(),
374384
/// )
375385
/// .map_err(|source| Error::CreateClusterResources { source })?;
376386
///
@@ -413,7 +423,7 @@ impl ClusterResource for Deployment {
413423
/// Ok(Action::await_change())
414424
/// }
415425
/// ```
416-
#[derive(Debug, Eq, PartialEq)]
426+
#[derive(Debug, PartialEq)]
417427
pub struct ClusterResources {
418428
/// The namespace of the cluster
419429
namespace: String,
@@ -442,6 +452,9 @@ pub struct ClusterResources {
442452
/// Strategy to manage how cluster resources are applied. Resources could be patched, merged
443453
/// or not applied at all depending on the strategy.
444454
apply_strategy: ClusterResourceApplyStrategy,
455+
456+
/// Arbitrary Kubernetes object overrides specified by the user via the CRD.
457+
object_overrides: ObjectOverrides,
445458
}
446459

447460
impl ClusterResources {
@@ -470,6 +483,7 @@ impl ClusterResources {
470483
controller_name: &str,
471484
cluster: &ObjectReference,
472485
apply_strategy: ClusterResourceApplyStrategy,
486+
object_overrides: ObjectOverrides,
473487
) -> Result<Self> {
474488
let namespace = cluster
475489
.namespace
@@ -494,6 +508,7 @@ impl ClusterResources {
494508
manager: format_full_controller_name(operator_name, controller_name),
495509
resource_ids: Default::default(),
496510
apply_strategy,
511+
object_overrides,
497512
})
498513
}
499514

@@ -563,7 +578,12 @@ impl ClusterResources {
563578
.unwrap_or_else(|err| warn!("{}", err));
564579
}
565580

566-
let mutated = resource.maybe_mutate(&self.apply_strategy);
581+
let mut mutated = resource.maybe_mutate(&self.apply_strategy);
582+
583+
// We apply the object overrides of the user at the very end to offer maximum flexibility.
584+
self.object_overrides
585+
.apply_to(&mut mutated)
586+
.context(ApplyObjectOverridesSnafu)?;
567587

568588
let patched_resource = self
569589
.apply_strategy
Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,145 @@
1-
use crate::crd::listener::listeners::v1alpha1::ListenerSpec;
1+
use k8s_openapi::{DeepMerge, merge_strategies};
2+
3+
use crate::crd::listener::listeners::v1alpha1::{
4+
Listener, ListenerIngress, ListenerPort, ListenerSpec, ListenerStatus,
5+
};
26

37
impl ListenerSpec {
48
pub(super) const fn default_publish_not_ready_addresses() -> Option<bool> {
59
Some(true)
610
}
711
}
12+
13+
impl DeepMerge for Listener {
14+
fn merge_from(&mut self, other: Self) {
15+
DeepMerge::merge_from(&mut self.metadata, other.metadata);
16+
DeepMerge::merge_from(&mut self.spec, other.spec);
17+
DeepMerge::merge_from(&mut self.status, other.status);
18+
}
19+
}
20+
21+
impl DeepMerge for ListenerSpec {
22+
fn merge_from(&mut self, other: Self) {
23+
DeepMerge::merge_from(&mut self.class_name, other.class_name);
24+
merge_strategies::map::granular(
25+
&mut self.extra_pod_selector_labels,
26+
other.extra_pod_selector_labels,
27+
|current_item, other_item| {
28+
DeepMerge::merge_from(current_item, other_item);
29+
},
30+
);
31+
merge_strategies::list::map(
32+
&mut self.ports,
33+
other.ports,
34+
// The unique thing identifying a port is it's name
35+
&[|lhs, rhs| lhs.name == rhs.name],
36+
|current_item, other_item| {
37+
DeepMerge::merge_from(current_item, other_item);
38+
},
39+
);
40+
DeepMerge::merge_from(
41+
&mut self.publish_not_ready_addresses,
42+
other.publish_not_ready_addresses,
43+
);
44+
}
45+
}
46+
47+
impl DeepMerge for ListenerStatus {
48+
fn merge_from(&mut self, other: Self) {
49+
DeepMerge::merge_from(&mut self.service_name, other.service_name);
50+
merge_strategies::list::map(
51+
&mut self.ingress_addresses,
52+
other.ingress_addresses,
53+
// The unique thing identifying an ingress address is it's address
54+
&[|lhs, rhs| lhs.address == rhs.address],
55+
|current_item, other_item| {
56+
DeepMerge::merge_from(current_item, other_item);
57+
},
58+
);
59+
merge_strategies::map::granular(
60+
&mut self.node_ports,
61+
other.node_ports,
62+
|current_item, other_item| {
63+
DeepMerge::merge_from(current_item, other_item);
64+
},
65+
);
66+
}
67+
}
68+
69+
impl DeepMerge for ListenerIngress {
70+
fn merge_from(&mut self, other: Self) {
71+
DeepMerge::merge_from(&mut self.address, other.address);
72+
self.address_type = other.address_type;
73+
merge_strategies::map::granular(
74+
&mut self.ports,
75+
other.ports,
76+
|current_item, other_item| {
77+
DeepMerge::merge_from(current_item, other_item);
78+
},
79+
);
80+
}
81+
}
82+
83+
impl DeepMerge for ListenerPort {
84+
fn merge_from(&mut self, other: Self) {
85+
DeepMerge::merge_from(&mut self.name, other.name);
86+
DeepMerge::merge_from(&mut self.port, other.port);
87+
DeepMerge::merge_from(&mut self.protocol, other.protocol);
88+
}
89+
}
90+
91+
#[cfg(test)]
92+
mod tests {
93+
use indoc::indoc;
94+
95+
use super::*;
96+
97+
#[test]
98+
fn deep_merge_listener() {
99+
let mut base: ListenerSpec = serde_yaml::from_str(indoc! {"
100+
className: my-listener-class
101+
extraPodSelectorLabels:
102+
foo: bar
103+
ports:
104+
- name: http
105+
port: 8080
106+
protocol: http
107+
- name: https
108+
port: 8080
109+
protocol: https
110+
# publishNotReadyAddresses defaults to true
111+
"})
112+
.expect("test YAML is valid");
113+
114+
let merge: ListenerSpec = serde_yaml::from_str(indoc! {"
115+
className: custom-listener-class
116+
extraPodSelectorLabels:
117+
foo: overridden
118+
extra: label
119+
ports:
120+
- name: https
121+
port: 8443
122+
publishNotReadyAddresses: false
123+
"})
124+
.expect("test YAML is valid");
125+
126+
base.merge_from(merge);
127+
let expected: ListenerSpec = serde_yaml::from_str(indoc! {"
128+
className: custom-listener-class
129+
extraPodSelectorLabels:
130+
foo: overridden
131+
extra: label
132+
ports:
133+
- name: http
134+
port: 8080
135+
protocol: http
136+
- name: https
137+
port: 8443 # overridden
138+
protocol: https
139+
publishNotReadyAddresses: false
140+
"})
141+
.expect("test YAML is valid");
142+
143+
assert_eq!(base, expected);
144+
}
145+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
use k8s_openapi::DeepMerge;
2+
use kube::api::DynamicObject;
3+
use schemars::JsonSchema;
4+
use serde::{Deserialize, Serialize, de::DeserializeOwned};
5+
6+
use super::apply_deep_merge;
7+
use crate::utils::crds::raw_object_list_schema;
8+
9+
#[derive(Clone, Debug, Deserialize, Default, JsonSchema, Serialize, PartialEq)]
10+
pub struct ObjectOverrides(
11+
/// A list of generic Kubernetes objects, which are merged into the objects that the operator
12+
/// creates.
13+
///
14+
/// List entries are arbitrary YAML objects, which need to be valid Kubernetes objects.
15+
///
16+
/// Read the [Object overrides documentation](DOCS_BASE_URL_PLACEHOLDER/concepts/overrides#object-overrides)
17+
/// for more information.
18+
//
19+
// Remember to use `#[serde(default)]` when including this into a CRD!
20+
#[schemars(schema_with = "raw_object_list_schema")]
21+
Vec<DynamicObject>,
22+
);
23+
24+
impl ObjectOverrides {
25+
/// Takes an arbitrary Kubernetes object (`base`) and applies the configured list of deep merges
26+
/// to it.
27+
///
28+
/// Merges are only applied to objects that have the same apiVersion, kind, name
29+
/// and namespace.
30+
pub fn apply_to<R>(&self, base: &mut R) -> Result<(), super::Error>
31+
where
32+
R: kube::Resource<DynamicType = ()> + DeepMerge + DeserializeOwned,
33+
{
34+
for object_override in &self.0 {
35+
apply_deep_merge(base, object_override)?;
36+
}
37+
Ok(())
38+
}
39+
}

0 commit comments

Comments
 (0)