495be7f9c0
claude_bottle/backend/smolmachines/sidecar_bundle.py — primitives for the per-bottle bridge + bundle container with pinned IP: - bundle_network_name(slug) / bundle_container_name(slug) - create_bundle_network(name, subnet, gateway) - remove_bundle_network(name) - start_bundle(BundleLaunchSpec, env=) - stop_bundle(slug) `BundleLaunchSpec` carries the launch-time fields (network + subnet + gateway + bundle_ip + daemons_csv + environment + volumes). Wiring it up from the inner Plans (PipelockProxyPlan, EgressPlan, GitGatePlan, SupervisePlan) is chunk 2d's job; this module is the docker-argv surface only. Pinning the bundle IP via `docker run --ip <bundle-ip>` is what makes smolvm's TSI allowlist (`<bundle-ip>/32`) safe to compute at prepare time — without pinning, we'd have to inspect the assigned IP after start and feed it back into the Smolfile. Idempotent semantics where it matters: `create_bundle_network` treats "already exists" as success, `remove_bundle_network` + `stop_bundle` treat "no such ..." as success. Other failures die / warn depending on whether the launch flow can recover. Tests: - 15 unit cases (mocked subprocess.run): argv shape for create / remove / start / stop, idempotent paths, host-env inheritance to docker run subprocess. - 1 integration case (real docker daemon, gated on docker available + not GITEA_ACTIONS): end-to-end bringup of an empty-daemons bundle on a 192.168.211.0/24 bridge, confirms the container lands at the pinned IP. Skipped if the claude-bottle-sidecars:latest image isn't built (operator hasn't run a docker bottle yet). 546 unit tests passing. Real-docker bundle bringup green locally. Launch wiring + provisioning + PRD 0022 acceptance probes land in chunk 2d. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
"""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 json
|
|
import os
|
|
import subprocess
|
|
import time
|
|
import unittest
|
|
|
|
from claude_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 (`claude-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", "claude-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(
|
|
"claude-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,
|
|
# Only run the pipelock daemon for this smoke — it's
|
|
# the lightest of the four and doesn't need bind
|
|
# mounts beyond what we'd skip without
|
|
# CLAUDE_BOTTLE_SIDECAR_DAEMONS. (The init
|
|
# supervisor will exit if pipelock fails to find its
|
|
# yaml — that's expected here; we just need the
|
|
# container to land on the network at the right IP.)
|
|
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()
|