From 80d1ea1049cf4c2250a727fcf0cc06e1c14c29f3 Mon Sep 17 00:00:00 2001 From: Devin Date: Sat, 26 Sep 2026 17:41:58 -0700 Subject: [PATCH 1/2] Add content-based image build or wait helpers --- README.md | 44 ++ .../client/managers/async_manager/sandbox.py | 172 ++++++++ .../client/managers/sandboxes/image_build.py | 9 + .../managers/sandboxes/image_resolution.py | 103 +++++ .../client/managers/sync_manager/sandbox.py | 169 ++++++++ hyperbrowser/image_builds.py | 5 + hyperbrowser/models/__init__.py | 2 + hyperbrowser/models/sandbox.py | 13 + pyproject.toml | 2 +- tests/test_image_resolution.py | 385 ++++++++++++++++++ tests/typecheck/invalid_requests.py | 9 + tests/typecheck/valid_requests.py | 15 + 12 files changed, 927 insertions(+), 1 deletion(-) create mode 100644 hyperbrowser/client/managers/sandboxes/image_resolution.py create mode 100644 hyperbrowser/image_builds.py create mode 100644 tests/test_image_resolution.py diff --git a/README.md b/README.md index 00134565..665089a4 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,50 @@ Image listings distinguish `ready` from `uploaded`: a completed team image can be ready to launch before its durability backup is uploaded. `ready` is `None` when talking to an older server. Keep the returned image ID to pin that revision. +### Reuse an image or join a build + +`get_or_build_image` provides the same operation on sync and async clients. Give +it either a remote Dockerfile context or a local Docker image. It derives a name +from the input identity and image initialization options, reuses a ready team +image, or submits a build and joins a compatible concurrent build automatically. +The optional prefix is a namespace, not a fixed image alias: different inputs +produce different names under the same prefix. + +```python +resolved = client.sandboxes.get_or_build_image( + context_path="./app", # alternatively: docker_image="local/app:latest" + image_name_prefix="my-app", + wait_timeout=3600, +) +print(resolved.outcome) # "reused", "joined", or "created" +sandbox = client.sandboxes.create({ + "image_name": resolved.image_name, + "image_id": resolved.image_id, +}) +``` + +With `wait=False`, a submitted/joined build is returned as `resolved.build`; +`image_id` is populated only when ready. `find_ready_image(name)` exposes the +exact-name lookup separately. Older servers fall back to uploaded-image reuse. +The public `hyperbrowser.image_builds.image_build_name` helper lets integrations +derive the same name from an existing context fingerprint or Docker image digest. +Passing `expected_context_fingerprint` or `expected_image_digest` avoids repeating +identity discovery; supply a fresh identity for each resolution request. Changes +between identity discovery and packaging are rejected instead of published under +the wrong name. Local Docker images must already be available in the daemon. + +`force_build=True` skips ready-image lookup but still joins matching active builds +and permits existing layer/artifact caches. Use it to refresh mutable base tags or +external Dockerfile downloads. Joining does not change an existing builder's +resources. Lookup and creation use separate API calls; if another build completes +between them, an additional revision can be submitted. + +Each caller owns its polling timeout. Canceling that wait does not cancel an +accepted backend build. Uploads have a separate inactivity allowance +(`upload_timeout=600` by default), not a total upload-duration limit. The existing +`build_image_from_dockerfile` and `build_image_from_docker_image` methods retain +their explicit-name behavior and continue to report build conflicts directly. + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/hyperbrowser/client/managers/async_manager/sandbox.py b/hyperbrowser/client/managers/async_manager/sandbox.py index d2669f4e..1468b43d 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -1,6 +1,7 @@ import asyncio import functools import time +from pathlib import Path from typing import Dict, Optional, Union from ..._request import coerce_request, dump_request @@ -15,6 +16,8 @@ SandboxExposeParams, SandboxExposeResult, SandboxImageBuild, + SandboxImageBuildResolution, + SandboxImageSummary, SandboxImageBuildCreateResult, SandboxDockerImageReuseResult, SandboxImageBuildListParams, @@ -63,6 +66,11 @@ parse_json_response, should_retry_get, ) +from ..sandboxes.image_resolution import ( + image_build_name, + matching_image_build, + completed_image_id, +) from ..sandboxes.shared import ( _build_sandbox_exposed_url, _copy_model, @@ -70,6 +78,8 @@ ) from ..sandboxes.image_build import ( IMAGE_BUILD_SOURCE_PLATFORM, + docker_build_context_fingerprint, + docker_image_digest, build_docker_image_from_dockerfile, is_terminal_image_build_status, make_temp_docker_tag, @@ -459,6 +469,159 @@ async def list_images( ) return SandboxImageListResponse(**payload) + async def find_ready_image(self, image_name: str) -> Optional[SandboxImageSummary]: + """Find an exact ready team image, including revisions awaiting backup.""" + page = 1 + while True: + response = await self.list_images( + SandboxImageListParams( + search=image_name, sources=["team"], page=page, limit=100 + ) + ) + for image in response.images: + if image.image_name == image_name and ( + image.uploaded or getattr(image, "ready", False) + ): + return image + if len(response.images) < 100: + return None + if response.total_count is not None and page * 100 >= response.total_count: + return None + page += 1 + + async def get_or_build_image( + self, + *, + context_path: Optional[Union[str, Path]] = None, + docker_image: Optional[str] = None, + image_name_prefix: str = "hb", + dockerfile: str = "Dockerfile", + platform: str = IMAGE_BUILD_SOURCE_PLATFORM, + remote_full_context: bool = False, + expected_context_fingerprint: Optional[str] = None, + expected_image_digest: Optional[str] = None, + image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, + image_config_user: Optional[str] = None, + builder_cpus: Optional[int] = None, + builder_memory_mib: Optional[int] = None, + builder_scratch_mib: Optional[int] = None, + force_build: bool = False, + wait: bool = True, + poll_interval: float = 3.0, + wait_timeout: Optional[float] = 35 * 60, + upload_timeout: Optional[float] = 600, + temp_dir: Optional[str] = None, + ) -> SandboxImageBuildResolution: + """Reuse, join, or build content-derived remote Dockerfile/image inputs. + + Supply exactly one of context_path or docker_image. Names include source + contents, platform and image initialization overrides. force_build skips + ready-image lookup, but joins matching active builds and retains builder + layer/artifact caches. Canceling polling never cancels the backend build. + wait_timeout applies to this caller's polling, independently of uploads. + This composes existing APIs; lookup plus creation is not server-atomic. + """ + platform = platform.strip().lower() + if platform != "linux/amd64": + raise ValueError("Image builds require platform='linux/amd64'") + if (context_path is None) == (docker_image is None): + raise ValueError("Supply exactly one of context_path or docker_image") + if context_path is not None: + if expected_image_digest is not None: + raise ValueError("expected_image_digest requires docker_image") + fingerprint = expected_context_fingerprint + if fingerprint is None: + fingerprint = await _run_blocking( + docker_build_context_fingerprint, + context_path, + dockerfile=dockerfile, + force_full_context=remote_full_context, + ) + source = "dockerfile" + input_format = "dockerfile_context_manifest_v1" + else: + if ( + expected_context_fingerprint is not None + or remote_full_context + or dockerfile != "Dockerfile" + ): + raise ValueError("Dockerfile context options require context_path") + fingerprint = expected_image_digest + if fingerprint is None: + fingerprint = await _run_blocking( + docker_image_digest, docker_image, platform=platform + ) + source = "prebuilt" + input_format = "docker_image_manifest_v1" + image_name = image_build_name( + source=source, + fingerprint=fingerprint, + name_prefix=image_name_prefix, + platform=platform, + image_init=image_init, + image_config_user=image_config_user, + ) + if not force_build: + image = await self.find_ready_image(image_name) + if image is not None: + return SandboxImageBuildResolution( + outcome="reused", + image_name=image_name, + image_id=image.id, + ) + common = dict( + image_name=image_name, + platform=platform, + image_init=image_init, + image_config_user=image_config_user, + builder_cpus=builder_cpus, + builder_memory_mib=builder_memory_mib, + builder_scratch_mib=builder_scratch_mib, + wait=False, + upload_timeout=upload_timeout, + temp_dir=temp_dir, + ) + common = { + key: value + for key, value in common.items() + if not (key.startswith("builder_") and value is None) + } + outcome = "created" + try: + if context_path is not None: + build = await self.build_image_from_dockerfile( + context_path=context_path, + dockerfile=dockerfile, + remote=True, + remote_full_context=remote_full_context, + expected_context_fingerprint=fingerprint, + **common, + ) + else: + build = await self.build_image_from_docker_image( + docker_image=docker_image, + expected_image_digest=fingerprint, + **common, + ) + except HyperbrowserError as error: + existing = matching_image_build(error, image_name, input_format) + if existing is None: + raise + build = existing + outcome = "joined" + if wait and build.status != "completed": + build = await self.wait_for_image_build( + build.id, + poll_interval=poll_interval, + timeout=wait_timeout, + ) + return SandboxImageBuildResolution( + outcome=outcome, + image_name=image_name, + image_id=completed_image_id(build), + build=build, + ) + async def list_snapshots( self, params: Optional[ @@ -582,6 +745,7 @@ async def build_image_from_docker_image( *, docker_image: str, image_name: str, + expected_image_digest: Optional[str] = None, platform: str = IMAGE_BUILD_SOURCE_PLATFORM, image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, image_config_user: Optional[str] = None, @@ -600,6 +764,14 @@ async def build_image_from_docker_image( platform=platform, ) try: + if ( + expected_image_digest is not None + and source.image_digest != expected_image_digest.lower() + ): + raise RuntimeError( + "Docker image changed after its cache identity was computed. " + "Retry with a fresh image digest." + ) explicit_image_init = ( coerce_request(image_init, SandboxImageInit, name="image_init") if image_init is not None diff --git a/hyperbrowser/client/managers/sandboxes/image_build.py b/hyperbrowser/client/managers/sandboxes/image_build.py index 8c754e54..e423681f 100644 --- a/hyperbrowser/client/managers/sandboxes/image_build.py +++ b/hyperbrowser/client/managers/sandboxes/image_build.py @@ -443,6 +443,15 @@ def package_docker_build_context_manifest( raise +def docker_image_digest( + docker_image: str, *, platform: str = IMAGE_BUILD_SOURCE_PLATFORM +) -> str: + """Inspect the platform identity without creating temporary resources.""" + return _normalize_sha256_digest( + _inspect_docker_image(docker_image, platform).get("Id") + ) + + def prepare_docker_image_manifest_source( docker_image: str, *, diff --git a/hyperbrowser/client/managers/sandboxes/image_resolution.py b/hyperbrowser/client/managers/sandboxes/image_resolution.py new file mode 100644 index 00000000..7d8e9c11 --- /dev/null +++ b/hyperbrowser/client/managers/sandboxes/image_resolution.py @@ -0,0 +1,103 @@ +"""Shared identity and validation for opt-in image resolution.""" + +import hashlib +import json +import re +from collections.abc import Mapping +from typing import Literal, Optional, Union + +from ....exceptions import HyperbrowserError +from ....models.sandbox import SandboxImageBuild, SandboxImageInit +from ....types import SandboxImageInit as SandboxImageInitDict +from ..._request import coerce_request + + +def image_build_name( + *, + source: Literal["dockerfile", "prebuilt"], + fingerprint: str, + name_prefix: str = "hb", + platform: str = "linux/amd64", + image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, + image_config_user: Optional[str] = None, +) -> str: + """Name an immutable input identity without packaging or uploading it. + + Fingerprints identify effective Dockerfile contexts or platform-specific + Docker image digests. Builder resources and wait policies do not change the + image contents and are excluded. Mutable external inputs (base tags, network + downloads) require an explicit force_build to request another build. + """ + platform = platform.strip().lower() + if not re.fullmatch(r"[a-z0-9]+/[a-z0-9]+(?:/[a-z0-9]+)?", platform): + raise ValueError("platform must be an OCI platform such as 'linux/amd64'") + if not re.fullmatch(r"[A-Za-z0-9_-]{1,21}", name_prefix): + raise ValueError("image_name_prefix must be 1-21 letters, digits, '_' or '-'") + if source == "prebuilt": + fingerprint = fingerprint.lower() + if not re.fullmatch(r"sha256:[0-9a-f]{64}", fingerprint): + raise ValueError("expected_image_digest must be a sha256: Docker digest") + payload = "docker_image\0{}\0platform\0{}".format(fingerprint, platform) + elif source == "dockerfile": + if not re.fullmatch(r"[0-9a-f]{64}", fingerprint): + raise ValueError( + "expected_context_fingerprint must be a SHA-256 hex digest" + ) + payload = "dockerfile-context-v3\0{}\0platform\0{}".format( + fingerprint, platform + ) + else: + raise ValueError("source must be 'dockerfile' or 'prebuilt'") + options = {} + if image_init is not None: + normalized = coerce_request(image_init, SandboxImageInit, name="image_init") + initialization = normalized.model_dump(by_alias=True, exclude_none=True) + if initialization: + options["imageInit"] = initialization + if image_config_user is not None: + options["imageConfigUser"] = image_config_user.strip() + if options: + payload += "\0options\0" + json.dumps( + options, sort_keys=True, separators=(",", ":") + ) + digest = hashlib.blake2b(payload.encode(), digest_size=8).hexdigest() + name = "{}__{}__{}__{}".format( + name_prefix, source, digest, platform.replace("/", "-") + ) + if len(name) > 64: + raise ValueError( + "image_name_prefix and platform produce a name longer than 64 characters" + ) + return name + + +def matching_image_build( + error: HyperbrowserError, image_name: str, input_format: str +) -> Optional[SandboxImageBuild]: + if error.status_code != 409 or error.code != "image_build_in_progress": + return None + if not isinstance(error.details, Mapping): + return None + data = error.details.get("build") + if not isinstance(data, Mapping): + return None + metadata = data.get("metadata") + if not isinstance(metadata, Mapping) or metadata.get("inputFormat") != input_format: + return None + if metadata.get("sourcePlatform", "linux/amd64") != "linux/amd64": + return None + try: + build = SandboxImageBuild(**dict(data)) + except (TypeError, ValueError): + return None + if not build.id or build.image_name != image_name: + return None + return build + + +def completed_image_id(build: SandboxImageBuild) -> Optional[str]: + if build.status != "completed": + return None + if not build.image_id: + raise RuntimeError("Completed image build did not return an image ID") + return build.image_id diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index a334727d..e2a3709f 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -1,4 +1,5 @@ import time +from pathlib import Path from typing import Dict, Optional, Union from ..._request import coerce_request, dump_request @@ -13,6 +14,8 @@ SandboxExposeParams, SandboxExposeResult, SandboxImageBuild, + SandboxImageBuildResolution, + SandboxImageSummary, SandboxImageBuildCreateResult, SandboxDockerImageReuseResult, SandboxImageBuildListParams, @@ -61,6 +64,11 @@ parse_json_response, should_retry_get, ) +from ..sandboxes.image_resolution import ( + image_build_name, + matching_image_build, + completed_image_id, +) from ..sandboxes.shared import ( _build_sandbox_exposed_url, _copy_model, @@ -68,6 +76,8 @@ ) from ..sandboxes.image_build import ( IMAGE_BUILD_SOURCE_PLATFORM, + docker_build_context_fingerprint, + docker_image_digest, build_docker_image_from_dockerfile, is_terminal_image_build_status, make_temp_docker_tag, @@ -451,6 +461,156 @@ def list_images( ) return SandboxImageListResponse(**payload) + def find_ready_image(self, image_name: str) -> Optional[SandboxImageSummary]: + """Find an exact ready team image, including revisions awaiting backup.""" + page = 1 + while True: + response = self.list_images( + SandboxImageListParams( + search=image_name, sources=["team"], page=page, limit=100 + ) + ) + for image in response.images: + if image.image_name == image_name and ( + image.uploaded or getattr(image, "ready", False) + ): + return image + if len(response.images) < 100: + return None + if response.total_count is not None and page * 100 >= response.total_count: + return None + page += 1 + + def get_or_build_image( + self, + *, + context_path: Optional[Union[str, Path]] = None, + docker_image: Optional[str] = None, + image_name_prefix: str = "hb", + dockerfile: str = "Dockerfile", + platform: str = IMAGE_BUILD_SOURCE_PLATFORM, + remote_full_context: bool = False, + expected_context_fingerprint: Optional[str] = None, + expected_image_digest: Optional[str] = None, + image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, + image_config_user: Optional[str] = None, + builder_cpus: Optional[int] = None, + builder_memory_mib: Optional[int] = None, + builder_scratch_mib: Optional[int] = None, + force_build: bool = False, + wait: bool = True, + poll_interval: float = 3.0, + wait_timeout: Optional[float] = 35 * 60, + upload_timeout: Optional[float] = 600, + temp_dir: Optional[str] = None, + ) -> SandboxImageBuildResolution: + """Reuse, join, or build content-derived remote Dockerfile/image inputs. + + Supply exactly one of context_path or docker_image. Names include source + contents, platform and image initialization overrides. force_build skips + ready-image lookup, but joins matching active builds and retains builder + layer/artifact caches. Canceling polling never cancels the backend build. + wait_timeout applies to this caller's polling, independently of uploads. + This composes existing APIs; lookup plus creation is not server-atomic. + """ + platform = platform.strip().lower() + if platform != "linux/amd64": + raise ValueError("Image builds require platform='linux/amd64'") + if (context_path is None) == (docker_image is None): + raise ValueError("Supply exactly one of context_path or docker_image") + if context_path is not None: + if expected_image_digest is not None: + raise ValueError("expected_image_digest requires docker_image") + fingerprint = expected_context_fingerprint + if fingerprint is None: + fingerprint = docker_build_context_fingerprint( + context_path, + dockerfile=dockerfile, + force_full_context=remote_full_context, + ) + source = "dockerfile" + input_format = "dockerfile_context_manifest_v1" + else: + if ( + expected_context_fingerprint is not None + or remote_full_context + or dockerfile != "Dockerfile" + ): + raise ValueError("Dockerfile context options require context_path") + fingerprint = expected_image_digest + if fingerprint is None: + fingerprint = docker_image_digest(docker_image, platform=platform) + source = "prebuilt" + input_format = "docker_image_manifest_v1" + image_name = image_build_name( + source=source, + fingerprint=fingerprint, + name_prefix=image_name_prefix, + platform=platform, + image_init=image_init, + image_config_user=image_config_user, + ) + if not force_build: + image = self.find_ready_image(image_name) + if image is not None: + return SandboxImageBuildResolution( + outcome="reused", + image_name=image_name, + image_id=image.id, + ) + common = dict( + image_name=image_name, + platform=platform, + image_init=image_init, + image_config_user=image_config_user, + builder_cpus=builder_cpus, + builder_memory_mib=builder_memory_mib, + builder_scratch_mib=builder_scratch_mib, + wait=False, + upload_timeout=upload_timeout, + temp_dir=temp_dir, + ) + common = { + key: value + for key, value in common.items() + if not (key.startswith("builder_") and value is None) + } + outcome = "created" + try: + if context_path is not None: + build = self.build_image_from_dockerfile( + context_path=context_path, + dockerfile=dockerfile, + remote=True, + remote_full_context=remote_full_context, + expected_context_fingerprint=fingerprint, + **common, + ) + else: + build = self.build_image_from_docker_image( + docker_image=docker_image, + expected_image_digest=fingerprint, + **common, + ) + except HyperbrowserError as error: + existing = matching_image_build(error, image_name, input_format) + if existing is None: + raise + build = existing + outcome = "joined" + if wait and build.status != "completed": + build = self.wait_for_image_build( + build.id, + poll_interval=poll_interval, + timeout=wait_timeout, + ) + return SandboxImageBuildResolution( + outcome=outcome, + image_name=image_name, + image_id=completed_image_id(build), + build=build, + ) + def list_snapshots( self, params: Optional[ @@ -574,6 +734,7 @@ def build_image_from_docker_image( *, docker_image: str, image_name: str, + expected_image_digest: Optional[str] = None, platform: str = IMAGE_BUILD_SOURCE_PLATFORM, image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, image_config_user: Optional[str] = None, @@ -591,6 +752,14 @@ def build_image_from_docker_image( platform=platform, ) try: + if ( + expected_image_digest is not None + and source.image_digest != expected_image_digest.lower() + ): + raise RuntimeError( + "Docker image changed after its cache identity was computed. " + "Retry with a fresh image digest." + ) explicit_image_init = ( coerce_request(image_init, SandboxImageInit, name="image_init") if image_init is not None diff --git a/hyperbrowser/image_builds.py b/hyperbrowser/image_builds.py new file mode 100644 index 00000000..d8c81312 --- /dev/null +++ b/hyperbrowser/image_builds.py @@ -0,0 +1,5 @@ +"""Content-derived image names for build-or-wait workflows.""" + +from .client.managers.sandboxes.image_resolution import image_build_name + +__all__ = ["image_build_name"] diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index 7bc21fe0..e99b6abf 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -364,6 +364,7 @@ CompleteSandboxImageBuildParams, SandboxImageBuildUpload, SandboxImageBuild, + SandboxImageBuildResolution, SandboxImageBuildCreateResult, SandboxDockerImageReuseResult, SandboxImageBuildListParams, @@ -695,6 +696,7 @@ "CompleteSandboxImageBuildParams", "SandboxImageBuildUpload", "SandboxImageBuild", + "SandboxImageBuildResolution", "SandboxImageBuildCreateResult", "SandboxDockerImageReuseResult", "SandboxImageBuildListParams", diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index 394fed35..a090998e 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -523,6 +523,19 @@ def parse_image_build_datetimes(cls, value): return _parse_optional_datetime(value) +class SandboxImageBuildResolution(SandboxBaseModel): + """The result of resolving content-derived image inputs. + + image_id is populated only for a ready image. With wait=False, build + identifies the submitted or joined build, which the caller can poll later. + """ + + outcome: Literal["reused", "joined", "created"] + image_name: str + image_id: Optional[str] = None + build: Optional[SandboxImageBuild] = None + + class SandboxImageBuildCreateResult(SandboxBaseModel): build: SandboxImageBuild upload: Optional[SandboxImageBuildUpload] = None diff --git a/pyproject.toml b/pyproject.toml index efe1a353..024f0894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hyperbrowser" -version = "1.8.0" +version = "1.9.0" description = "Python SDK for hyperbrowser" authors = ["Nikhil Shahi "] license = "MIT" diff --git a/tests/test_image_resolution.py b/tests/test_image_resolution.py new file mode 100644 index 00000000..e67ee920 --- /dev/null +++ b/tests/test_image_resolution.py @@ -0,0 +1,385 @@ +import asyncio +import inspect +import json + +import httpx +import pytest + +from hyperbrowser import AsyncHyperbrowser, Hyperbrowser +from hyperbrowser.build_context import DockerBuildContextChangedError +from hyperbrowser.client.managers.sandboxes import image_build +from hyperbrowser.exceptions import HyperbrowserError +from hyperbrowser.image_builds import image_build_name +from hyperbrowser.models import SandboxImageInit + + +class Backend: + def __init__(self, *, ready=False, conflict=False): + self.ready = ready + self.conflict = conflict + self.name = None + self.requests = [] + self.upload_timeouts = [] + self.polls = 0 + self.transform = lambda value: value + + def build(self, status="building"): + return { + "id": "build-1", + "imageName": self.name, + "imageId": "image-1", + "status": status, + "metadata": { + "inputFormat": "dockerfile_context_manifest_v1", + "sourcePlatform": "linux/amd64", + }, + } + + def respond(self, request): + path = request.url.path + self.requests.append((request.method, path)) + if path == "/api/images": + self.name = request.url.params["search"] + image = { + "id": "image-1", + "imageName": self.name, + "namespace": "team-local", + "uploaded": False, + "ready": True, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + } + return httpx.Response(200, json={"images": [image] if self.ready else []}) + if request.method == "POST" and path == "/api/images/builds": + body = json.loads(request.content) + self.name = body["imageName"] + if self.conflict: + return httpx.Response( + 409, + json=self.transform( + { + "message": "already building", + "code": "image_build_in_progress", + "build": self.build(), + } + ), + ) + return httpx.Response( + 200, json={"build": self.build("awaiting_upload"), "uploads": []} + ) + if path.endswith("/complete"): + return httpx.Response(200, json={"build": self.build()}) + if path.endswith("/reuse"): + self.name = json.loads(request.content)["imageName"] + return httpx.Response( + 200, json={"hit": True, "build": self.build("completed")} + ) + assert request.method == "GET" and path == "/api/images/builds/build-1", path + self.polls += 1 + return httpx.Response(200, json={"build": self.build("completed")}) + + +@pytest.fixture +def context(tmp_path): + (tmp_path / "Dockerfile").write_text("FROM scratch\nCOPY data /data\n") + (tmp_path / "data").write_text("first") + return tmp_path + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +def resolve(request, monkeypatch): + """Use real SDK request, packaging, resolution and polling implementations.""" + asynchronous = request.param + sync_http, async_http = httpx.Client, httpx.AsyncClient + + def run(backend, **kwargs): + async def invoke(): + factory = AsyncHyperbrowser if asynchronous else Hyperbrowser + client = factory(api_key="local-only", base_url="http://local.test") + try: + result = client.sandboxes.get_or_build_image(poll_interval=0, **kwargs) + return await result if inspect.isawaitable(result) else result + finally: + result = client.close() + if inspect.isawaitable(result): + await result + + transport = httpx.MockTransport(backend.respond) + with monkeypatch.context() as patch: + patch.setattr( + httpx, "Client", lambda **kw: sync_http(transport=transport, **kw) + ) + patch.setattr( + httpx, "AsyncClient", lambda **kw: async_http(transport=transport, **kw) + ) + return asyncio.run(invoke()) + + return run + + +@pytest.mark.parametrize("mode", ["ready", "created", "joined", "forced", "detached"]) +def test_resolves_ready_new_and_concurrent_builds(context, resolve, mode): + backend = Backend(ready=mode in ("ready", "forced"), conflict=mode == "joined") + result = resolve( + backend, + context_path=context, + force_build=mode == "forced", + wait=mode != "detached", + ) + assert result.outcome == {"ready": "reused", "joined": "joined"}.get( + mode, "created" + ) + assert result.image_name.startswith("hb__dockerfile__") + assert result.image_id == (None if mode == "detached" else "image-1") + assert backend.polls == int(mode not in ("ready", "detached")) + if mode == "ready": + assert backend.requests == [("GET", "/api/images")] + if mode == "forced": + assert ("GET", "/api/images") not in backend.requests + assert not any(path.endswith("/cancel") for _, path in backend.requests) + + +@pytest.mark.parametrize( + "mismatch", + ["name", "format", "platform", "missing-status", "missing-build", "code"], +) +def test_incompatible_conflicts_are_not_joined(context, resolve, mismatch): + backend = Backend(conflict=True) + + def transform(payload): + if mismatch == "name": + payload["build"]["imageName"] = "unrelated" + elif mismatch == "format": + payload["build"]["metadata"]["inputFormat"] = "docker_image_manifest_v1" + elif mismatch == "platform": + payload["build"]["metadata"]["sourcePlatform"] = "linux/arm64" + elif mismatch == "missing-status": + del payload["build"]["status"] + elif mismatch == "missing-build": + del payload["build"] + else: + payload["code"] = "different_conflict" + return payload + + backend.transform = transform + with pytest.raises(HyperbrowserError) as error: + resolve(backend, context_path=context) + assert error.value.status_code == 409 + assert backend.polls == 0 + + +def test_context_change_during_lookup_fails_before_submission(context, resolve): + backend = Backend() + respond = backend.respond + + def mutate(request): + response = respond(request) + if request.url.path == "/api/images": + (context / "data").write_text("changed") + return response + + backend.respond = mutate + with pytest.raises(DockerBuildContextChangedError): + resolve(backend, context_path=context) + assert backend.requests == [("GET", "/api/images")] + + +@pytest.mark.parametrize( + "uploaded,ready", [(True, None), (False, True), (False, False), (False, None)] +) +def test_ready_lookup_supports_old_servers_and_does_not_reuse_pending_rows( + context, resolve, uploaded, ready +): + backend = Backend(ready=True) + original = backend.respond + + def respond(request): + response = original(request) + if request.url.path == "/api/images": + payload = response.json() + payload["images"][0]["uploaded"] = uploaded + if ready is None: + del payload["images"][0]["ready"] + else: + payload["images"][0]["ready"] = ready + return httpx.Response(200, json=payload) + return response + + backend.respond = respond + result = resolve(backend, context_path=context) + assert result.outcome == ("reused" if uploaded or ready else "created") + + +def test_exact_ready_lookup_searches_later_pages(context, resolve): + backend = Backend(ready=True) + original = backend.respond + pages = [] + + def respond(request): + response = original(request) + if request.url.path == "/api/images": + page = int(request.url.params["page"]) + pages.append(page) + payload = response.json() + payload["totalCount"] = 101 + if page == 1: + template = payload["images"][0] + payload["images"] = [ + dict(template, imageName="unrelated-" + str(i)) for i in range(100) + ] + return httpx.Response(200, json=payload) + return response + + backend.respond = respond + assert resolve(backend, context_path=context).outcome == "reused" + assert pages == [1, 2] + + +@pytest.mark.parametrize("changed", [False, True]) +def test_docker_identity_is_verified_before_import(resolve, monkeypatch, changed): + digest = "sha256:" + "a" * 64 + current = {"Id": digest, "Config": {}} + monkeypatch.setattr( + image_build, "_inspect_docker_image", lambda *args: dict(current) + ) + backend = Backend() + respond = backend.respond + + def lookup(request): + response = respond(request) + if changed and request.url.path == "/api/images": + current["Id"] = "sha256:" + "b" * 64 + return response + + backend.respond = lookup + if changed: + with pytest.raises(RuntimeError, match="Docker image changed"): + resolve(backend, docker_image="local/app:latest") + assert backend.requests == [("GET", "/api/images")] + else: + result = resolve(backend, docker_image="local/app:latest") + assert result.image_name == image_build_name( + source="prebuilt", fingerprint=digest + ) + assert result.image_id == "image-1" + + +def test_identity_separates_content_and_initialization_but_normalizes_dict_order(): + common = dict(source="dockerfile", fingerprint="a" * 64) + default = image_build_name(**common) + variants = [ + {"fingerprint": "b" * 64}, + {"name_prefix": "custom"}, + {"image_init": {"command": "start"}}, + {"image_config_user": "1000"}, + ] + assert all( + image_build_name(**{**common, **variant}) != default for variant in variants + ) + assert image_build_name( + **common, image_init={"env": {"A": "1", "B": "2"}} + ) == image_build_name( + **common, image_init=SandboxImageInit(env={"B": "2", "A": "1"}) + ) + assert len(image_build_name(**common, name_prefix="x" * 21)) <= 64 + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"context_path": ".", "docker_image": "image"}, + {"context_path": ".", "expected_image_digest": "sha256:" + "a" * 64}, + {"docker_image": "image", "expected_context_fingerprint": "a" * 64}, + {"docker_image": "image", "remote_full_context": True}, + ], +) +def test_invalid_source_options_fail_without_http(resolve, kwargs): + backend = Backend() + with pytest.raises(ValueError): + resolve(backend, **kwargs) + assert backend.requests == [] + + +@pytest.mark.parametrize("leave", ["cancel-one", "timeout-one", "cancel-both"]) +@pytest.mark.anyio +async def test_independent_async_waiters_never_cancel_accepted_backend_build( + context, monkeypatch, leave +): + backend = Backend() + polls = asyncio.Queue() + release = asyncio.Event() + + async def respond(request): + if request.method == "GET" and request.url.path == "/api/images/builds/build-1": + backend.requests.append((request.method, request.url.path)) + polls.put_nowait(None) + # Let the SDK enforce the short caller's polling deadline. + if leave == "timeout-one" and not release.is_set(): + await asyncio.sleep(0.01) + return httpx.Response(200, json={"build": backend.build()}) + await release.wait() + return httpx.Response(200, json={"build": backend.build("completed")}) + response = backend.respond(request) + if request.method == "POST" and request.url.path == "/api/images/builds": + backend.conflict = True + return response + + original = httpx.AsyncClient + monkeypatch.setattr( + httpx, + "AsyncClient", + lambda **kw: original(transport=httpx.MockTransport(respond), **kw), + ) + async with AsyncHyperbrowser( + api_key="local-only", base_url="http://local.test" + ) as client: + callers = [] + try: + callers.append( + asyncio.create_task( + client.sandboxes.get_or_build_image( + context_path=context, + poll_interval=0, + wait_timeout=0 if leave == "timeout-one" else 5, + ) + ) + ) + await asyncio.wait_for(polls.get(), 2) + callers.append( + asyncio.create_task( + client.sandboxes.get_or_build_image( + context_path=context, + poll_interval=0, + wait_timeout=5, + ) + ) + ) + await asyncio.wait_for(polls.get(), 2) + if leave == "timeout-one": + with pytest.raises(TimeoutError): + await callers[0] + else: + callers[0].cancel() + with pytest.raises(asyncio.CancelledError): + await callers[0] + assert not callers[1].done() + if leave == "cancel-both": + callers[1].cancel() + with pytest.raises(asyncio.CancelledError): + await callers[1] + else: + release.set() + result = await asyncio.wait_for(callers[1], 2) + assert result.outcome == "joined" and result.image_id == "image-1" + assert not any(path.endswith("/cancel") for _, path in backend.requests) + assert sum(path.endswith("/complete") for _, path in backend.requests) == 1 + finally: + release.set() + for task in callers: + if not task.done(): + task.cancel() + try: + await task + except (asyncio.CancelledError, TimeoutError): + pass diff --git a/tests/typecheck/invalid_requests.py b/tests/typecheck/invalid_requests.py index 25f19ac6..f0cf6375 100644 --- a/tests/typecheck/invalid_requests.py +++ b/tests/typecheck/invalid_requests.py @@ -30,6 +30,11 @@ def invalid_sync_requests(client: Hyperbrowser) -> None: "builder_cpus": "eight", # M } ) + client.sandboxes.get_or_build_image( + context_path=".", + force_build="yes", # M,P + image_init={"command": 42}, # M,P + ) client.sandboxes.build_image_from_dockerfile( context_path=".", image_name="custom", @@ -57,6 +62,10 @@ async def invalid_async_requests(client: AsyncHyperbrowser) -> None: "builder_memory_mib": "16g", # M } ) + await client.sandboxes.get_or_build_image( + docker_image="local/app:latest", + wait_timeout="600", # M,P + ) await client.sandboxes.build_image_from_dockerfile( context_path=".", image_name="custom", diff --git a/tests/typecheck/valid_requests.py b/tests/typecheck/valid_requests.py index b357b622..f69f1821 100644 --- a/tests/typecheck/valid_requests.py +++ b/tests/typecheck/valid_requests.py @@ -201,6 +201,14 @@ def valid_sync_requests(client: Hyperbrowser) -> None: builder_scratch_mib=65536, ) ) + resolution = client.sandboxes.get_or_build_image( + context_path=".", + image_name_prefix="example", + force_build=False, + image_init={"env": {"MY_SETTING": "value"}}, + wait_timeout=600, + ) + client.sandboxes.find_ready_image(resolution.image_name) client.sandboxes.build_image_from_dockerfile( context_path=".", image_name="custom", @@ -332,6 +340,13 @@ async def valid_async_requests(client: AsyncHyperbrowser) -> None: builder_scratch_mib=65536, ) ) + resolution = await client.sandboxes.get_or_build_image( + docker_image="local/app:latest", + image_name_prefix="example", + wait=False, + expected_image_digest="sha256:" + "a" * 64, + ) + await client.sandboxes.find_ready_image(resolution.image_name) await client.sandboxes.build_image_from_dockerfile( context_path=".", image_name="custom", From 144e913030e89c21b9a6e5133c6ba4adc7d97b21 Mon Sep 17 00:00:00 2001 From: Devin Date: Sat, 26 Sep 2026 21:13:00 -0700 Subject: [PATCH 2/2] Clarify Docker API requirement for image identity --- README.md | 6 ++ .../client/managers/sandboxes/image_build.py | 20 +++++-- tests/test_image_resolution.py | 58 +++++++++++++++++++ tests/test_sandbox_image_build_helpers.py | 28 +++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 665089a4..4375be15 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,12 @@ identity discovery; supply a fresh identity for each resolution request. Changes between identity discovery and packaging are rejected instead of published under the wrong name. Local Docker images must already be available in the daemon. +Automatic local-image identity discovery requires a Docker CLI and Engine +supporting **API 1.49 or newer (Docker 28.1+)** for platform-specific inspection. +Upgrade Docker and check for an older `DOCKER_API_VERSION` override if the helper +reports this requirement. Remote Dockerfile builds do not require local Docker. +The existing explicit-name import method retains its inspection fallback. + `force_build=True` skips ready-image lookup but still joins matching active builds and permits existing layer/artifact caches. Use it to refresh mutable base tags or external Dockerfile downloads. Joining does not change an existing builder's diff --git a/hyperbrowser/client/managers/sandboxes/image_build.py b/hyperbrowser/client/managers/sandboxes/image_build.py index e423681f..0a59beed 100644 --- a/hyperbrowser/client/managers/sandboxes/image_build.py +++ b/hyperbrowser/client/managers/sandboxes/image_build.py @@ -446,10 +446,22 @@ def package_docker_build_context_manifest( def docker_image_digest( docker_image: str, *, platform: str = IMAGE_BUILD_SOURCE_PLATFORM ) -> str: - """Inspect the platform identity without creating temporary resources.""" - return _normalize_sha256_digest( - _inspect_docker_image(docker_image, platform).get("Id") - ) + """Inspect platform identity with Docker API 1.49+, without temporary resources.""" + try: + inspection = _inspect_docker_image(docker_image, platform) + except RuntimeError as error: + message = str(error) + if ( + '"--platform" requires API version' in message + or "unknown flag: --platform" in message + ): + raise RuntimeError( + "Local Docker image imports require a Docker CLI and Engine " + "supporting API 1.49 or newer (Docker 28.1+). Upgrade Docker " + "or remove an older DOCKER_API_VERSION override." + ) from error + raise + return _normalize_sha256_digest(inspection.get("Id")) def prepare_docker_image_manifest_source( diff --git a/tests/test_image_resolution.py b/tests/test_image_resolution.py index e67ee920..057bdfbf 100644 --- a/tests/test_image_resolution.py +++ b/tests/test_image_resolution.py @@ -264,6 +264,64 @@ def lookup(request): assert result.image_id == "image-1" +@pytest.mark.parametrize( + "message,unsupported", + [ + ( + '"--platform" requires API version 1.49, but the Docker daemon API version is 1.48', + True, + ), + ("unknown flag: --platform", True), + ("Error response from daemon: No such image: local/app:latest", False), + ("Cannot connect to the Docker daemon", False), + ], +) +def test_docker_identity_errors_fail_before_http( + resolve, monkeypatch, message, unsupported +): + original = RuntimeError(message) + calls = [] + + def command(args): + calls.append(args) + raise original + + monkeypatch.setattr(image_build, "_run_command_output", command) + backend = Backend() + with pytest.raises(RuntimeError) as error: + resolve(backend, docker_image="local/app:latest") + if unsupported: + assert "API 1.49 or newer (Docker 28.1+)" in str(error.value) + assert "DOCKER_API_VERSION" in str(error.value) + assert error.value.__cause__ is original + else: + assert error.value is original + assert backend.requests == [] + assert len(calls) == 1 + assert calls[0][:3] == ["docker", "image", "inspect"] + + +@pytest.mark.parametrize("source", ["dockerfile", "known-digest"]) +def test_remote_build_and_known_digest_cache_hit_need_no_docker( + context, resolve, monkeypatch, source +): + def command(*args, **kwargs): + pytest.fail("This path must not invoke Docker") + + monkeypatch.setattr(image_build, "_run_command_result", command) + backend = Backend(ready=source == "known-digest") + kwargs = ( + {"context_path": context} + if source == "dockerfile" + else { + "docker_image": "local/app:latest", + "expected_image_digest": "sha256:" + "a" * 64, + } + ) + result = resolve(backend, **kwargs) + assert result.outcome == ("created" if source == "dockerfile" else "reused") + + def test_identity_separates_content_and_initialization_but_normalizes_dict_order(): common = dict(source="dockerfile", fingerprint="a" * 64) default = image_build_name(**common) diff --git a/tests/test_sandbox_image_build_helpers.py b/tests/test_sandbox_image_build_helpers.py index 936e3b90..7998604a 100644 --- a/tests/test_sandbox_image_build_helpers.py +++ b/tests/test_sandbox_image_build_helpers.py @@ -501,6 +501,34 @@ def test_remote_context_preserves_external_and_broken_symlink_metadata(tmp_path) packaged.cleanup() +def test_existing_import_source_retains_container_fallback(monkeypatch): + digest = "sha256:" + "a" * 64 + calls = [] + + def command(args, **kwargs): + calls.append(args) + if args[1:3] == ["image", "inspect"]: + raise RuntimeError('"--platform" requires API version 1.49') + if args[1] == "create": + output = "stopped-container" + elif args[1:3] == ["container", "inspect"]: + output = '{"User":"1000"}' if args[4] == "{{json .Config}}" else digest + else: + assert args[1:] == ["rm", "-f", "stopped-container"] + output = "" + return subprocess.CompletedProcess(args, 0, stdout=output, stderr="") + + monkeypatch.setattr(image_build, "_run_command_result", command) + source = image_build.prepare_docker_image_manifest_source("local/app:latest") + try: + assert source.image_digest == digest + assert source.config == {"User": "1000"} + assert not any(args[1] == "rm" for args in calls) + finally: + source.cleanup() + assert calls[-1] == ["docker", "rm", "-f", "stopped-container"] + + def test_package_docker_image_manifest_preserves_reusable_layers( monkeypatch, tmp_path,