Skip to content
Open
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
109 changes: 89 additions & 20 deletions openhands-agent-server/openhands/agent_server/docker/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,12 +430,12 @@ def base_tag(self) -> str:
@property
def cache_tags(self) -> tuple[str, str]:
base = f"buildcache-{self.target}-{self.base_image_slug}"
if self.git_ref in ("main", "refs/heads/main"):
return f"{base}-main", base
elif self.git_ref != "unknown":
return f"{base}-{_sanitize_branch(self.git_ref)}", base
else:
return base, base
return _scoped_cache_tags(base, self.git_ref)

@property
def shared_cache_tags(self) -> tuple[str, str]:
base = f"buildcache-shared-{self.target}"
return _scoped_cache_tags(base, self.git_ref)

@property
def all_tags(self) -> list[str]:
Expand Down Expand Up @@ -596,6 +596,39 @@ def _get_dockerfile_path(sdk_project_root: Path) -> Path:
return dockerfile_path


def _scoped_cache_tags(base: str, git_ref: str) -> tuple[str, str]:
if git_ref in ("main", "refs/heads/main"):
return f"{base}-main", f"{base}-main"
if git_ref != "unknown":
return f"{base}-{_sanitize_branch(git_ref)}", f"{base}-main"
return base, f"{base}-main"


def _shared_registry_cache_export_enabled() -> bool:
raw = os.getenv("OPENHANDS_SHARED_REGISTRY_CACHE_EXPORT", "").strip().lower()
if raw in ("", "0", "false", "no", "off"):
return False
if raw in ("1", "true", "yes", "on"):
return True
logger.warning(
"[build] Unknown OPENHANDS_SHARED_REGISTRY_CACHE_EXPORT=%r; "
"defaulting to disabled",
raw,
)
return False


def _cache_to_mode() -> str:
raw = os.getenv("OPENHANDS_BUILDKIT_CACHE_MODE", "max").strip().lower()
if raw in {"max", "min", "off"}:
return raw
logger.warning(
"[build] Unknown OPENHANDS_BUILDKIT_CACHE_MODE=%r; defaulting to 'max'",
raw,
)
return "max"


def _round_seconds(value: float) -> float:
return round(value, 3)

Expand Down Expand Up @@ -717,7 +750,8 @@ def build_with_telemetry(opts: BuildOptions) -> BuildResult:
push = IN_CI

tags = opts.all_tags
cache_tag, cache_tag_base = opts.cache_tags
cache_tag, cache_tag_fallback = opts.cache_tags
shared_cache_tag, shared_cache_tag_fallback = opts.shared_cache_tags

telemetry = BuildTelemetry()
build_context_started = time.monotonic()
Expand Down Expand Up @@ -754,18 +788,43 @@ def build_with_telemetry(opts: BuildOptions) -> BuildResult:
driver = _active_buildx_driver() or "unknown"
local_cache_dir = _default_local_cache_dir()
cache_args: list[str] = []
cache_to_mode = _cache_to_mode()

if push:
# Remote/CI builds: use registry cache + inline for maximum reuse.
cache_args += [
"--cache-from",
f"type=registry,ref={opts.image}:{cache_tag}",
"--cache-from",
f"type=registry,ref={opts.image}:{cache_tag_base}-main",
"--cache-to",
f"type=registry,ref={opts.image}:{cache_tag},mode=max",
]
logger.info("[build] Cache: registry (remote/CI) + inline")
seen_cache_from: set[str] = set()
for cache_ref in (
cache_tag,
cache_tag_fallback,
shared_cache_tag,
shared_cache_tag_fallback,
):
if cache_ref in seen_cache_from:
continue
seen_cache_from.add(cache_ref)
cache_args += [
"--cache-from",
f"type=registry,ref={opts.image}:{cache_ref}",
]

cache_to_refs = []
if cache_to_mode != "off":
cache_to_refs.append(cache_tag)
if _shared_registry_cache_export_enabled():
cache_to_refs.append(shared_cache_tag)

