From 0e823d2aff0292b850bee0e19a238b0ed355f46e Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 10 Jun 2026 21:53:22 -0400 Subject: [PATCH] fix(macos-container): support git-gate launch --- .../backend/macos_container/bottle_plan.py | 12 ++ bot_bottle/backend/macos_container/launch.py | 90 ++++++++++++-- bot_bottle/backend/macos_container/util.py | 40 ++++++ bot_bottle/sidecar_init.py | 22 +++- tests/unit/test_macos_container_launch.py | 117 ++++++++++++++++-- tests/unit/test_sidecar_init.py | 23 ++++ 6 files changed, 280 insertions(+), 24 deletions(-) diff --git a/bot_bottle/backend/macos_container/bottle_plan.py b/bot_bottle/backend/macos_container/bottle_plan.py index d128d55..6d10a60 100644 --- a/bot_bottle/backend/macos_container/bottle_plan.py +++ b/bot_bottle/backend/macos_container/bottle_plan.py @@ -44,3 +44,15 @@ class MacosContainerBottlePlan(BottlePlan): @property def agent_provider_template(self) -> str: 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 diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index 59f5f95..f9fe7a7 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -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 ...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, @@ -37,6 +44,8 @@ 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: @@ -74,7 +83,6 @@ def launch( raise teardown_exc try: - _validate_supported_plan(plan) plan = _mint_certs(plan) _build_images(plan) @@ -86,6 +94,7 @@ def launch( 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, @@ -126,17 +135,6 @@ def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan: 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, @@ -213,14 +211,76 @@ def _stamp_agent_urls( 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="", + 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, @@ -269,6 +329,8 @@ def _sidecar_dns() -> str: 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) @@ -278,6 +340,8 @@ 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}", diff --git a/bot_bottle/backend/macos_container/util.py b/bot_bottle/backend/macos_container/util.py index 5ee92fe..d2c2a9b 100644 --- a/bot_bottle/backend/macos_container/util.py +++ b/bot_bottle/backend/macos_container/util.py @@ -8,6 +8,7 @@ import ipaddress import platform import shutil import subprocess +import time from typing import Iterable 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 ''}" + ) + + +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 ''}" + ) + + +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: args = [ _CONTAINER, "network", "create", diff --git a/bot_bottle/sidecar_init.py b/bot_bottle/sidecar_init.py index 9ea62b8..835c193 100644 --- a/bot_bottle/sidecar_init.py +++ b/bot_bottle/sidecar_init.py @@ -59,6 +59,7 @@ class _DaemonSpec: # reads to inject `Authorization` headers on configured routes; # no other daemon in the bundle should see these values. _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]: @@ -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( env: dict[str, str], 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]: + env = _env_for_daemon(spec.name, dict(os.environ)) proc = subprocess.Popen( - list(spec.argv), + _argv_for_daemon(spec.name, spec.argv, env), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0, - env=_env_for_daemon(spec.name, dict(os.environ)), + env=env, ) threading.Thread( target=_pump, args=(spec.name, proc.stdout), daemon=True diff --git a/tests/unit/test_macos_container_launch.py b/tests/unit/test_macos_container_launch.py index 27e41b6..948eadc 100644 --- a/tests/unit/test_macos_container_launch.py +++ b/tests/unit/test_macos_container_launch.py @@ -34,16 +34,26 @@ def _plan( token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"}, ) 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="/host/key", - known_hosts_file=Path("/host/known_hosts"), + identity_file=str(key_path), + known_hosts_file=known_hosts_path, ) 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"), + entrypoint_script=entrypoint, + hook_script=hook, + access_hook_script=access_hook, ) else: git_gate_plan = SimpleNamespace(upstreams=()) @@ -56,6 +66,7 @@ def _plan( provisioned_env={"CODEX_HOME": "/run/codex-home"}, ) return cast(MacosContainerBottlePlan, SimpleNamespace( + spec=SimpleNamespace(), stage_dir=stage_dir, slug="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.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) - with patch.object(launch, "die", side_effect=SystemExit("die")): - with self.assertRaises(SystemExit): - launch._validate_supported_plan(plan) + 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, + 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__": diff --git a/tests/unit/test_sidecar_init.py b/tests/unit/test_sidecar_init.py index cb77a35..b47f913 100644 --- a/tests/unit/test_sidecar_init.py +++ b/tests/unit/test_sidecar_init.py @@ -21,6 +21,7 @@ from unittest.mock import patch from bot_bottle.sidecar_init import ( _DaemonSpec, _Supervisor, + _argv_for_daemon, _env_for_daemon, _selected_daemons, ) @@ -120,6 +121,28 @@ class TestSelectedDaemons(unittest.TestCase): 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): """End-to-end: drive `_Supervisor` directly with fake commands. We don't go through `main()` because main installs signal