intermediate commit
This commit is contained in:
434
tools/release/govoplan_release/release_intelligence.py
Normal file
434
tools/release/govoplan_release/release_intelligence.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user