fix(sidecars): apply_routes_change targets the bundle + SIGHUP forwarding #60

Merged
didericis merged 1 commits from fix-egress-apply-bundle-target into main 2026-05-27 02:02:54 -04:00
9 changed files with 113 additions and 28 deletions
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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
+7 -9
View File
@@ -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
+6 -6
View File
@@ -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/<name>-{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/<name>-{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",
+32
View File
@@ -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
<bundle>`, 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 <bundle>`. 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)
+2 -2
View File
@@ -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
+8 -6
View File
@@ -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
+53
View File
@@ -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 <bundle>`). 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