feat(cred-proxy): SIGHUP reload of routes.json (PRD 0014)
Phase 1 of PRD 0014. Adds the in-sidecar SIGHUP signal handler that re-reads routes.json + re-resolves tokens from env without dropping in-flight connections: - reload_routes(server, path, environ=...) does the atomic swap. Returns (ok, message) so the caller can log/surface failures. On failure (bad JSON, missing file) the server keeps serving the old routes rather than dying — typos shouldn't crash the sidecar. - install_sighup_handler wires SIGHUP → reload_routes. No-op on platforms without SIGHUP (Windows). - serve() now installs the handler at startup. Atomicity: Python attribute reassignment is atomic, and the request handler reads server.routes/tokens once at the top of _proxy() so an in-flight request keeps the version it captured. Tests cover successful reload, JSON-parse failure, and missing-file failure (both verify the old routes survive). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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): "
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user