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)
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue