323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""Unit: bundle bringup primitives for the smolmachines backend
|
|
(PRD 0023 chunk 2c).
|
|
|
|
Tests mock `subprocess.run` and assert on the docker argv shape.
|
|
The end-to-end integration smoke (real docker daemon, real
|
|
bundle image) lands in chunk 2d."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from bot_bottle.backend.smolmachines.sidecar_bundle import (
|
|
BundleLaunchSpec,
|
|
allocate_raw_host_ports,
|
|
bundle_container_name,
|
|
bundle_network_name,
|
|
create_bundle_network,
|
|
ensure_bundle_image,
|
|
start_bundle_vm,
|
|
remove_bundle_network,
|
|
start_bundle,
|
|
stop_bundle,
|
|
stop_bundle_vm,
|
|
)
|
|
|
|
|
|
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
|
|
return subprocess.CompletedProcess(
|
|
args=[], returncode=0, stdout=stdout, stderr=stderr,
|
|
)
|
|
|
|
|
|
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
|
|
return subprocess.CompletedProcess(
|
|
args=[], returncode=1, stdout="", stderr=stderr,
|
|
)
|
|
|
|
|
|
def _spec(**kwargs) -> BundleLaunchSpec: # type: ignore
|
|
defaults = dict(
|
|
slug="demo-abc12",
|
|
network_name="bot-bottle-bundle-demo-abc12",
|
|
subnet="192.168.50.0/24",
|
|
gateway="192.168.50.1",
|
|
bundle_ip="192.168.50.2",
|
|
)
|
|
defaults.update(kwargs)
|
|
return BundleLaunchSpec(**defaults) # type: ignore
|
|
|
|
|
|
class TestNamingHelpers(unittest.TestCase):
|
|
def test_network_name_uses_bundle_prefix(self):
|
|
# Distinct from the docker backend's
|
|
# `bot-bottle-net-<slug>` so two backends running the
|
|
# same agent slug don't collide.
|
|
self.assertEqual(
|
|
"bot-bottle-bundle-myagent-xyz",
|
|
bundle_network_name("myagent-xyz"),
|
|
)
|
|
|
|
def test_container_name_matches_docker_bundle_shape(self):
|
|
# Same shape PRD 0024 chunk 5 set for the docker backend's
|
|
# bundle container — dashboard prefix-discovery covers
|
|
# both backends with one filter.
|
|
self.assertEqual(
|
|
"bot-bottle-sidecars-myagent-xyz",
|
|
bundle_container_name("myagent-xyz"),
|
|
)
|
|
|
|
|
|
class TestNetworkLifecycle(unittest.TestCase):
|
|
def _patch_run(self, **kwargs): # type: ignore
|
|
return patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
|
|
**kwargs,
|
|
)
|
|
|
|
def test_create_argv_explicit_subnet_and_gateway(self):
|
|
with self._patch_run(return_value=_ok()) as m:
|
|
create_bundle_network("nn", "192.168.50.0/24", "192.168.50.1")
|
|
self.assertEqual(
|
|
["docker", "network", "create",
|
|
"--subnet", "192.168.50.0/24",
|
|
"--gateway", "192.168.50.1",
|
|
"nn"],
|
|
m.call_args.args[0],
|
|
)
|
|
|
|
def test_create_treats_existing_network_as_success(self):
|
|
with self._patch_run(return_value=_fail("network nn already exists")):
|
|
# No SystemExit.
|
|
create_bundle_network("nn", "192.168.50.0/24", "192.168.50.1")
|
|
|
|
def test_create_other_failure_is_fatal(self):
|
|
with self._patch_run(return_value=_fail("invalid subnet")):
|
|
with self.assertRaises(SystemExit):
|
|
create_bundle_network("nn", "bogus", "bogus")
|
|
|
|
def test_remove_missing_network_is_idempotent(self):
|
|
# No SystemExit / no warn-and-continue noise; missing
|
|
# network is the expected case during a partial teardown.
|
|
with self._patch_run(return_value=_fail("Error: No such network: nn")):
|
|
remove_bundle_network("nn")
|
|
|
|
def test_remove_clean_returns_success(self):
|
|
with self._patch_run(return_value=_ok()):
|
|
remove_bundle_network("nn")
|
|
|
|
|
|
class TestStartBundle(unittest.TestCase):
|
|
def _patch_run(self):
|
|
return patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
|
|
return_value=_ok(),
|
|
)
|
|
|
|
def test_argv_pins_ip_on_network(self):
|
|
with self._patch_run() as m:
|
|
start_bundle(_spec())
|
|
argv = m.call_args.args[0]
|
|
# --network NETNAME --ip <bundle-ip> on the docker run.
|
|
self.assertIn("--network", argv)
|
|
self.assertIn("bot-bottle-bundle-demo-abc12", argv)
|
|
self.assertIn("--ip", argv)
|
|
self.assertIn("192.168.50.2", argv)
|
|
# Detached and auto-removed.
|
|
self.assertIn("--detach", argv)
|
|
self.assertIn("--rm", argv)
|
|
# Container name uses the per-slug bundle prefix.
|
|
i = argv.index("--name")
|
|
self.assertEqual("bot-bottle-sidecars-demo-abc12", argv[i + 1])
|
|
# Image at the end.
|
|
self.assertEqual("bot-bottle-sidecars:latest", argv[-1])
|
|
|
|
def test_daemons_env_passed_in(self):
|
|
with self._patch_run() as m:
|
|
start_bundle(_spec(daemons_csv="egress,supervise"))
|
|
argv = m.call_args.args[0]
|
|
self.assertIn("-e", argv)
|
|
self.assertIn(
|
|
"BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise",
|
|
argv,
|
|
)
|
|
|
|
def test_environment_entries_pass_through(self):
|
|
with self._patch_run() as m:
|
|
start_bundle(_spec(environment=(
|
|
"SUPERVISE_BOTTLE_SLUG=demo-abc12",
|
|
"EGRESS_TOKEN_0", # bare-name → host env inherit
|
|
)))
|
|
argv = m.call_args.args[0]
|
|
self.assertIn("SUPERVISE_BOTTLE_SLUG=demo-abc12", argv)
|
|
self.assertIn("EGRESS_TOKEN_0", argv)
|
|
|
|
def test_volumes_render_with_ro_flag(self):
|
|
with self._patch_run() as m:
|
|
start_bundle(_spec(volumes=(
|
|
("/host/egress-ca.pem", "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", True),
|
|
("/host/queue", "/run/supervise/queue", False),
|
|
)))
|
|
argv = m.call_args.args[0]
|
|
self.assertIn(
|
|
"/host/egress-ca.pem:/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem:ro",
|
|
argv,
|
|
)
|
|
self.assertIn("/host/queue:/run/supervise/queue", argv)
|
|
|
|
def test_failure_dies(self):
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
|
|
return_value=_fail("invalid mount"),
|
|
):
|
|
with self.assertRaises(SystemExit):
|
|
start_bundle(_spec())
|
|
|
|
def test_host_env_inherited_to_subprocess(self):
|
|
# Bare-name entries in spec.environment rely on the docker
|
|
# subprocess being run with the host env. Confirm `env=`
|
|
# threads through.
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
|
|
return_value=_ok(),
|
|
) as m:
|
|
start_bundle(_spec(), env={"FOO": "bar"})
|
|
self.assertEqual({"FOO": "bar"}, m.call_args.kwargs["env"])
|
|
|
|
|
|
class TestEnsureBundleImage(unittest.TestCase):
|
|
def test_builds_sidecar_dockerfile_before_plain_docker_run(self):
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle.docker_mod.build_image",
|
|
) as build:
|
|
ensure_bundle_image()
|
|
|
|
build.assert_called_once()
|
|
args = build.call_args.args
|
|
kwargs = build.call_args.kwargs
|
|
self.assertEqual("bot-bottle-sidecars:latest", args[0])
|
|
self.assertTrue((Path(args[1]) / "Dockerfile.sidecars").is_file())
|
|
self.assertEqual("Dockerfile.sidecars", kwargs["dockerfile"])
|
|
|
|
|
|
class TestStartBundleVm(unittest.TestCase):
|
|
def test_allocate_raw_host_ports_returns_bindable_ports(self):
|
|
raw_ports = allocate_raw_host_ports((9099, 9420))
|
|
|
|
self.assertEqual({9099, 9420}, set(raw_ports))
|
|
for host_port in raw_ports.values():
|
|
self.assertIsInstance(host_port, int)
|
|
self.assertGreater(host_port, 0)
|
|
self.assertNotEqual(raw_ports[9099], raw_ports[9420])
|
|
|
|
def test_creates_sidecar_vm_with_ports_volumes_and_env(self):
|
|
calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = []
|
|
|
|
def record(name): # type: ignore
|
|
def inner(*args, **kwargs): # type: ignore
|
|
calls.append((name, args, kwargs))
|
|
return inner
|
|
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle.allocate_raw_host_ports",
|
|
return_value={9099: 55001, 9420: 55002},
|
|
), patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_create",
|
|
side_effect=record("create"),
|
|
), patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_start",
|
|
side_effect=record("start"),
|
|
), patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.wait_exec_ready",
|
|
side_effect=record("wait"),
|
|
):
|
|
launch = start_bundle_vm(
|
|
_spec(
|
|
daemons_csv="egress,git-http",
|
|
environment=("FOO=bar", "TOKEN"),
|
|
volumes=(("/host/routes", "/etc/egress", True),),
|
|
ports_to_publish=(9099, 9420),
|
|
),
|
|
from_path=Path("/cache/sidecar.smolmachine"),
|
|
host_env={"TOKEN": "secret"},
|
|
)
|
|
|
|
self.assertEqual({9099: 55001, 9420: 55002}, launch.raw_ports)
|
|
create = calls[0]
|
|
self.assertEqual("create", create[0])
|
|
self.assertEqual(("bot-bottle-sidecars-demo-abc12",), create[1])
|
|
self.assertEqual(Path("/cache/sidecar.smolmachine"), create[2]["from_path"])
|
|
self.assertTrue(create[2]["net"])
|
|
self.assertEqual(((55001, 9099), (55002, 9420)), create[2]["ports"])
|
|
self.assertEqual((("/host/routes", "/etc/egress", True),), create[2]["volumes"])
|
|
self.assertEqual(
|
|
{
|
|
"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,git-http",
|
|
"FOO": "bar",
|
|
"TOKEN": "secret",
|
|
},
|
|
create[2]["env"],
|
|
)
|
|
|
|
|
|
class TestStopBundle(unittest.TestCase):
|
|
def _patch_run(self, **kwargs): # type: ignore
|
|
return patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
|
|
**kwargs,
|
|
)
|
|
|
|
def test_argv_force_removes(self):
|
|
with self._patch_run(return_value=_ok()) as m:
|
|
stop_bundle("demo-abc12")
|
|
self.assertEqual(
|
|
["docker", "rm", "-f", "bot-bottle-sidecars-demo-abc12"],
|
|
m.call_args.args[0],
|
|
)
|
|
|
|
def test_missing_container_is_idempotent(self):
|
|
with self._patch_run(return_value=_fail(
|
|
"Error: No such container: bot-bottle-sidecars-demo-abc12"
|
|
)):
|
|
stop_bundle("demo-abc12") # no raise
|
|
|
|
|
|
class TestStopBundleVm(unittest.TestCase):
|
|
def test_stops_then_deletes_sidecar_vm(self):
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_stop",
|
|
) as stop, patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_delete",
|
|
) as delete:
|
|
stop_bundle_vm("demo-abc12")
|
|
stop.assert_called_once_with("bot-bottle-sidecars-demo-abc12")
|
|
delete.assert_called_once_with("bot-bottle-sidecars-demo-abc12")
|
|
|
|
def test_stop_and_delete_errors_are_warnings(self):
|
|
from bot_bottle.backend.smolmachines.smolvm import SmolvmError
|
|
result = subprocess.CompletedProcess(
|
|
args=[],
|
|
returncode=1,
|
|
stdout="",
|
|
stderr="failed",
|
|
)
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_stop",
|
|
side_effect=SmolvmError(["smolvm", "machine", "stop"], result),
|
|
), patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_delete",
|
|
side_effect=SmolvmError(["smolvm", "machine", "delete"], result),
|
|
), patch(
|
|
"bot_bottle.backend.smolmachines.sidecar_bundle.warn",
|
|
) as warn:
|
|
stop_bundle_vm("demo-abc12")
|
|
|
|
self.assertEqual(2, warn.call_count)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|