From 61e334c1b80c9ad9c65677108010aec1bafae30b Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 13 May 2026 16:07:52 -0400 Subject: [PATCH] feat(cred_proxy): add DockerCredProxy concrete lifecycle (PRD 0010) Mirrors DockerGitGate: build the image, docker create on the internal network with --network-alias cred-proxy, docker cp the routes.json into /run/cred-proxy/, attach the egress network, docker start. stop() is idempotent. Token values flow host env -> subprocess env -> sidecar env via docker create -e NAME (no =VALUE on argv). The resolver fails early with a clear pointer at the missing host env var name if any TokenRef is unset. Helpers (cred_proxy_container_name, cred_proxy_url) are agent-side stable: the URL uses the network alias, not the slugged container name, so the provisioner can write a fixed http://cred-proxy:9099/ URL regardless of which bottle is running. --- claude_bottle/backend/docker/cred_proxy.py | 209 +++++++++++++++++++++ tests/unit/test_docker_cred_proxy.py | 82 ++++++++ 2 files changed, 291 insertions(+) create mode 100644 claude_bottle/backend/docker/cred_proxy.py create mode 100644 tests/unit/test_docker_cred_proxy.py diff --git a/claude_bottle/backend/docker/cred_proxy.py b/claude_bottle/backend/docker/cred_proxy.py new file mode 100644 index 0000000..4e28bab --- /dev/null +++ b/claude_bottle/backend/docker/cred_proxy.py @@ -0,0 +1,209 @@ +"""DockerCredProxy — the Docker-specific lifecycle for the per-bottle +cred-proxy sidecar (PRD 0010). Inherits the platform-agnostic prepare +step (upstream lift + routes.json render + token-env-map derivation) +from `CredProxy`.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from ...cred_proxy import ( + CredProxy, + CredProxyPlan, + cred_proxy_resolve_token_values, +) +from ...log import die, info, warn +from . import util as docker_mod + + +CRED_PROXY_IMAGE = os.environ.get( + "CLAUDE_BOTTLE_CRED_PROXY_IMAGE", + "claude-bottle-cred-proxy:latest", +) + +CRED_PROXY_DOCKERFILE = "Dockerfile.cred-proxy" + +# Listening port inside the sidecar. The agent dials cred-proxy on +# this port; surfaced as a constant so the provisioner and tests can +# both reference it. +CRED_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_CRED_PROXY_PORT", "9099")) + +# DNS name agents use to reach the sidecar. Attached as a +# --network-alias on the internal docker network so the URL the +# provisioner writes into the agent's environ is stable across +# bottles (the container name carries the per-bottle slug; the alias +# does not). +CRED_PROXY_HOSTNAME = "cred-proxy" + +# In-container path the proxy server reads its route table from. +# Pre-created in Dockerfile.cred-proxy so `docker cp` can drop the +# file directly. +CRED_PROXY_ROUTES_IN_CONTAINER = "/run/cred-proxy/routes.json" + +# Repo root, for `docker build` context. Resolved from this file's +# location: claude_bottle/backend/docker/cred_proxy.py → repo root. +_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) + + +def cred_proxy_container_name(slug: str) -> str: + return f"claude-bottle-cred-proxy-{slug}" + + +def cred_proxy_url() -> str: + """Base URL the agent dials. Stable across bottles because the + sidecar attaches `--network-alias cred-proxy` on the internal + network; the container name (which carries the slug) is not + referenced by agent-side config.""" + return f"http://{CRED_PROXY_HOSTNAME}:{CRED_PROXY_PORT}" + + +def build_cred_proxy_image() -> None: + """Build the cred-proxy image from `Dockerfile.cred-proxy`. + Called by `DockerCredProxy.start`; exposed at module level so + integration tests can build it without running the full launch + pipeline.""" + docker_mod.build_image(CRED_PROXY_IMAGE, _REPO_DIR, dockerfile=CRED_PROXY_DOCKERFILE) + + +class DockerCredProxy(CredProxy): + """Brings the cred-proxy sidecar up and down via Docker.""" + + def start(self, plan: CredProxyPlan) -> str: + """Boot the cred-proxy sidecar: + 1. Resolve every host TokenRef env var into a concrete + value. Fails early if any are unset. + 2. Build the cred-proxy image (no-op when cache is hot). + 3. `docker create` on the internal network with + `--network-alias cred-proxy` and one `-e CRED_PROXY_TOKEN_N` + flag per route. The values arrive via subprocess env, so + they never land on argv. + 4. `docker cp` the routes.json into the container. + 5. Attach to the per-agent egress network so the proxy can + reach the real upstream over HTTPS. + 6. `docker start`. + Returns the container name (the target passed to `.stop`).""" + if not plan.upstreams: + die("DockerCredProxy.start called with no upstreams; caller should skip") + if not plan.internal_network or not plan.egress_network: + die( + "DockerCredProxy.start: internal_network / egress_network must be " + "populated on the plan before start" + ) + if not plan.routes_path.is_file(): + die( + f"cred-proxy routes file missing at {plan.routes_path}; " + f"CredProxy.prepare must run first" + ) + + # Resolve host env vars into concrete values. This must + # happen at start time (not prepare) — the values flow into + # the sidecar's environ via subprocess env. The plan never + # holds them. + token_values = cred_proxy_resolve_token_values(plan.token_env_map, dict(os.environ)) + + build_cred_proxy_image() + + name = cred_proxy_container_name(plan.slug) + info(f"starting cred-proxy sidecar {name} on network {plan.internal_network}") + + create_args = [ + "docker", "create", + "--name", name, + "--network", plan.internal_network, + "--network-alias", CRED_PROXY_HOSTNAME, + ] + # One -e flag per token slot; values arrive via subprocess env. + # docker create with `-e NAME` (no =VALUE) reads NAME from the + # current process env at create time. We pass `env=child_env` + # to subprocess.run so the value comes from token_values, not + # the host's os.environ directly — keeps the resolver in one + # place and lets cred_proxy_resolve_token_values surface + # missing-env errors with a clear hint. + for token_env in sorted(plan.token_env_map.keys()): + create_args.extend(["-e", token_env]) + create_args.append(CRED_PROXY_IMAGE) + + child_env: dict[str, str] = {**os.environ, **token_values} + + if subprocess.run( + create_args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=child_env, + check=False, + ).returncode != 0: + die(f"failed to create cred-proxy sidecar {name}") + + cp_result = subprocess.run( + ["docker", "cp", str(plan.routes_path), + f"{name}:{CRED_PROXY_ROUTES_IN_CONTAINER}"], + capture_output=True, + text=True, + check=False, + ) + if cp_result.returncode != 0: + subprocess.run( + ["docker", "rm", "-f", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + die( + f"failed to copy routes.json into {name}: " + f"{cp_result.stderr.strip()}" + ) + + if subprocess.run( + ["docker", "network", "connect", plan.egress_network, name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ).returncode != 0: + subprocess.run( + ["docker", "rm", "-f", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + die( + f"failed to attach cred-proxy sidecar {name} to egress network " + f"{plan.egress_network}" + ) + + if subprocess.run( + ["docker", "start", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ).returncode != 0: + subprocess.run( + ["docker", "rm", "-f", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + die(f"failed to start cred-proxy sidecar {name}") + + return name + + def stop(self, target: str) -> None: + """Idempotent: missing container is success. `target` is the + container name returned by `.start`.""" + if subprocess.run( + ["docker", "inspect", target], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ).returncode == 0: + if subprocess.run( + ["docker", "rm", "-f", target], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ).returncode != 0: + warn( + f"failed to remove cred-proxy sidecar {target}; " + f"clean up with 'docker rm -f {target}'" + ) diff --git a/tests/unit/test_docker_cred_proxy.py b/tests/unit/test_docker_cred_proxy.py new file mode 100644 index 0000000..f292996 --- /dev/null +++ b/tests/unit/test_docker_cred_proxy.py @@ -0,0 +1,82 @@ +"""Unit: DockerCredProxy helpers + early-exit guards (PRD 0010). + +The full docker lifecycle is exercised by integration tests; here we +cover the pure helpers and the validation checks `.start` runs +before touching docker.""" + +import unittest +from pathlib import Path + +from claude_bottle.backend.docker.cred_proxy import ( + CRED_PROXY_HOSTNAME, + CRED_PROXY_PORT, + DockerCredProxy, + cred_proxy_container_name, + cred_proxy_url, +) +from claude_bottle.cred_proxy import CredProxyPlan, CredProxyUpstream +from claude_bottle.log import Die + + +def _empty_plan(**overrides): + base = { + "slug": "demo", + "routes_path": Path("/nonexistent"), + "upstreams": (), + "token_env_map": {}, + "internal_network": "", + "egress_network": "", + } + base.update(overrides) + return CredProxyPlan(**base) + + +class TestNameAndUrl(unittest.TestCase): + def test_container_name_carries_slug(self): + self.assertEqual("claude-bottle-cred-proxy-demo", + cred_proxy_container_name("demo")) + + def test_url_uses_alias_not_container_name(self): + # The URL agents dial is stable across bottles — the slug + # never appears in it. That's the whole point of attaching + # --network-alias cred-proxy on the internal network. + self.assertEqual(f"http://{CRED_PROXY_HOSTNAME}:{CRED_PROXY_PORT}", + cred_proxy_url()) + + +class TestStartGuards(unittest.TestCase): + def setUp(self): + self.proxy = DockerCredProxy() + + def test_empty_upstreams_dies(self): + with self.assertRaises(Die): + self.proxy.start(_empty_plan()) + + def test_missing_internal_network_dies(self): + upstream = CredProxyUpstream( + kind="anthropic", path="/anthropic/", + upstream="https://api.anthropic.com", + auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0", + token_ref="T", + ) + with self.assertRaises(Die): + self.proxy.start(_empty_plan(upstreams=(upstream,))) + + def test_missing_routes_file_dies(self): + upstream = CredProxyUpstream( + kind="anthropic", path="/anthropic/", + upstream="https://api.anthropic.com", + auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0", + token_ref="T", + ) + with self.assertRaises(Die): + self.proxy.start(_empty_plan( + upstreams=(upstream,), + internal_network="net-x", + egress_network="egress-x", + routes_path=Path("/tmp/cred-proxy-test-does-not-exist.json"), + )) + + +if __name__ == "__main__": + unittest.main()