feat(macos-container): launch explicit-proxy bottles
This commit is contained in:
@@ -79,3 +79,6 @@ class MacosContainerBottleBackend(
|
|||||||
|
|
||||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
||||||
return _enumerate.enumerate_active()
|
return _enumerate.enumerate_active()
|
||||||
|
|
||||||
|
def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str:
|
||||||
|
return plan.agent_supervise_url
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ from .. import BottlePlan
|
|||||||
class MacosContainerBottlePlan(BottlePlan):
|
class MacosContainerBottlePlan(BottlePlan):
|
||||||
slug: str
|
slug: str
|
||||||
forwarded_env: dict[str, str] = field(repr=False)
|
forwarded_env: dict[str, str] = field(repr=False)
|
||||||
|
agent_proxy_url: str = ""
|
||||||
|
agent_git_gate_url: str = ""
|
||||||
|
agent_supervise_url: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def container_name(self) -> str:
|
def container_name(self) -> str:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from ...bottle_state import read_metadata
|
|||||||
from .. import ActiveAgent
|
from .. import ActiveAgent
|
||||||
|
|
||||||
_PREFIX = "bot-bottle-"
|
_PREFIX = "bot-bottle-"
|
||||||
|
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
def enumerate_active() -> list[ActiveAgent]:
|
||||||
@@ -23,6 +24,8 @@ def enumerate_active() -> list[ActiveAgent]:
|
|||||||
for name in sorted(line.strip() for line in result.stdout.splitlines()):
|
for name in sorted(line.strip() for line in result.stdout.splitlines()):
|
||||||
if not name.startswith(_PREFIX):
|
if not name.startswith(_PREFIX):
|
||||||
continue
|
continue
|
||||||
|
if name.startswith(_SIDECAR_PREFIX):
|
||||||
|
continue
|
||||||
slug = name[len(_PREFIX):]
|
slug = name[len(_PREFIX):]
|
||||||
metadata = read_metadata(slug)
|
metadata = read_metadata(slug)
|
||||||
out.append(ActiveAgent(
|
out.append(ActiveAgent(
|
||||||
|
|||||||
@@ -1,34 +1,362 @@
|
|||||||
"""Launch flow for the macOS Apple Container backend.
|
"""Launch flow for the macOS Apple Container backend.
|
||||||
|
|
||||||
The backend is registered and its host primitives are implemented, but
|
This backend keeps the explicit proxy-env enforcement model for v1:
|
||||||
full launch is intentionally blocked until the sidecar network
|
the agent container is attached only to a host-only Apple Container
|
||||||
enforcement design is finished. Apple Container can publish ports and
|
network, while the sidecar bundle is attached to a NAT network first
|
||||||
create networks, but bot-bottle's Docker topology relies on an agent
|
and the host-only network second. The sidecar's host-only IP is
|
||||||
container attached only to an internal network while the sidecar bundle
|
discovered from `container inspect` and stamped into the agent's
|
||||||
also has egress. The first runnable version must preserve that
|
HTTP_PROXY / HTTPS_PROXY env vars.
|
||||||
no-direct-egress property.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import contextmanager
|
import dataclasses
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from contextlib import ExitStack, contextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...log import die
|
from ...bottle_state import egress_state_dir, git_gate_state_dir
|
||||||
|
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
|
||||||
|
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||||
|
from ...log import die, info, warn
|
||||||
|
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
||||||
|
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
|
||||||
|
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)
|
||||||
|
_SIDECAR_SLEEP_SECONDS = "2147483647"
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
||||||
del provision
|
"""Build, run, provision, and yield an Apple Container bottle."""
|
||||||
die(
|
stack = ExitStack()
|
||||||
"macos-container backend launch is not enabled yet: "
|
bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
"the backend primitives are present, but sidecar network "
|
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||||
"enforcement still needs implementation."
|
|
||||||
|
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:
|
||||||
|
_validate_supported_plan(plan)
|
||||||
|
plan = _mint_certs(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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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=plan.spec.label or 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),
|
||||||
)
|
)
|
||||||
yield # pragma: no cover
|
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 _validate_supported_plan(plan: MacosContainerBottlePlan) -> None:
|
||||||
|
if plan.git_gate_plan.upstreams:
|
||||||
|
die(
|
||||||
|
"macos-container backend launch does not support bottle.git yet: "
|
||||||
|
"Apple Container cannot bind-mount individual SSH key files, "
|
||||||
|
"and this backend will not mount broad host key directories. "
|
||||||
|
"Use docker/smolmachines for git-gate bottles until a safe key "
|
||||||
|
"delivery path lands."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_images(plan: MacosContainerBottlePlan) -> None:
|
||||||
|
container_mod.build_image(
|
||||||
|
SIDECAR_BUNDLE_IMAGE,
|
||||||
|
_REPO_DIR,
|
||||||
|
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
||||||
|
)
|
||||||
|
container_mod.build_image(
|
||||||
|
plan.image,
|
||||||
|
_REPO_DIR,
|
||||||
|
dockerfile=plan.dockerfile_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_networks(
|
||||||
|
internal_network: str,
|
||||||
|
egress_network: str,
|
||||||
|
stack: ExitStack,
|
||||||
|
) -> None:
|
||||||
|
container_mod.create_network(internal_network, internal=True)
|
||||||
|
stack.callback(container_mod.remove_network, internal_network)
|
||||||
|
container_mod.create_network(egress_network)
|
||||||
|
stack.callback(container_mod.remove_network, egress_network)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_sidecar_bundle(
|
||||||
|
plan: MacosContainerBottlePlan,
|
||||||
|
sidecar_name: str,
|
||||||
|
internal_network: str,
|
||||||
|
egress_network: str,
|
||||||
|
) -> None:
|
||||||
|
argv = _sidecar_run_argv(plan, sidecar_name, internal_network, egress_network)
|
||||||
|
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
|
||||||
|
token_values = egress_resolve_token_values(
|
||||||
|
plan.egress_plan.token_env_map, effective_env,
|
||||||
|
)
|
||||||
|
env = {**os.environ, **token_values}
|
||||||
|
info(f"container run sidecar bundle {sidecar_name}")
|
||||||
|
result = subprocess.run(
|
||||||
|
argv, capture_output=True, text=True, env=env, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(
|
||||||
|
f"container run for sidecar bundle {sidecar_name} failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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}/"
|
||||||
|
return dataclasses.replace(
|
||||||
|
plan,
|
||||||
|
agent_proxy_url=proxy_url,
|
||||||
|
agent_git_gate_url="",
|
||||||
|
agent_supervise_url=supervise_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
"--rm",
|
||||||
|
"--network", internal_network,
|
||||||
|
]
|
||||||
|
for entry in _agent_env_entries(plan, sidecar_ip):
|
||||||
|
argv += ["--env", entry]
|
||||||
|
argv += [plan.image, "sleep", _SIDECAR_SLEEP_SECONDS]
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_dns() -> str:
|
||||||
|
return os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "1.1.1.1")
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
||||||
|
daemons = ["egress"]
|
||||||
|
if plan.supervise_plan is not None:
|
||||||
|
daemons.append("supervise")
|
||||||
|
return tuple(daemons)
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
||||||
|
env: list[str] = []
|
||||||
|
if plan.egress_plan.routes:
|
||||||
|
env.extend(sorted(plan.egress_plan.token_env_map.keys()))
|
||||||
|
if plan.supervise_plan is not None:
|
||||||
|
env += [
|
||||||
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
|
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_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(_stage_routes_dir(plan)),
|
||||||
|
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
|
||||||
|
True,
|
||||||
|
))
|
||||||
|
|
||||||
|
sp = plan.supervise_plan
|
||||||
|
if sp is not None:
|
||||||
|
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
||||||
|
|
||||||
|
return tuple(mounts)
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_routes_dir(plan: MacosContainerBottlePlan) -> Path:
|
||||||
|
routes_dir = plan.stage_dir / "macos-container-egress"
|
||||||
|
routes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(
|
||||||
|
plan.egress_plan.routes_path,
|
||||||
|
routes_dir / Path(EGRESS_ROUTES_IN_CONTAINER).name,
|
||||||
|
)
|
||||||
|
return routes_dir
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
return tuple(env)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str:
|
||||||
|
hosts = ["localhost", "127.0.0.1", sidecar_ip]
|
||||||
|
return ",".join(hosts)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -71,6 +72,81 @@ def force_remove_container(name: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_network(name: str, *, internal: bool = False) -> None:
|
||||||
|
args = [
|
||||||
|
_CONTAINER, "network", "create",
|
||||||
|
"--label", "bot-bottle.backend=macos-container",
|
||||||
|
]
|
||||||
|
if internal:
|
||||||
|
args.append("--internal")
|
||||||
|
args.append(name)
|
||||||
|
result = subprocess.run(
|
||||||
|
args, capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return
|
||||||
|
if "already exists" in (result.stderr or "").lower():
|
||||||
|
return
|
||||||
|
die(
|
||||||
|
f"container network create {name} failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_network(name: str) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
[_CONTAINER, "network", "delete", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_container(name: str) -> dict[str, object]:
|
||||||
|
result = subprocess.run(
|
||||||
|
[_CONTAINER, "inspect", name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(
|
||||||
|
f"container inspect {name} failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = json.loads(result.stdout or "[]")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
die(f"container inspect {name} returned malformed JSON: {exc}")
|
||||||
|
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||||
|
return data[0]
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
die(f"container inspect {name} returned an unexpected shape")
|
||||||
|
raise AssertionError("unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
def container_ipv4_on_network(name: str, network: str) -> str:
|
||||||
|
data = inspect_container(name)
|
||||||
|
status = data.get("status")
|
||||||
|
networks = status.get("networks") if isinstance(status, dict) else None
|
||||||
|
if not isinstance(networks, list):
|
||||||
|
die(f"container inspect {name} did not include status.networks")
|
||||||
|
for entry in networks:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if entry.get("network") != network:
|
||||||
|
continue
|
||||||
|
raw = entry.get("ipv4Address")
|
||||||
|
if not isinstance(raw, str) or not raw:
|
||||||
|
die(f"container {name} has no IPv4 address on {network}")
|
||||||
|
return raw.split("/", 1)[0]
|
||||||
|
die(f"container {name} is not attached to network {network}")
|
||||||
|
raise AssertionError("unreachable")
|
||||||
|
|
||||||
|
|
||||||
def image_id(ref: str) -> str:
|
def image_id(ref: str) -> str:
|
||||||
"""Return the image digest/ID from `container image inspect`.
|
"""Return the image digest/ID from `container image inspect`.
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ class TestMacosContainerCleanup(unittest.TestCase):
|
|||||||
class TestMacosContainerEnumerate(unittest.TestCase):
|
class TestMacosContainerEnumerate(unittest.TestCase):
|
||||||
def test_enumerate_active_reads_metadata(self):
|
def test_enumerate_active_reads_metadata(self):
|
||||||
completed = enum_mod.subprocess.CompletedProcess(
|
completed = enum_mod.subprocess.CompletedProcess(
|
||||||
args=[], returncode=0, stdout="bot-bottle-a\nother\n", stderr="",
|
args=[],
|
||||||
|
returncode=0,
|
||||||
|
stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n",
|
||||||
|
stderr="",
|
||||||
)
|
)
|
||||||
|
|
||||||
class _Metadata:
|
class _Metadata:
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""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.backend.macos_container import launch
|
||||||
|
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
|
def _plan(
|
||||||
|
*,
|
||||||
|
stage_dir: Path,
|
||||||
|
git: bool = False,
|
||||||
|
supervise: bool = False,
|
||||||
|
agent_git_gate_url: str = "",
|
||||||
|
agent_supervise_url: str = "",
|
||||||
|
) -> MacosContainerBottlePlan:
|
||||||
|
routes_path = stage_dir / "source-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"},
|
||||||
|
)
|
||||||
|
if git:
|
||||||
|
upstream = SimpleNamespace(
|
||||||
|
name="origin",
|
||||||
|
identity_file="/host/key",
|
||||||
|
known_hosts_file=Path("/host/known_hosts"),
|
||||||
|
)
|
||||||
|
git_gate_plan = SimpleNamespace(
|
||||||
|
upstreams=(upstream,),
|
||||||
|
entrypoint_script=Path("/state/git/entrypoint.sh"),
|
||||||
|
hook_script=Path("/state/git/pre-receive"),
|
||||||
|
access_hook_script=Path("/state/git/access.sh"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
git_gate_plan = SimpleNamespace(upstreams=())
|
||||||
|
supervise_plan = (
|
||||||
|
SimpleNamespace(queue_dir=Path("/state/supervise/queue"))
|
||||||
|
if supervise else None
|
||||||
|
)
|
||||||
|
agent_provision = SimpleNamespace(
|
||||||
|
guest_env={"LITERAL": "value"},
|
||||||
|
provisioned_env={"CODEX_HOME": "/run/codex-home"},
|
||||||
|
)
|
||||||
|
return cast(MacosContainerBottlePlan, SimpleNamespace(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
routes_dir = self.stage_dir / "macos-container-egress"
|
||||||
|
self.assertIn(
|
||||||
|
f"type=bind,source={routes_dir},target=/etc/egress,readonly",
|
||||||
|
argv,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"routes: []\n",
|
||||||
|
(routes_dir / "routes.yaml").read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
|
||||||
|
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_is_blocked_until_safe_key_delivery_exists(self):
|
||||||
|
plan = _plan(stage_dir=self.stage_dir, git=True)
|
||||||
|
with patch.object(launch, "die", side_effect=SystemExit("die")):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
launch._validate_supported_plan(plan)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -55,6 +55,29 @@ class TestMacosContainerCommands(unittest.TestCase):
|
|||||||
with patch.object(util.subprocess, "run", return_value=completed):
|
with patch.object(util.subprocess, "run", return_value=completed):
|
||||||
self.assertEqual("sha256:abc", util.image_id("demo:latest"))
|
self.assertEqual("sha256:abc", util.image_id("demo:latest"))
|
||||||
|
|
||||||
|
def test_container_ipv4_on_network_reads_inspect_json(self):
|
||||||
|
payload = """[{
|
||||||
|
"status": {
|
||||||
|
"networks": [
|
||||||
|
{
|
||||||
|
"network": "bot-bottle-net-demo",
|
||||||
|
"ipv4Address": "192.168.128.2/24"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}]"""
|
||||||
|
completed = util.subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0, stdout=payload, stderr="",
|
||||||
|
)
|
||||||
|
with patch.object(util.subprocess, "run", return_value=completed):
|
||||||
|
self.assertEqual(
|
||||||
|
"192.168.128.2",
|
||||||
|
util.container_ipv4_on_network(
|
||||||
|
"bot-bottle-sidecars-demo",
|
||||||
|
"bot-bottle-net-demo",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user