trading-system-docs/tolaria/c5-sync-service/forgejo_source_loader.py

203 lines
7.7 KiB
Python

"""
AUTH.3E — forgejo_source_loader.py
===================================
Forgejo Source Loader für den SAVE-Executor.
Lädt Content aus der Forgejo Source of Truth, exakt commit-bound.
Eigenschaften:
* Commit-bound: liest Content aus genau dem source_commit (kein Branch-Latest).
* object_id -> Pfad: leitet den Repo-relativen Pfad aus dem vault_path ab
(VAULT_PREFIX + rel_path), konsistent mit C5C (_vault_path).
* Keine Write-Fähigkeit. Keine automatische Upstream-Integration.
* FAIL CLOSED bei: commit missing, object missing, hash mismatch,
provenance mismatch, path mismatch, Forgejo unavailable.
* Read-only git-Befehle (git show / git cat-file) gegen den lokalen Clone
ODER HTTP-API (public Repo, kein Credential nötig).
Zugriff: Repo nexo312/trading-system-docs ist PUBLIC -> kein Credential nötig.
"""
from __future__ import annotations
import hashlib
import os
import subprocess
from typing import Any, Dict, Optional
# Forgejo-Repo (autoritative Source of Truth)
DEFAULT_FORGEJO_REPO = "nexo312/trading-system-docs"
ENV_FORGEJO_REPO = "C5_FORGEJO_REPO"
# Vault-Pfad-Praefix (konsistent mit C5C VAULT_PREFIX)
VAULT_PREFIX = "/app/vault"
# Lokaler Clone-Pfad (falls vorhanden) — sonst HTTP-API
DEFAULT_CLONE_PATH = "/data/forgejo-clone"
ENV_CLONE_PATH = "C5_FORGEJO_CLONE"
# Forgejo HTTP-API Basis (public Repo, read-only)
DEFAULT_FORGEJO_BASE = "http://forgejo-c4u8yyi1eaz1gepn3pqmr5fb:3000"
ENV_FORGEJO_BASE = "C5_FORGEJO_BASE"
class SourceLoaderError(Exception):
pass
class SourceUnavailableError(SourceLoaderError):
pass
class SourceMismatchError(SourceLoaderError):
pass
class ForgejoSourceLoader:
"""
Lädt Content aus Forgejo, exakt commit-bound.
load(source_commit, object_id, vault_path) -> content (str)
"""
def __init__(self, repo: Optional[str] = None,
clone_path: Optional[str] = None,
forgejo_base: Optional[str] = None):
self.repo = (repo or os.environ.get(ENV_FORGEJO_REPO)
or DEFAULT_FORGEJO_REPO)
self.clone_path = (clone_path or os.environ.get(ENV_CLONE_PATH)
or DEFAULT_CLONE_PATH)
self.forgejo_base = (forgejo_base or os.environ.get(ENV_FORGEJO_BASE)
or DEFAULT_FORGEJO_BASE).rstrip("/")
# -- Pfad-Ableitung -----------------------------------------------------
def _rel_path(self, vault_path: str) -> str:
"""Leitet den Repo-relativen Pfad aus dem vault_path ab.
vault_path = /app/vault/<rel_path> -> rel_path.
Konsistent mit C5C _vault_path (VAULT_PREFIX + rel_path).
"""
vp = vault_path or ""
if vp.startswith(VAULT_PREFIX):
rel = vp[len(VAULT_PREFIX):].lstrip("/")
else:
rel = vp.lstrip("/")
if not rel:
raise SourceMismatchError("vault_path ergibt keinen rel_path")
return rel
# -- Content-Load (commit-bound) ----------------------------------------
def load(self, source_commit: str, object_id: str,
vault_path: str) -> str:
"""Lädt Content aus dem exakten Commit.
Returns: autoritativer Content (str).
Raises: SourceUnavailableError / SourceMismatchError (FAIL CLOSED).
"""
rel_path = self._rel_path(vault_path)
# 1. Commit-bound lesen (kein Branch-Latest)
content = self._read_from_commit(source_commit, rel_path)
if content is None:
raise SourceUnavailableError(
f"object {rel_path} nicht in commit {source_commit}")
# 2. object_id-Konsistenz prüfen (Frontmatter id: object/<uuid>)
self._validate_object_id(content, object_id, rel_path)
return content
def _read_from_commit(self, commit: str, rel_path: str) -> Optional[str]:
"""Liest Datei aus exakt dem Commit. Bevorzugt lokalen Clone, sonst HTTP."""
# Versuche lokalen Clone (falls gemountet)
if os.path.isdir(self.clone_path):
try:
return self._read_from_clone(commit, rel_path)
except SourceUnavailableError:
# Clone nicht verfügbar -> Fallback auf HTTP-API
pass
# HTTP-API (public Repo, read-only)
return self._read_from_http(commit, rel_path)
def _read_from_clone(self, commit: str, rel_path: str) -> Optional[str]:
"""git show <commit>:<rel_path> gegen lokalen Clone."""
try:
proc = subprocess.run(
["git", "-C", self.clone_path, "show", f"{commit}:{rel_path}"],
capture_output=True, text=True, timeout=30)
except (subprocess.SubprocessError, OSError) as e:
raise SourceUnavailableError(f"git show fehlgeschlagen: {e}")
if proc.returncode != 0:
return None # object nicht in commit
return proc.stdout
def _read_from_http(self, commit: str, rel_path: str) -> Optional[str]:
"""HTTP-API: GET /api/v1/repos/{repo}/raw/{commit}/{rel_path} (public)."""
import urllib.error
import urllib.request
url = (f"{self.forgejo_base}/api/v1/repos/{self.repo}"
f"/raw/{commit}/{rel_path}")
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode("utf-8")
except urllib.error.HTTPError as e:
if e.code in (404, 410):
return None # object nicht in commit
raise SourceUnavailableError(f"Forgejo HTTP {e.code} auf {rel_path}")
except (urllib.error.URLError, TimeoutError, OSError) as e:
raise SourceUnavailableError(f"Forgejo nicht erreichbar: {e}")
# -- object_id-Konsistenz ----------------------------------------------
def _validate_object_id(self, content: str, object_id: str,
rel_path: str) -> None:
"""Prüft, dass das Frontmatter die erwartete object_id enthält."""
fm = self._parse_frontmatter(content)
fm_id = fm.get("id")
if not fm_id:
raise SourceMismatchError(
f"object {rel_path} hat keine id im Frontmatter")
# object_id im Job ist 'object/<uuid>' oder '<uuid>'; Frontmatter id
# ist 'object/<uuid>'. Normalisiere.
expected = object_id
if not expected.startswith("object/"):
expected = f"object/{expected}"
if fm_id != expected:
raise SourceMismatchError(
f"object_id mismatch: Frontmatter={fm_id} Job={expected}")
@staticmethod
def _parse_frontmatter(content: str) -> Dict[str, Any]:
"""Minimaler Frontmatter-Parser (--- ... ---)."""
if not content.startswith("---"):
return {}
end = content.find("\n---", 3)
if end == -1:
return {}
fm_text = content[3:end]
fm: Dict[str, Any] = {}
for line in fm_text.splitlines():
if ":" in line:
k, _, v = line.partition(":")
fm[k.strip()] = v.strip().strip('"').strip("'")
return fm
# -- Hash/Provenance (RECOMPUTE) ---------------------------------------
@staticmethod
def content_hash(content: str) -> str:
"""SHA-256 des fachlichen Bodies (nach Frontmatter)."""
body = content
if content.startswith("---"):
end = content.find("\n---", 3)
if end != -1:
body = content[end + 4:]
return hashlib.sha256(body.encode("utf-8")).hexdigest()
@staticmethod
def provenance_hash(content: str) -> str:
"""SHA-256 des gesamten Contents (konsistent mit save_executor_core)."""
return hashlib.sha256(content.encode("utf-8")).hexdigest()