305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""sandbox.py – Docker container lifecycle and execution environment."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import tarfile
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING
|
||
import docker
|
||
import docker.errors
|
||
from loguru import logger
|
||
|
||
|
||
if TYPE_CHECKING:
|
||
import docker.models.containers
|
||
|
||
|
||
class DockerSandbox:
|
||
"""
|
||
Manages a single long-running Docker container.
|
||
|
||
All container configuration (volumes, ports, environment variables,
|
||
capabilities) is supplied by the caller.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
container_name: str,
|
||
image: str = "docker-agent-sandbox",
|
||
dockerfile_dir: str | None = None,
|
||
volumes: dict | None = None,
|
||
ports: dict | None = None,
|
||
environment: dict[str, str] | None = None,
|
||
working_dir: str | None = None,
|
||
# TODO(security): default network_mode is "bridge", giving containers unrestricted
|
||
# outbound network access. For an untrusted-code sandbox this should default to
|
||
# "none" so callers must explicitly opt in to network access.
|
||
network_mode: str = "bridge",
|
||
cap_drop: list[str] | None = None,
|
||
cap_add: list[str] | None = None,
|
||
security_opt: list[str] | None = None,
|
||
cpu_limit: float = 8,
|
||
memory_limit: str = "16g",
|
||
command: str | None = None,
|
||
uid: int | None = None,
|
||
gid: int | None = None,
|
||
) -> None:
|
||
self.container_name = container_name
|
||
self._image = image
|
||
self._dockerfile_dir = dockerfile_dir
|
||
self._volumes = volumes or {}
|
||
self._ports = ports or {}
|
||
self._environment = environment or {}
|
||
self._working_dir = working_dir
|
||
self._network_mode = network_mode
|
||
self._cap_drop = cap_drop
|
||
self._cap_add = cap_add
|
||
self._security_opt = security_opt
|
||
self._nano_cpus = int(cpu_limit * 1e9)
|
||
self._memory_limit = memory_limit
|
||
self._command = command
|
||
self._uid = uid
|
||
self._gid = gid
|
||
self._client: docker.DockerClient = docker.from_env()
|
||
self._container: docker.models.containers.Container | None = None
|
||
|
||
@property
|
||
def _user(self) -> str | None:
|
||
"""Docker ``user`` spec ("uid" or "uid:gid"), or None to use the image default."""
|
||
if self._uid is None and self._gid is None:
|
||
return None
|
||
uid = "" if self._uid is None else str(self._uid)
|
||
if self._gid is None:
|
||
return uid
|
||
return f"{uid}:{self._gid}"
|
||
|
||
def __enter__(self) -> "DockerSandbox":
|
||
return self
|
||
|
||
def __exit__(self, *_: object) -> None:
|
||
self.stop()
|
||
self._client.close()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Lifecycle
|
||
# ------------------------------------------------------------------
|
||
|
||
def build_image_if_missing(self) -> None:
|
||
"""Build the Docker image if it is not already present locally."""
|
||
try:
|
||
self._client.images.get(self._image)
|
||
logger.info("Image {!r} already present, skipping build.", self._image)
|
||
return
|
||
except docker.errors.ImageNotFound:
|
||
pass
|
||
|
||
if self._dockerfile_dir is None:
|
||
raise ValueError("dockerfile_dir must be provided to build the image")
|
||
|
||
logger.info("Building image {!r} from {} …", self._image, self._dockerfile_dir)
|
||
_, logs = self._client.images.build(
|
||
path=self._dockerfile_dir,
|
||
tag=self._image,
|
||
rm=True,
|
||
)
|
||
for entry in logs:
|
||
line = entry.get("stream", "").rstrip()
|
||
if line:
|
||
logger.debug(" {}", line)
|
||
|
||
logger.success("Image {!r} built successfully.", self._image)
|
||
|
||
def start(self) -> None:
|
||
"""
|
||
Start the container.
|
||
|
||
Any existing container with the same name is removed first so that
|
||
re-runs always start from a clean state.
|
||
"""
|
||
try:
|
||
old = self._client.containers.get(self.container_name)
|
||
old.remove(force=True)
|
||
except docker.errors.NotFound:
|
||
pass
|
||
|
||
run_kwargs: dict = dict(
|
||
name=self.container_name,
|
||
detach=True,
|
||
volumes=self._volumes,
|
||
environment=self._environment,
|
||
ports=self._ports,
|
||
network_mode=self._network_mode,
|
||
)
|
||
if self._working_dir is not None:
|
||
run_kwargs["working_dir"] = self._working_dir
|
||
if self._cap_drop is not None:
|
||
run_kwargs["cap_drop"] = self._cap_drop
|
||
if self._cap_add is not None:
|
||
run_kwargs["cap_add"] = self._cap_add
|
||
if self._security_opt is not None:
|
||
run_kwargs["security_opt"] = self._security_opt
|
||
run_kwargs["nano_cpus"] = self._nano_cpus
|
||
run_kwargs["mem_limit"] = self._memory_limit
|
||
if self._user is not None:
|
||
run_kwargs["user"] = self._user
|
||
|
||
try:
|
||
self._container = self._client.containers.run(
|
||
self._image, self._command, **run_kwargs
|
||
)
|
||
except docker.errors.ImageNotFound:
|
||
raise RuntimeError(
|
||
f"Image {self._image!r} not found locally. "
|
||
"Call build_image_if_missing() first, or pull/build the image manually."
|
||
) from None
|
||
self._container.reload()
|
||
|
||
logger.info(
|
||
"Container {!r} started (id={}).",
|
||
self.container_name,
|
||
self._container.short_id,
|
||
)
|
||
|
||
def stop(self) -> None:
|
||
"""Remove the container."""
|
||
if self._container is not None:
|
||
self._container.remove(force=True)
|
||
logger.info("Container {!r} stopped.", self.container_name)
|
||
self._container = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# Port inspection
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_host_port(self, container_port: str) -> str:
|
||
"""
|
||
Return the host port Docker mapped to *container_port*.
|
||
|
||
*container_port* should include the protocol, e.g. ``"8080/tcp"``.
|
||
Raises ``RuntimeError`` if the container is not running or the port
|
||
is not exposed.
|
||
"""
|
||
if self._container is None:
|
||
raise RuntimeError("Sandbox container is not running.")
|
||
mappings = self._container.ports.get(container_port)
|
||
if not mappings:
|
||
raise RuntimeError(
|
||
f"Port {container_port!r} is not mapped on container {self.container_name!r}"
|
||
)
|
||
return mappings[0]["HostPort"]
|
||
|
||
# ------------------------------------------------------------------
|
||
# File I/O
|
||
# ------------------------------------------------------------------
|
||
|
||
def _resolve_path(self, path: str) -> Path:
|
||
"""Resolve *path* against working_dir if relative."""
|
||
p = Path(path)
|
||
if not p.is_absolute() and self._working_dir is not None:
|
||
p = Path(self._working_dir) / p
|
||
return p
|
||
|
||
def read_file(self, path: str) -> bytes:
|
||
"""
|
||
Read the file at *path* from the container using ``get_archive``.
|
||
|
||
Returns the raw file bytes. Raises ``FileNotFoundError`` if the path
|
||
does not exist and ``RuntimeError`` if the container is not running.
|
||
"""
|
||
if self._container is None:
|
||
raise RuntimeError("Sandbox container is not running.")
|
||
|
||
p = self._resolve_path(path)
|
||
|
||
try:
|
||
stream, _ = self._container.get_archive(str(p))
|
||
except docker.errors.NotFound:
|
||
raise FileNotFoundError(f"No such file in container: {path!r}") from None
|
||
|
||
buf = io.BytesIO()
|
||
for chunk in stream:
|
||
buf.write(chunk)
|
||
buf.seek(0)
|
||
|
||
with tarfile.open(fileobj=buf) as tar:
|
||
member = tar.getmembers()[0]
|
||
f = tar.extractfile(member)
|
||
if f is None:
|
||
raise IsADirectoryError(f"{path!r} is a directory, not a file")
|
||
return f.read()
|
||
|
||
def write_file(self, path: str, content: str) -> None:
|
||
"""
|
||
Write *content* to *path* inside the container using ``put_archive``.
|
||
|
||
The tar archive API avoids all shell-escaping concerns. Parent
|
||
directories are created automatically via a preceding ``mkdir -p``.
|
||
"""
|
||
if self._container is None:
|
||
raise RuntimeError("Sandbox container is not running.")
|
||
|
||
p = self._resolve_path(path)
|
||
encoded = content.encode("utf-8")
|
||
|
||
self._container.exec_run(["mkdir", "-p", str(p.parent)])
|
||
|
||
buf = io.BytesIO()
|
||
with tarfile.open(fileobj=buf, mode="w") as tar:
|
||
info = tarfile.TarInfo(name=p.name)
|
||
info.size = len(encoded)
|
||
info.mode = 0o644
|
||
tar.addfile(info, io.BytesIO(encoded))
|
||
buf.seek(0)
|
||
self._container.put_archive(str(p.parent), buf)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Command execution
|
||
# ------------------------------------------------------------------
|
||
|
||
def exec(
|
||
self, command: str, timeout: int = 120, kill_grace: int = 5
|
||
) -> tuple[int, str]:
|
||
if self._container is None:
|
||
return 1, "Sandbox container is not running."
|
||
|
||
# docker-py's exec_run reads command output off a hijacked raw socket
|
||
# and intermittently returns nothing because bytes that http.client
|
||
# already buffered while parsing the response headers are stranded and
|
||
# never read (https://github.com/docker/docker-py/issues/2042,
|
||
# https://github.com/docker/docker-py/issues/3332). To avoid that path
|
||
# entirely we redirect combined output to a file and read it back via
|
||
# get_archive (a normal HTTP body), which is reliable on every
|
||
# transport. The exit code from exec_run is unaffected by the bug.
|
||
out_path = f"/tmp/.sandbox-exec-{uuid.uuid4().hex}"
|
||
# The command is passed via an env var so the outer shell never has to
|
||
# quote-escape it; "$SANDBOX_CMD" reaches the inner bash -c verbatim.
|
||
runner = (
|
||
f"timeout --kill-after={kill_grace}s {timeout}s "
|
||
f'bash -c "$SANDBOX_CMD" > {out_path} 2>&1'
|
||
)
|
||
kwargs = {}
|
||
if self._working_dir is not None:
|
||
kwargs["workdir"] = self._working_dir
|
||
|
||
try:
|
||
exit_code, _ = self._container.exec_run(
|
||
["bash", "-c", runner],
|
||
environment={"SANDBOX_CMD": command},
|
||
**kwargs,
|
||
)
|
||
except Exception as exc:
|
||
return 1, f"Error running command in container: {exc}"
|
||
|
||
try:
|
||
text = self.read_file(out_path).decode("utf-8", errors="replace")
|
||
except FileNotFoundError:
|
||
text = ""
|
||
finally:
|
||
self._container.exec_run(["rm", "-f", out_path])
|
||
|
||
if exit_code in (124, 137):
|
||
return exit_code, text + f"\n[command timed out after {timeout}s]"
|
||
return exit_code, text
|