66use std:: collections:: BTreeMap ;
77
88use snafu:: { ResultExt , Snafu } ;
9- use stackable_operator:: { cli:: OperatorEnvironmentOptions , commons:: product_image_selection} ;
9+ use stackable_operator:: {
10+ cli:: OperatorEnvironmentOptions ,
11+ commons:: product_image_selection,
12+ config:: merge:: { Merge , merge} ,
13+ v2:: config_overrides:: KeyValueConfigOverrides ,
14+ } ;
1015
1116use crate :: {
1217 controller:: {
@@ -169,12 +174,30 @@ pub fn validate(
169174 } )
170175}
171176
172- // DESIGN DECISION: role-group overrides are merged role-level first, then role-group
173- // extended on top so role-group wins — identical to the precedent product-config used.
174- // We read the v2 KeyValueConfigOverrides `.overrides` map and BTreeMap::extend rather
175- // than using its `Merge` impl, because plain extend reproduces the old behaviour exactly
176- // (last-writer-wins per key) and avoids depending on Merge semantics. Alternative:
177- // KeyValueConfigOverrides::merge — equivalent here but an unnecessary semantic dependency.
177+ /// Merge role-group overrides over the role-level overrides (role-group wins per key) via the
178+ /// `Merge` impl derived on the override structs.
179+ ///
180+ /// NOTE on semantics: `Merge` treats a role-group `null` value as "inherit the role-level value",
181+ /// *not* "unset it". This differs from `main`'s product-config layering, which `.extend()`ed the
182+ /// maps so a role-group `null` *removed* a role-level key. The `tests` module has a worked
183+ /// example of the difference.
184+ fn merge_role_group_overrides < O : Merge + Clone > ( role : & O , role_group : Option < & O > ) -> O {
185+ match role_group {
186+ Some ( role_group) => merge ( role_group. clone ( ) , role) ,
187+ None => role. clone ( ) ,
188+ }
189+ }
190+
191+ /// Flatten resolved key/value overrides into a plain map, dropping entries whose value is
192+ /// unset (`null`).
193+ fn flatten_overrides ( overrides : KeyValueConfigOverrides ) -> BTreeMap < String , String > {
194+ overrides
195+ . overrides
196+ . into_iter ( )
197+ . filter_map ( |( key, value) | value. map ( |value| ( key, value) ) )
198+ . collect ( )
199+ }
200+
178201fn collect_broker_role_group_overrides (
179202 kafka : & v1alpha1:: KafkaCluster ,
180203 broker_role : & crate :: crd:: BrokerRole ,
@@ -184,47 +207,15 @@ fn collect_broker_role_group_overrides(
184207 BTreeMap < String , String > ,
185208 BTreeMap < String , String > ,
186209) {
187- // --- broker.properties overrides ---
188- let role_broker_overrides: BTreeMap < String , Option < String > > = broker_role
189- . config
190- . config_overrides
191- . broker_properties
192- . as_ref ( )
193- . map ( |o| o. overrides . clone ( ) )
194- . unwrap_or_default ( ) ;
195- let rg_broker_overrides: BTreeMap < String , Option < String > > = broker_role
196- . role_groups
197- . get ( rolegroup_name)
198- . and_then ( |rg| rg. config . config_overrides . broker_properties . as_ref ( ) )
199- . map ( |o| o. overrides . clone ( ) )
200- . unwrap_or_default ( ) ;
201- let mut merged_broker = role_broker_overrides;
202- merged_broker. extend ( rg_broker_overrides) ;
203- let config_file_overrides: BTreeMap < String , String > = merged_broker
204- . into_iter ( )
205- . filter_map ( |( k, v) | v. map ( |v| ( k, v) ) )
206- . collect ( ) ;
207-
208- // --- security.properties overrides ---
209- let role_security_overrides: BTreeMap < String , Option < String > > = broker_role
210- . config
211- . config_overrides
212- . security_properties
213- . as_ref ( )
214- . map ( |o| o. overrides . clone ( ) )
215- . unwrap_or_default ( ) ;
216- let rg_security_overrides: BTreeMap < String , Option < String > > = broker_role
217- . role_groups
218- . get ( rolegroup_name)
219- . and_then ( |rg| rg. config . config_overrides . security_properties . as_ref ( ) )
220- . map ( |o| o. overrides . clone ( ) )
221- . unwrap_or_default ( ) ;
222- let mut merged_security = role_security_overrides;
223- merged_security. extend ( rg_security_overrides) ;
224- let jvm_security_overrides: BTreeMap < String , String > = merged_security
225- . into_iter ( )
226- . filter_map ( |( k, v) | v. map ( |v| ( k, v) ) )
227- . collect ( ) ;
210+ let merged_overrides = merge_role_group_overrides (
211+ & broker_role. config . config_overrides ,
212+ broker_role
213+ . role_groups
214+ . get ( rolegroup_name)
215+ . map ( |rg| & rg. config . config_overrides ) ,
216+ ) ;
217+ let config_file_overrides = flatten_overrides ( merged_overrides. broker_properties ) ;
218+ let jvm_security_overrides = flatten_overrides ( merged_overrides. security_properties ) ;
228219
229220 // --- env overrides ---
230221 // DESIGN DECISION: KAFKA_CLUSTER_ID is injected first, then the user env overrides
@@ -252,12 +243,6 @@ fn collect_broker_role_group_overrides(
252243 ( config_file_overrides, jvm_security_overrides, env_overrides)
253244}
254245
255- // DESIGN DECISION: role-group overrides are merged role-level first, then role-group
256- // extended on top so role-group wins — identical to the precedent product-config used.
257- // We read the v2 KeyValueConfigOverrides `.overrides` map and BTreeMap::extend rather
258- // than using its `Merge` impl, because plain extend reproduces the old behaviour exactly
259- // (last-writer-wins per key) and avoids depending on Merge semantics. Alternative:
260- // KeyValueConfigOverrides::merge — equivalent here but an unnecessary semantic dependency.
261246fn collect_controller_role_group_overrides (
262247 kafka : & v1alpha1:: KafkaCluster ,
263248 controller_role : & crate :: crd:: ControllerRole ,
@@ -267,47 +252,15 @@ fn collect_controller_role_group_overrides(
267252 BTreeMap < String , String > ,
268253 BTreeMap < String , String > ,
269254) {
270- // --- controller.properties overrides ---
271- let role_controller_overrides: BTreeMap < String , Option < String > > = controller_role
272- . config
273- . config_overrides
274- . controller_properties
275- . as_ref ( )
276- . map ( |o| o. overrides . clone ( ) )
277- . unwrap_or_default ( ) ;
278- let rg_controller_overrides: BTreeMap < String , Option < String > > = controller_role
279- . role_groups
280- . get ( rolegroup_name)
281- . and_then ( |rg| rg. config . config_overrides . controller_properties . as_ref ( ) )
282- . map ( |o| o. overrides . clone ( ) )
283- . unwrap_or_default ( ) ;
284- let mut merged_controller = role_controller_overrides;
285- merged_controller. extend ( rg_controller_overrides) ;
286- let config_file_overrides: BTreeMap < String , String > = merged_controller
287- . into_iter ( )
288- . filter_map ( |( k, v) | v. map ( |v| ( k, v) ) )
289- . collect ( ) ;
290-
291- // --- security.properties overrides ---
292- let role_security_overrides: BTreeMap < String , Option < String > > = controller_role
293- . config
294- . config_overrides
295- . security_properties
296- . as_ref ( )
297- . map ( |o| o. overrides . clone ( ) )
298- . unwrap_or_default ( ) ;
299- let rg_security_overrides: BTreeMap < String , Option < String > > = controller_role
300- . role_groups
301- . get ( rolegroup_name)
302- . and_then ( |rg| rg. config . config_overrides . security_properties . as_ref ( ) )
303- . map ( |o| o. overrides . clone ( ) )
304- . unwrap_or_default ( ) ;
305- let mut merged_security = role_security_overrides;
306- merged_security. extend ( rg_security_overrides) ;
307- let jvm_security_overrides: BTreeMap < String , String > = merged_security
308- . into_iter ( )
309- . filter_map ( |( k, v) | v. map ( |v| ( k, v) ) )
310- . collect ( ) ;
255+ let merged_overrides = merge_role_group_overrides (
256+ & controller_role. config . config_overrides ,
257+ controller_role
258+ . role_groups
259+ . get ( rolegroup_name)
260+ . map ( |rg| & rg. config . config_overrides ) ,
261+ ) ;
262+ let config_file_overrides = flatten_overrides ( merged_overrides. controller_properties ) ;
263+ let jvm_security_overrides = flatten_overrides ( merged_overrides. security_properties ) ;
311264
312265 // --- env overrides ---
313266 // DESIGN DECISION: KAFKA_CLUSTER_ID is injected first, then the user env overrides
@@ -335,3 +288,77 @@ fn collect_controller_role_group_overrides(
335288
336289 ( config_file_overrides, jvm_security_overrides, env_overrides)
337290}
291+
292+ #[ cfg( test) ]
293+ mod tests {
294+ use std:: collections:: BTreeMap ;
295+
296+ use stackable_operator:: v2:: config_overrides:: KeyValueConfigOverrides ;
297+
298+ use super :: { flatten_overrides, merge_role_group_overrides} ;
299+
300+ /// Build a `KeyValueConfigOverrides` from `(key, value)` pairs, where a `None` value
301+ /// represents an explicit `null` (unset) in the CRD.
302+ fn overrides ( pairs : & [ ( & str , Option < & str > ) ] ) -> KeyValueConfigOverrides {
303+ KeyValueConfigOverrides {
304+ overrides : pairs
305+ . iter ( )
306+ . map ( |( key, value) | ( key. to_string ( ) , value. map ( str:: to_string) ) )
307+ . collect ( ) ,
308+ }
309+ }
310+
311+ /// Run the full role/role-group resolution (merge then flatten) for a single config file.
312+ fn resolve (
313+ role : KeyValueConfigOverrides ,
314+ role_group : Option < KeyValueConfigOverrides > ,
315+ ) -> BTreeMap < String , String > {
316+ flatten_overrides ( merge_role_group_overrides ( & role, role_group. as_ref ( ) ) )
317+ }
318+
319+ #[ test]
320+ fn role_group_value_wins_over_role ( ) {
321+ let role = overrides ( & [ ( "a" , Some ( "role" ) ) , ( "b" , Some ( "role-only" ) ) ] ) ;
322+ let role_group = overrides ( & [ ( "a" , Some ( "rg" ) ) ] ) ;
323+
324+ let merged = resolve ( role, Some ( role_group) ) ;
325+
326+ assert_eq ! (
327+ merged,
328+ BTreeMap :: from( [
329+ ( "a" . to_string( ) , "rg" . to_string( ) ) , // role-group wins for shared keys
330+ ( "b" . to_string( ) , "role-only" . to_string( ) ) , // role-only keys are kept
331+ ] )
332+ ) ;
333+ }
334+
335+ /// Illustrates the key consequence of using `Merge` (rather than `.extend()`, as `main`'s
336+ /// product-config did): a role-group `null` is treated as "inherit", so the role-level value
337+ /// is *kept* — it does NOT unset the key. Under the old `.extend()` behaviour this same input
338+ /// would have removed `a` entirely.
339+ #[ test]
340+ fn role_group_null_inherits_role_value_rather_than_unsetting_it ( ) {
341+ let role = overrides ( & [ ( "a" , Some ( "role" ) ) ] ) ;
342+ let role_group = overrides ( & [ ( "a" , None ) ] ) ; // explicit `null` at the more specific level
343+
344+ let merged = resolve ( role, Some ( role_group) ) ;
345+
346+ assert_eq ! (
347+ merged,
348+ BTreeMap :: from( [ ( "a" . to_string( ) , "role" . to_string( ) ) ] ) ,
349+ "a role-group `null` should inherit the role-level value under Merge semantics"
350+ ) ;
351+ }
352+
353+ #[ test]
354+ fn without_a_role_group_role_values_are_kept_and_nulls_dropped ( ) {
355+ let role = overrides ( & [ ( "a" , Some ( "role" ) ) , ( "b" , None ) ] ) ;
356+
357+ let merged = resolve ( role, None ) ;
358+
359+ assert_eq ! (
360+ merged,
361+ BTreeMap :: from( [ ( "a" . to_string( ) , "role" . to_string( ) ) ] )
362+ ) ;
363+ }
364+ }
0 commit comments