for cache_ref in dict.fromkeys(cache_to_refs):
cache_args += [
"--cache-to",
f"type=registry,ref={opts.image}:{cache_ref},mode={cache_to_mode}",
]
logger.info(
"[build] Cache: registry (remote/CI), cache-to=%s, shared cache export=%s",
"disabled" if cache_to_mode == "off" else cache_to_mode,
"enabled"
if shared_cache_tag in cache_to_refs and cache_to_mode != "off"
else "disabled",
)
else:
# Local/dev builds: prefer local dir cache if
# driver supports it; otherwise inline-only.
Expand All @@ -774,11 +833,17 @@ def build_with_telemetry(opts: BuildOptions) -> BuildResult:
cache_args += [
"--cache-from",
f"type=local,src={str(local_cache_dir)}",
"--cache-to",
f"type=local,dest={str(local_cache_dir)},mode=max",
]
if cache_to_mode != "off":
cache_args += [
"--cache-to",
f"type=local,dest={str(local_cache_dir)},mode={cache_to_mode}",
]
logger.info(
f"[build] Cache: local dir at {local_cache_dir} (driver={driver})"
"[build] Cache: local dir at %s (driver=%s, cache-to=%s)",
local_cache_dir,
driver,
"disabled" if cache_to_mode == "off" else cache_to_mode,
)
else:
logger.warning(
Expand All @@ -805,7 +870,11 @@ def build_with_telemetry(opts: BuildOptions) -> BuildResult:
f"[build] Git ref='{opts.git_ref}' sha='{opts.git_sha}' "
f"package_version='{opts.sdk_version}'"
)
logger.info(f"[build] Cache tag: {cache_tag}")
logger.info(
"[build] Cache tags: "
f"image={cache_tag} fallback={cache_tag_fallback} "
f"shared={shared_cache_tag} shared_fallback={shared_cache_tag_fallback}"
)

buildx_started = time.monotonic()
try:
Expand Down
229 changes: 229 additions & 0 deletions tests/agent_server/test_docker_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,3 +759,232 @@ def fake_run(cmd: list[str], cwd: str | None = None):
assert excinfo.value.telemetry.buildx_wall_clock_seconds == 25.5
assert excinfo.value.telemetry.cache_export_seconds == 264.3
assert excinfo.value.telemetry.cache_import_miss_count == 1


def test_shared_cache_tags_are_stable_across_base_images():
from openhands.agent_server.docker.build import BuildOptions

first = BuildOptions(
base_image="python:3.12",
git_ref="refs/heads/feature/cache-tuning",
target="source-minimal",
)
second = BuildOptions(
base_image="ubuntu:22.04",
git_ref="refs/heads/feature/cache-tuning",
target="source-minimal",
)

assert first.cache_tags != second.cache_tags
assert first.shared_cache_tags == second.shared_cache_tags
assert first.shared_cache_tags == (
"buildcache-shared-source-minimal-feature-cache-tuning",
"buildcache-shared-source-minimal-main",
)


def test_shared_cache_tags_with_unknown_ref_still_fall_back_to_main():
from openhands.agent_server.docker.build import BuildOptions

opts = BuildOptions(target="source-minimal", git_ref="unknown")

assert opts.cache_tags == (
f"buildcache-source-minimal-{opts.base_image_slug}",
f"buildcache-source-minimal-{opts.base_image_slug}-main",
)
assert opts.shared_cache_tags == (
"buildcache-shared-source-minimal",
"buildcache-shared-source-minimal-main",
)


def test_build_push_reads_shared_registry_cache_but_does_not_export_it_by_default(
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/feature/cache-tuning",
image="ghcr.io/openhands/eval-agent-server",
target="source-minimal",
push=True,
sdk_project_root=_default_sdk_project_root(),
)

with (
patch(
"openhands.agent_server.docker.build._make_build_context", return_value=ctx
) as mock_make_context,
patch("openhands.agent_server.docker.build._run", side_effect=fake_run),
patch("openhands.agent_server.docker.build.shutil.rmtree"),
):
build(opts)

