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>
165 lines
6.0 KiB
Python
165 lines
6.0 KiB
Python
"""Per-bottle sidecar bundle bringup for the smolmachines backend
|
|
(PRD 0023).
|
|
|
|
Two docker resources per bottle live here:
|
|
|
|
- **A dedicated bridge network**, subnet derived from the slug.
|
|
The bundle container gets a pinned IP at `<subnet>.2` so the
|
|
smolvm guest's TSI allowlist (`<bundle-ip>/32`) has a stable
|
|
target. Without pinning, we'd have to inspect the container's
|
|
assigned IP after start and feed it back into the Smolfile
|
|
— a race we can sidestep with `--ip`.
|
|
|
|
- **The bundle container itself**, running the PRD 0024 bundle
|
|
image (`claude-bottle-sidecars:latest` by default). Same
|
|
image, same daemons, same daemon-private env / bind-mounts
|
|
as the docker backend.
|
|
|
|
This module ships the lifecycle primitives only — create
|
|
network, start bundle, stop bundle, remove network — wrapped
|
|
around `subprocess.run(["docker", ...])`. Wiring them into the
|
|
launch flow + populating the `BundleLaunchSpec` from the inner
|
|
Plans (PipelockProxyPlan, EgressPlan, …) lands in chunk 2d."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Sequence
|
|
|
|
from ...log import die, warn
|
|
from ..docker.sidecar_bundle import SIDECAR_BUNDLE_IMAGE
|
|
|
|
|
|
def bundle_network_name(slug: str) -> str:
|
|
"""`claude-bottle-bundle-<slug>` — distinct from the docker
|
|
backend's `claude-bottle-net-<slug>` so a smolmachines bottle
|
|
and a docker bottle for the same agent don't collide on
|
|
network name."""
|
|
return f"claude-bottle-bundle-{slug}"
|
|
|
|
|
|
def bundle_container_name(slug: str) -> str:
|
|
"""`claude-bottle-sidecars-<slug>` — same name shape the docker
|
|
backend uses for the bundle (PRD 0024 chunk 5). The dashboard's
|
|
prefix-based discovery covers both backends with one filter."""
|
|
return f"claude-bottle-sidecars-{slug}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BundleLaunchSpec:
|
|
"""Everything `start_bundle` needs to bring up one bundle
|
|
container. Populated by chunk-2d's launch flow from the inner
|
|
Plans the prepare step already produces."""
|
|
|
|
slug: str
|
|
network_name: str
|
|
subnet: str
|
|
gateway: str
|
|
bundle_ip: str
|
|
image: str = SIDECAR_BUNDLE_IMAGE
|
|
# Daemon subset CSV for CLAUDE_BOTTLE_SIDECAR_DAEMONS. The
|
|
# supervisor inside the bundle reads it to skip
|
|
# bottle-irrelevant daemons (e.g. supervise=False bottles).
|
|
daemons_csv: str = "egress,pipelock"
|
|
# Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name
|
|
# form inherits the value from the docker-run subprocess env,
|
|
# matching the docker backend's compose-up secret-forwarding
|
|
# pattern).
|
|
environment: Sequence[str] = field(default_factory=tuple)
|
|
# (host_path, container_path, read_only) bind mounts.
|
|
volumes: Sequence[tuple[str, str, bool]] = field(default_factory=tuple)
|
|
|
|
|
|
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
|
|
"""`docker network create` with an explicit subnet + gateway
|
|
so the bundle's `--ip` lands on the address the Smolfile's
|
|
TSI allowlist points at. Idempotent on the caller's side —
|
|
`start_bundle` catches the "network exists" error and treats
|
|
it as success (chunk-2d teardown is paired with each create).
|
|
"""
|
|
result = subprocess.run(
|
|
["docker", "network", "create",
|
|
"--subnet", subnet, "--gateway", gateway,
|
|
network_name],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
# Already-exists is fine on a resume path; everything else
|
|
# is fatal — the bundle won't have an addressable network.
|
|
if "already exists" in (result.stderr or "").lower():
|
|
return
|
|
die(
|
|
f"docker network create {network_name} failed: "
|
|
f"{(result.stderr or '').strip()}"
|
|
)
|
|
|
|
|
|
def remove_bundle_network(network_name: str) -> None:
|
|
"""Idempotent: a missing network returns success."""
|
|
result = subprocess.run(
|
|
["docker", "network", "rm", network_name],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if result.returncode == 0:
|
|
return
|
|
if "no such network" in (result.stderr or "").lower():
|
|
return
|
|
# Network with attached containers is the common non-fatal
|
|
# case during a partial teardown — warn but don't die.
|
|
warn(
|
|
f"docker network rm {network_name} failed: "
|
|
f"{(result.stderr or '').strip()}"
|
|
)
|
|
|
|
|
|
def start_bundle(spec: BundleLaunchSpec, *,
|
|
env: dict[str, str] | None = None) -> None:
|
|
"""Bring the bundle container up on the per-bottle bridge with
|
|
the pinned IP. Argv is built deterministically from `spec`;
|
|
`env` is the host subprocess env (forwarded values for any
|
|
bare-name entries in `spec.environment`)."""
|
|
container = bundle_container_name(spec.slug)
|
|
argv = [
|
|
"docker", "run",
|
|
"--name", container,
|
|
"--detach",
|
|
"--rm",
|
|
"--network", spec.network_name,
|
|
"--ip", spec.bundle_ip,
|
|
"-e", f"CLAUDE_BOTTLE_SIDECAR_DAEMONS={spec.daemons_csv}",
|
|
]
|
|
for entry in spec.environment:
|
|
argv += ["-e", entry]
|
|
for host_path, container_path, read_only in spec.volumes:
|
|
suffix = ":ro" if read_only else ""
|
|
argv += ["-v", f"{host_path}:{container_path}{suffix}"]
|
|
argv.append(spec.image)
|
|
result = subprocess.run(
|
|
argv, capture_output=True, text=True,
|
|
env=dict(env) if env is not None else None, check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
die(
|
|
f"docker run for bundle {container} failed: "
|
|
f"{(result.stderr or '').strip()}"
|
|
)
|
|
|
|
|
|
def stop_bundle(slug: str) -> None:
|
|
"""Idempotent: a missing container returns success."""
|
|
container = bundle_container_name(slug)
|
|
result = subprocess.run(
|
|
["docker", "rm", "-f", container],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if result.returncode == 0:
|
|
return
|
|
if "no such container" in (result.stderr or "").lower():
|
|
return
|
|
warn(
|
|
f"docker rm -f {container} failed: "
|
|
f"{(result.stderr or '').strip()}"
|
|
)
|