refactor(gateway): parameterize git-gate provisioning transport (Stage B prep)
git-gate provisioning into the running gateway was hard-wired to docker exec/cp. Extract a backend-neutral `GatewayTransport` (exec + cp_into) so the same provisioning logic serves both the docker gateway container and the firecracker gateway VM (over SSH, added with the launch swap). - `provision_git_gate` / `deprovision_git_gate` now take a transport instead of a gateway name; `DockerGatewayTransport` wraps the existing docker exec/cp behavior. deprovision is best-effort (catches the transport error) — matching the prior idempotent teardown. - both consolidated_launch callers pass `DockerGatewayTransport(name)` — no behavior change; the firecracker swap flips only its own to SSH. Pure refactor: docker path unchanged, gateway_provision tests updated to construct the transport, all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -29,7 +29,11 @@ from ...orchestrator.gateway import GATEWAY_NAME, GATEWAY_NETWORK
|
|||||||
from ...orchestrator.lifecycle import OrchestratorService
|
from ...orchestrator.lifecycle import OrchestratorService
|
||||||
from ...orchestrator.registration import registration_inputs
|
from ...orchestrator.registration import registration_inputs
|
||||||
from .gateway_net import next_free_ip
|
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):
|
class ConsolidatedLaunchError(RuntimeError):
|
||||||
@@ -121,7 +125,8 @@ def launch_consolidated(
|
|||||||
metadata=inputs.metadata, tokens=tokens,
|
metadata=inputs.metadata, tokens=tokens,
|
||||||
)
|
)
|
||||||
try:
|
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:
|
except Exception:
|
||||||
# Roll the registration back so a provisioning failure leaves no orphan.
|
# Roll the registration back so a provisioning failure leaves no orphan.
|
||||||
client.teardown_bottle(reg.bottle_id)
|
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.
|
"""Deregister the bottle and remove its git-gate state from the gateway.
|
||||||
Both steps are idempotent so this is safe from a cleanup trap."""
|
Both steps are idempotent so this is safe from a cleanup trap."""
|
||||||
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||||
deprovision_git_gate(gateway_name, bottle_id)
|
deprovision_git_gate(DockerGatewayTransport(gateway_name), bottle_id)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ bottle's push credentials out of another's repos on the shared gateway.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
from ...docker_cmd import run_docker
|
from ...docker_cmd import run_docker
|
||||||
from ...git_gate import GitGatePlan, git_gate_render_provision
|
from ...git_gate import GitGatePlan, git_gate_render_provision
|
||||||
|
|
||||||
# bottle ids index the gateway's per-bottle repo + creds dirs; they land in
|
# 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
|
# exec/cp path arguments, so validate before any path is built (a traversal
|
||||||
# traversal id like "../etc" must never reach the container). Registry ids are
|
# id like "../etc" must never reach the gateway). Registry ids are token_hex —
|
||||||
# token_hex — this is defense in depth at the docker boundary.
|
# this is defense in depth at the transport boundary.
|
||||||
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
|
_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."""
|
"""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:
|
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}")
|
||||||
@@ -39,27 +76,11 @@ def _creds_dir(bottle_id: str) -> str:
|
|||||||
return f"/git-gate/creds/{bottle_id}"
|
return f"/git-gate/creds/{bottle_id}"
|
||||||
|
|
||||||
|
|
||||||
def _exec(gateway: str, argv: list[str]) -> None:
|
def provision_git_gate(
|
||||||
"""`docker exec` a command in the gateway, raising on non-zero exit."""
|
transport: GatewayTransport, bottle_id: str, plan: GitGatePlan,
|
||||||
proc = run_docker(["docker", "exec", gateway, *argv])
|
) -> None:
|
||||||
if proc.returncode != 0:
|
"""Place `bottle_id`'s git-gate credentials into the running gateway and
|
||||||
raise GatewayProvisionError(
|
init its bare repos under `/git/<bottle_id>/`.
|
||||||
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/<bottle_id>/`.
|
|
||||||
|
|
||||||
Copies each upstream's identity key (and known_hosts, when present) into
|
Copies each upstream's identity key (and known_hosts, when present) into
|
||||||
`/git-gate/creds/<bottle_id>/`, then runs the namespaced provisioning
|
`/git-gate/creds/<bottle_id>/`, 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
|
# The pre-receive + access hooks are bottle-agnostic and shared by every
|
||||||
# bottle's repos; install them into the gateway (idempotent — same content
|
# 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.
|
# each time). The per-bottle model cp'd these into each bundle at start.
|
||||||
_exec(gateway, ["mkdir", "-p", "/etc/git-gate"])
|
transport.exec(["mkdir", "-p", "/etc/git-gate"])
|
||||||
_cp_into(gateway, str(plan.hook_script), "/etc/git-gate/pre-receive")
|
transport.cp_into(str(plan.hook_script), "/etc/git-gate/pre-receive")
|
||||||
_cp_into(gateway, str(plan.access_hook_script), "/etc/git-gate/access-hook")
|
transport.cp_into(str(plan.access_hook_script), "/etc/git-gate/access-hook")
|
||||||
creds = _creds_dir(bottle_id)
|
creds = _creds_dir(bottle_id)
|
||||||
_exec(gateway, ["mkdir", "-p", creds])
|
transport.exec(["mkdir", "-p", creds])
|
||||||
for u in plan.upstreams:
|
for u in plan.upstreams:
|
||||||
if u.identity_file:
|
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)
|
known_hosts = str(u.known_hosts_file)
|
||||||
if known_hosts and known_hosts != ".":
|
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.
|
# Init the bare repos + per-repo credential config for this namespace.
|
||||||
script = git_gate_render_provision(bottle_id, plan.upstreams)
|
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
|
"""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
|
— 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)."""
|
can't leak, since attribution is by source IP and the bottle is gone)."""
|
||||||
_require_safe(bottle_id)
|
_require_safe(bottle_id)
|
||||||
run_docker([
|
try:
|
||||||
"docker", "exec", gateway, "rm", "-rf",
|
transport.exec([
|
||||||
f"/git/{bottle_id}", _creds_dir(bottle_id),
|
"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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ from ...orchestrator.lifecycle import (
|
|||||||
)
|
)
|
||||||
from ...orchestrator.registration import registration_inputs
|
from ...orchestrator.registration import registration_inputs
|
||||||
from ..docker.egress import EGRESS_PORT
|
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
|
from ...supervise import SUPERVISE_PORT
|
||||||
|
|
||||||
_GIT_HTTP_PORT = 9420
|
_GIT_HTTP_PORT = 9420
|
||||||
@@ -126,7 +130,8 @@ def launch_consolidated(
|
|||||||
metadata=inputs.metadata, tokens=tokens,
|
metadata=inputs.metadata, tokens=tokens,
|
||||||
)
|
)
|
||||||
try:
|
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:
|
except Exception:
|
||||||
client.teardown_bottle(reg.bottle_id)
|
client.teardown_bottle(reg.bottle_id)
|
||||||
raise
|
raise
|
||||||
@@ -152,7 +157,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."""
|
Both steps are idempotent so this is safe from a cleanup trap."""
|
||||||
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||||
deprovision_git_gate(gateway_name, bottle_id)
|
deprovision_git_gate(DockerGatewayTransport(gateway_name), bottle_id)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -54,7 +55,7 @@ class TestProvisionGitGate(unittest.TestCase):
|
|||||||
def test_copies_creds_and_runs_namespaced_init(self) -> None:
|
def test_copies_creds_and_runs_namespaced_init(self) -> None:
|
||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
with patch(_RUN, side_effect=_recorder(calls)):
|
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"]]
|
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)
|
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:
|
def test_omits_known_hosts_copy_when_absent(self) -> None:
|
||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
with patch(_RUN, side_effect=_recorder(calls)):
|
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]]
|
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.assertEqual(1, len(creds_cps)) # only the key, not known_hosts
|
||||||
self.assertTrue(creds_cps[0][3].endswith("/foo-key"))
|
self.assertTrue(creds_cps[0][3].endswith("/foo-key"))
|
||||||
|
|
||||||
def test_no_upstreams_is_noop(self) -> None:
|
def test_no_upstreams_is_noop(self) -> None:
|
||||||
with patch(_RUN) as m:
|
with patch(_RUN) as m:
|
||||||
provision_git_gate("gw", "b1", _plan())
|
provision_git_gate(DockerGatewayTransport("gw"), "b1", _plan())
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
def test_raises_on_docker_failure(self) -> None:
|
def test_raises_on_docker_failure(self) -> None:
|
||||||
with patch(_RUN, return_value=_proc(returncode=1, stderr="boom")):
|
with patch(_RUN, return_value=_proc(returncode=1, stderr="boom")):
|
||||||
with self.assertRaises(GatewayProvisionError):
|
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:
|
def test_rejects_unsafe_bottle_id_before_any_docker(self) -> None:
|
||||||
with patch(_RUN) as m:
|
with patch(_RUN) as m:
|
||||||
with self.assertRaises(GatewayProvisionError):
|
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
|
m.assert_not_called() # rejected before a single docker call
|
||||||
|
|
||||||
|
|
||||||
class TestDeprovision(unittest.TestCase):
|
class TestDeprovision(unittest.TestCase):
|
||||||
def test_removes_repo_and_creds(self) -> None:
|
def test_removes_repo_and_creds(self) -> None:
|
||||||
with patch(_RUN, return_value=_proc()) as m:
|
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]
|
argv = m.call_args.args[0]
|
||||||
self.assertEqual(["docker", "exec", "gw", "rm", "-rf"], argv[:5])
|
self.assertEqual(["docker", "exec", "gw", "rm", "-rf"], argv[:5])
|
||||||
self.assertIn("/git/b1", argv)
|
self.assertIn("/git/b1", argv)
|
||||||
@@ -105,7 +106,7 @@ class TestDeprovision(unittest.TestCase):
|
|||||||
def test_rejects_unsafe_bottle_id(self) -> None:
|
def test_rejects_unsafe_bottle_id(self) -> None:
|
||||||
with patch(_RUN) as m:
|
with patch(_RUN) as m:
|
||||||
with self.assertRaises(GatewayProvisionError):
|
with self.assertRaises(GatewayProvisionError):
|
||||||
deprovision_git_gate("gw", "a/b")
|
deprovision_git_gate(DockerGatewayTransport("gw"), "a/b")
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user