intermediate commit
This commit is contained in:
185
tools/release/govoplan_release/catalog.py
Normal file
185
tools/release/govoplan_release/catalog.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Release catalog inspection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from .http_fetch import fetch_http
|
||||
from .model import CatalogSnapshot
|
||||
from .workspace import website_root
|
||||
|
||||
DEFAULT_PUBLIC_BASE_URL = "https://govoplan.add-ideas.de"
|
||||
|
||||
|
||||
def collect_catalog_snapshot(
|
||||
*,
|
||||
workspace_root: Path,
|
||||
channel: str = "stable",
|
||||
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
||||
check_public: bool = False,
|
||||
) -> CatalogSnapshot:
|
||||
web_root = website_root(workspace_root)
|
||||
catalog_path = web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
|
||||
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"
|
||||
|
||||
catalog_version: str | None = None
|
||||
core_release_version: str | None = None
|
||||
module_count = 0
|
||||
signature_count = 0
|
||||
generated_at: str | None = None
|
||||
expires_at: str | None = None
|
||||
catalog_error: str | None = None
|
||||
local_catalog_hash: str | None = None
|
||||
|
||||
if catalog_path.exists():
|
||||
try:
|
||||
payload = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
local_catalog_hash = canonical_hash(payload)
|
||||
parsed = parse_catalog_payload(payload)
|
||||
catalog_version = parsed["catalog_version"]
|
||||
core_release_version = parsed["core_release_version"]
|
||||
module_count = parsed["module_count"] or 0
|
||||
signature_count = parsed["signature_count"] or 0
|
||||
generated_at = parsed["generated_at"]
|
||||
expires_at = parsed["expires_at"]
|
||||
except Exception as exc: # noqa: BLE001 - shown in local operator UI.
|
||||
catalog_error = str(exc)
|
||||
|
||||
key_count = 0
|
||||
keyring_error: str | None = None
|
||||
local_keyring_hash: str | None = None
|
||||
if keyring_path.exists():
|
||||
try:
|
||||
payload = json.loads(keyring_path.read_text(encoding="utf-8"))
|
||||
local_keyring_hash = canonical_hash(payload)
|
||||
keys = payload.get("keys") if isinstance(payload, dict) else None
|
||||
key_count = len(keys) if isinstance(keys, list) else 0
|
||||
except Exception as exc: # noqa: BLE001 - shown in local operator UI.
|
||||
keyring_error = str(exc)
|
||||
|
||||
public_catalog_exists: bool | None = None
|
||||
public_keyring_exists: bool | None = None
|
||||
public_core_release_version: str | None = None
|
||||
public_module_count: int | None = None
|
||||
public_signature_count: int | None = None
|
||||
public_key_count: int | None = None
|
||||
public_catalog_hash: str | None = None
|
||||
public_keyring_hash: str | None = None
|
||||
public_catalog_error: str | None = None
|
||||
public_keyring_error: str | None = None
|
||||
|
||||
if check_public:
|
||||
public_catalog = fetch_json(public_catalog_url)
|
||||
if public_catalog["ok"]:
|
||||
public_catalog_exists = True
|
||||
payload = public_catalog["payload"]
|
||||
public_catalog_hash = canonical_hash(payload)
|
||||
parsed = parse_catalog_payload(payload)
|
||||
public_core_release_version = parsed["core_release_version"]
|
||||
public_module_count = parsed["module_count"]
|
||||
public_signature_count = parsed["signature_count"]
|
||||
else:
|
||||
public_catalog_exists = False
|
||||
public_catalog_error = str(public_catalog["error"])
|
||||
|
||||
public_keyring = fetch_json(public_keyring_url)
|
||||
if public_keyring["ok"]:
|
||||
public_keyring_exists = True
|
||||
payload = public_keyring["payload"]
|
||||
public_keyring_hash = canonical_hash(payload)
|
||||
keys = payload.get("keys") if isinstance(payload, dict) else None
|
||||
public_key_count = len(keys) if isinstance(keys, list) else 0
|
||||
else:
|
||||
public_keyring_exists = False
|
||||
public_keyring_error = str(public_keyring["error"])
|
||||
|
||||
return CatalogSnapshot(
|
||||
channel=channel,
|
||||
website_path=str(web_root),
|
||||
catalog_path=str(catalog_path),
|
||||
catalog_exists=catalog_path.exists(),
|
||||
keyring_path=str(keyring_path),
|
||||
keyring_exists=keyring_path.exists(),
|
||||
catalog_version=catalog_version,
|
||||
core_release_version=core_release_version,
|
||||
module_count=module_count,
|
||||
signature_count=signature_count,
|
||||
key_count=key_count,
|
||||
generated_at=generated_at,
|
||||
expires_at=expires_at,
|
||||
catalog_error=catalog_error,
|
||||
keyring_error=keyring_error,
|
||||
local_catalog_hash=local_catalog_hash,
|
||||
local_keyring_hash=local_keyring_hash,
|
||||
public_catalog_url=public_catalog_url,
|
||||
public_keyring_url=public_keyring_url,
|
||||
public_checked=check_public,
|
||||
public_catalog_exists=public_catalog_exists,
|
||||
public_keyring_exists=public_keyring_exists,
|
||||
public_core_release_version=public_core_release_version,
|
||||
public_module_count=public_module_count,
|
||||
public_signature_count=public_signature_count,
|
||||
public_key_count=public_key_count,
|
||||
public_catalog_hash=public_catalog_hash,
|
||||
public_keyring_hash=public_keyring_hash,
|
||||
public_catalog_error=public_catalog_error,
|
||||
public_keyring_error=public_keyring_error,
|
||||
catalog_matches_public=compare_hashes(local_catalog_hash, public_catalog_hash) if check_public else None,
|
||||
keyring_matches_public=compare_hashes(local_keyring_hash, public_keyring_hash) if check_public else None,
|
||||
)
|
||||
|
||||
|
||||
def parse_catalog_payload(payload: object) -> dict[str, str | int | None]:
|
||||
if not isinstance(payload, dict):
|
||||
return {
|
||||
"catalog_version": None,
|
||||
"core_release_version": None,
|
||||
"module_count": 0,
|
||||
"signature_count": 0,
|
||||
"generated_at": None,
|
||||
"expires_at": None,
|
||||
}
|
||||
core_release = payload.get("core_release")
|
||||
modules = payload.get("modules")
|
||||
signatures = payload.get("signatures")
|
||||
return {
|
||||
"catalog_version": string_or_none(payload.get("catalog_version")),
|
||||
"core_release_version": string_or_none(core_release.get("version")) if isinstance(core_release, dict) else None,
|
||||
"module_count": len(modules) if isinstance(modules, list) else 0,
|
||||
"signature_count": len(signatures) if isinstance(signatures, list) else 0,
|
||||
"generated_at": string_or_none(payload.get("generated_at")),
|
||||
"expires_at": string_or_none(payload.get("expires_at")),
|
||||
}
|
||||
|
||||
|
||||
def fetch_json(url: str) -> dict[str, object]:
|
||||
try:
|
||||
response = fetch_http(
|
||||
url,
|
||||
timeout=10,
|
||||
label="Public release catalog URL",
|
||||
headers={"User-Agent": "GovOPlaN release-console"},
|
||||
)
|
||||
return {"ok": True, "payload": json.loads(response.text())}
|
||||
except (HTTPError, URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def canonical_hash(payload: object) -> str:
|
||||
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
return hashlib.sha256(canonical).hexdigest()
|
||||
|
||||
|
||||
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_or_none(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
Reference in New Issue
Block a user