diff --git a/claude_bottle/cred_proxy_server.py b/claude_bottle/cred_proxy_server.py index 5fc1c8a..a866d83 100644 --- a/claude_bottle/cred_proxy_server.py +++ b/claude_bottle/cred_proxy_server.py @@ -30,6 +30,7 @@ import http.client import http.server import json import os +import signal import socketserver import sys import typing @@ -398,6 +399,56 @@ def load_tokens(routes: tuple[Route, ...], environ: typing.Mapping[str, str]) -> return out +def reload_routes( + server: "CredProxyServer", + routes_path: str, + *, + environ: typing.Mapping[str, str] | None = None, +) -> tuple[bool, str]: + """Re-read routes.json + tokens and swap them onto `server`. Used + by the SIGHUP handler (PRD 0014) so the operator can update the + routes file in-place and have cred-proxy pick up the change + without dropping in-flight connections. + + Returns (ok, message). On failure the server's existing routes + stay in place — better to keep serving the old config than to + leave the proxy with no routes after a typo. + + Atomic swap: Python attribute reassignment is atomic, and the + request handler reads `server.routes`/`server.tokens` once at + the top of `_proxy()` so an in-flight request keeps the version + it captured. New requests see the new routes.""" + env = environ if environ is not None else os.environ + try: + new_routes = load_routes(routes_path) + new_tokens = load_tokens(new_routes, env) + except (OSError, ValueError, json.JSONDecodeError) as e: + return False, f"reload failed: {e}" + server.routes = new_routes + server.tokens = new_tokens + return True, ( + f"reloaded {len(new_routes)} route(s): " + f"{', '.join(r.path for r in new_routes)}" + ) + + +def install_sighup_handler(server: "CredProxyServer", routes_path: str) -> None: + """Wire SIGHUP to reload_routes. No-op on platforms without + SIGHUP (Windows). The handler swallows exceptions so a bad + reload doesn't crash the long-lived sidecar.""" + if not hasattr(signal, "SIGHUP"): + return + + def handler(signum: int, frame: object) -> None: + del signum, frame + ok, message = reload_routes(server, routes_path) + prefix = "cred-proxy: SIGHUP " + ("ok: " if ok else "failed: ") + sys.stderr.write(prefix + message + "\n") + sys.stderr.flush() + + signal.signal(signal.SIGHUP, handler) + + def serve( *, routes_path: str = DEFAULT_ROUTES_PATH, @@ -414,6 +465,7 @@ def serve( server = CredProxyServer((bind, port), CredProxyHandler) server.routes = routes server.tokens = tokens + install_sighup_handler(server, routes_path) sys.stderr.write( f"cred-proxy listening on {bind}:{port}; " f"{len(routes)} route(s): " diff --git a/tests/unit/test_cred_proxy_server.py b/tests/unit/test_cred_proxy_server.py index bace39a..c308af9 100644 --- a/tests/unit/test_cred_proxy_server.py +++ b/tests/unit/test_cred_proxy_server.py @@ -1,15 +1,20 @@ """Unit: cred-proxy server pure functions — route parsing, route -selection, header injection (PRD 0010).""" +selection, header injection (PRD 0010); SIGHUP reload (PRD 0014).""" +import json +import tempfile import unittest +from pathlib import Path from claude_bottle.cred_proxy_server import ( + CredProxyServer, Route, build_forward_headers, filter_response_headers, is_git_push_request, load_tokens, parse_routes, + reload_routes, select_route, ) @@ -258,5 +263,77 @@ class TestLoadTokens(unittest.TestCase): self.assertEqual({"T_0": ""}, out) +class TestReloadRoutes(unittest.TestCase): + """SIGHUP reload helper (PRD 0014). + + Drives the same code path the signal handler invokes, but + without actually sending a signal — keeps the test + deterministic. The signal binding is just `signal.signal(SIGHUP, + handler)`; install_sighup_handler is exercised by the + integration test.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="cp-reload-test.") + self.routes_path = Path(self._tmp.name) / "routes.json" + self.routes_path.write_text(json.dumps({"routes": [ + {"path": "/a/", "upstream": "https://a.example", + "auth_scheme": "Bearer", "token_env": "T0"}, + ]})) + # Bind to :0 so the test doesn't need a fixed port. + self.server = CredProxyServer(("127.0.0.1", 0), _NullHandler) + self.server.routes = parse_routes(json.loads(self.routes_path.read_text())) + self.server.tokens = {"T0": "old"} + + def tearDown(self): + self.server.server_close() + self._tmp.cleanup() + + def test_reload_swaps_routes_and_tokens(self): + self.routes_path.write_text(json.dumps({"routes": [ + {"path": "/a/", "upstream": "https://a.example", + "auth_scheme": "Bearer", "token_env": "T0"}, + {"path": "/b/", "upstream": "https://b.example", + "auth_scheme": "Bearer", "token_env": "T1"}, + ]})) + ok, msg = reload_routes( + self.server, str(self.routes_path), + environ={"T0": "new0", "T1": "new1"}, + ) + self.assertTrue(ok, msg) + self.assertEqual(2, len(self.server.routes)) + self.assertEqual({"T0": "new0", "T1": "new1"}, self.server.tokens) + self.assertIn("reloaded 2 route(s)", msg) + + def test_failed_reload_keeps_old_routes(self): + original_routes = self.server.routes + original_tokens = self.server.tokens + self.routes_path.write_text("not valid json {") + ok, msg = reload_routes( + self.server, str(self.routes_path), + environ={"T0": "ignored"}, + ) + self.assertFalse(ok) + self.assertIn("reload failed", msg) + self.assertIs(original_routes, self.server.routes) + self.assertIs(original_tokens, self.server.tokens) + + def test_failed_reload_on_missing_file_keeps_old_routes(self): + original_routes = self.server.routes + self.routes_path.unlink() + ok, _ = reload_routes( + self.server, str(self.routes_path), environ={}, + ) + self.assertFalse(ok) + self.assertIs(original_routes, self.server.routes) + + +class _NullHandler: # noqa: D401 — test helper, not a real handler + """Dummy handler class; the reload tests never actually serve a + request, so the handler is never instantiated.""" + + def __init__(self, *args, **kwargs): + raise RuntimeError("should not be called in reload tests") + + if __name__ == "__main__": unittest.main()