"""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 `.2` so the smolvm guest's TSI allowlist (`/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 (`bot-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 (EgressPlan, …) lands in chunk 2d.""" from __future__ import annotations import os import socket import subprocess from dataclasses import dataclass, field from pathlib import Path from typing import Sequence from ...log import die, warn from ..docker import util as docker_mod from ..docker.sidecar_bundle import ( SIDECAR_BUNDLE_DOCKERFILE, SIDECAR_BUNDLE_IMAGE, ) from . import smolvm as _smolvm _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) def bundle_network_name(slug: str) -> str: """`bot-bottle-bundle-` — distinct from the docker backend's `bot-bottle-net-` so a smolmachines bottle and a docker bottle for the same agent don't collide on network name.""" return f"bot-bottle-bundle-{slug}" def bundle_container_name(slug: str) -> str: """`bot-bottle-sidecars-` — 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"bot-bottle-sidecars-{slug}" def bundle_machine_name(slug: str) -> str: """Sidecar smolVM machine name.""" return bundle_container_name(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 BOT_BOTTLE_SIDECAR_DAEMONS. The # supervisor inside the bundle reads it to skip # bottle-irrelevant daemons (e.g. supervise=False bottles). daemons_csv: str = "egress" # 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) # Container ports to publish on `publish_host_ip`, random # host-side port per entry. The smolvm guest's TSI talks via # macOS networking, so docker container IPs (192.168.x.x in # the daemon's bridge) aren't directly reachable from the # guest — host-loopback port-forwards are. Egress's port # is bundle-internal and never published. ports_to_publish: Sequence[int] = field(default_factory=tuple) # Loopback IP to bind published ports against. Per-bottle # loopback aliases (`127.0.0.16` etc., added via sudo # ifconfig lo0 alias) narrow the TSI allowlist so a bottle # can't reach other bottles' (or other host services') ports # via 127.0.0.1. publish_host_ip: str = "127.0.0.1" @dataclass(frozen=True) class BundleVmLaunch: raw_ports: dict[int, int] def ensure_bundle_image(image: str = SIDECAR_BUNDLE_IMAGE) -> None: """Build the sidecar bundle image before `docker run`. The Docker backend gets this for free from compose's `build:` stanza. smolmachines starts the bundle with plain `docker run`, so without an explicit build a first launch tries to pull the local-only `bot-bottle-sidecars:latest` tag from a registry. """ docker_mod.build_image( image, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE, ) def allocate_raw_host_ports(container_ports: Sequence[int]) -> dict[int, int]: """Reserve candidate host loopback ports for smolVM `-p HOST:GUEST`. The sockets are closed before smolVM binds them, so this remains best-effort. smolVM failing to bind a selected port is fatal in the caller's launch path.""" out: dict[int, int] = {} for container_port in container_ports: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) out[container_port] = int(sock.getsockname()[1]) return out def start_bundle_vm( spec: BundleLaunchSpec, *, from_path: Path, host_env: dict[str, str] | None = None, ) -> BundleVmLaunch: """Create and start the sidecar bundle as a smolVM. smolVM's own published ports are raw host-loopback ports. The launch flow wraps them with per-bottle address-bound forwarders before exposing anything to the agent VM.""" raw_ports = allocate_raw_host_ports(spec.ports_to_publish) effective_host_env = host_env if host_env is not None else os.environ env: dict[str, str] = {"BOT_BOTTLE_SIDECAR_DAEMONS": spec.daemons_csv} for entry in spec.environment: name, sep, value = entry.partition("=") if sep: env[name] = value elif entry in effective_host_env: env[entry] = effective_host_env[entry] name = bundle_machine_name(spec.slug) _smolvm.machine_create( name, from_path=from_path, net=True, env=env, volumes=spec.volumes, ports=tuple( (host_port, guest_port) for guest_port, host_port in raw_ports.items() ), ) _smolvm.machine_start(name) _smolvm.wait_exec_ready(name) return BundleVmLaunch(raw_ports=raw_ports) def stop_bundle_vm(slug: str) -> None: """Best-effort sidecar VM teardown.""" name = bundle_machine_name(slug) try: _smolvm.machine_stop(name) except _smolvm.SmolvmError as exc: warn(f"smolvm machine stop {name} failed: {exc}") try: _smolvm.machine_delete(name) except _smolvm.SmolvmError as exc: warn(f"smolvm machine delete {name} failed: {exc}") 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"BOT_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}"] # Loopback-only host port-forwards — the smolvm guest's TSI # uses macOS networking, and macOS loopback is the only host # surface that round-trips into Docker Desktop's daemon VM. # Binds to the per-bottle alias so TSI's IP-only allowlist # narrows reachability to this bottle's bundle only. for port in spec.ports_to_publish: argv += ["-p", f"{spec.publish_host_ip}::{port}"] 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 bundle_host_port( slug: str, container_port: int, *, host_ip: str = "127.0.0.1", ) -> int: """`docker port /tcp` → the random host-side port docker assigned for the binding on `host_ip`. Called after `start_bundle` on each container port listed in `BundleLaunchSpec.ports_to_publish` so the launch step can build the agent's HTTPS_PROXY / GIT_GATE / SUPERVISE URLs in `:` form.""" container = bundle_container_name(slug) result = subprocess.run( ["docker", "port", container, f"{container_port}/tcp"], capture_output=True, text=True, check=False, ) if result.returncode != 0: die( f"docker port {container} {container_port}/tcp failed: " f"{(result.stderr or '').strip() or ''}" ) # Each line looks like `127.0.0.16:54321` — one per address # family / host IP. Match on the expected host_ip prefix so # bottles bound to per-bottle aliases pick the right line. for raw in (result.stdout or "").splitlines(): line = raw.strip() if line.startswith(f"{host_ip}:"): _, _, port_str = line.rpartition(":") try: return int(port_str) except ValueError: die(f"unexpected `docker port` output: {line!r}") die( f"no port mapping on {host_ip} for {container} " f"{container_port}/tcp; got: {(result.stdout or '').strip()!r}" ) 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()}" )