intermediate commit
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 16s
Security Audit / security-audit (push) Failing after 11s

This commit is contained in:
2026-07-14 13:32:09 +02:00
parent dd796b4d3c
commit 05ae81d641
58 changed files with 7526 additions and 66 deletions

View 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