diff --git a/tests/integration/test_cred_proxy_sighup.py b/tests/integration/test_cred_proxy_sighup.py new file mode 100644 index 0000000..d151bc9 --- /dev/null +++ b/tests/integration/test_cred_proxy_sighup.py @@ -0,0 +1,223 @@ +"""Integration: SIGHUP reload + host-side apply_routes_change +(PRD 0014). + +Brings up a real cred-proxy sidecar with one route, then uses +apply_routes_change (docker cp + SIGHUP) to swap to a different +route. Verifies cred-proxy actually serves the new routes after the +reload (and 404s the old ones). + +Avoids a real upstream by routing to unreachable hostnames — the +proxy's 502 "upstream connection failed" is a sufficient signal that +the route matched. 404 means no route matched. + +apply_routes_change uses docker exec / cp / kill (not bind mounts), +so this test should work in docker-in-docker environments too — no +skip decorator beyond skip_unless_docker. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +import time +import unittest +from pathlib import Path + +from claude_bottle.backend.docker.cred_proxy import ( + CRED_PROXY_PORT, + DockerCredProxy, + build_cred_proxy_image, + cred_proxy_container_name, +) +from claude_bottle.backend.docker.cred_proxy_apply import ( + CredProxyApplyError, + apply_routes_change, + fetch_current_routes, +) +from claude_bottle.backend.docker.network import ( + network_create_egress, + network_create_internal, + network_remove, +) +from claude_bottle.cred_proxy import ( + CRED_PROXY_HOSTNAME, + CredProxyPlan, + CredProxyRoute, +) +from tests._docker import skip_unless_docker + + +CURL_IMAGE = "curlimages/curl:latest" + + +@skip_unless_docker() +class TestCredProxySighupReload(unittest.TestCase): + @classmethod + def setUpClass(cls): + r = subprocess.run( + ["docker", "pull", CURL_IMAGE], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, + ) + if r.returncode != 0: + raise unittest.SkipTest(f"could not pull {CURL_IMAGE}") + build_cred_proxy_image() + + def setUp(self): + self.slug = f"cb-test-sighup-{os.getpid()}-{int(time.time())}" + self.proxy_name = "" + self.internal_net = "" + self.egress_net = "" + self.work_dir = Path(tempfile.mkdtemp(prefix="cred-proxy-sighup.")) + # Token value for both initial and post-SIGHUP routes — they + # share the same TokenRef so they share CRED_PROXY_TOKEN_0 in + # the container's environ. + os.environ["CB_SIGHUP_TEST_TOKEN"] = "test-token" + + def tearDown(self): + os.environ.pop("CB_SIGHUP_TEST_TOKEN", None) + if self.proxy_name: + subprocess.run( + ["docker", "rm", "-f", self.proxy_name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, + ) + for n in (self.internal_net, self.egress_net): + if n: + network_remove(n) + shutil.rmtree(self.work_dir, ignore_errors=True) + + def _bring_up_with_route(self, path: str, upstream: str) -> None: + self.internal_net = network_create_internal(self.slug) + self.egress_net = network_create_egress(self.slug) + route = CredProxyRoute( + path=path, + upstream=upstream, + auth_scheme="Bearer", + token_env="CRED_PROXY_TOKEN_0", + token_ref="CB_SIGHUP_TEST_TOKEN", + ) + routes_path = self.work_dir / "routes.json" + from claude_bottle.cred_proxy import cred_proxy_render_routes + routes_path.write_text(cred_proxy_render_routes((route,))) + routes_path.chmod(0o600) + plan = CredProxyPlan( + slug=self.slug, + routes_path=routes_path, + routes=(route,), + token_env_map={"CRED_PROXY_TOKEN_0": "CB_SIGHUP_TEST_TOKEN"}, + internal_network=self.internal_net, + egress_network=self.egress_net, + # No pipelock for this test — the proxy talks directly to + # the egress network. Upstreams are unreachable so the + # 502s confirm the route table. + ) + self.proxy_name = DockerCredProxy().start(plan) + # Wait until the proxy is serving (it's the only way I have + # to know python has bound to the port). + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline: + code = self._curl("/__probe/") + if code in (404, 502): # serving — either response proves it's up + return + time.sleep(0.2) + raise AssertionError("cred-proxy never came up") + + def _curl(self, path: str) -> int | None: + """Return the HTTP status from a curl-in-container request to + the cred-proxy, or None on connection failure.""" + r = subprocess.run( + [ + "docker", "run", "--rm", + "--network", self.internal_net, + CURL_IMAGE, + "-sS", "-o", "/dev/null", + "-w", "%{http_code}", + "--max-time", "8", + f"http://{CRED_PROXY_HOSTNAME}:{CRED_PROXY_PORT}{path}", + ], + capture_output=True, text=True, check=False, + ) + if r.returncode != 0: + return None + try: + return int(r.stdout.strip()) + except ValueError: + return None + + def test_sighup_swaps_routes(self): + """Initial route /a/ matches (502 from unreachable upstream); + /b/ 404s. After apply_routes_change with /b/ only, the table + flips: /a/ 404s, /b/ matches.""" + self._bring_up_with_route("/a/", "https://unreachable-a.example") + + self.assertEqual(502, self._curl("/a/foo")) + self.assertEqual(404, self._curl("/b/foo")) + + new_routes = json.dumps({"routes": [{ + "path": "/b/", + "upstream": "https://unreachable-b.example", + "auth_scheme": "Bearer", + "token_env": "CRED_PROXY_TOKEN_0", + }]}) + "\n" + + before, after = apply_routes_change(self.slug, new_routes) + self.assertIn("/a/", before) + self.assertEqual(new_routes, after) + + # SIGHUP propagates as a Python signal — runs at the next + # bytecode boundary on the main thread. Give it a moment. + deadline = time.monotonic() + 5.0 + flipped = False + while time.monotonic() < deadline: + if self._curl("/a/foo") == 404 and self._curl("/b/foo") == 502: + flipped = True + break + time.sleep(0.2) + self.assertTrue(flipped, "SIGHUP reload did not propagate to the route table") + + def test_in_flight_connections_survive_sighup(self): + """SIGHUP must reload without dropping the bound socket. The + signal handler runs on the main thread; in-flight worker + threads keep the routes they captured at request start. + Verified by issuing a request right after SIGHUP and seeing + the new route in effect (the listener never restarted).""" + self._bring_up_with_route("/a/", "https://unreachable.example") + # Fetching the current routes also proves the proxy is up. + current = fetch_current_routes(self.slug) + self.assertIn("/a/", current) + + new_routes = json.dumps({"routes": [{ + "path": "/c/", + "upstream": "https://unreachable-c.example", + "auth_scheme": "Bearer", + "token_env": "CRED_PROXY_TOKEN_0", + }]}) + "\n" + apply_routes_change(self.slug, new_routes) + + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if self._curl("/c/foo") == 502: + return + time.sleep(0.2) + self.fail("new route not picked up after SIGHUP") + + def test_apply_with_invalid_json_raises(self): + self._bring_up_with_route("/a/", "https://unreachable.example") + with self.assertRaises(CredProxyApplyError) as cm: + apply_routes_change(self.slug, "{not json") + self.assertIn("not valid JSON", str(cm.exception)) + + def test_apply_against_missing_sidecar_raises(self): + # Don't bring up the sidecar; the slug points at nothing. + with self.assertRaises(CredProxyApplyError): + apply_routes_change( + self.slug, + '{"routes": [{"path": "/x/", "upstream": "https://example.com",' + ' "auth_scheme": "Bearer", "token_env": "CRED_PROXY_TOKEN_0"}]}', + ) + + +if __name__ == "__main__": + unittest.main()