11import asyncio
22import functools
33import time
4+ from pathlib import Path
45from typing import Dict , Optional , Union
56
67from ..._request import coerce_request , dump_request
1516 SandboxExposeParams ,
1617 SandboxExposeResult ,
1718 SandboxImageBuild ,
19+ SandboxImageBuildResolution ,
20+ SandboxImageSummary ,
1821 SandboxImageBuildCreateResult ,
1922 SandboxDockerImageReuseResult ,
2023 SandboxImageBuildListParams ,
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+ )
6674from ..sandboxes .shared import (
6775 _build_sandbox_exposed_url ,
6876 _copy_model ,
6977 _expires_within_buffer ,
7078)
7179from ..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
0 commit comments