Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20e83258bf | |||
| c5ba33bf0a |
@@ -0,0 +1,94 @@
|
|||||||
|
"""Provision one bottle's git-gate state into the running shared gateway
|
||||||
|
(PRD 0070, docker slice).
|
||||||
|
|
||||||
|
The consolidated gateway serves every bottle's repos under `/git/<bottle_id>/`
|
||||||
|
with per-repo credentials under `/git-gate/creds/<bottle_id>/`. When a bottle
|
||||||
|
is registered the launcher must place *its* deploy keys + known_hosts into
|
||||||
|
that per-bottle creds dir and init its bare repos there — so this copies the
|
||||||
|
credential files into the live gateway container and runs the (namespaced,
|
||||||
|
init-only) provisioning script produced by `git_gate_render_provision`.
|
||||||
|
|
||||||
|
Isolating each bottle's creds dir + repo root by id is what keeps one
|
||||||
|
bottle's push credentials out of another's repos on the shared gateway.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
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.
|
||||||
|
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayProvisionError(RuntimeError):
|
||||||
|
"""A git-gate provisioning step against the running gateway failed."""
|
||||||
|
|
||||||
|
|
||||||
|
def _require_safe(bottle_id: str) -> None:
|
||||||
|
if not _SAFE_BOTTLE_ID.fullmatch(bottle_id):
|
||||||
|
raise GatewayProvisionError(f"unsafe bottle id {bottle_id!r}")
|
||||||
|
|
||||||
|
|
||||||
|
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/<bottle_id>/`.
|
||||||
|
|
||||||
|
Copies each upstream's identity key (and known_hosts, when present) into
|
||||||
|
`/git-gate/creds/<bottle_id>/`, then runs the namespaced provisioning
|
||||||
|
script. No-op for a bottle with no git upstreams."""
|
||||||
|
_require_safe(bottle_id)
|
||||||
|
if not plan.upstreams:
|
||||||
|
return
|
||||||
|
creds = _creds_dir(bottle_id)
|
||||||
|
_exec(gateway, ["mkdir", "-p", creds])
|
||||||
|
for u in plan.upstreams:
|
||||||
|
if u.identity_file:
|
||||||
|
_cp_into(gateway, 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")
|
||||||
|
# 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])
|
||||||
|
|
||||||
|
|
||||||
|
def deprovision_git_gate(gateway: str, 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),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["provision_git_gate", "deprovision_git_gate", "GatewayProvisionError"]
|
||||||
@@ -25,6 +25,11 @@ from ..docker_cmd import run_docker
|
|||||||
|
|
||||||
GATEWAY_NAME = "bot-bottle-orch-gateway"
|
GATEWAY_NAME = "bot-bottle-orch-gateway"
|
||||||
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
|
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
|
||||||
|
# The single user-defined network the gateway and every agent bottle share.
|
||||||
|
# Agents attach here with a pinned IP and reach the gateway's egress /
|
||||||
|
# git-http / supervise ports by its address — no host port publishing, and
|
||||||
|
# the source IP the gateway attributes by is the address on this network.
|
||||||
|
GATEWAY_NETWORK = "bot-bottle-gateway"
|
||||||
|
|
||||||
# The real sidecar-bundle image + its Dockerfile. Kept as a local constant
|
# The real sidecar-bundle image + its Dockerfile. Kept as a local constant
|
||||||
# rather than imported from backend.docker.sidecar_bundle, which would drag
|
# rather than imported from backend.docker.sidecar_bundle, which would drag
|
||||||
@@ -77,11 +82,13 @@ class DockerGateway(Gateway):
|
|||||||
image_ref: str = GATEWAY_IMAGE,
|
image_ref: str = GATEWAY_IMAGE,
|
||||||
*,
|
*,
|
||||||
name: str = GATEWAY_NAME,
|
name: str = GATEWAY_NAME,
|
||||||
|
network: str = GATEWAY_NETWORK,
|
||||||
build_context: Path | None = None,
|
build_context: Path | None = None,
|
||||||
dockerfile: str | None = GATEWAY_DOCKERFILE,
|
dockerfile: str | None = GATEWAY_DOCKERFILE,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.image_ref = image_ref
|
self.image_ref = image_ref
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.network = network
|
||||||
self._build_context = build_context or _REPO_ROOT
|
self._build_context = build_context or _REPO_ROOT
|
||||||
self._dockerfile = dockerfile
|
self._dockerfile = dockerfile
|
||||||
|
|
||||||
@@ -112,9 +119,22 @@ class DockerGateway(Gateway):
|
|||||||
])
|
])
|
||||||
return self.name in proc.stdout.split()
|
return self.name in proc.stdout.split()
|
||||||
|
|
||||||
|
def _ensure_network(self) -> None:
|
||||||
|
"""Create the shared gateway network if it doesn't exist. Idempotent —
|
||||||
|
a concurrent create loses harmlessly (the loser sees 'already exists').
|
||||||
|
Docker picks the subnet; the launcher reads it back to allocate IPs."""
|
||||||
|
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
|
||||||
|
return
|
||||||
|
proc = run_docker(["docker", "network", "create", self.network])
|
||||||
|
if proc.returncode != 0 and "already exists" not in proc.stderr:
|
||||||
|
raise GatewayError(
|
||||||
|
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
def ensure_running(self) -> None:
|
def ensure_running(self) -> None:
|
||||||
if self.is_running():
|
if self.is_running():
|
||||||
return
|
return
|
||||||
|
self._ensure_network()
|
||||||
# Clear any stale (stopped) container holding the fixed name, then
|
# Clear any stale (stopped) container holding the fixed name, then
|
||||||
# start fresh. `rm --force` on an absent name is a tolerated no-op.
|
# start fresh. `rm --force` on an absent name is a tolerated no-op.
|
||||||
run_docker(["docker", "rm", "--force", self.name])
|
run_docker(["docker", "rm", "--force", self.name])
|
||||||
@@ -122,6 +142,7 @@ class DockerGateway(Gateway):
|
|||||||
"docker", "run", "--detach",
|
"docker", "run", "--detach",
|
||||||
"--name", self.name,
|
"--name", self.name,
|
||||||
"--label", GATEWAY_LABEL,
|
"--label", GATEWAY_LABEL,
|
||||||
|
"--network", self.network,
|
||||||
self.image_ref,
|
self.image_ref,
|
||||||
])
|
])
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
@@ -135,5 +156,5 @@ class DockerGateway(Gateway):
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Gateway", "DockerGateway", "GatewayError",
|
"Gateway", "DockerGateway", "GatewayError",
|
||||||
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE",
|
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Unit: git-gate provisioning into the running shared gateway (PRD 0070)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from bot_bottle.backend.docker.gateway_provision import (
|
||||||
|
GatewayProvisionError,
|
||||||
|
deprovision_git_gate,
|
||||||
|
provision_git_gate,
|
||||||
|
)
|
||||||
|
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||||
|
|
||||||
|
_RUN = "bot_bottle.backend.docker.gateway_provision.run_docker"
|
||||||
|
|
||||||
|
|
||||||
|
def _proc(returncode: int = 0, stderr: str = "") -> Mock:
|
||||||
|
return Mock(returncode=returncode, stderr=stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _recorder(calls: list[list[str]]):
|
||||||
|
"""A run_docker side_effect that records argv and returns success."""
|
||||||
|
def fake(argv: list[str]) -> Mock:
|
||||||
|
calls.append(argv)
|
||||||
|
return _proc()
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _plan(*upstreams: GitGateUpstream) -> GitGatePlan:
|
||||||
|
return GitGatePlan(
|
||||||
|
slug="demo",
|
||||||
|
entrypoint_script=Path(),
|
||||||
|
hook_script=Path(),
|
||||||
|
access_hook_script=Path(),
|
||||||
|
upstreams=tuple(upstreams),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _up(name: str, *, key: str = "/host/keys/id", known_hosts: str = "") -> GitGateUpstream:
|
||||||
|
return GitGateUpstream(
|
||||||
|
name=name,
|
||||||
|
upstream_url=f"ssh://git@github.com/x/{name}.git",
|
||||||
|
upstream_host="github.com",
|
||||||
|
upstream_port="22",
|
||||||
|
identity_file=key,
|
||||||
|
known_host_key="",
|
||||||
|
known_hosts_file=Path(known_hosts) if known_hosts else Path(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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")))
|
||||||
|
|
||||||
|
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/kh", "gw:/git-gate/creds/bottle1/foo-known_hosts"], cps,
|
||||||
|
)
|
||||||
|
# The init script runs in the gateway, namespaced under the bottle id.
|
||||||
|
exec_scripts = [c for c in calls if c[:3] == ["docker", "exec", "gw"] and c[3] == "sh"]
|
||||||
|
self.assertEqual(1, len(exec_scripts))
|
||||||
|
self.assertIn("repo=/git/bottle1/${name}.git", exec_scripts[0][-1])
|
||||||
|
|
||||||
|
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
|
||||||
|
cps = [c for c in calls if c[:2] == ["docker", "cp"]]
|
||||||
|
self.assertEqual(1, len(cps)) # only the key, not known_hosts
|
||||||
|
self.assertTrue(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())
|
||||||
|
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")))
|
||||||
|
|
||||||
|
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")))
|
||||||
|
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")
|
||||||
|
argv = m.call_args.args[0]
|
||||||
|
self.assertEqual(["docker", "exec", "gw", "rm", "-rf"], argv[:5])
|
||||||
|
self.assertIn("/git/b1", argv)
|
||||||
|
self.assertIn("/git-gate/creds/b1", argv)
|
||||||
|
|
||||||
|
def test_rejects_unsafe_bottle_id(self) -> None:
|
||||||
|
with patch(_RUN) as m:
|
||||||
|
with self.assertRaises(GatewayProvisionError):
|
||||||
|
deprovision_git_gate("gw", "a/b")
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -51,6 +51,33 @@ class TestDockerGateway(unittest.TestCase):
|
|||||||
self.assertEqual(1, len(runs))
|
self.assertEqual(1, len(runs))
|
||||||
self.assertIn(self.sc.name, runs[0])
|
self.assertIn(self.sc.name, runs[0])
|
||||||
self.assertIn("bot-bottle-sidecars:latest", runs[0])
|
self.assertIn("bot-bottle-sidecars:latest", runs[0])
|
||||||
|
# Runs on the shared gateway network so agents can reach it by IP.
|
||||||
|
self.assertEqual(self.sc.network, runs[0][runs[0].index("--network") + 1])
|
||||||
|
|
||||||
|
def test_ensure_running_creates_network_when_missing(self) -> None:
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake(argv: list[str]) -> Mock:
|
||||||
|
calls.append(argv)
|
||||||
|
if argv[:3] == ["docker", "network", "inspect"]:
|
||||||
|
return _proc(returncode=1, stderr="No such network")
|
||||||
|
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
|
||||||
|
|
||||||
|
with patch(_RUN_DOCKER, side_effect=fake):
|
||||||
|
self.sc.ensure_running()
|
||||||
|
creates = [c for c in calls if c[:3] == ["docker", "network", "create"]]
|
||||||
|
self.assertEqual([["docker", "network", "create", self.sc.network]], creates)
|
||||||
|
|
||||||
|
def test_ensure_running_reuses_existing_network(self) -> None:
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake(argv: list[str]) -> Mock:
|
||||||
|
calls.append(argv)
|
||||||
|
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
|
||||||
|
|
||||||
|
with patch(_RUN_DOCKER, side_effect=fake):
|
||||||
|
self.sc.ensure_running() # network inspect returns 0 → exists
|
||||||
|
self.assertEqual([], [c for c in calls if c[:3] == ["docker", "network", "create"]])
|
||||||
|
|
||||||
def test_ensure_running_raises_on_docker_failure(self) -> None:
|
def test_ensure_running_raises_on_docker_failure(self) -> None:
|
||||||
def fake(argv: list[str]) -> Mock:
|
def fake(argv: list[str]) -> Mock:
|
||||||
|
|||||||
Reference in New Issue
Block a user