refactor(de-sidecar): remove the per-bottle companion-container architecture
The per-agent companion container (the egress/git-gate/supervise data plane run once per bottle) is the pre-consolidation architecture. Remove it and disable the backends that still depend on it, per the #385 thread. - Delete `backend/docker/sidecar_bundle.py`; docker's live path uses the consolidated shared gateway, not a per-bottle bundle. - Disable the firecracker and macos-container backends: their `launch()` fails closed (they launched a per-bottle companion; firecracker's consolidated relaunch is #354, macos follows). Their `enumerate` return empty and `cleanup` drop the companion-container discovery (firecracker keeps VMM/run-dir cleanup). - Fail-close both backends' `egress_apply` reload (it signalled the per-bottle container); consolidated egress policy resolves per-request against the orchestrator, so gateway-side apply is a follow-up. - Rename `egress_sidecar_env_entries` → `egress_gateway_env_entries`, `SIDECAR_PORTS` → `GATEWAY_PORTS`. - Move the shared DockerBottlePlan fixture to `tests/unit/_docker_bottle_plan.py`; delete tests for the removed launch paths; update cleanup/egress-apply tests. Docker consolidated launch verified end-to-end (multitenant isolation integration test passes). macos/firecracker are intentionally disabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -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,
|
The per-bottle companion container this used to signal (`docker kill
|
||||||
write it to the bottle's live egress state dir, and signal the sidecar
|
--signal HUP <container>`) was removed in the de-sidecar cleanup (#385).
|
||||||
bundle so the mitmproxy addon reloads it.
|
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
|
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 ..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):
|
class DockerEgressApplicator(EgressApplicator):
|
||||||
def _signal_bundle_reload(self, slug: str) -> None:
|
def _signal_bundle_reload(self, slug: str) -> None:
|
||||||
container = sidecar_bundle_container_name(slug)
|
del slug
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "kill", "--signal", "HUP", container],
|
|
||||||
capture_output=True, text=True, check=False, env=os.environ,
|
|
||||||
)
|
|
||||||
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(
|
raise EgressApplyError(
|
||||||
f"could not reload egress bundle {container}: "
|
"live egress route-apply was removed with the per-bottle "
|
||||||
f"{last_error or 'docker kill failed'}"
|
"companion container (#385); route changes will flow through "
|
||||||
|
"the consolidated gateway in a follow-up."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
applicator = DockerEgressApplicator()
|
applicator = DockerEgressApplicator()
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["DockerEgressApplicator", "EgressApplyError", "applicator"]
|
||||||
"DockerEgressApplicator",
|
|
||||||
"EgressApplyError",
|
|
||||||
"applicator",
|
|
||||||
"fetch_current_routes",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -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-<slug>`. 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}"
|
|
||||||
@@ -10,10 +10,9 @@ from .. import BottleCleanupPlan
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
|
class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
|
||||||
# PIDs of orphaned firecracker VMM processes and the sidecar
|
# PIDs of orphaned firecracker VMM processes and the per-bottle run
|
||||||
# containers left behind by previous bottles.
|
# dirs left behind by previous bottles.
|
||||||
vm_pids: tuple[int, ...] = ()
|
vm_pids: tuple[int, ...] = ()
|
||||||
containers: tuple[str, ...] = ()
|
|
||||||
run_dirs: tuple[str, ...] = ()
|
run_dirs: tuple[str, ...] = ()
|
||||||
|
|
||||||
def print(self) -> None:
|
def print(self) -> None:
|
||||||
@@ -22,11 +21,9 @@ class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
|
|||||||
return
|
return
|
||||||
for pid in self.vm_pids:
|
for pid in self.vm_pids:
|
||||||
info(f"firecracker VM process: pid {pid}")
|
info(f"firecracker VM process: pid {pid}")
|
||||||
for name in self.containers:
|
|
||||||
info(f"firecracker sidecar container: {name}")
|
|
||||||
for path in self.run_dirs:
|
for path in self.run_dirs:
|
||||||
info(f"firecracker run dir: {path}")
|
info(f"firecracker run dir: {path}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def empty(self) -> bool:
|
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)
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Cleanup for the Firecracker backend.
|
"""Cleanup for the Firecracker backend.
|
||||||
|
|
||||||
Orphans are: firecracker VMM processes whose config lives under our run
|
Orphans are: firecracker VMM processes whose config lives under our run
|
||||||
dir, the `bot-bottle-sidecars-*` containers, and the per-bottle run
|
dir, and the per-bottle run dirs. TAP slots free themselves (the flock
|
||||||
dirs. TAP slots free themselves (the flock drops when the launcher
|
drops when the launcher exits), so there is nothing to reclaim there.
|
||||||
exits), so there is nothing to reclaim there.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -18,8 +17,6 @@ from ...log import info
|
|||||||
from . import util
|
from . import util
|
||||||
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
||||||
|
|
||||||
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def _run_root() -> Path:
|
def _run_root() -> Path:
|
||||||
return util.cache_dir() / "run"
|
return util.cache_dir() / "run"
|
||||||
@@ -46,17 +43,6 @@ def _orphan_vm_pids() -> list[int]:
|
|||||||
return pids
|
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]:
|
def _run_dirs() -> list[str]:
|
||||||
run_root = _run_root()
|
run_root = _run_root()
|
||||||
if not run_root.is_dir():
|
if not run_root.is_dir():
|
||||||
@@ -67,7 +53,6 @@ def _run_dirs() -> list[str]:
|
|||||||
def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
|
def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
|
||||||
return FirecrackerBottleCleanupPlan(
|
return FirecrackerBottleCleanupPlan(
|
||||||
vm_pids=tuple(_orphan_vm_pids()),
|
vm_pids=tuple(_orphan_vm_pids()),
|
||||||
containers=tuple(_sidecar_containers()),
|
|
||||||
run_dirs=tuple(_run_dirs()),
|
run_dirs=tuple(_run_dirs()),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,12 +64,6 @@ def cleanup(plan: FirecrackerBottleCleanupPlan) -> None:
|
|||||||
os.kill(pid, signal.SIGTERM)
|
os.kill(pid, signal.SIGTERM)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
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:
|
for path in plan.run_dirs:
|
||||||
info(f"rm -rf {path}")
|
info(f"rm -rf {path}")
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
|||||||
@@ -1,43 +1,14 @@
|
|||||||
"""Active-agent enumeration for the Firecracker backend.
|
"""Active-agent enumeration for the Firecracker backend.
|
||||||
|
|
||||||
The agent runs in a VM (no container to list), so a live bottle is
|
The backend is disabled during the de-sidecar cleanup (#385) — it can't
|
||||||
identified by its running sidecar container `bot-bottle-sidecars-<slug>`
|
launch bottles, so there are none to enumerate. Real enumeration returns
|
||||||
— the same discovery-by-prefix the other backends use.
|
with the backend's consolidated relaunch (#354).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...bottle_state import read_metadata
|
|
||||||
from .. import ActiveAgent
|
from .. import ActiveAgent
|
||||||
|
|
||||||
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
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 []
|
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
|
|
||||||
|
|||||||
@@ -1,404 +1,39 @@
|
|||||||
"""Launch flow for the Firecracker backend.
|
"""Launch flow for the Firecracker backend — temporarily disabled (#385).
|
||||||
|
|
||||||
Per bottle:
|
The firecracker backend launched a per-bottle companion container (the
|
||||||
1. mint the egress CA, build the agent image (docker), export it to a
|
egress / git-gate / supervise data plane) alongside each microVM. That
|
||||||
cached ext4 rootfs;
|
per-bottle-companion architecture was removed in the de-sidecar cleanup;
|
||||||
2. claim a free TAP pool slot (rootless flock);
|
firecracker's replacement — the consolidated per-host gateway — lands in
|
||||||
3. bring up the Docker sidecar bundle, publishing egress / git-gate /
|
its own cutover (#354).
|
||||||
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.
|
|
||||||
|
|
||||||
Isolation is enforced by the operator-provisioned nft table (checked
|
Until that lands, launching a firecracker bottle fails closed rather than
|
||||||
fail-closed in preflight): a VM reaches only its sidecar (DNAT'd from
|
silently running the removed path. `prepare` / `status` / cleanup still
|
||||||
the host TAP IP) and nothing else. The agent's HTTPS_PROXY therefore
|
work, so `backend status --backend=firecracker` and orphan cleanup are
|
||||||
points at `http://<host_tap_ip>:9099`, its only route to the world.
|
unaffected.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
from contextlib import contextmanager
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from contextlib import ExitStack, contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...bottle_state import (
|
from ...log import die
|
||||||
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 .bottle import FirecrackerBottle
|
from .bottle import FirecrackerBottle
|
||||||
from .bottle_plan import FirecrackerBottlePlan
|
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
|
@contextmanager
|
||||||
def launch(
|
def launch(
|
||||||
plan: FirecrackerBottlePlan,
|
plan: FirecrackerBottlePlan,
|
||||||
*,
|
*,
|
||||||
provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None],
|
provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None],
|
||||||
) -> Generator[FirecrackerBottle, None, None]:
|
) -> Generator[FirecrackerBottle, None, None]:
|
||||||
stack = ExitStack()
|
"""Fail closed: the firecracker backend is disabled while its
|
||||||
bottle_for_revoke = plan.manifest.bottle
|
consolidated (gateway-backed) launch is built in #354."""
|
||||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
del plan, provision
|
||||||
|
die(
|
||||||
def teardown() -> None:
|
"the firecracker backend is temporarily disabled during the "
|
||||||
teardown_exc: BaseException | None = None
|
"companion-container removal (#385); its consolidated relaunch "
|
||||||
try:
|
"lands in #354. Use --backend=docker for now."
|
||||||
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)
|
yield # unreachable — `die` raises; keeps this a generator/contextmanager
|
||||||
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,
|
|
||||||
)
|
|
||||||
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 '<no stderr>'}")
|
|
||||||
|
|
||||||
|
|
||||||
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 '<no stderr>'}")
|
|
||||||
|
|
||||||
|
|
||||||
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 '<no stderr>'}")
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ def ip_base() -> str:
|
|||||||
# Sidecar ports the VM reaches at its host-side TAP IP. Kept in sync
|
# Sidecar ports the VM reaches at its host-side TAP IP. Kept in sync
|
||||||
# with the backend constants (egress 9099, supervise 9100, git-http
|
# with the backend constants (egress 9099, supervise 9100, git-http
|
||||||
# 9420); rendered into the setup output for operator visibility.
|
# 9420); rendered into the setup output for operator visibility.
|
||||||
SIDECAR_PORTS = (9099, 9100, 9420)
|
GATEWAY_PORTS = (9099, 9100, 9420)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from . import util as container_mod
|
|||||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
||||||
|
|
||||||
_PREFIX = "bot-bottle-"
|
_PREFIX = "bot-bottle-"
|
||||||
_BUNDLE_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def _list_prefixed_containers() -> list[str]:
|
def _list_prefixed_containers() -> list[str]:
|
||||||
@@ -24,7 +23,7 @@ def _list_prefixed_containers() -> list[str]:
|
|||||||
return []
|
return []
|
||||||
return sorted(
|
return sorted(
|
||||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,23 @@
|
|||||||
"""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
|
The per-bottle companion container this used to signal (`container kill
|
||||||
of `docker kill` to signal the sidecar bundle.
|
--signal HUP <container>`) 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...log import warn
|
|
||||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
from ..egress_apply import EgressApplicator, EgressApplyError
|
||||||
from .launch import sidecar_container_name
|
|
||||||
|
|
||||||
|
|
||||||
class MacOSContainerEgressApplicator(EgressApplicator):
|
class MacOSContainerEgressApplicator(EgressApplicator):
|
||||||
def _signal_bundle_reload(self, slug: str) -> None:
|
def _signal_bundle_reload(self, slug: str) -> None:
|
||||||
container = sidecar_container_name(slug)
|
del slug
|
||||||
result = subprocess.run(
|
|
||||||
["container", "kill", "--signal", "HUP", container],
|
|
||||||
capture_output=True, text=True, check=False, env=os.environ,
|
|
||||||
)
|
|
||||||
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(
|
raise EgressApplyError(
|
||||||
f"could not reload egress bundle {container}: "
|
"live egress route-apply was removed with the per-bottle "
|
||||||
f"{last_error or 'container kill failed'}"
|
"companion container (#385); the macos-container backend is "
|
||||||
|
"disabled until it uses the consolidated gateway."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...bottle_state import read_metadata
|
|
||||||
from .. import ActiveAgent
|
from .. import ActiveAgent
|
||||||
|
|
||||||
_PREFIX = "bot-bottle-"
|
|
||||||
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
def enumerate_active() -> list[ActiveAgent]:
|
||||||
result = subprocess.run(
|
|
||||||
["container", "list", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
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
|
|
||||||
|
|||||||
@@ -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:
|
This backend launched a per-bottle companion container (the egress /
|
||||||
the agent container is attached only to a host-only Apple Container
|
git-gate / supervise data plane) alongside the agent container, with the
|
||||||
network, while the sidecar bundle is attached to a NAT network first
|
agent's proxy env pointed at the companion's host-only IP. That
|
||||||
and the host-only network second. The sidecar's host-only IP is
|
per-bottle-companion architecture was removed in the de-sidecar cleanup;
|
||||||
discovered from `container inspect` and stamped into the agent's
|
the macOS backend will be re-enabled once it grows the consolidated
|
||||||
HTTP_PROXY / HTTPS_PROXY env vars.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
from contextlib import contextmanager
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from contextlib import ExitStack, contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...bottle_state import (
|
from ...log import die
|
||||||
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 .bottle import MacosContainerBottle
|
from .bottle import MacosContainerBottle
|
||||||
from .bottle_plan import MacosContainerBottlePlan
|
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
|
@contextmanager
|
||||||
def launch(
|
def launch(
|
||||||
plan: MacosContainerBottlePlan,
|
plan: MacosContainerBottlePlan,
|
||||||
*,
|
*,
|
||||||
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
|
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
|
||||||
) -> Generator[MacosContainerBottle, None, None]:
|
) -> Generator[MacosContainerBottle, None, None]:
|
||||||
"""Build, run, provision, and yield an Apple Container bottle."""
|
"""Fail closed: the macOS backend is disabled until it grows the
|
||||||
stack = ExitStack()
|
consolidated per-host gateway (the companion-container path it used
|
||||||
bottle_for_revoke = plan.manifest.bottle
|
was removed in #385)."""
|
||||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
del plan, provision
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
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(
|
die(
|
||||||
f"container run for sidecar bundle {sidecar_name} failed: "
|
"the macos-container backend is temporarily disabled during the "
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
"companion-container removal (#385); it will return once it uses "
|
||||||
|
"the consolidated gateway. Use --backend=docker for now."
|
||||||
)
|
)
|
||||||
|
yield # unreachable — `die` raises; keeps this a generator/contextmanager
|
||||||
|
|
||||||
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 '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ def _random_canary_env() -> str:
|
|||||||
return f"{first}_{second}_SECRET"
|
return f"{first}_{second}_SECRET"
|
||||||
|
|
||||||
|
|
||||||
def egress_sidecar_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
|
def egress_gateway_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
|
||||||
"""Return sidecar env entries needed by egress across all backends."""
|
"""Return gateway env entries needed by egress across all backends."""
|
||||||
env: list[str] = []
|
env: list[str] = []
|
||||||
if plan.routes:
|
if plan.routes:
|
||||||
env.extend(sorted(plan.token_env_map.keys()))
|
env.extend(sorted(plan.token_env_map.keys()))
|
||||||
@@ -412,6 +412,6 @@ __all__ = [
|
|||||||
"egress_resolve_token_values",
|
"egress_resolve_token_values",
|
||||||
"egress_routes_for_bottle",
|
"egress_routes_for_bottle",
|
||||||
"egress_agent_env_entries",
|
"egress_agent_env_entries",
|
||||||
"egress_sidecar_env_entries",
|
"egress_gateway_env_entries",
|
||||||
"egress_token_env_map",
|
"egress_token_env_map",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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={},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from bot_bottle.backend.docker.consolidated_compose import consolidated_agent_compose
|
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"
|
_GW = "172.18.0.2"
|
||||||
_IP = "172.18.0.5"
|
_IP = "172.18.0.5"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from bot_bottle.egress import (
|
|||||||
egress_render_routes,
|
egress_render_routes,
|
||||||
egress_resolve_token_values,
|
egress_resolve_token_values,
|
||||||
egress_routes_for_bottle,
|
egress_routes_for_bottle,
|
||||||
egress_sidecar_env_entries,
|
egress_gateway_env_entries,
|
||||||
egress_token_env_map,
|
egress_token_env_map,
|
||||||
)
|
)
|
||||||
from bot_bottle.errors import MissingEnvVarError
|
from bot_bottle.errors import MissingEnvVarError
|
||||||
@@ -603,7 +603,7 @@ class TestEgressEnvEntries(unittest.TestCase):
|
|||||||
"CANON_ALPHA_SECRET=fake-canary-value",
|
"CANON_ALPHA_SECRET=fake-canary-value",
|
||||||
"BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET",
|
"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):
|
def test_agent_entries_include_only_canary_bait(self):
|
||||||
@@ -630,7 +630,7 @@ class TestEgressEnvEntries(unittest.TestCase):
|
|||||||
canary="fake-canary-value",
|
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))
|
self.assertEqual((), egress_agent_env_entries(plan))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -70,32 +70,16 @@ class TestApplyRoutesChange(unittest.TestCase):
|
|||||||
self.addCleanup(self._tmp.cleanup)
|
self.addCleanup(self._tmp.cleanup)
|
||||||
self.addCleanup(use_bottle_root(Path(self._tmp.name) / ".bot-bottle"))
|
self.addCleanup(use_bottle_root(Path(self._tmp.name) / ".bot-bottle"))
|
||||||
|
|
||||||
def test_writes_live_routes_and_signals_reload(self):
|
def test_apply_routes_change_fails_closed_after_companion_removal(self):
|
||||||
calls: list[list[str]] = []
|
# The per-bottle companion container that live route-apply used to
|
||||||
|
# signal was removed in the de-sidecar cleanup (#385); apply now
|
||||||
def fake_run(argv: list[str], **kwargs: object) -> SimpleNamespace:
|
# fails closed until the gateway-side apply lands.
|
||||||
calls.append(list(argv))
|
with self.assertRaises(EgressApplyError) as cm:
|
||||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
applicator.apply_routes_change(
|
||||||
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.docker.egress_apply.subprocess.run",
|
|
||||||
side_effect=fake_run,
|
|
||||||
):
|
|
||||||
before, after = applicator.apply_routes_change(
|
|
||||||
"dev",
|
"dev",
|
||||||
"routes:\n - host: google.com\n",
|
"routes:\n - host: google.com\n",
|
||||||
)
|
)
|
||||||
|
self.assertIn("consolidated gateway", str(cm.exception))
|
||||||
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],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -38,18 +38,6 @@ class TestOrphanEnumeration(unittest.TestCase):
|
|||||||
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)):
|
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)):
|
||||||
self.assertEqual([], fc_cleanup._orphan_vm_pids())
|
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):
|
def test_run_dirs_empty_when_absent(self):
|
||||||
with patch.object(fc_cleanup.util, "cache_dir") as cache:
|
with patch.object(fc_cleanup.util, "cache_dir") as cache:
|
||||||
cache.return_value.__truediv__.return_value.is_dir.return_value = False
|
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):
|
def test_prepare_cleanup_assembles_plan(self):
|
||||||
with patch.object(fc_cleanup, "_orphan_vm_pids", return_value=[7]), \
|
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"]):
|
patch.object(fc_cleanup, "_run_dirs", return_value=["/run/x"]):
|
||||||
plan = fc_cleanup.prepare_cleanup()
|
plan = fc_cleanup.prepare_cleanup()
|
||||||
self.assertEqual((7,), plan.vm_pids)
|
self.assertEqual((7,), plan.vm_pids)
|
||||||
self.assertEqual(("c1",), plan.containers)
|
|
||||||
self.assertEqual(("/run/x",), plan.run_dirs)
|
self.assertEqual(("/run/x",), plan.run_dirs)
|
||||||
|
|
||||||
|
|
||||||
class TestCleanupRemoval(unittest.TestCase):
|
class TestCleanupRemoval(unittest.TestCase):
|
||||||
def test_cleanup_kills_removes_and_rmtrees(self):
|
def test_cleanup_kills_and_rmtrees(self):
|
||||||
plan = FirecrackerBottleCleanupPlan(
|
plan = FirecrackerBottleCleanupPlan(
|
||||||
vm_pids=(101,), containers=("bot-bottle-sidecars-x",),
|
vm_pids=(101,),
|
||||||
run_dirs=("/run/dev-x",),
|
run_dirs=("/run/dev-x",),
|
||||||
)
|
)
|
||||||
with patch.object(fc_cleanup.os, "kill") as kill, \
|
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.shutil, "rmtree") as rmtree, \
|
||||||
patch.object(fc_cleanup, "info"):
|
patch.object(fc_cleanup, "info"):
|
||||||
fc_cleanup.cleanup(plan)
|
fc_cleanup.cleanup(plan)
|
||||||
kill.assert_called_once()
|
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)
|
rmtree.assert_called_once_with("/run/dev-x", ignore_errors=True)
|
||||||
|
|
||||||
def test_cleanup_tolerates_dead_pid(self):
|
def test_cleanup_tolerates_dead_pid(self):
|
||||||
@@ -101,13 +84,12 @@ class TestCleanupPlan(unittest.TestCase):
|
|||||||
|
|
||||||
def test_print_lists_resources(self):
|
def test_print_lists_resources(self):
|
||||||
plan = FirecrackerBottleCleanupPlan(
|
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:
|
with patch("bot_bottle.backend.firecracker.bottle_cleanup_plan.info") as info:
|
||||||
plan.print()
|
plan.print()
|
||||||
joined = " ".join(c.args[0] for c in info.call_args_list)
|
joined = " ".join(c.args[0] for c in info.call_args_list)
|
||||||
self.assertIn("pid 5", joined)
|
self.assertIn("pid 5", joined)
|
||||||
self.assertIn("container: c", joined)
|
|
||||||
self.assertIn("run dir: /r", joined)
|
self.assertIn("run dir: /r", joined)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,27 +43,10 @@ class TestMacosContainerCleanup(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMacosContainerEnumerate(unittest.TestCase):
|
class TestMacosContainerEnumerate(unittest.TestCase):
|
||||||
def test_enumerate_active_reads_metadata(self):
|
def test_enumerate_active_is_empty_while_disabled(self):
|
||||||
completed = enum_mod.subprocess.CompletedProcess(
|
# The macOS backend is disabled during the de-sidecar cleanup
|
||||||
args=[],
|
# (#385); it launches nothing, so there is nothing to enumerate.
|
||||||
returncode=0,
|
self.assertEqual([], enum_mod.enumerate_active())
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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()
|
|
||||||
Reference in New Issue
Block a user