refactor(de-sidecar): rename sidecar_init→gateway_init, drop docker's dead per-bottle compose renderer
Part of the de-sidecar cleanup (#385 discussion): the per-bottle companion container is the old architecture. - Rename `bot_bottle/sidecar_init.py` → `gateway_init.py` (it's the gateway image's PID-1 supervisor); env var `BOT_BOTTLE_SIDECAR_DAEMONS` → `BOT_BOTTLE_GATEWAY_DAEMONS`; log prefix `sidecar-init:` → `gateway-init:`. Update Dockerfile.gateway COPY/ENTRYPOINT and the test. - Remove the dead per-bottle compose renderer from `backend/docker/compose.py` (`bottle_plan_to_compose`, `_sidecar_bundle_service`, `_agent_service`, and the network/bind/proxy helpers). Docker's live path uses `consolidated_agent_compose`; only the compose *lifecycle* helpers (up/down/ls/write) remain. Trim `test_compose.py` to the surviving helpers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -1,20 +1,10 @@
|
||||
"""Compose-spec rendering for a Docker bottle (PRD 0018, chunk 1).
|
||||
"""Docker compose lifecycle helpers (PRD 0018).
|
||||
|
||||
`bottle_plan_to_compose(plan)` returns a Compose v2 spec dict
|
||||
describing the per-bottle container topology — one project per
|
||||
bottle instance, services for the agent + every applicable sidecar,
|
||||
two networks, no named volumes.
|
||||
|
||||
Pure function. No I/O, no subprocess. Expects every launch-time
|
||||
field (network names, CA host paths, etc.) on the plan's inner
|
||||
plans to be populated; chunks 2+3 own that ordering.
|
||||
|
||||
Conditional services follow the plan content:
|
||||
|
||||
- agent + sidecars bundle: always.
|
||||
- git-gate: iff plan.git_gate_plan.upstreams.
|
||||
- egress: iff plan.egress_plan.routes.
|
||||
- supervise: iff plan.supervise_plan is not None.
|
||||
Serialize a compose spec to disk, drive `docker compose up/down`,
|
||||
dump the merged log on teardown, and enumerate `bot-bottle-*`
|
||||
projects. The spec itself is built by `consolidated_compose.py`
|
||||
(the consolidated per-host gateway topology); this module owns the
|
||||
I/O side that persists and runs it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,233 +15,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...egress import (
|
||||
EGRESS_HOSTNAME,
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
egress_agent_env_entries,
|
||||
egress_sidecar_env_entries,
|
||||
)
|
||||
from ...git_gate import GIT_GATE_HOSTNAME
|
||||
from ...log import die, warn
|
||||
from ...supervise import (
|
||||
DB_PATH_IN_CONTAINER,
|
||||
SUPERVISE_HOSTNAME,
|
||||
SUPERVISE_PORT,
|
||||
)
|
||||
from ...util import expand_tilde
|
||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .egress import (
|
||||
EGRESS_CA_IN_CONTAINER,
|
||||
EGRESS_PORT,
|
||||
)
|
||||
from .git_gate import (
|
||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
||||
GIT_GATE_HOOK_IN_CONTAINER,
|
||||
)
|
||||
from . import network as network_mod
|
||||
from .sidecar_bundle import (
|
||||
SIDECAR_BUNDLE_DOCKERFILE,
|
||||
SIDECAR_BUNDLE_IMAGE,
|
||||
sidecar_bundle_container_name,
|
||||
)
|
||||
|
||||
|
||||
# Repo root, used as the build context for the bundle Dockerfile.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Render a Compose v2 spec dict from a fully-resolved
|
||||
DockerBottlePlan.
|
||||
|
||||
The plan must have its inner plans (`git_gate_plan`,
|
||||
`egress_plan`, `supervise_plan`) populated with launch-time
|
||||
fields — network names, CA host paths. The renderer doesn't
|
||||
validate; callers feed it a fully-resolved plan or get an
|
||||
incomplete compose spec back.
|
||||
"""
|
||||
project = f"bot-bottle-{plan.slug}"
|
||||
services: dict[str, Any] = {
|
||||
"sidecars": _sidecar_bundle_service(plan),
|
||||
"agent": _agent_service(plan),
|
||||
}
|
||||
return {
|
||||
"name": project,
|
||||
"services": services,
|
||||
"networks": _networks(plan),
|
||||
}
|
||||
|
||||
|
||||
def _networks(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Compose-managed networks with explicit `name:` matching the
|
||||
existing slug-suffixed convention. Compose creates them on `up`
|
||||
and destroys them on `down`. The internal one is `--internal`
|
||||
(no default gateway); the egress one is a normal user-defined
|
||||
bridge."""
|
||||
return {
|
||||
"internal": {
|
||||
"name": network_mod.network_name_for_slug(plan.slug),
|
||||
"internal": True,
|
||||
},
|
||||
"egress": {
|
||||
"name": network_mod.network_egress_name_for_slug(plan.slug),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str, Any]:
|
||||
"""One bind-mount entry in the long-form `volumes:` shape.
|
||||
Long form is preferred over `host:target:ro` strings because
|
||||
it's easier to inspect in tests and survives whitespace in
|
||||
host paths."""
|
||||
return {
|
||||
"type": "bind",
|
||||
"source": str(host),
|
||||
"target": target,
|
||||
"read_only": read_only,
|
||||
}
|
||||
|
||||
|
||||
def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""The `sidecars` service: one container per bottle, bundle
|
||||
image, all daemons under a Python init supervisor.
|
||||
|
||||
Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env.
|
||||
egress is always present; git-gate / supervise are conditional.
|
||||
"""
|
||||
daemons: list[str] = ["egress"]
|
||||
if plan.git_gate_plan.upstreams:
|
||||
daemons.append("git-gate")
|
||||
if plan.supervise_plan is not None:
|
||||
daemons.append("supervise")
|
||||
|
||||
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
|
||||
volumes: list[dict[str, Any]] = []
|
||||
|
||||
# --- egress -------------------------------------------------------
|
||||
ep = plan.egress_plan
|
||||
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
|
||||
if ep.routes:
|
||||
volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent)))
|
||||
env.extend(egress_sidecar_env_entries(ep))
|
||||
|
||||
# --- git-gate -----------------------------------------------------
|
||||
gp = plan.git_gate_plan
|
||||
if gp.upstreams:
|
||||
volumes += [
|
||||
_bind(gp.entrypoint_script, GIT_GATE_ENTRYPOINT_IN_CONTAINER),
|
||||
_bind(gp.hook_script, GIT_GATE_HOOK_IN_CONTAINER),
|
||||
_bind(gp.access_hook_script, GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
|
||||
]
|
||||
for u in gp.upstreams:
|
||||
keypath = expand_tilde(u.identity_file)
|
||||
volumes.append(_bind(
|
||||
keypath,
|
||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
||||
))
|
||||
if u.known_hosts_file:
|
||||
volumes.append(_bind(
|
||||
u.known_hosts_file,
|
||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
|
||||
))
|
||||
|
||||
# --- supervise ----------------------------------------------------
|
||||
sp = plan.supervise_plan
|
||||
if sp is not None:
|
||||
env += [
|
||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
]
|
||||
volumes.append({
|
||||
"type": "bind",
|
||||
"source": str(sp.db_path),
|
||||
"target": DB_PATH_IN_CONTAINER,
|
||||
"read_only": False,
|
||||
})
|
||||
internal_aliases = [EGRESS_HOSTNAME]
|
||||
if gp.upstreams:
|
||||
internal_aliases.append(GIT_GATE_HOSTNAME)
|
||||
if sp is not None:
|
||||
internal_aliases.append(SUPERVISE_HOSTNAME)
|
||||
|
||||
service: dict[str, Any] = {
|
||||
"image": SIDECAR_BUNDLE_IMAGE,
|
||||
"build": {
|
||||
"context": _REPO_DIR,
|
||||
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
|
||||
},
|
||||
"container_name": sidecar_bundle_container_name(plan.slug),
|
||||
"networks": {
|
||||
"internal": {"aliases": internal_aliases},
|
||||
"egress": None,
|
||||
},
|
||||
"environment": env,
|
||||
"volumes": volumes,
|
||||
}
|
||||
return service
|
||||
|
||||
|
||||
def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Agent container. Runs `sleep infinity`; claude is `docker
|
||||
exec -it`'d into it later. HTTP_PROXY/HTTPS_PROXY point at the
|
||||
egress sidecar."""
|
||||
proxy_url = _agent_proxy_url(plan)
|
||||
no_proxy = _agent_no_proxy(plan)
|
||||
env: list[str] = [
|
||||
f"HTTPS_PROXY={proxy_url}",
|
||||
f"HTTP_PROXY={proxy_url}",
|
||||
f"https_proxy={proxy_url}",
|
||||
f"http_proxy={proxy_url}",
|
||||
f"NO_PROXY={no_proxy}",
|
||||
f"no_proxy={no_proxy}",
|
||||
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
||||
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
|
||||
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
|
||||
]
|
||||
for name, value in sorted(plan.agent_provision.guest_env.items()):
|
||||
env.append(f"{name}={value}")
|
||||
# Forwarded vars (OAuth token, manifest host-interpolations):
|
||||
# bare name → inherits from compose-up process env, value
|
||||
# never lands on argv or in the compose file.
|
||||
for name in sorted(plan.forwarded_env.keys()):
|
||||
env.append(name)
|
||||
env.extend(egress_agent_env_entries(plan.egress_plan))
|
||||
|
||||
service: dict[str, Any] = {
|
||||
"image": plan.image,
|
||||
"container_name": plan.container_name,
|
||||
"command": ["sleep", "infinity"],
|
||||
"networks": {"internal": None},
|
||||
"environment": env,
|
||||
}
|
||||
if plan.use_runsc:
|
||||
service["runtime"] = "runsc"
|
||||
|
||||
# The init supervisor inside the bundle owns intra-bundle
|
||||
# daemon ordering, so the agent only waits for the bundle
|
||||
# container itself.
|
||||
service["depends_on"] = ["sidecars"]
|
||||
|
||||
return service
|
||||
|
||||
|
||||
def _agent_proxy_url(plan: DockerBottlePlan) -> str:
|
||||
"""Agent's HTTP_PROXY — always points at egress."""
|
||||
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
|
||||
|
||||
|
||||
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
||||
"""NO_PROXY for the agent: loopback always; supervise hostname
|
||||
when the supervise sidecar is up (MCP long-poll must bypass
|
||||
the egress proxy)."""
|
||||
hosts = ["localhost", "127.0.0.1"]
|
||||
if plan.supervise_plan is not None:
|
||||
hosts.append(SUPERVISE_HOSTNAME)
|
||||
return ",".join(hosts)
|
||||
|
||||
|
||||
# --- Lifecycle helpers (PRD 0018 chunk 3) ----------------------------------
|
||||
@@ -442,7 +206,6 @@ __all__ = [
|
||||
"COMPOSE_FILE_NAME",
|
||||
"COMPOSE_LOG_NAME",
|
||||
"COMPOSE_PROJECT_PREFIX",
|
||||
"bottle_plan_to_compose",
|
||||
"compose_down",
|
||||
"compose_dump_logs",
|
||||
"compose_file_path",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Per-bottle sidecar supervisor (PRD 0024 chunk 1).
|
||||
"""Gateway data-plane supervisor (PRD 0070; PRD 0024 bundle shape).
|
||||
|
||||
PID 1 inside the `bot-bottle-gateway` data-plane image. Spawns
|
||||
the configured daemons (egress, git-gate, supervise),
|
||||
@@ -7,20 +7,20 @@ stdout+stderr to the container log with a `[name] ` prefix.
|
||||
|
||||
Failure policy (interim): when a child dies unexpectedly, the
|
||||
supervisor logs the death and leaves the surviving children
|
||||
running. The bundle stays up; whatever the dead daemon served
|
||||
running. The gateway stays up; whatever the dead daemon served
|
||||
will start failing, surfacing in the agent's own error path.
|
||||
The supervisor itself exits only when (a) the operator/compose
|
||||
sends SIGTERM/SIGINT, or (b) every child has died.
|
||||
The supervisor itself exits only when (a) the operator sends
|
||||
SIGTERM/SIGINT, or (b) every child has died.
|
||||
|
||||
Failure policy (eventual): on unexpected death, the supervisor
|
||||
restarts the daemon and emits a notification to the supervise
|
||||
sidecar so the operator sees the event. That lands in a later
|
||||
PR; the interim policy is "don't take the bundle down for one
|
||||
daemon so the operator sees the event. That lands in a later
|
||||
PR; the interim policy is "don't take the gateway down for one
|
||||
sick daemon."
|
||||
|
||||
Daemon subset is env-driven. The compose renderer narrows it via
|
||||
`BOT_BOTTLE_SIDECAR_DAEMONS=egress` for bottles that
|
||||
don't use git-gate or supervise. Default: all daemons.
|
||||
Daemon subset is env-driven via `BOT_BOTTLE_GATEWAY_DAEMONS=egress`
|
||||
for callers that don't use git-gate or supervise. Default: all
|
||||
daemons.
|
||||
|
||||
Stdlib-only by design — adding supervisord/s6/runit for four
|
||||
daemons is heavier than this script.
|
||||
@@ -103,8 +103,8 @@ def _selected_daemons(
|
||||
env: dict[str, str],
|
||||
all_daemons: Sequence[_DaemonSpec] | None = None,
|
||||
) -> tuple[_DaemonSpec, ...]:
|
||||
"""Filter the daemon set by the BOT_BOTTLE_SIDECAR_DAEMONS env
|
||||
var. Unknown names in the list are ignored — the renderer is the
|
||||
"""Filter the daemon set by the BOT_BOTTLE_GATEWAY_DAEMONS env
|
||||
var. Unknown names in the list are ignored — the caller is the
|
||||
source of truth for which daemons are wired.
|
||||
|
||||
`all_daemons` defaults to `_DAEMONS` resolved at call time (not
|
||||
@@ -112,7 +112,7 @@ def _selected_daemons(
|
||||
`_DAEMONS` and have the new value take effect."""
|
||||
if all_daemons is None:
|
||||
all_daemons = _DAEMONS
|
||||
raw = env.get("BOT_BOTTLE_SIDECAR_DAEMONS", "").strip()
|
||||
raw = env.get("BOT_BOTTLE_GATEWAY_DAEMONS", "").strip()
|
||||
if not raw:
|
||||
return tuple(all_daemons)
|
||||
wanted = {n.strip() for n in raw.split(",") if n.strip()}
|
||||
@@ -120,7 +120,7 @@ def _selected_daemons(
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
sys.stdout.write(f"sidecar-init: {msg}\n")
|
||||
sys.stdout.write(f"gateway-init: {msg}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user