"""Selective release catalog candidate generation.""" from __future__ import annotations import base64 from datetime import UTC, datetime, timedelta import json from pathlib import Path import re from typing import Any from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from govoplan_core.core.module_package_catalog import validate_module_package_catalog from .catalog import DEFAULT_PUBLIC_BASE_URL, canonical_hash, fetch_json from .contracts import collect_contracts from .git_state import collect_repository_snapshot from .model import CatalogEntryChange, SelectiveCatalogCandidate from .module_directory import write_module_directory from .workspace import load_repository_specs, resolve_workspace_root, website_root GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas" def build_selective_catalog_candidate( *, repo_versions: dict[str, str], channel: str = "stable", workspace_root: Path | str | None = None, output_dir: Path | str | None = None, base_catalog: Path | str | None = None, signing_keys: tuple[str, ...] = (), public_base_url: str = DEFAULT_PUBLIC_BASE_URL, repository_base: str = GITEA_BASE, expires_days: int = 90, sequence: int | None = None, check_public: bool = True, write_summary: bool = True, ) -> SelectiveCatalogCandidate: if not repo_versions: raise ValueError("At least one repo version must be provided.") parsed_keys = tuple(parse_signing_key(value) for value in signing_keys) if not parsed_keys: raise ValueError("At least one signing key is required.") workspace = resolve_workspace_root(workspace_root) web_root = website_root(workspace) source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel) payload = read_catalog(source_catalog) if not isinstance(payload, dict): raise ValueError("Selective catalog updates require object-style catalogs.") generated_at = datetime.now(tz=UTC) resolved_sequence = sequence or next_sequence(payload, generated_at=generated_at) candidate = json.loads(json.dumps(payload)) candidate["channel"] = channel candidate["sequence"] = resolved_sequence candidate["generated_at"] = json_datetime(generated_at) candidate["expires_at"] = json_datetime(generated_at + timedelta(days=expires_days)) release = candidate.get("release") if not isinstance(release, dict): release = {} release["catalog_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json" release["keyring_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json" release["selected_units"] = [{"repo": repo, "version": version, "tag": f"v{version.removeprefix('v')}"} for repo, version in sorted(repo_versions.items())] candidate["release"] = release repo_contracts = contracts_by_repo(workspace) changes = apply_repo_updates( candidate, repo_versions=repo_versions, repo_contracts=repo_contracts, repository_base=repository_base.rstrip("/"), ) candidate.pop("signature", None) candidate.pop("signatures", None) candidate["signatures"] = [signature(candidate, key_id=key_id, private_key=private_key) for key_id, private_key in parsed_keys] output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at) catalog_path = output_root / "channels" / f"{channel}.json" keyring_path = output_root / "keyring.json" summary_path = output_root / "summary.json" if write_summary else None catalog_path.parent.mkdir(parents=True, exist_ok=True) catalog_path.write_text(json.dumps(candidate, indent=2, sort_keys=True) + "\n", encoding="utf-8") keyring_payload = keyring_payload_for_candidate( existing_keyring=web_root / "public" / "catalogs" / "v1" / "keyring.json", signing_keys=parsed_keys, generated_at=generated_at, ) keyring_path.write_text(json.dumps(keyring_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") module_directory_files = write_module_directory( catalog_payload=candidate, keyring_payload=keyring_payload, output_root=output_root, channel=channel, public_base_url=public_base_url, ) trusted_keys = trusted_keys_from_keyring(keyring_payload) validation = validate_module_package_catalog( catalog_path, require_trusted=True, approved_channels=(channel,), trusted_keys=trusted_keys, ) validation_warnings = tuple(str(item) for item in validation.get("warnings") or ()) validation_error = validation.get("error") candidate_catalog_hash = canonical_hash(candidate) candidate_keyring_hash = canonical_hash(keyring_payload) public_catalog_url = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json" public_keyring_url = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json" published_catalog_hash: str | None = None published_keyring_hash: str | None = None if check_public: published_catalog = fetch_json(public_catalog_url) if published_catalog["ok"]: published_catalog_hash = canonical_hash(published_catalog["payload"]) published_keyring = fetch_json(public_keyring_url) if published_keyring["ok"]: published_keyring_hash = canonical_hash(published_keyring["payload"]) result = SelectiveCatalogCandidate( generated_at=json_datetime(generated_at), channel=channel, status="ready" if validation.get("valid") else "blocked", candidate_dir=str(output_root), catalog_path=str(catalog_path), keyring_path=str(keyring_path), summary_path=str(summary_path) if summary_path is not None else None, source_catalog=str(source_catalog), public_catalog_url=public_catalog_url, public_keyring_url=public_keyring_url, sequence=resolved_sequence, signature_count=len(candidate["signatures"]) if isinstance(candidate.get("signatures"), list) else 0, key_count=len(keyring_payload.get("keys", ())) if isinstance(keyring_payload.get("keys"), list) else 0, candidate_catalog_hash=candidate_catalog_hash, candidate_keyring_hash=candidate_keyring_hash, published_catalog_hash=published_catalog_hash, published_keyring_hash=published_keyring_hash, candidate_matches_published_catalog=compare_hash(candidate_catalog_hash, published_catalog_hash), candidate_matches_published_keyring=compare_hash(candidate_keyring_hash, published_keyring_hash), validation_valid=validation.get("valid") is True, validation_error=str(validation_error) if validation_error else None, validation_warnings=validation_warnings, changes=tuple(changes), notes=( "Candidate catalog preserves unchanged entries from the source catalog.", f"Generated {len(module_directory_files)} module-directory file(s).", "Publishing is intentionally separate from candidate generation.", ), ) if summary_path is not None: summary_path.write_text(json.dumps(dataclass_payload(result), indent=2, sort_keys=True) + "\n", encoding="utf-8") return result def resolve_base_catalog(*, base_catalog: Path | str | None, web_root: Path, channel: str) -> Path | str: if base_catalog is not None: value = str(base_catalog) return value if value.startswith(("http://", "https://")) else Path(value).expanduser() local = web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json" if local.exists(): return local return f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/channels/{channel}.json" def read_catalog(source: Path | str) -> object: if isinstance(source, str) and source.startswith(("http://", "https://")): payload = fetch_json(source) if not payload["ok"]: raise ValueError(f"Could not fetch catalog {source}: {payload['error']}") return payload["payload"] return json.loads(Path(source).read_text(encoding="utf-8")) def next_sequence(payload: dict[str, Any], *, generated_at: datetime) -> int: timestamp_sequence = int(generated_at.strftime("%Y%m%d%H%M")) try: current = int(payload.get("sequence") or 0) except (TypeError, ValueError): current = 0 return max(current + 1, timestamp_sequence) def contracts_by_repo(workspace: Path) -> dict[str, Any]: specs = load_repository_specs(include_website=False) snapshots = tuple( collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False) for spec in specs ) return {contract.repo: contract for contract in collect_contracts(snapshots)} def apply_repo_updates( payload: dict[str, Any], *, repo_versions: dict[str, str], repo_contracts: dict[str, Any], repository_base: str, ) -> list[CatalogEntryChange]: changes: list[CatalogEntryChange] = [] modules = payload.get("modules") if not isinstance(modules, list): raise ValueError("Catalog payload has no modules list.") handled: set[str] = set() if "govoplan-core" in repo_versions: version = repo_versions["govoplan-core"].removeprefix("v") tag = f"v{version}" core_release = payload.get("core_release") if not isinstance(core_release, dict): raise ValueError("Catalog payload has no core_release object.") changes.extend(update_field(core_release, repo="govoplan-core", module_id=None, field="version", value=version)) changes.extend(update_field(core_release, repo="govoplan-core", module_id=None, field="python_ref", value=f"govoplan-core[server] @ {repository_base}/govoplan-core.git@{tag}")) changes.extend(update_field(core_release, repo="govoplan-core", module_id=None, field="webui_ref", value=f"{repository_base}/govoplan-core.git#{tag}")) release = payload.get("release") if isinstance(release, dict) and isinstance(release.get("version"), str): changes.extend(update_field(release, repo="govoplan-core", module_id=None, field="version", value=version)) changes.extend(update_field(release, repo="govoplan-core", module_id=None, field="tag", value=tag)) handled.add("govoplan-core") for entry in modules: if not isinstance(entry, dict): continue repo = module_entry_repo(entry) if repo not in repo_versions: continue version = repo_versions[repo].removeprefix("v") tag = f"v{version}" module_id = str(entry.get("module_id") or "") package = str(entry.get("python_package") or repo) changes.extend(update_field(entry, repo=repo, module_id=module_id, field="version", value=version)) changes.extend(update_field(entry, repo=repo, module_id=module_id, field="python_ref", value=f"{package} @ {repository_base}/{repo}.git@{tag}")) if entry.get("webui_package"): changes.extend(update_field(entry, repo=repo, module_id=module_id, field="webui_ref", value=f"{repository_base}/{repo}.git#{tag}")) contract = repo_contracts.get(repo) if contract is not None: provider_payload = [{"name": item.name, "version": item.version} for item in contract.provides_interfaces] requirement_payload = [ { "name": item.name, **({"version_min": item.version_min} if item.version_min else {}), **({"version_max_exclusive": item.version_max_exclusive} if item.version_max_exclusive else {}), "optional": item.optional, } for item in contract.requires_interfaces ] if provider_payload: changes.extend(update_json_field(entry, repo=repo, module_id=module_id, field="provides_interfaces", value=provider_payload)) if requirement_payload: changes.extend(update_json_field(entry, repo=repo, module_id=module_id, field="requires_interfaces", value=requirement_payload)) handled.add(repo) missing = sorted(set(repo_versions) - handled) if missing: raise ValueError(f"Selected repositories are not represented in the catalog: {', '.join(missing)}") return changes def module_entry_repo(entry: dict[str, Any]) -> str | None: for field in ("python_ref", "webui_ref"): value = entry.get(field) if isinstance(value, str): match = re.search(r"/([^/@#]+)[.]git(?:[@#]|$)", value) if match: return match.group(1) package = entry.get("python_package") return str(package).split("[", 1)[0] if isinstance(package, str) and package.startswith("govoplan-") else None def update_field(target: dict[str, Any], *, repo: str, module_id: str | None, field: str, value: str) -> list[CatalogEntryChange]: before = target.get(field) before_text = before if isinstance(before, str) else None if before_text == value: return [] target[field] = value return [CatalogEntryChange(repo=repo, module_id=module_id, field=field, before=before_text, after=value)] def update_json_field(target: dict[str, Any], *, repo: str, module_id: str | None, field: str, value: list[dict[str, object]]) -> list[CatalogEntryChange]: before = target.get(field) if before == value: return [] target[field] = value return [ CatalogEntryChange( repo=repo, module_id=module_id, field=field, before=json.dumps(before, sort_keys=True) if before is not None else None, after=json.dumps(value, sort_keys=True), ) ] def parse_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]: key_id, separator, path_text = value.partition("=") if not separator or not key_id.strip() or not path_text.strip(): raise ValueError("--catalog-signing-key must use KEY_ID=/path/to/private.pem") path = Path(path_text).expanduser() private_key = serialization.load_pem_private_key(path.read_bytes(), password=None) if not isinstance(private_key, Ed25519PrivateKey): raise ValueError(f"Catalog signing key must be an Ed25519 private key: {path}") return key_id.strip(), private_key def signature(payload: dict[str, Any], *, key_id: str, private_key: Ed25519PrivateKey) -> dict[str, str]: signature_payload = dict(payload) signature_payload.pop("signature", None) signature_payload.pop("signatures", None) return { "algorithm": "ed25519", "key_id": key_id, "value": base64.b64encode(private_key.sign(canonical_bytes(signature_payload))).decode("ascii"), } def keyring_payload_for_candidate( *, existing_keyring: Path, signing_keys: tuple[tuple[str, Ed25519PrivateKey], ...], generated_at: datetime, ) -> dict[str, Any]: expected = {key_id: public_key_base64(private_key) for key_id, private_key in signing_keys} if existing_keyring.exists(): payload = json.loads(existing_keyring.read_text(encoding="utf-8")) keys = payload.get("keys") if isinstance(payload, dict) else None if isinstance(keys, list): existing = { str(item.get("key_id")): str(item.get("public_key") or item.get("public_key_base64")) for item in keys if isinstance(item, dict) and item.get("key_id") } if all(existing.get(key_id) == public_key for key_id, public_key in expected.items()): return payload return { "keyring_version": "1", "purpose": "govoplan module package catalog signatures", "generated_at": json_datetime(generated_at), "keys": [ { "key_id": key_id, "status": "active", "public_key": public_key_base64(private_key), "not_before": generated_at.date().isoformat() + "T00:00:00Z", } for key_id, private_key in signing_keys ], } def trusted_keys_from_keyring(payload: dict[str, Any]) -> dict[str, str]: keys = payload.get("keys") if not isinstance(keys, list): return {} result: dict[str, str] = {} for item in keys: if not isinstance(item, dict): continue status = str(item.get("status") or "active").lower() if status in {"revoked", "disabled", "retired"}: continue key_id = str(item.get("key_id") or "") public_key = str(item.get("public_key") or item.get("public_key_base64") or "") if key_id and public_key: result[key_id] = public_key return result def public_key_base64(private_key: Ed25519PrivateKey) -> str: public_bytes = private_key.public_key().public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ) return base64.b64encode(public_bytes).decode("ascii") def canonical_bytes(payload: object) -> bytes: return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") def compare_hash(candidate_hash: str | None, published_hash: str | None) -> bool | None: if candidate_hash is None or published_hash is None: return None return candidate_hash == published_hash def resolve_output_dir(*, output_dir: Path | str | None, channel: str, generated_at: datetime) -> Path: if output_dir is not None: return Path(output_dir).expanduser() stamp = generated_at.strftime("%Y%m%d-%H%M%S") return Path.cwd() / "runtime" / "release-candidates" / f"{channel}-{stamp}" def json_datetime(value: datetime) -> str: return value.astimezone(UTC).isoformat().replace("+00:00", "Z") def dataclass_payload(value: object) -> dict[str, object]: from dataclasses import asdict return asdict(value)