refactor(gateway): each backend's transport in its own gateway_transport.py

Give every backend a `gateway_transport.py` holding just its `GatewayTransport`
impl, named consistently with the backend: `DockerGatewayTransport`,
`MacosGatewayTransport` (was `AppleGatewayTransport`), and
`FirecrackerGatewayTransport` (was `SshGatewayTransport`).

- docker: split `DockerGatewayTransport` out of `gateway_provision.py`, which
  keeps only the backend-neutral provisioning logic (provision_git_gate /
  deprovision_git_gate) the other backends share.
- macOS: `gateway_provision.py` held only the transport, so it's replaced by
  `gateway_transport.py`.
- firecracker: move the transport out of `gateway.py`.

Behaviour-preserving; importers + tests updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 13:52:31 -04:00
parent 6e90522664
commit e1fd8ef1b4
13 changed files with 128 additions and 95 deletions
@@ -26,7 +26,7 @@ from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ...orchestrator.reprovision import reprovision_bottles from ...orchestrator.reprovision import reprovision_bottles
from ..consolidated_util import provision_bottle from ..consolidated_util import provision_bottle
from ..consolidated_util import teardown_consolidated as _teardown_util 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 from .gateway_net import next_free_ip
+1 -1
View File
@@ -5,7 +5,7 @@ import time
from pathlib import Path from pathlib import Path
from .util import run_docker from .util import run_docker
from .gateway_provision import DockerGatewayTransport from .gateway_transport import DockerGatewayTransport
from ...paths import ( from ...paths import (
ORCHESTRATOR_AUTH_JWT_ENV, ORCHESTRATOR_AUTH_JWT_ENV,
host_gateway_ca_dir, host_gateway_ca_dir,
+1 -23
View File
@@ -16,7 +16,6 @@ from __future__ import annotations
import re import re
from .util import run_docker
from ...git_gate import GitGatePlan, git_gate_render_provision from ...git_gate import GitGatePlan, git_gate_render_provision
from ...gateway import GatewayProvisionError, GatewayTransport from ...gateway import GatewayProvisionError, GatewayTransport
@@ -27,27 +26,6 @@ from ...gateway import GatewayProvisionError, GatewayTransport
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+") _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: def _require_safe(bottle_id: str) -> None:
if not _SAFE_BOTTLE_ID.fullmatch(bottle_id): if not _SAFE_BOTTLE_ID.fullmatch(bottle_id):
raise GatewayProvisionError(f"unsafe bottle id {bottle_id!r}") raise GatewayProvisionError(f"unsafe bottle id {bottle_id!r}")
@@ -109,5 +87,5 @@ def deprovision_git_gate(transport: GatewayTransport, bottle_id: str) -> None:
__all__ = [ __all__ = [
"provision_git_gate", "deprovision_git_gate", "provision_git_gate", "deprovision_git_gate",
"GatewayProvisionError", "GatewayTransport", "DockerGatewayTransport", "GatewayProvisionError", "GatewayTransport",
] ]
@@ -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"]
+3 -41
View File
@@ -20,22 +20,18 @@ Orchestrator service lands and `infra_vm` is dissolved.
from __future__ import annotations from __future__ import annotations
import os
import shlex
import stat
import subprocess import subprocess
from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from ...gateway import ( from ...gateway import (
DEFAULT_CA_TIMEOUT_SECONDS, DEFAULT_CA_TIMEOUT_SECONDS,
Gateway, Gateway,
GatewayError, GatewayError,
GatewayProvisionError,
GatewayTransport, GatewayTransport,
) )
from .. import util as backend_util from .. import util as backend_util
from . import infra_vm, netpool, 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 # The gateway microVM's name (the `bb_role=gateway` boot tag / run dir). Fixed
# per host — one gateway VM, shared by every agent VM. # 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 _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): class FirecrackerGateway(Gateway):
"""The consolidated gateway as a Firecracker microVM on the gateway link. """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 — 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 built from the stable key + the gateway link's guest IP, so teardown can
use it too.""" use it too."""
return SshGatewayTransport( return FirecrackerGatewayTransport(
infra_vm._infra_dir() / "id_ed25519", netpool.gw_slot().guest_ip) infra_vm._infra_dir() / "id_ed25519", netpool.gw_slot().guest_ip)
__all__ = ["FirecrackerGateway", "SshGatewayTransport", "GATEWAY_NAME"] __all__ = ["FirecrackerGateway", "FirecrackerGatewayTransport", "GATEWAY_NAME"]
@@ -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"]
@@ -47,7 +47,7 @@ from ..consolidated_util import (
from . import util as container_mod from . import util as container_mod
from .enumerate import CONTAINER_NAME_PREFIX, EnumerationError, enumerate_active from .enumerate import CONTAINER_NAME_PREFIX, EnumerationError, enumerate_active
from .gateway import GATEWAY_NETWORK from .gateway import GATEWAY_NETWORK
from .gateway_provision import AppleGatewayTransport from .gateway_transport import MacosGatewayTransport
from .infra import MacosInfraService, OrchestratorStartError from .infra import MacosInfraService, OrchestratorStartError
@@ -172,7 +172,7 @@ def register_agent(
except (OrchestratorClientError, EnumerationError) as e: except (OrchestratorClientError, EnumerationError) as e:
info(f"registry reconciliation skipped: {e}") info(f"registry reconciliation skipped: {e}")
reg = provision_bottle( 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, image_ref=image_ref, tokens=tokens,
env_var_secret=env_var_secret, 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. """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 Both steps are idempotent so this is safe from a cleanup trap. Does NOT
stop the gateway — it's a persistent per-host singleton.""" 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) orchestrator_url=orchestrator_url, timeout=timeout)
@@ -184,10 +184,10 @@ class MacosGateway(Gateway):
def provisioning_transport(self) -> GatewayTransport: def provisioning_transport(self) -> GatewayTransport:
"""The exec/cp transport git-gate provisioning stages per-bottle repos + """The exec/cp transport git-gate provisioning stages per-bottle repos +
deploy keys through (over the `container` CLI).""" deploy keys through (over the `container` CLI)."""
# Local import: gateway_provision imports GATEWAY_NAME from this module, # Local import: gateway_transport imports GATEWAY_NAME from this module,
# so importing AppleGatewayTransport at module scope would cycle. # so importing MacosGatewayTransport at module scope would cycle.
from .gateway_provision import AppleGatewayTransport from .gateway_transport import MacosGatewayTransport
return AppleGatewayTransport(self.name) return MacosGatewayTransport(self.name)
__all__ = [ __all__ = [
@@ -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 How the launcher stages files + runs commands in the running gateway container:
backend-neutral and lives in `backend.docker.gateway_provision`; this is only the `container` CLI's exec/cp equivalents. The backend-neutral provisioning
the transport how files and commands reach the running gateway. Docker uses logic that drives it lives in `backend.docker.gateway_provision`; Docker uses
`docker exec`/`docker cp` and Firecracker uses SSH; Apple uses the `container` `docker exec`/`docker cp` and Firecracker uses SSH.
CLI's equivalents against the infra container that hosts the gateway daemons.
""" """
from __future__ import annotations from __future__ import annotations
@@ -14,8 +13,8 @@ from . import util as container_mod
from .gateway import GATEWAY_NAME from .gateway import GATEWAY_NAME
class AppleGatewayTransport: class MacosGatewayTransport:
"""`GatewayTransport` for the gateway daemons in the Apple infra container.""" """`GatewayTransport` for the gateway daemons in the Apple gateway container."""
def __init__(self, gateway: str = GATEWAY_NAME) -> None: def __init__(self, gateway: str = GATEWAY_NAME) -> None:
self.gateway = gateway self.gateway = gateway
@@ -41,4 +40,4 @@ class AppleGatewayTransport:
) )
__all__ = ["AppleGatewayTransport", "GatewayProvisionError"] __all__ = ["MacosGatewayTransport"]
+10 -7
View File
@@ -18,11 +18,14 @@ from bot_bottle.backend.firecracker import infra_vm
from bot_bottle.backend.firecracker.gateway import ( from bot_bottle.backend.firecracker.gateway import (
GATEWAY_NAME, GATEWAY_NAME,
FirecrackerGateway, FirecrackerGateway,
SshGatewayTransport, )
from bot_bottle.backend.firecracker.gateway_transport import (
FirecrackerGatewayTransport,
) )
from bot_bottle.gateway import GatewayError, GatewayProvisionError from bot_bottle.gateway import GatewayError, GatewayProvisionError
_GW = "bot_bottle.backend.firecracker.gateway" _GW = "bot_bottle.backend.firecracker.gateway"
_GW_TRANSPORT = "bot_bottle.backend.firecracker.gateway_transport"
_ORCH_URL = "http://10.243.255.1:8099" _ORCH_URL = "http://10.243.255.1:8099"
_TOKEN = "gw-jwt-token" _TOKEN = "gw-jwt-token"
@@ -115,24 +118,24 @@ class TestFirecrackerGatewaySurface(unittest.TestCase):
def test_provisioning_transport_targets_the_gateway_link(self) -> None: def test_provisioning_transport_targets_the_gateway_link(self) -> None:
gw = FirecrackerGateway() gw = FirecrackerGateway()
transport = gw.provisioning_transport() transport = gw.provisioning_transport()
assert isinstance(transport, SshGatewayTransport) assert isinstance(transport, FirecrackerGatewayTransport)
self.assertEqual(infra_vm.netpool.gw_slot().guest_ip, transport._ip) 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: def test_cp_into_preserves_source_mode(self) -> None:
with tempfile.NamedTemporaryFile() as f: with tempfile.NamedTemporaryFile() as f:
os.chmod(f.name, 0o700) # like the staged access-hook os.chmod(f.name, 0o700) # like the staged access-hook
t = SshGatewayTransport(Path("/k"), "10.0.0.1") t = FirecrackerGatewayTransport(Path("/k"), "10.0.0.1")
with patch(f"{_GW}.subprocess.run", with patch(f"{_GW_TRANSPORT}.subprocess.run",
return_value=CompletedProcess([], 0)) as run: return_value=CompletedProcess([], 0)) as run:
t.cp_into(f.name, "/etc/git-gate/access-hook") t.cp_into(f.name, "/etc/git-gate/access-hook")
remote_cmd = run.call_args.args[0][-1] remote_cmd = run.call_args.args[0][-1]
self.assertIn("chmod 700", remote_cmd) # exec bit preserved over SSH self.assertIn("chmod 700", remote_cmd) # exec bit preserved over SSH
def test_exec_raises_on_failure(self) -> None: def test_exec_raises_on_failure(self) -> None:
t = SshGatewayTransport(Path("/k"), "10.0.0.1") t = FirecrackerGatewayTransport(Path("/k"), "10.0.0.1")
with patch(f"{_GW}.subprocess.run", with patch(f"{_GW_TRANSPORT}.subprocess.run",
return_value=CompletedProcess([], 1, stderr="nope")), \ return_value=CompletedProcess([], 1, stderr="nope")), \
self.assertRaises(GatewayProvisionError): self.assertRaises(GatewayProvisionError):
t.exec(["mkdir", "-p", "/git-gate"]) t.exec(["mkdir", "-p", "/git-gate"])
+2 -2
View File
@@ -8,13 +8,13 @@ from unittest.mock import Mock, patch
from bot_bottle.backend.docker.gateway_provision import ( from bot_bottle.backend.docker.gateway_provision import (
GatewayProvisionError, GatewayProvisionError,
DockerGatewayTransport,
deprovision_git_gate, deprovision_git_gate,
provision_git_gate, provision_git_gate,
) )
from bot_bottle.backend.docker.gateway_transport import DockerGatewayTransport
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream 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: def _proc(returncode: int = 0, stderr: str = "") -> Mock:
+3 -3
View File
@@ -135,11 +135,11 @@ class TestMacosGatewayLifecycle(unittest.TestCase):
self.gw.ca_cert_pem(timeout=0) self.gw.ca_cert_pem(timeout=0)
def test_provisioning_transport_targets_the_gateway_container(self) -> None: def test_provisioning_transport_targets_the_gateway_container(self) -> None:
from bot_bottle.backend.macos_container.gateway_provision import ( from bot_bottle.backend.macos_container.gateway_transport import (
AppleGatewayTransport, MacosGatewayTransport,
) )
transport = self.gw.provisioning_transport() transport = self.gw.provisioning_transport()
assert isinstance(transport, AppleGatewayTransport) assert isinstance(transport, MacosGatewayTransport)
self.assertEqual(self.gw.name, transport.gateway) self.assertEqual(self.gw.name, transport.gateway)
+1 -1
View File
@@ -175,7 +175,7 @@ class TestDockerGateway(unittest.TestCase):
self.sc.address() self.sc.address()
def test_provisioning_transport_targets_the_gateway_container(self) -> None: 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() transport = self.sc.provisioning_transport()
assert isinstance(transport, DockerGatewayTransport) assert isinstance(transport, DockerGatewayTransport)