Skip to content

Commit a1f39d4

Browse files
committed
update for remote sandbox builds
1 parent d200af2 commit a1f39d4

13 files changed

Lines changed: 2196 additions & 79 deletions

File tree

hyperbrowser/client/managers/async_manager/sandbox.py

Lines changed: 203 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
CompleteSandboxImageBuildParams,
1010
CreateSandboxParams,
1111
CreateSandboxImageBuildParams,
12+
ReuseSandboxDockerImageParams,
1213
SandboxDetail,
1314
SandboxExecParams,
1415
SandboxExposeParams,
1516
SandboxExposeResult,
1617
SandboxImageBuild,
1718
SandboxImageBuildCreateResult,
19+
SandboxDockerImageReuseResult,
1820
SandboxImageBuildListParams,
1921
SandboxImageBuildListResponse,
2022
SandboxImageListParams,
@@ -39,6 +41,7 @@
3941
from ....types import (
4042
CompleteSandboxImageBuildParams as CompleteSandboxImageBuildParamsDict,
4143
CreateSandboxImageBuildParams as CreateSandboxImageBuildParamsDict,
44+
ReuseSandboxDockerImageParams as ReuseSandboxDockerImageParamsDict,
4245
CreateSandboxParams as CreateSandboxParamsDict,
4346
SandboxExecParams as SandboxExecParamsDict,
4447
SandboxExposeParams as SandboxExposeParamsDict,
@@ -67,9 +70,12 @@
6770
build_docker_image_from_dockerfile,
6871
is_terminal_image_build_status,
6972
make_temp_docker_tag,
70-
package_docker_image,
73+
merge_image_init,
74+
package_docker_build_context_manifest,
75+
package_docker_image_manifest,
76+
prepare_docker_image_manifest_source,
7177
remove_docker_image,
72-
upload_image_build_artifact,
78+
upload_missing_image_build_artifacts,
7379
)
7480
from .sandboxes.sandbox_files import (
7581
DEFAULT_WATCH_TIMEOUT_MS,
@@ -494,6 +500,20 @@ async def get_image_build(self, build_id: str) -> SandboxImageBuild:
494500
payload = await self._request("GET", f"/images/builds/{build_id}")
495501
return SandboxImageBuild(**payload["build"])
496502

503+
async def reuse_docker_image(
504+
self,
505+
params: Union[
506+
ReuseSandboxDockerImageParamsDict,
507+
ReuseSandboxDockerImageParams,
508+
],
509+
) -> SandboxDockerImageReuseResult:
510+
payload = await self._request(
511+
"POST",
512+
"/images/builds/reuse",
513+
data=dump_request(params, ReuseSandboxDockerImageParams),
514+
)
515+
return SandboxDockerImageReuseResult(**payload)
516+
497517
async def complete_image_build(
498518
self,
499519
build_id: str,
@@ -566,63 +586,204 @@ async def build_image_from_docker_image(
566586
temp_dir: Optional[str] = None,
567587
upload_timeout: Optional[float] = None,
568588
) -> SandboxImageBuild:
569-
artifact = await _run_blocking(
570-
package_docker_image,
589+
source = await _run_blocking(
590+
prepare_docker_image_manifest_source,
571591
docker_image,
572592
platform=platform,
593+
)
594+
try:
595+
explicit_image_init = (
596+
coerce_request(image_init, SandboxImageInit, name="image_init")
597+
if image_init is not None
598+
else None
599+
)
600+
normalized_image_init = merge_image_init(
601+
source.image_init,
602+
explicit_image_init,
603+
)
604+
normalized_image_config_user = (
605+
image_config_user
606+
if image_config_user is not None
607+
else source.image_config_user
608+
)
609+
try:
610+
reused = await self.reuse_docker_image(
611+
ReuseSandboxDockerImageParams(
612+
image_name=image_name,
613+
source_image_digest=source.image_digest,
614+
source_platform=platform,
615+
image_config_user=normalized_image_config_user,
616+
image_init=normalized_image_init,
617+
)
618+
)
619+
except HyperbrowserError:
620+
reused = None
621+
if reused is not None and reused.hit:
622+
if reused.build is None:
623+
raise RuntimeError(
624+
"exact image cache response is missing its completed build"
625+
)
626+
return reused.build
627+
628+
packaged = await _run_blocking(
629+
package_docker_image_manifest,
630+
docker_image,
631+
source.image_digest,
632+
source.config,
633+
platform=platform,
634+
temp_dir=temp_dir,
635+
)
636+
build_id = None
637+
build_started = False
638+
try:
639+
artifact = packaged.artifact
640+
create_result = await self.create_image_build(
641+
CreateSandboxImageBuildParams(
642+
image_name=image_name,
643+
input_sha256=artifact.sha256_hex,
644+
input_size_bytes=artifact.size_bytes,
645+
input_format=artifact.input_format,
646+
source_platform=artifact.source_platform,
647+
image_config_user=normalized_image_config_user,
648+
image_init=normalized_image_init,
649+
docker_image_manifest=packaged.manifest,
650+
)
651+
)
652+
build_id = create_result.build.id
653+
await _run_blocking(
654+
upload_missing_image_build_artifacts,
655+
create_result.uploads,
656+
packaged.layers,
657+
label="Docker image layer",
658+
timeout=upload_timeout,
659+
)
660+
build = await self._complete_image_build_resilient(
661+
build_id,
662+
artifact,
663+
)
664+
build_started = True
665+
if wait:
666+
return await self.wait_for_image_build(
667+
build.id,
668+
poll_interval=poll_interval,
669+
timeout=wait_timeout,
670+
)
671+
return build
672+
except Exception:
673+
if build_id is not None and not build_started:
674+
try:
675+
await self.cancel_image_build(build_id)
676+
except Exception:
677+
pass
678+
raise
679+
finally:
680+
await _run_blocking(packaged.cleanup)
681+
finally:
682+
await _run_blocking(source.cleanup)
683+
684+
async def _complete_image_build_resilient(
685+
self,
686+
build_id: str,
687+
artifact,
688+
) -> SandboxImageBuild:
689+
params = CompleteSandboxImageBuildParams(
690+
input_sha256=artifact.sha256_hex,
691+
input_size_bytes=artifact.size_bytes,
692+
input_format=artifact.input_format,
693+
)
694+
try:
695+
return await self.complete_image_build(build_id, params)
696+
except HyperbrowserError as exc:
697+
if exc.status_code != 409:
698+
raise
699+
if "already in progress" in str(exc).lower():
700+
return await self.get_image_build(build_id)
701+
current = await self.get_image_build(build_id)
702+
if current.status not in ("awaiting_upload", "upload_verified"):
703+
raise
704+
await asyncio.sleep(2)
705+
return await self.complete_image_build(build_id, params)
706+
707+
async def _build_image_from_remote_dockerfile(
708+
self,
709+
*,
710+
context_path,
711+
image_name: str,
712+
dockerfile,
713+
platform: str,
714+
remote_full_context: bool,
715+
image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]],
716+
image_config_user: Optional[str],
717+
wait: bool,
718+
poll_interval: float,
719+
wait_timeout: Optional[float],
720+
temp_dir: Optional[str],
721+
upload_timeout: Optional[float],
722+
) -> SandboxImageBuild:
723+
packaged = await _run_blocking(
724+
package_docker_build_context_manifest,
725+
context_path,
726+
dockerfile=dockerfile,
727+
force_full_context=remote_full_context,
573728
temp_dir=temp_dir,
574729
)
730+
build_id = None
731+
build_started = False
575732
try:
576733
normalized_image_init = (
577734
coerce_request(image_init, SandboxImageInit, name="image_init")
578735
if image_init is not None
579-
else artifact.image_init
736+
else None
580737
)
738+
artifact = packaged.artifact
581739
create_result = await self.create_image_build(
582740
CreateSandboxImageBuildParams(
583741
image_name=image_name,
584742
input_sha256=artifact.sha256_hex,
585743
input_size_bytes=artifact.size_bytes,
586744
input_format=artifact.input_format,
587745
source_platform=artifact.source_platform,
588-
image_config_user=(
589-
image_config_user
590-
if image_config_user is not None
591-
else artifact.image_config_user
592-
),
746+
dockerfile_path=packaged.manifest.dockerfile_path,
747+
image_config_user=image_config_user,
593748
image_init=normalized_image_init,
749+
context_manifest=packaged.manifest,
594750
)
595751
)
752+
build_id = create_result.build.id
596753
await _run_blocking(
597-
upload_image_build_artifact,
598-
create_result.upload,
599-
artifact.path,
754+
upload_missing_image_build_artifacts,
755+
create_result.uploads,
756+
packaged.bundles,
757+
label="build context bundle",
600758
timeout=upload_timeout,
601759
)
602-
build = await self.complete_image_build(
603-
create_result.build.id,
604-
CompleteSandboxImageBuildParams(
605-
input_sha256=artifact.sha256_hex,
606-
input_size_bytes=artifact.size_bytes,
607-
input_format=artifact.input_format,
608-
),
609-
)
760+
build = await self._complete_image_build_resilient(build_id, artifact)
761+
build_started = True
610762
if wait:
611763
return await self.wait_for_image_build(
612764
build.id,
613765
poll_interval=poll_interval,
614766
timeout=wait_timeout,
615767
)
616768
return build
769+
except Exception:
770+
if build_id is not None and not build_started:
771+
try:
772+
await self.cancel_image_build(build_id)
773+
except Exception:
774+
pass
775+
raise
617776
finally:
618-
artifact.cleanup()
777+
await _run_blocking(packaged.cleanup)
619778

620779
async def build_image_from_dockerfile(
621780
self,
622781
*,
623782
context_path,
624783
image_name: str,
625784
dockerfile="Dockerfile",
785+
remote: bool = True,
786+
remote_full_context: bool = False,
626787
docker_tag: Optional[str] = None,
627788
platform: str = IMAGE_BUILD_SOURCE_PLATFORM,
628789
build_args: Optional[Dict[str, str]] = None,
@@ -634,6 +795,26 @@ async def build_image_from_dockerfile(
634795
temp_dir: Optional[str] = None,
635796
upload_timeout: Optional[float] = None,
636797
) -> SandboxImageBuild:
798+
if remote:
799+
if docker_tag is not None or build_args:
800+
raise ValueError(
801+
"docker_tag and build_args require remote=False; remote "
802+
"Dockerfile builds send the build context to Hyperbrowser"
803+
)
804+
return await self._build_image_from_remote_dockerfile(
805+
context_path=context_path,
806+
image_name=image_name,
807+
dockerfile=dockerfile,
808+
platform=platform,
809+
remote_full_context=remote_full_context,
810+
image_init=image_init,
811+
image_config_user=image_config_user,
812+
wait=wait,
813+
poll_interval=poll_interval,
814+
wait_timeout=wait_timeout,
815+
temp_dir=temp_dir,
816+
upload_timeout=upload_timeout,
817+
)
637818
tag = docker_tag or make_temp_docker_tag()
638819
remove_tag = docker_tag is None
639820
try:

0 commit comments

Comments
 (0)