2222import argparse
2323import json
2424import sys
25+ from collections import defaultdict
2526from pathlib import Path
27+ from typing import Any
2628
2729import yaml
2830
4143
4244DEFAULT_OVERLAY_SLUG = "rhdh-parasol/rhdh-plugin-export-overlays"
4345ROOT_COMPONENT_NAME = "rhdh-plugin-export-overlays"
46+ ANNOTATION_OVERLAY_WORKSPACE = "rhdh.io/overlay-workspace"
47+ ANNOTATION_SOURCE_REPOSITORY = "rhdh.io/source-repository"
48+ ANNOTATION_SOURCE_REVISION = "rhdh.io/source-revision"
49+ ANNOTATION_PLUGIN_REFS = "rhdh.io/extensions-plugin-refs"
50+ ANNOTATION_PACKAGE_REFS = "rhdh.io/extensions-package-refs"
4451
4552# Software-catalog Component metadata.name in rhdh-plugins workspaces/*/catalog-info.yaml.
4653# Only set spec.dependsOn when the source plugin already has a Component in the
@@ -104,6 +111,154 @@ def load_source_json(workspace_dir: Path) -> dict:
104111 return json .load (handle )
105112
106113
114+ def load_yaml (path : Path ) -> dict [str , Any ] | None :
115+ """Load one YAML entity, returning None for malformed/non-mapping files."""
116+ try :
117+ with path .open (encoding = "utf-8" ) as handle :
118+ value = yaml .safe_load (handle )
119+ except (OSError , yaml .YAMLError ) as error :
120+ log_warn (f"Skipping unreadable YAML { path } : { error } " )
121+ return None
122+ if not isinstance (value , dict ):
123+ log_warn (f"Skipping non-mapping YAML { path } " )
124+ return None
125+ return value
126+
127+
128+ def canonical_entity_ref (kind : str , namespace : str , name : str ) -> str :
129+ return f"{ kind .lower ()} :{ namespace } /{ name } "
130+
131+
132+ def package_name_from_plugin_entry (entry : Any ) -> str | None :
133+ if isinstance (entry , str ) and entry :
134+ return entry
135+ if isinstance (entry , dict ) and isinstance (entry .get ("name" ), str ):
136+ return entry ["name" ]
137+ return None
138+
139+
140+ def normalize_part_of_ref (value : Any ) -> tuple [str , str ] | None :
141+ """Return (namespace, name) for the common Package.spec.partOf shapes."""
142+ if isinstance (value , dict ):
143+ value = value .get ("name" ) or value .get ("ref" )
144+ if not isinstance (value , str ) or not value .strip ():
145+ return None
146+ value = value .strip ()
147+ if value .startswith ("plugin:" ):
148+ value = value .removeprefix ("plugin:" )
149+ if "/" in value :
150+ namespace , name = value .split ("/" , 1 )
151+ return namespace or "rhdh" , name
152+ return "rhdh" , value
153+
154+
155+ def extension_entities (repo_root : Path ) -> dict [str , list [dict [str , Any ]]]:
156+ """Index Extensions Catalog Plugin entities by canonical ref."""
157+ entities : dict [str , list [dict [str , Any ]]] = defaultdict (list )
158+ plugin_dir = repo_root / "catalog-entities" / "extensions" / "plugins"
159+ if not plugin_dir .is_dir ():
160+ return entities
161+ for path in sorted (plugin_dir .glob ("*.yaml" )):
162+ entity = load_yaml (path )
163+ if entity is None or entity .get ("kind" ) != "Plugin" :
164+ continue
165+ metadata = entity .get ("metadata" )
166+ if not isinstance (metadata , dict ):
167+ continue
168+ namespace = metadata .get ("namespace" , "default" )
169+ name = metadata .get ("name" )
170+ if isinstance (namespace , str ) and isinstance (name , str ) and name :
171+ entities [canonical_entity_ref ("plugin" , namespace , name )].append (entity )
172+ return entities
173+
174+
175+ def workspace_packages (workspace_dir : Path ) -> list [dict [str , Any ]]:
176+ """Return valid Extensions Package entities declared by one workspace."""
177+ packages : list [dict [str , Any ]] = []
178+ metadata_dir = workspace_dir / "metadata"
179+ if not metadata_dir .is_dir ():
180+ return packages
181+ for path in sorted (metadata_dir .glob ("*.yaml" )):
182+ entity = load_yaml (path )
183+ if entity is None or entity .get ("kind" ) != "Package" :
184+ continue
185+ metadata = entity .get ("metadata" )
186+ if not isinstance (metadata , dict ):
187+ continue
188+ if isinstance (metadata .get ("name" ), str ) and metadata ["name" ]:
189+ packages .append (entity )
190+ return packages
191+
192+
193+ def extension_refs (
194+ repo_root : Path ,
195+ workspace_dir : Path ,
196+ plugin_entity_index : dict [str , list [dict [str , Any ]]] | None = None ,
197+ ) -> tuple [list [str ], list [str ]]:
198+ """Derive verified Plugin and Package refs from existing Extensions YAML."""
199+ packages = workspace_packages (workspace_dir )
200+ package_refs : list [str ] = []
201+ packages_by_plugin : dict [str , set [str ]] = defaultdict (set )
202+ for package in packages :
203+ metadata = package ["metadata" ]
204+ namespace = metadata .get ("namespace" , "default" )
205+ package_name = metadata ["name" ]
206+ if not isinstance (namespace , str ):
207+ namespace = "default"
208+ package_refs .append (canonical_entity_ref ("package" , namespace , package_name ))
209+ part_of = package .get ("spec" , {}).get ("partOf" , [])
210+ if not isinstance (part_of , list ):
211+ part_of = [part_of ]
212+ for value in part_of :
213+ normalized = normalize_part_of_ref (value )
214+ if normalized is None :
215+ log_warn (
216+ f"{ workspace_dir .name } : ignoring invalid Package.spec.partOf { value !r} "
217+ )
218+ continue
219+ plugin_ref = canonical_entity_ref ("plugin" , * normalized )
220+ packages_by_plugin [plugin_ref ].add (package_name )
221+
222+ # The index is shared across all workspaces during a generation. Keep the
223+ # fallback for callers/tests that resolve one workspace directly.
224+ plugin_entities = (
225+ plugin_entity_index
226+ if plugin_entity_index is not None
227+ else extension_entities (repo_root )
228+ )
229+ plugin_refs : list [str ] = []
230+ for plugin_ref , package_names in sorted (packages_by_plugin .items ()):
231+ matches = plugin_entities .get (plugin_ref , [])
232+ if not matches :
233+ log_warn (
234+ f"{ workspace_dir .name } : Package metadata references missing { plugin_ref } "
235+ )
236+ continue
237+ if len (matches ) != 1 :
238+ log_warn (
239+ f"{ workspace_dir .name } : Package metadata references ambiguous { plugin_ref } "
240+ )
241+ continue
242+ expected = {
243+ name
244+ for name in (
245+ package_name_from_plugin_entry (entry )
246+ for entry in matches [0 ].get ("spec" , {}).get ("packages" , [])
247+ )
248+ if name
249+ }
250+ unexpected = package_names - expected
251+ if unexpected or not expected :
252+ log_warn (
253+ f"{ workspace_dir .name } : omitting { plugin_ref } ; Package names not present "
254+ f"in Plugin.spec.packages: { ', ' .join (sorted (unexpected )) or 'none' } "
255+ )
256+ continue
257+ plugin_refs .append (plugin_ref )
258+
259+ return sorted (set (plugin_refs )), sorted (set (package_refs ))
260+
261+
107262class _IndentDumper (yaml .SafeDumper ):
108263 """Keep list items indented under their parent key."""
109264
@@ -166,6 +321,8 @@ def build_workspace_entity(
166321 source : dict ,
167322 plugin_paths : list [str ],
168323 overlay_slug : str ,
324+ plugin_refs : list [str ] | None = None ,
325+ package_refs : list [str ] | None = None ,
169326) -> dict :
170327 plugin_count = len (plugin_paths )
171328 package_word = "package" if plugin_count == 1 else "packages"
@@ -184,6 +341,7 @@ def build_workspace_entity(
184341 ),
185342 "annotations" : {
186343 "github.com/project-slug" : overlay_slug ,
344+ ANNOTATION_OVERLAY_WORKSPACE : workspace ,
187345 },
188346 "tags" : ["overlay" , "dynamic-plugin" ],
189347 "links" : [
@@ -211,9 +369,27 @@ def build_workspace_entity(
211369 "lifecycle" : "experimental" ,
212370 "owner" : "rhdh-team" ,
213371 "system" : "rhdh" ,
214- "subcomponentOf" : ROOT_COMPONENT_NAME ,
372+ "subcomponentOf" : f"component:default/ { ROOT_COMPONENT_NAME } " ,
215373 },
216374 }
375+ source_repo = source .get ("repo" )
376+ if isinstance (source_repo , str ) and source_repo :
377+ entity ["metadata" ]["annotations" ][ANNOTATION_SOURCE_REPOSITORY ] = (
378+ source_repo .rstrip ("/" )
379+ )
380+ source_revision = source .get ("repo-ref" )
381+ if isinstance (source_revision , str ) and source_revision :
382+ entity ["metadata" ]["annotations" ][ANNOTATION_SOURCE_REVISION ] = source_revision
383+
384+ if plugin_refs :
385+ entity ["metadata" ]["annotations" ][ANNOTATION_PLUGIN_REFS ] = ", " .join (
386+ plugin_refs
387+ )
388+ if package_refs :
389+ entity ["metadata" ]["annotations" ][ANNOTATION_PACKAGE_REFS ] = ", " .join (
390+ package_refs
391+ )
392+
217393 depends_on = rhdh_plugins_depends_on (workspace )
218394 if depends_on and is_rhdh_plugins_source (source .get ("repo" , "" )):
219395 entity ["spec" ]["dependsOn" ] = [f"component:default/{ depends_on } " ]
@@ -235,6 +411,7 @@ def planned_files(
235411 repo_root : Path , overlay_slug : str
236412) -> list [tuple [Path , str ]]:
237413 workspaces = discover_workspaces (repo_root )
414+ plugin_entity_index = extension_entities (repo_root )
238415 files : list [tuple [Path , str ]] = [
239416 (
240417 repo_root / "catalog-info.yaml" ,
@@ -244,20 +421,56 @@ def planned_files(
244421 for workspace_dir in workspaces :
245422 source = load_source_json (workspace_dir )
246423 plugin_paths = read_plugins_list (workspace_dir )
424+ plugin_refs , package_refs = extension_refs (
425+ repo_root , workspace_dir , plugin_entity_index
426+ )
247427 entity = build_workspace_entity (
248- workspace_dir .name , source , plugin_paths , overlay_slug
428+ workspace_dir .name ,
429+ source ,
430+ plugin_paths ,
431+ overlay_slug ,
432+ plugin_refs ,
433+ package_refs ,
249434 )
250435 files .append ((workspace_dir / "catalog-info.yaml" , dump_entity (entity )))
251436 return files
252437
253438
254- def write_files (files : list [tuple [Path , str ]], dry_run : bool ) -> None :
439+ def generated_catalog_paths (repo_root : Path ) -> set [Path ]:
440+ return {
441+ path
442+ for path in (
443+ repo_root / "catalog-info.yaml" ,
444+ * (repo_root / "workspaces" ).glob ("*/catalog-info.yaml" ),
445+ )
446+ if path .is_file ()
447+ }
448+
449+
450+ def is_generated_catalog_file (path : Path ) -> bool :
451+ try :
452+ first_lines = "\n " .join (path .read_text (encoding = "utf-8" ).splitlines ()[:2 ])
453+ except OSError :
454+ return False
455+ return first_lines == GENERATED_HEADER .rstrip ()
456+
457+
458+ def write_files (
459+ files : list [tuple [Path , str ]], dry_run : bool , repo_root : Path | None = None
460+ ) -> None :
255461 for path , content in files :
256462 if dry_run :
257463 log_info (f"would write { path } " )
258464 continue
259465 path .write_text (content , encoding = "utf-8" )
260466 log_info (f"wrote { path } " )
467+ if dry_run or repo_root is None :
468+ return
469+ expected = {path for path , _ in files }
470+ for path in generated_catalog_paths (repo_root ) - expected :
471+ if is_generated_catalog_file (path ):
472+ path .unlink ()
473+ log_info (f"removed obsolete generated file { path } " )
261474
262475
263476def check_files (files : list [tuple [Path , str ]]) -> int :
@@ -268,6 +481,12 @@ def check_files(files: list[tuple[Path, str]]) -> int:
268481 continue
269482 if path .read_text (encoding = "utf-8" ) != content :
270483 stale .append (f"stale { path } " )
484+ if files :
485+ repo_root = files [0 ][0 ].parent
486+ expected = {path for path , _ in files }
487+ for path in generated_catalog_paths (repo_root ) - expected :
488+ if is_generated_catalog_file (path ):
489+ stale .append (f"unexpected generated file { path } " )
271490 if stale :
272491 for item in stale :
273492 log_error (item )
@@ -332,7 +551,7 @@ def main(argv: list[str] | None = None) -> int:
332551 if args .check :
333552 return check_files (files )
334553
335- write_files (files , dry_run = args .dry_run )
554+ write_files (files , dry_run = args .dry_run , repo_root = repo_root )
336555 log_info (f"{ len (files )} catalog-info.yaml file(s)" )
337556 return 0
338557
0 commit comments