diff --git a/claude_bottle/backend/docker/egress_apply.py b/claude_bottle/backend/docker/egress_apply.py index 72cf212..1cc73b0 100644 --- a/claude_bottle/backend/docker/egress_apply.py +++ b/claude_bottle/backend/docker/egress_apply.py @@ -31,7 +31,7 @@ from ...egress import EGRESS_ROUTES_IN_CONTAINER from ...egress_addon_core import load_routes from ...yaml_subset import YamlSubsetError, parse_yaml_subset from .bottle_state import egress_state_dir -from .egress import egress_container_name +from .sidecar_bundle import sidecar_bundle_container_name from .pipelock_apply import ( PipelockApplyError, apply_allowlist_change, @@ -81,7 +81,7 @@ def fetch_current_routes(slug: str) -> str: for `slug`. Returns the file content as a string. Raises EgressApplyError if the sidecar isn't reachable or the read fails.""" - container = egress_container_name(slug) + container = sidecar_bundle_container_name(slug) r = subprocess.run( ["docker", "exec", container, "cat", EGRESS_ROUTES_IN_CONTAINER], capture_output=True, text=True, check=False, @@ -185,7 +185,7 @@ def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]: Returns (before, after) where `after` == `new_content`. Raises EgressApplyError on any step.""" - container = egress_container_name(slug) + container = sidecar_bundle_container_name(slug) before = fetch_current_routes(slug) validate_routes_content(new_content) diff --git a/claude_bottle/egress.py b/claude_bottle/egress.py index f59d3e0..58504a7 100644 --- a/claude_bottle/egress.py +++ b/claude_bottle/egress.py @@ -40,7 +40,7 @@ from .manifest import Bottle EGRESS_HOSTNAME = "egress" # In-container path the addon reads. Pre-created in -# `Dockerfile.egress` so the host bind-mount can drop the file +# `Dockerfile.sidecars` so the host bind-mount can drop the file # directly. Content is YAML (hand-rolled by `egress_render_routes` # in the style of `pipelock_render_yaml`, parsed by `yaml_subset` # inside the addon). diff --git a/claude_bottle/egress_addon.py b/claude_bottle/egress_addon.py index bb8e65c..8180c89 100644 --- a/claude_bottle/egress_addon.py +++ b/claude_bottle/egress_addon.py @@ -18,7 +18,7 @@ This file imports `mitmproxy` and is never imported on the host — mitmproxy is a container-only dependency. The host's tests target `egress_addon_core`. -Dockerfile.egress copies both this file and +Dockerfile.sidecars copies both this file and `egress_addon_core.py` flat into `/app/`; the absolute import below works because mitmdump runs with `/app` on its sys.path. The parallel file in the package source tree (claude_bottle/) is the diff --git a/claude_bottle/egress_addon_core.py b/claude_bottle/egress_addon_core.py index 835dc5f..c99c547 100644 --- a/claude_bottle/egress_addon_core.py +++ b/claude_bottle/egress_addon_core.py @@ -7,8 +7,8 @@ exercise the parse + decision functions without depending on the container. Imports: stdlib + `yaml_subset` (which is itself stdlib-only and -ships flat into the egress image alongside this file — see -`Dockerfile.egress`). +ships flat into the sidecar bundle image alongside this file — +see `Dockerfile.sidecars`). """ from __future__ import annotations @@ -16,13 +16,11 @@ from __future__ import annotations import typing from dataclasses import dataclass -# Absolute import — `yaml_subset.py` is copied flat into the egress -# image's `/app/` next to this file (via `Dockerfile.egress`). The -# host-side unit tests run with the repo on sys.path, where the -# bare `yaml_subset` module also resolves because -# `claude_bottle/yaml_subset.py` shadows it at import time... actually -# no, on host the module lives under the `claude_bottle` package. -# The try/except shim picks whichever import works. +# Absolute import — `yaml_subset.py` is copied flat into the bundle +# image's `/app/` next to this file (via `Dockerfile.sidecars`). +# The host-side unit tests run with the repo on sys.path, where the +# import resolves under the `claude_bottle` package. The try/except +# shim picks whichever import works. try: from yaml_subset import YamlSubsetError, parse_yaml_subset # type: ignore[import-not-found] except ImportError: # pragma: no cover - host-side path diff --git a/claude_bottle/git_gate.py b/claude_bottle/git_gate.py index ffdb952..ae24fe9 100644 --- a/claude_bottle/git_gate.py +++ b/claude_bottle/git_gate.py @@ -147,12 +147,12 @@ def git_gate_known_hosts_line(host: str, port: str, key: str) -> str: def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: - """Posix-sh entrypoint (alpine ash). One `init_repo` call per - upstream, then `exec git daemon`. The function reads - `/git-gate/creds/-{key,known_hosts}` (laid down by - `DockerGitGate.start` via docker cp) and wires them into each - bare repo's config; the access-hook + pre-receive hook pick those - paths up at fetch / push time.""" + """Posix-sh entrypoint. One `init_repo` call per upstream, then + `exec git daemon`. The function reads + `/git-gate/creds/-{key,known_hosts}` (bind-mounted into + the bundle by the renderer) and wires them into each bare repo's + config; the access-hook + pre-receive hook pick those paths up + at fetch / push time.""" lines = [ "#!/bin/sh", "set -eu", diff --git a/claude_bottle/sidecar_init.py b/claude_bottle/sidecar_init.py index 6d9fafd..9b08951 100644 --- a/claude_bottle/sidecar_init.py +++ b/claude_bottle/sidecar_init.py @@ -200,6 +200,30 @@ class _Supervisor: the operator gets that code on container exit.""" return max((p.returncode for _, p in self.procs), default=0) + def forward_signal(self, sig: int, daemon_name: str) -> bool: + """Forward a signal to one named child. Used by the SIGHUP + path: egress_apply.py runs `docker kill --signal HUP + `, the host kernel delivers SIGHUP to PID 1 (this + supervisor), and we relay it to mitmdump so it reloads + its addon's routes.yaml. Returns True iff a live child by + that name was signaled.""" + for spec, p in self.procs: + if spec.name != daemon_name: + continue + if p.poll() is not None: + _log( + f"SIGHUP for {daemon_name} dropped; daemon " + f"already exited (rc={p.returncode})" + ) + return False + try: + p.send_signal(sig) + except ProcessLookupError: + return False + _log(f"forwarded {signal.Signals(sig).name} to {daemon_name}") + return True + return False + def main(argv: Sequence[str] | None = None) -> int: del argv # no flags yet; env-driven only @@ -213,6 +237,14 @@ def main(argv: Sequence[str] | None = None) -> int: signal.signal(signal.SIGTERM, lambda *_: sup.request_shutdown("SIGTERM")) signal.signal(signal.SIGINT, lambda *_: sup.request_shutdown("SIGINT")) + # SIGHUP reload path: routes.yaml + pipelock.yaml hot-reloads + # in egress_apply.py / pipelock_apply.py issue `docker kill + # --signal HUP `. The kernel delivers SIGHUP to PID 1 + # (this supervisor); we forward it to the daemon that knows + # how to reload (egress = mitmdump-reload-addons; pipelock has + # no in-process reload, so the pipelock-apply path uses + # `docker restart` instead). + signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress")) while not sup.tick(): time.sleep(_POLL_INTERVAL) diff --git a/claude_bottle/supervise_server.py b/claude_bottle/supervise_server.py index 5bc4c25..7a1f164 100644 --- a/claude_bottle/supervise_server.py +++ b/claude_bottle/supervise_server.py @@ -42,8 +42,8 @@ import urllib.request from dataclasses import dataclass from pathlib import Path -# Same-directory import inside the container; `supervise.py` is COPYed -# alongside this file by Dockerfile.supervise. +# Same-directory import inside the bundle container; `supervise.py` +# is COPYed alongside this file by Dockerfile.sidecars. import supervise as _sv diff --git a/tests/README.md b/tests/README.md index 9e8ef91..47438e2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -40,15 +40,17 @@ Discovery is invoked with `-t .` (top-level dir = repo root) so the ## What the integration tests cover -- `test_pipelock_sidecar_smoke.py` — drives `DockerPipelockProxy.prepare` - + `.start` (the production code path) against a real Docker daemon and - probes the sidecar's `/health` from an in-network curl container. - `test_dry_run_plan.py` — `cli.py start --dry-run --format=json` emits a structured plan that contains the resolved egress allowlist and the bottle's runtime, and creates zero Docker resources. -- `test_orphan_cleanup.py` — `network_remove` and `PipelockProxy.stop` - are idempotent against missing resources, so the EXIT trap can call - them unconditionally. +- `test_orphan_cleanup.py` — `network_remove` is idempotent against + missing resources, so the EXIT trap can call it unconditionally. +- `test_sidecar_bundle_image.py` — builds Dockerfile.sidecars and + probes that pipelock / gitleaks / mitmdump / supervise are all + reachable inside the bundle. +- `test_sidecar_bundle_compose.py` — end-to-end compose-up of an + agent + bundle pair; verifies the agent reaches the bundle via + the legacy network aliases. ## Canaries diff --git a/tests/unit/test_sidecar_init.py b/tests/unit/test_sidecar_init.py index 1112ada..239bb0c 100644 --- a/tests/unit/test_sidecar_init.py +++ b/tests/unit/test_sidecar_init.py @@ -174,6 +174,59 @@ class TestSupervisor(unittest.TestCase): self.assertEqual(2, rc) self.assertIsNone(sup.shutdown_at) + def test_forward_signal_to_named_child(self): + # SIGHUP needs to reach mitmdump inside the bundle so + # routes.yaml reloads (egress_apply.py issues `docker kill + # --signal HUP `). The supervisor forwards by daemon + # name. + # + # The child is Python (not a shell-with-trap) on purpose: + # bash on macOS defers trap execution while a foreground + # builtin like `sleep` is running and stdout is a pipe, + # which makes shell-trap test fixtures flaky. The + # production code path (mitmdump as the bundle child) + # doesn't have a shell in between — egress_entrypoint.sh + # `exec`s mitmdump, so SIGHUP lands directly on mitmdump. + sighup_marker = ( + sys.executable, "-c", + "import signal, sys, time\n" + "def _h(*_): sys.exit(42)\n" + "signal.signal(signal.SIGHUP, _h)\n" + "while True: time.sleep(0.1)\n", + ) + specs = [ + _DaemonSpec("egress", sighup_marker), + _DaemonSpec("other", ("/bin/sleep", "30")), + ] + sup = _Supervisor(specs) + sup.start_all() + time.sleep(0.3) # let Python install the handler + + delivered = sup.forward_signal(signal.SIGHUP, "egress") + self.assertTrue(delivered) + + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline: + if sup.procs[0][1].poll() is not None: + break + time.sleep(0.05) + self.assertEqual(42, sup.procs[0][1].returncode, + "egress did not see SIGHUP") + # The other daemon is untouched. + self.assertIsNone(sup.procs[1][1].poll()) + + sup.request_shutdown(reason="cleanup") + self._drive(sup) + + def test_forward_signal_unknown_daemon_no_op(self): + specs = [_DaemonSpec("a", ("/bin/sleep", "30"))] + sup = _Supervisor(specs) + sup.start_all() + delivered = sup.forward_signal(signal.SIGHUP, "ghost") + self.assertFalse(delivered) + sup.request_shutdown(reason="cleanup") + self._drive(sup) + def test_shutdown_after_start_terminates_children(self): # Two long-running children. Caller requests shutdown; # both should receive SIGTERM and exit. exit_code() is