Skip to content

Commit fa1be4d

Browse files
authored
Merge pull request #24 from rohitkrai03/plugin-lifecycle-poc
feat(catalog): link overlay workspaces to Extensions entities
2 parents ea7f6d3 + 56e0682 commit fa1be4d

68 files changed

Lines changed: 789 additions & 71 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/validate-catalog-scripts.yaml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ on:
2020
- '.github/workflows/validate-catalog-scripts.yaml'
2121
- 'catalog-info.yaml'
2222
- 'workspaces/*/catalog-info.yaml'
23+
- 'workspaces/*/source.json'
24+
- 'workspaces/*/plugins-list.yaml'
25+
- 'workspaces/*/metadata/*.yaml'
26+
- 'catalog-entities/extensions/plugins/*.yaml'
2327
workflow_dispatch:
2428

2529
concurrency:

‎scripts/generateCatalogInfo.py‎

Lines changed: 223 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
import argparse
2323
import json
2424
import sys
25+
from collections import defaultdict
2526
from pathlib import Path
27+
from typing import Any
2628

2729
import yaml
2830

@@ -41,6 +43,11 @@
4143

4244
DEFAULT_OVERLAY_SLUG = "rhdh-parasol/rhdh-plugin-export-overlays"
4345
ROOT_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+
107262
class _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

263476
def 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

Comments
 (0)