diff --git a/bot_bottle/backend/docker/consolidated_launch.py b/bot_bottle/backend/docker/consolidated_launch.py index 4ea31c8..2d7c230 100644 --- a/bot_bottle/backend/docker/consolidated_launch.py +++ b/bot_bottle/backend/docker/consolidated_launch.py @@ -29,7 +29,11 @@ from ...orchestrator.gateway import GATEWAY_NAME, GATEWAY_NETWORK from ...orchestrator.lifecycle import OrchestratorService from ...orchestrator.registration import registration_inputs from .gateway_net import next_free_ip -from .gateway_provision import deprovision_git_gate, provision_git_gate +from .gateway_provision import ( + DockerGatewayTransport, + deprovision_git_gate, + provision_git_gate, +) class ConsolidatedLaunchError(RuntimeError): @@ -121,7 +125,8 @@ def launch_consolidated( metadata=inputs.metadata, tokens=tokens, ) try: - provision_git_gate(gateway_name, reg.bottle_id, git_gate_plan) + provision_git_gate( + DockerGatewayTransport(gateway_name), reg.bottle_id, git_gate_plan) except Exception: # Roll the registration back so a provisioning failure leaves no orphan. client.teardown_bottle(reg.bottle_id) @@ -142,7 +147,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.""" OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id) - deprovision_git_gate(gateway_name, bottle_id) + deprovision_git_gate(DockerGatewayTransport(gateway_name), bottle_id) __all__ = [ diff --git a/bot_bottle/backend/docker/gateway_provision.py b/bot_bottle/backend/docker/gateway_provision.py index 1e1fff9..e7d5596 100644 --- a/bot_bottle/backend/docker/gateway_provision.py +++ b/bot_bottle/backend/docker/gateway_provision.py @@ -15,14 +15,15 @@ bottle's push credentials out of another's repos on the shared gateway. from __future__ import annotations import re +from typing import Protocol from ...docker_cmd import run_docker from ...git_gate import GitGatePlan, git_gate_render_provision # bottle ids index the gateway's per-bottle repo + creds dirs; they land in -# `docker cp`/`rm` path arguments, so validate before any path is built (a -# traversal id like "../etc" must never reach the container). Registry ids are -# token_hex — this is defense in depth at the docker boundary. +# exec/cp path arguments, so validate before any path is built (a traversal +# id like "../etc" must never reach the gateway). Registry ids are token_hex — +# this is defense in depth at the transport boundary. _SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+") @@ -30,6 +31,42 @@ class GatewayProvisionError(RuntimeError): """A git-gate provisioning step against the running gateway failed.""" +class GatewayTransport(Protocol): + """How the launcher stages files + runs commands in the running gateway. + Backend-neutral so the same provisioning logic serves the docker gateway + (exec/cp over the docker socket) and the firecracker gateway VM (over + SSH).""" + + def exec(self, argv: list[str]) -> None: + """Run `argv` in the gateway, raising `GatewayProvisionError` on + failure.""" + + def cp_into(self, src: str, dest: str) -> None: + """Copy host file `src` to `dest` in the gateway, raising on + failure.""" + + +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}") @@ -39,27 +76,11 @@ def _creds_dir(bottle_id: str) -> str: return f"/git-gate/creds/{bottle_id}" -def _exec(gateway: str, argv: list[str]) -> None: - """`docker exec` a command in the gateway, raising on non-zero exit.""" - proc = run_docker(["docker", "exec", gateway, *argv]) - if proc.returncode != 0: - raise GatewayProvisionError( - f"gateway exec {argv!r} failed: {proc.stderr.strip()}" - ) - - -def _cp_into(gateway: str, src: str, dest: str) -> None: - """`docker cp` a host file into the gateway, raising on non-zero exit.""" - proc = run_docker(["docker", "cp", src, f"{gateway}:{dest}"]) - if proc.returncode != 0: - raise GatewayProvisionError( - f"gateway cp {src} -> {dest} failed: {proc.stderr.strip()}" - ) - - -def provision_git_gate(gateway: str, bottle_id: str, plan: GitGatePlan) -> None: - """Place `bottle_id`'s git-gate credentials into the running `gateway` - container and init its bare repos under `/git//`. +def provision_git_gate( + transport: GatewayTransport, bottle_id: str, plan: GitGatePlan, +) -> None: + """Place `bottle_id`'s git-gate credentials into the running gateway and + init its bare repos under `/git//`. Copies each upstream's identity key (and known_hosts, when present) into `/git-gate/creds//`, then runs the namespaced provisioning @@ -70,31 +91,36 @@ def provision_git_gate(gateway: str, bottle_id: str, plan: GitGatePlan) -> None: # The pre-receive + access hooks are bottle-agnostic and shared by every # bottle's repos; install them into the gateway (idempotent — same content # each time). The per-bottle model cp'd these into each bundle at start. - _exec(gateway, ["mkdir", "-p", "/etc/git-gate"]) - _cp_into(gateway, str(plan.hook_script), "/etc/git-gate/pre-receive") - _cp_into(gateway, str(plan.access_hook_script), "/etc/git-gate/access-hook") + transport.exec(["mkdir", "-p", "/etc/git-gate"]) + transport.cp_into(str(plan.hook_script), "/etc/git-gate/pre-receive") + transport.cp_into(str(plan.access_hook_script), "/etc/git-gate/access-hook") creds = _creds_dir(bottle_id) - _exec(gateway, ["mkdir", "-p", creds]) + transport.exec(["mkdir", "-p", creds]) for u in plan.upstreams: if u.identity_file: - _cp_into(gateway, u.identity_file, f"{creds}/{u.name}-key") + transport.cp_into(u.identity_file, f"{creds}/{u.name}-key") known_hosts = str(u.known_hosts_file) if known_hosts and known_hosts != ".": - _cp_into(gateway, known_hosts, f"{creds}/{u.name}-known_hosts") + transport.cp_into(known_hosts, f"{creds}/{u.name}-known_hosts") # Init the bare repos + per-repo credential config for this namespace. script = git_gate_render_provision(bottle_id, plan.upstreams) - _exec(gateway, ["sh", "-c", script]) + transport.exec(["sh", "-c", script]) -def deprovision_git_gate(gateway: str, bottle_id: str) -> None: +def deprovision_git_gate(transport: GatewayTransport, bottle_id: str) -> None: """Remove a bottle's repos + creds from the gateway on teardown. Idempotent — an already-absent namespace is a clean no-op (best effort; a stray dir can't leak, since attribution is by source IP and the bottle is gone).""" _require_safe(bottle_id) - run_docker([ - "docker", "exec", gateway, "rm", "-rf", - f"/git/{bottle_id}", _creds_dir(bottle_id), - ]) + try: + transport.exec([ + "rm", "-rf", f"/git/{bottle_id}", _creds_dir(bottle_id), + ]) + except GatewayProvisionError: + pass # best-effort teardown; absent namespace is success -__all__ = ["provision_git_gate", "deprovision_git_gate", "GatewayProvisionError"] +__all__ = [ + "provision_git_gate", "deprovision_git_gate", + "GatewayProvisionError", "GatewayTransport", "DockerGatewayTransport", +] diff --git a/bot_bottle/backend/firecracker/consolidated_launch.py b/bot_bottle/backend/firecracker/consolidated_launch.py index 6ac26b3..0609fac 100644 --- a/bot_bottle/backend/firecracker/consolidated_launch.py +++ b/bot_bottle/backend/firecracker/consolidated_launch.py @@ -44,7 +44,11 @@ from ...orchestrator.lifecycle import ( ) from ...orchestrator.registration import registration_inputs from ..docker.egress import EGRESS_PORT -from ..docker.gateway_provision import deprovision_git_gate, provision_git_gate +from ..docker.gateway_provision import ( + DockerGatewayTransport, + deprovision_git_gate, + provision_git_gate, +) from ...supervise import SUPERVISE_PORT _GIT_HTTP_PORT = 9420 @@ -126,7 +130,8 @@ def launch_consolidated( metadata=inputs.metadata, tokens=tokens, ) try: - provision_git_gate(gateway_name, reg.bottle_id, git_gate_plan) + provision_git_gate( + DockerGatewayTransport(gateway_name), reg.bottle_id, git_gate_plan) except Exception: client.teardown_bottle(reg.bottle_id) raise @@ -152,7 +157,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.""" OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id) - deprovision_git_gate(gateway_name, bottle_id) + deprovision_git_gate(DockerGatewayTransport(gateway_name), bottle_id) __all__ = [ diff --git a/tests/unit/test_gateway_provision.py b/tests/unit/test_gateway_provision.py index b28c3a0..fe2a4f6 100644 --- a/tests/unit/test_gateway_provision.py +++ b/tests/unit/test_gateway_provision.py @@ -8,6 +8,7 @@ from unittest.mock import Mock, patch from bot_bottle.backend.docker.gateway_provision import ( GatewayProvisionError, + DockerGatewayTransport, deprovision_git_gate, provision_git_gate, ) @@ -54,7 +55,7 @@ class TestProvisionGitGate(unittest.TestCase): def test_copies_creds_and_runs_namespaced_init(self) -> None: calls: list[list[str]] = [] with patch(_RUN, side_effect=_recorder(calls)): - provision_git_gate("gw", "bottle1", _plan(_up("foo", known_hosts="/host/kh"))) + provision_git_gate(DockerGatewayTransport("gw"), "bottle1", _plan(_up("foo", known_hosts="/host/kh"))) cps = [c for c in calls if c[:2] == ["docker", "cp"]] self.assertIn(["docker", "cp", "/host/keys/id", "gw:/git-gate/creds/bottle1/foo-key"], cps) @@ -71,32 +72,32 @@ class TestProvisionGitGate(unittest.TestCase): def test_omits_known_hosts_copy_when_absent(self) -> None: calls: list[list[str]] = [] with patch(_RUN, side_effect=_recorder(calls)): - provision_git_gate("gw", "b1", _plan(_up("foo"))) # no known_hosts + provision_git_gate(DockerGatewayTransport("gw"), "b1", _plan(_up("foo"))) # no known_hosts creds_cps = [c for c in calls if c[:2] == ["docker", "cp"] and "/git-gate/creds/" in c[3]] self.assertEqual(1, len(creds_cps)) # only the key, not known_hosts self.assertTrue(creds_cps[0][3].endswith("/foo-key")) def test_no_upstreams_is_noop(self) -> None: with patch(_RUN) as m: - provision_git_gate("gw", "b1", _plan()) + provision_git_gate(DockerGatewayTransport("gw"), "b1", _plan()) m.assert_not_called() def test_raises_on_docker_failure(self) -> None: with patch(_RUN, return_value=_proc(returncode=1, stderr="boom")): with self.assertRaises(GatewayProvisionError): - provision_git_gate("gw", "b1", _plan(_up("foo"))) + provision_git_gate(DockerGatewayTransport("gw"), "b1", _plan(_up("foo"))) def test_rejects_unsafe_bottle_id_before_any_docker(self) -> None: with patch(_RUN) as m: with self.assertRaises(GatewayProvisionError): - provision_git_gate("gw", "../etc", _plan(_up("foo"))) + provision_git_gate(DockerGatewayTransport("gw"), "../etc", _plan(_up("foo"))) m.assert_not_called() # rejected before a single docker call class TestDeprovision(unittest.TestCase): def test_removes_repo_and_creds(self) -> None: with patch(_RUN, return_value=_proc()) as m: - deprovision_git_gate("gw", "b1") + deprovision_git_gate(DockerGatewayTransport("gw"), "b1") argv = m.call_args.args[0] self.assertEqual(["docker", "exec", "gw", "rm", "-rf"], argv[:5]) self.assertIn("/git/b1", argv) @@ -105,7 +106,7 @@ class TestDeprovision(unittest.TestCase): def test_rejects_unsafe_bottle_id(self) -> None: with patch(_RUN) as m: with self.assertRaises(GatewayProvisionError): - deprovision_git_gate("gw", "a/b") + deprovision_git_gate(DockerGatewayTransport("gw"), "a/b") m.assert_not_called()