Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5ba33bf0a | |||
| 72286f188d | |||
| 77aaabae63 |
@@ -0,0 +1,47 @@
|
|||||||
|
"""Shared-gateway source-IP allocation for the consolidated docker backend
|
||||||
|
(PRD 0070).
|
||||||
|
|
||||||
|
In the consolidated model one gateway container serves every bottle over a
|
||||||
|
single shared docker network, and each agent bottle attaches with a pinned,
|
||||||
|
deterministic address that the gateway uses as its **attribution key**. This
|
||||||
|
allocates those addresses from the network's subnet, skipping the reserved
|
||||||
|
ones — the network address and broadcast (excluded by `hosts()`), docker's
|
||||||
|
router `.1`, and everything already in use (`taken`: the gateway container
|
||||||
|
plus every live bottle, which the caller reads from the registry).
|
||||||
|
|
||||||
|
Pure `ipaddress` logic — the docker-specific bits (the subnet CIDR, the
|
||||||
|
gateway container's own address) are gathered by the caller and passed in, so
|
||||||
|
this stays testable without docker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
class NoFreeAddressError(RuntimeError):
|
||||||
|
"""The shared gateway network's subnet is exhausted — every host address
|
||||||
|
is reserved or already assigned to a bottle."""
|
||||||
|
|
||||||
|
|
||||||
|
def next_free_ip(cidr: str, taken: Iterable[str]) -> str:
|
||||||
|
"""The lowest host address in `cidr` not in `taken` and not docker's
|
||||||
|
router (`.1`). `taken` must include the gateway container's own address
|
||||||
|
and every live bottle's. Raises `NoFreeAddressError` if the subnet is
|
||||||
|
full."""
|
||||||
|
net = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
reserved = {str(a) for a in taken}
|
||||||
|
# Docker assigns the network's first host (.1) to the bridge router; a
|
||||||
|
# bottle must never be handed that address.
|
||||||
|
reserved.add(str(net.network_address + 1))
|
||||||
|
for host in net.hosts(): # hosts() already excludes network + broadcast
|
||||||
|
candidate = str(host)
|
||||||
|
if candidate not in reserved:
|
||||||
|
return candidate
|
||||||
|
raise NoFreeAddressError(
|
||||||
|
f"no free address in {cidr} ({len(reserved)} reserved/assigned)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["next_free_ip", "NoFreeAddressError"]
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Host-side control-plane client (PRD 0070).
|
||||||
|
|
||||||
|
The launch path talks to the orchestrator over its HTTP control plane to
|
||||||
|
register, re-policy, and tear down bottles — the counterpart to the
|
||||||
|
gateway-side `PolicyResolver` (which only reads `/resolve`). Where
|
||||||
|
`PolicyResolver` is fail-closed and lives in the untrusted data plane, this
|
||||||
|
is the trusted control-plane caller: a non-success response is an error the
|
||||||
|
launch path must surface, not silently swallow.
|
||||||
|
|
||||||
|
Stdlib-only, so the CLI can drive the orchestrator without any dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
class OrchestratorClientError(RuntimeError):
|
||||||
|
"""A control-plane call failed (unreachable, or an unexpected status)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RegisteredBottle:
|
||||||
|
"""What `POST /bottles` returns: the minted bottle id and the per-bottle
|
||||||
|
identity token the agent presents for app-layer attribution."""
|
||||||
|
|
||||||
|
bottle_id: str
|
||||||
|
identity_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class OrchestratorClient:
|
||||||
|
"""Trusted host-side client for the orchestrator control plane."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None:
|
||||||
|
self._base = base_url.rstrip("/")
|
||||||
|
self._timeout = timeout
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self, method: str, path: str, body: dict[str, object] | None = None,
|
||||||
|
) -> tuple[int, dict[str, object]]:
|
||||||
|
"""Send one request; return `(status, payload)`. Raises
|
||||||
|
`OrchestratorClientError` only when the orchestrator can't be reached
|
||||||
|
or returns malformed data — HTTP *status* codes are returned so
|
||||||
|
callers can treat 404 as a meaningful "no such bottle"."""
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._base}{path}", data=data, method=method, headers=headers,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
payload = json.loads(raw) if raw else {}
|
||||||
|
return resp.status, payload if isinstance(payload, dict) else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
# A structured error response still carries a usable status.
|
||||||
|
try:
|
||||||
|
payload = json.loads(e.read() or b"{}")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
payload = {}
|
||||||
|
return e.code, payload if isinstance(payload, dict) else {}
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
||||||
|
raise OrchestratorClientError(f"{method} {path}: {e}") from e
|
||||||
|
|
||||||
|
def _ok(self, method: str, path: str, body: dict[str, object] | None = None) -> dict[str, object]:
|
||||||
|
"""`_request` that requires a 2xx, raising otherwise."""
|
||||||
|
status, payload = self._request(method, path, body)
|
||||||
|
if not 200 <= status < 300:
|
||||||
|
detail = payload.get("error", "")
|
||||||
|
raise OrchestratorClientError(f"{method} {path}: HTTP {status} {detail}".rstrip())
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def health(self) -> bool:
|
||||||
|
"""True iff the control plane answers `GET /health` with 200."""
|
||||||
|
try:
|
||||||
|
status, _ = self._request("GET", "/health")
|
||||||
|
except OrchestratorClientError:
|
||||||
|
return False
|
||||||
|
return status == 200
|
||||||
|
|
||||||
|
def register_bottle(
|
||||||
|
self,
|
||||||
|
source_ip: str,
|
||||||
|
*,
|
||||||
|
image_ref: str = "",
|
||||||
|
metadata: str = "",
|
||||||
|
policy: str = "",
|
||||||
|
) -> RegisteredBottle:
|
||||||
|
"""Register a bottle and broker its launch (`POST /bottles`). Returns
|
||||||
|
its minted id + identity token."""
|
||||||
|
payload = self._ok("POST", "/bottles", {
|
||||||
|
"source_ip": source_ip,
|
||||||
|
"image_ref": image_ref,
|
||||||
|
"metadata": metadata,
|
||||||
|
"policy": policy,
|
||||||
|
})
|
||||||
|
bottle_id = payload.get("bottle_id")
|
||||||
|
token = payload.get("identity_token")
|
||||||
|
if not isinstance(bottle_id, str) or not isinstance(token, str):
|
||||||
|
raise OrchestratorClientError("register: response missing bottle_id/identity_token")
|
||||||
|
return RegisteredBottle(bottle_id=bottle_id, identity_token=token)
|
||||||
|
|
||||||
|
def teardown_bottle(self, bottle_id: str) -> bool:
|
||||||
|
"""Tear a bottle down (`DELETE /bottles/<id>`). False if the
|
||||||
|
orchestrator didn't know it (404) — idempotent for cleanup paths."""
|
||||||
|
status, _ = self._request("DELETE", f"/bottles/{bottle_id}")
|
||||||
|
if status == 404:
|
||||||
|
return False
|
||||||
|
if not 200 <= status < 300:
|
||||||
|
raise OrchestratorClientError(f"teardown {bottle_id}: HTTP {status}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
||||||
|
"""Live-reload a bottle's policy (`PUT /bottles/<id>/policy`). False on
|
||||||
|
404 (unknown bottle)."""
|
||||||
|
status, _ = self._request("PUT", f"/bottles/{bottle_id}/policy", {"policy": policy})
|
||||||
|
if status == 404:
|
||||||
|
return False
|
||||||
|
if not 200 <= status < 300:
|
||||||
|
raise OrchestratorClientError(f"set_policy {bottle_id}: HTTP {status}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_bottles(self) -> list[dict[str, object]]:
|
||||||
|
"""Every registered bottle's redacted record (`GET /bottles`)."""
|
||||||
|
payload = self._ok("GET", "/bottles")
|
||||||
|
bottles = payload.get("bottles")
|
||||||
|
return bottles if isinstance(bottles, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"OrchestratorClient",
|
||||||
|
"OrchestratorClientError",
|
||||||
|
"RegisteredBottle",
|
||||||
|
"DEFAULT_TIMEOUT_SECONDS",
|
||||||
|
]
|
||||||
@@ -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,42 @@
|
|||||||
|
"""Unit: shared-gateway source-IP allocation (PRD 0070)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from bot_bottle.backend.docker.gateway_net import NoFreeAddressError, next_free_ip
|
||||||
|
|
||||||
|
|
||||||
|
class TestNextFreeIp(unittest.TestCase):
|
||||||
|
def test_skips_router_and_returns_first_host(self) -> None:
|
||||||
|
# .1 is docker's router; the first assignable address is .2.
|
||||||
|
self.assertEqual("172.18.0.2", next_free_ip("172.18.0.0/16", []))
|
||||||
|
|
||||||
|
def test_skips_taken_addresses(self) -> None:
|
||||||
|
# Gateway container holds .2, a live bottle holds .3 -> next is .4.
|
||||||
|
self.assertEqual(
|
||||||
|
"172.18.0.4", next_free_ip("172.18.0.0/16", ["172.18.0.2", "172.18.0.3"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_taken_order_does_not_matter(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
"172.18.0.2", next_free_ip("172.18.0.0/16", ["172.18.0.3", "172.18.0.5"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_allocation_is_deterministic(self) -> None:
|
||||||
|
taken = ["172.18.0.2"]
|
||||||
|
self.assertEqual(next_free_ip("172.18.0.0/16", taken),
|
||||||
|
next_free_ip("172.18.0.0/16", taken))
|
||||||
|
|
||||||
|
def test_raises_when_subnet_exhausted(self) -> None:
|
||||||
|
# /30: hosts are .1 (router, reserved) and .2; taking .2 leaves none.
|
||||||
|
with self.assertRaises(NoFreeAddressError):
|
||||||
|
next_free_ip("10.9.9.0/30", ["10.9.9.2"])
|
||||||
|
|
||||||
|
def test_accepts_host_bits_set_cidr(self) -> None:
|
||||||
|
# A container's inspected address arrives as e.g. 172.18.0.2/16.
|
||||||
|
self.assertEqual("172.18.0.2", next_free_ip("172.18.0.5/16", []))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Unit: host-side orchestrator control-plane client (PRD 0070). HTTP mocked."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from bot_bottle.orchestrator.client import (
|
||||||
|
OrchestratorClient,
|
||||||
|
OrchestratorClientError,
|
||||||
|
RegisteredBottle,
|
||||||
|
)
|
||||||
|
|
||||||
|
_URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
|
||||||
|
|
||||||
|
|
||||||
|
def _resp(status: int, payload: object) -> MagicMock:
|
||||||
|
m = MagicMock()
|
||||||
|
inner = m.__enter__.return_value
|
||||||
|
inner.status = status
|
||||||
|
inner.read.return_value = json.dumps(payload).encode()
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error(code: int, payload: object = None) -> urllib.error.HTTPError:
|
||||||
|
del payload # the client tolerates an empty error body; keep the signature
|
||||||
|
return urllib.error.HTTPError("http://x", code, "err", {}, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegister(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.c = OrchestratorClient("http://orch:8080")
|
||||||
|
|
||||||
|
def test_register_returns_id_and_token(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp(201, {"bottle_id": "b1", "identity_token": "tok"})):
|
||||||
|
got = self.c.register_bottle("10.0.0.2", policy="routes: []\n", metadata="{}")
|
||||||
|
self.assertEqual(RegisteredBottle("b1", "tok"), got)
|
||||||
|
|
||||||
|
def test_register_posts_source_ip_and_policy(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp(201, {"bottle_id": "b", "identity_token": "t"})) as m:
|
||||||
|
self.c.register_bottle("10.0.0.9", policy="P", metadata="M", image_ref="img")
|
||||||
|
sent = json.loads(m.call_args.args[0].data)
|
||||||
|
self.assertEqual("10.0.0.9", sent["source_ip"])
|
||||||
|
self.assertEqual("P", sent["policy"])
|
||||||
|
self.assertEqual("img", sent["image_ref"])
|
||||||
|
|
||||||
|
def test_register_missing_fields_raises(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp(201, {"bottle_id": "b1"})):
|
||||||
|
with self.assertRaises(OrchestratorClientError):
|
||||||
|
self.c.register_bottle("10.0.0.2")
|
||||||
|
|
||||||
|
def test_register_non_2xx_raises(self) -> None:
|
||||||
|
with patch(_URLOPEN, side_effect=_http_error(400, {"error": "bad"})):
|
||||||
|
with self.assertRaises(OrchestratorClientError):
|
||||||
|
self.c.register_bottle("")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTeardown(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.c = OrchestratorClient("http://orch:8080")
|
||||||
|
|
||||||
|
def test_teardown_true_on_success(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp(200, {"torn_down": True})):
|
||||||
|
self.assertTrue(self.c.teardown_bottle("b1"))
|
||||||
|
|
||||||
|
def test_teardown_false_on_404(self) -> None:
|
||||||
|
# Idempotent: an already-gone bottle is a clean no-op.
|
||||||
|
with patch(_URLOPEN, side_effect=_http_error(404)):
|
||||||
|
self.assertFalse(self.c.teardown_bottle("gone"))
|
||||||
|
|
||||||
|
def test_teardown_uses_delete(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp(200, {"torn_down": True})) as m:
|
||||||
|
self.c.teardown_bottle("b1")
|
||||||
|
self.assertEqual("DELETE", m.call_args.args[0].get_method())
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthAndPolicy(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.c = OrchestratorClient("http://orch:8080")
|
||||||
|
|
||||||
|
def test_health_true_on_200(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp(200, {"status": "ok"})):
|
||||||
|
self.assertTrue(self.c.health())
|
||||||
|
|
||||||
|
def test_health_false_when_unreachable(self) -> None:
|
||||||
|
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
||||||
|
self.assertFalse(self.c.health())
|
||||||
|
|
||||||
|
def test_set_policy_false_on_404(self) -> None:
|
||||||
|
with patch(_URLOPEN, side_effect=_http_error(404)):
|
||||||
|
self.assertFalse(self.c.set_policy("gone", "P"))
|
||||||
|
|
||||||
|
def test_set_policy_true_on_success(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp(200, {"updated": True})):
|
||||||
|
self.assertTrue(self.c.set_policy("b1", "P"))
|
||||||
|
|
||||||
|
def test_unreachable_raises(self) -> None:
|
||||||
|
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
||||||
|
with self.assertRaises(OrchestratorClientError):
|
||||||
|
self.c.register_bottle("10.0.0.2")
|
||||||
|
|
||||||
|
|
||||||
|
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