feat(orchestrator): slice 4 — consolidated per-host sidecar (#352)

The core consolidation win: one persistent sidecar per host, shared by
every bottle, instead of a sidecar bundle per bottle. Safe to share
because the attribution invariant (source IP + identity token) lets the
sidecar map each request to the right bottle.

  * orchestrator/sidecar.py — a backend-neutral `Sidecar` lifecycle
    contract (mirrors LaunchBroker) + a `DockerSidecar` impl. The defining
    behaviour is idempotent singleton: `ensure_running` starts the instance
    if absent and is a no-op if it's already up, so N launches never spawn
    N sidecars; `stop` is idempotent.
  * orchestrator/dockerutil.py — a shared `run_docker` helper; DockerBroker
    now uses it too (DRY with slice 3).
  * service.py — the Orchestrator holds an optional `Sidecar`, exposes
    `ensure_sidecar()` + `sidecar_status()`.
  * control_plane.py — `GET /sidecar` reports it; __main__ gains
    `--sidecar-image` and ensures the single sidecar on startup.

Tests: unit (docker mocked) — is_running, ensure idempotent (no-op when up,
starts when absent), failure raises, stop idempotent; Orchestrator sidecar
wiring/status; control-plane /sidecar; integration (gated) — ensure is a
real idempotent singleton (one container after two ensures), stop removes.
Full suite green (only pre-existing /bin/sleep errors); integration
verified locally against real docker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-13 15:28:29 -04:00
parent b70f3228ca
commit bc52c3525c
11 changed files with 331 additions and 9 deletions
+38
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from bot_bottle.orchestrator.broker import LaunchBroker, LaunchRequest, StubBroker
from bot_bottle.orchestrator.registry import RegistryStore
from bot_bottle.orchestrator.service import Orchestrator
from bot_bottle.orchestrator.sidecar import Sidecar
class _FailingBroker(LaunchBroker):
@@ -23,6 +24,25 @@ class _FailingBroker(LaunchBroker):
pass
class _FakeSidecar(Sidecar):
"""In-memory sidecar for wiring tests."""
def __init__(self) -> None:
self.name = "fake-sidecar"
self.ensured = 0
self._running = False
def ensure_running(self) -> None:
self.ensured += 1
self._running = True
def is_running(self) -> bool:
return self._running
def stop(self) -> None:
self._running = False
class TestOrchestrator(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
@@ -68,6 +88,24 @@ class TestOrchestrator(unittest.TestCase):
orch.launch_bottle("10.243.0.9")
self.assertEqual([], self.store.all()) # no orphan
def test_sidecar_unconfigured_by_default(self) -> None:
self.assertEqual({"configured": False}, self.orch.sidecar_status())
self.orch.ensure_sidecar() # no-op, must not raise
def test_sidecar_wired_and_ensured(self) -> None:
sc = _FakeSidecar()
orch = Orchestrator(self.store, self.broker, self.secret, sidecar=sc)
self.assertEqual(
{"configured": True, "name": "fake-sidecar", "running": False},
orch.sidecar_status(),
)
orch.ensure_sidecar()
self.assertEqual(1, sc.ensured)
self.assertEqual(
{"configured": True, "name": "fake-sidecar", "running": True},
orch.sidecar_status(),
)
if __name__ == "__main__":
unittest.main()