Skip to content

Commit 33f7080

Browse files
committed
add networking policy interface and image build
1 parent 65143b4 commit 33f7080

16 files changed

Lines changed: 2098 additions & 56 deletions

File tree

hyperbrowser/client/managers/async_manager/sandbox.py

Lines changed: 237 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
1+
import asyncio
2+
import functools
3+
import time
14
from typing import Dict, Optional, Union
25

36
from ....exceptions import HyperbrowserError
47
from ....models.sandbox import (
8+
CompleteSandboxImageBuildParams,
59
CreateSandboxParams,
10+
CreateSandboxImageBuildParams,
611
SandboxDetail,
712
SandboxExecParams,
813
SandboxExposeParams,
914
SandboxExposeResult,
15+
SandboxImageBuild,
16+
SandboxImageBuildCreateResult,
17+
SandboxImageListParams,
1018
SandboxImageListResponse,
19+
SandboxImageInit,
1120
SandboxListParams,
1221
SandboxListResponse,
1322
SandboxMemorySnapshotParams,
1423
SandboxMemorySnapshotResult,
24+
SandboxNetworkPolicy,
25+
SandboxNetworkUpdateResult,
1526
SandboxRuntimeSession,
1627
SandboxSnapshotListParams,
1728
SandboxSnapshotListResponse,
@@ -30,6 +41,15 @@
3041
_copy_model,
3142
_expires_within_buffer,
3243
)
44+
from ..sandboxes.image_build import (
45+
IMAGE_BUILD_SOURCE_PLATFORM,
46+
build_docker_image_from_dockerfile,
47+
is_terminal_image_build_status,
48+
make_temp_docker_tag,
49+
package_docker_image,
50+
remove_docker_image,
51+
upload_image_build_artifact,
52+
)
3353
from .sandboxes.sandbox_files import (
3454
DEFAULT_WATCH_TIMEOUT_MS,
3555
SandboxFileWatchHandle,
@@ -67,6 +87,12 @@
6787
]
6888

6989

90+
async def _run_blocking(func, *args, **kwargs):
91+
loop = asyncio.get_running_loop()
92+
call = functools.partial(func, *args, **kwargs)
93+
return await loop.run_in_executor(None, call)
94+
95+
7096
class SandboxHandle:
7197
def __init__(self, service: "SandboxManager", detail: SandboxDetail):
7298
self._service = service
@@ -130,6 +156,10 @@ def disk_mib(self):
130156
def exposed_ports(self):
131157
return self._detail.exposed_ports
132158

159+
@property
160+
def network(self):
161+
return self._detail.network
162+
133163
def to_dict(self):
134164
return self._detail.model_dump()
135165

@@ -166,6 +196,25 @@ async def create_memory_snapshot(
166196
raise TypeError("params must be a SandboxMemorySnapshotParams instance")
167197
return await self._service.create_memory_snapshot(self.id, normalized)
168198

199+
async def update_network(
200+
self,
201+
policy: SandboxNetworkPolicy,
202+
) -> SandboxNetworkUpdateResult:
203+
if not isinstance(policy, SandboxNetworkPolicy):
204+
raise TypeError("policy must be a SandboxNetworkPolicy instance")
205+
result = await self._service.update_network(self.id, policy)
206+
self._detail = self._detail.model_copy(update={"network": result.network})
207+
return result
208+
209+
async def clear_network(self) -> SandboxNetworkUpdateResult:
210+
return await self.update_network(
211+
SandboxNetworkPolicy(
212+
allow_internet_access=True,
213+
allow_out=[],
214+
deny_out=[],
215+
)
216+
)
217+
169218
async def expose(self, params: SandboxExposeParams) -> SandboxExposeResult:
170219
if not isinstance(params, SandboxExposeParams):
171220
raise TypeError("params must be a SandboxExposeParams instance")
@@ -350,8 +399,16 @@ async def list(
350399
)
351400
return SandboxListResponse(**payload)
352401

353-
async def list_images(self) -> SandboxImageListResponse:
354-
payload = await self._request("GET", "/images")
402+
async def list_images(
403+
self, params: SandboxImageListParams = SandboxImageListParams()
404+
) -> SandboxImageListResponse:
405+
if not isinstance(params, SandboxImageListParams):
406+
raise TypeError("params must be a SandboxImageListParams instance")
407+
payload = await self._request(
408+
"GET",
409+
"/images",
410+
params=params.model_dump(exclude_none=True, by_alias=True),
411+
)
355412
return SandboxImageListResponse(**payload)
356413

357414
async def list_snapshots(
@@ -370,6 +427,170 @@ async def stop(self, sandbox_id: str) -> BasicResponse:
370427
payload = await self._request("PUT", f"/sandbox/{sandbox_id}/stop")
371428
return BasicResponse(**payload)
372429

430+
async def create_image_build(
431+
self,
432+
params: CreateSandboxImageBuildParams,
433+
) -> SandboxImageBuildCreateResult:
434+
if not isinstance(params, CreateSandboxImageBuildParams):
435+
raise TypeError("params must be a CreateSandboxImageBuildParams instance")
436+
payload = await self._request(
437+
"POST",
438+
"/images/builds",
439+
data=params.model_dump(exclude_none=True, by_alias=True),
440+
)
441+
return SandboxImageBuildCreateResult(**payload)
442+
443+
async def get_image_build(self, build_id: str) -> SandboxImageBuild:
444+
payload = await self._request("GET", f"/images/builds/{build_id}")
445+
return SandboxImageBuild(**payload["build"])
446+
447+
async def complete_image_build(
448+
self,
449+
build_id: str,
450+
params: CompleteSandboxImageBuildParams,
451+
) -> SandboxImageBuild:
452+
if not isinstance(params, CompleteSandboxImageBuildParams):
453+
raise TypeError("params must be a CompleteSandboxImageBuildParams instance")
454+
payload = await self._request(
455+
"POST",
456+
f"/images/builds/{build_id}/complete",
457+
data=params.model_dump(exclude_none=True, by_alias=True),
458+
)
459+
return SandboxImageBuild(**payload["build"])
460+
461+
async def cancel_image_build(self, build_id: str) -> SandboxImageBuild:
462+
payload = await self._request("POST", f"/images/builds/{build_id}/cancel")
463+
return SandboxImageBuild(**payload["build"])
464+
465+
async def wait_for_image_build(
466+
self,
467+
build_id: str,
468+
*,
469+
poll_interval: float = 3.0,
470+
timeout: Optional[float] = 35 * 60,
471+
) -> SandboxImageBuild:
472+
deadline = None if timeout is None else time.monotonic() + timeout
473+
while True:
474+
build = await self.get_image_build(build_id)
475+
if build.status == "completed":
476+
return build
477+
if build.status == "failed":
478+
message = build.error_message or "image build failed"
479+
if build.error_code:
480+
message = f"image build failed [{build.error_code}]: {message}"
481+
raise RuntimeError(message)
482+
if is_terminal_image_build_status(build.status):
483+
raise RuntimeError(f"image build {build.status}")
484+
if deadline is not None and time.monotonic() >= deadline:
485+
raise TimeoutError(f"timed out waiting for image build {build_id}")
486+
await asyncio.sleep(poll_interval)
487+
488+
async def build_image_from_docker_image(
489+
self,
490+
*,
491+
docker_image: str,
492+
image_name: str,
493+
platform: str = IMAGE_BUILD_SOURCE_PLATFORM,
494+
image_init: Optional[SandboxImageInit] = None,
495+
image_config_user: Optional[str] = None,
496+
wait: bool = True,
497+
poll_interval: float = 3.0,
498+
wait_timeout: Optional[float] = 35 * 60,
499+
temp_dir: Optional[str] = None,
500+
upload_timeout: Optional[float] = None,
501+
) -> SandboxImageBuild:
502+
artifact = await _run_blocking(
503+
package_docker_image,
504+
docker_image,
505+
platform=platform,
506+
temp_dir=temp_dir,
507+
)
508+
try:
509+
create_result = await self.create_image_build(
510+
CreateSandboxImageBuildParams(
511+
image_name=image_name,
512+
input_sha256=artifact.sha256_hex,
513+
input_size_bytes=artifact.size_bytes,
514+
input_format=artifact.input_format,
515+
source_platform=artifact.source_platform,
516+
image_config_user=(
517+
image_config_user
518+
if image_config_user is not None
519+
else artifact.image_config_user
520+
),
521+
image_init=image_init
522+
if image_init is not None
523+
else artifact.image_init,
524+
)
525+
)
526+
await _run_blocking(
527+
upload_image_build_artifact,
528+
create_result.upload,
529+
artifact.path,
530+
timeout=upload_timeout,
531+
)
532+
build = await self.complete_image_build(
533+
create_result.build.id,
534+
CompleteSandboxImageBuildParams(
535+
input_sha256=artifact.sha256_hex,
536+
input_size_bytes=artifact.size_bytes,
537+
input_format=artifact.input_format,
538+
),
539+
)
540+
if wait:
541+
return await self.wait_for_image_build(
542+
build.id,
543+
poll_interval=poll_interval,
544+
timeout=wait_timeout,
545+
)
546+
return build
547+
finally:
548+
artifact.cleanup()
549+
550+
async def build_image_from_dockerfile(
551+
self,
552+
*,
553+
context_path,
554+
image_name: str,
555+
dockerfile="Dockerfile",
556+
docker_tag: Optional[str] = None,
557+
platform: str = IMAGE_BUILD_SOURCE_PLATFORM,
558+
build_args: Optional[Dict[str, str]] = None,
559+
image_init: Optional[SandboxImageInit] = None,
560+
image_config_user: Optional[str] = None,
561+
wait: bool = True,
562+
poll_interval: float = 3.0,
563+
wait_timeout: Optional[float] = 35 * 60,
564+
temp_dir: Optional[str] = None,
565+
upload_timeout: Optional[float] = None,
566+
) -> SandboxImageBuild:
567+
tag = docker_tag or make_temp_docker_tag()
568+
remove_tag = docker_tag is None
569+
try:
570+
await _run_blocking(
571+
build_docker_image_from_dockerfile,
572+
context_path=context_path,
573+
dockerfile=dockerfile,
574+
tag=tag,
575+
platform=platform,
576+
build_args=build_args,
577+
)
578+
return await self.build_image_from_docker_image(
579+
docker_image=tag,
580+
image_name=image_name,
581+
platform=platform,
582+
image_init=image_init,
583+
image_config_user=image_config_user,
584+
wait=wait,
585+
poll_interval=poll_interval,
586+
wait_timeout=wait_timeout,
587+
temp_dir=temp_dir,
588+
upload_timeout=upload_timeout,
589+
)
590+
finally:
591+
if remove_tag:
592+
await _run_blocking(remove_docker_image, tag)
593+
373594
async def get_runtime_session(self, sandbox_id: str) -> SandboxRuntimeSession:
374595
detail = await self.get_detail(sandbox_id)
375596
session = SandboxHandle._to_runtime_session(detail)
@@ -404,6 +625,20 @@ async def create_memory_snapshot(
404625
)
405626
return SandboxMemorySnapshotResult(**payload)
406627

628+
async def update_network(
629+
self,
630+
sandbox_id: str,
631+
policy: SandboxNetworkPolicy,
632+
) -> SandboxNetworkUpdateResult:
633+
if not isinstance(policy, SandboxNetworkPolicy):
634+
raise TypeError("policy must be a SandboxNetworkPolicy instance")
635+
payload = await self._request(
636+
"PUT",
637+
f"/sandbox/{sandbox_id}/network",
638+
data=policy.model_dump(exclude_none=True, by_alias=True),
639+
)
640+
return SandboxNetworkUpdateResult(**payload)
641+
407642
async def expose(
408643
self,
409644
sandbox_id: str,

0 commit comments

Comments
 (0)