Skip to content

Commit 8b431f3

Browse files
authored
Add image build-or-wait helpers (#130)
* Add content-based image build or wait helpers * Clarify Docker API requirement for image identity
1 parent f6150e3 commit 8b431f3

13 files changed

Lines changed: 1031 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,56 @@ Image listings distinguish `ready` from `uploaded`: a completed team image can
388388
be ready to launch before its durability backup is uploaded. `ready` is `None`
389389
when talking to an older server. Keep the returned image ID to pin that revision.
390390

391+
### Reuse an image or join a build
392+
393+
`get_or_build_image` provides the same operation on sync and async clients. Give
394+
it either a remote Dockerfile context or a local Docker image. It derives a name
395+
from the input identity and image initialization options, reuses a ready team
396+
image, or submits a build and joins a compatible concurrent build automatically.
397+
The optional prefix is a namespace, not a fixed image alias: different inputs
398+
produce different names under the same prefix.
399+
400+
```python
401+
resolved = client.sandboxes.get_or_build_image(
402+
context_path="./app", # alternatively: docker_image="local/app:latest"
403+
image_name_prefix="my-app",
404+
wait_timeout=3600,
405+
)
406+
print(resolved.outcome) # "reused", "joined", or "created"
407+
sandbox = client.sandboxes.create({
408+
"image_name": resolved.image_name,
409+
"image_id": resolved.image_id,
410+
})
411+
```
412+
413+
With `wait=False`, a submitted/joined build is returned as `resolved.build`;
414+
`image_id` is populated only when ready. `find_ready_image(name)` exposes the
415+
exact-name lookup separately. Older servers fall back to uploaded-image reuse.
416+
The public `hyperbrowser.image_builds.image_build_name` helper lets integrations
417+
derive the same name from an existing context fingerprint or Docker image digest.
418+
Passing `expected_context_fingerprint` or `expected_image_digest` avoids repeating
419+
identity discovery; supply a fresh identity for each resolution request. Changes
420+
between identity discovery and packaging are rejected instead of published under
421+
the wrong name. Local Docker images must already be available in the daemon.
422+
423+
Automatic local-image identity discovery requires a Docker CLI and Engine
424+
supporting **API 1.49 or newer (Docker 28.1+)** for platform-specific inspection.
425+
Upgrade Docker and check for an older `DOCKER_API_VERSION` override if the helper
426+
reports this requirement. Remote Dockerfile builds do not require local Docker.
427+
The existing explicit-name import method retains its inspection fallback.
428+
429+
`force_build=True` skips ready-image lookup but still joins matching active builds
430+
and permits existing layer/artifact caches. Use it to refresh mutable base tags or
431+
external Dockerfile downloads. Joining does not change an existing builder's
432+
resources. Lookup and creation use separate API calls; if another build completes
433+
between them, an additional revision can be submitted.
434+
435+
Each caller owns its polling timeout. Canceling that wait does not cancel an
436+
accepted backend build. Uploads have a separate inactivity allowance
437+
(`upload_timeout=600` by default), not a total upload-duration limit. The existing
438+
`build_image_from_dockerfile` and `build_image_from_docker_image` methods retain
439+
their explicit-name behavior and continue to report build conflicts directly.
440+
391441
## License
392442

393443
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

‎hyperbrowser/client/managers/async_manager/sandbox.py‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import functools
33
import time
4+
from pathlib import Path
45
from typing import Dict, Optional, Union
56

67
from ..._request import coerce_request, dump_request
@@ -15,6 +16,8 @@
1516
SandboxExposeParams,
1617
SandboxExposeResult,
1718
SandboxImageBuild,
19+
SandboxImageBuildResolution,
20+
SandboxImageSummary,
1821
SandboxImageBuildCreateResult,
1922
SandboxDockerImageReuseResult,
2023
SandboxImageBuildListParams,
@@ -63,13 +66,20 @@
6366
parse_json_response,
6467
should_retry_get,
6568
)
69+
from ..sandboxes.image_resolution import (
70+
image_build_name,
71+
matching_image_build,
72+
completed_image_id,
73+
)
6674
from ..sandboxes.shared import (
6775
_build_sandbox_exposed_url,
6876
_copy_model,
6977
_expires_within_buffer,
7078
)
7179
from ..sandboxes.image_build import (
7280
IMAGE_BUILD_SOURCE_PLATFORM,
81+
docker_build_context_fingerprint,
82+
docker_image_digest,
7383
build_docker_image_from_dockerfile,
7484
is_terminal_image_build_status,
7585
make_temp_docker_tag,
@@ -459,6 +469,159 @@ async def list_images(
459469
)
460470
return SandboxImageListResponse(**payload)
461471

472+
async def find_ready_image(self, image_name: str) -> Optional[SandboxImageSummary]:
473+
"""Find an exact ready team image, including revisions awaiting backup."""
474+
page = 1
475+
while True:
476+
response = await self.list_images(
477+
SandboxImageListParams(
478+
search=image_name, sources=["team"], page=page, limit=100
479+
)
480+
)
481+
for image in response.images:
482+
if image.image_name == image_name and (
483+
image.uploaded or getattr(image, "ready", False)
484+
):
485+
return image
486+
if len(response.images) < 100:
487+
return None
488+
if response.total_count is not None and page * 100 >= response.total_count:
489+
return None
490+
page += 1
491+
492+
async def get_or_build_image(
493+
self,
494+
*,
495+
context_path: Optional[Union[str, Path]] = None,
496+
docker_image: Optional[str] = None,
497+
image_name_prefix: str = "hb",
498+
dockerfile: str = "Dockerfile",
499+
platform: str = IMAGE_BUILD_SOURCE_PLATFORM,
500+
remote_full_context: bool = False,
501+
expected_context_fingerprint: Optional[str] = None,
502+
expected_image_digest: Optional[str] = None,
503+
image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None,
504+
image_config_user: Optional[str] = None,
505+
builder_cpus: Optional[int] = None,
506+
builder_memory_mib: Optional[int] = None,
507+
builder_scratch_mib: Optional[int] = None,
508+
force_build: bool = False,
509+
wait: bool = True,
510+
poll_interval: float = 3.0,
511+
wait_timeout: Optional[float] = 35 * 60,
512+
upload_timeout: Optional[float] = 600,
513+
temp_dir: Optional[str] = None,
514+
) -> SandboxImageBuildResolution:
515+
"""Reuse, join, or build content-derived remote Dockerfile/image inputs.
516+
517+
Supply exactly one of context_path or docker_image. Names include source
518+
contents, platform and image initialization overrides. force_build skips
519+
ready-image lookup, but joins matching active builds and retains builder
520+
layer/artifact caches. Canceling polling never cancels the backend build.
521+
wait_timeout applies to this caller's polling, independently of uploads.
522+
This composes existing APIs; lookup plus creation is not server-atomic.
523+
"""
524+
platform = platform.strip().lower()
525+
if platform != "linux/amd64":
526+
raise ValueError("Image builds require platform='linux/amd64'")
527+
if (context_path is None) == (docker_image is None):
528+
raise ValueError("Supply exactly one of context_path or docker_image")
529+
if context_path is not None:
530+
if expected_image_digest is not None:
531+
raise ValueError("expected_image_digest requires docker_image")
532+
fingerprint = expected_context_fingerprint
533+
if fingerprint is None:
534+
fingerprint = await _run_blocking(
535+
docker_build_context_fingerprint,
536+
context_path,
537+
dockerfile=dockerfile,
538+
force_full_context=remote_full_context,
539+
)
540+
source = "dockerfile"
541+
input_format = "dockerfile_context_manifest_v1"
542+
else:
543+
if (
544+
expected_context_fingerprint is not None
545+
or remote_full_context
546+
or dockerfile != "Dockerfile"
547+
):
548+
raise ValueError("Dockerfile context options require context_path")
549+
fingerprint = expected_image_digest
550+
if fingerprint is None:
551+
fingerprint = await _run_blocking(
552+
docker_image_digest, docker_image, platform=platform
553+
)
554+
source = "prebuilt"
555+
input_format = "docker_image_manifest_v1"
556+
image_name = image_build_name(
557+
source=source,
558+
fingerprint=fingerprint,
559+
name_prefix=image_name_prefix,
560+
platform=platform,
561+
image_init=image_init,
562+
image_config_user=image_config_user,
563+
)
564+
if not force_build:
565+
image = await self.find_ready_image(image_name)
566+
if image is not None:
567+
return SandboxImageBuildResolution(
568+
outcome="reused",
569+
image_name=image_name,
570+
image_id=image.id,
571+
)
572+
common = dict(
573+
image_name=image_name,
574+
platform=platform,
575+
image_init=image_init,
576+
image_config_user=image_config_user,
577+
builder_cpus=builder_cpus,
578+
builder_memory_mib=builder_memory_mib,
579+
builder_scratch_mib=builder_scratch_mib,
580+
wait=False,
581+
upload_timeout=upload_timeout,
582+
temp_dir=temp_dir,
583+
)
584+
common = {
585+
key: value
586+
for key, value in common.items()
587+
if not (key.startswith("builder_") and value is None)
588+
}
589+
outcome = "created"
590+
try:
591+
if context_path is not None:
592+
build = await self.build_image_from_dockerfile(
593+
context_path=context_path,
594+
dockerfile=dockerfile,
595+
remote=True,
596+
remote_full_context=remote_full_context,
597+
expected_context_fingerprint=fingerprint,
598+
**common,
599+
)
600+
else:
601+
build = await self.build_image_from_docker_image(
602+
docker_image=docker_image,
603+
expected_image_digest=fingerprint,
604+
**common,
605+
)
606+
except HyperbrowserError as error:
607+
existing = matching_image_build(error, image_name, input_format)
608+
if existing is None:
609+
raise
610+
build = existing
611+
outcome = "joined"
612+
if wait and build.status != "completed":
613+
build = await self.wait_for_image_build(
614+
build.id,
615+
poll_interval=poll_interval,
616+
timeout=wait_timeout,
617+
)
618+
return SandboxImageBuildResolution(
619+
outcome=outcome,
620+
image_name=image_name,
621+
image_id=completed_image_id(build),
622+
build=build,
623+
)
624+
462625
async def list_snapshots(
463626
self,
464627
params: Optional[
@@ -582,6 +745,7 @@ async def build_image_from_docker_image(
582745
*,
583746
docker_image: str,
584747
image_name: str,
748+
expected_image_digest: Optional[str] = None,
585749
platform: str = IMAGE_BUILD_SOURCE_PLATFORM,
586750
image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None,
587751
image_config_user: Optional[str] = None,
@@ -600,6 +764,14 @@ async def build_image_from_docker_image(
600764
platform=platform,
601765
)
602766
try:
767+
if (
768+
expected_image_digest is not None
769+
and source.image_digest != expected_image_digest.lower()
770+
):
771+
raise RuntimeError(
772+
"Docker image changed after its cache identity was computed. "
773+
"Retry with a fresh image digest."
774+
)
603775
explicit_image_init = (
604776
coerce_request(image_init, SandboxImageInit, name="image_init")
605777
if image_init is not None

‎hyperbrowser/client/managers/sandboxes/image_build.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,27 @@ def package_docker_build_context_manifest(
443443
raise
444444

445445

446+
def docker_image_digest(
447+
docker_image: str, *, platform: str = IMAGE_BUILD_SOURCE_PLATFORM
448+
) -> str:
449+
"""Inspect platform identity with Docker API 1.49+, without temporary resources."""
450+
try:
451+
inspection = _inspect_docker_image(docker_image, platform)
452+
except RuntimeError as error:
453+
message = str(error)
454+
if (
455+
'"--platform" requires API version' in message
456+
or "unknown flag: --platform" in message
457+
):
458+
raise RuntimeError(
459+
"Local Docker image imports require a Docker CLI and Engine "
460+
"supporting API 1.49 or newer (Docker 28.1+). Upgrade Docker "
461+
"or remove an older DOCKER_API_VERSION override."
462+
) from error
463+
raise
464+
return _normalize_sha256_digest(inspection.get("Id"))
465+
466+
446467
def prepare_docker_image_manifest_source(
447468
docker_image: str,
448469
*,

0 commit comments

Comments
 (0)