fix(tolaria): handle missing vault object in C5C read path
This commit is contained in:
parent
63f957f4fb
commit
238536710a
2 changed files with 113 additions and 4 deletions
|
|
@ -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)."""
|
||||||
|
try:
|
||||||
resp = self._post("content", {"path": vault_path})
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue