Skip to content

Commit 0333453

Browse files
authored
update for remote sandbox builds (#117)
* update for remote sandbox builds * simplify client work * implement dockerfile parser * support colon paths
1 parent d200af2 commit 0333453

17 files changed

Lines changed: 3755 additions & 78 deletions

hyperbrowser/client/managers/async_manager/sandbox.py

Lines changed: 205 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,206 @@ 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 as error:
620+
if error.status_code != 404:
621+
raise
622+
reused = None
623+
if reused is not None and reused.hit:
624+
if reused.build is None:
625+
raise RuntimeError(
626+
"exact image cache response is missing its completed build"
627+
)
628+
return reused.build
629+
630+
packaged = await _run_blocking(
631+
package_docker_image_manifest,
632+
docker_image,
633+
source.image_digest,
634+
source.config,
635+
platform=platform,
636+
temp_dir=temp_dir,
637+
)
638+
build_id = None
639+
build_started = False
640+
try:
641+
artifact = packaged.artifact
642+
create_result = await self.create_image_build(
643+
CreateSandboxImageBuildParams(
644+
image_name=image_name,
645+
input_sha256=artifact.sha256_hex,
646+
input_size_bytes=artifact.size_bytes,
647+
input_format=artifact.input_format,
648+
source_platform=artifact.source_platform,
649+
image_config_user=normalized_image_config_user,
650+
image_init=normalized_image_init,
651+
docker_image_manifest=packaged.manifest,
652+
)
653+
)
654+
build_id = create_result.build.id
655+
await _run_blocking(
656+
upload_missing_image_build_artifacts,
657+
create_result.uploads,
658+
packaged.layers,
659+
label="Docker image layer",
660+
timeout=upload_timeout,
661+
)
662+
build = await self._complete_image_build_resilient(
663+
build_id,
664+
artifact,
665+
)
666+
build_started = True
667+
if wait:
668+
return await self.wait_for_image_build(
669+
build.id,
670+
poll_interval=poll_interval,
671+
timeout=wait_timeout,
672+
)
673+
return build
674+
except Exception:
675+
if build_id is not None and not build_started:
676+
try:
677+
await self.cancel_image_build(build_id)
678+
except Exception:
679+
pass
680+
raise
681+
finally:
682+
await _run_blocking(packaged.cleanup)
683+
finally:
684+
await _run_blocking(source.cleanup)
685+
686+
async def _complete_image_build_resilient(
687+
self,
688+
build_id: str,
689+
artifact,
690+
) -> SandboxImageBuild:
691+
params = CompleteSandboxImageBuildParams(
692+
input_sha256=artifact.sha256_hex,
693+
input_size_bytes=artifact.size_bytes,
694+
input_format=artifact.input_format,
695+
)
696+
try:
697+
return await self.complete_image_build(build_id, params)
698+
except HyperbrowserError as exc:
699+
if exc.status_code != 409:
700+
raise
701+
if "already in progress" in str(exc).lower():
702+
return await self.get_image_build(build_id)
703+
current = await self.get_image_build(build_id)
704+
if current.status not in ("awaiting_upload", "upload_verified"):
705+
raise
706+
await asyncio.sleep(2)
707+
return await self.complete_image_build(build_id, params)
708+
709+
async def _build_image_from_remote_dockerfile(
710+
self,
711+
*,
712+
context_path,
713+
image_name: str,
714+
dockerfile,
715+
platform: str,
716+
remote_full_context: bool,
717+
image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]],
718+
image_config_user: Optional[str],
719+
wait: bool,
720+
poll_interval: float,
721+
wait_timeout: Optional[float],
722+
temp_dir: Optional[str],
723+
upload_timeout: Optional[float],
724+
) -> SandboxImageBuild:
725+
packaged = await _run_blocking(
726+
package_docker_build_context_manifest,
727+
context_path,
728+
dockerfile=dockerfile,
729+
force_full_context=remote_full_context,
573730
temp_dir=temp_dir,
574731
)
732+
build_id = None
733+
build_started = False
575734
try:
576735
normalized_image_init = (
577736
coerce_request(image_init, SandboxImageInit, name="image_init")
578737
if image_init is not None
579-
else artifact.image_init
738+
else None
580739
)
740+
artifact = packaged.artifact
581741
create_result = await self.create_image_build(
582742
CreateSandboxImageBuildParams(
583743
image_name=image_name,
584744
input_sha256=artifact.sha256_hex,
585745
input_size_bytes=artifact.size_bytes,
586746
input_format=artifact.input_format,
587747
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-
),
748+
dockerfile_path=packaged.manifest.dockerfile_path,
749+
image_config_user=image_config_user,
593750
image_init=normalized_image_init,
751+
context_manifest=packaged.manifest,
594752
)
595753
)
754+
build_id = create_result.build.id
596755
await _run_blocking(
597-
upload_image_build_artifact,
598-
create_result.upload,
599-
artifact.path,
756+
upload_missing_image_build_artifacts,
757+
create_result.uploads,
758+
packaged.bundles,
759+
label="build context bundle",
600760
timeout=upload_timeout,
601761
)
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-
)
762+
build = await self._complete_image_build_resilient(build_id, artifact)
763+
build_started = True
610764
if wait:
611765
return await self.wait_for_image_build(
612766
build.id,
613767
poll_interval=poll_interval,
614768
timeout=wait_timeout,
615769
)
616770
return build
771+
except Exception:
772+
if build_id is not None and not build_started:
773+
try:
774+
await self.cancel_image_build(build_id)
775+
except Exception:
776+
pass
777+
raise
617778
finally:
618-
artifact.cleanup()
779+
await _run_blocking(packaged.cleanup)
619780

620781
async def build_image_from_dockerfile(
621782
self,
622783
*,
623784
context_path,
624785
image_name: str,
625786
dockerfile="Dockerfile",
787+
remote: bool = True,
788+
remote_full_context: bool = False,
626789
docker_tag: Optional[str] = None,
627790
platform: str = IMAGE_BUILD_SOURCE_PLATFORM,
628791
build_args: Optional[Dict[str, str]] = None,
@@ -634,6 +797,26 @@ async def build_image_from_dockerfile(
634797
temp_dir: Optional[str] = None,
635798
upload_timeout: Optional[float] = None,
636799
) -> SandboxImageBuild:
800+
if remote:
801+
if docker_tag is not None or build_args:
802+
raise ValueError(
803+
"docker_tag and build_args require remote=False; remote "
804+
"Dockerfile builds send the build context to Hyperbrowser"
805+
)
806+
return await self._build_image_from_remote_dockerfile(
807+
context_path=context_path,
808+
image_name=image_name,
809+
dockerfile=dockerfile,
810+
platform=platform,
811+
remote_full_context=remote_full_context,
812+
image_init=image_init,
813+
image_config_user=image_config_user,
814+
wait=wait,
815+
poll_interval=poll_interval,
816+
wait_timeout=wait_timeout,
817+
temp_dir=temp_dir,
818+
upload_timeout=upload_timeout,
819+
)
637820
tag = docker_tag or make_temp_docker_tag()
638821
remove_tag = docker_tag is None
639822
try:

0 commit comments

Comments
 (0)