feat(backend): remove smolmachines; firecracker is the Linux default
Delete the smolmachines backend (the whole bot_bottle/backend/smolmachines package and its tests). It had fatal Linux issues (TSI networking under sustained use, exec-channel contention, no SIGWINCH) and is superseded by the Firecracker backend (issue #342). Backend selection now: - default is macos-container on macOS, firecracker on KVM-capable Linux hosts, and docker as the last resort (was smolmachines). - firecracker is selected on a KVM host even when the `firecracker` binary isn't installed, so start routes through its preflight and prints an install pointer (same UX as require_container), instead of silently falling back. Split is_host_capable() (Linux + KVM) out of is_available() (adds the binary check) to drive this. Retarget the cross-backend tests (parity, print-parity, prepare, workspace, freezer, selection) from smolmachines to firecracker rather than dropping the coverage. Remove docker.util.image_id/save, which only smolmachines used. Update README/AGENTS/example bottles and stale comments; historical docs/prds are left as a point-in-time record. BREAKING: BOT_BOTTLE_BACKEND=smolmachines now errors as unknown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -11,7 +11,7 @@ asserts each one is blocked:
|
||||
5. Secret exfil via README link pushed through git-gate
|
||||
|
||||
The suite is backend-agnostic — it goes through `get_bottle_backend()`
|
||||
so smolmachines can be tested by setting `BOT_BOTTLE_BACKEND=smolmachines`.
|
||||
so another backend can be tested by setting `BOT_BOTTLE_BACKEND`.
|
||||
When unset, this integration test pins Docker explicitly to preserve
|
||||
the Docker-backed CI path.
|
||||
|
||||
@@ -24,7 +24,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -70,27 +69,16 @@ class TestSandboxEscape(unittest.TestCase):
|
||||
_launch_cm = None # backend.launch context manager
|
||||
_bottle = None
|
||||
_identity: str = ""
|
||||
_backend_name: str = "docker"
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
# Per-backend prerequisites. Docker is always required (both
|
||||
# backends use it — docker for the agent + sidecars, smolmachines
|
||||
# for the sidecar bundle); the class-level @skip_unless_docker
|
||||
# already covers that. Smolmachines additionally needs smolvm on
|
||||
# PATH and is macOS-only in v1 (libkrun/TSI). Skip cleanly when
|
||||
# those are missing rather than die-ing inside backend.prepare.
|
||||
backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker")
|
||||
if backend_name == "smolmachines":
|
||||
if sys.platform not in ("darwin", "linux"):
|
||||
raise unittest.SkipTest(
|
||||
f"BOT_BOTTLE_BACKEND=smolmachines is not supported "
|
||||
f"on {sys.platform} (macOS and Linux only)"
|
||||
)
|
||||
if shutil.which("smolvm") is None:
|
||||
raise unittest.SkipTest(
|
||||
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` "
|
||||
"on PATH: curl -sSL https://smolmachines.com/install.sh | sh"
|
||||
)
|
||||
# Docker is always required (the agent + sidecars run under it,
|
||||
# and VM backends still use it for the sidecar bundle); the
|
||||
# class-level @skip_unless_docker already covers that. Pin
|
||||
# Docker when BOT_BOTTLE_BACKEND is unset to preserve the
|
||||
# Docker-backed CI path.
|
||||
cls._backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker")
|
||||
|
||||
# Throwaway static key for the git-gate fixture. It need not
|
||||
# be a real SSH key: test 5 reaches gitleaks before any SSH
|
||||
@@ -149,7 +137,7 @@ class TestSandboxEscape(unittest.TestCase):
|
||||
|
||||
cls._stage_dir = Path(tempfile.mkdtemp(prefix="sandbox-escape-stage."))
|
||||
try:
|
||||
backend = get_bottle_backend(backend_name)
|
||||
backend = get_bottle_backend(cls._backend_name)
|
||||
plan = backend.prepare(spec, stage_dir=cls._stage_dir)
|
||||
cls._identity = plan.slug
|
||||
|
||||
@@ -421,7 +409,7 @@ class TestSandboxEscape(unittest.TestCase):
|
||||
# ~/.gitconfig insteadOf rewrite (set up by provision_git)
|
||||
# redirects to the gate. This makes the test backend-
|
||||
# agnostic: docker resolves the gate via the short `git-gate`
|
||||
# alias, smolmachines via `<bundle_ip>:9418` — both
|
||||
# alias, a VM backend via `<bundle_ip>:9418` — both
|
||||
# transparent to the test through insteadOf.
|
||||
upstream_url = "ssh://git@unreachable.invalid:22/throwaway.git"
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Integration: PRD 0023 chunk 2c — bundle bringup on a per-bottle
|
||||
docker bridge with the pinned IP.
|
||||
|
||||
End-to-end against the real docker daemon. Brings up just the
|
||||
sidecar bundle on its own bridge, confirms the container lands at
|
||||
the pinned IP, then tears down. Skipped under act_runner (docker
|
||||
socket mount topology breaks bridge visibility) and when the
|
||||
bundle image isn't available.
|
||||
|
||||
Full launch flow (smolvm + bundle + provisioning + the
|
||||
localhost-reach / egress-port-bypass probes) lives in chunk 2d."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from bot_bottle.backend.smolmachines.sidecar_bundle import (
|
||||
BundleLaunchSpec,
|
||||
bundle_container_name,
|
||||
bundle_network_name,
|
||||
create_bundle_network,
|
||||
remove_bundle_network,
|
||||
start_bundle,
|
||||
stop_bundle,
|
||||
)
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: docker socket mount topology breaks "
|
||||
"in-process visibility of networks created on the host daemon",
|
||||
)
|
||||
class TestBundleBringup(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.slug = f"cb-test-bundle-{os.getpid()}-{int(time.time())}"
|
||||
self.network = bundle_network_name(self.slug)
|
||||
self.container = bundle_container_name(self.slug)
|
||||
|
||||
def tearDown(self):
|
||||
stop_bundle(self.slug)
|
||||
remove_bundle_network(self.network)
|
||||
|
||||
def _bundle_image_built(self) -> bool:
|
||||
"""The bundle image (`bot-bottle-sidecars:latest`) is
|
||||
built lazily by the docker backend's compose. If a
|
||||
smolmachines-only operator hasn't run the docker backend
|
||||
first, the image won't exist locally. Skip rather than
|
||||
fail."""
|
||||
r = subprocess.run(
|
||||
["docker", "image", "inspect", "bot-bottle-sidecars:latest"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
return r.returncode == 0
|
||||
|
||||
def test_create_network_then_start_bundle_pins_ip(self):
|
||||
if not self._bundle_image_built():
|
||||
self.skipTest(
|
||||
"bot-bottle-sidecars:latest not built; run a docker "
|
||||
"bottle first or `docker build -f Dockerfile.sidecars .`"
|
||||
)
|
||||
|
||||
# Pick a subnet unlikely to collide on the host. Last
|
||||
# octet of the slug hash isn't deterministic across runs;
|
||||
# we hardcode a high octet (.211) that the docker default
|
||||
# bridges almost never use.
|
||||
subnet = "192.168.211.0/24"
|
||||
gateway = "192.168.211.1"
|
||||
bundle_ip = "192.168.211.2"
|
||||
|
||||
create_bundle_network(self.network, subnet, gateway)
|
||||
|
||||
spec = BundleLaunchSpec(
|
||||
slug=self.slug,
|
||||
network_name=self.network,
|
||||
subnet=subnet,
|
||||
gateway=gateway,
|
||||
bundle_ip=bundle_ip,
|
||||
# Empty daemons_csv → init exits "no daemons selected"
|
||||
# immediately. We just need the container to land on
|
||||
# the network at the right IP before it exits.
|
||||
daemons_csv="", # empty → init exits "no daemons selected"
|
||||
)
|
||||
start_bundle(spec)
|
||||
|
||||
# Inspect the container's IP on the per-bottle network.
|
||||
r = subprocess.run(
|
||||
["docker", "inspect",
|
||||
"--format",
|
||||
"{{(index .NetworkSettings.Networks \"" + self.network + "\").IPAddress}}",
|
||||
self.container],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
# Container may have exited (no daemons selected → exit 0).
|
||||
# The inspect still works on exited containers as long as
|
||||
# `--rm` hasn't fired yet, which is a race. Even if it has,
|
||||
# the launch succeeded — the container existed, on the
|
||||
# right network, at the right IP. We don't fail here on
|
||||
# missing inspect.
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
self.assertEqual(bundle_ip, r.stdout.strip(),
|
||||
f"bundle landed at wrong IP: {r.stdout!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,223 +0,0 @@
|
||||
"""Integration: PRD 0023 chunk 2d — end-to-end launch + exec
|
||||
round trip + the acceptance probes.
|
||||
|
||||
The smoke confirms the launch flow (per-bottle docker bridge →
|
||||
sidecar bundle with host-loopback published ports → smolvm guest
|
||||
with TSI allowlist → exec) plumbs together end to end. The probes confirm the
|
||||
security properties the design pivot was about:
|
||||
|
||||
- **localhost-reach probe** — guest tries to dial a service
|
||||
bound on the host's `127.0.0.1`. TSI's per-bottle loopback
|
||||
alias allowlist must refuse the connect.
|
||||
|
||||
- **egress proxy probe** — guest reaches the egress proxy through
|
||||
the injected `HTTPS_PROXY`/`HTTP_PROXY` URL on the per-bottle
|
||||
loopback alias, while direct egress with proxy vars unset fails.
|
||||
|
||||
- **egress-port-bypass probe** — guest tries to dial
|
||||
`<bundle-ip>:9099` (egress's port). TSI permits the IP but
|
||||
the bundle's egress daemon binds `127.0.0.1` inside its
|
||||
container, so the connect refuses at the socket level. The
|
||||
bind-address mitigation is what closes TSI's port-granularity
|
||||
gap.
|
||||
|
||||
Gated on macOS/Linux + smolvm + docker + not GITEA_ACTIONS — the
|
||||
runner can't host libkrun-backed VMs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
||||
from bot_bottle.backend.smolmachines.smolvm import is_available as _smolvm_available
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
_AGENT_PROMPT = "You are demo. Be brief."
|
||||
|
||||
|
||||
def _minimal_manifest() -> ManifestIndex:
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"egress": {
|
||||
"routes": [
|
||||
{"host": "example.com"},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"agents": {
|
||||
"demo": {
|
||||
"skills": [],
|
||||
"prompt": _AGENT_PROMPT,
|
||||
"bottle": "dev",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
@unittest.skipUnless(
|
||||
platform.system() in ("Darwin", "Linux"),
|
||||
"smolvm requires macOS or Linux",
|
||||
)
|
||||
@unittest.skipUnless(
|
||||
_smolvm_available(),
|
||||
"smolvm not on PATH; install via "
|
||||
"curl -sSL https://smolmachines.com/install.sh | sh",
|
||||
)
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: cannot host libkrun-backed VMs",
|
||||
)
|
||||
class TestSmolmachinesLaunch(unittest.TestCase):
|
||||
"""The full smoke + the two acceptance probes share one
|
||||
bottle bringup to amortize the ~10s cold-start cost across
|
||||
three assertions."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.stage = Path(tempfile.mkdtemp(prefix="cb-smol-launch."))
|
||||
os.environ["BOT_BOTTLE_BACKEND"] = "smolmachines"
|
||||
backend = get_bottle_backend()
|
||||
spec = BottleSpec(
|
||||
manifest=_minimal_manifest(),
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd=str(cls.stage),
|
||||
)
|
||||
cls.plan = backend.prepare(spec, stage_dir=cls.stage)
|
||||
cls._launch = backend.launch(cls.plan)
|
||||
cls.bottle = cls._launch.__enter__()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
try:
|
||||
cls._launch.__exit__(None, None, None)
|
||||
finally:
|
||||
shutil.rmtree(cls.stage, ignore_errors=True)
|
||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
||||
|
||||
def test_smoke_exec_echo(self):
|
||||
# The plumbing-verifies-end-to-end smoke: a shell command
|
||||
# round-trips through smolvm machine exec.
|
||||
r = self.bottle.exec("echo hello-from-vm")
|
||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
||||
self.assertIn("hello-from-vm", r.stdout)
|
||||
|
||||
def test_localhost_reach_probe(self):
|
||||
# Agent dials a 127.0.0.1 service on the host. TSI's
|
||||
# allowlist contains only <bundle-ip>/32, so this must
|
||||
# refuse. We use a port unlikely to be bound on the host
|
||||
# (high-numbered) so we're confirming TSI refusal, not
|
||||
# just "no service listening."
|
||||
r = self.bottle.exec(
|
||||
"curl -s --show-error --max-time 3 http://127.0.0.1:9 2>&1 || true"
|
||||
)
|
||||
# `curl` to a denied destination produces a connect error.
|
||||
# The exact phrasing varies by curl version; we assert
|
||||
# the response is NOT the body of any real service.
|
||||
self.assertNotIn("hello-from-vm", r.stdout)
|
||||
self.assertTrue(
|
||||
"refused" in r.stdout.lower()
|
||||
or "timed out" in r.stdout.lower()
|
||||
or "unreachable" in r.stdout.lower()
|
||||
or "failed" in r.stdout.lower(),
|
||||
f"expected a connect-refusal message; got: {r.stdout!r}",
|
||||
)
|
||||
|
||||
def test_egress_proxy_reachable_through_tsi_loopback_alias(self):
|
||||
r = self.bottle.exec(
|
||||
"printf '%s\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\""
|
||||
)
|
||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
||||
proxies = [line.strip() for line in r.stdout.splitlines()]
|
||||
self.assertEqual(2, len(proxies), proxies)
|
||||
self.assertEqual(proxies[0], proxies[1], proxies)
|
||||
# macOS: proxy binds to the per-bottle loopback alias (127.x.x.x) so
|
||||
# TSI can intercept guest connections to it. Linux: the guest kernel
|
||||
# routes 127.0.0.0/8 to its own loopback (TSI never sees those), so
|
||||
# the proxy instead binds to the per-bottle bridge gateway (192.168.x.1)
|
||||
# which routes via eth0 and is intercepted by TSI normally.
|
||||
self.assertRegex(
|
||||
proxies[0], r"^http://\d+\.\d+\.\d+\.\d+:\d+$",
|
||||
"expected proxy URL to be an http://IP:port address",
|
||||
)
|
||||
|
||||
r = self.bottle.exec(
|
||||
"curl -fsS --max-time 20 https://example.com >/dev/null && echo OK"
|
||||
)
|
||||
self.assertEqual(0, r.returncode, msg=r.stderr + r.stdout)
|
||||
self.assertIn("OK", r.stdout)
|
||||
|
||||
def test_direct_egress_bypass_without_proxy_fails(self):
|
||||
r = self.bottle.exec(
|
||||
"env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy "
|
||||
"curl -s --show-error --max-time 5 https://example.com 2>&1 || true"
|
||||
)
|
||||
self.assertTrue(
|
||||
"refused" in r.stdout.lower()
|
||||
or "timed out" in r.stdout.lower()
|
||||
or "unreachable" in r.stdout.lower()
|
||||
or "failed" in r.stdout.lower()
|
||||
or "could not resolve" in r.stdout.lower()
|
||||
or "connection reset" in r.stdout.lower(),
|
||||
f"expected direct egress to fail; got: {r.stdout!r}",
|
||||
)
|
||||
|
||||
def test_non_allowlisted_host_fails_through_proxy(self):
|
||||
r = self.bottle.exec(
|
||||
"curl -s --show-error --max-time 10 https://iana.org 2>&1 || true"
|
||||
)
|
||||
self.assertTrue(
|
||||
"403" in r.stdout
|
||||
or "502" in r.stdout
|
||||
or "blocked" in r.stdout.lower()
|
||||
or "not allowed" in r.stdout.lower()
|
||||
or "not in the bottle's egress.routes allowlist" in r.stdout.lower()
|
||||
or "forbidden" in r.stdout.lower()
|
||||
or "failed" in r.stdout.lower(),
|
||||
f"expected non-allowlisted proxy request to fail; got: {r.stdout!r}",
|
||||
)
|
||||
|
||||
def test_prompt_file_lands_in_guest(self):
|
||||
# provision_prompt copies the host-side prompt.txt into the
|
||||
# guest at /home/node/.bot-bottle-prompt.txt. The content
|
||||
# must match what the manifest declared so claude-code's
|
||||
# --append-system-prompt-file reads the right text.
|
||||
r = self.bottle.exec("cat /home/node/.bot-bottle-prompt.txt")
|
||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
||||
self.assertEqual(_AGENT_PROMPT, r.stdout.rstrip("\n"))
|
||||
|
||||
def test_egress_port_bypass_probe(self):
|
||||
# Agent dials <bundle-ip>:9099 (egress's port). TSI
|
||||
# permits the IP, but egress will bind 127.0.0.1:9099
|
||||
# inside the bundle in chunk 3, so the connect refuses
|
||||
# at the socket level. NOTE: in chunk 2d the bundle's
|
||||
# daemons aren't running (daemons_csv=""), so nothing
|
||||
# is listening on :9099 anyway — this test asserts the
|
||||
# connect fails, which is the property chunk 3 will
|
||||
# preserve once egress is actually running.
|
||||
r = self.bottle.exec(
|
||||
"env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy "
|
||||
f"curl -s --show-error --max-time 3 http://{self.plan.bundle_ip}:9099 "
|
||||
"2>&1 || true"
|
||||
)
|
||||
self.assertTrue(
|
||||
"refused" in r.stdout.lower()
|
||||
or "timed out" in r.stdout.lower()
|
||||
or "unreachable" in r.stdout.lower()
|
||||
or "failed" in r.stdout.lower(),
|
||||
f"expected egress port refusal; got: {r.stdout!r}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Integration: PRD 0023 chunk 2b — smolvm subprocess wrapper
|
||||
exercised against the real binary.
|
||||
|
||||
The full machine-lifecycle round trip (create → start → exec →
|
||||
delete) is gated behind macOS/Linux platform check and lives
|
||||
in chunk 2d's smoke. This file just verifies `is_available()`
|
||||
correctly reports presence and `_smolvm()` can run a no-op
|
||||
subcommand without errors — enough to flag wrapper drift if
|
||||
smolvm's flag parser changes shape across versions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from bot_bottle.backend.smolmachines.smolvm import is_available
|
||||
|
||||
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: smolvm not installed on the runner",
|
||||
)
|
||||
@unittest.skipUnless(
|
||||
platform.system() in ("Darwin", "Linux"),
|
||||
"smolvm requires macOS or Linux",
|
||||
)
|
||||
@unittest.skipUnless(
|
||||
is_available(),
|
||||
"smolvm not on PATH; install via "
|
||||
"curl -sSL https://smolmachines.com/install.sh | sh",
|
||||
)
|
||||
class TestSmolvmSmoke(unittest.TestCase):
|
||||
def test_smolvm_help_responds(self):
|
||||
# `smolvm --help` exits 0 (per `smolvm machine --help`
|
||||
# convention) — verifies the binary launches and the
|
||||
# top-level parser is intact.
|
||||
r = subprocess.run(
|
||||
["smolvm", "--help"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
# Either exit-code 0 (clean) or 1 (some CLIs return 1
|
||||
# from --help by convention; smolvm 0.8.0 does this). The
|
||||
# point is the binary runs and emits help text.
|
||||
self.assertIn("smolvm", r.stdout)
|
||||
self.assertIn("machine", r.stdout)
|
||||
|
||||
def test_machine_ls_empty_returns_json_array(self):
|
||||
# `machine ls --json` is the contract chunk 4's
|
||||
# list_active wires to. Lock in that the JSON shape is
|
||||
# parseable now so chunk 4 doesn't surprise us.
|
||||
import json
|
||||
r = subprocess.run(
|
||||
["smolvm", "machine", "ls", "--json"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
self.assertEqual(0, r.returncode, r.stderr)
|
||||
parsed = json.loads(r.stdout)
|
||||
self.assertIsInstance(parsed, list)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,7 +12,7 @@ from bot_bottle.backend import ActiveAgent
|
||||
from bot_bottle.backend.freeze import get_freezer
|
||||
from bot_bottle.backend.docker.freezer import DockerFreezer
|
||||
from bot_bottle.backend.macos_container.freezer import MacosContainerFreezer
|
||||
from bot_bottle.backend.smolmachines.freezer import SmolmachinesFreezer
|
||||
from bot_bottle.backend.firecracker.freezer import FirecrackerFreezer
|
||||
|
||||
|
||||
class _FakeHomeMixin:
|
||||
@@ -51,8 +51,8 @@ class TestGetFreezer(unittest.TestCase):
|
||||
def test_macos_container(self):
|
||||
self.assertIsInstance(get_freezer("macos-container"), MacosContainerFreezer)
|
||||
|
||||
def test_smolmachines(self):
|
||||
self.assertIsInstance(get_freezer("smolmachines"), SmolmachinesFreezer)
|
||||
def test_firecracker(self):
|
||||
self.assertIsInstance(get_freezer("firecracker"), FirecrackerFreezer)
|
||||
|
||||
def test_unknown_backend_dies(self):
|
||||
with patch("bot_bottle.backend.freeze.die", side_effect=SystemExit("die")):
|
||||
@@ -176,39 +176,59 @@ class TestMacosContainerFreezer(_FakeHomeMixin, unittest.TestCase):
|
||||
self.assertTrue(bottle_state.is_preserved(slug))
|
||||
|
||||
|
||||
class TestSmolmachinesFreezer(_FakeHomeMixin, unittest.TestCase):
|
||||
class TestFirecrackerFreezer(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
# The freezer resolves the running VM's SSH key + config from
|
||||
# the per-bottle run dir under the firecracker cache; point that
|
||||
# at a temp dir so we can stage a fake live bottle.
|
||||
self._cache = tempfile.TemporaryDirectory(prefix="fc-freezer-cache.")
|
||||
self._cache_patch = patch(
|
||||
"bot_bottle.backend.firecracker.freezer.util.cache_dir",
|
||||
return_value=Path(self._cache.name),
|
||||
)
|
||||
self._cache_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._cache_patch.stop()
|
||||
self._cache.cleanup()
|
||||
self._teardown_fake_home()
|
||||
|
||||
def _write_meta(self, slug: str) -> None:
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="smolmachines",
|
||||
started_at="t", backend="firecracker",
|
||||
))
|
||||
|
||||
def _stage_run_dir(self, slug: str, guest_ip: str = "100.64.0.1") -> None:
|
||||
run_dir = Path(self._cache.name) / "run" / slug
|
||||
run_dir.mkdir(parents=True)
|
||||
(run_dir / "bottle_id_ed25519").write_text("KEY")
|
||||
(run_dir / "config.json").write_text(
|
||||
'{"boot-source": {"boot_args": '
|
||||
f'"console=ttyS0 ip={guest_ip}::100.64.0.0:255.255.255.254::eth0:off"}}}}'
|
||||
)
|
||||
|
||||
def test_snapshots_running_vm_without_stopping(self):
|
||||
"""Commit should exec-tar the running VM, not stop it."""
|
||||
"""Commit should tar the running guest rootfs over SSH, not stop it."""
|
||||
slug = "dev-abc12"
|
||||
self._write_meta(slug)
|
||||
freezer = SmolmachinesFreezer()
|
||||
agent = _make_agent(slug, "smolmachines")
|
||||
self._stage_run_dir(slug)
|
||||
freezer = FirecrackerFreezer()
|
||||
agent = _make_agent(slug, "firecracker")
|
||||
|
||||
with patch("bot_bottle.backend.smolmachines.freezer._snapshot_running_vm") as mock_snap, \
|
||||
with patch("bot_bottle.backend.firecracker.freezer._commit_via_ssh") as mock_commit, \
|
||||
patch("bot_bottle.backend.freeze.info"), \
|
||||
patch("bot_bottle.backend.smolmachines.freezer.info"):
|
||||
patch("bot_bottle.backend.firecracker.freezer.info"):
|
||||
freezer.commit(agent)
|
||||
|
||||
expected_binary = bottle_state.bottle_state_dir(slug) / "committed-smolmachine"
|
||||
mock_snap.assert_called_once_with(
|
||||
f"bot-bottle-{slug}",
|
||||
f"bot-bottle-committed-{slug}:latest",
|
||||
expected_binary,
|
||||
)
|
||||
expected_sidecar = str(expected_binary.with_suffix(".smolmachine"))
|
||||
self.assertEqual(expected_sidecar, bottle_state.read_committed_image(slug))
|
||||
image_tag = f"bot-bottle-committed-{slug}:latest"
|
||||
self.assertEqual(1, mock_commit.call_count)
|
||||
# (private_key, guest_ip, image_tag) — guest_ip parsed from config.
|
||||
args = mock_commit.call_args.args
|
||||
self.assertEqual("100.64.0.1", args[1])
|
||||
self.assertEqual(image_tag, args[2])
|
||||
self.assertEqual(image_tag, bottle_state.read_committed_image(slug))
|
||||
self.assertTrue(bottle_state.is_preserved(slug))
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""Cross-backend parity tests (PRD 0042).
|
||||
|
||||
Verifies that Docker and smolmachines bottles expose the same
|
||||
Verifies that Docker and firecracker bottles expose the same
|
||||
observable contracts for env injection, agent argv, and exec. Tests
|
||||
use mock subprocess layers so no live VM or Docker daemon is needed.
|
||||
|
||||
The scenarios here document what must hold across both backends. As
|
||||
PRDs 0038–0040 land these tests provide regression coverage for the
|
||||
contracts they establish.
|
||||
The scenarios here document what must hold across both backends and
|
||||
provide regression coverage for those contracts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -31,10 +31,12 @@ def _docker_bottle(guest_env: dict[str, str]) -> "object":
|
||||
)
|
||||
|
||||
|
||||
def _smolmachines_bottle(guest_env: dict[str, str]) -> "object":
|
||||
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||
return SmolmachinesBottle(
|
||||
def _firecracker_bottle(guest_env: dict[str, str]) -> "object":
|
||||
from bot_bottle.backend.firecracker.bottle import FirecrackerBottle
|
||||
return FirecrackerBottle(
|
||||
"bot-bottle-test",
|
||||
private_key=Path("/tmp/key"),
|
||||
guest_ip="100.64.0.1",
|
||||
guest_env=guest_env,
|
||||
agent_command="claude",
|
||||
)
|
||||
@@ -43,7 +45,7 @@ def _smolmachines_bottle(guest_env: dict[str, str]) -> "object":
|
||||
# One entry per backend: (label, factory).
|
||||
_BACKENDS: list[tuple[str, Callable[[dict[str, str]], object]]] = [
|
||||
("docker", _docker_bottle),
|
||||
("smolmachines", _smolmachines_bottle),
|
||||
("firecracker", _firecracker_bottle),
|
||||
]
|
||||
|
||||
|
||||
@@ -91,19 +93,16 @@ class TestAgentArgvParity(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestSmolmachinesEnvInArgv(unittest.TestCase):
|
||||
"""smolmachines bottle includes guest_env values in exec argv."""
|
||||
class TestFirecrackerEnvInArgv(unittest.TestCase):
|
||||
"""firecracker bottle includes guest_env values in the agent argv."""
|
||||
|
||||
def test_guest_env_in_exec_argv(self):
|
||||
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||
bottle = SmolmachinesBottle(
|
||||
"bot-bottle-test",
|
||||
guest_env={"TOKEN": "abc123", "PROXY": "http://proxy:8888"},
|
||||
def test_guest_env_in_agent_argv(self):
|
||||
bottle = _firecracker_bottle(
|
||||
{"TOKEN": "abc123", "PROXY": "http://proxy:8888"},
|
||||
)
|
||||
argv = bottle.agent_argv([], tty=False)
|
||||
joined = " ".join(argv)
|
||||
self.assertIn("TOKEN=abc123", joined)
|
||||
self.assertIn("PROXY=http://proxy:8888", joined)
|
||||
argv = bottle.agent_argv([], tty=False) # type: ignore[union-attr]
|
||||
self.assertIn("TOKEN=abc123", argv)
|
||||
self.assertIn("PROXY=http://proxy:8888", argv)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -129,17 +128,16 @@ class TestExecUserSwitching(unittest.TestCase):
|
||||
self.assertIn("node", call_args,
|
||||
"docker exec should use 'node' user by default")
|
||||
|
||||
def test_smolmachines_exec_uses_node_user_by_default(self):
|
||||
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
|
||||
with patch("bot_bottle.backend.smolmachines.bottle.subprocess.run") as run:
|
||||
def test_firecracker_exec_uses_node_user_by_default(self):
|
||||
bottle = _firecracker_bottle({})
|
||||
with patch("bot_bottle.backend.firecracker.bottle.subprocess.run") as run:
|
||||
run.return_value = subprocess.CompletedProcess(
|
||||
[], 0, stdout="", stderr="",
|
||||
)
|
||||
bottle.exec("echo hi")
|
||||
call_args = run.call_args[0][0]
|
||||
self.assertIn("node", call_args,
|
||||
"smolvm exec should use 'node' user by default")
|
||||
bottle.exec("echo hi") # type: ignore[union-attr]
|
||||
call_args = " ".join(run.call_args[0][0])
|
||||
self.assertIn("runuser -u node", call_args,
|
||||
"firecracker exec should use 'node' user by default")
|
||||
|
||||
def test_docker_exec_respects_root_user(self):
|
||||
from bot_bottle.backend.docker.bottle import DockerBottle
|
||||
@@ -156,16 +154,15 @@ class TestExecUserSwitching(unittest.TestCase):
|
||||
call_args = run.call_args[0][0]
|
||||
self.assertIn("root", call_args)
|
||||
|
||||
def test_smolmachines_exec_respects_root_user(self):
|
||||
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
|
||||
with patch("bot_bottle.backend.smolmachines.bottle.subprocess.run") as run:
|
||||
def test_firecracker_exec_respects_root_user(self):
|
||||
bottle = _firecracker_bottle({})
|
||||
with patch("bot_bottle.backend.firecracker.bottle.subprocess.run") as run:
|
||||
run.return_value = subprocess.CompletedProcess(
|
||||
[], 0, stdout="", stderr="",
|
||||
)
|
||||
bottle.exec("id", user="root")
|
||||
call_args = run.call_args[0][0]
|
||||
self.assertIn("root", call_args)
|
||||
bottle.exec("id", user="root") # type: ignore[union-attr]
|
||||
call_args = " ".join(run.call_args[0][0])
|
||||
self.assertIn("runuser -u root", call_args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -196,13 +193,12 @@ class TestExecResultParity(unittest.TestCase):
|
||||
self.assertIsInstance(result.stdout, str)
|
||||
self.assertIsInstance(result.stderr, str)
|
||||
|
||||
def test_smolmachines_exec_result_shape(self):
|
||||
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||
def test_firecracker_exec_result_shape(self):
|
||||
from bot_bottle.backend import ExecResult
|
||||
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
|
||||
with patch("bot_bottle.backend.smolmachines.bottle.subprocess.run",
|
||||
bottle = _firecracker_bottle({})
|
||||
with patch("bot_bottle.backend.firecracker.bottle.subprocess.run",
|
||||
side_effect=self._stub_run):
|
||||
result = bottle.exec("echo hi")
|
||||
result = bottle.exec("echo hi") # type: ignore[union-attr]
|
||||
self.assertIsInstance(result, ExecResult)
|
||||
self.assertEqual(0, result.returncode)
|
||||
self.assertIsInstance(result.stdout, str)
|
||||
@@ -229,11 +225,10 @@ class TestCloseParity(unittest.TestCase):
|
||||
# DockerBottle.close calls teardown — once per call is fine;
|
||||
# what matters is it doesn't raise.
|
||||
|
||||
def test_smolmachines_close_is_noop(self):
|
||||
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
|
||||
bottle.close()
|
||||
bottle.close()
|
||||
def test_firecracker_close_is_noop(self):
|
||||
bottle = _firecracker_bottle({})
|
||||
bottle.close() # type: ignore[union-attr]
|
||||
bottle.close() # type: ignore[union-attr]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -17,7 +17,7 @@ from bot_bottle import supervise
|
||||
from bot_bottle.backend import BottleSpec
|
||||
from bot_bottle.backend.docker import DockerBottleBackend
|
||||
from bot_bottle.backend.resolve_common import mint_slug
|
||||
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
|
||||
from bot_bottle.backend.firecracker import FirecrackerBottleBackend
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
|
||||
@@ -90,30 +90,28 @@ class TestDockerPrepare(_FakeStateMixin, unittest.TestCase):
|
||||
self.assertNotIn("FORWARDED_ENV", plan.agent_provision.guest_env)
|
||||
|
||||
|
||||
class TestSmolmachinesPrepare(_FakeStateMixin, unittest.TestCase):
|
||||
class TestFirecrackerPrepare(_FakeStateMixin, unittest.TestCase):
|
||||
def test_records_backend_and_builds_guest_env(self) -> None:
|
||||
backend = SmolmachinesBottleBackend()
|
||||
spec = _spec(Path(self.tmp.name), identity="demo-smol")
|
||||
backend = FirecrackerBottleBackend()
|
||||
spec = _spec(Path(self.tmp.name), identity="demo-fc")
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"HOST_SECRET_ENV": "secret-value"}),
|
||||
patch(
|
||||
"bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight",
|
||||
"bot_bottle.backend.firecracker.resolve_plan.util.require_firecracker",
|
||||
) as preflight,
|
||||
):
|
||||
plan = backend.prepare(spec, Path(self.tmp.name) / "stage")
|
||||
|
||||
preflight.assert_called_once_with()
|
||||
metadata = bottle_state.read_metadata("demo-smol")
|
||||
metadata = bottle_state.read_metadata("demo-fc")
|
||||
self.assertIsNotNone(metadata)
|
||||
assert metadata is not None
|
||||
self.assertEqual("smolmachines", metadata.backend)
|
||||
self.assertEqual("literal-value", plan.guest_env["LITERAL_ENV"])
|
||||
self.assertEqual("secret-value", plan.guest_env["FORWARDED_ENV"])
|
||||
self.assertEqual("firecracker", metadata.backend)
|
||||
self.assertEqual(
|
||||
"/etc/ssl/certs/ca-certificates.crt",
|
||||
plan.guest_env["SSL_CERT_FILE"],
|
||||
"literal-value", plan.agent_provision.guest_env["LITERAL_ENV"],
|
||||
)
|
||||
self.assertEqual({"FORWARDED_ENV": "secret-value"}, plan.forwarded_env)
|
||||
|
||||
|
||||
class TestMintSlug(unittest.TestCase):
|
||||
|
||||
@@ -23,14 +23,14 @@ from bot_bottle.backend import (
|
||||
|
||||
class TestGetBottleBackend(unittest.TestCase):
|
||||
def test_explicit_name_wins_over_env(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "smolmachines"}):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}):
|
||||
b = get_bottle_backend("docker")
|
||||
self.assertEqual("docker", b.name)
|
||||
|
||||
def test_env_var_fallback(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "smolmachines"}):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}):
|
||||
b = get_bottle_backend()
|
||||
self.assertEqual("smolmachines", b.name)
|
||||
self.assertEqual("firecracker", b.name)
|
||||
|
||||
def test_default_macos_container_when_available(self):
|
||||
class _FakeBackend:
|
||||
@@ -42,12 +42,12 @@ class TestGetBottleBackend(unittest.TestCase):
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(backend_mod, "_BACKENDS", {
|
||||
"macos-container": _FakeBackend(),
|
||||
"smolmachines": _FakeBackend(),
|
||||
"docker": _FakeBackend(),
|
||||
}):
|
||||
b = get_bottle_backend()
|
||||
self.assertEqual("macos-container", b.name)
|
||||
|
||||
def test_default_smolmachines_when_macos_container_unavailable(self):
|
||||
def test_default_docker_when_no_macos_and_host_not_kvm(self):
|
||||
class _FakeBackend:
|
||||
def __init__(self, name: str, available: bool) -> None:
|
||||
self.name = name
|
||||
@@ -56,15 +56,19 @@ class TestGetBottleBackend(unittest.TestCase):
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
# No macOS container and the host can't run firecracker (no
|
||||
# KVM / not Linux) → docker is the last resort.
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||
"is_host_capable", classmethod(lambda cls: False)), \
|
||||
patch.object(backend_mod, "_BACKENDS", {
|
||||
"macos-container": _FakeBackend("macos-container", False),
|
||||
"smolmachines": _FakeBackend("smolmachines", False),
|
||||
"docker": _FakeBackend("docker", True),
|
||||
}):
|
||||
b = get_bottle_backend()
|
||||
self.assertEqual("smolmachines", b.name)
|
||||
self.assertEqual("docker", b.name)
|
||||
|
||||
def test_default_firecracker_when_macos_unavailable_but_fc_available(self):
|
||||
def test_default_firecracker_on_kvm_host_even_when_binary_missing(self):
|
||||
class _FakeBackend:
|
||||
def __init__(self, name: str, available: bool) -> None:
|
||||
self.name = name
|
||||
@@ -73,11 +77,16 @@ class TestGetBottleBackend(unittest.TestCase):
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
# A KVM-capable Linux host defaults to firecracker even when the
|
||||
# binary isn't installed (is_available False) — start then prints
|
||||
# the install pointer instead of falling back to docker.
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||
"is_host_capable", classmethod(lambda cls: True)), \
|
||||
patch.object(backend_mod, "_BACKENDS", {
|
||||
"macos-container": _FakeBackend("macos-container", False),
|
||||
"firecracker": _FakeBackend("firecracker", True),
|
||||
"smolmachines": _FakeBackend("smolmachines", True),
|
||||
"firecracker": _FakeBackend("firecracker", False),
|
||||
"docker": _FakeBackend("docker", True),
|
||||
}):
|
||||
b = get_bottle_backend()
|
||||
self.assertEqual("firecracker", b.name)
|
||||
@@ -91,7 +100,7 @@ class TestGetBottleBackend(unittest.TestCase):
|
||||
class TestKnownBackendNames(unittest.TestCase):
|
||||
def test_returns_backends_sorted(self):
|
||||
self.assertEqual(
|
||||
("docker", "firecracker", "macos-container", "smolmachines"),
|
||||
("docker", "firecracker", "macos-container"),
|
||||
known_backend_names(),
|
||||
)
|
||||
|
||||
@@ -99,8 +108,8 @@ class TestKnownBackendNames(unittest.TestCase):
|
||||
class TestEnumerateActiveAgents(unittest.TestCase):
|
||||
"""Combines each backend's `enumerate_active`. Each backend's
|
||||
implementation has its own tests (`test_docker_enumerate_active`,
|
||||
`test_smolmachines_*`); this just asserts the aggregator stitches
|
||||
them together."""
|
||||
`test_firecracker_backend`); this just asserts the aggregator
|
||||
stitches them together."""
|
||||
|
||||
def test_concatenates_per_backend(self):
|
||||
a = ActiveAgent(
|
||||
@@ -108,7 +117,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
||||
started_at="", services=("egress",),
|
||||
)
|
||||
b = ActiveAgent(
|
||||
backend_name="smolmachines", slug="b-2", agent_name="research",
|
||||
backend_name="firecracker", slug="b-2", agent_name="research",
|
||||
started_at="", services=(),
|
||||
)
|
||||
|
||||
@@ -125,7 +134,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
||||
|
||||
with patch.object(
|
||||
backend_mod, "_BACKENDS",
|
||||
{"docker": _FakeBackend([a]), "smolmachines": _FakeBackend([b])},
|
||||
{"docker": _FakeBackend([a]), "firecracker": _FakeBackend([b])},
|
||||
):
|
||||
self.assertEqual([a, b], enumerate_active_agents())
|
||||
|
||||
@@ -139,11 +148,11 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
||||
started_at="2026-06-02T11:00:00Z", services=(),
|
||||
)
|
||||
missing_metadata = ActiveAgent(
|
||||
backend_name="smolmachines", slug="missing-metadata",
|
||||
backend_name="firecracker", slug="missing-metadata",
|
||||
agent_name="?", started_at="", services=(),
|
||||
)
|
||||
tie_a = ActiveAgent(
|
||||
backend_name="smolmachines", slug="a-slug", agent_name="research",
|
||||
backend_name="firecracker", slug="a-slug", agent_name="research",
|
||||
started_at="2026-06-02T11:00:00Z", services=(),
|
||||
)
|
||||
|
||||
@@ -161,7 +170,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
||||
backend_mod, "_BACKENDS",
|
||||
{
|
||||
"docker": _FakeBackend([newer, tie_b]),
|
||||
"smolmachines": _FakeBackend([missing_metadata, tie_a]),
|
||||
"firecracker": _FakeBackend([missing_metadata, tie_a]),
|
||||
},
|
||||
):
|
||||
self.assertEqual(
|
||||
@@ -179,21 +188,21 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
||||
|
||||
with patch.object(
|
||||
backend_mod, "_BACKENDS",
|
||||
{"docker": _FakeBackend(), "smolmachines": _FakeBackend()},
|
||||
{"docker": _FakeBackend(), "firecracker": _FakeBackend()},
|
||||
):
|
||||
self.assertEqual([], enumerate_active_agents())
|
||||
|
||||
def test_skips_unavailable_backends(self):
|
||||
# If a backend's runtime isn't installed (smolvm missing on
|
||||
# a docker-only host, or docker missing on a smolmachines-
|
||||
# only host), the cross-backend enumerator skips it rather
|
||||
# than dying — `has_backend` gates the iteration.
|
||||
# If a backend's runtime isn't installed (docker missing on a
|
||||
# firecracker host, or KVM missing on a docker-only host), the
|
||||
# cross-backend enumerator skips it rather than dying —
|
||||
# `has_backend` gates the iteration.
|
||||
present = ActiveAgent(
|
||||
backend_name="docker", slug="a-1", agent_name="impl",
|
||||
started_at="", services=(),
|
||||
)
|
||||
hidden = ActiveAgent(
|
||||
backend_name="smolmachines", slug="x", agent_name="x",
|
||||
backend_name="firecracker", slug="x", agent_name="x",
|
||||
started_at="", services=(),
|
||||
)
|
||||
|
||||
@@ -212,7 +221,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
||||
backend_mod, "_BACKENDS",
|
||||
{
|
||||
"docker": _FakeBackend([present], available=True),
|
||||
"smolmachines": _FakeBackend([hidden], available=False),
|
||||
"firecracker": _FakeBackend([hidden], available=False),
|
||||
},
|
||||
):
|
||||
self.assertEqual([present], enumerate_active_agents())
|
||||
|
||||
@@ -34,7 +34,7 @@ class TestPalettePrintf(unittest.TestCase):
|
||||
|
||||
|
||||
class TestExecShellScript(unittest.TestCase):
|
||||
_ARGV = ["smolvm", "machine", "exec", "--name", "x", "--", "claude"]
|
||||
_ARGV = ["ssh", "-t", "100.64.0.1", "--", "claude"]
|
||||
|
||||
def test_no_decoration_returns_none(self):
|
||||
self.assertIsNone(exec_shell_script(self._ARGV))
|
||||
@@ -58,7 +58,7 @@ class TestExecShellScript(unittest.TestCase):
|
||||
self.assertIn("\\033]111", script) # background reset
|
||||
# No exec-replace when palette is active (shell must survive for reset)
|
||||
parts = script.split("; ")
|
||||
agent_part = next(p for p in parts if "smolvm" in p)
|
||||
agent_part = next(p for p in parts if "ssh" in p)
|
||||
self.assertFalse(agent_part.startswith("exec "))
|
||||
|
||||
def test_title_and_color_both_appear(self):
|
||||
|
||||
@@ -16,7 +16,7 @@ from bot_bottle import bottle_state
|
||||
from bot_bottle import supervise
|
||||
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||
from bot_bottle.backend.docker import DockerBottleBackend
|
||||
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
|
||||
from bot_bottle.backend.firecracker import FirecrackerBottleBackend
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
|
||||
@@ -114,13 +114,13 @@ class TestRuntimeWorkspaceProvisioning(_FakeStateMixin, unittest.TestCase):
|
||||
bottle.exec.assert_not_called()
|
||||
bottle.cp_in.assert_not_called()
|
||||
|
||||
def test_smolmachines_uses_same_running_bottle_method(self) -> None:
|
||||
backend = SmolmachinesBottleBackend()
|
||||
def test_firecracker_uses_same_running_bottle_method(self) -> None:
|
||||
backend = FirecrackerBottleBackend()
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight",
|
||||
"bot_bottle.backend.firecracker.resolve_plan.util.require_firecracker",
|
||||
):
|
||||
plan = backend.prepare(
|
||||
_spec(self.tmp, identity="demo-smol-work"),
|
||||
_spec(self.tmp, identity="demo-fc-work"),
|
||||
self.tmp / "stage",
|
||||
)
|
||||
|
||||
@@ -128,10 +128,10 @@ class TestRuntimeWorkspaceProvisioning(_FakeStateMixin, unittest.TestCase):
|
||||
backend.provision_workspace(plan, bottle)
|
||||
|
||||
bottle.cp_in.assert_called_once_with(str(self.tmp), "/home/node/workspace")
|
||||
metadata = bottle_state.read_metadata("demo-smol-work")
|
||||
metadata = bottle_state.read_metadata("demo-fc-work")
|
||||
self.assertIsNotNone(metadata)
|
||||
assert metadata is not None
|
||||
self.assertEqual("smolmachines", metadata.backend)
|
||||
self.assertEqual("firecracker", metadata.backend)
|
||||
|
||||
|
||||
class TestWorkspaceTrustPath(_FakeStateMixin, unittest.TestCase):
|
||||
|
||||
@@ -241,7 +241,7 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
|
||||
assert loaded is not None
|
||||
self.assertEqual("docker", loaded.backend)
|
||||
|
||||
def test_backend_field_roundtrips_smolmachines(self):
|
||||
def test_backend_field_roundtrips_firecracker(self):
|
||||
meta = BottleMetadata(
|
||||
identity="dev-b2",
|
||||
agent_name="dev",
|
||||
@@ -249,13 +249,13 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
|
||||
copy_cwd=False,
|
||||
started_at="2026-06-02T00:00:00+00:00",
|
||||
compose_project="",
|
||||
backend="smolmachines",
|
||||
backend="firecracker",
|
||||
)
|
||||
write_metadata(meta)
|
||||
loaded = read_metadata("dev-b2")
|
||||
self.assertIsNotNone(loaded)
|
||||
assert loaded is not None
|
||||
self.assertEqual("smolmachines", loaded.backend)
|
||||
self.assertEqual("firecracker", loaded.backend)
|
||||
|
||||
def test_missing_backend_field_defaults_to_empty(self):
|
||||
# Old state dirs written before PRD 0040 have no backend key.
|
||||
|
||||
@@ -23,12 +23,12 @@ def _make_backend(empty: bool = True):
|
||||
class TestCmdCleanup(unittest.TestCase):
|
||||
def test_iterates_every_backend(self):
|
||||
docker, docker_plan = _make_backend(empty=False)
|
||||
smol, smol_plan = _make_backend(empty=False)
|
||||
backends_by_name = {"docker": docker, "smolmachines": smol}
|
||||
fc, fc_plan = _make_backend(empty=False)
|
||||
backends_by_name = {"docker": docker, "firecracker": fc}
|
||||
|
||||
with patch.object(
|
||||
cmd, "known_backend_names",
|
||||
return_value=("docker", "smolmachines"),
|
||||
return_value=("docker", "firecracker"),
|
||||
), patch.object(
|
||||
cmd, "get_bottle_backend",
|
||||
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||
@@ -38,18 +38,18 @@ class TestCmdCleanup(unittest.TestCase):
|
||||
self.assertEqual(0, cmd.cmd_cleanup([]))
|
||||
|
||||
docker.prepare_cleanup.assert_called_once()
|
||||
smol.prepare_cleanup.assert_called_once()
|
||||
fc.prepare_cleanup.assert_called_once()
|
||||
docker.cleanup.assert_called_once_with(docker_plan)
|
||||
smol.cleanup.assert_called_once_with(smol_plan)
|
||||
fc.cleanup.assert_called_once_with(fc_plan)
|
||||
|
||||
def test_short_circuits_when_all_empty(self):
|
||||
docker, _ = _make_backend(empty=True)
|
||||
smol, _ = _make_backend(empty=True)
|
||||
backends_by_name = {"docker": docker, "smolmachines": smol}
|
||||
fc, _ = _make_backend(empty=True)
|
||||
backends_by_name = {"docker": docker, "firecracker": fc}
|
||||
|
||||
with patch.object(
|
||||
cmd, "known_backend_names",
|
||||
return_value=("docker", "smolmachines"),
|
||||
return_value=("docker", "firecracker"),
|
||||
), patch.object(
|
||||
cmd, "get_bottle_backend",
|
||||
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||
@@ -59,16 +59,16 @@ class TestCmdCleanup(unittest.TestCase):
|
||||
self.assertEqual(0, cmd.cmd_cleanup([]))
|
||||
prompt.assert_not_called()
|
||||
docker.cleanup.assert_not_called()
|
||||
smol.cleanup.assert_not_called()
|
||||
fc.cleanup.assert_not_called()
|
||||
|
||||
def test_abort_at_prompt_runs_nothing(self):
|
||||
docker, _ = _make_backend(empty=False)
|
||||
smol, _ = _make_backend(empty=True)
|
||||
backends_by_name = {"docker": docker, "smolmachines": smol}
|
||||
fc, _ = _make_backend(empty=True)
|
||||
backends_by_name = {"docker": docker, "firecracker": fc}
|
||||
|
||||
with patch.object(
|
||||
cmd, "known_backend_names",
|
||||
return_value=("docker", "smolmachines"),
|
||||
return_value=("docker", "firecracker"),
|
||||
), patch.object(
|
||||
cmd, "get_bottle_backend",
|
||||
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||
@@ -77,18 +77,18 @@ class TestCmdCleanup(unittest.TestCase):
|
||||
):
|
||||
self.assertEqual(0, cmd.cmd_cleanup([]))
|
||||
docker.cleanup.assert_not_called()
|
||||
smol.cleanup.assert_not_called()
|
||||
fc.cleanup.assert_not_called()
|
||||
|
||||
def test_skips_empty_plans_when_others_have_work(self):
|
||||
# docker has work, smolmachines doesn't — only docker.cleanup
|
||||
# docker has work, firecracker doesn't — only docker.cleanup
|
||||
# is called.
|
||||
docker, docker_plan = _make_backend(empty=False)
|
||||
smol, _ = _make_backend(empty=True)
|
||||
backends_by_name = {"docker": docker, "smolmachines": smol}
|
||||
fc, _ = _make_backend(empty=True)
|
||||
backends_by_name = {"docker": docker, "firecracker": fc}
|
||||
|
||||
with patch.object(
|
||||
cmd, "known_backend_names",
|
||||
return_value=("docker", "smolmachines"),
|
||||
return_value=("docker", "firecracker"),
|
||||
), patch.object(
|
||||
cmd, "get_bottle_backend",
|
||||
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||
@@ -97,7 +97,7 @@ class TestCmdCleanup(unittest.TestCase):
|
||||
):
|
||||
cmd.cmd_cleanup([])
|
||||
docker.cleanup.assert_called_once_with(docker_plan)
|
||||
smol.cleanup.assert_not_called()
|
||||
fc.cleanup.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -83,9 +83,9 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
||||
mock_gf.assert_called_once_with("macos-container")
|
||||
mock_freezer.commit_slug.assert_called_once_with(slug)
|
||||
|
||||
def test_commits_smolmachines_bottle(self):
|
||||
def test_commits_firecracker_bottle(self):
|
||||
slug = "dev-abc12"
|
||||
self._write_meta(slug, "smolmachines")
|
||||
self._write_meta(slug, "firecracker")
|
||||
|
||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
||||
mock_freezer = MagicMock()
|
||||
@@ -93,7 +93,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
||||
rc = cmd_commit([slug])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
mock_gf.assert_called_once_with("smolmachines")
|
||||
mock_gf.assert_called_once_with("firecracker")
|
||||
|
||||
def test_returns_zero_on_commit_cancelled(self):
|
||||
slug = "dev-abc12"
|
||||
|
||||
@@ -35,8 +35,8 @@ class TestStartBackendFlag(unittest.TestCase):
|
||||
return parser
|
||||
|
||||
def test_flag_recognized(self):
|
||||
args = self._build_parser().parse_args(["--backend=smolmachines", "researcher"])
|
||||
self.assertEqual("smolmachines", args.backend)
|
||||
args = self._build_parser().parse_args(["--backend=firecracker", "researcher"])
|
||||
self.assertEqual("firecracker", args.backend)
|
||||
self.assertEqual("researcher", args.name)
|
||||
|
||||
def test_flag_default_none_means_env_or_default_backend(self):
|
||||
@@ -53,7 +53,7 @@ class TestStartBackendFlag(unittest.TestCase):
|
||||
# `--backend` ultimately threads to) prefers the explicit
|
||||
# name over BOT_BOTTLE_BACKEND.
|
||||
from bot_bottle.backend import get_bottle_backend
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "smolmachines"}):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}):
|
||||
self.assertEqual("docker", get_bottle_backend("docker").name)
|
||||
|
||||
|
||||
|
||||
@@ -99,25 +99,25 @@ class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
|
||||
|
||||
def test_protected_identity_skips_dir(self):
|
||||
# `protected_identities` carries slugs that are live in
|
||||
# any backend (smolmachines included). docker's orphan
|
||||
# detection respects them so a running smolmachines
|
||||
# any backend (firecracker included). docker's orphan
|
||||
# detection respects them so a running firecracker
|
||||
# bottle's state dir isn't reaped while the VM is up.
|
||||
bottle_state.write_per_bottle_dockerfile("smol-hhh", "FROM x\n")
|
||||
bottle_state.write_per_bottle_dockerfile("fc-hhh", "FROM x\n")
|
||||
self.assertEqual(
|
||||
[],
|
||||
_list_orphan_state_dirs(set(), {"smol-hhh"}),
|
||||
_list_orphan_state_dirs(set(), {"fc-hhh"}),
|
||||
)
|
||||
|
||||
def test_protected_overrides_no_live_project(self):
|
||||
# A smolmachines bottle has no docker compose project but
|
||||
# A firecracker bottle has no docker compose project but
|
||||
# IS in the protected set; the absence of a project
|
||||
# shouldn't cause a reap.
|
||||
bottle_state.write_per_bottle_dockerfile("smol-iii", "FROM x\n")
|
||||
bottle_state.write_per_bottle_dockerfile("fc-iii", "FROM x\n")
|
||||
self.assertEqual(
|
||||
[],
|
||||
_list_orphan_state_dirs(
|
||||
{"bot-bottle-something-else"}, # different project up
|
||||
{"smol-iii"}, # but smol-iii is live
|
||||
{"fc-iii"}, # but fc-iii is live
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit: image_id / tag / push helpers in
|
||||
"""Unit: commit_container helper in
|
||||
bot_bottle.backend.docker.util (PRD 0023 chunk 4c additions).
|
||||
|
||||
Tests mock `subprocess.run` and assert on argv shape + parsing.
|
||||
@@ -26,47 +26,6 @@ def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
|
||||
)
|
||||
|
||||
|
||||
class TestImageId(unittest.TestCase):
|
||||
def test_strips_trailing_newline(self):
|
||||
# docker image inspect --format ... emits a trailing newline.
|
||||
with patch.object(
|
||||
docker_mod.subprocess, "run",
|
||||
return_value=_ok(stdout="sha256:abcdef\n"),
|
||||
) as run:
|
||||
self.assertEqual(
|
||||
"sha256:abcdef", docker_mod.image_id("bot-bottle-claude:latest")
|
||||
)
|
||||
argv = run.call_args.args[0]
|
||||
self.assertEqual(
|
||||
["docker", "image", "inspect", "--format", "{{.Id}}", "bot-bottle-claude:latest"],
|
||||
argv,
|
||||
)
|
||||
|
||||
def test_dies_on_inspect_failure(self):
|
||||
with patch.object(
|
||||
docker_mod.subprocess, "run", return_value=_fail("No such image"),
|
||||
), patch.object(
|
||||
docker_mod, "die", side_effect=SystemExit("die"),
|
||||
) as die:
|
||||
with self.assertRaises(SystemExit):
|
||||
docker_mod.image_id("missing:tag")
|
||||
die.assert_called_once()
|
||||
self.assertIn("missing:tag", die.call_args.args[0])
|
||||
|
||||
|
||||
class TestSave(unittest.TestCase):
|
||||
def test_save_runs_docker_save(self):
|
||||
with patch.object(
|
||||
docker_mod.subprocess, "run", return_value=_ok(),
|
||||
) as run:
|
||||
docker_mod.save("bot-bottle-claude:latest", "/tmp/img.tar")
|
||||
argv = run.call_args.args[0]
|
||||
self.assertEqual(
|
||||
["docker", "save", "bot-bottle-claude:latest", "-o", "/tmp/img.tar"],
|
||||
argv,
|
||||
)
|
||||
|
||||
|
||||
class TestCommitContainer(unittest.TestCase):
|
||||
def test_runs_docker_commit(self):
|
||||
with patch.object(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Unit: egress_entrypoint.sh argv construction (PRD 0023 chunk 3).
|
||||
|
||||
The egress entrypoint is a small POSIX-sh script that builds
|
||||
the mitmdump argv from env vars. The smolmachines backend
|
||||
the mitmdump argv from env vars. A VM backend (e.g. firecracker)
|
||||
controls egress's bind address via EGRESS_LISTEN_HOST; the
|
||||
docker backend leaves it unset and gets mitmdump's default
|
||||
(all interfaces).
|
||||
@@ -67,7 +67,7 @@ class TestEgressEntrypointArgv(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_listen_host_127_0_0_1_emits_flag(self):
|
||||
# smolmachines backend sets EGRESS_LISTEN_HOST=127.0.0.1
|
||||
# A VM backend sets EGRESS_LISTEN_HOST=127.0.0.1
|
||||
# to scope egress to localhost inside the bundle.
|
||||
argv = _run_entrypoint({"EGRESS_LISTEN_HOST": "127.0.0.1"})
|
||||
self.assertIn("--listen-host\n127.0.0.1", argv)
|
||||
@@ -79,7 +79,7 @@ class TestEgressEntrypointArgv(unittest.TestCase):
|
||||
self.assertNotIn("--listen-host", argv)
|
||||
|
||||
def test_upstream_mode_combined_with_listen_host(self):
|
||||
# smolmachines mode also sets EGRESS_UPSTREAM_PROXY so
|
||||
# VM-backend mode also sets EGRESS_UPSTREAM_PROXY so
|
||||
# both flags should compose correctly.
|
||||
argv = _run_entrypoint({
|
||||
"EGRESS_UPSTREAM_PROXY": "http://192.168.50.2:8888",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit: BottlePlan.print parity across Docker and smolmachines (PRD 0044).
|
||||
"""Unit: BottlePlan.print parity across Docker and firecracker (PRD 0044).
|
||||
|
||||
Both backends inherit a single concrete print() from BottlePlan. These
|
||||
tests verify that identical git_gate_plan and egress_plan inputs produce
|
||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||
from bot_bottle.backend import BottleSpec
|
||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
|
||||
from bot_bottle.backend.firecracker.bottle_plan import FirecrackerBottlePlan
|
||||
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||
from bot_bottle.manifest import Manifest, ManifestIndex
|
||||
@@ -107,9 +107,9 @@ def _docker_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> DockerBottle
|
||||
)
|
||||
|
||||
|
||||
def _smolmachines_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> SmolmachinesBottlePlan:
|
||||
def _firecracker_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> FirecrackerBottlePlan:
|
||||
stage = Path(tmp)
|
||||
return SmolmachinesBottlePlan(
|
||||
return FirecrackerBottlePlan(
|
||||
spec=spec,
|
||||
manifest=manifest,
|
||||
stage_dir=stage,
|
||||
@@ -118,14 +118,11 @@ def _smolmachines_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> Smolma
|
||||
supervise_plan=None,
|
||||
agent_provision=_agent_provision(tmp),
|
||||
slug="test-00001",
|
||||
bundle_subnet="10.99.0.0/24",
|
||||
bundle_gateway="10.99.0.1",
|
||||
bundle_ip="10.99.0.2",
|
||||
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
|
||||
forwarded_env={},
|
||||
)
|
||||
|
||||
|
||||
def _capture_print(plan: DockerBottlePlan | SmolmachinesBottlePlan) -> list[str]:
|
||||
def _capture_print(plan: DockerBottlePlan | FirecrackerBottlePlan) -> list[str]:
|
||||
buf = io.StringIO()
|
||||
orig = sys.stderr
|
||||
sys.stderr = buf
|
||||
@@ -144,7 +141,7 @@ class TestGitGatePrintParity(unittest.TestCase):
|
||||
manifest = _INDEX.load_for_agent("demo")
|
||||
spec = _spec(_INDEX, self._tmp)
|
||||
self._docker_lines = _capture_print(_docker_plan(spec, manifest, self._tmp))
|
||||
self._smol_lines = _capture_print(_smolmachines_plan(spec, manifest, self._tmp))
|
||||
self._fc_lines = _capture_print(_firecracker_plan(spec, manifest, self._tmp))
|
||||
|
||||
def _git_gate_lines(self, lines: list[str]) -> list[str]:
|
||||
return [ln for ln in lines if "git gate" in ln]
|
||||
@@ -154,15 +151,15 @@ class TestGitGatePrintParity(unittest.TestCase):
|
||||
self.assertEqual(1, len(git_lines))
|
||||
self.assertIn("myrepo → gitea.example.com:30009", git_lines[0])
|
||||
|
||||
def test_smolmachines_renders_name_arrow_host_port(self) -> None:
|
||||
git_lines = self._git_gate_lines(self._smol_lines)
|
||||
def test_firecracker_renders_name_arrow_host_port(self) -> None:
|
||||
git_lines = self._git_gate_lines(self._fc_lines)
|
||||
self.assertEqual(1, len(git_lines))
|
||||
self.assertIn("myrepo → gitea.example.com:30009", git_lines[0])
|
||||
|
||||
def test_git_gate_lines_match_across_backends(self) -> None:
|
||||
self.assertEqual(
|
||||
self._git_gate_lines(self._docker_lines),
|
||||
self._git_gate_lines(self._smol_lines),
|
||||
self._git_gate_lines(self._fc_lines),
|
||||
)
|
||||
|
||||
|
||||
@@ -174,7 +171,7 @@ class TestEgressPrintParity(unittest.TestCase):
|
||||
manifest = _INDEX.load_for_agent("demo")
|
||||
spec = _spec(_INDEX, self._tmp)
|
||||
self._docker_lines = _capture_print(_docker_plan(spec, manifest, self._tmp))
|
||||
self._smol_lines = _capture_print(_smolmachines_plan(spec, manifest, self._tmp))
|
||||
self._fc_lines = _capture_print(_firecracker_plan(spec, manifest, self._tmp))
|
||||
|
||||
def _egress_section(self, lines: list[str]) -> list[str]:
|
||||
"""Return lines from the egress label through the last route entry.
|
||||
@@ -210,8 +207,8 @@ class TestEgressPrintParity(unittest.TestCase):
|
||||
combined = "\n".join(self._egress_section(self._docker_lines))
|
||||
self.assertIn("api.example.com [auth:bearer]", combined)
|
||||
|
||||
def test_smolmachines_includes_auth_annotation(self) -> None:
|
||||
combined = "\n".join(self._egress_section(self._smol_lines))
|
||||
def test_firecracker_includes_auth_annotation(self) -> None:
|
||||
combined = "\n".join(self._egress_section(self._fc_lines))
|
||||
self.assertIn("api.example.com [auth:bearer]", combined)
|
||||
|
||||
def test_unauthenticated_route_has_no_annotation(self) -> None:
|
||||
@@ -222,7 +219,7 @@ class TestEgressPrintParity(unittest.TestCase):
|
||||
def test_egress_lines_match_across_backends(self) -> None:
|
||||
self.assertEqual(
|
||||
self._egress_section(self._docker_lines),
|
||||
self._egress_section(self._smol_lines),
|
||||
self._egress_section(self._fc_lines),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -53,15 +53,16 @@ class TestGitGateGitconfigRender(unittest.TestCase):
|
||||
self.assertNotIn("pushInsteadOf", out)
|
||||
|
||||
def test_gate_host_can_be_ip_port_form(self):
|
||||
# The smolmachines backend's TSI-allowlisted guest has no
|
||||
# DNS, so it dials git-gate via `<bundle_ip>:<port>`.
|
||||
# A VM backend's guest may have no DNS (e.g. firecracker over
|
||||
# the point-to-point TAP), so it dials git-gate via
|
||||
# `<bundle_ip>:<port>`.
|
||||
bottle = fixture_with_git().bottles["dev"]
|
||||
out = git_gate_render_gitconfig(bottle.git, "192.168.20.2:9418")
|
||||
self.assertIn(
|
||||
'[url "git://192.168.20.2:9418/bot-bottle.git"]', out,
|
||||
)
|
||||
|
||||
def test_scheme_can_be_http_for_smolmachines(self):
|
||||
def test_scheme_can_be_http_for_vm_backend(self):
|
||||
bottle = fixture_with_git().bottles["dev"]
|
||||
out = git_gate_render_gitconfig(
|
||||
bottle.git, "127.0.0.16:57001", scheme="http",
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
"""Unit: SmolmachinesBottle's `agent_argv` builder.
|
||||
|
||||
The dashboard's tmux pane-respawn path calls `bottle.agent_argv`
|
||||
directly (it spawns claude inside a tmux pane rather than as a
|
||||
child of the current process), so the argv shape is the
|
||||
non-trivial part. `exec_agent` is a thin wrapper around the same
|
||||
builder + `subprocess.run`; we lock the shape here.
|
||||
|
||||
The TTY-mode argv is wrapped in the pty_resize helper (issue #82
|
||||
workaround); we assert both the wrapper presence and the wrapped
|
||||
smolvm argv shape. Non-TTY mode skips the wrapper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from bot_bottle.backend.smolmachines import pty_resize as _pty_resize
|
||||
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||
|
||||
|
||||
def _bottle(prompt_path: str | None = None, **env: str) -> SmolmachinesBottle:
|
||||
return SmolmachinesBottle(
|
||||
"bot-bottle-dev-abc",
|
||||
prompt_path=prompt_path,
|
||||
guest_env=env,
|
||||
)
|
||||
|
||||
|
||||
def _pi_bottle(prompt_path: str | None = None) -> SmolmachinesBottle:
|
||||
return SmolmachinesBottle(
|
||||
"bot-bottle-dev-abc",
|
||||
prompt_path=prompt_path,
|
||||
agent_command="pi",
|
||||
agent_prompt_mode="append_system_prompt",
|
||||
)
|
||||
|
||||
|
||||
def _workspace_bottle() -> SmolmachinesBottle:
|
||||
return SmolmachinesBottle(
|
||||
"bot-bottle-dev-abc",
|
||||
prompt_path=None,
|
||||
agent_workdir="/home/node/workspace",
|
||||
)
|
||||
|
||||
|
||||
def _unwrap(argv: list[str]) -> list[str]:
|
||||
"""Strip the pty_resize wrapper from the front of a TTY-mode
|
||||
argv, return the inner smolvm argv. Mirrors what the kernel
|
||||
sees inside the wrapper's `subprocess.Popen`."""
|
||||
idx = argv.index("--")
|
||||
return argv[idx + 1:]
|
||||
|
||||
|
||||
class TestClaudeArgvWrapped(unittest.TestCase):
|
||||
"""TTY-mode argv: pty_resize wrapper + inner smolvm exec."""
|
||||
|
||||
def test_pty_resize_wrapper_prefix(self):
|
||||
argv = _bottle().agent_argv([])
|
||||
# Absolute script path (not `-m <dotted>`) so the tmux
|
||||
# pane's cwd doesn't matter — see the `_PTY_RESIZE_SCRIPT`
|
||||
# docstring in bottle.py.
|
||||
self.assertEqual(
|
||||
[
|
||||
sys.executable, _pty_resize.__file__,
|
||||
"bot-bottle-dev-abc", "--",
|
||||
],
|
||||
argv[:4],
|
||||
)
|
||||
|
||||
def test_minimal_inner_argv_no_prompt(self):
|
||||
argv = _unwrap(_bottle().agent_argv([]))
|
||||
self.assertEqual(
|
||||
[
|
||||
"smolvm", "machine", "exec", "--name",
|
||||
"bot-bottle-dev-abc",
|
||||
"-i", "-t",
|
||||
"--",
|
||||
"runuser", "-u", "node", "--",
|
||||
"env", "HOME=/home/node", "USER=node",
|
||||
(
|
||||
"PATH=/home/node/.local/bin:"
|
||||
"/home/node/.codex/packages/standalone/current/bin:"
|
||||
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
),
|
||||
"claude",
|
||||
],
|
||||
argv,
|
||||
)
|
||||
|
||||
def test_appends_passed_args_after_claude(self):
|
||||
argv = _unwrap(_bottle().agent_argv(
|
||||
["--dangerously-skip-permissions", "--continue"],
|
||||
))
|
||||
self.assertEqual(
|
||||
["claude", "--dangerously-skip-permissions", "--continue"],
|
||||
argv[argv.index("claude"):],
|
||||
)
|
||||
|
||||
def test_appends_prompt_file_flag_when_set(self):
|
||||
argv = _unwrap(
|
||||
_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv(
|
||||
["--dangerously-skip-permissions"],
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"claude",
|
||||
"--append-system-prompt-file",
|
||||
"/home/node/.bot-bottle-prompt.txt",
|
||||
"--dangerously-skip-permissions",
|
||||
],
|
||||
argv[argv.index("claude"):],
|
||||
)
|
||||
|
||||
def test_no_prompt_flag_when_none(self):
|
||||
argv = _bottle(None).agent_argv(["--continue"])
|
||||
self.assertNotIn("--append-system-prompt-file", argv)
|
||||
|
||||
def test_empty_prompt_string_is_treated_as_no_prompt(self):
|
||||
argv = _bottle("").agent_argv(["--continue"])
|
||||
self.assertNotIn("--append-system-prompt-file", argv)
|
||||
|
||||
def test_guest_env_forwarded_as_e_flags(self):
|
||||
argv = _unwrap(_bottle(
|
||||
None,
|
||||
HTTPS_PROXY="http://127.0.0.1:1234",
|
||||
NO_PROXY="localhost",
|
||||
).agent_argv([]))
|
||||
self.assertIn("env", argv)
|
||||
self.assertIn("HTTPS_PROXY=http://127.0.0.1:1234", argv)
|
||||
self.assertIn("NO_PROXY=localhost", argv)
|
||||
|
||||
def test_guest_env_path_overrides_default_path(self):
|
||||
argv = _unwrap(_bottle(None, PATH="/custom/bin").agent_argv([]))
|
||||
self.assertIn("PATH=/custom/bin", argv)
|
||||
self.assertFalse(any(
|
||||
item.startswith("PATH=/home/node/.local/bin")
|
||||
for item in argv
|
||||
))
|
||||
|
||||
def test_runuser_switch_precedes_claude(self):
|
||||
# The dashboard's `_build_resume_argv_with_fallback` finds
|
||||
# the `claude` token to split exec-framing from the claude
|
||||
# tail. `runuser -u node --` must sit on the prefix side so
|
||||
# the shell wrap inherits the UID switch.
|
||||
argv = _bottle().agent_argv([])
|
||||
runuser_idx = argv.index("runuser")
|
||||
self.assertEqual(
|
||||
["runuser", "-u", "node", "--", "env"],
|
||||
argv[runuser_idx:runuser_idx + 5],
|
||||
)
|
||||
|
||||
def test_pi_provider_appends_system_prompt_without_print_mode(self):
|
||||
argv = _unwrap(
|
||||
_pi_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv([])
|
||||
)
|
||||
self.assertEqual(
|
||||
["pi", "--append-system-prompt", "/home/node/.bot-bottle-prompt.txt"],
|
||||
argv[argv.index("pi"):],
|
||||
)
|
||||
self.assertNotIn("-p", argv)
|
||||
|
||||
def test_workspace_workdir_wraps_agent_command(self):
|
||||
argv = _unwrap(_workspace_bottle().agent_argv([]))
|
||||
agent_idx = argv.index("claude")
|
||||
self.assertEqual(
|
||||
[
|
||||
"sh", "-lc",
|
||||
"cd /home/node/workspace && exec \"$@\"",
|
||||
"bot-bottle-agent",
|
||||
"claude",
|
||||
],
|
||||
argv[agent_idx - 4:agent_idx + 1],
|
||||
)
|
||||
|
||||
|
||||
class TestClaudeArgvNoTTY(unittest.TestCase):
|
||||
"""`tty=False` paths skip the pty_resize wrapper — there's no
|
||||
PTY whose SIGWINCH we'd need to bridge."""
|
||||
|
||||
def test_no_wrapper_when_tty_false(self):
|
||||
argv = _bottle().agent_argv([], tty=False)
|
||||
self.assertEqual("smolvm", argv[0])
|
||||
self.assertFalse(any("pty_resize" in a for a in argv))
|
||||
|
||||
def test_tty_false_drops_it_flags(self):
|
||||
argv = _bottle().agent_argv([], tty=False)
|
||||
self.assertNotIn("-i", argv)
|
||||
self.assertNotIn("-t", argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Unit: smolmachines backend cleanup (`cleanup.py` +
|
||||
`bottle_cleanup_plan.py`).
|
||||
|
||||
Tests mock `subprocess.run` + `has_backend` so they execute
|
||||
without docker / smolvm on PATH. Each cleanup step verifies argv
|
||||
shape; teardown verifies order (machines → bundles → networks)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle import backend as backend_mod
|
||||
from bot_bottle.backend.smolmachines import cleanup
|
||||
from bot_bottle.backend.smolmachines.bottle_cleanup_plan import (
|
||||
SmolmachinesBottleCleanupPlan,
|
||||
)
|
||||
|
||||
|
||||
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
|
||||
return subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout=stdout, stderr=stderr,
|
||||
)
|
||||
|
||||
|
||||
class TestPrepareCleanup(unittest.TestCase):
|
||||
def test_empty_when_nothing_running(self):
|
||||
with patch.object(cleanup, "_smolvm") as smolvm, \
|
||||
patch.object(cleanup.subprocess, "run") as run, \
|
||||
patch.object(backend_mod, "has_backend", return_value=True):
|
||||
smolvm.is_available.return_value = True
|
||||
run.return_value = _ok(stdout="[]")
|
||||
plan = cleanup.prepare_cleanup()
|
||||
self.assertTrue(plan.empty)
|
||||
|
||||
def test_lists_machines_bundles_networks(self):
|
||||
def fake_run(argv, *a, **kw): # type: ignore
|
||||
if argv[:3] == ["smolvm", "machine", "ls"]:
|
||||
return _ok(stdout=(
|
||||
'[{"name":"bot-bottle-a-1","state":"running"},'
|
||||
' {"name":"bot-bottle-b-2","state":"created"},'
|
||||
' {"name":"unrelated","state":"running"}]'
|
||||
))
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _ok(stdout=(
|
||||
"bot-bottle-sidecars-a-1\n"
|
||||
"bot-bottle-sidecars-b-2\n"
|
||||
))
|
||||
if argv[:3] == ["docker", "network", "ls"]:
|
||||
return _ok(stdout=(
|
||||
"bot-bottle-bundle-a-1\n"
|
||||
"bot-bottle-bundle-b-2\n"
|
||||
))
|
||||
return _ok()
|
||||
|
||||
with patch.object(cleanup, "_smolvm") as smolvm, \
|
||||
patch.object(cleanup.subprocess, "run", side_effect=fake_run), \
|
||||
patch.object(backend_mod, "has_backend", return_value=True):
|
||||
smolvm.is_available.return_value = True
|
||||
plan = cleanup.prepare_cleanup()
|
||||
|
||||
# `unrelated` filtered out (no bot-bottle- prefix).
|
||||
self.assertEqual(
|
||||
("bot-bottle-a-1", "bot-bottle-b-2"),
|
||||
plan.machines,
|
||||
)
|
||||
self.assertEqual(
|
||||
("bot-bottle-sidecars-a-1", "bot-bottle-sidecars-b-2"),
|
||||
plan.bundles,
|
||||
)
|
||||
self.assertEqual(
|
||||
("bot-bottle-bundle-a-1", "bot-bottle-bundle-b-2"),
|
||||
plan.networks,
|
||||
)
|
||||
|
||||
def test_no_smolvm_means_no_machines(self):
|
||||
with patch.object(cleanup, "_smolvm") as smolvm, \
|
||||
patch.object(cleanup.subprocess, "run", return_value=_ok()), \
|
||||
patch.object(backend_mod, "has_backend", return_value=True):
|
||||
smolvm.is_available.return_value = False
|
||||
plan = cleanup.prepare_cleanup()
|
||||
self.assertEqual((), plan.machines)
|
||||
|
||||
|
||||
class TestCleanup(unittest.TestCase):
|
||||
def test_machines_stopped_then_deleted_then_bundles_then_networks(self):
|
||||
plan = SmolmachinesBottleCleanupPlan(
|
||||
machines=("bot-bottle-a-1",),
|
||||
bundles=("bot-bottle-sidecars-a-1",),
|
||||
networks=("bot-bottle-bundle-a-1",),
|
||||
)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_run(argv, *a, **kw): # type: ignore
|
||||
calls.append(list(argv[:4]))
|
||||
return _ok()
|
||||
|
||||
with patch.object(cleanup.subprocess, "run", side_effect=fake_run):
|
||||
cleanup.cleanup(plan)
|
||||
|
||||
# Stop precedes delete precedes bundle rm precedes network rm.
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "stop", "--name"], calls[0],
|
||||
)
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "delete", "-f"], calls[1],
|
||||
)
|
||||
self.assertEqual(
|
||||
["docker", "rm", "-f", "bot-bottle-sidecars-a-1"], calls[2],
|
||||
)
|
||||
self.assertEqual(
|
||||
["docker", "network", "rm", "bot-bottle-bundle-a-1"], calls[3],
|
||||
)
|
||||
|
||||
def test_failures_are_warnings_not_fatal(self):
|
||||
# smolvm machine delete -f returning non-zero should warn
|
||||
# but continue with bundles + networks. The cleanup is
|
||||
# idempotent on success and tries to remove every resource.
|
||||
plan = SmolmachinesBottleCleanupPlan(
|
||||
machines=("bot-bottle-a-1",),
|
||||
bundles=("bot-bottle-sidecars-a-1",),
|
||||
networks=(),
|
||||
)
|
||||
results = iter([
|
||||
_ok(), # stop succeeds
|
||||
subprocess.CompletedProcess(
|
||||
args=[], returncode=1, stdout="", stderr="boom"
|
||||
), # delete fails
|
||||
_ok(), # bundle rm succeeds
|
||||
])
|
||||
|
||||
def fake_run(argv, *a, **kw): # type: ignore
|
||||
return next(results)
|
||||
|
||||
with patch.object(cleanup.subprocess, "run", side_effect=fake_run), \
|
||||
patch.object(cleanup, "warn") as warn:
|
||||
cleanup.cleanup(plan)
|
||||
# warn called once for the delete failure.
|
||||
warn.assert_called_once()
|
||||
|
||||
def test_empty_plan_is_noop(self):
|
||||
plan = SmolmachinesBottleCleanupPlan()
|
||||
with patch.object(cleanup.subprocess, "run") as run:
|
||||
cleanup.cleanup(plan)
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,192 +0,0 @@
|
||||
"""Unit: smolmachines `_ensure_smolmachine` agent-image pipeline
|
||||
(PRD 0023 chunk 4c).
|
||||
|
||||
Asserts that the cache-hit path returns without touching the
|
||||
registry / pack pipeline, and that the cache-miss path runs
|
||||
build → tag → push → pack in order against a registry port the
|
||||
helper yields.
|
||||
|
||||
The pipeline lives in `launch.py` (moved from `prepare.py` so the
|
||||
docker build doesn't run before the dashboard's preflight modal;
|
||||
the curses-endwin / tmux pane-routing handoff happens around
|
||||
`launch`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.smolmachines import launch as _launch_mod
|
||||
|
||||
|
||||
class TestEnsureSmolmachine(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="cb-cache.")
|
||||
self._cache_patch = patch.object(
|
||||
_launch_mod, "_SMOLMACHINE_CACHE_DIR", Path(self._tmp.name),
|
||||
)
|
||||
self._cache_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._cache_patch.stop()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_cache_hit_skips_registry_and_pack(self):
|
||||
# Pre-populate the cache for image id `sha256:abcdef0123456789...`.
|
||||
digest = "abcdef0123456789"
|
||||
sidecar = Path(self._tmp.name) / f"{digest}.smolmachine.smolmachine"
|
||||
sidecar.write_text("")
|
||||
|
||||
with patch.object(
|
||||
_launch_mod.docker_mod, "build_image",
|
||||
) as build, patch.object(
|
||||
_launch_mod.docker_mod, "image_id",
|
||||
return_value=f"sha256:{digest}fffffffffffffffff",
|
||||
), patch.object(
|
||||
_launch_mod.docker_mod, "save",
|
||||
) as save, patch.object(
|
||||
_launch_mod, "ephemeral_registry",
|
||||
) as registry, patch.object(
|
||||
_launch_mod, "crane_push_tarball",
|
||||
) as push, patch.object(
|
||||
_launch_mod._smolvm, "pack_create",
|
||||
) as pack:
|
||||
result = _launch_mod._ensure_smolmachine("bot-bottle-claude:latest")
|
||||
|
||||
self.assertEqual(sidecar, result)
|
||||
# build still runs (Dockerfile edits land without manual rmi).
|
||||
build.assert_called_once()
|
||||
# No save (500MB tarball), no registry, no push, no pack on
|
||||
# cache hit.
|
||||
save.assert_not_called()
|
||||
registry.assert_not_called()
|
||||
push.assert_not_called()
|
||||
pack.assert_not_called()
|
||||
|
||||
def test_cache_miss_runs_build_save_push_pack_in_order(self):
|
||||
digest = "0123456789abcdef"
|
||||
|
||||
# ephemeral_registry yields a RegistryHandle with the
|
||||
# docker network + a push endpoint (container DNS) and
|
||||
# pull endpoint (host port-forward).
|
||||
from bot_bottle.backend.smolmachines.local_registry import (
|
||||
RegistryHandle,
|
||||
)
|
||||
|
||||
class _Reg:
|
||||
def __enter__(self_inner): # type: ignore
|
||||
return RegistryHandle(
|
||||
network="cb-net-xyz",
|
||||
push_endpoint="cb-registry-xyz:5000",
|
||||
pull_endpoint="localhost:54321",
|
||||
)
|
||||
def __exit__(self_inner, *exc): # type: ignore
|
||||
return False
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def record(name): # type: ignore
|
||||
def _f(*a, **kw): # type: ignore
|
||||
calls.append(name)
|
||||
return _f
|
||||
|
||||
def save_and_record(image_ref: str, path: str) -> None:
|
||||
Path(path).touch()
|
||||
calls.append("save")
|
||||
|
||||
with patch.object(
|
||||
_launch_mod.docker_mod, "build_image",
|
||||
side_effect=record("build"),
|
||||
), patch.object(
|
||||
_launch_mod.docker_mod, "image_id",
|
||||
return_value=f"sha256:{digest}fffffffffffffffff",
|
||||
), patch.object(
|
||||
_launch_mod.docker_mod, "save",
|
||||
side_effect=save_and_record,
|
||||
) as save, patch.object(
|
||||
_launch_mod, "ephemeral_registry",
|
||||
return_value=_Reg(),
|
||||
), patch.object(
|
||||
_launch_mod, "crane_push_tarball",
|
||||
side_effect=record("push"),
|
||||
) as push, patch.object(
|
||||
_launch_mod._smolvm, "pack_create",
|
||||
side_effect=record("pack"),
|
||||
) as pack:
|
||||
_launch_mod._ensure_smolmachine("bot-bottle-claude:latest")
|
||||
|
||||
# Build → save → push → pack in that order. No `docker
|
||||
# push` (the daemon's HTTPS-by-default path is what we're
|
||||
# sidestepping).
|
||||
self.assertEqual(["build", "save", "push", "pack"], calls)
|
||||
|
||||
# docker save targets a per-digest tarball alongside the
|
||||
# cached sidecar.
|
||||
save_args = save.call_args.args
|
||||
self.assertEqual("bot-bottle-claude:latest", save_args[0])
|
||||
self.assertTrue(save_args[1].endswith(f"{digest}.image.tar"))
|
||||
|
||||
# crane push runs against the push_endpoint (container DNS
|
||||
# on the registry network) with the digest as the tag.
|
||||
push_args = push.call_args.args
|
||||
self.assertEqual(
|
||||
f"cb-registry-xyz:5000/bot-bottle:{digest}", push_args[2],
|
||||
)
|
||||
|
||||
# pack_create reads from the pull_endpoint (host port-
|
||||
# forward, smolvm is on the host). Same repo+tag, just a
|
||||
# different routing hostname — the registry stores one blob.
|
||||
pack_args = pack.call_args.args
|
||||
self.assertEqual(
|
||||
f"localhost:54321/bot-bottle:{digest}", pack_args[0],
|
||||
)
|
||||
self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))
|
||||
|
||||
|
||||
class TestAgentFromPath(unittest.TestCase):
|
||||
def _plan(self) -> Any:
|
||||
return cast(Any, SimpleNamespace(
|
||||
slug="dev-abc12",
|
||||
agent_image="bot-bottle-claude:latest",
|
||||
agent_dockerfile_path="/repo/Dockerfile",
|
||||
))
|
||||
|
||||
def test_uses_committed_artifact_when_present(self):
|
||||
with tempfile.TemporaryDirectory(prefix="committed-smolmachine.") as tmp:
|
||||
artifact = Path(tmp) / "committed-smolmachine.smolmachine"
|
||||
artifact.write_text("")
|
||||
with patch.object(
|
||||
_launch_mod, "read_committed_image", return_value=str(artifact),
|
||||
), patch.object(
|
||||
_launch_mod, "_ensure_smolmachine",
|
||||
) as ensure, patch.object(
|
||||
_launch_mod, "info",
|
||||
):
|
||||
result = _launch_mod._agent_from_path(self._plan())
|
||||
|
||||
self.assertEqual(artifact, result)
|
||||
ensure.assert_not_called()
|
||||
|
||||
def test_falls_back_when_committed_artifact_missing(self):
|
||||
packed = Path("/cache/agent.smolmachine")
|
||||
with patch.object(
|
||||
_launch_mod, "read_committed_image",
|
||||
return_value="/missing/committed.smolmachine",
|
||||
), patch.object(
|
||||
_launch_mod, "_ensure_smolmachine", return_value=packed,
|
||||
) as ensure:
|
||||
result = _launch_mod._agent_from_path(self._plan())
|
||||
|
||||
self.assertEqual(packed, result)
|
||||
ensure.assert_called_once_with(
|
||||
"bot-bottle-claude:latest",
|
||||
dockerfile="/repo/Dockerfile",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,250 +0,0 @@
|
||||
"""Unit: ephemeral local-registry helper (PRD 0023 chunk 4c).
|
||||
|
||||
The helper brings up a `registry:2.8.3` container on a private
|
||||
docker network with a random host-side port, yields a
|
||||
`RegistryHandle`, and tears the container + network down on exit.
|
||||
Tests mock `subprocess.run` + `socket.create_connection` so they
|
||||
run without docker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.smolmachines import local_registry
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# Run sequence per ephemeral_registry() call:
|
||||
# docker network create -> ok
|
||||
# docker run -d (registry) -> ok (container id)
|
||||
# docker port (host port) -> ok (mapping line)
|
||||
# docker rm -f (registry) -> ok (in finally)
|
||||
# docker network rm -> ok (in finally)
|
||||
def _stock_run_sequence(port_line: str = "0.0.0.0:54321\n"):
|
||||
return [
|
||||
_ok(), # docker network create
|
||||
_ok(stdout="<container-id>\n"), # docker run
|
||||
_ok(stdout=port_line), # docker port
|
||||
_ok(), # docker rm -f
|
||||
_ok(), # docker network rm
|
||||
]
|
||||
|
||||
|
||||
class TestEphemeralRegistry(unittest.TestCase):
|
||||
def test_yields_handle_with_network_and_endpoints(self):
|
||||
with patch.object(
|
||||
local_registry.subprocess, "run",
|
||||
side_effect=_stock_run_sequence(),
|
||||
) as run, patch.object(
|
||||
local_registry.socket, "create_connection",
|
||||
return_value=_FakeSocket(),
|
||||
):
|
||||
with local_registry.ephemeral_registry() as handle:
|
||||
# push_endpoint points at the registry container by
|
||||
# its docker-network name on its container port.
|
||||
self.assertTrue(
|
||||
handle.push_endpoint.startswith(
|
||||
"bot-bottle-registry-"
|
||||
)
|
||||
)
|
||||
self.assertTrue(handle.push_endpoint.endswith(":5000"))
|
||||
# pull_endpoint is the host-side mapping for smolvm.
|
||||
self.assertEqual("localhost:54321", handle.pull_endpoint)
|
||||
# network name is the per-session bridge crane joins.
|
||||
self.assertTrue(
|
||||
handle.network.startswith("bot-bottle-registry-net-")
|
||||
)
|
||||
# docker network create + docker run + docker port + rm -f + network rm
|
||||
self.assertEqual(5, run.call_count)
|
||||
|
||||
def test_registry_run_publishes_random_port_across_interfaces(self):
|
||||
with patch.object(
|
||||
local_registry.subprocess, "run",
|
||||
side_effect=_stock_run_sequence(),
|
||||
) as run, patch.object(
|
||||
local_registry.socket, "create_connection",
|
||||
return_value=_FakeSocket(),
|
||||
):
|
||||
with local_registry.ephemeral_registry():
|
||||
pass
|
||||
# second call is the docker run for the registry
|
||||
run_argv = run.call_args_list[1].args[0]
|
||||
self.assertEqual(["docker", "run"], run_argv[:2])
|
||||
self.assertIn("--rm", run_argv)
|
||||
# `-p 5000` (no IP prefix) — needed so the host-published
|
||||
# port is reachable from BOTH the host (for smolvm) and the
|
||||
# docker daemon (for the docker port command to find it).
|
||||
self.assertIn("5000", run_argv)
|
||||
# And the registry is attached to the same per-session
|
||||
# network the crane push container joins.
|
||||
self.assertIn("--network", run_argv)
|
||||
|
||||
def test_force_removes_container_and_network_on_clean_exit(self):
|
||||
with patch.object(
|
||||
local_registry.subprocess, "run",
|
||||
side_effect=_stock_run_sequence(),
|
||||
) as run, patch.object(
|
||||
local_registry.socket, "create_connection",
|
||||
return_value=_FakeSocket(),
|
||||
):
|
||||
with local_registry.ephemeral_registry():
|
||||
pass
|
||||
|
||||
# Last two calls are `docker rm -f <container>` then
|
||||
# `docker network rm <network>`.
|
||||
argvs = [c.args[0] for c in run.call_args_list]
|
||||
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3])
|
||||
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
|
||||
|
||||
def test_force_removes_on_exception_inside_with(self):
|
||||
with patch.object(
|
||||
local_registry.subprocess, "run",
|
||||
side_effect=_stock_run_sequence(),
|
||||
) as run, patch.object(
|
||||
local_registry.socket, "create_connection",
|
||||
return_value=_FakeSocket(),
|
||||
):
|
||||
with self.assertRaises(RuntimeError):
|
||||
with local_registry.ephemeral_registry():
|
||||
raise RuntimeError("inside with")
|
||||
|
||||
# Both teardowns still ran.
|
||||
argvs = [c.args[0] for c in run.call_args_list]
|
||||
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3])
|
||||
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
|
||||
|
||||
def test_wait_ready_times_out(self):
|
||||
with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object(
|
||||
local_registry.subprocess, "run",
|
||||
side_effect=_stock_run_sequence(),
|
||||
) as run, patch.object(
|
||||
local_registry.socket, "create_connection",
|
||||
side_effect=OSError("conn refused"),
|
||||
), patch.object(
|
||||
local_registry, "die",
|
||||
side_effect=SystemExit("die called"),
|
||||
) as die:
|
||||
with self.assertRaises(SystemExit):
|
||||
with local_registry.ephemeral_registry():
|
||||
self.fail("yield reached despite unreachable registry")
|
||||
die.assert_called_once()
|
||||
# Teardown still ran via the finally blocks.
|
||||
argvs = [c.args[0] for c in run.call_args_list]
|
||||
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3])
|
||||
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
|
||||
|
||||
def test_unique_session_ids_per_call(self):
|
||||
sessions: list[tuple[str, str]] = []
|
||||
|
||||
def capture(argv, *a, **kw): # type: ignore
|
||||
if argv[:3] == ["docker", "network", "create"]:
|
||||
return _ok()
|
||||
if argv[:2] == ["docker", "run"]:
|
||||
# `--name <registry-name>` and `--network <net-name>`
|
||||
# both encode the session id.
|
||||
name = argv[argv.index("--name") + 1]
|
||||
network = argv[argv.index("--network") + 1]
|
||||
sessions.append((name, network))
|
||||
return _ok(stdout="cid\n")
|
||||
if argv[:2] == ["docker", "port"]:
|
||||
return _ok(stdout="0.0.0.0:1\n")
|
||||
return _ok()
|
||||
|
||||
with patch.object(
|
||||
local_registry.subprocess, "run", side_effect=capture,
|
||||
), patch.object(
|
||||
local_registry.socket, "create_connection",
|
||||
return_value=_FakeSocket(),
|
||||
):
|
||||
with local_registry.ephemeral_registry():
|
||||
pass
|
||||
with local_registry.ephemeral_registry():
|
||||
pass
|
||||
|
||||
self.assertEqual(2, len(sessions))
|
||||
self.assertNotEqual(sessions[0], sessions[1])
|
||||
|
||||
|
||||
class TestCranePushTarball(unittest.TestCase):
|
||||
def test_runs_crane_container_on_registry_network_with_insecure_flag(self):
|
||||
handle = local_registry.RegistryHandle(
|
||||
network="cb-registry-net-x",
|
||||
push_endpoint="cb-registry-x:5000",
|
||||
pull_endpoint="localhost:54321",
|
||||
)
|
||||
with patch.object(
|
||||
local_registry.subprocess, "run", return_value=_ok(),
|
||||
) as run:
|
||||
local_registry.crane_push_tarball(
|
||||
handle, "/tmp/img.tar", "cb-registry-x:5000/cb:abc",
|
||||
)
|
||||
|
||||
argv = run.call_args.args[0]
|
||||
# Joined to the same docker network so it can reach the
|
||||
# registry by container name (no host port-forward needed
|
||||
# for the push leg).
|
||||
self.assertEqual("docker", argv[0])
|
||||
self.assertEqual("run", argv[1])
|
||||
self.assertIn("--rm", argv)
|
||||
self.assertIn("--network", argv)
|
||||
self.assertEqual(
|
||||
"cb-registry-net-x", argv[argv.index("--network") + 1],
|
||||
)
|
||||
# The tarball is mounted read-only at /img.tar.
|
||||
self.assertIn("-v", argv)
|
||||
self.assertIn("/tmp/img.tar:/img.tar:ro", argv)
|
||||
# And the crane command itself uses --insecure so plain
|
||||
# HTTP is allowed against the registry container.
|
||||
self.assertIn("push", argv)
|
||||
self.assertIn("--insecure", argv)
|
||||
self.assertIn("/img.tar", argv)
|
||||
self.assertIn("cb-registry-x:5000/cb:abc", argv)
|
||||
|
||||
def test_dies_when_crane_returns_non_zero(self):
|
||||
handle = local_registry.RegistryHandle(
|
||||
network="cb-net", push_endpoint="cb:5000", pull_endpoint="localhost:1",
|
||||
)
|
||||
with patch.object(
|
||||
local_registry.subprocess, "run",
|
||||
return_value=_fail("push failed"),
|
||||
), patch.object(
|
||||
local_registry, "die", side_effect=SystemExit("die"),
|
||||
) as die:
|
||||
with self.assertRaises(SystemExit):
|
||||
local_registry.crane_push_tarball(
|
||||
handle, "/tmp/img.tar", "cb:5000/cb:abc",
|
||||
)
|
||||
die.assert_called_once()
|
||||
# Error message names what was being pushed where.
|
||||
msg = die.call_args.args[0]
|
||||
self.assertIn("/tmp/img.tar", msg)
|
||||
self.assertIn("cb:5000/cb:abc", msg)
|
||||
|
||||
|
||||
class _FakeSocket:
|
||||
"""Minimal context-manager stand-in for the socket
|
||||
`create_connection` returns. The helper only uses `with` on it
|
||||
and discards the value, so we don't need any real network."""
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc): # type: ignore
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,430 +0,0 @@
|
||||
"""Unit: per-bottle loopback alias pool (follow-up to the
|
||||
Docker-Desktop fix in PR #74).
|
||||
|
||||
`ensure_pool` lazily sudo-adds missing aliases on macOS; no-ops
|
||||
on Linux. `allocate` picks the lowest-numbered unused alias by
|
||||
inspecting running bundle containers' port bindings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.smolmachines import loopback_alias
|
||||
|
||||
|
||||
def _ok(stdout: str = "") -> subprocess.CompletedProcess: # type: ignore
|
||||
return subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout=stdout, stderr="",
|
||||
)
|
||||
|
||||
|
||||
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
|
||||
return subprocess.CompletedProcess(
|
||||
args=[], returncode=1, stdout="", stderr=stderr,
|
||||
)
|
||||
|
||||
|
||||
# `ifconfig lo0` on macOS with the default lo0 config: just
|
||||
# 127.0.0.1. We craft fixtures around this shape.
|
||||
_LO0_DEFAULT = (
|
||||
"lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384\n"
|
||||
"\tinet 127.0.0.1 netmask 0xff000000\n"
|
||||
"\tinet6 ::1 prefixlen 128\n"
|
||||
)
|
||||
|
||||
_LO0_PARTIAL = (
|
||||
_LO0_DEFAULT
|
||||
+ "\tinet 127.0.0.16 netmask 0xffffffff\n"
|
||||
+ "\tinet 127.0.0.17 netmask 0xffffffff\n"
|
||||
)
|
||||
|
||||
|
||||
def _lo0_full() -> str:
|
||||
"""All 16 pool addresses already aliased."""
|
||||
aliases = "".join(
|
||||
f"\tinet 127.0.0.{i} netmask 0xffffffff\n"
|
||||
for i in range(16, 32)
|
||||
)
|
||||
return _LO0_DEFAULT + aliases
|
||||
|
||||
|
||||
class TestEnsurePool(unittest.TestCase):
|
||||
def test_noop_on_linux(self):
|
||||
# `_is_macos` returns False on Linux; ensure_pool should
|
||||
# never shell out to sudo.
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=False), \
|
||||
patch.object(loopback_alias.subprocess, "run") as run:
|
||||
loopback_alias.ensure_pool()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_all_present_skips_sudo(self):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(
|
||||
loopback_alias.subprocess, "run",
|
||||
return_value=_ok(stdout=_lo0_full()),
|
||||
) as run:
|
||||
loopback_alias.ensure_pool()
|
||||
# Just the ifconfig probe per pool address; no sudo at all.
|
||||
for call in run.call_args_list:
|
||||
self.assertNotIn("sudo", call.args[0])
|
||||
|
||||
def test_missing_aliases_dispatch_sudo(self):
|
||||
# lo0 only has 16+17 already; sudo runs for 18..31 (14 missing).
|
||||
runs: list[list[str]] = []
|
||||
|
||||
def fake_run(argv, *a, **kw): # type: ignore
|
||||
runs.append(argv)
|
||||
if argv[:2] == ["/sbin/ifconfig", "lo0"]:
|
||||
return _ok(stdout=_LO0_PARTIAL)
|
||||
return _ok()
|
||||
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias.subprocess, "run", side_effect=fake_run):
|
||||
loopback_alias.ensure_pool()
|
||||
|
||||
sudo_calls = [r for r in runs if r and r[0] == "sudo"]
|
||||
self.assertEqual(14, len(sudo_calls))
|
||||
sudo_ips = {call[call.index("alias") + 1].split("/")[0] for call in sudo_calls}
|
||||
self.assertEqual(
|
||||
{f"127.0.0.{i}" for i in range(18, 32)},
|
||||
sudo_ips,
|
||||
)
|
||||
|
||||
def test_sudo_failure_dies(self):
|
||||
def fake_run(argv, *a, **kw): # type: ignore
|
||||
if argv[:2] == ["/sbin/ifconfig", "lo0"]:
|
||||
return _ok(stdout=_LO0_DEFAULT)
|
||||
if argv[:1] == ["sudo"]:
|
||||
return _fail()
|
||||
return _ok()
|
||||
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias.subprocess, "run", side_effect=fake_run), \
|
||||
patch.object(loopback_alias, "die", side_effect=SystemExit("die")):
|
||||
with self.assertRaises(SystemExit):
|
||||
loopback_alias.ensure_pool()
|
||||
|
||||
|
||||
class TestAllocate(unittest.TestCase):
|
||||
def test_per_bottle_alias_on_linux(self):
|
||||
# Linux gets the same per-bottle scoping as macOS (127/8 is
|
||||
# already loopback, so no ifconfig is needed). A fresh host
|
||||
# with no running bundles allocates the first pool entry.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
lock_path = Path(tmp) / "smolmachines.lock"
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=False), \
|
||||
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
|
||||
patch.object(loopback_alias, "_aliases_in_use", return_value=set()):
|
||||
self.assertEqual("127.0.0.16", loopback_alias.allocate("demo"))
|
||||
|
||||
def test_picks_lowest_unused_on_macos(self):
|
||||
# No bundles running -> first pool entry.
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias, "_aliases_in_use", return_value=set()):
|
||||
self.assertEqual("127.0.0.16", loopback_alias.allocate("demo-1"))
|
||||
|
||||
def test_skips_in_use_aliases(self):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(
|
||||
loopback_alias, "_aliases_in_use",
|
||||
return_value={"127.0.0.16", "127.0.0.17", "127.0.0.19"},
|
||||
):
|
||||
# First unused = 127.0.0.18.
|
||||
self.assertEqual("127.0.0.18", loopback_alias.allocate("demo-3"))
|
||||
|
||||
def test_dies_when_pool_exhausted(self):
|
||||
all_in_use = {f"127.0.0.{i}" for i in range(16, 32)}
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(
|
||||
loopback_alias, "_aliases_in_use",
|
||||
return_value=all_in_use,
|
||||
), patch.object(
|
||||
loopback_alias, "die", side_effect=SystemExit("die"),
|
||||
):
|
||||
with self.assertRaises(SystemExit):
|
||||
loopback_alias.allocate("demo-overflow")
|
||||
|
||||
|
||||
class TestAllocateLock(unittest.TestCase):
|
||||
"""allocate() on macOS acquires a file lock so concurrent calls
|
||||
serialise rather than racing on docker state."""
|
||||
|
||||
def test_acquires_exclusive_lock_on_macos(self):
|
||||
import fcntl as fcntl_mod
|
||||
flock_calls: list[int] = []
|
||||
|
||||
def record_flock(fd, op): # type: ignore
|
||||
flock_calls.append(op)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
lock_path = Path(tmp) / "smolmachines.lock"
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
|
||||
patch.object(loopback_alias, "_aliases_in_use", return_value=set()), \
|
||||
patch.object(loopback_alias.fcntl, "flock",
|
||||
side_effect=record_flock):
|
||||
loopback_alias.allocate("demo")
|
||||
|
||||
self.assertIn(fcntl_mod.LOCK_EX, flock_calls)
|
||||
|
||||
def test_acquires_exclusive_lock_on_linux(self):
|
||||
# Linux allocates per-bottle too, so it must take the same
|
||||
# lock to serialise concurrent launches.
|
||||
import fcntl as fcntl_mod
|
||||
flock_calls: list[int] = []
|
||||
|
||||
def record_flock(fd, op): # type: ignore
|
||||
flock_calls.append(op)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
lock_path = Path(tmp) / "smolmachines.lock"
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=False), \
|
||||
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
|
||||
patch.object(loopback_alias, "_aliases_in_use", return_value=set()), \
|
||||
patch.object(loopback_alias.fcntl, "flock",
|
||||
side_effect=record_flock):
|
||||
loopback_alias.allocate("demo")
|
||||
|
||||
self.assertIn(fcntl_mod.LOCK_EX, flock_calls)
|
||||
|
||||
def test_sequential_allocations_with_shared_lock_are_serialised(self):
|
||||
# Two sequential calls share the same lock file. The second
|
||||
# call sees {127.0.0.16} in use (as if the first caller's
|
||||
# docker run completed between the two lock acquisitions) and
|
||||
# returns the next alias.
|
||||
in_use_seq = [set(), {"127.0.0.16"}]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
lock_path = Path(tmp) / "smolmachines.lock"
|
||||
results: list[str] = []
|
||||
for _ in range(2):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
|
||||
patch.object(loopback_alias, "_aliases_in_use",
|
||||
return_value=in_use_seq.pop(0)):
|
||||
results.append(loopback_alias.allocate("demo"))
|
||||
|
||||
self.assertEqual(["127.0.0.16", "127.0.0.17"], results)
|
||||
|
||||
|
||||
class TestAliasInUseDetection(unittest.TestCase):
|
||||
"""`_aliases_in_use` inspects every running bundle and pulls
|
||||
each container's port-binding `HostIp` out. The detection has
|
||||
to survive: no running bundles, multiple bundles, docker
|
||||
inspect failures."""
|
||||
|
||||
def test_no_bundles_returns_empty(self):
|
||||
with patch.object(
|
||||
loopback_alias.subprocess, "run",
|
||||
return_value=_ok(stdout=""),
|
||||
):
|
||||
self.assertEqual(set(), loopback_alias._aliases_in_use())
|
||||
|
||||
def test_walks_bundles_and_pulls_host_ips(self):
|
||||
# First call: docker ps -> two bundle names.
|
||||
# Then docker inspect each, returning a port-bindings JSON
|
||||
# blob with a HostIp on the per-bottle alias.
|
||||
ps_out = "bot-bottle-sidecars-a\nbot-bottle-sidecars-b\n"
|
||||
inspect_a = (
|
||||
'{"8888/tcp":[{"HostIp":"127.0.0.16","HostPort":"54000"}]}'
|
||||
)
|
||||
inspect_b = (
|
||||
'{"9099/tcp":[{"HostIp":"127.0.0.17","HostPort":"54001"}]}'
|
||||
)
|
||||
|
||||
seq = [
|
||||
_ok(stdout=ps_out),
|
||||
_ok(stdout=inspect_a),
|
||||
_ok(stdout=inspect_b),
|
||||
]
|
||||
with patch.object(
|
||||
loopback_alias.subprocess, "run", side_effect=seq,
|
||||
):
|
||||
self.assertEqual(
|
||||
{"127.0.0.16", "127.0.0.17"},
|
||||
loopback_alias._aliases_in_use(),
|
||||
)
|
||||
|
||||
def test_inspect_failures_are_skipped(self):
|
||||
ps_out = "bot-bottle-sidecars-c\n"
|
||||
with patch.object(
|
||||
loopback_alias.subprocess, "run",
|
||||
side_effect=[_ok(stdout=ps_out), _fail("inspect failed")],
|
||||
):
|
||||
self.assertEqual(set(), loopback_alias._aliases_in_use())
|
||||
|
||||
|
||||
class TestForceAllowlist(unittest.TestCase):
|
||||
"""Smolvm 0.8.0 silently drops `--allow-cidr` with `--from`, so
|
||||
`force_allowlist` opens the state DB directly and sets the row's
|
||||
`allowed_cidrs` field — on both macOS and Linux. It is
|
||||
fail-closed: it dies rather than launching a VM whose allowlist
|
||||
it can't confirm. Round-trip tests against a real SQLite DB to
|
||||
lock down the BLOB encoding."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="smolvm-db.")
|
||||
self.db = Path(self._tmp.name) / "smolvm.db"
|
||||
con = sqlite3.connect(str(self.db))
|
||||
con.execute(
|
||||
"CREATE TABLE vms (name TEXT PRIMARY KEY NOT NULL, data BLOB NOT NULL)"
|
||||
)
|
||||
# Mimic smolvm's row shape (the JSON keys that exist on
|
||||
# creation; allowed_cidrs is the field we patch).
|
||||
cfg = {
|
||||
"name": "demo-vm",
|
||||
"cpus": 4,
|
||||
"mem": 8192,
|
||||
"network": True,
|
||||
"allowed_cidrs": None,
|
||||
}
|
||||
con.execute(
|
||||
"INSERT INTO vms (name, data) VALUES (?, ?)",
|
||||
("demo-vm", sqlite3.Binary(json.dumps(cfg).encode())),
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_patches_allowed_cidrs_on_row(self):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
|
||||
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
|
||||
|
||||
con = sqlite3.connect(str(self.db))
|
||||
row = con.execute(
|
||||
"SELECT typeof(data), data FROM vms WHERE name='demo-vm'",
|
||||
).fetchone()
|
||||
con.close()
|
||||
# Must round-trip as BLOB (the column type smolvm reads).
|
||||
self.assertEqual("blob", row[0])
|
||||
cfg = json.loads(row[1])
|
||||
self.assertEqual(["127.0.0.16/32"], cfg["allowed_cidrs"])
|
||||
# Other fields preserved verbatim.
|
||||
self.assertEqual(4, cfg["cpus"])
|
||||
self.assertTrue(cfg["network"])
|
||||
|
||||
def test_patches_on_linux_too(self):
|
||||
# force_allowlist no longer no-ops on Linux — the TSI
|
||||
# allowlist must be enforced there as well.
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=False), \
|
||||
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
|
||||
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
|
||||
con = sqlite3.connect(str(self.db))
|
||||
cfg = json.loads(con.execute(
|
||||
"SELECT data FROM vms WHERE name='demo-vm'",
|
||||
).fetchone()[0])
|
||||
con.close()
|
||||
self.assertEqual(["127.0.0.16/32"], cfg["allowed_cidrs"])
|
||||
|
||||
def test_skips_write_when_already_matching(self):
|
||||
# A newer smolvm that honors --allow-cidr at create leaves the
|
||||
# row already correct; force_allowlist must not rewrite it. We
|
||||
# detect a no-write by comparing the raw BLOB byte-for-byte
|
||||
# (a rewrite re-serialises the JSON, changing key order/bytes
|
||||
# is not guaranteed, but mtime/identity isn't observable — so
|
||||
# we assert the stored bytes are exactly what we pre-seeded).
|
||||
seeded = json.dumps({
|
||||
"name": "demo-vm", "cpus": 4, "mem": 8192,
|
||||
"network": True, "allowed_cidrs": ["127.0.0.16/32"],
|
||||
}).encode()
|
||||
con = sqlite3.connect(str(self.db))
|
||||
con.execute(
|
||||
"UPDATE vms SET data=? WHERE name='demo-vm'",
|
||||
(sqlite3.Binary(seeded),),
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
|
||||
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
|
||||
|
||||
con = sqlite3.connect(str(self.db))
|
||||
stored = con.execute(
|
||||
"SELECT data FROM vms WHERE name='demo-vm'").fetchone()[0]
|
||||
con.close()
|
||||
self.assertEqual(seeded, bytes(stored))
|
||||
|
||||
def test_dies_when_patch_does_not_take(self):
|
||||
# If the persisted allowlist still doesn't match after the
|
||||
# patch (e.g. wrong schema / smolvm stores it elsewhere),
|
||||
# force_allowlist must fail closed rather than boot the VM.
|
||||
original = loopback_alias._read_machine_cfg
|
||||
|
||||
def stale_cfg(con: sqlite3.Connection, name: str) -> dict[str, object]:
|
||||
# Always report the un-patched row so the post-write
|
||||
# verification never sees the requested cidrs.
|
||||
cfg = original(con, name)
|
||||
cfg["allowed_cidrs"] = None
|
||||
return cfg
|
||||
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db), \
|
||||
patch.object(loopback_alias, "_read_machine_cfg", side_effect=stale_cfg), \
|
||||
patch.object(loopback_alias, "die", side_effect=SystemExit("die")):
|
||||
with self.assertRaises(SystemExit):
|
||||
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
|
||||
|
||||
def test_dies_on_missing_db(self):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(
|
||||
loopback_alias, "_SMOLVM_DB_PATH",
|
||||
Path("/nonexistent/smolvm.db"),
|
||||
), patch.object(
|
||||
loopback_alias, "die", side_effect=SystemExit("die"),
|
||||
):
|
||||
with self.assertRaises(SystemExit):
|
||||
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
|
||||
|
||||
def test_dies_on_missing_row(self):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db), \
|
||||
patch.object(
|
||||
loopback_alias, "die", side_effect=SystemExit("die"),
|
||||
):
|
||||
with self.assertRaises(SystemExit):
|
||||
loopback_alias.force_allowlist("not-in-db", ["127.0.0.16/32"])
|
||||
|
||||
|
||||
class TestSmolvmDbPath(unittest.TestCase):
|
||||
"""The smolvm state-DB path is platform-derived: Application
|
||||
Support on macOS, XDG data dir on Linux."""
|
||||
|
||||
def test_macos_path(self):
|
||||
with patch.object(loopback_alias.platform, "system", return_value="Darwin"):
|
||||
p = loopback_alias._smolvm_db_path()
|
||||
self.assertEqual(
|
||||
("Library", "Application Support", "smolvm", "server", "smolvm.db"),
|
||||
p.parts[-5:],
|
||||
)
|
||||
|
||||
def test_linux_default_xdg_path(self):
|
||||
env = {k: v for k, v in os.environ.items() if k != "XDG_DATA_HOME"}
|
||||
with patch.object(loopback_alias.platform, "system", return_value="Linux"), \
|
||||
patch.dict(loopback_alias.os.environ, env, clear=True):
|
||||
p = loopback_alias._smolvm_db_path()
|
||||
self.assertEqual(
|
||||
(".local", "share", "smolvm", "server", "smolvm.db"),
|
||||
p.parts[-5:],
|
||||
)
|
||||
|
||||
def test_linux_respects_xdg_data_home(self):
|
||||
with patch.object(loopback_alias.platform, "system", return_value="Linux"), \
|
||||
patch.dict(loopback_alias.os.environ,
|
||||
{"XDG_DATA_HOME": "/custom/data"}, clear=False):
|
||||
p = loopback_alias._smolvm_db_path()
|
||||
self.assertEqual(Path("/custom/data/smolvm/server/smolvm.db"), p)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,559 +0,0 @@
|
||||
"""Unit: smolmachines provisioning helpers (PRD 0023 chunks 4a + 4d).
|
||||
|
||||
Tests mock `bottle.exec` / `bottle.cp_in` and assert on the
|
||||
dispatched script shape. The real round-trip lives in the chunk-4
|
||||
integration smoke."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.agent_provider import (
|
||||
AgentProvider,
|
||||
AgentProviderRuntime,
|
||||
AgentProvisionCommand,
|
||||
AgentProvisionDir,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
)
|
||||
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||
from bot_bottle.backend.smolmachines.bottle_plan import (
|
||||
SmolmachinesBottlePlan,
|
||||
)
|
||||
from bot_bottle.backend.smolmachines import launch as _launch
|
||||
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
|
||||
from bot_bottle.backend.util import AGENT_CA_PATH
|
||||
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||
from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, ManifestIndex
|
||||
from bot_bottle.supervise import SupervisePlan
|
||||
|
||||
|
||||
class _Provider(AgentProvider):
|
||||
"""Minimal concrete subclass for testing the default provision_ca/provision_git."""
|
||||
@property
|
||||
def runtime(self) -> AgentProviderRuntime:
|
||||
return AgentProviderRuntime(
|
||||
template="test", command="test", image="",
|
||||
prompt_mode="append_file", bypass_args=(), resume_args=(),
|
||||
)
|
||||
def provision_plan(self, **kwargs): # type: ignore[override]
|
||||
raise NotImplementedError
|
||||
def provision_skills(self, plan, bottle): ... # type: ignore[override]
|
||||
def provision_prompt(self, plan, bottle): ... # type: ignore[override]
|
||||
def provision(self, plan, bottle): ... # type: ignore[override]
|
||||
def provision_supervise_mcp(self, plan, bottle, supervise_url): ... # type: ignore[override]
|
||||
def headless_prompt(self, prompt): return [] # type: ignore[override]
|
||||
|
||||
|
||||
_PROVIDER = _Provider()
|
||||
|
||||
|
||||
def _make_bottle(
|
||||
name: str = "bot-bottle-demo-abc12",
|
||||
exec_result: ExecResult | None = None,
|
||||
) -> MagicMock:
|
||||
bottle = MagicMock(spec=Bottle)
|
||||
bottle.name = name
|
||||
bottle.exec.return_value = (
|
||||
exec_result if exec_result is not None
|
||||
else ExecResult(returncode=0, stdout="", stderr="")
|
||||
)
|
||||
return bottle
|
||||
|
||||
|
||||
def _exec_users(bottle: MagicMock) -> list[str]: # type: ignore
|
||||
"""user= kwarg from each bottle.exec call, in order."""
|
||||
return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list]
|
||||
|
||||
|
||||
def _plan(
|
||||
*,
|
||||
agent_prompt: str = "",
|
||||
skills: list[str] | None = None,
|
||||
git: list[ManifestGitEntry] = (), # type: ignore
|
||||
git_user: dict | None = None, # type: ignore
|
||||
copy_cwd: bool = False,
|
||||
user_cwd: str = "/tmp/x",
|
||||
stage_dir: Path | None = None,
|
||||
egress_routes: tuple[EgressRoute, ...] = (),
|
||||
egress_ca_path: Path = Path(),
|
||||
canary: bool = False,
|
||||
supervise: bool = False,
|
||||
bundle_ip: str = "192.168.50.2",
|
||||
agent_git_gate_host: str = "127.0.0.1:55555",
|
||||
agent_supervise_url: str = "http://127.0.0.1:55556/",
|
||||
codex_auth_file: Path | None = None,
|
||||
agent_provider_template: str = "claude",
|
||||
guest_env: dict[str, str] | None = None,
|
||||
) -> SmolmachinesBottlePlan:
|
||||
bottle_json: dict = {} # type: ignore
|
||||
git_gate_json: dict = {} # type: ignore
|
||||
if git:
|
||||
git_gate_json["repos"] = {
|
||||
g.Name: {
|
||||
"url": g.Upstream,
|
||||
"key": {"provider": g.Key.provider or "static", "path": g.Key.path or g.IdentityFile},
|
||||
}
|
||||
for g in git
|
||||
}
|
||||
if git_user is not None:
|
||||
git_gate_json["user"] = git_user
|
||||
if git_gate_json:
|
||||
bottle_json["git-gate"] = git_gate_json
|
||||
if supervise:
|
||||
bottle_json["supervise"] = True
|
||||
index = ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": bottle_json},
|
||||
"agents": {
|
||||
"demo": {
|
||||
"skills": list(skills or []),
|
||||
"prompt": agent_prompt,
|
||||
"bottle": "dev",
|
||||
},
|
||||
},
|
||||
})
|
||||
manifest = index.load_for_agent("demo")
|
||||
spec = BottleSpec(
|
||||
manifest=index,
|
||||
agent_name="demo",
|
||||
copy_cwd=copy_cwd,
|
||||
user_cwd=user_cwd,
|
||||
)
|
||||
supervise_plan = None
|
||||
if supervise:
|
||||
supervise_plan = SupervisePlan(
|
||||
slug="demo-abc12",
|
||||
db_path=Path("/tmp/bot-bottle.db"),
|
||||
)
|
||||
return SmolmachinesBottlePlan(
|
||||
spec=spec,
|
||||
manifest=manifest,
|
||||
stage_dir=stage_dir or Path("/tmp/stage"),
|
||||
slug="demo-abc12",
|
||||
bundle_subnet="192.168.50.0/24",
|
||||
bundle_gateway="192.168.50.1",
|
||||
bundle_ip=bundle_ip,
|
||||
guest_env=dict(guest_env or {}),
|
||||
git_gate_plan=GitGatePlan(
|
||||
slug="demo-abc12",
|
||||
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
|
||||
hook_script=Path("/tmp/git-gate-hook"),
|
||||
access_hook_script=Path("/tmp/git-gate-access-hook"),
|
||||
upstreams=(),
|
||||
),
|
||||
egress_plan=EgressPlan(
|
||||
slug="demo-abc12",
|
||||
routes_path=Path("/tmp/routes.yaml"),
|
||||
routes=egress_routes,
|
||||
token_env_map={},
|
||||
mitmproxy_ca_cert_only_host_path=egress_ca_path,
|
||||
canary="fake-canary-value" if canary else "",
|
||||
canary_env="CANON_ALPHA_SECRET" if canary else "",
|
||||
),
|
||||
supervise_plan=supervise_plan,
|
||||
agent_git_gate_host=agent_git_gate_host,
|
||||
agent_supervise_url=agent_supervise_url,
|
||||
agent_provision=_agent_provision(
|
||||
agent_provider_template,
|
||||
codex_auth_file=codex_auth_file,
|
||||
guest_env=dict(guest_env or {}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _agent_provision(
|
||||
template: str,
|
||||
*,
|
||||
codex_auth_file: Path | None = None,
|
||||
guest_env: dict[str, str] | None = None,
|
||||
) -> AgentProvisionPlan:
|
||||
if template != "codex":
|
||||
return AgentProvisionPlan(
|
||||
template=template,
|
||||
command=template,
|
||||
prompt_mode="append_file",
|
||||
image="bot-bottle-claude:latest",
|
||||
dockerfile="",
|
||||
guest_home="/home/node",
|
||||
instance_name="bot-bottle-demo-abc12",
|
||||
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
|
||||
guest_env=dict(guest_env or {}),
|
||||
)
|
||||
auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex")
|
||||
files = [
|
||||
AgentProvisionFile(
|
||||
Path("/tmp/codex-config.toml"),
|
||||
f"{auth_dir}/config.toml",
|
||||
),
|
||||
]
|
||||
pre_copy: tuple[AgentProvisionCommand, ...] = ()
|
||||
verify: tuple[AgentProvisionCommand, ...] = ()
|
||||
if codex_auth_file is not None:
|
||||
files.append(AgentProvisionFile(codex_auth_file, f"{auth_dir}/auth.json"))
|
||||
pre_copy = (AgentProvisionCommand((
|
||||
"find", auth_dir,
|
||||
"-maxdepth", "1",
|
||||
"-type", "f",
|
||||
"(",
|
||||
"-name", "*.sqlite",
|
||||
"-o", "-name", "*.sqlite-*",
|
||||
"-o", "-name", "*.codex-repair-*.bak",
|
||||
")",
|
||||
"-delete",
|
||||
), "codex host credentials: could not reset runtime db files"),)
|
||||
verify = (AgentProvisionCommand((
|
||||
"runuser", "-u", "node", "--",
|
||||
"env",
|
||||
"HOME=/home/node",
|
||||
f"CODEX_HOME={auth_dir}",
|
||||
"codex", "login", "status",
|
||||
), "codex host credentials: dummy auth was copied into the guest"),)
|
||||
return AgentProvisionPlan(
|
||||
template="codex",
|
||||
command="codex",
|
||||
prompt_mode="read_prompt_file",
|
||||
image="bot-bottle-codex:latest",
|
||||
dockerfile="",
|
||||
guest_home="/home/node",
|
||||
instance_name="bot-bottle-demo-abc12",
|
||||
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
|
||||
guest_env=dict(guest_env or {}),
|
||||
dirs=(AgentProvisionDir(auth_dir),),
|
||||
files=tuple(files),
|
||||
pre_copy=pre_copy,
|
||||
verify=verify,
|
||||
)
|
||||
|
||||
|
||||
def _write_self_signed_cert(path: Path) -> None:
|
||||
"""Drop a real self-signed PEM at `path` so provision_ca's
|
||||
fingerprint computation (PEM_cert_to_DER_cert + sha256) has
|
||||
actual bytes to chew on. Generated once per test via openssl."""
|
||||
subprocess.run(
|
||||
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", "/dev/null",
|
||||
"-out", str(path),
|
||||
"-days", "1",
|
||||
"-subj", "/CN=test"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
class TestProvisionCA(unittest.TestCase):
|
||||
"""provision_ca always uses the egress MITM CA and dispatches
|
||||
cp_in + exec in the right order."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.") # pylint: disable=consider-using-with
|
||||
self.tmp = Path(self._tmp.name)
|
||||
self.egress_ca = self.tmp / "egress-ca.pem"
|
||||
_write_self_signed_cert(self.egress_ca)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
# provision_ca dies hard if update-ca-certificates' exit
|
||||
# is non-zero; supply a stock success return so the bulk of
|
||||
# the tests below exercise the happy path.
|
||||
_UPDATE_OK = ExecResult(
|
||||
returncode=0,
|
||||
stdout="Updating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\n",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
def test_egress_ca_always_installed(self):
|
||||
plan = _plan(egress_ca_path=self.egress_ca)
|
||||
bottle = _make_bottle(exec_result=self._UPDATE_OK)
|
||||
_PROVIDER.provision_ca(bottle, plan)
|
||||
bottle.cp_in.assert_called_once_with(
|
||||
str(self.egress_ca),
|
||||
AGENT_CA_PATH,
|
||||
)
|
||||
self.assertEqual(2, bottle.exec.call_count)
|
||||
script = bottle.exec.call_args_list[1].args[0]
|
||||
self.assertIn("chmod 644", script)
|
||||
self.assertIn("update-ca-certificates", script)
|
||||
self.assertEqual("root", bottle.exec.call_args.kwargs.get("user"))
|
||||
|
||||
def test_dies_when_egress_cert_missing(self):
|
||||
plan = _plan(egress_ca_path=self.tmp / "does-not-exist.pem")
|
||||
bottle = _make_bottle()
|
||||
with self.assertRaises(SystemExit):
|
||||
_PROVIDER.provision_ca(bottle, plan)
|
||||
|
||||
|
||||
class TestSmolmachinesBottleExec(unittest.TestCase):
|
||||
"""SmolmachinesBottle.exec retries once on SIGKILL (exit 137)."""
|
||||
|
||||
_SIGKILL = subprocess.CompletedProcess(
|
||||
args=[], returncode=137, stdout="", stderr="",
|
||||
)
|
||||
_SUCCESS = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="done", stderr="",
|
||||
)
|
||||
|
||||
def test_retries_on_sigkill(self):
|
||||
bottle = SmolmachinesBottle("test-machine")
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
|
||||
side_effect=[self._SIGKILL, self._SUCCESS],
|
||||
) as mock_run, patch(
|
||||
"bot_bottle.backend.smolmachines.bottle.time.sleep"
|
||||
) as mock_sleep:
|
||||
result = bottle.exec("echo hi")
|
||||
|
||||
self.assertEqual(0, result.returncode)
|
||||
self.assertEqual(2, mock_run.call_count)
|
||||
mock_sleep.assert_called_once_with(1.0)
|
||||
|
||||
def test_no_retry_on_success(self):
|
||||
bottle = SmolmachinesBottle("test-machine")
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
|
||||
return_value=self._SUCCESS,
|
||||
) as mock_run:
|
||||
result = bottle.exec("echo hi")
|
||||
|
||||
self.assertEqual(0, result.returncode)
|
||||
self.assertEqual(1, mock_run.call_count)
|
||||
|
||||
def test_no_retry_on_other_error(self):
|
||||
fail = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="err")
|
||||
bottle = SmolmachinesBottle("test-machine")
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
|
||||
return_value=fail,
|
||||
) as mock_run:
|
||||
result = bottle.exec("bad-cmd")
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertEqual(1, mock_run.call_count)
|
||||
|
||||
|
||||
class TestProvisionGit(unittest.TestCase):
|
||||
"""provision_git writes gitconfig insteadOf rules when configured."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") # pylint: disable=consider-using-with
|
||||
self.stage = Path(self._tmp.name)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_noop_when_no_cwd_and_no_git_entries(self):
|
||||
bottle = _make_bottle()
|
||||
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
|
||||
bottle.cp_in.assert_not_called()
|
||||
bottle.exec.assert_not_called()
|
||||
|
||||
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
|
||||
# Smolmachines's TSI-allowlisted guest dials git-gate via
|
||||
# smart HTTP at `127.0.0.1:<host port>` — the bundle's
|
||||
# git HTTP port is published on host loopback at launch
|
||||
# time, and the plan carries the discovered host port.
|
||||
plan = _plan(
|
||||
git=[ManifestGitEntry(
|
||||
Name="bot-bottle",
|
||||
Upstream="ssh://git@host/repo.git",
|
||||
Key=ManifestKeyConfig(provider="static", path="~/.ssh/id_ed25519"),
|
||||
IdentityFile="~/.ssh/id_ed25519",
|
||||
)],
|
||||
stage_dir=self.stage,
|
||||
agent_git_gate_host="127.0.0.1:9418",
|
||||
)
|
||||
bottle = _make_bottle()
|
||||
_PROVIDER.provision_git(bottle, plan)
|
||||
# The staged gitconfig path is whatever NamedTemporaryFile
|
||||
# picked; we read its contents.
|
||||
cp_call = bottle.cp_in.call_args
|
||||
staged_path = Path(cp_call.args[0])
|
||||
self.assertEqual(self.stage, staged_path.parent)
|
||||
content = staged_path.read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
'[url "http://127.0.0.1:9418/bot-bottle.git"]', content,
|
||||
)
|
||||
self.assertIn(
|
||||
"\tinsteadOf = ssh://git@host/repo.git", content,
|
||||
)
|
||||
|
||||
|
||||
class TestBundleLaunchSpec(unittest.TestCase):
|
||||
def test_git_gate_uses_http_daemon_for_smolmachines(self):
|
||||
plan = _plan()
|
||||
plan = replace(
|
||||
plan,
|
||||
git_gate_plan=replace(
|
||||
plan.git_gate_plan,
|
||||
upstreams=(GitGateUpstream(
|
||||
name="bot-bottle",
|
||||
upstream_url="ssh://git@host/repo.git",
|
||||
upstream_host="host",
|
||||
upstream_port="22",
|
||||
identity_file="/tmp/key",
|
||||
known_host_key="",
|
||||
),),
|
||||
),
|
||||
)
|
||||
|
||||
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
||||
|
||||
self.assertEqual(
|
||||
"egress,git-gate,git-http",
|
||||
spec.daemons_csv,
|
||||
)
|
||||
self.assertIn(9420, spec.ports_to_publish)
|
||||
self.assertNotIn(9418, spec.ports_to_publish)
|
||||
|
||||
def test_canary_env_registered_as_sensitive_in_bundle(self):
|
||||
plan = _plan(canary=True)
|
||||
|
||||
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
||||
|
||||
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", spec.environment)
|
||||
self.assertIn(
|
||||
"BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET",
|
||||
spec.environment,
|
||||
)
|
||||
|
||||
def test_supervise_adds_daemon_volume_and_env(self):
|
||||
from bot_bottle.supervise import DB_PATH_IN_CONTAINER
|
||||
plan = _plan(supervise=True)
|
||||
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
||||
self.assertIn("supervise", spec.daemons_csv)
|
||||
self.assertIn(f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", spec.environment)
|
||||
self.assertIn(("/tmp/bot-bottle.db", DB_PATH_IN_CONTAINER, False), spec.volumes)
|
||||
|
||||
def test_canary_env_visible_to_smolvm_guest(self):
|
||||
plan = _plan(canary=True)
|
||||
with patch.object(
|
||||
_launch._bundle,
|
||||
"bundle_host_port",
|
||||
return_value="65000",
|
||||
):
|
||||
stamped = _launch._discover_urls(plan, "127.0.0.16")
|
||||
|
||||
self.assertEqual(
|
||||
"fake-canary-value",
|
||||
stamped.guest_env["CANON_ALPHA_SECRET"],
|
||||
)
|
||||
|
||||
|
||||
class TestProvisionGitUser(unittest.TestCase):
|
||||
"""`provision_git` runs `git config --global` inside the
|
||||
guest as the node user. SmolmachinesBottle.exec sets HOME and
|
||||
USER automatically for the requested user, so --global lands
|
||||
in /home/node/.gitconfig. No-op when the bottle didn't declare
|
||||
git_user (issue #86)."""
|
||||
|
||||
def _git_config_calls(self, bottle: MagicMock) -> list[tuple[str, str]]:
|
||||
"""Filter bottle.exec calls down to git-config invocations,
|
||||
return list of (script, user) tuples."""
|
||||
out = []
|
||||
for c in bottle.exec.call_args_list:
|
||||
script = c.args[0] if c.args else ""
|
||||
user = c.kwargs.get("user", "node")
|
||||
if "git config" in script:
|
||||
out.append((script, user))
|
||||
return out
|
||||
|
||||
def test_noop_when_no_git_user(self):
|
||||
bottle = _make_bottle()
|
||||
_PROVIDER.provision_git(bottle, _plan())
|
||||
self.assertEqual([], self._git_config_calls(bottle))
|
||||
|
||||
def test_sets_name_and_email_as_node(self):
|
||||
plan = _plan(git_user={
|
||||
"name": "Eric Bauerfeld",
|
||||
"email": "eric@dideric.is",
|
||||
})
|
||||
bottle = _make_bottle()
|
||||
_PROVIDER.provision_git(bottle, plan)
|
||||
calls = self._git_config_calls(bottle)
|
||||
self.assertEqual(2, len(calls))
|
||||
# Both run as node so SmolmachinesBottle.exec sets HOME=/home/node
|
||||
# automatically, ensuring --global writes to /home/node/.gitconfig.
|
||||
for script, user in calls:
|
||||
self.assertEqual("node", user)
|
||||
self.assertIn("git config --global", script)
|
||||
self.assertIn("user.name", calls[0][0])
|
||||
self.assertIn("Eric Bauerfeld", calls[0][0])
|
||||
self.assertIn("user.email", calls[1][0])
|
||||
self.assertIn("eric@dideric.is", calls[1][0])
|
||||
|
||||
def test_name_only(self):
|
||||
plan = _plan(git_user={"name": "Bot"})
|
||||
bottle = _make_bottle()
|
||||
_PROVIDER.provision_git(bottle, plan)
|
||||
calls = self._git_config_calls(bottle)
|
||||
self.assertEqual(1, len(calls))
|
||||
self.assertIn("user.name", calls[0][0])
|
||||
self.assertIn("Bot", calls[0][0])
|
||||
|
||||
def test_email_only(self):
|
||||
plan = _plan(git_user={"email": "bot@example.com"})
|
||||
bottle = _make_bottle()
|
||||
_PROVIDER.provision_git(bottle, plan)
|
||||
calls = self._git_config_calls(bottle)
|
||||
self.assertEqual(1, len(calls))
|
||||
self.assertIn("user.email", calls[0][0])
|
||||
self.assertIn("bot@example.com", calls[0][0])
|
||||
|
||||
|
||||
class TestProxyHost(unittest.TestCase):
|
||||
"""_proxy_host returns the bridge gateway on Linux and the loopback
|
||||
alias on other platforms."""
|
||||
|
||||
def test_linux_returns_bundle_gateway(self):
|
||||
plan = _plan()
|
||||
with patch("bot_bottle.backend.smolmachines.launch.platform.system",
|
||||
return_value="Linux"):
|
||||
result = _launch._proxy_host(plan, "127.0.0.16")
|
||||
self.assertEqual(plan.bundle_gateway, result)
|
||||
|
||||
def test_non_linux_returns_loopback(self):
|
||||
plan = _plan()
|
||||
with patch("bot_bottle.backend.smolmachines.launch.platform.system",
|
||||
return_value="Darwin"):
|
||||
result = _launch._proxy_host(plan, "127.0.0.16")
|
||||
self.assertEqual("127.0.0.16", result)
|
||||
|
||||
|
||||
class TestDiscoverUrls(unittest.TestCase):
|
||||
"""_discover_urls stamps git-gate host + supervise URL into the plan."""
|
||||
|
||||
def test_git_gate_host_set_when_upstreams_present(self):
|
||||
plan = _plan()
|
||||
plan = replace(
|
||||
plan,
|
||||
git_gate_plan=replace(
|
||||
plan.git_gate_plan,
|
||||
upstreams=(GitGateUpstream(
|
||||
name="bot-bottle",
|
||||
upstream_url="ssh://git@host/repo.git",
|
||||
upstream_host="host",
|
||||
upstream_port="22",
|
||||
identity_file="/tmp/key",
|
||||
known_host_key="",
|
||||
),),
|
||||
),
|
||||
)
|
||||
with patch.object(_launch._bundle, "bundle_host_port", return_value="9420"):
|
||||
stamped = _launch._discover_urls(plan, "127.0.0.16")
|
||||
self.assertEqual("127.0.0.16:9420", stamped.agent_git_gate_host)
|
||||
|
||||
def test_supervise_url_set_when_supervise_present(self):
|
||||
plan = _plan(supervise=True)
|
||||
with patch.object(_launch._bundle, "bundle_host_port", return_value="55556"):
|
||||
stamped = _launch._discover_urls(plan, "127.0.0.16")
|
||||
self.assertEqual("http://127.0.0.16:55556/", stamped.agent_supervise_url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Unit: smolmachines pty_resize bridge (issue #82).
|
||||
|
||||
Locks down the parts of the wrapper we can test without spawning
|
||||
real children or signalling — argument parsing, the side-channel
|
||||
`smolvm machine exec` argv shape, and TTY-resolution fallback
|
||||
across stdin/stdout/stderr.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.smolmachines import pty_resize
|
||||
|
||||
|
||||
class TestPushSize(unittest.TestCase):
|
||||
def test_emits_for_loop_over_all_pts_devices(self):
|
||||
# The shell `for f in /dev/pts/*` handles multiple
|
||||
# interactive sessions in the same VM (rare but cheap).
|
||||
# Per-PTY `stty -F ... 2>/dev/null` swallows EBADF when a
|
||||
# session has already exited.
|
||||
with patch.object(pty_resize.subprocess, "run") as run:
|
||||
pty_resize._push_size("bot-bottle-m", 50, 200)
|
||||
argv = run.call_args.args[0]
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "exec", "--name",
|
||||
"bot-bottle-m", "--", "sh", "-c"],
|
||||
argv[:8],
|
||||
)
|
||||
# cols / rows land in the order stty wants them.
|
||||
self.assertIn("cols 200", argv[8])
|
||||
self.assertIn("rows 50", argv[8])
|
||||
self.assertIn("for f in /dev/pts/*", argv[8])
|
||||
|
||||
def test_side_channel_uses_devnull_stdin(self):
|
||||
# Load-bearing regression: under tmux, inheriting the
|
||||
# pane PTY as the side-channel's stdin makes smolvm crash
|
||||
# within ~100ms (concurrent smolvm processes sharing the
|
||||
# PTY's FG-PG / input plumbing). DEVNULL stdin sidesteps
|
||||
# the interaction.
|
||||
with patch.object(pty_resize.subprocess, "run") as run:
|
||||
pty_resize._push_size("bot-bottle-m", 24, 80)
|
||||
self.assertEqual(
|
||||
pty_resize.subprocess.DEVNULL,
|
||||
run.call_args.kwargs.get("stdin"),
|
||||
)
|
||||
|
||||
def test_swallows_subprocess_failures(self):
|
||||
# `check=False` + DEVNULL streams: a side-channel failure
|
||||
# mustn't break the operator's session.
|
||||
with patch.object(
|
||||
pty_resize.subprocess, "run",
|
||||
side_effect=OSError("boom"),
|
||||
):
|
||||
with self.assertRaises(OSError):
|
||||
pty_resize._push_size("m", 24, 80)
|
||||
# The wrapper-level `sync()` is what swallows; `_push_size`
|
||||
# itself raises so the test above documents that. The
|
||||
# signal-handler-side `sync` in main wraps in try/except
|
||||
# via the `if size is None: return` guard for the
|
||||
# no-TTY case (no separate try needed because subprocess
|
||||
# already has check=False; only fcntl.ioctl raising would
|
||||
# surface, and _read_winsize handles that).
|
||||
|
||||
|
||||
class TestReadWinsize(unittest.TestCase):
|
||||
def test_returns_none_when_no_tty(self):
|
||||
# Patch ioctl to always OSError — simulates the case where
|
||||
# none of stdin/stdout/stderr is a TTY (e.g., tests, piped
|
||||
# automation).
|
||||
with patch.object(
|
||||
pty_resize.fcntl, "ioctl",
|
||||
side_effect=OSError("ENOTTY"),
|
||||
):
|
||||
self.assertIsNone(pty_resize._read_winsize())
|
||||
|
||||
def test_returns_first_tty_size(self):
|
||||
# First fd that responds with a non-zero size wins —
|
||||
# matches the "different surfaces give different TTYs"
|
||||
# invariant noted in the module docstring.
|
||||
import struct
|
||||
|
||||
calls: list[int] = []
|
||||
|
||||
def fake_ioctl(fd, req, buf): # type: ignore
|
||||
calls.append(fd)
|
||||
if fd == 0:
|
||||
raise OSError("stdin not a tty")
|
||||
return struct.pack("hhhh", 42, 137, 0, 0)
|
||||
|
||||
with patch.object(pty_resize.fcntl, "ioctl", side_effect=fake_ioctl):
|
||||
self.assertEqual((42, 137), pty_resize._read_winsize())
|
||||
|
||||
def test_skips_zero_sizes(self):
|
||||
# A TTY that reports `0 0` (the smolvm-allocated PTY's
|
||||
# initial state, ironically) shouldn't be used as the
|
||||
# source of truth — keep probing fallback fds.
|
||||
import struct
|
||||
|
||||
responses = iter([
|
||||
struct.pack("hhhh", 0, 0, 0, 0), # stdin: zero
|
||||
struct.pack("hhhh", 24, 80, 0, 0), # stdout: real
|
||||
])
|
||||
|
||||
def fake_ioctl(fd, req, buf): # type: ignore
|
||||
return next(responses)
|
||||
|
||||
with patch.object(pty_resize.fcntl, "ioctl", side_effect=fake_ioctl):
|
||||
self.assertEqual((24, 80), pty_resize._read_winsize())
|
||||
|
||||
|
||||
class TestMainArgvParsing(unittest.TestCase):
|
||||
def test_missing_separator_returns_error_exit_code(self):
|
||||
# No `--` between machine name and inner argv.
|
||||
with patch.object(pty_resize.sys, "stderr", new=io.StringIO()) as err:
|
||||
rc = pty_resize.main(["bot-bottle-m", "smolvm", "machine"])
|
||||
self.assertEqual(2, rc)
|
||||
self.assertIn("usage:", err.getvalue())
|
||||
|
||||
def test_too_few_args_returns_error_exit_code(self):
|
||||
with patch.object(pty_resize.sys, "stderr", new=io.StringIO()):
|
||||
self.assertEqual(2, pty_resize.main([]))
|
||||
self.assertEqual(2, pty_resize.main(["m"]))
|
||||
self.assertEqual(2, pty_resize.main(["m", "--"]))
|
||||
|
||||
|
||||
class TestStartupSyncDeferred(unittest.TestCase):
|
||||
"""Regression: the initial sync MUST be deferred (timer), not
|
||||
called synchronously between Popen + wait. Calling it
|
||||
immediately races libkrun's per-exec OCI config write during
|
||||
the main exec's bringup and crashes the child (rc=137 or
|
||||
'parse error: trailing garbage')."""
|
||||
|
||||
def test_main_schedules_timer_does_not_call_sync_synchronously(self):
|
||||
# Fake Popen + wait so main returns immediately. Patch
|
||||
# Timer to record args without spawning a real thread.
|
||||
# _push_size patched so any rogue synchronous call would
|
||||
# be observable.
|
||||
fake_proc = unittest.mock.MagicMock()
|
||||
fake_proc.wait.return_value = 0
|
||||
with patch.object(
|
||||
pty_resize.subprocess, "Popen", return_value=fake_proc,
|
||||
), patch.object(
|
||||
pty_resize.threading, "Timer",
|
||||
) as timer_cls, patch.object(
|
||||
pty_resize, "_push_size",
|
||||
) as push:
|
||||
rc = pty_resize.main(["machine-name", "--", "echo", "hi"])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
# Timer scheduled with the documented delay constant.
|
||||
timer_cls.assert_called_once()
|
||||
delay, callback = timer_cls.call_args.args # type: ignore
|
||||
self.assertEqual(pty_resize._STARTUP_SYNC_DELAY_SEC, delay)
|
||||
# _push_size never called synchronously — the only path to
|
||||
# it is via the (mocked) timer's callback firing.
|
||||
push.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,226 +0,0 @@
|
||||
"""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,
|
||||
bundle_container_name,
|
||||
bundle_network_name,
|
||||
create_bundle_network,
|
||||
ensure_bundle_image,
|
||||
remove_bundle_network,
|
||||
start_bundle,
|
||||
stop_bundle,
|
||||
)
|
||||
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,284 +0,0 @@
|
||||
"""Unit: smolvm subprocess wrapper (PRD 0023 chunk 2b).
|
||||
|
||||
The wrapper is one thin function per smolvm CLI subcommand. Tests
|
||||
mock `subprocess.run` and assert on the constructed argv +
|
||||
SmolvmError raising on non-zero. The actual smolvm binary's
|
||||
behavior is exercised in chunk 2d's integration smoke test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.smolmachines import smolvm as smolvm_mod
|
||||
from bot_bottle.backend.smolmachines.smolvm import (
|
||||
SmolvmError,
|
||||
SmolvmRunResult,
|
||||
is_available,
|
||||
machine_cp,
|
||||
machine_create,
|
||||
machine_delete,
|
||||
machine_exec,
|
||||
machine_start,
|
||||
machine_stop,
|
||||
pack_create,
|
||||
pack_create_from_vm,
|
||||
wait_exec_ready,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class TestArgvShapes(unittest.TestCase):
|
||||
"""The CLI mixes `--name NAME` and positional-NAME styles
|
||||
across subcommands. The wrapper hides that inconsistency
|
||||
behind a uniform `name=` kwarg; lock down which form lands
|
||||
in each argv."""
|
||||
|
||||
def _patch_run(self):
|
||||
return patch(
|
||||
"bot_bottle.backend.smolmachines.smolvm.subprocess.run",
|
||||
return_value=_ok(),
|
||||
)
|
||||
|
||||
def test_pack_create_argv(self):
|
||||
with self._patch_run() as m:
|
||||
pack_create("bot-bottle-claude:latest", Path("/tmp/agent.smolmachine"))
|
||||
argv = m.call_args.args[0]
|
||||
self.assertEqual(
|
||||
["smolvm", "pack", "create",
|
||||
"--image", "bot-bottle-claude:latest",
|
||||
"-o", "/tmp/agent.smolmachine"],
|
||||
argv,
|
||||
)
|
||||
|
||||
def test_pack_create_from_vm_argv(self):
|
||||
with self._patch_run() as m:
|
||||
pack_create_from_vm("bot-bottle-dev-abc12", Path("/tmp/committed"))
|
||||
argv = m.call_args.args[0]
|
||||
self.assertEqual(
|
||||
["smolvm", "pack", "create",
|
||||
"--from-vm", "bot-bottle-dev-abc12",
|
||||
"-o", "/tmp/committed"],
|
||||
argv,
|
||||
)
|
||||
|
||||
def test_machine_create_minimal(self):
|
||||
with self._patch_run() as m:
|
||||
machine_create("agent-xyz")
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "create", "--name", "agent-xyz"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_create_with_from_and_allow_cidr_and_env(self):
|
||||
with self._patch_run() as m:
|
||||
machine_create(
|
||||
"agent-xyz",
|
||||
from_path=Path("/stage/agent.smolmachine"),
|
||||
allow_cidrs=["192.168.50.2/32"],
|
||||
env={"HTTPS_PROXY": "http://192.168.50.2:8888"},
|
||||
)
|
||||
argv = m.call_args.args[0]
|
||||
# --from + --allow-cidr + -e are all flags, name is positional.
|
||||
self.assertEqual("smolvm", argv[0])
|
||||
self.assertIn("--from", argv)
|
||||
self.assertIn("/stage/agent.smolmachine", argv)
|
||||
# `--net` is explicit because smolvm 0.8.0's implied-net
|
||||
# from --allow-cidr doesn't fire when --from is set.
|
||||
self.assertIn("--net", argv)
|
||||
self.assertIn("--allow-cidr", argv)
|
||||
self.assertIn("192.168.50.2/32", argv)
|
||||
self.assertIn("-e", argv)
|
||||
self.assertIn("HTTPS_PROXY=http://192.168.50.2:8888", argv)
|
||||
self.assertIn("--name", argv)
|
||||
self.assertIn("agent-xyz", argv)
|
||||
|
||||
def test_machine_create_omits_net_when_no_allow_cidrs(self):
|
||||
with self._patch_run() as m:
|
||||
machine_create("agent-xyz", from_path=Path("/x.smolmachine"))
|
||||
self.assertNotIn("--net", m.call_args.args[0])
|
||||
|
||||
def test_machine_start_uses_dash_name(self):
|
||||
# `start` is the --name flag form, NOT positional.
|
||||
with self._patch_run() as m:
|
||||
machine_start("agent-xyz")
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "start", "--name", "agent-xyz"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_stop_uses_dash_name(self):
|
||||
with self._patch_run() as m:
|
||||
machine_stop("agent-xyz")
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "stop", "--name", "agent-xyz"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_delete_uses_name_flag_and_force(self):
|
||||
# delete uses --name flag; -f required so no interactive
|
||||
# confirmation blocks teardown.
|
||||
with self._patch_run() as m:
|
||||
machine_delete("agent-xyz")
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "delete", "--name", "agent-xyz", "-f"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_exec_argv_with_separator(self):
|
||||
# `--` separator before the command — smolvm's flag parser
|
||||
# would otherwise grab argv items that look like flags.
|
||||
with self._patch_run() as m:
|
||||
machine_exec("agent-xyz", ["echo", "hello"])
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "exec", "--name", "agent-xyz",
|
||||
"--", "echo", "hello"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_exec_env_workdir_timeout(self):
|
||||
with self._patch_run() as m:
|
||||
machine_exec(
|
||||
"agent-xyz",
|
||||
["ls"],
|
||||
env={"FOO": "bar", "BAZ": "qux"},
|
||||
workdir="/app",
|
||||
timeout="30s",
|
||||
)
|
||||
argv = m.call_args.args[0]
|
||||
self.assertIn("-w", argv); self.assertIn("/app", argv)
|
||||
self.assertIn("--timeout", argv); self.assertIn("30s", argv)
|
||||
# Both env vars present as -e K=V pairs.
|
||||
for pair in ("FOO=bar", "BAZ=qux"):
|
||||
self.assertIn(pair, argv)
|
||||
|
||||
def test_machine_cp_positional_argv(self):
|
||||
with self._patch_run() as m:
|
||||
machine_cp("/host/file", "agent-xyz:/dest/file")
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "cp",
|
||||
"/host/file", "agent-xyz:/dest/file"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_cp_empty_is_noop(self):
|
||||
# Guard against an upstream caller passing an unset path —
|
||||
# cp with an empty string is meaningless and would just
|
||||
# confuse smolvm's error message.
|
||||
with self._patch_run() as m:
|
||||
machine_cp("", "agent-xyz:/dest")
|
||||
machine_cp("/host", "")
|
||||
self.assertEqual(0, m.call_count)
|
||||
|
||||
|
||||
class TestErrorPath(unittest.TestCase):
|
||||
"""`check=True` paths raise SmolvmError on non-zero; `exec` is
|
||||
the one path that returns a result regardless."""
|
||||
|
||||
def test_create_failure_raises(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.smolvm.subprocess.run",
|
||||
return_value=_fail("no such image"),
|
||||
):
|
||||
with self.assertRaises(SmolvmError) as cm:
|
||||
machine_create("agent-xyz")
|
||||
self.assertEqual(1, cm.exception.returncode)
|
||||
self.assertIn("no such image", str(cm.exception))
|
||||
|
||||
def test_pack_create_failure_raises(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.smolvm.subprocess.run",
|
||||
return_value=_fail("pack failed"),
|
||||
):
|
||||
with self.assertRaises(SmolvmError):
|
||||
pack_create("missing:tag", Path("/tmp/out"))
|
||||
|
||||
def test_pack_create_from_vm_failure_raises(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.smolvm.subprocess.run",
|
||||
return_value=_fail("pack failed"),
|
||||
):
|
||||
with self.assertRaises(SmolvmError):
|
||||
pack_create_from_vm("bot-bottle-dev-abc12", Path("/tmp/out"))
|
||||
|
||||
def test_exec_failure_returns_result(self):
|
||||
# The in-VM command's exit code is what Bottle.exec sees;
|
||||
# `false` exiting non-zero is not a smolvm failure.
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.smolvm.subprocess.run",
|
||||
return_value=subprocess.CompletedProcess(
|
||||
args=[], returncode=42, stdout="", stderr="nope",
|
||||
),
|
||||
):
|
||||
r = machine_exec("agent-xyz", ["sh", "-c", "exit 42"])
|
||||
self.assertEqual(SmolvmRunResult(42, "", "nope"), r)
|
||||
|
||||
|
||||
class TestWaitExecReady(unittest.TestCase):
|
||||
"""wait_exec_ready polls machine_exec(name, ["true"]) until it
|
||||
returns 0, then exits. On timeout it calls die()."""
|
||||
|
||||
def test_returns_immediately_when_exec_succeeds_first_try(self):
|
||||
with patch.object(smolvm_mod, "machine_exec",
|
||||
return_value=SmolvmRunResult(0, "", "")) as m:
|
||||
wait_exec_ready("vm-x")
|
||||
m.assert_called_once_with("vm-x", ["true"])
|
||||
|
||||
def test_retries_on_nonzero_and_returns_on_success(self):
|
||||
results = [
|
||||
SmolvmRunResult(1, "", "not ready"),
|
||||
SmolvmRunResult(1, "", "not ready"),
|
||||
SmolvmRunResult(0, "", ""),
|
||||
]
|
||||
with patch.object(smolvm_mod, "machine_exec",
|
||||
side_effect=results) as m, \
|
||||
patch.object(smolvm_mod.time, "sleep"):
|
||||
wait_exec_ready("vm-x")
|
||||
self.assertEqual(3, m.call_count)
|
||||
|
||||
def test_raises_smolvm_error_on_timeout(self):
|
||||
# machine_exec always returns non-zero; monotonic advances past
|
||||
# the deadline after the first sleep so the loop exits.
|
||||
ticks = [0.0, 0.0, 10.0] # third call puts us past deadline
|
||||
with patch.object(smolvm_mod, "machine_exec",
|
||||
return_value=SmolvmRunResult(1, "", "")), \
|
||||
patch.object(smolvm_mod.time, "monotonic",
|
||||
side_effect=ticks), \
|
||||
patch.object(smolvm_mod.time, "sleep"):
|
||||
with self.assertRaises(SmolvmError) as cm:
|
||||
wait_exec_ready("vm-x", timeout=5.0)
|
||||
self.assertIn("vm-x", str(cm.exception))
|
||||
self.assertIn("not ready", str(cm.exception))
|
||||
|
||||
|
||||
class TestIsAvailable(unittest.TestCase):
|
||||
def test_true_when_on_path(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.smolvm.shutil.which",
|
||||
return_value="/usr/local/bin/smolvm",
|
||||
):
|
||||
self.assertTrue(is_available())
|
||||
|
||||
def test_false_when_missing(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.smolvm.shutil.which",
|
||||
return_value=None,
|
||||
):
|
||||
self.assertFalse(is_available())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,155 +0,0 @@
|
||||
"""Unit: smolmachines backend util helpers (PRD 0023)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.smolmachines.util import (
|
||||
smolmachines_bundle_subnet,
|
||||
smolmachines_preflight,
|
||||
)
|
||||
|
||||
|
||||
class TestBundleSubnet(unittest.TestCase):
|
||||
def test_returns_subnet_gateway_and_bundle_ip(self):
|
||||
subnet, gateway, bundle_ip = smolmachines_bundle_subnet("demo-abc12")
|
||||
self.assertTrue(subnet.startswith("192.168."))
|
||||
self.assertTrue(subnet.endswith(".0/24"))
|
||||
# Gateway at .1, bundle at .2 — fixed convention.
|
||||
self.assertTrue(gateway.endswith(".1"))
|
||||
self.assertTrue(bundle_ip.endswith(".2"))
|
||||
# All three share the same third octet.
|
||||
third = subnet.split(".")[2]
|
||||
self.assertEqual(third, gateway.split(".")[2])
|
||||
self.assertEqual(third, bundle_ip.split(".")[2])
|
||||
|
||||
def test_stable_for_same_slug(self):
|
||||
# Recoverability: `cli.py resume` reuses the slug and
|
||||
# expects to find the same per-bottle subnet (a fresh
|
||||
# docker bridge would mean a different IP, and smolvm's
|
||||
# allow_cidrs would no longer match).
|
||||
a = smolmachines_bundle_subnet("demo-abc12")
|
||||
b = smolmachines_bundle_subnet("demo-abc12")
|
||||
self.assertEqual(a, b)
|
||||
|
||||
def test_different_slugs_likely_differ(self):
|
||||
# Not a guarantee — it's hash-mod-254, collisions exist —
|
||||
# but two arbitrary slugs shouldn't share a subnet in the
|
||||
# typical case.
|
||||
seen = {
|
||||
smolmachines_bundle_subnet(s)[0]
|
||||
for s in ("a", "b", "c", "d", "e", "alpha", "beta", "gamma")
|
||||
}
|
||||
self.assertGreater(len(seen), 1)
|
||||
|
||||
def test_skips_docker_default_octet(self):
|
||||
# docker's default bridge sits at 172.17.x.x; operators
|
||||
# often also see 192.168.17.x from VPN clients on macOS.
|
||||
# The util skips octet 17 → 18 so the smolmachines subnet
|
||||
# doesn't collide with that historical pain point.
|
||||
for slug in (f"slug-{i}" for i in range(500)):
|
||||
subnet, _, _ = smolmachines_bundle_subnet(slug)
|
||||
self.assertNotEqual("192.168.17.0/24", subnet,
|
||||
f"slug {slug!r} landed on the skipped octet")
|
||||
|
||||
|
||||
class TestPreflight(unittest.TestCase):
|
||||
def test_smolvm_present_returns_none(self):
|
||||
# Pin macOS so the Linux KVM gate doesn't fire on a CI runner
|
||||
# (ubuntu, no /dev/kvm) — this test isolates the PATH check.
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.util.shutil.which",
|
||||
return_value="/usr/local/bin/smolvm",
|
||||
), patch(
|
||||
"bot_bottle.backend.smolmachines.util.platform.system",
|
||||
return_value="Darwin",
|
||||
):
|
||||
self.assertIsNone(smolmachines_preflight())
|
||||
|
||||
def test_missing_smolvm_dies(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.util.shutil.which",
|
||||
return_value=None,
|
||||
):
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
smolmachines_preflight()
|
||||
self.assertNotEqual(0, cm.exception.code)
|
||||
|
||||
def test_install_pointer_in_error(self):
|
||||
import io
|
||||
import sys
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.util.shutil.which",
|
||||
return_value=None,
|
||||
):
|
||||
captured = io.StringIO()
|
||||
with patch.object(sys, "stderr", captured):
|
||||
with self.assertRaises(SystemExit):
|
||||
smolmachines_preflight()
|
||||
msg = captured.getvalue()
|
||||
self.assertIn("smolvm", msg)
|
||||
self.assertIn("smolmachines.com/install.sh", msg)
|
||||
self.assertIn("BOT_BOTTLE_BACKEND=docker", msg)
|
||||
|
||||
|
||||
class TestKvmPreflight(unittest.TestCase):
|
||||
"""Linux-only KVM gate: smolvm needs /dev/kvm present and
|
||||
accessible. macOS skips this entirely (Hypervisor.framework)."""
|
||||
|
||||
def _run(self, *, system: str, exists: bool, access: bool) -> None:
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.util.shutil.which",
|
||||
return_value="/usr/bin/smolvm",
|
||||
), patch(
|
||||
"bot_bottle.backend.smolmachines.util.platform.system",
|
||||
return_value=system,
|
||||
), patch(
|
||||
"bot_bottle.backend.smolmachines.util.os.path.exists",
|
||||
return_value=exists,
|
||||
), patch(
|
||||
"bot_bottle.backend.smolmachines.util.os.access",
|
||||
return_value=access,
|
||||
):
|
||||
return smolmachines_preflight()
|
||||
|
||||
def test_macos_skips_kvm_check(self):
|
||||
# Even with /dev/kvm absent, macOS must not run the gate.
|
||||
self.assertIsNone(self._run(system="Darwin", exists=False, access=False))
|
||||
|
||||
def test_linux_ok_returns_none(self):
|
||||
self.assertIsNone(self._run(system="Linux", exists=True, access=True))
|
||||
|
||||
def test_linux_missing_device_dies(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
self._run(system="Linux", exists=False, access=False)
|
||||
|
||||
def test_linux_no_access_dies(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
self._run(system="Linux", exists=True, access=False)
|
||||
|
||||
def test_linux_missing_device_message(self):
|
||||
import io
|
||||
import sys
|
||||
captured = io.StringIO()
|
||||
with patch.object(sys, "stderr", captured):
|
||||
with self.assertRaises(SystemExit):
|
||||
self._run(system="Linux", exists=False, access=False)
|
||||
msg = captured.getvalue()
|
||||
self.assertIn("/dev/kvm", msg)
|
||||
self.assertIn("kvm-intel", msg)
|
||||
|
||||
def test_linux_no_access_message(self):
|
||||
import io
|
||||
import sys
|
||||
captured = io.StringIO()
|
||||
with patch.object(sys, "stderr", captured):
|
||||
with self.assertRaises(SystemExit):
|
||||
self._run(system="Linux", exists=True, access=False)
|
||||
msg = captured.getvalue()
|
||||
self.assertIn("kvm", msg)
|
||||
self.assertIn("group", msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user