mock_make_context.assert_called_once()
assert mock_make_context.call_args.args[0] == opts.sdk_project_root
assert len(docker_calls) == 1
cmd, cwd = docker_calls[0]
assert cwd == str(ctx)

expected_cache_from = {
f"type=registry,ref={opts.image}:{opts.cache_tags[0]}",
f"type=registry,ref={opts.image}:{opts.cache_tags[1]}",
f"type=registry,ref={opts.image}:{opts.shared_cache_tags[0]}",
f"type=registry,ref={opts.image}:{opts.shared_cache_tags[1]}",
}
expected_cache_to = {
f"type=registry,ref={opts.image}:{opts.cache_tags[0]},mode=max",
}

actual_cache_from: set[str] = set()
actual_cache_to: set[str] = set()
for idx, arg in enumerate(cmd):
if arg == "--cache-from":
actual_cache_from.add(cmd[idx + 1])
if arg == "--cache-to":
actual_cache_to.add(cmd[idx + 1])

assert actual_cache_from == expected_cache_from
assert actual_cache_to == expected_cache_to


def test_build_push_can_disable_registry_cache_export(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/feature/cache-tuning",
image="ghcr.io/openhands/eval-agent-server",
target="source-minimal",
push=True,
sdk_project_root=_default_sdk_project_root(),
)

with (
patch.dict(
os.environ,
{"OPENHANDS_BUILDKIT_CACHE_MODE": "off"},
clear=False,
),
patch(
"openhands.agent_server.docker.build._make_build_context", return_value=ctx
) as mock_make_context,
patch("openhands.agent_server.docker.build._run", side_effect=fake_run),
patch("openhands.agent_server.docker.build.shutil.rmtree"),
):
build(opts)

mock_make_context.assert_called_once()
assert mock_make_context.call_args.args[0] == opts.sdk_project_root
assert len(docker_calls) == 1
cmd, cwd = docker_calls[0]
assert cwd == str(ctx)

expected_cache_from = {
f"type=registry,ref={opts.image}:{opts.cache_tags[0]}",
f"type=registry,ref={opts.image}:{opts.cache_tags[1]}",
f"type=registry,ref={opts.image}:{opts.shared_cache_tags[0]}",
f"type=registry,ref={opts.image}:{opts.shared_cache_tags[1]}",
}

actual_cache_from: set[str] = set()
actual_cache_to: set[str] = set()
for idx, arg in enumerate(cmd):
if arg == "--cache-from":
actual_cache_from.add(cmd[idx + 1])
if arg == "--cache-to":
actual_cache_to.add(cmd[idx + 1])

assert actual_cache_from == expected_cache_from
assert actual_cache_to == set()


def test_build_push_can_opt_in_to_shared_registry_cache_export(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/feature/cache-tuning",
image="ghcr.io/openhands/eval-agent-server",
target="source-minimal",
push=True,
sdk_project_root=_default_sdk_project_root(),
)

with (
patch.dict(
os.environ,
{"OPENHANDS_SHARED_REGISTRY_CACHE_EXPORT": "1"},
clear=False,
),
patch(
"openhands.agent_server.docker.build._make_build_context", return_value=ctx
) as mock_make_context,
patch("openhands.agent_server.docker.build._run", side_effect=fake_run),
patch("openhands.agent_server.docker.build.shutil.rmtree"),
):
build(opts)

mock_make_context.assert_called_once()
assert mock_make_context.call_args.args[0] == opts.sdk_project_root
assert len(docker_calls) == 1
cmd, _ = docker_calls[0]

actual_cache_to: set[str] = set()
for idx, arg in enumerate(cmd):
if arg == "--cache-to":
actual_cache_to.add(cmd[idx + 1])

assert actual_cache_to == {
f"type=registry,ref={opts.image}:{opts.cache_tags[0]},mode=max",
f"type=registry,ref={opts.image}:{opts.shared_cache_tags[0]},mode=max",
}
Loading