fix(macos-container): support git-gate launch
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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 '<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:
|
||||
args = [
|
||||
_CONTAINER, "network", "create",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user