fix(sidecars): apply_routes_change targets the bundle + SIGHUP forwarding
test / unit (pull_request) Successful in 20s
test / integration (pull_request) Successful in 42s

Two bugs surfaced when applying an egress route change:

1. egress_apply.py still targeted claude-bottle-egress-<slug> —
   the legacy per-sidecar container that no longer exists (it's
   a docker-network alias on the bundle now). Switched it to
   sidecar_bundle_container_name(slug), matching the chunk-5
   fix already made to pipelock_apply.py.

2. `docker kill --signal HUP <bundle>` lands SIGHUP on the
   supervisor (PID 1 in the bundle), which previously had no
   SIGHUP handler — the signal was ignored. Added
   `_Supervisor.forward_signal(sig, daemon_name)` and a SIGHUP
   handler in main() that forwards to the egress daemon so
   mitmdump's addon reload still works under the bundle.

Tests:
- New _Supervisor.forward_signal cases: forwards to the named
  child (Python subprocess as the SIGHUP target — bash trap +
  stdout=PIPE deferral interferes with the production-style
  test); unknown-daemon name is a no-op.

Stale-reference cleanup (separate issue surfaced while looking
at this):
- claude_bottle/{egress,git_gate,egress_addon,
  egress_addon_core,supervise_server}.py: Dockerfile.egress /
  Dockerfile.git-gate / Dockerfile.supervise references updated
  to Dockerfile.sidecars (the old per-sidecar Dockerfiles were
  deleted in PRD 0024 chunk 5).
- tests/README.md: dropped the entry for
  test_pipelock_sidecar_smoke (deleted in chunk 3) and added
  the new bundle integration tests.
- git_gate.py: stale `DockerGitGate.start via docker cp`
  reference (the method was deleted in chunk 3) rewritten to
  the bind-mount path the renderer uses now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 01:56:38 -04:00
parent 853d28bc89
commit 0848344438
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