f2f82910c9
container stop was removing the container immediately (due to --rm) before container export could run. The force_remove_container teardown callback on the ExitStack already handles cleanup on normal exit, so --rm was redundant. Without it, the stopped container stays available for container export to snapshot.
441 lines
14 KiB
Python
441 lines
14 KiB
Python
"""Launch flow for the macOS Apple Container backend.
|
|
|
|
This backend keeps the explicit proxy-env enforcement model for v1:
|
|
the agent container is attached only to a host-only Apple Container
|
|
network, while the sidecar bundle is attached to a NAT network first
|
|
and the host-only network second. The sidecar's host-only IP is
|
|
discovered from `container inspect` and stamped into the agent's
|
|
HTTP_PROXY / HTTPS_PROXY env vars.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from contextlib import ExitStack, contextmanager
|
|
from pathlib import Path
|
|
from typing import Callable, Generator
|
|
|
|
from ...bottle_state import (
|
|
egress_state_dir,
|
|
git_gate_state_dir,
|
|
read_committed_image,
|
|
)
|
|
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_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 ...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_plan import MacosContainerBottlePlan
|
|
|
|
|
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
_AGENT_SLEEP_SECONDS = "2147483647"
|
|
_GIT_HTTP_PORT = 9420
|
|
_GIT_GATE_READY_FILE = "/run/git-gate/ready"
|
|
|
|
|
|
def internal_network_name(slug: str) -> str:
|
|
return f"bot-bottle-net-{slug}"
|
|
|
|
|
|
def egress_network_name(slug: str) -> str:
|
|
return f"bot-bottle-egress-{slug}"
|
|
|
|
|
|
def sidecar_container_name(slug: str) -> str:
|
|
return f"bot-bottle-sidecars-{slug}"
|
|
|
|
|
|
@contextmanager
|
|
def launch(
|
|
plan: MacosContainerBottlePlan,
|
|
*,
|
|
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
|
|
) -> Generator[MacosContainerBottle, None, None]:
|
|
"""Build, run, provision, and yield an Apple Container bottle."""
|
|
stack = ExitStack()
|
|
bottle_for_revoke = plan.manifest.bottle
|
|
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
|
|
|
def teardown() -> None:
|
|
teardown_exc: BaseException | None = None
|
|
try:
|
|
stack.close()
|
|
except BaseException as exc: # noqa: W0718 - teardown must continue
|
|
teardown_exc = exc
|
|
warn(f"macos-container teardown failed: {exc!r}")
|
|
revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke)
|
|
if teardown_exc is not None:
|
|
raise teardown_exc
|
|
|
|
try:
|
|
plan = _mint_certs(plan)
|
|
plan = _build_images(plan)
|
|
|
|
internal_network = internal_network_name(plan.slug)
|
|
egress_network = egress_network_name(plan.slug)
|
|
_create_networks(internal_network, egress_network, stack)
|
|
|
|
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(
|
|
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}/"
|
|
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 _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] = []
|
|
if plan.egress_plan.routes:
|
|
env.extend(sorted(plan.egress_plan.token_env_map.keys()))
|
|
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_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)
|