Skip to content
Merged
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
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
172 changes: 172 additions & 0 deletions hyperbrowser/client/managers/async_manager/sandbox.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,6 +16,8 @@
SandboxExposeParams,
SandboxExposeResult,
SandboxImageBuild,
SandboxImageBuildResolution,
SandboxImageSummary,
SandboxImageBuildCreateResult,
SandboxDockerImageReuseResult,
SandboxImageBuildListParams,
Expand Down Expand Up @@ -63,13 +66,20 @@
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,
_expires_within_buffer,
)
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,
Expand Down Expand Up @@ -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[
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions hyperbrowser/client/managers/sandboxes/image_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down
Loading