@@ -107,24 +107,22 @@ def visit(name):
107107 return list (resolved .keys ())
108108
109109
110- def _namespace_key (item ):
111- """Extract the unique key from a namespace list entry (string or mapping)."""
112- if isinstance (item , str ):
113- return item
114- if isinstance (item , dict ):
115- keys = list (item .keys ())
116- return keys [0 ] if keys else None
117- return None
118-
119-
120- def _merge_namespace_lists (base_list , fragment_list ):
121- """Append namespace entries from fragment_list that are not already in base_list."""
122- existing = {_namespace_key (item ) for item in base_list }
123- for item in fragment_list :
124- key = _namespace_key (item )
125- if key not in existing :
126- base_list .append (item )
127- existing .add (key )
110+ def _merge_namespace_dicts (base_dict , fragment_dict ):
111+ """Merge namespace entries from fragment_dict into base_dict.
112+
113+ Namespaces are now dictionaries where keys are namespace names and values
114+ are their configurations (or empty/None for namespaces without config).
115+ """
116+ for ns_name , ns_config in fragment_dict .items ():
117+ if ns_name not in base_dict :
118+ # Add new namespace
119+ base_dict [ns_name ] = copy .deepcopy (ns_config ) if ns_config else None
120+ elif ns_config :
121+ # Merge configuration for existing namespace
122+ if base_dict [ns_name ] is None :
123+ base_dict [ns_name ] = copy .deepcopy (ns_config )
124+ elif isinstance (base_dict [ns_name ], dict ) and isinstance (ns_config , dict ):
125+ _deep_merge_mappings (base_dict [ns_name ], copy .deepcopy (ns_config ))
128126
129127
130128def _is_named_list (lst ):
@@ -168,7 +166,7 @@ def _deep_merge_mappings(base, overlay):
168166 base [key ] = overlay [key ]
169167
170168
171- def _apply_merge_into (base_apps , merge_into_spec ):
169+ def _apply_merge_into (base_apps , merge_into_spec , vault_jwt_roles_accumulator ):
172170 """Handle merge_into_applications: merge fragment data into existing app configs.
173171
174172 merge_into_spec is a mapping like:
@@ -181,8 +179,27 @@ def _apply_merge_into(base_apps, merge_into_spec):
181179 For each target app, recursively merge into the existing app config.
182180 Named lists (items with a 'name' key) use upsert semantics; plain lists
183181 are appended.
182+
183+ Special handling for vault JWT roles: instead of merging them into
184+ clusterGroup.applications.vault, accumulate them in vault_jwt_roles_accumulator
185+ for later merging into the overrides/values-vault-jwt.yaml structure.
184186 """
185187 for app_name , additions in merge_into_spec .items ():
188+ # Special handling for vault JWT roles
189+ if app_name == "vault" and "jwt" in additions :
190+ jwt_config = additions .get ("jwt" , {})
191+ if "roles" in jwt_config :
192+ # Accumulate JWT roles for later merging into vault
193+ # override file
194+ vault_jwt_roles_accumulator .extend (copy .deepcopy (jwt_config ["roles" ]))
195+ # Remove jwt from additions to prevent it from being
196+ # merged into app config
197+ additions = copy .deepcopy (additions )
198+ del additions ["jwt" ]
199+ # If nothing else to merge, continue to next app
200+ if not additions :
201+ continue
202+
186203 if app_name not in base_apps :
187204 print (
188205 f"WARNING: merge_into_applications target '{ app_name } '"
@@ -213,14 +230,20 @@ def _insert_key_before(mapping, new_key, new_value, before_key):
213230 mapping [k ] = v
214231
215232
216- def merge_fragment (base , fragment ):
217- """Merge a single feature fragment into the base YAML tree."""
233+ def merge_fragment (base , fragment , vault_jwt_roles_accumulator ):
234+ """Merge a single feature fragment into the base YAML tree.
235+
236+ vault_jwt_roles_accumulator is a list that collects JWT roles from all fragments
237+ for later merging into the vault override file.
238+ """
218239 if fragment is None :
219240 return
220241
221242 for top_key in fragment :
222243 if top_key == "clusterGroup" :
223- _merge_cluster_group (base , fragment ["clusterGroup" ])
244+ _merge_cluster_group (
245+ base , fragment ["clusterGroup" ], vault_jwt_roles_accumulator
246+ )
224247 elif top_key in base and isinstance (base [top_key ], dict ):
225248 _deep_merge_mappings (base [top_key ], copy .deepcopy (fragment [top_key ]))
226249 elif top_key not in base :
@@ -234,13 +257,26 @@ def merge_fragment(base, fragment):
234257 base [top_key ] = copy .deepcopy (fragment [top_key ])
235258
236259
237- def _merge_cluster_group (base , frag_cg ):
238- """Merge clusterGroup sections with type-aware strategies."""
260+ def _merge_cluster_group (base , frag_cg , vault_jwt_roles_accumulator ):
261+ """Merge clusterGroup sections with type-aware strategies.
262+
263+ vault_jwt_roles_accumulator is a list that collects JWT roles from all fragments
264+ for later merging into the vault override file.
265+ """
239266 base_cg = base .setdefault ("clusterGroup" , {})
240267
241268 if "namespaces" in frag_cg :
242- base_ns = base_cg .setdefault ("namespaces" , [])
243- _merge_namespace_lists (base_ns , frag_cg ["namespaces" ])
269+ base_ns = base_cg .setdefault ("namespaces" , {})
270+ # Ensure namespaces is a dict
271+ if not isinstance (base_ns , dict ):
272+ print (
273+ f"WARNING: base namespaces is not a dict (type: { type (base_ns )} ), "
274+ "converting to empty dict" ,
275+ file = sys .stderr ,
276+ )
277+ base_ns = {}
278+ base_cg ["namespaces" ] = base_ns
279+ _merge_namespace_dicts (base_ns , frag_cg ["namespaces" ])
244280
245281 if "subscriptions" in frag_cg :
246282 base_subs = base_cg .setdefault ("subscriptions" , {})
@@ -256,20 +292,25 @@ def _merge_cluster_group(base, frag_cg):
256292
257293 if "merge_into_applications" in frag_cg :
258294 base_apps = base_cg .get ("applications" , {})
259- _apply_merge_into (base_apps , frag_cg ["merge_into_applications" ])
295+ _apply_merge_into (
296+ base_apps , frag_cg ["merge_into_applications" ], vault_jwt_roles_accumulator
297+ )
260298
261299
262300def validate_output (data ):
263301 """Run basic sanity checks on the merged YAML tree."""
264302 cg = data .get ("clusterGroup" , {})
265303
266- ns_list = cg .get ("namespaces" , [])
267- seen = set ()
268- for item in ns_list :
269- key = _namespace_key (item )
270- if key in seen :
271- print (f"WARNING: duplicate namespace '{ key } '" , file = sys .stderr )
272- seen .add (key )
304+ ns_dict = cg .get ("namespaces" , {})
305+ if isinstance (ns_dict , dict ):
306+ # Namespaces are now a dict, so duplicate checking is implicit
307+ # (dict keys are unique by definition)
308+ pass
309+ else :
310+ print (
311+ f"WARNING: namespaces is not a dict (type: { type (ns_dict )} )" ,
312+ file = sys .stderr ,
313+ )
273314
274315 apps = cg .get ("applications" , {})
275316 for app_name , app_val in apps .items ():
@@ -286,14 +327,8 @@ def validate_output(data):
286327 if name :
287328 override_names .add (name )
288329
289- vault = apps .get ("vault" , {})
290- jwt_roles = vault .get ("jwt" , {}).get ("roles" , [])
291- role_names = set ()
292- for role in jwt_roles :
293- name = role .get ("name" )
294- if name in role_names :
295- print (f"WARNING: duplicate vault JWT role '{ name } '" , file = sys .stderr )
296- role_names .add (name )
330+ # Vault JWT roles are now in overrides/values-vault-jwt.yaml
331+ # No need to validate them here as they're not in the generated variant
297332
298333
299334def _substitute_repository_placeholders (base , org = None , image_name = None ):
@@ -343,6 +378,51 @@ def _substitute_git_overrides(base, git_repo_url, git_host, git_auth_type):
343378 override ["value" ] = entry [1 ]
344379
345380
381+ def _update_vault_jwt_override_file (override_file_path , new_roles ):
382+ """Update the vault JWT override file with new roles from feature fragments.
383+
384+ Merges new_roles into the vault_jwt_roles list in the override file.
385+ Uses named list semantics (upsert by role name).
386+ """
387+ if not new_roles :
388+ return
389+
390+ yaml = YAML ()
391+ yaml .preserve_quotes = True
392+ yaml .default_flow_style = False
393+ yaml .width = 4096
394+
395+ # Load existing override file
396+ if os .path .isfile (override_file_path ):
397+ with open (override_file_path ) as fh :
398+ override_data = yaml .load (fh )
399+ else :
400+ # Create new structure if file doesn't exist
401+ oidc_url = (
402+ "https://spire-spiffe-oidc-discovery-provider"
403+ ".zero-trust-workload-identity-manager.svc.cluster.local"
404+ )
405+ override_data = {
406+ "vault_jwt_config" : True ,
407+ "vault_jwt_policies" : [],
408+ "vault_jwt_roles" : [],
409+ "oidc_discovery_url" : oidc_url ,
410+ }
411+
412+ # Get existing roles list
413+ existing_roles = override_data .setdefault ("vault_jwt_roles" , [])
414+
415+ # Merge new roles using named list semantics
416+ _merge_named_lists (existing_roles , new_roles )
417+
418+ # Write back to file
419+ with open (override_file_path , "w" ) as fh :
420+ yaml .dump (override_data , fh )
421+
422+ role_names = [r .get ("name" , "unknown" ) for r in new_roles ]
423+ print (f" Updated { override_file_path } with roles: { ', ' .join (role_names )} " )
424+
425+
346426def generate_variant (
347427 base_path ,
348428 features_dir ,
@@ -362,13 +442,16 @@ def generate_variant(
362442 with open (base_path ) as fh :
363443 base = yaml .load (fh )
364444
445+ # Accumulator for vault JWT roles from feature fragments
446+ vault_jwt_roles_accumulator = []
447+
365448 for feat_name in resolved_features :
366449 frag_path = os .path .join (features_dir , f"{ feat_name } .yaml" )
367450 if not os .path .isfile (frag_path ):
368451 print (f"ERROR: fragment file not found: { frag_path } " , file = sys .stderr )
369452 sys .exit (1 )
370453 fragment = load_yaml_file (frag_path )
371- merge_fragment (base , fragment )
454+ merge_fragment (base , fragment , vault_jwt_roles_accumulator )
372455
373456 if registry_fragment_path :
374457 if not os .path .isfile (registry_fragment_path ):
@@ -378,7 +461,15 @@ def generate_variant(
378461 )
379462 sys .exit (1 )
380463 registry_frag = load_yaml_file (registry_fragment_path )
381- merge_fragment (base , registry_frag )
464+ merge_fragment (base , registry_frag , vault_jwt_roles_accumulator )
465+
466+ # Update vault JWT override file with roles from feature fragments
467+ if vault_jwt_roles_accumulator :
468+ repo_root = os .path .dirname (SCRIPT_DIR )
469+ override_file_path = os .path .join (
470+ repo_root , "overrides" , "values-vault-jwt.yaml"
471+ )
472+ _update_vault_jwt_override_file (override_file_path , vault_jwt_roles_accumulator )
382473
383474 if org or image_name :
384475 _substitute_repository_placeholders (base , org = org , image_name = image_name )
0 commit comments