feat(bottles): implement bottle factory abstraction per PRD 0003
test / run tests/run_tests.py (pull_request) Successful in 16s

Introduce claude_bottle/bottles/ with a Bottle Protocol and a
get_bottle_factory() that dispatches on CLAUDE_BOTTLE_PLATFORM
(default "docker"). Move every Docker-specific subprocess.run call
from cli/start.py, plus the orchestration of build, networks, the
pipelock sidecar, container launch, and per-container provisioning
(prompt, skills, ssh, .git), into create_docker_bottle.

Drop bottles[].runtime from the manifest schema. Auto-detect whether
gVisor is registered with the daemon and pass --runtime=runsc when it
is; the preflight shows the resolved runtime so the choice is visible.
Manifests still carrying 'runtime' get a clear error pointing at the
auto-detect behavior, rather than silent ignore.

Out of scope: cli/cleanup.py and cli/list.py still call docker
directly. They enumerate active bottles across the host, which is a
separate concern from "create a bottle" and is left for a follow-up
that introduces a list_active/cleanup primitive on the factory.
This commit is contained in:
2026-05-10 22:15:05 -04:00
parent d5c056f36e
commit d75cc9325f
8 changed files with 468 additions and 240 deletions
+37 -21
View File
@@ -1,16 +1,21 @@
"""Unit: bottle runtime — Manifest.from_json_obj defaults runtime to runc,
accepts runsc, and rejects unknown values, non-strings, and empty strings."""
"""Unit: bottle 'runtime' field is no longer supported (PRD 0003).
gVisor is now auto-detected by the Docker factory. A manifest carrying
the legacy 'runtime' field must fail loudly with a message pointing the
user at the auto-detect behavior, rather than silently ignoring."""
import io
import sys
import unittest
from claude_bottle.log import Die
from claude_bottle.manifest import Manifest
from claude_bottle.manifest import Bottle, Manifest
_ABSENT = object()
def _bottle(runtime_value: object) -> dict:
def _manifest(runtime_value: object) -> dict:
"""Build a minimal manifest JSON shape with one bottle whose runtime
field is set (or absent if `runtime_value is _ABSENT`)."""
bottle: dict = {}
@@ -22,30 +27,41 @@ def _bottle(runtime_value: object) -> dict:
}
class TestManifestBottleRuntime(unittest.TestCase):
def test_default_runc_when_absent(self):
m = Manifest.from_json_obj(_bottle(_ABSENT))
self.assertEqual("runc", m.bottles["dev"].runtime)
class TestManifestRuntimeRemoved(unittest.TestCase):
def test_loads_when_runtime_absent(self):
m = Manifest.from_json_obj(_manifest(_ABSENT))
self.assertIn("dev", m.bottles)
def test_explicit_runc(self):
m = Manifest.from_json_obj(_bottle("runc"))
self.assertEqual("runc", m.bottles["dev"].runtime)
def test_bottle_dataclass_has_no_runtime_attribute(self):
"""Structural check: the field has been removed from the dataclass."""
b = Bottle()
self.assertFalse(hasattr(b, "runtime"))
def test_explicit_runsc(self):
m = Manifest.from_json_obj(_bottle("runsc"))
self.assertEqual("runsc", m.bottles["dev"].runtime)
def test_rejects_runsc_value_with_helpful_message(self):
captured = io.StringIO()
old_stderr = sys.stderr
sys.stderr = captured
try:
with self.assertRaises(Die):
Manifest.from_json_obj(_manifest("runsc"))
finally:
sys.stderr = old_stderr
msg = captured.getvalue()
self.assertIn("'runtime'", msg, "error names the field")
self.assertIn("auto-detect", msg, "error points at the new behavior")
def test_rejects_unknown_runtime(self):
def test_rejects_runc_value(self):
with self.assertRaises(Die):
Manifest.from_json_obj(_bottle("kata-runtime"))
Manifest.from_json_obj(_manifest("runc"))
def test_rejects_unknown_value(self):
with self.assertRaises(Die):
Manifest.from_json_obj(_manifest("kata-runtime"))
def test_rejects_non_string(self):
"""Any presence of the field is an error; type is not consulted."""
with self.assertRaises(Die):
Manifest.from_json_obj(_bottle(42))
def test_rejects_empty_string(self):
with self.assertRaises(Die):
Manifest.from_json_obj(_bottle(""))
Manifest.from_json_obj(_manifest(42))
if __name__ == "__main__":