diff --git a/README.md b/README.md index 00134565..4375be15 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,56 @@ 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. + +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 +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..0a59beed 100644 --- a/hyperbrowser/client/managers/sandboxes/image_build.py +++ b/hyperbrowser/client/managers/sandboxes/image_build.py @@ -443,6 +443,27 @@ def package_docker_build_context_manifest( raise +def docker_image_digest( + docker_image: str, *, platform: str = IMAGE_BUILD_SOURCE_PLATFORM +) -> str: + """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( 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..057bdfbf --- /dev/null +++ b/tests/test_image_resolution.py @@ -0,0 +1,443 @@ +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" + + +@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) + 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/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, 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",