Files
govoplan/tools/release/govoplan_release/release_intelligence.py
Albrecht Degering 05ae81d641
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 16s
Security Audit / security-audit (push) Failing after 11s
intermediate commit
2026-07-14 13:32:09 +02:00

435 lines
18 KiB
Python

"""Release catalog intelligence for the local release cockpit."""
from __future__ import annotations
from datetime import UTC, datetime
import json
from pathlib import Path
import re
from .catalog import DEFAULT_PUBLIC_BASE_URL, canonical_hash, fetch_json
from .git_state import collect_repository_snapshot
from .workspace import load_repository_specs, resolve_workspace_root, website_root
def build_release_intelligence(
*,
workspace_root: Path | str | None = None,
channel: str = "stable",
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
prefer_public: bool = True,
) -> dict[str, object]:
workspace = resolve_workspace_root(workspace_root)
web_root = website_root(workspace)
local_catalog_path = web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
local_keyring_path = web_root / "public" / "catalogs" / "v1" / "keyring.json"
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"
local_catalog = read_json_file(local_catalog_path)
local_keyring = read_json_file(local_keyring_path)
public_catalog = fetch_json(public_catalog_url) if prefer_public else {"ok": False, "error": "public check disabled"}
public_keyring = fetch_json(public_keyring_url) if prefer_public else {"ok": False, "error": "public check disabled"}
catalog_source = "public" if prefer_public and public_catalog.get("ok") else "local"
keyring_source = "public" if prefer_public and public_keyring.get("ok") else "local"
catalog_payload = public_catalog["payload"] if catalog_source == "public" else local_catalog.get("payload")
keyring_payload = public_keyring["payload"] if keyring_source == "public" else local_keyring.get("payload")
modules = module_rows(catalog_payload)
compatibility = compatibility_rows(modules)
signatures = signature_rows(catalog_payload, keyring_payload)
local_versions = repository_versions(workspace)
for module in modules:
repo = module.get("repo")
module["local_version"] = local_versions.get(repo) if isinstance(repo, str) else None
module["local_matches_catalog"] = module.get("version") == module.get("local_version") if module.get("local_version") else None
local_catalog_hash = canonical_hash(local_catalog["payload"]) if local_catalog.get("ok") else None
public_catalog_hash = canonical_hash(public_catalog["payload"]) if public_catalog.get("ok") else None
local_keyring_hash = canonical_hash(local_keyring["payload"]) if local_keyring.get("ok") else None
public_keyring_hash = canonical_hash(public_keyring["payload"]) if public_keyring.get("ok") else None
issues = health_issues(
catalog_payload=catalog_payload,
keyring_payload=keyring_payload,
local_catalog_hash=local_catalog_hash,
public_catalog_hash=public_catalog_hash,
local_keyring_hash=local_keyring_hash,
public_keyring_hash=public_keyring_hash,
signatures=signatures,
compatibility=compatibility,
)
return {
"generated_at": json_datetime(datetime.now(tz=UTC)),
"channel": channel,
"catalog_source": catalog_source,
"keyring_source": keyring_source,
"catalog": catalog_summary(
catalog_payload,
source=catalog_source,
local_path=str(local_catalog_path),
public_url=public_catalog_url,
local_ok=bool(local_catalog.get("ok")),
public_ok=bool(public_catalog.get("ok")),
local_hash=local_catalog_hash,
public_hash=public_catalog_hash,
public_error=str(public_catalog.get("error")) if public_catalog.get("error") else None,
),
"keyring": keyring_summary(
keyring_payload,
source=keyring_source,
local_path=str(local_keyring_path),
public_url=public_keyring_url,
local_ok=bool(local_keyring.get("ok")),
public_ok=bool(public_keyring.get("ok")),
local_hash=local_keyring_hash,
public_hash=public_keyring_hash,
public_error=str(public_keyring.get("error")) if public_keyring.get("error") else None,
),
"modules": modules,
"signatures": signatures,
"compatibility": compatibility,
"issues": issues,
"summary": {
"status": status_from_issues(issues),
"published_modules": len(modules),
"signed": bool(signatures),
"trusted_signatures": sum(1 for item in signatures if item.get("trusted") is True),
"compatibility_blockers": sum(1 for item in compatibility if item.get("severity") == "blocker"),
"compatibility_warnings": sum(1 for item in compatibility if item.get("severity") == "warning"),
"catalog_matches_public": compare_hashes(local_catalog_hash, public_catalog_hash),
"keyring_matches_public": compare_hashes(local_keyring_hash, public_keyring_hash),
},
}
def read_json_file(path: Path) -> dict[str, object]:
if not path.exists():
return {"ok": False, "error": "missing"}
try:
return {"ok": True, "payload": json.loads(path.read_text(encoding="utf-8"))}
except (OSError, json.JSONDecodeError) as exc:
return {"ok": False, "error": str(exc)}
def catalog_summary(
payload: object,
*,
source: str,
local_path: str,
public_url: str,
local_ok: bool,
public_ok: bool,
local_hash: str | None,
public_hash: str | None,
public_error: str | None,
) -> dict[str, object]:
catalog = payload if isinstance(payload, dict) else {}
core = catalog.get("core_release") if isinstance(catalog.get("core_release"), dict) else {}
modules = catalog.get("modules") if isinstance(catalog.get("modules"), list) else []
signatures = catalog.get("signatures") if isinstance(catalog.get("signatures"), list) else []
return {
"source": source,
"local_path": local_path,
"public_url": public_url,
"local_ok": local_ok,
"public_ok": public_ok,
"public_error": public_error,
"local_hash": local_hash,
"public_hash": public_hash,
"matches_public": compare_hashes(local_hash, public_hash),
"catalog_version": catalog.get("catalog_version"),
"sequence": catalog.get("sequence"),
"generated_at": catalog.get("generated_at"),
"expires_at": catalog.get("expires_at"),
"core_version": core.get("version") if isinstance(core, dict) else None,
"module_count": len(modules),
"signature_count": len(signatures),
}
def keyring_summary(
payload: object,
*,
source: str,
local_path: str,
public_url: str,
local_ok: bool,
public_ok: bool,
local_hash: str | None,
public_hash: str | None,
public_error: str | None,
) -> dict[str, object]:
keyring = payload if isinstance(payload, dict) else {}
keys = keyring.get("keys") if isinstance(keyring.get("keys"), list) else []
return {
"source": source,
"local_path": local_path,
"public_url": public_url,
"local_ok": local_ok,
"public_ok": public_ok,
"public_error": public_error,
"local_hash": local_hash,
"public_hash": public_hash,
"matches_public": compare_hashes(local_hash, public_hash),
"generated_at": keyring.get("generated_at"),
"key_count": len(keys),
"active_key_count": sum(1 for item in keys if isinstance(item, dict) and item.get("status") == "active"),
}
def module_rows(payload: object) -> list[dict[str, object]]:
catalog = payload if isinstance(payload, dict) else {}
raw_modules = catalog.get("modules") if isinstance(catalog.get("modules"), list) else []
modules: list[dict[str, object]] = []
for item in raw_modules:
if not isinstance(item, dict):
continue
repo = module_repo(item)
modules.append(
{
"module_id": string(item.get("module_id")),
"name": string(item.get("name")),
"version": string(item.get("version")),
"repo": repo,
"python_ref": string(item.get("python_ref")),
"python_tag": ref_tag(string(item.get("python_ref"))),
"webui_ref": string(item.get("webui_ref")),
"webui_tag": ref_tag(string(item.get("webui_ref"))),
"provides_interfaces": interface_list(item.get("provides_interfaces")),
"requires_interfaces": requirement_list(item.get("requires_interfaces")),
"migration_safety": string(item.get("migration_safety")),
"tags": tuple(str(value) for value in item.get("tags", ()) if isinstance(value, str)) if isinstance(item.get("tags"), list) else (),
}
)
return sorted(modules, key=lambda row: str(row.get("module_id") or row.get("repo") or ""))
def module_repo(entry: dict[str, object]) -> 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")
if isinstance(package, str) and package.startswith("govoplan-"):
return package.split("[", 1)[0]
return None
def ref_tag(value: str | None) -> str | None:
if not value:
return None
if "#" in value:
return value.rsplit("#", 1)[-1]
if "@" in value:
return value.rsplit("@", 1)[-1]
return None
def interface_list(value: object) -> tuple[dict[str, str], ...]:
if not isinstance(value, list):
return ()
result: list[dict[str, str]] = []
for item in value:
if not isinstance(item, dict):
continue
name = string(item.get("name"))
version = string(item.get("version"))
if name and version:
result.append({"name": name, "version": version})
return tuple(result)
def requirement_list(value: object) -> tuple[dict[str, object], ...]:
if not isinstance(value, list):
return ()
result: list[dict[str, object]] = []
for item in value:
if not isinstance(item, dict):
continue
name = string(item.get("name"))
if not name:
continue
result.append(
{
"name": name,
"version_min": string(item.get("version_min")),
"version_max_exclusive": string(item.get("version_max_exclusive")),
"optional": bool(item.get("optional")),
}
)
return tuple(result)
def compatibility_rows(modules: list[dict[str, object]]) -> list[dict[str, object]]:
providers: dict[str, list[dict[str, object]]] = {}
for module in modules:
for provided in module.get("provides_interfaces", ()):
if isinstance(provided, dict):
providers.setdefault(str(provided.get("name")), []).append(
{
"module_id": module.get("module_id"),
"repo": module.get("repo"),
"version": provided.get("version"),
}
)
rows: list[dict[str, object]] = []
for module in modules:
for requirement in module.get("requires_interfaces", ()):
if not isinstance(requirement, dict):
continue
name = string(requirement.get("name"))
if not name:
continue
optional = bool(requirement.get("optional"))
available = providers.get(name, [])
satisfied = [provider for provider in available if version_satisfies(string(provider.get("version")), requirement)]
if satisfied:
severity = "ok"
status = "satisfied"
elif available:
severity = "warning" if optional else "blocker"
status = "version-mismatch"
else:
severity = "info" if optional else "blocker"
status = "optional-missing" if optional else "missing"
rows.append(
{
"module_id": module.get("module_id"),
"repo": module.get("repo"),
"interface": name,
"requirement": format_requirement(requirement),
"optional": optional,
"status": status,
"severity": severity,
"providers": available,
"satisfied_by": satisfied,
}
)
return sorted(rows, key=lambda row: (str(row.get("severity")), str(row.get("module_id")), str(row.get("interface"))))
def signature_rows(catalog_payload: object, keyring_payload: object) -> list[dict[str, object]]:
catalog = catalog_payload if isinstance(catalog_payload, dict) else {}
keyring = keyring_payload if isinstance(keyring_payload, dict) else {}
signatures = catalog.get("signatures") if isinstance(catalog.get("signatures"), list) else []
keys = keyring.get("keys") if isinstance(keyring.get("keys"), list) else []
trusted = {
string(item.get("key_id")): item
for item in keys
if isinstance(item, dict) and string(item.get("key_id"))
}
rows: list[dict[str, object]] = []
for item in signatures:
if not isinstance(item, dict):
continue
key_id = string(item.get("key_id"))
key = trusted.get(key_id)
rows.append(
{
"key_id": key_id,
"algorithm": string(item.get("algorithm")),
"trusted": key is not None and key.get("status") == "active",
"key_status": string(key.get("status")) if isinstance(key, dict) else None,
}
)
return rows
def repository_versions(workspace: Path) -> dict[str | None, str | None]:
versions: dict[str | None, str | None] = {}
for spec in load_repository_specs(include_website=False):
snapshot = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
versions[spec.name] = snapshot.versions.primary
return versions
def health_issues(
*,
catalog_payload: object,
keyring_payload: object,
local_catalog_hash: str | None,
public_catalog_hash: str | None,
local_keyring_hash: str | None,
public_keyring_hash: str | None,
signatures: list[dict[str, object]],
compatibility: list[dict[str, object]],
) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
if not isinstance(catalog_payload, dict):
issues.append({"severity": "blocker", "code": "catalog_missing", "message": "No usable catalog is available."})
if not isinstance(keyring_payload, dict):
issues.append({"severity": "blocker", "code": "keyring_missing", "message": "No usable keyring is available."})
if not signatures:
issues.append({"severity": "blocker", "code": "catalog_unsigned", "message": "The selected catalog has no signatures."})
elif not any(item.get("trusted") is True for item in signatures):
issues.append({"severity": "blocker", "code": "signature_untrusted", "message": "No catalog signature matches an active keyring key."})
if compare_hashes(local_catalog_hash, public_catalog_hash) is False:
issues.append({"severity": "warning", "code": "catalog_drift", "message": "Local and published catalogs differ."})
if compare_hashes(local_keyring_hash, public_keyring_hash) is False:
issues.append({"severity": "warning", "code": "keyring_drift", "message": "Local and published keyrings differ."})
blockers = [item for item in compatibility if item.get("severity") == "blocker"]
warnings = [item for item in compatibility if item.get("severity") == "warning"]
if blockers:
issues.append({"severity": "blocker", "code": "compatibility_blockers", "message": f"{len(blockers)} compatibility blocker(s) found."})
if warnings:
issues.append({"severity": "warning", "code": "compatibility_warnings", "message": f"{len(warnings)} compatibility warning(s) found."})
return issues
def version_satisfies(version: str | None, requirement: dict[str, object]) -> bool:
parsed = parse_version(version)
if parsed is None:
return False
minimum = parse_version(string(requirement.get("version_min")))
maximum = parse_version(string(requirement.get("version_max_exclusive")))
if minimum is not None and parsed < minimum:
return False
if maximum is not None and parsed >= maximum:
return False
return True
def parse_version(value: str | None) -> tuple[int, ...] | None:
if not value:
return None
core = value.removeprefix("v").split("-", 1)[0]
parts = core.split(".")
if not parts or any(not part.isdigit() for part in parts):
return None
return tuple(int(part) for part in parts)
def format_requirement(requirement: dict[str, object]) -> str:
parts: list[str] = []
if requirement.get("version_min"):
parts.append(f">={requirement['version_min']}")
if requirement.get("version_max_exclusive"):
parts.append(f"<{requirement['version_max_exclusive']}")
return ", ".join(parts) if parts else "any"
def status_from_issues(issues: list[dict[str, str]]) -> str:
if any(item.get("severity") == "blocker" for item in issues):
return "blocked"
if any(item.get("severity") == "warning" for item in issues):
return "attention"
return "ready"
def compare_hashes(local_hash: str | None, public_hash: str | None) -> bool | None:
if local_hash is None or public_hash is None:
return None
return local_hash == public_hash
def string(value: object) -> str | None:
return value if isinstance(value, str) else None
def json_datetime(value: datetime) -> str:
return value.replace(microsecond=0).isoformat().replace("+00:00", "Z")