Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion openhands-agent-server/openhands/agent_server/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ ARG PORT=8000
# Docker-in-Docker) reject dlopen() on such libraries with:
# "cannot enable executable stack as shared object requires: Invalid argument"
# Debian's CPython packages do not have this issue.
#
# PORTABILITY: After the venv is built we bundle the interpreter, stdlib,
# and libpython into /agent-server/.python/ and repoint the venv at it.
# This makes /agent-server fully self-contained — downstream consumers
# (e.g. eval images) can COPY it onto any base image without needing a
# compatible system Python.
####################################################################################
FROM python:3.13-bookworm AS builder
FROM --platform=$TARGETPLATFORM python:3.13-bookworm AS builder
ARG USERNAME UID GID
ENV UV_PROJECT_ENVIRONMENT=/agent-server/.venv

Expand All @@ -40,6 +46,51 @@ COPY --chown=${USERNAME}:${USERNAME} openhands-agent-server ./openhands-agent-se
RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
uv venv --python-preference only-system .venv && uv sync --frozen --no-editable --extra boto3

# Bundle the Python runtime inside /agent-server so that the entire directory
# is self-contained and portable. Eval images (and any other consumer) can
# COPY /agent-server onto *any* base image without requiring that base image
# to ship a compatible system Python.
#
# What we copy:
# .python/bin/python3.13 – the interpreter binary
# .python/lib/python3.13/ – the standard library (minus tests)
# .python/lib/libpython*.so* – shared libraries (Debian builds --enable-shared)
#
# We then repoint the venv's symlinks and pyvenv.cfg at the bundled copy.
RUN set -eux; \
REAL_PYTHON=$(readlink -f .venv/bin/python3); \
PY_VER=$("${REAL_PYTHON}" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"); \
PYTHON_PREFIX=$("${REAL_PYTHON}" -c "import sys; print(sys.base_prefix)"); \
# --- copy interpreter binary ------------------------------------------------- \
mkdir -p .python/bin; \
cp "${REAL_PYTHON}" ".python/bin/python${PY_VER}"; \
ln -s "python${PY_VER}" .python/bin/python3; \
ln -s "python${PY_VER}" .python/bin/python; \
# --- copy standard library (skip test suite to save ~30 MB) ------------------ \
mkdir -p .python/lib; \
cp -a "${PYTHON_PREFIX}/lib/python${PY_VER}" ".python/lib/python${PY_VER}"; \
rm -rf ".python/lib/python${PY_VER}/test" \
".python/lib/python${PY_VER}/tests" \
".python/lib/python${PY_VER}/idle_test" \
".python/lib/python${PY_VER}/idlelib"; \
# --- copy shared libraries (libpython) --------------------------------------- \
for lib in "${PYTHON_PREFIX}"/lib/libpython*.so*; do \
[ -e "$lib" ] && cp -a "$lib" .python/lib/; \
done; \
# --- repoint venv at the bundled Python -------------------------------------- \
for f in .venv/bin/python*; do \
[ -L "$f" ] || continue; \
name=$(basename "$f"); \
rm "$f"; \
ln -s "../../.python/bin/${name}" "$f"; \
done; \
# Ensure canonical names resolve (some venvs only create python3 + python) \
[ -L .venv/bin/python ] || ln -s "../../.python/bin/python" .venv/bin/python; \
[ -L .venv/bin/python3 ] || ln -s "../../.python/bin/python3" .venv/bin/python3; \
sed -i "s|^home = .*|home = /agent-server/.python/bin|" .venv/pyvenv.cfg; \
# --- quick smoke-test inside the builder ------------------------------------- \
.venv/bin/python -c "import sys; print('bundled python:', sys.executable, sys.version)"
Comment thread
simonrosenberg marked this conversation as resolved.

####################################################################################
# Binary Builder (binary mode)
# We run pyinstaller here to produce openhands-agent-server
Expand Down Expand Up @@ -272,13 +323,32 @@ EXPOSE ${PORT} ${NOVNC_PORT}
FROM base-image AS source
ARG USERNAME
COPY --chown=${USERNAME}:${USERNAME} --from=builder /agent-server /agent-server
# Bundled Python's libpython*.so lives under /agent-server/.python/lib
ENV LD_LIBRARY_PATH=/agent-server/.python/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Acceptable: The ${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} syntax correctly avoids a trailing colon when the variable is empty. Prepending ensures the bundled libpython takes precedence.

Potential consideration: If a runtime image has conflicting Python libraries in its LD_LIBRARY_PATH, prepending should resolve it. If issues arise, may need LD_LIBRARY_PATH=/agent-server/.python/lib (no append) to force isolation.

ENTRYPOINT ["/agent-server/.venv/bin/python", "-m", "openhands.agent_server"]

FROM base-image-minimal AS source-minimal
ARG USERNAME
COPY --chown=${USERNAME}:${USERNAME} --from=builder /agent-server /agent-server
ENV LD_LIBRARY_PATH=/agent-server/.python/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
ENTRYPOINT ["/agent-server/.venv/bin/python", "-m", "openhands.agent_server"]

############################
# Target A.1: portability-test
# Verifies that /agent-server stays runnable when copied onto a base image
# with no Python installed, matching the benchmarks repo assembly pattern.
############################
FROM debian:bookworm-slim AS portability-test
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /agent-server /agent-server
ENV LD_LIBRARY_PATH=/agent-server/.python/lib
RUN set -eux; \
test -x /agent-server/.python/bin/python3; \
test -f /agent-server/.venv/pyvenv.cfg; \
/agent-server/.venv/bin/python -c "import sys; print('OK:', sys.executable, sys.version)"; \
/agent-server/.venv/bin/python -c "import openhands.agent_server; print('agent_server importable')"

############################
# Target B: binary-runtime
# Production mode: build the binary inside Docker and copy it in.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,8 @@ def build_with_telemetry(opts: BuildOptions) -> BuildResult:
if push:
args += ["--platform", ",".join(opts.platforms), "--push"]
else:
if len(opts.platforms) == 1:
args += ["--platform", opts.platforms[0]]
args += ["--load"]

for t in tags:
Expand Down Expand Up @@ -842,7 +844,7 @@ def build_with_telemetry(opts: BuildOptions) -> BuildResult:
logger.info(
f"[build] Building target='{opts.target}' image='{opts.image}' "
f"custom_tags='{opts.custom_tags}' from base='{opts.base_image}' "
f"for platforms='{opts.platforms if push else 'local-arch'}'"
f"for platforms='{opts.platforms}'"
)
logger.info(
f"[build] Git ref='{opts.git_ref}' sha='{opts.git_sha}' "
Expand Down
117 changes: 117 additions & 0 deletions tests/agent_server/test_docker_build.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for agent_server docker build module."""

import os
import re
import subprocess
import tarfile
from pathlib import Path
Expand Down Expand Up @@ -568,13 +569,64 @@ def fake_run(cmd: list[str], cwd: str | None = None):
cmd, cwd = docker_calls[0]
assert cwd == str(ctx)
assert "--load" in cmd
assert "--platform" in cmd and "linux/amd64" in cmd
assert "--target" in cmd and "source-minimal" in cmd
assert "--build-arg" in cmd
assert "BASE_IMAGE=python:3.12" in cmd
for tag in opts.all_tags:
assert tag in cmd


def test_local_build_with_multiple_platforms_skips_platform_flag(tmp_path: Path):
from openhands.agent_server.docker.build import (
BuildOptions,
_default_sdk_project_root,
build,
)

ctx = tmp_path / "ctx"
ctx.mkdir()
docker_calls: list[tuple[list[str], str | None]] = []

def fake_run(cmd: list[str], cwd: str | None = None):
if cmd[:3] != ["docker", "buildx", "build"]:
raise AssertionError(f"unexpected command: {cmd}")
docker_calls.append((cmd, cwd))
return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="")

opts = BuildOptions(
base_image="python:3.12",
custom_tags="python",
git_sha="abc1234567890",
git_ref="refs/heads/main",
target="source-minimal",
platforms=["linux/amd64", "linux/arm64"],
push=False,
sdk_project_root=_default_sdk_project_root(),
)

with (
patch(
"openhands.agent_server.docker.build._make_build_context", return_value=ctx
),
patch("openhands.agent_server.docker.build._run", side_effect=fake_run),
patch(
"openhands.agent_server.docker.build._active_buildx_driver",
return_value="docker-container",
),
patch(
"openhands.agent_server.docker.build._default_local_cache_dir",
return_value=tmp_path / "cache",
),
patch("openhands.agent_server.docker.build.shutil.rmtree"),
):
build(opts)

cmd = docker_calls[0][0]
assert "--load" in cmd
assert "--platform" not in cmd


def test_build_can_reuse_same_prebuilt_sdist_multiple_times(tmp_path: Path):
from openhands.agent_server.docker.build import (
BuildOptions,
Expand Down Expand Up @@ -865,3 +917,68 @@ def fake_run(cmd: list[str], cwd: str | None = None):
assert f"mode={expect_mode_value}" in cmd_str
else:
assert "--cache-to" not in cmd_str


# ---------------------------------------------------------------------------
# Portability contract tests — verify that the Dockerfile bundles Python
# inside /agent-server so that COPY /agent-server works on any base image.
# These tests parse the Dockerfile text (no Docker daemon needed).
# ---------------------------------------------------------------------------

_DOCKERFILE_PATH = (
Path(__file__).resolve().parents[2]
/ "openhands-agent-server"
/ "openhands"
/ "agent_server"
/ "docker"
/ "Dockerfile"
)


@pytest.fixture()
def dockerfile_text() -> str:
return _DOCKERFILE_PATH.read_text(encoding="utf-8")


def test_builder_bundles_python_runtime(dockerfile_text: str):
"""The builder stage must copy the interpreter into .python/."""
assert "FROM --platform=$TARGETPLATFORM python:3.13-bookworm AS builder" in (
dockerfile_text
), "Builder must run on the target platform so bundled Python matches the image"
assert ".python/bin" in dockerfile_text, (
"Builder must copy the Python binary into .python/bin"
)
assert "pyvenv.cfg" in dockerfile_text, (
"Builder must update pyvenv.cfg to point at the bundled Python"
)


def test_source_targets_set_ld_library_path(dockerfile_text: str):
"""source and source-minimal targets need LD_LIBRARY_PATH for libpython."""
lines = dockerfile_text.splitlines()
for target_name in ["source", "source-minimal"]:
pattern = rf"FROM .* AS {re.escape(target_name)}\b"
in_target = False
found_ld_path = False
for line in lines:
if re.search(pattern, line):
in_target = True
continue
if in_target and line.startswith("FROM "):
break
if in_target and "LD_LIBRARY_PATH" in line:
assert "/agent-server/.python/lib" in line
found_ld_path = True
break

assert found_ld_path, (
f"{target_name} target must set "
"LD_LIBRARY_PATH=/agent-server/.python/lib"
)


def test_portability_test_target_validates_builder_artifacts(dockerfile_text: str):
"""The main Dockerfile should include a runnable portability-test stage."""
assert "FROM debian:bookworm-slim AS portability-test" in dockerfile_text
assert "COPY --from=builder /agent-server /agent-server" in dockerfile_text
assert "agent_server importable" in dockerfile_text
Loading