diff --git a/bot_bottle/backend/docker/egress_apply.py b/bot_bottle/backend/docker/egress_apply.py index 9f05e47..d65df11 100644 --- a/bot_bottle/backend/docker/egress_apply.py +++ b/bot_bottle/backend/docker/egress_apply.py @@ -1,60 +1,29 @@ -"""Host-side helper for egress sidecar inspection and live updates. +"""Host-side egress route-apply for the docker backend. -The approve path uses this module to validate a proposed routes file, -write it to the bottle's live egress state dir, and signal the sidecar -bundle so the mitmproxy addon reloads it. +The per-bottle companion container this used to signal (`docker kill +--signal HUP `) was removed in the de-sidecar cleanup (#385). +In the consolidated model the shared gateway resolves egress policy +per-request against the orchestrator rather than reloading a per-bottle +routes file, so the live per-bottle reload is not supported here and +fails closed until the gateway-side apply lands. """ from __future__ import annotations -import os -import subprocess - -from ...egress import EGRESS_ROUTES_IN_CONTAINER -from ...log import warn from ..egress_apply import EgressApplicator, EgressApplyError -from .sidecar_bundle import sidecar_bundle_container_name - - -def fetch_current_routes(slug: str) -> str: - container = sidecar_bundle_container_name(slug) - r = subprocess.run( - ["docker", "exec", container, "cat", EGRESS_ROUTES_IN_CONTAINER], - capture_output=True, text=True, check=False, - ) - if r.returncode != 0: - raise EgressApplyError( - f"could not read routes.yaml from {container}: " - f"{(r.stderr or '').strip() or 'container not running?'}" - ) - return r.stdout class DockerEgressApplicator(EgressApplicator): def _signal_bundle_reload(self, slug: str) -> None: - container = sidecar_bundle_container_name(slug) - result = subprocess.run( - ["docker", "kill", "--signal", "HUP", container], - capture_output=True, text=True, check=False, env=os.environ, + del slug + raise EgressApplyError( + "live egress route-apply was removed with the per-bottle " + "companion container (#385); route changes will flow through " + "the consolidated gateway in a follow-up." ) - if result.returncode != 0: - last_error = (result.stderr or "").strip() or (result.stdout or "").strip() - warn( - f"egress: routes updated on disk for {slug}, but bundle reload failed: " - f"{last_error or 'docker kill failed'}" - ) - raise EgressApplyError( - f"could not reload egress bundle {container}: " - f"{last_error or 'docker kill failed'}" - ) applicator = DockerEgressApplicator() -__all__ = [ - "DockerEgressApplicator", - "EgressApplyError", - "applicator", - "fetch_current_routes", -] +__all__ = ["DockerEgressApplicator", "EgressApplyError", "applicator"] diff --git a/bot_bottle/backend/docker/sidecar_bundle.py b/bot_bottle/backend/docker/sidecar_bundle.py deleted file mode 100644 index 105d044..0000000 --- a/bot_bottle/backend/docker/sidecar_bundle.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Sidecar bundle constants + helpers for the Docker backend -(PRD 0024). - -A per-bottle sidecar bundle runs egress + git-gate + supervise as one -container under a small Python init supervisor. As of PRD 0024 chunk 5 -the bundle is the only shape — the legacy four-sidecar topology and its -`BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone. - -The bundle's image is the **same data plane** as the consolidated -per-host gateway (PRD 0070), so it is one image with one name and one -Dockerfile: `bot-bottle-gateway` / `Dockerfile.gateway` (#384). These -constants alias the gateway's so a per-bottle bundle and the shared -gateway can never drift onto different images.""" - -from __future__ import annotations - -from ...orchestrator.gateway import GATEWAY_DOCKERFILE, GATEWAY_IMAGE - - -# The per-bottle bundle image == the gateway data-plane image (aliased so -# there is one source of truth; the `BOT_BOTTLE_GATEWAY_IMAGE` env override -# on the gateway constant applies here too). -SIDECAR_BUNDLE_IMAGE = GATEWAY_IMAGE -SIDECAR_BUNDLE_DOCKERFILE = GATEWAY_DOCKERFILE - - -def sidecar_bundle_container_name(slug: str) -> str: - """`bot-bottle-sidecars-`. Same prefix scheme as the - per-sidecar containers it replaces, so the dashboard's - discovery-by-prefix logic keeps working. (The per-bottle *container* - name keeps the historical `sidecars-` prefix; only the *image* was - renamed to `bot-bottle-gateway` in #384.)""" - return f"bot-bottle-sidecars-{slug}" diff --git a/bot_bottle/backend/firecracker/bottle_cleanup_plan.py b/bot_bottle/backend/firecracker/bottle_cleanup_plan.py index 3fd8eec..a052239 100644 --- a/bot_bottle/backend/firecracker/bottle_cleanup_plan.py +++ b/bot_bottle/backend/firecracker/bottle_cleanup_plan.py @@ -10,10 +10,9 @@ from .. import BottleCleanupPlan @dataclass(frozen=True) class FirecrackerBottleCleanupPlan(BottleCleanupPlan): - # PIDs of orphaned firecracker VMM processes and the sidecar - # containers left behind by previous bottles. + # PIDs of orphaned firecracker VMM processes and the per-bottle run + # dirs left behind by previous bottles. vm_pids: tuple[int, ...] = () - containers: tuple[str, ...] = () run_dirs: tuple[str, ...] = () def print(self) -> None: @@ -22,11 +21,9 @@ class FirecrackerBottleCleanupPlan(BottleCleanupPlan): return for pid in self.vm_pids: info(f"firecracker VM process: pid {pid}") - for name in self.containers: - info(f"firecracker sidecar container: {name}") for path in self.run_dirs: info(f"firecracker run dir: {path}") @property def empty(self) -> bool: - return not (self.vm_pids or self.containers or self.run_dirs) + return not (self.vm_pids or self.run_dirs) diff --git a/bot_bottle/backend/firecracker/cleanup.py b/bot_bottle/backend/firecracker/cleanup.py index 938bca9..af41e29 100644 --- a/bot_bottle/backend/firecracker/cleanup.py +++ b/bot_bottle/backend/firecracker/cleanup.py @@ -1,9 +1,8 @@ """Cleanup for the Firecracker backend. Orphans are: firecracker VMM processes whose config lives under our run -dir, the `bot-bottle-sidecars-*` containers, and the per-bottle run -dirs. TAP slots free themselves (the flock drops when the launcher -exits), so there is nothing to reclaim there. +dir, and the per-bottle run dirs. TAP slots free themselves (the flock +drops when the launcher exits), so there is nothing to reclaim there. """ from __future__ import annotations @@ -18,8 +17,6 @@ from ...log import info from . import util from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan -_SIDECAR_PREFIX = "bot-bottle-sidecars-" - def _run_root() -> Path: return util.cache_dir() / "run" @@ -46,17 +43,6 @@ def _orphan_vm_pids() -> list[int]: return pids -def _sidecar_containers() -> list[str]: - result = subprocess.run( - ["docker", "ps", "-a", "--format", "{{.Names}}", - "--filter", f"name={_SIDECAR_PREFIX}"], - capture_output=True, text=True, check=False, - ) - if result.returncode != 0: - return [] - return sorted(n.strip() for n in result.stdout.splitlines() if n.strip()) - - def _run_dirs() -> list[str]: run_root = _run_root() if not run_root.is_dir(): @@ -67,7 +53,6 @@ def _run_dirs() -> list[str]: def prepare_cleanup() -> FirecrackerBottleCleanupPlan: return FirecrackerBottleCleanupPlan( vm_pids=tuple(_orphan_vm_pids()), - containers=tuple(_sidecar_containers()), run_dirs=tuple(_run_dirs()), ) @@ -79,12 +64,6 @@ def cleanup(plan: FirecrackerBottleCleanupPlan) -> None: os.kill(pid, signal.SIGTERM) except ProcessLookupError: pass - for name in plan.containers: - info(f"docker rm -f {name}") - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) for path in plan.run_dirs: info(f"rm -rf {path}") shutil.rmtree(path, ignore_errors=True) diff --git a/bot_bottle/backend/firecracker/enumerate.py b/bot_bottle/backend/firecracker/enumerate.py index 4c9e90d..d8eecf1 100644 --- a/bot_bottle/backend/firecracker/enumerate.py +++ b/bot_bottle/backend/firecracker/enumerate.py @@ -1,43 +1,14 @@ """Active-agent enumeration for the Firecracker backend. -The agent runs in a VM (no container to list), so a live bottle is -identified by its running sidecar container `bot-bottle-sidecars-` -— the same discovery-by-prefix the other backends use. +The backend is disabled during the de-sidecar cleanup (#385) — it can't +launch bottles, so there are none to enumerate. Real enumeration returns +with the backend's consolidated relaunch (#354). """ from __future__ import annotations -import subprocess - -from ...bottle_state import read_metadata from .. import ActiveAgent -_SIDECAR_PREFIX = "bot-bottle-sidecars-" - def enumerate_active() -> list[ActiveAgent]: - result = subprocess.run( - ["docker", "ps", "--format", "{{.Names}}", - "--filter", f"name={_SIDECAR_PREFIX}"], - capture_output=True, text=True, check=False, - ) - if result.returncode != 0: - return [] - out: list[ActiveAgent] = [] - for name in sorted(n.strip() for n in result.stdout.splitlines() if n.strip()): - slug = name[len(_SIDECAR_PREFIX):] - metadata = read_metadata(slug) - if metadata is None or metadata.backend != "firecracker": - # Skip sidecars owned by another backend (docker shares the - # container-name prefix). - continue - out.append(ActiveAgent( - backend_name="firecracker", - slug=slug, - agent_name=metadata.agent_name, - started_at=metadata.started_at, - services=(), - label=metadata.label, - color=metadata.color, - )) - return out + return [] diff --git a/bot_bottle/backend/firecracker/launch.py b/bot_bottle/backend/firecracker/launch.py index a0b0486..a5e6347 100644 --- a/bot_bottle/backend/firecracker/launch.py +++ b/bot_bottle/backend/firecracker/launch.py @@ -1,404 +1,39 @@ -"""Launch flow for the Firecracker backend. +"""Launch flow for the Firecracker backend — temporarily disabled (#385). -Per bottle: - 1. mint the egress CA, build the agent image (docker), export it to a - cached ext4 rootfs; - 2. claim a free TAP pool slot (rootless flock); - 3. bring up the Docker sidecar bundle, publishing egress / git-gate / - supervise on the slot's host-side TAP IP at fixed ports; - 4. boot the microVM on that TAP; wait for SSH; - 5. provision (CA, prompt, skills, workspace, git, supervise) over SSH. +The firecracker backend launched a per-bottle companion container (the +egress / git-gate / supervise data plane) alongside each microVM. That +per-bottle-companion architecture was removed in the de-sidecar cleanup; +firecracker's replacement — the consolidated per-host gateway — lands in +its own cutover (#354). -Isolation is enforced by the operator-provisioned nft table (checked -fail-closed in preflight): a VM reaches only its sidecar (DNAT'd from -the host TAP IP) and nothing else. The agent's HTTPS_PROXY therefore -points at `http://:9099`, its only route to the world. +Until that lands, launching a firecracker bottle fails closed rather than +silently running the removed path. `prepare` / `status` / cleanup still +work, so `backend status --backend=firecracker` and orphan cleanup are +unaffected. """ from __future__ import annotations -import dataclasses -import os -import subprocess -from contextlib import ExitStack, contextmanager -from pathlib import Path +from contextlib import contextmanager from typing import Callable, Generator -from ...bottle_state import ( - egress_state_dir, - git_gate_state_dir, - read_committed_image, -) -from ...egress import ( - EGRESS_ROUTES_IN_CONTAINER, - egress_agent_env_entries, - egress_resolve_token_values, - egress_sidecar_env_entries, -) -from ...git_gate import ( - provision_git_gate_dynamic_keys, - revoke_git_gate_provisioned_keys, -) -from ...log import die, info, warn -from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT -from ...util import expand_tilde -from ..docker.egress import ( - EGRESS_CA_IN_CONTAINER, - EGRESS_PORT, - egress_tls_init, -) -from ..docker.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 ..docker.sidecar_bundle import ( - SIDECAR_BUNDLE_DOCKERFILE, - SIDECAR_BUNDLE_IMAGE, -) -from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH -from . import firecracker_vm, isolation_probe, netpool, util +from ...log import die from .bottle import FirecrackerBottle from .bottle_plan import FirecrackerBottlePlan -_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) -_GIT_HTTP_PORT = 9420 -_GIT_GATE_READY_FILE = "/run/git-gate/ready" - - -def sidecar_container_name(slug: str) -> str: - return f"bot-bottle-sidecars-{slug}" - - @contextmanager def launch( plan: FirecrackerBottlePlan, *, provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None], ) -> Generator[FirecrackerBottle, None, None]: - stack = ExitStack() - bottle_for_revoke = plan.manifest.bottle - git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) - - def teardown() -> None: - teardown_exc: BaseException | None = None - try: - stack.close() - except BaseException as exc: # noqa: W0718 - teardown must continue - teardown_exc = exc - warn(f"firecracker teardown failed: {exc!r}") - revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke) - if teardown_exc is not None: - raise teardown_exc - - try: - plan = _mint_certs(plan) - plan = _build_agent_image(plan) - - # Claim a TAP slot; the flock is held until teardown closes it. - slot, lock = netpool.allocate(plan.slug) - stack.callback(lock.close) - info(f"firecracker slot {slot.iface}: host={slot.host_ip} " - f"guest={slot.guest_ip}") - - plan = _provision_git_gate_keys(plan) - - sidecar_name = sidecar_container_name(plan.slug) - _force_remove_container(sidecar_name) - _start_sidecar_bundle(plan, sidecar_name, slot.host_ip) - stack.callback(_force_remove_container, sidecar_name) - _stage_git_gate(plan, sidecar_name) - - plan = _stamp_agent_urls(plan, slot.host_ip) - - # Build the per-bottle rootfs + SSH key, then boot. - base_dir = util.build_base_rootfs_dir(plan.image) - run_dir = util.cache_dir() / "run" / plan.slug - run_dir.mkdir(parents=True, exist_ok=True) - rootfs = run_dir / "rootfs.ext4" - util.build_rootfs_ext4(base_dir, rootfs) - private_key, pubkey = util.generate_keypair(run_dir) - - vm = firecracker_vm.boot( - name=plan.container_name, - rootfs=rootfs, - tap=slot.iface, - guest_ip=slot.guest_ip, - host_ip=slot.host_ip, - pubkey=pubkey, - run_dir=run_dir, - ) - stack.callback(vm.terminate) - firecracker_vm.wait_for_ssh(vm, private_key) - - # Authoritative fail-closed egress-boundary check, before the - # agent runs: prove the VM cannot reach the host directly. - isolation_probe.verify_isolation(private_key, slot.guest_ip) - - bottle = FirecrackerBottle( - plan.container_name, - private_key=private_key, - guest_ip=slot.guest_ip, - guest_env=_agent_guest_env(plan, slot.host_ip), - agent_command=plan.agent_command, - agent_prompt_mode=plan.agent_prompt_mode, - agent_provider_template=plan.agent_provider_template, - terminal_title=( - f"{plan.spec.label} ({plan.spec.agent_name})" - if plan.spec.label else plan.spec.agent_name - ), - terminal_color=plan.spec.color, - agent_workdir=plan.workspace_plan.workdir, - ) - bottle.prompt_path = provision(plan, bottle) - - yield bottle - finally: - teardown() - - -def _mint_certs(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan: - egress_ca_host, egress_ca_cert_only = egress_tls_init(egress_state_dir(plan.slug)) - egress_plan = dataclasses.replace( - plan.egress_plan, - mitmproxy_ca_host_path=egress_ca_host, - mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, + """Fail closed: the firecracker backend is disabled while its + consolidated (gateway-backed) launch is built in #354.""" + del plan, provision + die( + "the firecracker backend is temporarily disabled during the " + "companion-container removal (#385); its consolidated relaunch " + "lands in #354. Use --backend=docker for now." ) - return dataclasses.replace(plan, egress_plan=egress_plan) - - -def _build_agent_image(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan: - _docker_build(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE) - committed = read_committed_image(plan.slug) - if committed and _image_exists(committed): - info(f"using committed image {committed!r}") - return dataclasses.replace( - plan, - agent_provision=dataclasses.replace(plan.agent_provision, image=committed), - ) - _docker_build(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path) - return plan - - -def _provision_git_gate_keys(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan: - if not plan.git_gate_plan.upstreams: - return plan - git_gate_plan = provision_git_gate_dynamic_keys( - plan.manifest.bottle, plan.git_gate_plan, git_gate_state_dir(plan.slug), - ) - return dataclasses.replace(plan, git_gate_plan=git_gate_plan) - - -def _stamp_agent_urls( - plan: FirecrackerBottlePlan, host_ip: str, -) -> FirecrackerBottlePlan: - proxy_url = f"http://{host_ip}:{EGRESS_PORT}" - supervise_url = ( - f"http://{host_ip}:{SUPERVISE_PORT}/" if plan.supervise_plan is not None else "" - ) - git_gate_url = ( - f"http://{host_ip}:{_GIT_HTTP_PORT}" if plan.git_gate_plan.upstreams else "" - ) - return dataclasses.replace( - plan, - agent_proxy_url=proxy_url, - agent_git_gate_url=git_gate_url, - agent_supervise_url=supervise_url, - ) - - -# --- sidecar bundle (Docker) ---------------------------------------- - -def _start_sidecar_bundle( - plan: FirecrackerBottlePlan, sidecar_name: str, host_ip: str, -) -> None: - argv = ["docker", "run", "--name", sidecar_name, "--detach", "--rm", - "-e", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}"] - for entry in _sidecar_env_entries(plan): - argv += ["-e", entry] - for host_path, container_path, read_only in _sidecar_mounts(plan): - argv += ["-v", f"{host_path}:{container_path}{':ro' if read_only else ''}"] - # Publish on the slot's host TAP IP at fixed ports — each bottle - # has a distinct host_ip, so fixed ports never collide, and the VM - # reaches them at a stable, well-known address (its only route out). - for port in _sidecar_ports(plan): - argv += ["-p", f"{host_ip}:{port}:{port}"] - argv.append(SIDECAR_BUNDLE_IMAGE) - - effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env} - token_values = egress_resolve_token_values( - plan.egress_plan.token_env_map, effective_env, - ) - env = {**os.environ, **token_values} - info(f"docker run sidecar bundle {sidecar_name} (published on {host_ip})") - result = subprocess.run(argv, capture_output=True, text=True, env=env, check=False) - if result.returncode != 0: - die(f"docker run for sidecar bundle {sidecar_name} failed: " - f"{(result.stderr or '').strip() or ''}") - - -def _sidecar_daemons(plan: FirecrackerBottlePlan) -> tuple[str, ...]: - daemons = ["egress"] - if plan.git_gate_plan.upstreams: - daemons += ["git-gate", "git-http"] - if plan.supervise_plan is not None: - daemons.append("supervise") - return tuple(daemons) - - -def _sidecar_ports(plan: FirecrackerBottlePlan) -> tuple[int, ...]: - ports = [EGRESS_PORT] - if plan.git_gate_plan.upstreams: - ports.append(_GIT_HTTP_PORT) - if plan.supervise_plan is not None: - ports.append(SUPERVISE_PORT) - return tuple(ports) - - -def _sidecar_env_entries(plan: FirecrackerBottlePlan) -> tuple[str, ...]: - env: list[str] = list(egress_sidecar_env_entries(plan.egress_plan)) - if plan.git_gate_plan.upstreams: - env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}") - if plan.supervise_plan is not None: - env += [ - f"SUPERVISE_BOTTLE_SLUG={plan.slug}", - f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", - f"SUPERVISE_PORT={SUPERVISE_PORT}", - ] - return tuple(env) - - -def _sidecar_mounts( - plan: FirecrackerBottlePlan, -) -> tuple[tuple[str, str, bool], ...]: - mounts: list[tuple[str, str, bool]] = [] - ep = plan.egress_plan - mounts.append((str(ep.mitmproxy_ca_host_path.parent), - str(Path(EGRESS_CA_IN_CONTAINER).parent), False)) - if ep.routes: - mounts.append((str(ep.routes_path.parent), - str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True)) - sp = plan.supervise_plan - if sp is not None: - mounts.append((str(sp.db_path.parent), - str(Path(DB_PATH_IN_CONTAINER).parent), False)) - return tuple(mounts) - - -def _stage_git_gate(plan: FirecrackerBottlePlan, sidecar_name: str) -> None: - gp = plan.git_gate_plan - if not gp.upstreams: - return - _docker_exec(sidecar_name, [ - "mkdir", "-p", - str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent), - GIT_GATE_CREDS_DIR_IN_CONTAINER, "/git", - str(Path(_GIT_GATE_READY_FILE).parent), - ]) - for host_path, container_path in _git_gate_files(plan): - _docker_cp(host_path, f"{sidecar_name}:{container_path}") - _docker_exec(sidecar_name, [ - "sh", "-c", - f"chmod 755 {GIT_GATE_ENTRYPOINT_IN_CONTAINER} " - f"{GIT_GATE_HOOK_IN_CONTAINER} {GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && " - f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && " - f"touch {_GIT_GATE_READY_FILE}", - ]) - - -def _git_gate_files(plan: FirecrackerBottlePlan) -> tuple[tuple[str, str], ...]: - gp = plan.git_gate_plan - files: list[tuple[str, str]] = [ - (str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER), - (str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER), - (str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER), - ] - for upstream in gp.upstreams: - files.append((expand_tilde(upstream.identity_file), - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key")) - if upstream.known_hosts_file: - files.append((str(upstream.known_hosts_file), - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts")) - return tuple(files) - - -# --- agent guest env ------------------------------------------------- - -def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str]: - """Env injected into every agent/exec call over SSH. The VM has no - baked process env (it just runs init), so the proxy/CA/git/supervise - wiring is applied per-invocation.""" - proxy_url = f"http://{host_ip}:{EGRESS_PORT}" - no_proxy = f"localhost,127.0.0.1,{host_ip}" - env: dict[str, str] = { - "HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url, - "https_proxy": proxy_url, "http_proxy": proxy_url, - "NO_PROXY": no_proxy, "no_proxy": no_proxy, - "NODE_EXTRA_CA_CERTS": AGENT_CA_PATH, - "SSL_CERT_FILE": AGENT_CA_BUNDLE, - "REQUESTS_CA_BUNDLE": AGENT_CA_BUNDLE, - } - if plan.agent_git_gate_url: - env["GIT_GATE_URL"] = plan.agent_git_gate_url - if plan.agent_supervise_url: - env["MCP_SUPERVISE_URL"] = plan.agent_supervise_url - for entry in egress_agent_env_entries(plan.egress_plan): - key, _, value = entry.partition("=") - env[key] = value - env.update(plan.agent_provision.guest_env) - # Forwarded (bare-name) env: resolve host values now, since the VM - # can't inherit them from a `docker run --env NAME`. - for name in plan.forwarded_env: - value = os.environ.get(name) - if value is not None: - env[name] = value - return env - - -# --- docker helpers -------------------------------------------------- - -def _docker_build(ref: str, context: str, *, dockerfile: str = "") -> None: - info(f"docker build {ref}") - args = ["docker", "build", "-t", ref] - if dockerfile: - if not os.path.isabs(dockerfile): - dockerfile = os.path.join(context, dockerfile) - args += ["-f", dockerfile] - args.append(context) - result = subprocess.run(args, check=False) - if result.returncode != 0: - die(f"docker build for {ref!r} failed") - - -def _image_exists(ref: str) -> bool: - return subprocess.run( - ["docker", "image", "inspect", ref], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ).returncode == 0 - - -def _force_remove_container(name: str) -> None: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - - -def _docker_exec(name: str, argv: list[str]) -> None: - result = subprocess.run( - ["docker", "exec", name, *argv], capture_output=True, text=True, check=False, - ) - if result.returncode != 0: - die(f"docker exec in {name} failed: " - f"{(result.stderr or '').strip() or ''}") - - -def _docker_cp(host_path: str, dest: str) -> None: - result = subprocess.run( - ["docker", "cp", host_path, dest], capture_output=True, text=True, check=False, - ) - if result.returncode != 0: - die(f"docker cp {host_path} -> {dest} failed: " - f"{(result.stderr or '').strip() or ''}") + yield # unreachable — `die` raises; keeps this a generator/contextmanager diff --git a/bot_bottle/backend/firecracker/netpool.py b/bot_bottle/backend/firecracker/netpool.py index 09bb596..9de0b09 100644 --- a/bot_bottle/backend/firecracker/netpool.py +++ b/bot_bottle/backend/firecracker/netpool.py @@ -91,7 +91,7 @@ def ip_base() -> str: # Sidecar ports the VM reaches at its host-side TAP IP. Kept in sync # with the backend constants (egress 9099, supervise 9100, git-http # 9420); rendered into the setup output for operator visibility. -SIDECAR_PORTS = (9099, 9100, 9420) +GATEWAY_PORTS = (9099, 9100, 9420) @dataclass(frozen=True) diff --git a/bot_bottle/backend/macos_container/cleanup.py b/bot_bottle/backend/macos_container/cleanup.py index 2dae5f8..bd7d5ee 100644 --- a/bot_bottle/backend/macos_container/cleanup.py +++ b/bot_bottle/backend/macos_container/cleanup.py @@ -9,7 +9,6 @@ from . import util as container_mod from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan _PREFIX = "bot-bottle-" -_BUNDLE_PREFIX = "bot-bottle-sidecars-" def _list_prefixed_containers() -> list[str]: @@ -24,7 +23,7 @@ def _list_prefixed_containers() -> list[str]: return [] return sorted( name for name in (line.strip() for line in result.stdout.splitlines()) - if name.startswith(_PREFIX) or name.startswith(_BUNDLE_PREFIX) + if name.startswith(_PREFIX) ) diff --git a/bot_bottle/backend/macos_container/egress_apply.py b/bot_bottle/backend/macos_container/egress_apply.py index a9c7df6..c9b6eda 100644 --- a/bot_bottle/backend/macos_container/egress_apply.py +++ b/bot_bottle/backend/macos_container/egress_apply.py @@ -1,36 +1,24 @@ -"""Host-side egress apply for the macos-container backend. +"""Host-side egress route-apply for the macos-container backend. -Uses `container kill --signal HUP` (Apple Container framework) instead -of `docker kill` to signal the sidecar bundle. +The per-bottle companion container this used to signal (`container kill +--signal HUP `) was removed in the de-sidecar cleanup (#385), +along with the disabled macOS launch path. Fails closed until the macOS +backend grows the consolidated gateway. """ from __future__ import annotations -import os -import subprocess - -from ...log import warn from ..egress_apply import EgressApplicator, EgressApplyError -from .launch import sidecar_container_name class MacOSContainerEgressApplicator(EgressApplicator): def _signal_bundle_reload(self, slug: str) -> None: - container = sidecar_container_name(slug) - result = subprocess.run( - ["container", "kill", "--signal", "HUP", container], - capture_output=True, text=True, check=False, env=os.environ, + del slug + raise EgressApplyError( + "live egress route-apply was removed with the per-bottle " + "companion container (#385); the macos-container backend is " + "disabled until it uses the consolidated gateway." ) - if result.returncode != 0: - last_error = (result.stderr or "").strip() or (result.stdout or "").strip() - warn( - f"egress: routes updated on disk for {slug}, but bundle reload failed: " - f"{last_error or 'container kill failed'}" - ) - raise EgressApplyError( - f"could not reload egress bundle {container}: " - f"{last_error or 'container kill failed'}" - ) applicator = MacOSContainerEgressApplicator() diff --git a/bot_bottle/backend/macos_container/enumerate.py b/bot_bottle/backend/macos_container/enumerate.py index b7d261d..27f8b8d 100644 --- a/bot_bottle/backend/macos_container/enumerate.py +++ b/bot_bottle/backend/macos_container/enumerate.py @@ -1,40 +1,14 @@ -"""Active-agent enumeration for the macOS Apple Container backend.""" +"""Active-agent enumeration for the macOS Apple Container backend. + +The backend is disabled during the de-sidecar cleanup (#385) — it can't +launch bottles, so there are none to enumerate. Enumeration returns when +the backend grows the consolidated gateway. +""" from __future__ import annotations -import subprocess - -from ...bottle_state import read_metadata from .. import ActiveAgent -_PREFIX = "bot-bottle-" -_SIDECAR_PREFIX = "bot-bottle-sidecars-" - def enumerate_active() -> list[ActiveAgent]: - result = subprocess.run( - ["container", "list", "--quiet"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - return [] - out: list[ActiveAgent] = [] - for name in sorted(line.strip() for line in result.stdout.splitlines()): - if not name.startswith(_PREFIX): - continue - if name.startswith(_SIDECAR_PREFIX): - continue - slug = name[len(_PREFIX):] - metadata = read_metadata(slug) - out.append(ActiveAgent( - backend_name="macos-container", - slug=slug, - agent_name=metadata.agent_name if metadata else "?", - started_at=metadata.started_at if metadata else "", - services=(), - label=metadata.label if metadata else "", - color=metadata.color if metadata else "", - )) - return out + return [] diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index 6e9034f..d6141f3 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -1,458 +1,39 @@ -"""Launch flow for the macOS Apple Container backend. +"""Launch flow for the macOS Apple Container backend — disabled (#385). -This backend keeps the explicit proxy-env enforcement model for v1: -the agent container is attached only to a host-only Apple Container -network, while the sidecar bundle is attached to a NAT network first -and the host-only network second. The sidecar's host-only IP is -discovered from `container inspect` and stamped into the agent's -HTTP_PROXY / HTTPS_PROXY env vars. +This backend launched a per-bottle companion container (the egress / +git-gate / supervise data plane) alongside the agent container, with the +agent's proxy env pointed at the companion's host-only IP. That +per-bottle-companion architecture was removed in the de-sidecar cleanup; +the macOS backend will be re-enabled once it grows the consolidated +per-host gateway the docker backend already uses. + +Until then, launching a macOS bottle fails closed. `prepare` / `status` +/ cleanup still work. """ from __future__ import annotations -import dataclasses -import os -import subprocess -from contextlib import ExitStack, contextmanager -from pathlib import Path +from contextlib import contextmanager from typing import Callable, Generator -from ...bottle_state import ( - egress_state_dir, - git_gate_state_dir, - read_committed_image, -) -from ...egress import ( - EGRESS_ROUTES_IN_CONTAINER, - egress_agent_env_entries, - egress_resolve_token_values, - egress_sidecar_env_entries, -) -from ...git_gate import ( - provision_git_gate_dynamic_keys, - revoke_git_gate_provisioned_keys, -) -from ...log import die, info, warn -from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT -from ...util import expand_tilde -from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT -from ..docker.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 ..docker.sidecar_bundle import ( - SIDECAR_BUNDLE_DOCKERFILE, - SIDECAR_BUNDLE_IMAGE, -) -from ..docker.egress import egress_tls_init -from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH -from . import util as container_mod +from ...log import die from .bottle import MacosContainerBottle from .bottle_plan import MacosContainerBottlePlan -_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) -_AGENT_SLEEP_SECONDS = "2147483647" -_GIT_HTTP_PORT = 9420 -_GIT_GATE_READY_FILE = "/run/git-gate/ready" - - -def internal_network_name(slug: str) -> str: - return f"bot-bottle-net-{slug}" - - -def egress_network_name(slug: str) -> str: - return f"bot-bottle-egress-{slug}" - - -def sidecar_container_name(slug: str) -> str: - return f"bot-bottle-sidecars-{slug}" - - @contextmanager def launch( plan: MacosContainerBottlePlan, *, provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None], ) -> Generator[MacosContainerBottle, None, None]: - """Build, run, provision, and yield an Apple Container bottle.""" - stack = ExitStack() - bottle_for_revoke = plan.manifest.bottle - git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) - - def teardown() -> None: - teardown_exc: BaseException | None = None - try: - stack.close() - except BaseException as exc: # noqa: W0718 - teardown must continue - teardown_exc = exc - warn(f"macos-container teardown failed: {exc!r}") - revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke) - if teardown_exc is not None: - raise teardown_exc - - try: - plan = _mint_certs(plan) - plan = _build_images(plan) - - internal_network = internal_network_name(plan.slug) - egress_network = egress_network_name(plan.slug) - _create_networks(internal_network, egress_network, stack) - - plan = _provision_git_gate_keys(plan) - - sidecar_name = sidecar_container_name(plan.slug) - container_mod.force_remove_container(sidecar_name) - _start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network) - stack.callback(container_mod.force_remove_container, sidecar_name) - _stage_git_gate(plan, sidecar_name) - - sidecar_ip = container_mod.container_ipv4_on_network( - sidecar_name, internal_network, - ) - plan = _stamp_agent_urls(plan, sidecar_ip) - - container_mod.force_remove_container(plan.container_name) - _start_agent(plan, internal_network, sidecar_ip) - stack.callback(container_mod.force_remove_container, plan.container_name) - - bottle = MacosContainerBottle( - plan.container_name, - teardown, - None, - agent_command=plan.agent_command, - agent_prompt_mode=plan.agent_prompt_mode, - agent_provider_template=plan.agent_provider_template, - terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name, - terminal_color=plan.spec.color, - agent_workdir=plan.workspace_plan.workdir, - ) - bottle.prompt_path = provision(plan, bottle) - - yield bottle - finally: - teardown() - - -def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan: - egress_ca_host, egress_ca_cert_only = egress_tls_init( - egress_state_dir(plan.slug), + """Fail closed: the macOS backend is disabled until it grows the + consolidated per-host gateway (the companion-container path it used + was removed in #385).""" + del plan, provision + die( + "the macos-container backend is temporarily disabled during the " + "companion-container removal (#385); it will return once it uses " + "the consolidated gateway. Use --backend=docker for now." ) - egress_plan = dataclasses.replace( - plan.egress_plan, - mitmproxy_ca_host_path=egress_ca_host, - mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, - ) - return dataclasses.replace(plan, egress_plan=egress_plan) - - -def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan: - container_mod.build_image( - SIDECAR_BUNDLE_IMAGE, - _REPO_DIR, - dockerfile=SIDECAR_BUNDLE_DOCKERFILE, - ) - committed = read_committed_image(plan.slug) - if committed and container_mod.image_exists(committed): - info(f"using committed image {committed!r}") - return dataclasses.replace( - plan, - agent_provision=dataclasses.replace( - plan.agent_provision, - image=committed, - ), - ) - container_mod.build_image( - plan.image, - _REPO_DIR, - dockerfile=plan.dockerfile_path, - ) - return plan - - -def _create_networks( - internal_network: str, - egress_network: str, - stack: ExitStack, -) -> None: - container_mod.create_network(internal_network, internal=True) - stack.callback(container_mod.remove_network, internal_network) - container_mod.create_network(egress_network) - stack.callback(container_mod.remove_network, egress_network) - - -def _start_sidecar_bundle( - plan: MacosContainerBottlePlan, - sidecar_name: str, - internal_network: str, - egress_network: str, -) -> None: - argv = _sidecar_run_argv(plan, sidecar_name, internal_network, egress_network) - effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env} - token_values = egress_resolve_token_values( - plan.egress_plan.token_env_map, effective_env, - ) - env = {**os.environ, **token_values} - info(f"container run sidecar bundle {sidecar_name}") - result = subprocess.run( - argv, capture_output=True, text=True, env=env, check=False, - ) - if result.returncode != 0: - die( - f"container run for sidecar bundle {sidecar_name} failed: " - f"{(result.stderr or '').strip() or ''}" - ) - - -def _start_agent( - plan: MacosContainerBottlePlan, - internal_network: str, - sidecar_ip: str, -) -> None: - argv = _agent_run_argv(plan, internal_network, sidecar_ip) - env = { - **os.environ, - **plan.forwarded_env, - } - info(f"container run agent {plan.container_name}") - result = subprocess.run( - argv, capture_output=True, text=True, env=env, check=False, - ) - if result.returncode != 0: - die( - f"container run for agent {plan.container_name} failed: " - f"{(result.stderr or '').strip() or ''}" - ) - - -def _stamp_agent_urls( - plan: MacosContainerBottlePlan, - sidecar_ip: str, -) -> MacosContainerBottlePlan: - proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}" - supervise_url = "" - if plan.supervise_plan is not None: - supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/" - git_gate_url = "" - if plan.git_gate_plan.upstreams: - git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}" - return dataclasses.replace( - plan, - agent_proxy_url=proxy_url, - agent_git_gate_url=git_gate_url, - agent_supervise_url=supervise_url, - ) - - -def _provision_git_gate_keys( - plan: MacosContainerBottlePlan, -) -> MacosContainerBottlePlan: - if not plan.git_gate_plan.upstreams: - return plan - git_gate_plan = provision_git_gate_dynamic_keys( - plan.manifest.bottle, - plan.git_gate_plan, - git_gate_state_dir(plan.slug), - ) - return dataclasses.replace(plan, git_gate_plan=git_gate_plan) - - -def _stage_git_gate(plan: MacosContainerBottlePlan, sidecar_name: str) -> None: - gp = plan.git_gate_plan - if not gp.upstreams: - return - - container_mod.exec_container( - sidecar_name, - [ - "mkdir", - "-p", - str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent), - GIT_GATE_CREDS_DIR_IN_CONTAINER, - "/git", - str(Path(_GIT_GATE_READY_FILE).parent), - ], - ) - - for host_path, container_path in _git_gate_files(plan): - container_mod.copy_into_container( - sidecar_name, host_path, container_path, - ) - - container_mod.exec_container( - sidecar_name, - [ - "sh", - "-c", - "chmod 755 " - f"{GIT_GATE_ENTRYPOINT_IN_CONTAINER} " - f"{GIT_GATE_HOOK_IN_CONTAINER} " - f"{GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && " - f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && " - f"touch {_GIT_GATE_READY_FILE}", - ], - ) - - -def _git_gate_files( - plan: MacosContainerBottlePlan, -) -> tuple[tuple[str, str], ...]: - gp = plan.git_gate_plan - files: list[tuple[str, str]] = [ - (str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER), - (str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER), - (str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER), - ] - for upstream in gp.upstreams: - files.append(( - expand_tilde(upstream.identity_file), - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key", - )) - if upstream.known_hosts_file: - files.append(( - str(upstream.known_hosts_file), - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts", - )) - return tuple(files) - - -def _sidecar_run_argv( - plan: MacosContainerBottlePlan, - sidecar_name: str, - internal_network: str, - egress_network: str, -) -> list[str]: - argv = [ - "container", "run", - "--name", sidecar_name, - "--detach", - "--rm", - "--network", egress_network, - "--network", internal_network, - "--dns", _sidecar_dns(), - "--env", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}", - ] - for entry in _sidecar_env_entries(plan): - argv += ["--env", entry] - for host_path, container_path, read_only in _sidecar_mounts(plan): - argv += ["--mount", _mount_spec(host_path, container_path, read_only)] - argv.append(SIDECAR_BUNDLE_IMAGE) - return argv - - -def _agent_run_argv( - plan: MacosContainerBottlePlan, - internal_network: str, - sidecar_ip: str, -) -> list[str]: - argv = [ - "container", "run", - "--name", plan.container_name, - "--detach", - "--network", internal_network, - ] - for entry in _agent_env_entries(plan, sidecar_ip): - argv += ["--env", entry] - argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS] - return argv - - -def _sidecar_dns() -> str: - return container_mod.dns_server() - - -def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]: - daemons = ["egress"] - if plan.git_gate_plan.upstreams: - daemons += ["git-gate", "git-http"] - if plan.supervise_plan is not None: - daemons.append("supervise") - return tuple(daemons) - - -def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]: - env: list[str] = list(egress_sidecar_env_entries(plan.egress_plan)) - if plan.git_gate_plan.upstreams: - env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}") - if plan.supervise_plan is not None: - env += [ - f"SUPERVISE_BOTTLE_SLUG={plan.slug}", - f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", - f"SUPERVISE_PORT={SUPERVISE_PORT}", - ] - return tuple(env) - - -def _sidecar_mounts( - plan: MacosContainerBottlePlan, -) -> tuple[tuple[str, str, bool], ...]: - mounts: list[tuple[str, str, bool]] = [] - - ep = plan.egress_plan - mounts.append(( - str(ep.mitmproxy_ca_host_path.parent), - str(Path(EGRESS_CA_IN_CONTAINER).parent), - False, - )) - if ep.routes: - mounts.append(( - str(ep.routes_path.parent), - str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), - True, - )) - - sp = plan.supervise_plan - if sp is not None: - # `container run --mount type=bind` only accepts directory - # sources (a file source fails with "is not a directory") — - # mount db_path's dedicated parent dir instead of the file - # itself, same as the CA/routes mounts above. - mounts.append(( - str(sp.db_path.parent), - str(Path(DB_PATH_IN_CONTAINER).parent), - False, - )) - - return tuple(mounts) - -def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str: - spec = f"type=bind,source={host_path},target={container_path}" - if read_only: - spec += ",readonly" - return spec - - -def _agent_env_entries( - plan: MacosContainerBottlePlan, - sidecar_ip: str, -) -> tuple[str, ...]: - proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}" - no_proxy = _agent_no_proxy(plan, sidecar_ip) - env = [ - 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}", - ] - if plan.agent_git_gate_url: - env.append(f"GIT_GATE_URL={plan.agent_git_gate_url}") - if plan.agent_supervise_url: - env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}") - for name, value in sorted(plan.agent_provision.guest_env.items()): - env.append(f"{name}={value}") - for name in sorted(plan.forwarded_env.keys()): - env.append(name) - env.extend(egress_agent_env_entries(plan.egress_plan)) - return tuple(env) - - -def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str: - hosts = ["localhost", "127.0.0.1", sidecar_ip] - return ",".join(hosts) + yield # unreachable — `die` raises; keeps this a generator/contextmanager diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index 98ea7bf..d3e4436 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -63,8 +63,8 @@ def _random_canary_env() -> str: return f"{first}_{second}_SECRET" -def egress_sidecar_env_entries(plan: "EgressPlan") -> tuple[str, ...]: - """Return sidecar env entries needed by egress across all backends.""" +def egress_gateway_env_entries(plan: "EgressPlan") -> tuple[str, ...]: + """Return gateway env entries needed by egress across all backends.""" env: list[str] = [] if plan.routes: env.extend(sorted(plan.token_env_map.keys())) @@ -412,6 +412,6 @@ __all__ = [ "egress_resolve_token_values", "egress_routes_for_bottle", "egress_agent_env_entries", - "egress_sidecar_env_entries", + "egress_gateway_env_entries", "egress_token_env_map", ] diff --git a/tests/integration/test_firecracker_launch.py b/tests/integration/test_firecracker_launch.py deleted file mode 100644 index 654e524..0000000 --- a/tests/integration/test_firecracker_launch.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Integration: Firecracker microVM launch. - -End-to-end against a real Firecracker microVM: prepare + launch a bottle -on the firecracker backend and verify the agent execs after provisioning -and that the egress proxy env is wired to the sidecar. - -Gated on the `backend status` result for firecracker (0 == the privileged -TAP pool + nft isolation table are provisioned). Skips cleanly with setup -instructions otherwise, so the suite runs on hosts without the pool. -""" - -from __future__ import annotations - -import contextlib -import io -import os -import shutil -import tempfile -import unittest -from pathlib import Path - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from bot_bottle.backend.firecracker import FirecrackerBottleBackend -from bot_bottle.manifest import ManifestIndex - - -def _firecracker_status_ok() -> bool: - """Gate on `./cli.py backend status --backend=firecracker`: a 0 exit - means the pool + nft table are ready. Output is captured so the - decorator stays quiet during collection; any error → not ready.""" - buf = io.StringIO() - try: - with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf): - return FirecrackerBottleBackend.status() == 0 - except Exception: - return False - - -_SKIP_MSG = ( - "firecracker backend not ready — provision the network pool with " - "`./cli.py backend setup --backend=firecracker`, then confirm with " - "`./cli.py backend status --backend=firecracker`" -) - - -def _minimal_agent_dockerfile(path: Path) -> None: - path.write_text( - "\n".join(( - "FROM node:22-slim", - "RUN apt-get update \\", - " && apt-get install -y --no-install-recommends \\", - " ca-certificates curl git \\", - " && rm -rf /var/lib/apt/lists/*", - "USER node", - "WORKDIR /home/node", - "CMD [\"sleep\", \"infinity\"]", - "", - )), - encoding="utf-8", - ) - - -def _minimal_manifest(dockerfile: Path) -> ManifestIndex: - return ManifestIndex.from_json_obj({ - "bottles": { - "dev": { - "agent_provider": { - "template": "pi", - "dockerfile": str(dockerfile), - "settings": { - "provider": "example", - "base_url": "https://example.com/v1", - "models": ["smoke"], - }, - }, - "egress": {"routes": [{"host": "example.com"}]}, - }, - }, - "agents": { - "demo": {"skills": [], "prompt": "smoke", "bottle": "dev"}, - }, - }) - - -@unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: cannot host Firecracker microVMs", -) -@unittest.skipUnless(_firecracker_status_ok(), _SKIP_MSG) -class TestFirecrackerLaunch(unittest.TestCase): - """Launch once, reuse the bottle across probes.""" - - @classmethod - def setUpClass(cls) -> None: - cls.stage = Path(tempfile.mkdtemp(prefix="cb-firecracker-launch.")) - cls._launch = None - cls.bottle = None - dockerfile = cls.stage / "Dockerfile.agent-smoke" - _minimal_agent_dockerfile(dockerfile) - os.environ["BOT_BOTTLE_BACKEND"] = "firecracker" - try: - backend = get_bottle_backend() - spec = BottleSpec( - manifest=_minimal_manifest(dockerfile), - agent_name="demo", - copy_cwd=False, - user_cwd=str(cls.stage), - ) - cls.plan = backend.prepare(spec, stage_dir=cls.stage) - cls._launch = backend.launch(cls.plan) - cls.bottle = cls._launch.__enter__() - except BaseException: - if cls._launch is not None: - cls._launch.__exit__(None, None, None) - shutil.rmtree(cls.stage, ignore_errors=True) - os.environ.pop("BOT_BOTTLE_BACKEND", None) - raise - - @classmethod - def tearDownClass(cls) -> None: - try: - if cls._launch is not None: - cls._launch.__exit__(None, None, None) - finally: - shutil.rmtree(cls.stage, ignore_errors=True) - os.environ.pop("BOT_BOTTLE_BACKEND", None) - - def test_smoke_exec_echo(self) -> None: - r = self.bottle.exec("echo hello-from-firecracker") # type: ignore[union-attr] - self.assertEqual(0, r.returncode, msg=r.stderr) - self.assertIn("hello-from-firecracker", r.stdout) - - def test_proxy_env_points_at_sidecar(self) -> None: - r = self.bottle.exec( # type: ignore[union-attr] - "printf '%s\\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\"" - ) - self.assertEqual(0, r.returncode, msg=r.stderr) - self.assertIn("http", r.stdout.lower()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_macos_container_launch.py b/tests/integration/test_macos_container_launch.py deleted file mode 100644 index 71678d5..0000000 --- a/tests/integration/test_macos_container_launch.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Integration: macOS Container launch topology. - -End-to-end against Apple's real `container` runtime. The smoke launches -a bottle with the experimental macOS Container backend and verifies the -properties that make the explicit-proxy launch acceptable: - - - the agent can exec commands after provisioning; - - HTTP(S)_PROXY points at the sidecar's internal-network IP; - - allowlisted HTTPS reaches the egress sidecar; - - direct egress with proxy env removed fails from the internal-only - agent network; - - non-allowlisted proxy traffic is blocked. - -Skipped under Gitea Actions and on hosts without Apple's `container`. -""" - -from __future__ import annotations - -import os -import platform -import shutil -import subprocess -import tempfile -import unittest -from pathlib import Path - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from bot_bottle.backend.macos_container.util import ( - dns_server as _container_dns_server, - is_available as _container_available, -) -from bot_bottle.manifest import ManifestIndex - - -_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief." - - -def _minimal_agent_dockerfile(path: Path) -> None: - path.write_text( - "\n".join(( - "FROM node:22-slim", - "RUN apt-get update \\", - " && apt-get install -y --no-install-recommends \\", - " ca-certificates curl git \\", - " && rm -rf /var/lib/apt/lists/*", - "USER node", - "WORKDIR /home/node", - "CMD [\"sleep\", \"infinity\"]", - "", - )), - encoding="utf-8", - ) - - -def _minimal_manifest(dockerfile: Path) -> ManifestIndex: - return ManifestIndex.from_json_obj({ - "bottles": { - "dev": { - "agent_provider": { - "template": "pi", - "dockerfile": str(dockerfile), - "settings": { - "provider": "example", - "base_url": "https://example.com/v1", - "models": ["smoke"], - }, - }, - "egress": { - "routes": [ - {"host": "example.com"}, - ], - }, - }, - }, - "agents": { - "demo": { - "skills": [], - "prompt": _AGENT_PROMPT, - "bottle": "dev", - }, - }, - }) - - -def _buildkit_dns_available() -> bool: - if platform.system() != "Darwin" or not _container_available(): - return False - stage = Path(tempfile.mkdtemp(prefix="cb-container-buildkit-dns.")) - image = "bot-bottle-buildkit-dns-check:latest" - try: - dockerfile = stage / "Dockerfile" - dockerfile.write_text( - "FROM debian:bookworm-slim\n" - "RUN getent hosts deb.debian.org\n", - encoding="utf-8", - ) - result = subprocess.run( - [ - "container", "build", - "--dns", _container_dns_server(), - "-t", image, - "-f", str(dockerfile), - str(stage), - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - return result.returncode == 0 - finally: - subprocess.run( - ["container", "image", "delete", image], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - shutil.rmtree(stage, ignore_errors=True) - - -@unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: cannot host Apple Container VMs", -) -@unittest.skipUnless( - platform.system() == "Darwin", - "Apple Container is macOS-only", -) -@unittest.skipUnless( - _container_available(), - "Apple Container not on PATH; install from " - "https://github.com/apple/container/releases", -) -@unittest.skipUnless( - _buildkit_dns_available(), - "Apple Container BuildKit cannot resolve deb.debian.org on this host", -) -class TestMacosContainerLaunch(unittest.TestCase): - """Launch once and reuse the bottle across probes.""" - - @classmethod - def setUpClass(cls) -> None: - cls.stage = Path(tempfile.mkdtemp(prefix="cb-macos-container-launch.")) - cls._launch = None - cls.bottle = None - dockerfile = cls.stage / "Dockerfile.agent-smoke" - _minimal_agent_dockerfile(dockerfile) - os.environ["BOT_BOTTLE_BACKEND"] = "macos-container" - try: - backend = get_bottle_backend() - spec = BottleSpec( - manifest=_minimal_manifest(dockerfile), - agent_name="demo", - copy_cwd=False, - user_cwd=str(cls.stage), - ) - cls.plan = backend.prepare(spec, stage_dir=cls.stage) - cls._launch = backend.launch(cls.plan) - cls.bottle = cls._launch.__enter__() - except BaseException: - if cls._launch is not None: - cls._launch.__exit__(None, None, None) - shutil.rmtree(cls.stage, ignore_errors=True) - os.environ.pop("BOT_BOTTLE_BACKEND", None) - raise - - @classmethod - def tearDownClass(cls) -> None: - try: - if cls._launch is not None: - cls._launch.__exit__(None, None, None) - finally: - shutil.rmtree(cls.stage, ignore_errors=True) - os.environ.pop("BOT_BOTTLE_BACKEND", None) - - def test_smoke_exec_echo(self): - r = self.bottle.exec( # type: ignore[union-attr] - "echo hello-from-macos-container" - ) - self.assertEqual(0, r.returncode, msg=r.stderr) - self.assertIn("hello-from-macos-container", r.stdout) - - def test_proxy_env_points_at_sidecar_internal_ip(self): - r = self.bottle.exec( # type: ignore[union-attr] - "printf '%s\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\" " - "\"$NO_PROXY\" \"$NODE_EXTRA_CA_CERTS\"" - ) - self.assertEqual(0, r.returncode, msg=r.stderr) - values = [line.strip() for line in r.stdout.splitlines()] - self.assertEqual(4, len(values), values) - self.assertEqual(values[0], values[1], values) - self.assertRegex(values[0], r"^http://[0-9.]+:9099$") - self.assertNotIn("127.0.0.1", values[0]) - sidecar_host = values[0].removeprefix("http://").removesuffix(":9099") - self.assertIn(sidecar_host, values[2]) - self.assertEqual( - "/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt", - values[3], - ) - - def test_allowlisted_https_reaches_egress_proxy(self): - r = self.bottle.exec( # type: ignore[union-attr] - "curl -fsS --max-time 20 https://example.com >/dev/null && echo OK" - ) - self.assertEqual(0, r.returncode, msg=r.stderr + r.stdout) - self.assertIn("OK", r.stdout) - - def test_direct_egress_bypass_without_proxy_fails(self): - r = self.bottle.exec( # type: ignore[union-attr] - "env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy " - "curl -s --show-error --max-time 5 https://example.com 2>&1 || true" - ) - self.assertTrue( - "refused" in r.stdout.lower() - or "timed out" in r.stdout.lower() - or "unreachable" in r.stdout.lower() - or "failed" in r.stdout.lower() - or "could not resolve" in r.stdout.lower() - or "connection reset" in r.stdout.lower(), - f"expected direct egress to fail; got: {r.stdout!r}", - ) - - def test_non_allowlisted_host_fails_through_proxy(self): - r = self.bottle.exec( # type: ignore[union-attr] - "curl -s --show-error --max-time 10 https://iana.org 2>&1 || true" - ) - self.assertTrue( - "403" in r.stdout - or "502" in r.stdout - or "blocked" in r.stdout.lower() - or "not allowed" in r.stdout.lower() - or "not in the bottle's egress.routes allowlist" in r.stdout.lower() - or "forbidden" in r.stdout.lower() - or "failed" in r.stdout.lower(), - f"expected non-allowlisted proxy request to fail; got: {r.stdout!r}", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_sidecar_bundle_compose.py b/tests/integration/test_sidecar_bundle_compose.py deleted file mode 100644 index b57600e..0000000 --- a/tests/integration/test_sidecar_bundle_compose.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Integration: end-to-end smoke for the PRD 0024 bundle shape. - -Verifies that flipping `BOT_BOTTLE_SIDECAR_BUNDLE=1` produces a -working bottle: `docker compose up` brings the agent + bundle pair -online, the daemons inside the bundle bind their ports, and the -agent can reach egress + supervise via the bundle's network -aliases (no agent-side config changes between flag positions). - -Skipped under GITEA_ACTIONS — the bundle image is a multi-stage -build pulling 200+MB of base layers, and the bind-mounts won't -share filesystem with the runner container. Same constraint as -the chunk-1 image-probe test. -""" - -from __future__ import annotations - -import os -import shutil -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from bot_bottle.manifest import ManifestIndex -from tests._docker import skip_unless_docker - - -def _manifest() -> ManifestIndex: - """Bottle with supervise on so the bundle exercises egress + - supervise. Git is off because a meaningful git-gate test needs - a real upstream and SSH keys — out of scope for a bundle smoke.""" - return ManifestIndex.from_json_obj({ - "bottles": { - "dev": { - "supervise": True, - }, - }, - "agents": { - "demo": {"skills": [], "prompt": "", "bottle": "dev"}, - }, - }) - - -@skip_unless_docker() -@unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: multi-stage bundle build pulls 200+MB " - "of base layers and bind-mounts don't share fs with the runner", -) -class TestSidecarBundleCompose(unittest.TestCase): - """One end-to-end pass with the bundle flag on. Skipping under - act_runner; the local docker daemon does the work.""" - - def test_bottle_up_with_bundle_flag_on(self): - stage_dir = Path(tempfile.mkdtemp(prefix="cb-bundle-smoke.")) - try: - with patch.dict(os.environ, {"BOT_BOTTLE_SIDECAR_BUNDLE": "1"}): - backend = get_bottle_backend("docker") - spec = BottleSpec( - manifest=_manifest(), - agent_name="demo", - copy_cwd=False, - user_cwd=str(stage_dir), - ) - plan = backend.prepare(spec, stage_dir=stage_dir) - with backend.launch(plan) as bottle: - # The agent's HTTPS_PROXY URL (resolved at - # renderer-time) should reach egress inside - # the bundle. A bare CONNECT with no upstream - # URL gets rejected with 400 or 405 but proves - # the listener is alive at the alias. - probe = bottle.exec( - "set -eu\n" - "echo HTTPS_PROXY=$HTTPS_PROXY\n" - "PORT=$(echo \"$HTTPS_PROXY\" | sed -E 's|.*:([0-9]+).*|\\1|')\n" - "HOST=$(echo \"$HTTPS_PROXY\" | sed -E 's|http://([^:]+):.*|\\1|')\n" - "echo HOST=$HOST PORT=$PORT\n" - "curl -sS --max-time 5 -o /dev/null -w 'http=%{http_code}\\n' " - " \"http://$HOST:$PORT/\" || true\n" - ) - # The supervise URL resolves to the same bundle - # via its supervise alias, on a different port. - supervise_probe = bottle.exec( - "set -eu\n" - "curl -sS --max-time 5 -o /dev/null " - " -w 'http=%{http_code}\\n' " - " \"http://supervise:9100/health\" || true\n" - ) - finally: - shutil.rmtree(stage_dir, ignore_errors=True) - - self.assertEqual(0, probe.returncode, msg=probe.stderr) - # egress answered SOMETHING — any 4xx is fine, just proves - # the egress daemon is listening at the proxy address. - self.assertIn("http=", probe.stdout, - f"no HTTP response from egress: {probe.stdout!r}") - # supervise's /health endpoint exists (PRD 0013); it should - # answer 200 or similar — anything non-empty proves the - # third daemon's alias resolves to the same bundle. - self.assertEqual(0, supervise_probe.returncode, msg=supervise_probe.stderr) - self.assertIn("http=", supervise_probe.stdout) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/_docker_bottle_plan.py b/tests/unit/_docker_bottle_plan.py new file mode 100644 index 0000000..e2126af --- /dev/null +++ b/tests/unit/_docker_bottle_plan.py @@ -0,0 +1,156 @@ +"""Shared in-memory `DockerBottlePlan` fixture for docker-backend tests. + +A fully-resolved plan with toggles for the conditional-service matrix +(git-gate / egress / supervise / canary). Consumed by the consolidated +compose tests; kept here (rather than in a test module) so it survives +independent of any one test file. +""" + +from __future__ import annotations + +from pathlib import Path + +from bot_bottle.agent_provider import AgentProvisionPlan +from bot_bottle.backend import BottleSpec +from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan +from bot_bottle.egress import EgressPlan, EgressRoute +from bot_bottle.git_gate import GitGatePlan, GitGateUpstream +from bot_bottle.manifest import ManifestIndex +from bot_bottle.supervise import SupervisePlan + + +SLUG = "demo-abc12" +STAGE = Path("/tmp/cb-stage") +STATE = Path("/tmp/cb-state") + + +def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex: + """Minimal manifest with the toggles the matrix needs. The renderer + only reads from the plan, not the manifest, so this is just here to + back BottleSpec.""" + bottle: dict[str, object] = {} + if supervise: + bottle["supervise"] = True + if with_git: + bottle["git-gate"] = {"repos": { + "upstream": { + "url": "ssh://git@example.com:22/x/y.git", + "key": {"provider": "static", "path": "/etc/hostname"}, + }, + }} + if with_egress: + bottle["egress"] = { + "routes": [{ + "host": "api.example", + "auth": {"scheme": "Bearer", "token_ref": "TOK"}, + }], + } + return ManifestIndex.from_json_obj({ + "bottles": {"dev": bottle}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + + +def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan: + return GitGatePlan( + slug=SLUG, + entrypoint_script=STATE / "git-gate" / "entrypoint.sh", + hook_script=STATE / "git-gate" / "pre-receive", + access_hook_script=STATE / "git-gate" / "access-hook", + upstreams=upstreams, + internal_network=f"bot-bottle-net-{SLUG}", + egress_network=f"bot-bottle-egress-{SLUG}", + ) + + +def _egress_plan( + routes: tuple[EgressRoute, ...] = (), + *, + canary: bool = False, +) -> EgressPlan: + token_env_map = { + r.token_env: r.token_ref + for r in routes + if r.token_env + } + return EgressPlan( + slug=SLUG, + routes_path=STATE / "egress" / "routes.yaml", + routes=routes, + token_env_map=token_env_map, + internal_network=f"bot-bottle-net-{SLUG}", + egress_network=f"bot-bottle-egress-{SLUG}", + mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem", + mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem", + canary="fake-canary-value" if canary else "", + canary_env="CANON_ALPHA_SECRET" if canary else "", + ) + + +def _supervise_plan() -> SupervisePlan: + return SupervisePlan( + slug=SLUG, + db_path=STATE / "bot-bottle.db", + internal_network=f"bot-bottle-net-{SLUG}", + ) + + +def _plan( + *, + with_git: bool = False, + with_egress: bool = False, + supervise: bool = False, + canary: bool = False, +) -> DockerBottlePlan: + """Build a fully-resolved DockerBottlePlan. Toggles cover the + matrix the renderer's conditional-service logic branches on.""" + upstreams: tuple[GitGateUpstream, ...] = () + if with_git: + upstreams = (GitGateUpstream( + name="upstream", + upstream_url="ssh://git@example.com:22/x/y.git", + upstream_host="example.com", + upstream_port="22", + identity_file="/etc/hostname", + known_host_key="", + known_hosts_file=STATE / "git-gate" / "upstream-known_hosts", + ),) + routes: tuple[EgressRoute, ...] = () + if with_egress: + routes = (EgressRoute( + host="api.example", + auth_scheme="Bearer", + token_env="EGRESS_TOKEN_0", + token_ref="TOK", + roles=(), + ),) + + index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress) + spec = BottleSpec( + manifest=index, + agent_name="demo", + copy_cwd=False, + user_cwd="/tmp/x", + ) + return DockerBottlePlan( + spec=spec, + manifest=index.load_for_agent("demo"), + stage_dir=STAGE, + slug=SLUG, + forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, + git_gate_plan=_git_gate_plan(upstreams), + egress_plan=_egress_plan(routes, canary=canary), + supervise_plan=_supervise_plan() if supervise else None, + use_runsc=False, + agent_provision=AgentProvisionPlan( + template="claude", + command="claude", + prompt_mode="append_file", + image="bot-bottle-claude:latest", + dockerfile="", + guest_home="/home/node", + instance_name=f"bot-bottle-{SLUG}", + prompt_file=STAGE / "prompt", + guest_env={}, + ), + ) diff --git a/tests/unit/test_consolidated_compose.py b/tests/unit/test_consolidated_compose.py index f463d33..47ea9a4 100644 --- a/tests/unit/test_consolidated_compose.py +++ b/tests/unit/test_consolidated_compose.py @@ -5,7 +5,7 @@ from __future__ import annotations import unittest from bot_bottle.backend.docker.consolidated_compose import consolidated_agent_compose -from tests.unit.test_compose import _plan +from tests.unit._docker_bottle_plan import _plan _GW = "172.18.0.2" _IP = "172.18.0.5" diff --git a/tests/unit/test_egress.py b/tests/unit/test_egress.py index 4b41afb..ee44f2a 100644 --- a/tests/unit/test_egress.py +++ b/tests/unit/test_egress.py @@ -16,7 +16,7 @@ from bot_bottle.egress import ( egress_render_routes, egress_resolve_token_values, egress_routes_for_bottle, - egress_sidecar_env_entries, + egress_gateway_env_entries, egress_token_env_map, ) from bot_bottle.errors import MissingEnvVarError @@ -603,7 +603,7 @@ class TestEgressEnvEntries(unittest.TestCase): "CANON_ALPHA_SECRET=fake-canary-value", "BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET", ), - egress_sidecar_env_entries(plan), + egress_gateway_env_entries(plan), ) def test_agent_entries_include_only_canary_bait(self): @@ -630,7 +630,7 @@ class TestEgressEnvEntries(unittest.TestCase): canary="fake-canary-value", ) - self.assertEqual((), egress_sidecar_env_entries(plan)) + self.assertEqual((), egress_gateway_env_entries(plan)) self.assertEqual((), egress_agent_env_entries(plan)) diff --git a/tests/unit/test_egress_apply.py b/tests/unit/test_egress_apply.py index 12d3b1a..203b606 100644 --- a/tests/unit/test_egress_apply.py +++ b/tests/unit/test_egress_apply.py @@ -70,32 +70,16 @@ class TestApplyRoutesChange(unittest.TestCase): self.addCleanup(self._tmp.cleanup) self.addCleanup(use_bottle_root(Path(self._tmp.name) / ".bot-bottle")) - def test_writes_live_routes_and_signals_reload(self): - calls: list[list[str]] = [] - - def fake_run(argv: list[str], **kwargs: object) -> SimpleNamespace: - calls.append(list(argv)) - return SimpleNamespace(returncode=0, stdout="", stderr="") - - with patch( - "bot_bottle.backend.docker.egress_apply.subprocess.run", - side_effect=fake_run, - ): - before, after = applicator.apply_routes_change( + def test_apply_routes_change_fails_closed_after_companion_removal(self): + # The per-bottle companion container that live route-apply used to + # signal was removed in the de-sidecar cleanup (#385); apply now + # fails closed until the gateway-side apply lands. + with self.assertRaises(EgressApplyError) as cm: + applicator.apply_routes_change( "dev", "routes:\n - host: google.com\n", ) - - self.assertEqual("", before) - self.assertEqual("routes:\n - host: google.com\n", after) - self.assertEqual( - "routes:\n - host: google.com\n", - (Path(self._tmp.name) / ".bot-bottle/state/dev/egress/routes.yaml").read_text(encoding="utf-8"), - ) - self.assertEqual( - ["docker", "kill", "--signal", "HUP", "bot-bottle-sidecars-dev"], - calls[0], - ) + self.assertIn("consolidated gateway", str(cm.exception)) if __name__ == "__main__": diff --git a/tests/unit/test_firecracker_cleanup.py b/tests/unit/test_firecracker_cleanup.py index d5e6018..27b0097 100644 --- a/tests/unit/test_firecracker_cleanup.py +++ b/tests/unit/test_firecracker_cleanup.py @@ -38,18 +38,6 @@ class TestOrphanEnumeration(unittest.TestCase): with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)): self.assertEqual([], fc_cleanup._orphan_vm_pids()) - def test_sidecar_containers_sorted(self): - with patch.object(fc_cleanup.subprocess, "run", - return_value=_proc("bot-bottle-sidecars-b\nbot-bottle-sidecars-a\n")): - self.assertEqual( - ["bot-bottle-sidecars-a", "bot-bottle-sidecars-b"], - fc_cleanup._sidecar_containers(), - ) - - def test_sidecar_containers_empty_on_failure(self): - with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)): - self.assertEqual([], fc_cleanup._sidecar_containers()) - def test_run_dirs_empty_when_absent(self): with patch.object(fc_cleanup.util, "cache_dir") as cache: cache.return_value.__truediv__.return_value.is_dir.return_value = False @@ -57,28 +45,23 @@ class TestOrphanEnumeration(unittest.TestCase): def test_prepare_cleanup_assembles_plan(self): with patch.object(fc_cleanup, "_orphan_vm_pids", return_value=[7]), \ - patch.object(fc_cleanup, "_sidecar_containers", return_value=["c1"]), \ patch.object(fc_cleanup, "_run_dirs", return_value=["/run/x"]): plan = fc_cleanup.prepare_cleanup() self.assertEqual((7,), plan.vm_pids) - self.assertEqual(("c1",), plan.containers) self.assertEqual(("/run/x",), plan.run_dirs) class TestCleanupRemoval(unittest.TestCase): - def test_cleanup_kills_removes_and_rmtrees(self): + def test_cleanup_kills_and_rmtrees(self): plan = FirecrackerBottleCleanupPlan( - vm_pids=(101,), containers=("bot-bottle-sidecars-x",), + vm_pids=(101,), run_dirs=("/run/dev-x",), ) with patch.object(fc_cleanup.os, "kill") as kill, \ - patch.object(fc_cleanup.subprocess, "run") as run, \ patch.object(fc_cleanup.shutil, "rmtree") as rmtree, \ patch.object(fc_cleanup, "info"): fc_cleanup.cleanup(plan) kill.assert_called_once() - run.assert_called_once() - self.assertIn("bot-bottle-sidecars-x", run.call_args.args[0]) rmtree.assert_called_once_with("/run/dev-x", ignore_errors=True) def test_cleanup_tolerates_dead_pid(self): @@ -101,13 +84,12 @@ class TestCleanupPlan(unittest.TestCase): def test_print_lists_resources(self): plan = FirecrackerBottleCleanupPlan( - vm_pids=(5,), containers=("c",), run_dirs=("/r",), + vm_pids=(5,), run_dirs=("/r",), ) with patch("bot_bottle.backend.firecracker.bottle_cleanup_plan.info") as info: plan.print() joined = " ".join(c.args[0] for c in info.call_args_list) self.assertIn("pid 5", joined) - self.assertIn("container: c", joined) self.assertIn("run dir: /r", joined) diff --git a/tests/unit/test_macos_container_cleanup.py b/tests/unit/test_macos_container_cleanup.py index fc2d980..8a736a0 100644 --- a/tests/unit/test_macos_container_cleanup.py +++ b/tests/unit/test_macos_container_cleanup.py @@ -43,27 +43,10 @@ class TestMacosContainerCleanup(unittest.TestCase): class TestMacosContainerEnumerate(unittest.TestCase): - def test_enumerate_active_reads_metadata(self): - completed = enum_mod.subprocess.CompletedProcess( - args=[], - returncode=0, - stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n", - stderr="", - ) - - class _Metadata: - agent_name = "impl" - started_at = "2026-06-10T00:00:00Z" - label = "Implement" - color = "blue" - - with patch.object(enum_mod.subprocess, "run", return_value=completed), \ - patch.object(enum_mod, "read_metadata", return_value=_Metadata()): - agents = enum_mod.enumerate_active() - self.assertEqual(1, len(agents)) - self.assertEqual("macos-container", agents[0].backend_name) - self.assertEqual("a", agents[0].slug) - self.assertEqual("impl", agents[0].agent_name) + def test_enumerate_active_is_empty_while_disabled(self): + # The macOS backend is disabled during the de-sidecar cleanup + # (#385); it launches nothing, so there is nothing to enumerate. + self.assertEqual([], enum_mod.enumerate_active()) if __name__ == "__main__": diff --git a/tests/unit/test_macos_container_launch.py b/tests/unit/test_macos_container_launch.py deleted file mode 100644 index 7dd9de7..0000000 --- a/tests/unit/test_macos_container_launch.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Unit: Apple Container launch argv construction.""" - -from __future__ import annotations - -import unittest -import tempfile -from pathlib import Path -from types import SimpleNamespace -from typing import cast -from unittest.mock import patch - -from bot_bottle.agent_provider import AgentProvisionPlan -from bot_bottle.backend import BottleSpec -from bot_bottle.backend.macos_container import launch -from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan -from bot_bottle.egress import EgressPlan -from bot_bottle.git_gate import GitGatePlan -from bot_bottle.manifest import ManifestIndex - -_MANIFEST = ManifestIndex.from_json_obj({ - "bottles": {"dev": {}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, -}).load_for_agent("demo") - - -def _plan( - *, - stage_dir: Path, - git: bool = False, - supervise: bool = False, - agent_git_gate_url: str = "", - agent_supervise_url: str = "", - canary: bool = False, -) -> MacosContainerBottlePlan: - routes_path = stage_dir / "routes.yaml" - routes_path.write_text("routes: []\n", encoding="utf-8") - ca_dir = stage_dir / "egress-ca" - ca_dir.mkdir(exist_ok=True) - ca_path = ca_dir / "mitmproxy-ca.pem" - ca_path.write_text("ca\n", encoding="utf-8") - egress_plan = SimpleNamespace( - mitmproxy_ca_host_path=ca_path, - routes_path=routes_path, - routes=("route",), - token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"}, - canary="fake-canary-value" if canary else "", - canary_env="CANON_ALPHA_SECRET" if canary else "", - ) - if git: - key_path = stage_dir / "origin-key" - key_path.write_text("key\n", encoding="utf-8") - known_hosts_path = stage_dir / "origin-known-hosts" - known_hosts_path.write_text("example.com ssh-ed25519 AAAA\n", encoding="utf-8") - entrypoint = stage_dir / "git_gate_entrypoint.sh" - entrypoint.write_text("#!/bin/sh\n", encoding="utf-8") - hook = stage_dir / "git_gate_pre_receive.sh" - hook.write_text("#!/bin/sh\n", encoding="utf-8") - access_hook = stage_dir / "git_gate_access_hook.sh" - access_hook.write_text("#!/bin/sh\n", encoding="utf-8") - upstream = SimpleNamespace( - name="origin", - identity_file=str(key_path), - known_hosts_file=known_hosts_path, - ) - git_gate_plan = SimpleNamespace( - upstreams=(upstream,), - entrypoint_script=entrypoint, - hook_script=hook, - access_hook_script=access_hook, - ) - else: - git_gate_plan = SimpleNamespace(upstreams=()) - supervise_plan = ( - SimpleNamespace( - db_path=Path("/state/bot-bottle.db"), - ) - if supervise else None - ) - agent_provision = SimpleNamespace( - guest_env={"LITERAL": "value"}, - provisioned_env={"CODEX_HOME": "/run/codex-home"}, - ) - return cast(MacosContainerBottlePlan, SimpleNamespace( - spec=SimpleNamespace(), - manifest=_MANIFEST, - stage_dir=stage_dir, - slug="dev-abc", - container_name="bot-bottle-dev-abc", - image="bot-bottle-agent:latest", - forwarded_env={"OAUTH_TOKEN": "host-value"}, - egress_plan=egress_plan, - git_gate_plan=git_gate_plan, - supervise_plan=supervise_plan, - agent_provision=agent_provision, - agent_git_gate_url=agent_git_gate_url, - agent_supervise_url=agent_supervise_url, - )) - - -class TestMacosContainerLaunchArgv(unittest.TestCase): - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.stage_dir = Path(self._tmp.name) - - def tearDown(self): - self._tmp.cleanup() - - def test_sidecar_argv_uses_egress_network_first_and_explicit_dns(self): - plan = _plan(stage_dir=self.stage_dir, supervise=True) - with patch.object(launch.os, "environ", { - "BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9", - }): - argv = launch._sidecar_run_argv( - plan, - "bot-bottle-sidecars-dev-abc", - "bot-bottle-net-dev-abc", - "bot-bottle-egress-dev-abc", - ) - self.assertEqual( - [ - "--network", "bot-bottle-egress-dev-abc", - "--network", "bot-bottle-net-dev-abc", - ], - argv[argv.index("--network"):argv.index("--dns")], - ) - self.assertIn("--dns", argv) - self.assertEqual("9.9.9.9", argv[argv.index("--dns") + 1]) - self.assertIn( - "BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise", - argv, - ) - self.assertIn("EGRESS_TOKEN_0", argv) - self.assertIn( - f"type=bind,source={self.stage_dir / 'egress-ca'},target=/home/mitmproxy/.mitmproxy", - argv, - ) - self.assertIn( - f"type=bind,source={self.stage_dir},target=/etc/egress,readonly", - argv, - ) - self.assertIn( - "type=bind,source=/state,target=/run/supervise", - argv, - ) - - def test_sidecar_argv_registers_canary_env_as_sensitive(self): - plan = _plan(stage_dir=self.stage_dir, canary=True) - argv = launch._sidecar_run_argv( - plan, - "bot-bottle-sidecars-dev-abc", - "bot-bottle-net-dev-abc", - "bot-bottle-egress-dev-abc", - ) - self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", argv) - self.assertIn("BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET", argv) - - def test_agent_argv_receives_canary_env(self): - plan = _plan(stage_dir=self.stage_dir, canary=True) - argv = launch._agent_run_argv( - plan, - "bot-bottle-net-dev-abc", - "192.0.2.10", - ) - self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", argv) - - def test_agent_env_points_proxy_at_sidecar_ip(self): - plan = _plan( - stage_dir=self.stage_dir, - agent_git_gate_url="http://192.168.128.2:9420", - agent_supervise_url="http://192.168.128.2:9100/", - ) - env = launch._agent_env_entries(plan, "192.168.128.2") - self.assertIn("HTTPS_PROXY=http://192.168.128.2:9099", env) - self.assertIn("HTTP_PROXY=http://192.168.128.2:9099", env) - self.assertIn("https_proxy=http://192.168.128.2:9099", env) - self.assertIn("http_proxy=http://192.168.128.2:9099", env) - self.assertIn("NO_PROXY=localhost,127.0.0.1,192.168.128.2", env) - self.assertIn("NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt", env) - self.assertIn("SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", env) - self.assertIn("REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt", env) - self.assertIn("GIT_GATE_URL=http://192.168.128.2:9420", env) - self.assertIn("MCP_SUPERVISE_URL=http://192.168.128.2:9100/", env) - self.assertIn("LITERAL=value", env) - self.assertIn("OAUTH_TOKEN", env) - self.assertNotIn("CODEX_HOME", env) - - def test_agent_run_uses_internal_network_only(self): - plan = _plan(stage_dir=self.stage_dir) - argv = launch._agent_run_argv( - plan, "bot-bottle-net-dev-abc", "192.168.128.2", - ) - self.assertIn("--network", argv) - self.assertEqual("bot-bottle-net-dev-abc", argv[argv.index("--network") + 1]) - self.assertNotIn("bot-bottle-egress-dev-abc", argv) - self.assertEqual(["bot-bottle-agent:latest", "sleep", "2147483647"], argv[-3:]) - - def test_git_gate_daemons_are_ready_gated(self): - plan = _plan(stage_dir=self.stage_dir, git=True) - self.assertEqual( - ("egress", "git-gate", "git-http"), - launch._sidecar_daemons(plan), - ) - self.assertIn( - "BOT_BOTTLE_GIT_GATE_READY_FILE=/run/git-gate/ready", - launch._sidecar_env_entries(plan), - ) - - def test_stamp_agent_urls_includes_git_http_when_git_gate_exists(self): - plan = _plan(stage_dir=self.stage_dir, git=True, supervise=True) - with patch.object(launch.dataclasses, "replace") as replace: - launch._stamp_agent_urls(plan, "192.168.128.2") - replace.assert_called_once_with( - plan, - agent_proxy_url="http://192.168.128.2:9099", - agent_git_gate_url="http://192.168.128.2:9420", - agent_supervise_url="http://192.168.128.2:9100/", - ) - - def test_macos_plan_uses_http_git_gate_rewrites(self): - base = _plan( - stage_dir=self.stage_dir, - git=True, - agent_git_gate_url="http://192.168.128.2:9420", - ) - plan = MacosContainerBottlePlan( - spec=base.spec, - manifest=base.manifest, - stage_dir=base.stage_dir, - git_gate_plan=base.git_gate_plan, - egress_plan=base.egress_plan, - supervise_plan=base.supervise_plan, - agent_provision=base.agent_provision, - slug=base.slug, - forwarded_env=base.forwarded_env, - agent_git_gate_url=base.agent_git_gate_url, - ) - self.assertEqual( - "192.168.128.2:9420", - plan.git_gate_insteadof_host, - ) - self.assertEqual("http", plan.git_gate_insteadof_scheme) - - def test_stage_git_gate_copies_files_and_releases_ready_marker(self): - plan = _plan(stage_dir=self.stage_dir, git=True) - with ( - patch.object(launch.container_mod, "exec_container") as exec_container, - patch.object(launch.container_mod, "copy_into_container") as copy_in, - ): - launch._stage_git_gate(plan, "sidecar") - - exec_container.assert_any_call( - "sidecar", - [ - "mkdir", - "-p", - "/etc/git-gate", - "/git-gate/creds", - "/git", - "/run/git-gate", - ], - ) - copied = [call.args for call in copy_in.call_args_list] - self.assertIn( - ( - "sidecar", - str(self.stage_dir / "git_gate_entrypoint.sh"), - "/git-gate-entrypoint.sh", - ), - copied, - ) - self.assertIn( - ( - "sidecar", - str(self.stage_dir / "origin-key"), - "/git-gate/creds/origin-key", - ), - copied, - ) - self.assertIn( - ( - "sidecar", - str(self.stage_dir / "origin-known-hosts"), - "/git-gate/creds/origin-known_hosts", - ), - copied, - ) - self.assertIn( - "touch /run/git-gate/ready", - exec_container.call_args_list[-1].args[1][-1], - ) - - -def _build_plan(stage_dir: Path) -> MacosContainerBottlePlan: - return MacosContainerBottlePlan( - spec=cast(BottleSpec, SimpleNamespace()), - manifest=_MANIFEST, - stage_dir=stage_dir, - git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())), - egress_plan=cast(EgressPlan, SimpleNamespace(canary="")), - supervise_plan=None, - agent_provision=AgentProvisionPlan( - template="claude", - command="claude", - prompt_mode="append_file", - image="bot-bottle-agent:latest", - dockerfile="/repo/Dockerfile", - guest_home="/home/node", - instance_name="bot-bottle-dev-abc", - prompt_file=stage_dir / "prompt.txt", - guest_env={}, - ), - slug="dev-abc", - forwarded_env={}, - ) - - -class TestMacosContainerLaunchCommittedImage(unittest.TestCase): - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.stage_dir = Path(self._tmp.name) - - def tearDown(self): - self._tmp.cleanup() - - def test_build_images_uses_committed_image_when_present(self): - plan = _build_plan(self.stage_dir) - calls = [] - - def fake_build(image: str, context: str, *, dockerfile: str = "") -> None: - calls.append((image, context, dockerfile)) - - with patch.object( - launch, "read_committed_image", - return_value="bot-bottle-committed-dev-abc:latest", - ), patch.object( - launch.container_mod, "image_exists", return_value=True, - ), patch.object( - launch.container_mod, "build_image", side_effect=fake_build, - ), patch.object(launch, "info"): - updated = launch._build_images(plan) - - self.assertEqual("bot-bottle-committed-dev-abc:latest", updated.image) - self.assertEqual(1, len(calls)) - self.assertEqual(launch.SIDECAR_BUNDLE_IMAGE, calls[0][0]) - - def test_build_images_builds_agent_when_committed_image_missing(self): - plan = _build_plan(self.stage_dir) - calls = [] - - def fake_build(image: str, context: str, *, dockerfile: str = "") -> None: - calls.append((image, context, dockerfile)) - - with patch.object( - launch, "read_committed_image", - return_value="bot-bottle-committed-dev-abc:latest", - ), patch.object( - launch.container_mod, "image_exists", return_value=False, - ), patch.object( - launch.container_mod, "build_image", side_effect=fake_build, - ): - updated = launch._build_images(plan) - - self.assertEqual("bot-bottle-agent:latest", updated.image) - self.assertEqual(2, len(calls)) - self.assertEqual("bot-bottle-agent:latest", calls[1][0]) - - -if __name__ == "__main__": - unittest.main()