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

147 lines
5.6 KiB
Python

"""
AUTH.3A — tolaria_client.py
============================
Isolierter Tolaria-Client für den SAVE-Executor (SAVE-only).
Eigenschaften:
* FIXED Base URL (aus ENV C5_TOLARIA_BASE oder Default http://tolaria:5173/api/vault)
— NIE aus dem Job. Keine arbitrary URL.
* SAVE Endpoint fest: /save. Read-Back fest: /content.
* Keine arbitrary headers/method.
* Fail-closed: kein HTTP ohne SAVE-Credential (TOLARIA_SAVE_TOKEN).
* OUTCOME_UNKNOWN bei unklarem HTTP-Ergebnis (kein blinder Retry).
* Token-Wert wird NIE geloggt.
Nur SAVE-Scope. Kein DELETE. Kein Master-Token.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from typing import Any, Dict, Optional
# FIXED Tolaria Base URL (produktiv: interne Docker-DNS-Adresse)
DEFAULT_TOLARIA_BASE = "http://tolaria:5173/api/vault"
ENV_TOLARIA_BASE = "C5_TOLARIA_BASE"
ENV_TOLARIA_SAVE_TOKEN = "TOLARIA_SAVE_TOKEN"
# Feste Endpoints (keine arbitrary URL/method)
ENDPOINT_SAVE = "save"
ENDPOINT_CONTENT = "content"
# Eindeutige, enge Not-Found-Semantik der Tolaria-Vault-API
TOLARIA_NOT_FOUND_MSG = "Invalid or missing path"
class TolariaClientError(Exception):
pass
class TolariaUnavailableError(TolariaClientError):
pass
class TolariaWriteError(TolariaClientError):
def __init__(self, message: str, code: str, http_code: Optional[int] = None):
super().__init__(message)
self.code = code
self.http_code = http_code
class TolariaClient:
"""
Isolierter Tolaria-Client (SAVE-only).
read() — POST /content (Read-Back)
write() — POST /save (NUR SAVE; fail-closed ohne Credential)
"""
def __init__(self, base_url: Optional[str] = None, timeout: float = 15.0,
save_token: Optional[str] = None):
# FIXED Base URL: aus ENV oder Default. NIE aus dem Job.
self.base_url = (base_url or os.environ.get(ENV_TOLARIA_BASE)
or DEFAULT_TOLARIA_BASE).rstrip("/")
self.timeout = timeout
# SAVE-Credential explizit injiziert (kein verstecktes globales).
# Fehlend/leer -> fail-closed beim mutierenden Aufruf.
self.save_token = save_token
# -- HTTP-Helfer --------------------------------------------------------
def _post(self, endpoint: str, payload: Dict[str, Any],
auth_token: Optional[str] = None) -> Dict[str, Any]:
url = f"{self.base_url}/{endpoint.lstrip('/')}"
data = json.dumps(payload).encode("utf-8")
headers: Dict[str, str] = {"Content-Type": "application/json"}
# Authorization-Header NUR wenn ein Token explizit übergeben wird
# (mutierender SAVE). READ sendet KEIN Credential (Least Privilege).
if auth_token is not None:
headers["Authorization"] = f"Bearer {auth_token}"
req = urllib.request.Request(url, data=data, headers=headers,
method="POST")
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
if e.code >= 500:
raise TolariaUnavailableError(
f"Tolaria HTTP {e.code} auf {endpoint}",
"TOLARIA_UNAVAILABLE")
raise TolariaWriteError(
f"Tolaria HTTP {e.code} auf {endpoint}: "
f"{e.read().decode('utf-8', 'replace')[:200]}",
"AUTH_FAILURE" if e.code in (401, 403) else "INVALID_SCHEMA",
http_code=e.code)
except (urllib.error.URLError, TimeoutError, OSError) as e:
raise TolariaUnavailableError(
f"Tolaria nicht erreichbar ({endpoint}): {e}",
"TOLARIA_UNAVAILABLE")
# -- Read ---------------------------------------------------------------
def read(self, vault_path: str) -> Optional[str]:
"""Read-Back eines Vault-Objekts. Gibt Inhalt oder None (nicht vorhanden)."""
try:
resp = self._post(ENDPOINT_CONTENT, {"path": vault_path})
except TolariaWriteError as e:
# Enger Not-Found-Fall: HTTP 400 + eindeutige 'Invalid or missing
# path'-Semantik -> Objekt existiert nicht -> None (kein Fehler).
if e.http_code == 400 and TOLARIA_NOT_FOUND_MSG in (e.args[0] or ""):
return None
raise
if "error" in resp:
return None
return resp.get("content")
# -- Write (NUR SAVE) ---------------------------------------------------
def _require_token(self) -> str:
"""Fail-closed: fehlendes/leeres SAVE-Credential -> kein HTTP."""
token = self.save_token
if not token or not isinstance(token, str) or not token.strip():
raise TolariaWriteError(
"Tolaria SAVE-Credential fehlt oder ist leer "
"(fail-closed, kein Request gesendet)",
"CREDENTIAL_MISSING")
return token
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
"""Schreibt ein Vault-Objekt (POST /save). SAVE-Scope.
Fail-closed: fehlendes/leeres SAVE-Credential -> lokaler Abbruch,
HTTP wird NICHT aufgerufen.
"""
token = self._require_token()
resp = self._post(ENDPOINT_SAVE, {"path": vault_path, "content": content},
auth_token=token)
if resp is None:
resp = {}
if "error" in resp:
raise TolariaWriteError(
f"Tolaria save fehlgeschlagen: {resp['error']}",
"INVALID_SCHEMA")
return resp