fix(tolaria): handle missing vault object in C5C read path

This commit is contained in:
Red Queen 2026-08-26 10:03:40 +00:00
parent 63f957f4fb
commit 238536710a
2 changed files with 113 additions and 4 deletions

View file

@ -93,6 +93,12 @@ ENV_TOLARIA_BASE = "C5_TOLARIA_BASE"
# README statt des Vault-Objekts -> false drift) # README statt des Vault-Objekts -> false drift)
VAULT_PREFIX = "/app/vault" VAULT_PREFIX = "/app/vault"
# Eindeutige, enge Not-Found-Semantik der Tolaria-Vault-API:
# Ein nicht existierendes Objekt wird als HTTP 400 mit dieser Fehlermeldung
# zurueckgemeldet. NUR dieser exakte Fall darf von read() als None (Objekt
# nicht vorhanden) interpretiert werden — kein pauschales 400-Schlucken.
TOLARIA_NOT_FOUND_MSG = "Invalid or missing path"
# Pre-Write-Drift-Ergebnisse # Pre-Write-Drift-Ergebnisse
DRIFT_WRITE_ALLOWED = "WRITE_ALLOWED" DRIFT_WRITE_ALLOWED = "WRITE_ALLOWED"
DRIFT_ALREADY_AT_TARGET = "ALREADY_AT_TARGET" DRIFT_ALREADY_AT_TARGET = "ALREADY_AT_TARGET"
@ -154,7 +160,17 @@ class TolariaUnavailableError(C5CError):
class TolariaWriteError(C5CError): class TolariaWriteError(C5CError):
"""Tolaria-Write fehlgeschlagen (nicht retrybar, z.B. Auth/Schema).""" """Tolaria-Write fehlgeschlagen (nicht retrybar, z.B. Auth/Schema).
Tragt optional den HTTP-Statuscode der Antwort (http_code), damit
Caller (z.B. read()) den engen 'Objekt existiert nicht'-Fall gezielt
von anderen 400-/Schema-/Auth-Fehlern unterscheiden koennen.
"""
def __init__(self, message: str, reason_code: Optional[str] = None,
http_code: Optional[int] = None):
super().__init__(message, reason_code)
self.http_code = http_code
class UnexpectedDriftError(C5CError): class UnexpectedDriftError(C5CError):
@ -214,7 +230,8 @@ class TolariaClient:
f"Tolaria HTTP {e.code} auf {endpoint}", RC_TOLARIA_UNAVAILABLE) f"Tolaria HTTP {e.code} auf {endpoint}", RC_TOLARIA_UNAVAILABLE)
raise TolariaWriteError( raise TolariaWriteError(
f"Tolaria HTTP {e.code} auf {endpoint}: {e.read().decode('utf-8', 'replace')[:200]}", f"Tolaria HTTP {e.code} auf {endpoint}: {e.read().decode('utf-8', 'replace')[:200]}",
RC_AUTH_FAILURE if e.code in (401, 403) else RC_INVALID_SCHEMA) RC_AUTH_FAILURE if e.code in (401, 403) else RC_INVALID_SCHEMA,
http_code=e.code)
except (urllib.error.URLError, TimeoutError, OSError) as e: except (urllib.error.URLError, TimeoutError, OSError) as e:
raise TolariaUnavailableError( raise TolariaUnavailableError(
f"Tolaria nicht erreichbar ({endpoint}): {e}", RC_TOLARIA_UNAVAILABLE) f"Tolaria nicht erreichbar ({endpoint}): {e}", RC_TOLARIA_UNAVAILABLE)
@ -223,7 +240,15 @@ class TolariaClient:
def read(self, vault_path: str) -> Optional[str]: def read(self, vault_path: str) -> Optional[str]:
"""Read-Back eines Vault-Objekts. Gibt Inhalt oder None (nicht vorhanden).""" """Read-Back eines Vault-Objekts. Gibt Inhalt oder None (nicht vorhanden)."""
resp = self._post("content", {"path": vault_path}) try:
resp = self._post("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).
# Alle anderen Fehler (andere 400, Auth 401/403, ...) bleiben fail-closed.
if e.http_code == 400 and TOLARIA_NOT_FOUND_MSG in (e.message or ""):
return None
raise
if "error" in resp: if "error" in resp:
return None # Invalid or missing path -> Objekt nicht vorhanden return None # Invalid or missing path -> Objekt nicht vorhanden
return resp.get("content") return resp.get("content")

View file

@ -34,6 +34,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from unittest import mock
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
# C5C importieren (aus demselben Verzeichnis) # C5C importieren (aus demselben Verzeichnis)
@ -57,7 +58,8 @@ from rq_c5c import (
pre_write_drift_check, _vault_path, pre_write_drift_check, _vault_path,
DRIFT_WRITE_ALLOWED, DRIFT_ALREADY_AT_TARGET, DRIFT_UNEXPECTED, DRIFT_WRITE_ALLOWED, DRIFT_ALREADY_AT_TARGET, DRIFT_UNEXPECTED,
PLAN_WOULD_WRITE, PLAN_ALREADY_AT_TARGET, PLAN_HUMAN_REVIEW, PLAN_DRIFT, PLAN_WOULD_WRITE, PLAN_ALREADY_AT_TARGET, PLAN_HUMAN_REVIEW, PLAN_DRIFT,
TolariaUnavailableError, ReadBackMismatchError, UnexpectedDriftError, TolariaUnavailableError, TolariaWriteError, ReadBackMismatchError,
UnexpectedDriftError,
assert_no_search_calls, assert_no_master_write, assert_no_search_calls, assert_no_master_write,
) )
@ -900,5 +902,87 @@ class TestDryRun(unittest.TestCase):
self.assertEqual(self.fake.write_count, 0) self.assertEqual(self.fake.write_count, 0)
# ---------------------------------------------------------------------------
# C5C HOTFIX: Not-Found-Read-Semantik (regression)
# Testet die ECHTE TolariaClient.read() gegen nachgestelltes HTTP-Verhalten.
# Kein Fake-Client, kein Netzwerk: _post wird gemockt.
# ---------------------------------------------------------------------------
class TestTolariaReadNotFoundSemantic(unittest.TestCase):
"""C5C-Hotfix: fehlendes Objekt -> None; alle anderen Fehler fail-closed."""
def setUp(self):
self.client = TolariaClient(base_url="http://fake")
def _post(self, status: int, body: str, reason_code=None):
"""Liefert einen Callable, der ein TolariaWriteError/JSON wirft/liefert."""
def fake_post(endpoint, payload):
if status == 200:
return json.loads(body)
raise TolariaWriteError(
f"Tolaria HTTP {status} auf {endpoint}: {body}",
reason_code or "INVALID_SCHEMA",
http_code=status,
)
return fake_post
# A) EXISTING OBJECT -> HTTP 200 -> content zurück
def test_a_existing_returns_content(self):
content = "hello vault"
with mock.patch.object(self.client, "_post",
side_effect=self._post(200, json.dumps({"content": content}))):
self.assertEqual(self.client.read("/app/vault/x.md"), content)
# B) MISSING OBJECT -> HTTP 400 + "Invalid or missing path" -> None
def test_b_missing_object_returns_none(self):
with mock.patch.object(self.client, "_post",
side_effect=self._post(400, json.dumps({"error": "Invalid or missing path"}))):
self.assertIsNone(self.client.read("/app/vault/c5f-controlled-canary.md"))
# C) OTHER HTTP 400 -> weiterhin fail-closed (Exception)
def test_c_other_400_fails_closed(self):
with mock.patch.object(self.client, "_post",
side_effect=self._post(400, json.dumps({"error": "Malformed request"}))):
with self.assertRaises(TolariaWriteError):
self.client.read("/app/vault/x.md")
# D) HTTP 401/403 -> weiterhin Auth-Fehler
def test_d_auth_fails_closed(self):
for status in (401, 403):
with mock.patch.object(self.client, "_post",
side_effect=self._post(status, json.dumps({"error": "unauthorized"}))):
with self.assertRaises(TolariaWriteError):
self.client.read("/app/vault/x.md")
# E) HTTP 500 -> weiterhin technischer Fehler (retrybar, Unavailable)
def test_e_500_is_unavailable(self):
def fake_500(endpoint, payload):
raise TolariaUnavailableError("Tolaria HTTP 500", "TOLARIA_UNAVAILABLE")
with mock.patch.object(self.client, "_post", side_effect=fake_500):
with self.assertRaises(TolariaUnavailableError):
self.client.read("/app/vault/x.md")
# F) CREATE pre_write_drift_check: before=None current=None -> WRITE_ALLOWED
def test_f_create_no_target_no_current_write_allowed(self):
# read() liefert None (missing) -> drift check => WRITE_ALLOWED
with mock.patch.object(self.client, "_post",
side_effect=self._post(400, json.dumps({"error": "Invalid or missing path"}))):
drift, cur = pre_write_drift_check(
self.client, "/app/vault/c5f-controlled-canary.md", None,
_fm(UUID_A, state="current") + "body")
self.assertEqual(drift, DRIFT_WRITE_ALLOWED)
self.assertIsNone(cur)
# G) existierendes unerwartetes Target -> weiterhin Drift/Human Gate
def test_g_unexpected_target_drift(self):
target = _fm(UUID_A, state="current") + "body v2"
# Tolaria CURRENT weder None (missing) noch BEFORE -> UNEXPECTED_DRIFT
with mock.patch.object(self.client, "_post",
side_effect=self._post(200, json.dumps({"content": _fm(UUID_A, state="current") + "DRIFT"}))):
drift, cur = pre_write_drift_check(
self.client, "/app/vault/x.md", _fm(UUID_A, state="current") + "body v1", target)
self.assertEqual(drift, DRIFT_UNEXPECTED)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)