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)
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
DRIFT_WRITE_ALLOWED = "WRITE_ALLOWED"
DRIFT_ALREADY_AT_TARGET = "ALREADY_AT_TARGET"
@ -154,7 +160,17 @@ class TolariaUnavailableError(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):
@ -214,7 +230,8 @@ class TolariaClient:
f"Tolaria HTTP {e.code} auf {endpoint}", RC_TOLARIA_UNAVAILABLE)
raise TolariaWriteError(
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:
raise TolariaUnavailableError(
f"Tolaria nicht erreichbar ({endpoint}): {e}", RC_TOLARIA_UNAVAILABLE)
@ -223,7 +240,15 @@ class TolariaClient:
def read(self, vault_path: str) -> Optional[str]:
"""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:
return None # Invalid or missing path -> Objekt nicht vorhanden
return resp.get("content")

View file

@ -34,6 +34,7 @@ import subprocess
import sys
import tempfile
import unittest
from unittest import mock
from typing import Any, Dict, List, Optional
# C5C importieren (aus demselben Verzeichnis)
@ -57,7 +58,8 @@ from rq_c5c import (
pre_write_drift_check, _vault_path,
DRIFT_WRITE_ALLOWED, DRIFT_ALREADY_AT_TARGET, DRIFT_UNEXPECTED,
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,
)
@ -900,5 +902,87 @@ class TestDryRun(unittest.TestCase):
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__":
unittest.main(verbosity=2)