diff --git a/bot_bottle/backend/docker/consolidated_launch.py b/bot_bottle/backend/docker/consolidated_launch.py index 391f545d..bce49ab6 100644 --- a/bot_bottle/backend/docker/consolidated_launch.py +++ b/bot_bottle/backend/docker/consolidated_launch.py @@ -26,7 +26,7 @@ from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME from ...orchestrator.reprovision import reprovision_bottles from ..consolidated_util import provision_bottle from ..consolidated_util import teardown_consolidated as _teardown_util -from .gateway_provision import DockerGatewayTransport +from .gateway_transport import DockerGatewayTransport from .gateway_net import next_free_ip diff --git a/bot_bottle/backend/docker/gateway.py b/bot_bottle/backend/docker/gateway.py index a0de4674..1cac5d32 100644 --- a/bot_bottle/backend/docker/gateway.py +++ b/bot_bottle/backend/docker/gateway.py @@ -5,7 +5,7 @@ import time from pathlib import Path from .util import run_docker -from .gateway_provision import DockerGatewayTransport +from .gateway_transport import DockerGatewayTransport from ...paths import ( ORCHESTRATOR_AUTH_JWT_ENV, host_gateway_ca_dir, diff --git a/bot_bottle/backend/docker/gateway_provision.py b/bot_bottle/backend/docker/gateway_provision.py index 2b579130..d17d8fbc 100644 --- a/bot_bottle/backend/docker/gateway_provision.py +++ b/bot_bottle/backend/docker/gateway_provision.py @@ -16,7 +16,6 @@ from __future__ import annotations import re -from .util import run_docker from ...git_gate import GitGatePlan, git_gate_render_provision from ...gateway import GatewayProvisionError, GatewayTransport @@ -27,27 +26,6 @@ from ...gateway import GatewayProvisionError, GatewayTransport _SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+") -class DockerGatewayTransport: - """`GatewayTransport` for the docker gateway container (exec/cp).""" - - def __init__(self, gateway: str) -> None: - self.gateway = gateway - - def exec(self, argv: list[str]) -> None: - proc = run_docker(["docker", "exec", self.gateway, *argv]) - if proc.returncode != 0: - raise GatewayProvisionError( - f"gateway exec {argv!r} failed: {proc.stderr.strip()}" - ) - - def cp_into(self, src: str, dest: str) -> None: - proc = run_docker(["docker", "cp", src, f"{self.gateway}:{dest}"]) - if proc.returncode != 0: - raise GatewayProvisionError( - f"gateway cp {src} -> {dest} failed: {proc.stderr.strip()}" - ) - - def _require_safe(bottle_id: str) -> None: if not _SAFE_BOTTLE_ID.fullmatch(bottle_id): raise GatewayProvisionError(f"unsafe bottle id {bottle_id!r}") @@ -109,5 +87,5 @@ def deprovision_git_gate(transport: GatewayTransport, bottle_id: str) -> None: __all__ = [ "provision_git_gate", "deprovision_git_gate", - "GatewayProvisionError", "GatewayTransport", "DockerGatewayTransport", + "GatewayProvisionError", "GatewayTransport", ] diff --git a/bot_bottle/backend/docker/gateway_transport.py b/bot_bottle/backend/docker/gateway_transport.py new file mode 100644 index 00000000..c618b672 --- /dev/null +++ b/bot_bottle/backend/docker/gateway_transport.py @@ -0,0 +1,35 @@ +"""The `GatewayTransport` for the docker gateway container (PRD 0070). + +How the launcher stages files + runs commands in the running gateway container: +`docker exec` / `docker cp` over the docker socket. The backend-neutral +provisioning logic that drives it lives in `gateway_provision`. +""" + +from __future__ import annotations + +from .util import run_docker +from ...gateway import GatewayProvisionError + + +class DockerGatewayTransport: + """`GatewayTransport` for the docker gateway container (exec/cp).""" + + def __init__(self, gateway: str) -> None: + self.gateway = gateway + + def exec(self, argv: list[str]) -> None: + proc = run_docker(["docker", "exec", self.gateway, *argv]) + if proc.returncode != 0: + raise GatewayProvisionError( + f"gateway exec {argv!r} failed: {proc.stderr.strip()}" + ) + + def cp_into(self, src: str, dest: str) -> None: + proc = run_docker(["docker", "cp", src, f"{self.gateway}:{dest}"]) + if proc.returncode != 0: + raise GatewayProvisionError( + f"gateway cp {src} -> {dest} failed: {proc.stderr.strip()}" + ) + + +__all__ = ["DockerGatewayTransport"] diff --git a/bot_bottle/backend/firecracker/gateway.py b/bot_bottle/backend/firecracker/gateway.py index 23a0778e..97d9bb6c 100644 --- a/bot_bottle/backend/firecracker/gateway.py +++ b/bot_bottle/backend/firecracker/gateway.py @@ -20,22 +20,18 @@ Orchestrator service lands and `infra_vm` is dissolved. from __future__ import annotations -import os -import shlex -import stat import subprocess -from pathlib import Path from urllib.parse import urlparse from ...gateway import ( DEFAULT_CA_TIMEOUT_SECONDS, Gateway, GatewayError, - GatewayProvisionError, GatewayTransport, ) from .. import util as backend_util from . import infra_vm, netpool, util +from .gateway_transport import FirecrackerGatewayTransport # The gateway microVM's name (the `bb_role=gateway` boot tag / run dir). Fixed # per host — one gateway VM, shared by every agent VM. @@ -57,40 +53,6 @@ _GATEWAY_CA_PATH = "/home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem" _CA_FETCH_TIMEOUT_SECONDS = 15.0 -class SshGatewayTransport: - """`GatewayTransport` for the gateway running in the gateway VM — the docker - exec/cp equivalents over SSH (dropbear + the stable infra key).""" - - def __init__(self, private_key: Path, guest_ip: str) -> None: - self._key = private_key - self._ip = guest_ip - - def exec(self, argv: list[str]) -> None: - proc = subprocess.run( - util.ssh_base_argv(self._key, self._ip) + [shlex.join(argv)], - capture_output=True, text=True, timeout=60, check=False, - ) - if proc.returncode != 0: - raise GatewayProvisionError( - f"infra gateway exec {argv!r} failed: {proc.stderr.strip()}") - - def cp_into(self, src: str, dest: str) -> None: - # Preserve the source mode (docker cp does): the access-hook is staged - # 0700 and git-http execs it directly — a plain `cat >` would land it - # 0644 and the exec fails with EACCES; keys stay 0600. - mode = stat.S_IMODE(os.stat(src).st_mode) - q = shlex.quote(dest) - proc = subprocess.run( - util.ssh_base_argv(self._key, self._ip) - + [f"cat > {q} && chmod {mode:o} {q}"], - input=Path(src).read_bytes(), capture_output=True, timeout=30, check=False, - ) - if proc.returncode != 0: - raise GatewayProvisionError( - f"infra gateway cp {src} -> {dest} failed: " - f"{proc.stderr.decode(errors='replace').strip()}") - - class FirecrackerGateway(Gateway): """The consolidated gateway as a Firecracker microVM on the gateway link. @@ -188,8 +150,8 @@ class FirecrackerGateway(Gateway): deploy keys through (over SSH to the gateway VM). Needs no live handle — built from the stable key + the gateway link's guest IP, so teardown can use it too.""" - return SshGatewayTransport( + return FirecrackerGatewayTransport( infra_vm._infra_dir() / "id_ed25519", netpool.gw_slot().guest_ip) -__all__ = ["FirecrackerGateway", "SshGatewayTransport", "GATEWAY_NAME"] +__all__ = ["FirecrackerGateway", "FirecrackerGatewayTransport", "GATEWAY_NAME"] diff --git a/bot_bottle/backend/firecracker/gateway_transport.py b/bot_bottle/backend/firecracker/gateway_transport.py new file mode 100644 index 00000000..5da338b7 --- /dev/null +++ b/bot_bottle/backend/firecracker/gateway_transport.py @@ -0,0 +1,56 @@ +"""The `GatewayTransport` for the gateway running in the gateway microVM +(PRD 0070). + +How the launcher stages files + runs commands in the running gateway: the +docker exec/cp equivalents over SSH (dropbear + the stable infra key). The +backend-neutral provisioning logic that drives it lives in +`backend.docker.gateway_provision`. +""" + +from __future__ import annotations + +import os +import shlex +import stat +import subprocess +from pathlib import Path + +from ...gateway import GatewayProvisionError +from . import util + + +class FirecrackerGatewayTransport: + """`GatewayTransport` for the gateway running in the gateway VM — the docker + exec/cp equivalents over SSH (dropbear + the stable infra key).""" + + def __init__(self, private_key: Path, guest_ip: str) -> None: + self._key = private_key + self._ip = guest_ip + + def exec(self, argv: list[str]) -> None: + proc = subprocess.run( + util.ssh_base_argv(self._key, self._ip) + [shlex.join(argv)], + capture_output=True, text=True, timeout=60, check=False, + ) + if proc.returncode != 0: + raise GatewayProvisionError( + f"infra gateway exec {argv!r} failed: {proc.stderr.strip()}") + + def cp_into(self, src: str, dest: str) -> None: + # Preserve the source mode (docker cp does): the access-hook is staged + # 0700 and git-http execs it directly — a plain `cat >` would land it + # 0644 and the exec fails with EACCES; keys stay 0600. + mode = stat.S_IMODE(os.stat(src).st_mode) + q = shlex.quote(dest) + proc = subprocess.run( + util.ssh_base_argv(self._key, self._ip) + + [f"cat > {q} && chmod {mode:o} {q}"], + input=Path(src).read_bytes(), capture_output=True, timeout=30, check=False, + ) + if proc.returncode != 0: + raise GatewayProvisionError( + f"infra gateway cp {src} -> {dest} failed: " + f"{proc.stderr.decode(errors='replace').strip()}") + + +__all__ = ["FirecrackerGatewayTransport"] diff --git a/bot_bottle/backend/macos_container/consolidated_launch.py b/bot_bottle/backend/macos_container/consolidated_launch.py index a641a9b6..e197a2ba 100644 --- a/bot_bottle/backend/macos_container/consolidated_launch.py +++ b/bot_bottle/backend/macos_container/consolidated_launch.py @@ -47,7 +47,7 @@ from ..consolidated_util import ( from . import util as container_mod from .enumerate import CONTAINER_NAME_PREFIX, EnumerationError, enumerate_active from .gateway import GATEWAY_NETWORK -from .gateway_provision import AppleGatewayTransport +from .gateway_transport import MacosGatewayTransport from .infra import MacosInfraService, OrchestratorStartError @@ -172,7 +172,7 @@ def register_agent( except (OrchestratorClientError, EnumerationError) as e: info(f"registry reconciliation skipped: {e}") reg = provision_bottle( - client, source_ip, egress_plan, git_gate_plan, AppleGatewayTransport(), + client, source_ip, egress_plan, git_gate_plan, MacosGatewayTransport(), image_ref=image_ref, tokens=tokens, env_var_secret=env_var_secret, ) @@ -193,7 +193,7 @@ def teardown_consolidated( """Deregister the bottle and remove its git-gate state from the gateway. Both steps are idempotent so this is safe from a cleanup trap. Does NOT stop the gateway — it's a persistent per-host singleton.""" - _teardown_util(bottle_id, AppleGatewayTransport(), + _teardown_util(bottle_id, MacosGatewayTransport(), orchestrator_url=orchestrator_url, timeout=timeout) diff --git a/bot_bottle/backend/macos_container/gateway.py b/bot_bottle/backend/macos_container/gateway.py index 02ad93a7..26c6ec23 100644 --- a/bot_bottle/backend/macos_container/gateway.py +++ b/bot_bottle/backend/macos_container/gateway.py @@ -184,10 +184,10 @@ class MacosGateway(Gateway): def provisioning_transport(self) -> GatewayTransport: """The exec/cp transport git-gate provisioning stages per-bottle repos + deploy keys through (over the `container` CLI).""" - # Local import: gateway_provision imports GATEWAY_NAME from this module, - # so importing AppleGatewayTransport at module scope would cycle. - from .gateway_provision import AppleGatewayTransport - return AppleGatewayTransport(self.name) + # Local import: gateway_transport imports GATEWAY_NAME from this module, + # so importing MacosGatewayTransport at module scope would cycle. + from .gateway_transport import MacosGatewayTransport + return MacosGatewayTransport(self.name) __all__ = [ diff --git a/bot_bottle/backend/macos_container/gateway_provision.py b/bot_bottle/backend/macos_container/gateway_transport.py similarity index 61% rename from bot_bottle/backend/macos_container/gateway_provision.py rename to bot_bottle/backend/macos_container/gateway_transport.py index 54c07f6f..9aa58b81 100644 --- a/bot_bottle/backend/macos_container/gateway_provision.py +++ b/bot_bottle/backend/macos_container/gateway_transport.py @@ -1,10 +1,9 @@ -"""`GatewayTransport` for the Apple infra container (PRD 0070). +"""The `GatewayTransport` for the Apple gateway container (PRD 0070). -The provisioning *logic* (per-bottle creds dirs, namespaced repo init) is -backend-neutral and lives in `backend.docker.gateway_provision`; this is only -the transport — how files and commands reach the running gateway. Docker uses -`docker exec`/`docker cp` and Firecracker uses SSH; Apple uses the `container` -CLI's equivalents against the infra container that hosts the gateway daemons. +How the launcher stages files + runs commands in the running gateway container: +the `container` CLI's exec/cp equivalents. The backend-neutral provisioning +logic that drives it lives in `backend.docker.gateway_provision`; Docker uses +`docker exec`/`docker cp` and Firecracker uses SSH. """ from __future__ import annotations @@ -14,8 +13,8 @@ from . import util as container_mod from .gateway import GATEWAY_NAME -class AppleGatewayTransport: - """`GatewayTransport` for the gateway daemons in the Apple infra container.""" +class MacosGatewayTransport: + """`GatewayTransport` for the gateway daemons in the Apple gateway container.""" def __init__(self, gateway: str = GATEWAY_NAME) -> None: self.gateway = gateway @@ -41,4 +40,4 @@ class AppleGatewayTransport: ) -__all__ = ["AppleGatewayTransport", "GatewayProvisionError"] +__all__ = ["MacosGatewayTransport"] diff --git a/tests/unit/test_firecracker_gateway.py b/tests/unit/test_firecracker_gateway.py index ae1aeec4..1a52ad0d 100644 --- a/tests/unit/test_firecracker_gateway.py +++ b/tests/unit/test_firecracker_gateway.py @@ -18,11 +18,14 @@ from bot_bottle.backend.firecracker import infra_vm from bot_bottle.backend.firecracker.gateway import ( GATEWAY_NAME, FirecrackerGateway, - SshGatewayTransport, +) +from bot_bottle.backend.firecracker.gateway_transport import ( + FirecrackerGatewayTransport, ) from bot_bottle.gateway import GatewayError, GatewayProvisionError _GW = "bot_bottle.backend.firecracker.gateway" +_GW_TRANSPORT = "bot_bottle.backend.firecracker.gateway_transport" _ORCH_URL = "http://10.243.255.1:8099" _TOKEN = "gw-jwt-token" @@ -115,24 +118,24 @@ class TestFirecrackerGatewaySurface(unittest.TestCase): def test_provisioning_transport_targets_the_gateway_link(self) -> None: gw = FirecrackerGateway() transport = gw.provisioning_transport() - assert isinstance(transport, SshGatewayTransport) + assert isinstance(transport, FirecrackerGatewayTransport) self.assertEqual(infra_vm.netpool.gw_slot().guest_ip, transport._ip) -class TestSshGatewayTransport(unittest.TestCase): +class TestFirecrackerGatewayTransport(unittest.TestCase): def test_cp_into_preserves_source_mode(self) -> None: with tempfile.NamedTemporaryFile() as f: os.chmod(f.name, 0o700) # like the staged access-hook - t = SshGatewayTransport(Path("/k"), "10.0.0.1") - with patch(f"{_GW}.subprocess.run", + t = FirecrackerGatewayTransport(Path("/k"), "10.0.0.1") + with patch(f"{_GW_TRANSPORT}.subprocess.run", return_value=CompletedProcess([], 0)) as run: t.cp_into(f.name, "/etc/git-gate/access-hook") remote_cmd = run.call_args.args[0][-1] self.assertIn("chmod 700", remote_cmd) # exec bit preserved over SSH def test_exec_raises_on_failure(self) -> None: - t = SshGatewayTransport(Path("/k"), "10.0.0.1") - with patch(f"{_GW}.subprocess.run", + t = FirecrackerGatewayTransport(Path("/k"), "10.0.0.1") + with patch(f"{_GW_TRANSPORT}.subprocess.run", return_value=CompletedProcess([], 1, stderr="nope")), \ self.assertRaises(GatewayProvisionError): t.exec(["mkdir", "-p", "/git-gate"]) diff --git a/tests/unit/test_gateway_provision.py b/tests/unit/test_gateway_provision.py index e2470387..d2361cee 100644 --- a/tests/unit/test_gateway_provision.py +++ b/tests/unit/test_gateway_provision.py @@ -8,13 +8,13 @@ from unittest.mock import Mock, patch from bot_bottle.backend.docker.gateway_provision import ( GatewayProvisionError, - DockerGatewayTransport, deprovision_git_gate, provision_git_gate, ) +from bot_bottle.backend.docker.gateway_transport import DockerGatewayTransport from bot_bottle.git_gate import GitGatePlan, GitGateUpstream -_RUN = "bot_bottle.backend.docker.gateway_provision.run_docker" +_RUN = "bot_bottle.backend.docker.gateway_transport.run_docker" def _proc(returncode: int = 0, stderr: str = "") -> Mock: diff --git a/tests/unit/test_macos_gateway.py b/tests/unit/test_macos_gateway.py index 308d4eab..68eb5586 100644 --- a/tests/unit/test_macos_gateway.py +++ b/tests/unit/test_macos_gateway.py @@ -135,11 +135,11 @@ class TestMacosGatewayLifecycle(unittest.TestCase): self.gw.ca_cert_pem(timeout=0) def test_provisioning_transport_targets_the_gateway_container(self) -> None: - from bot_bottle.backend.macos_container.gateway_provision import ( - AppleGatewayTransport, + from bot_bottle.backend.macos_container.gateway_transport import ( + MacosGatewayTransport, ) transport = self.gw.provisioning_transport() - assert isinstance(transport, AppleGatewayTransport) + assert isinstance(transport, MacosGatewayTransport) self.assertEqual(self.gw.name, transport.gateway) diff --git a/tests/unit/test_orchestrator_gateway.py b/tests/unit/test_orchestrator_gateway.py index bf397982..a2aa1ddc 100644 --- a/tests/unit/test_orchestrator_gateway.py +++ b/tests/unit/test_orchestrator_gateway.py @@ -175,7 +175,7 @@ class TestDockerGateway(unittest.TestCase): self.sc.address() def test_provisioning_transport_targets_the_gateway_container(self) -> None: - from bot_bottle.backend.docker.gateway_provision import DockerGatewayTransport + from bot_bottle.backend.docker.gateway_transport import DockerGatewayTransport transport = self.sc.provisioning_transport() assert isinstance(transport, DockerGatewayTransport)