fix(macos-container): support git-gate launch
test / unit (pull_request) Successful in 32s
test / integration (pull_request) Successful in 20s
lint / lint (push) Successful in 1m45s
prd-number / assign-numbers (push) Successful in 25s
test / unit (push) Successful in 32s
test / integration (push) Successful in 19s
Update Quality Badges / update-badges (push) Failing after 1m23s

This commit was merged in pull request #229.
This commit is contained in:
2026-06-10 21:53:22 -04:00
parent 932e71c0bf
commit bc9a22b46a
6 changed files with 280 additions and 24 deletions
@@ -44,3 +44,15 @@ class MacosContainerBottlePlan(BottlePlan):
@property @property
def agent_provider_template(self) -> str: def agent_provider_template(self) -> str:
return self.agent_provision.template return self.agent_provision.template
@property
def git_gate_insteadof_host(self) -> str:
if self.agent_git_gate_url.startswith("http://"):
return self.agent_git_gate_url.removeprefix("http://").rstrip("/")
return super().git_gate_insteadof_host
@property
def git_gate_insteadof_scheme(self) -> str:
if self.agent_git_gate_url.startswith("http://"):
return "http"
return super().git_gate_insteadof_scheme
+77 -13
View File
@@ -23,7 +23,14 @@ from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
from ...git_gate import revoke_git_gate_provisioned_keys from ...git_gate import revoke_git_gate_provisioned_keys
from ...log import die, info, warn from ...log import die, info, warn
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT 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.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 ( from ..docker.sidecar_bundle import (
SIDECAR_BUNDLE_DOCKERFILE, SIDECAR_BUNDLE_DOCKERFILE,
SIDECAR_BUNDLE_IMAGE, SIDECAR_BUNDLE_IMAGE,
@@ -37,6 +44,8 @@ from .bottle_plan import MacosContainerBottlePlan
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
_AGENT_SLEEP_SECONDS = "2147483647" _AGENT_SLEEP_SECONDS = "2147483647"
_GIT_HTTP_PORT = 9420
_GIT_GATE_READY_FILE = "/run/git-gate/ready"
def internal_network_name(slug: str) -> str: def internal_network_name(slug: str) -> str:
@@ -74,7 +83,6 @@ def launch(
raise teardown_exc raise teardown_exc
try: try:
_validate_supported_plan(plan)
plan = _mint_certs(plan) plan = _mint_certs(plan)
_build_images(plan) _build_images(plan)
@@ -86,6 +94,7 @@ def launch(
container_mod.force_remove_container(sidecar_name) container_mod.force_remove_container(sidecar_name)
_start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network) _start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network)
stack.callback(container_mod.force_remove_container, sidecar_name) stack.callback(container_mod.force_remove_container, sidecar_name)
_stage_git_gate(plan, sidecar_name)
sidecar_ip = container_mod.container_ipv4_on_network( sidecar_ip = container_mod.container_ipv4_on_network(
sidecar_name, internal_network, sidecar_name, internal_network,
@@ -126,17 +135,6 @@ def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
return dataclasses.replace(plan, egress_plan=egress_plan) 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: def _build_images(plan: MacosContainerBottlePlan) -> None:
container_mod.build_image( container_mod.build_image(
SIDECAR_BUNDLE_IMAGE, SIDECAR_BUNDLE_IMAGE,
@@ -213,14 +211,76 @@ def _stamp_agent_urls(
supervise_url = "" supervise_url = ""
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/" 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( return dataclasses.replace(
plan, plan,
agent_proxy_url=proxy_url, agent_proxy_url=proxy_url,
agent_git_gate_url="", agent_git_gate_url=git_gate_url,
agent_supervise_url=supervise_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( def _sidecar_run_argv(
plan: MacosContainerBottlePlan, plan: MacosContainerBottlePlan,
sidecar_name: str, sidecar_name: str,
@@ -269,6 +329,8 @@ def _sidecar_dns() -> str:
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]: def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
daemons = ["egress"] daemons = ["egress"]
if plan.git_gate_plan.upstreams:
daemons += ["git-gate", "git-http"]
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
daemons.append("supervise") daemons.append("supervise")
return tuple(daemons) return tuple(daemons)
@@ -278,6 +340,8 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
env: list[str] = [] env: list[str] = []
if plan.egress_plan.routes: if plan.egress_plan.routes:
env.extend(sorted(plan.egress_plan.token_env_map.keys())) 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: if plan.supervise_plan is not None:
env += [ env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
@@ -8,6 +8,7 @@ import ipaddress
import platform import platform
import shutil import shutil
import subprocess import subprocess
import time
from typing import Iterable from typing import Iterable
from ...log import die, info from ...log import die, info
@@ -213,6 +214,45 @@ def force_remove_container(name: str) -> None:
) )
def copy_into_container(name: str, host_path: str, container_path: str) -> None:
cmd = [_CONTAINER, "cp", host_path, f"{name}:{container_path}"]
result = _run_container_op(cmd)
if result.returncode != 0:
die(
f"container cp into {name}:{container_path} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def exec_container(name: str, argv: list[str]) -> None:
result = _run_container_op([_CONTAINER, "exec", name, *argv])
if result.returncode != 0:
die(
f"container exec in {name} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def _run_container_op(cmd: list[str]) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
)
for _ in range(19):
if result.returncode == 0:
return result
time.sleep(0.1)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
)
return result
def create_network(name: str, *, internal: bool = False) -> None: def create_network(name: str, *, internal: bool = False) -> None:
args = [ args = [
_CONTAINER, "network", "create", _CONTAINER, "network", "create",
+20 -2
View File
@@ -59,6 +59,7 @@ class _DaemonSpec:
# reads to inject `Authorization` headers on configured routes; # reads to inject `Authorization` headers on configured routes;
# no other daemon in the bundle should see these values. # no other daemon in the bundle should see these values.
_EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",) _EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",)
_READY_GATED_DAEMONS: tuple[str, ...] = ("git-gate", "git-http")
def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]: def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]:
@@ -82,6 +83,22 @@ _DAEMONS: tuple[_DaemonSpec, ...] = (
) )
def _argv_for_daemon(name: str, argv: Sequence[str], env: dict[str, str]) -> list[str]:
ready_file = env.get("BOT_BOTTLE_GIT_GATE_READY_FILE", "").strip()
if name not in _READY_GATED_DAEMONS or not ready_file:
return list(argv)
return [
"/bin/sh",
"-c",
"while [ ! -f \"$BOT_BOTTLE_GIT_GATE_READY_FILE\" ]; do "
"sleep 0.1; "
"done; "
"exec \"$@\"",
name,
*argv,
]
def _selected_daemons( def _selected_daemons(
env: dict[str, str], env: dict[str, str],
all_daemons: Sequence[_DaemonSpec] | None = None, all_daemons: Sequence[_DaemonSpec] | None = None,
@@ -118,12 +135,13 @@ def _pump(name: str, stream: IO[bytes]) -> None:
def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]: def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]:
env = _env_for_daemon(spec.name, dict(os.environ))
proc = subprocess.Popen( proc = subprocess.Popen(
list(spec.argv), _argv_for_daemon(spec.name, spec.argv, env),
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
bufsize=0, bufsize=0,
env=_env_for_daemon(spec.name, dict(os.environ)), env=env,
) )
threading.Thread( threading.Thread(
target=_pump, args=(spec.name, proc.stdout), daemon=True target=_pump, args=(spec.name, proc.stdout), daemon=True
+108 -9
View File
@@ -34,16 +34,26 @@ def _plan(
token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"}, token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"},
) )
if git: 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( upstream = SimpleNamespace(
name="origin", name="origin",
identity_file="/host/key", identity_file=str(key_path),
known_hosts_file=Path("/host/known_hosts"), known_hosts_file=known_hosts_path,
) )
git_gate_plan = SimpleNamespace( git_gate_plan = SimpleNamespace(
upstreams=(upstream,), upstreams=(upstream,),
entrypoint_script=Path("/state/git/entrypoint.sh"), entrypoint_script=entrypoint,
hook_script=Path("/state/git/pre-receive"), hook_script=hook,
access_hook_script=Path("/state/git/access.sh"), access_hook_script=access_hook,
) )
else: else:
git_gate_plan = SimpleNamespace(upstreams=()) git_gate_plan = SimpleNamespace(upstreams=())
@@ -56,6 +66,7 @@ def _plan(
provisioned_env={"CODEX_HOME": "/run/codex-home"}, provisioned_env={"CODEX_HOME": "/run/codex-home"},
) )
return cast(MacosContainerBottlePlan, SimpleNamespace( return cast(MacosContainerBottlePlan, SimpleNamespace(
spec=SimpleNamespace(),
stage_dir=stage_dir, stage_dir=stage_dir,
slug="dev-abc", slug="dev-abc",
container_name="bot-bottle-dev-abc", container_name="bot-bottle-dev-abc",
@@ -152,11 +163,99 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
self.assertNotIn("bot-bottle-egress-dev-abc", argv) self.assertNotIn("bot-bottle-egress-dev-abc", argv)
self.assertEqual(["bot-bottle-agent:latest", "sleep", "2147483647"], argv[-3:]) self.assertEqual(["bot-bottle-agent:latest", "sleep", "2147483647"], argv[-3:])
def test_git_gate_is_blocked_until_safe_key_delivery_exists(self): def test_git_gate_daemons_are_ready_gated(self):
plan = _plan(stage_dir=self.stage_dir, git=True) plan = _plan(stage_dir=self.stage_dir, git=True)
with patch.object(launch, "die", side_effect=SystemExit("die")): self.assertEqual(
with self.assertRaises(SystemExit): ("egress", "git-gate", "git-http"),
launch._validate_supported_plan(plan) 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,
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],
)
if __name__ == "__main__": if __name__ == "__main__":
+23
View File
@@ -21,6 +21,7 @@ from unittest.mock import patch
from bot_bottle.sidecar_init import ( from bot_bottle.sidecar_init import (
_DaemonSpec, _DaemonSpec,
_Supervisor, _Supervisor,
_argv_for_daemon,
_env_for_daemon, _env_for_daemon,
_selected_daemons, _selected_daemons,
) )
@@ -120,6 +121,28 @@ class TestSelectedDaemons(unittest.TestCase):
self.assertEqual([d.name for d in got], ["egress", "git-gate"]) self.assertEqual([d.name for d in got], ["egress", "git-gate"])
class TestDaemonArgv(unittest.TestCase):
def test_git_daemons_wait_for_ready_marker_when_configured(self):
argv = _argv_for_daemon(
"git-gate",
("/bin/sh", "/git-gate-entrypoint.sh"),
{"BOT_BOTTLE_GIT_GATE_READY_FILE": "/run/git-gate/ready"},
)
self.assertEqual("/bin/sh", argv[0])
self.assertEqual("-c", argv[1])
self.assertIn("BOT_BOTTLE_GIT_GATE_READY_FILE", argv[2])
self.assertEqual("git-gate", argv[3])
self.assertEqual(["/bin/sh", "/git-gate-entrypoint.sh"], argv[4:])
def test_non_git_daemon_does_not_wait_for_ready_marker(self):
argv = _argv_for_daemon(
"egress",
("/bin/sh", "/app/egress-entrypoint.sh"),
{"BOT_BOTTLE_GIT_GATE_READY_FILE": "/run/git-gate/ready"},
)
self.assertEqual(["/bin/sh", "/app/egress-entrypoint.sh"], argv)
class TestSupervisor(unittest.TestCase): class TestSupervisor(unittest.TestCase):
"""End-to-end: drive `_Supervisor` directly with fake commands. """End-to-end: drive `_Supervisor` directly with fake commands.
We don't go through `main()` because main installs signal We don't go through `main()` because main installs signal