66from pathlib import PurePosixPath
77from typing import Dict , List , Optional
88
9+ import gpuhunt
910from cachetools import TTLCache , cached
1011
1112from dstack ._internal import settings
5556from dstack ._internal .server .services .docker import (
5657 ImageConfig ,
5758 apply_server_docker_defaults ,
58- get_image_config ,
59+ get_image_config_and_cpu_architectures ,
5960)
6061from dstack ._internal .utils import crypto
6162from dstack ._internal .utils .common import run_async
6970DSTACK_DIR = "/dstack"
7071DSTACK_PROFILE_PATH = f"{ DSTACK_DIR } /profile"
7172
73+ # A non-existent image name used to signal that the image registry must never be requested
74+ # and some dummy defaults should be used instead.
75+ # As a job with such an image cannot be started, this special value only makes sense
76+ # when used for offer collection (via `/runs/get_plan` with `for_offers_only`), not
77+ # regular run planning/submission.
78+ # Specifying a single "magic" value is still hacky but better than requiring clients to set
79+ # an ever-growing list of optional configuration fields such as `commands`/`entrypoint`,
80+ # `user`, `resources.cpu.arch`.
81+ # In addition, it has a special effect on `resources.cpu.arch` -- unlike unset image,
82+ # which defaults the arch to x86-only (as the default dstack image doesn't support ARM),
83+ # this dummy image leaves the arch unset.
84+ DUMMY_IMAGE_NAME = "scratch"
85+
7286
7387def get_default_python_verison () -> str :
7488 version_info = sys .version_info
@@ -98,6 +112,7 @@ class JobConfigurator(ABC):
98112 TYPE : RunConfigurationType
99113
100114 _image_config : Optional [ImageConfig ] = None
115+ _image_cpu_architectures : Optional [set [gpuhunt .CPUArchitecture ]] = None
101116 # JobSSHKey should be shared for all jobs in a replica for inter-node communication.
102117 _job_ssh_key : Optional [JobSSHKey ] = None
103118
@@ -139,8 +154,17 @@ def _ports(self) -> List[PortMapping]:
139154 pass
140155
141156 async def _get_image_config (self ) -> ImageConfig :
157+ image_config , _ = await self ._get_image_config_and_cpu_architectures ()
158+ return image_config
159+
160+ async def _get_image_config_and_cpu_architectures (
161+ self ,
162+ ) -> tuple [ImageConfig , set [gpuhunt .CPUArchitecture ]]:
142163 if self ._image_config is not None :
143- return self ._image_config
164+ assert self ._image_cpu_architectures is not None
165+ return self ._image_config , self ._image_cpu_architectures
166+ image_name = self ._image_name ()
167+ assert image_name != DUMMY_IMAGE_NAME
144168 interpolate = VariablesInterpolator ({"secrets" : self .secrets }).interpolate_or_error
145169 registry_auth = self .run_spec .configuration .registry_auth
146170 if registry_auth is not None :
@@ -151,14 +175,15 @@ async def _get_image_config(self) -> ImageConfig:
151175 )
152176 except InterpolatorError as e :
153177 raise ServerClientError (e .args [0 ])
154- image_name , registry_auth = apply_server_docker_defaults (self . _image_name () , registry_auth )
155- image_config = await run_async (
156- _get_image_config ,
178+ image_name , registry_auth = apply_server_docker_defaults (image_name , registry_auth )
179+ image_config , cpu_architectures = await run_async (
180+ _get_image_config_and_cpu_architectures ,
157181 image_name ,
158182 registry_auth ,
159183 )
160184 self ._image_config = image_config
161- return image_config
185+ self ._image_cpu_architectures = cpu_architectures
186+ return image_config , cpu_architectures
162187
163188 async def _get_job_spec (
164189 self ,
@@ -184,7 +209,7 @@ async def _get_job_spec(
184209 stop_duration = self ._stop_duration (),
185210 utilization_policy = self ._utilization_policy (),
186211 registry_auth = self ._registry_auth (),
187- requirements = self ._requirements (jobs_per_replica ),
212+ requirements = await self ._requirements (jobs_per_replica ),
188213 retry = self ._retry (),
189214 working_dir = self ._working_dir (),
190215 volumes = self ._volumes (job_num ),
@@ -219,6 +244,9 @@ async def _commands(self) -> List[str]:
219244 entrypoint = [self ._shell (), "-i" , "-c" ]
220245 dstack_image_commands = self ._dstack_image_commands ()
221246 commands = [_join_shell_commands (dstack_image_commands + shell_commands )]
247+ elif self ._image_name () == DUMMY_IMAGE_NAME :
248+ entrypoint = []
249+ commands = [":" ]
222250 else : # custom docker image without commands
223251 image_config = await self ._get_image_config ()
224252 entrypoint = image_config .entrypoint or []
@@ -299,6 +327,8 @@ def _image_name(self) -> str:
299327 async def _user (self ) -> Optional [UnixUser ]:
300328 user = self .run_spec .configuration .user
301329 if user is None and self .run_spec .configuration .image is not None :
330+ if self .run_spec .configuration .image == DUMMY_IMAGE_NAME :
331+ return None
302332 image_config = await self ._get_image_config ()
303333 user = image_config .user
304334 if user is None :
@@ -335,13 +365,29 @@ def _utilization_policy(self) -> Optional[UtilizationPolicy]:
335365 def _registry_auth (self ) -> Optional [RegistryAuth ]:
336366 return self .run_spec .configuration .registry_auth
337367
338- def _requirements (self , jobs_per_replica : int ) -> Requirements :
368+ async def _requirements (self , jobs_per_replica : int ) -> Requirements :
339369 resources = self .run_spec .configuration .resources
370+ image = self .run_spec .configuration .image
340371 if self .run_spec .configuration .type == "service" :
341372 for group in self .run_spec .configuration .replica_groups :
342373 if group .name == self .replica_group_name :
343374 resources = group .resources
375+ if group .image is not None :
376+ image = group .image
344377 break
378+ resources = resources .model_copy (deep = True )
379+ if resources .cpu .arch is None and image != DUMMY_IMAGE_NAME :
380+ if image is None :
381+ # dstackai/base or dstackai/dind image, both don't support ARM
382+ resources .cpu .arch = gpuhunt .CPUArchitecture .X86
383+ else :
384+ _ , cpu_architectures = await self ._get_image_config_and_cpu_architectures ()
385+ if len (cpu_architectures ) == 1 :
386+ resources .cpu .arch = next (iter (cpu_architectures ))
387+ # len(cpu_architectures) > 1 => multi-arch image, keep CPUSpec.arch unset.
388+ # In the requirements, unset arch means "any architecture supported by the
389+ # image", unlike the run configuration, where unset arch means "not specified,
390+ # resolve it here"
345391 spot_policy = self ._spot_policy ()
346392 return Requirements (
347393 resources = resources ,
@@ -514,10 +560,15 @@ def _join_shell_commands(commands: List[str]) -> str:
514560 cache = TTLCache (maxsize = 2048 , ttl = 80 ),
515561 lock = threading .Lock (),
516562)
517- def _get_image_config (image : str , registry_auth : Optional [RegistryAuth ]) -> ImageConfig :
563+ def _get_image_config_and_cpu_architectures (
564+ image : str , registry_auth : Optional [RegistryAuth ]
565+ ) -> tuple [ImageConfig , set [gpuhunt .CPUArchitecture ]]:
518566 try :
519- return get_image_config (image , registry_auth ).config
567+ image_config , cpu_architectures = get_image_config_and_cpu_architectures (
568+ image , registry_auth
569+ )
520570 except DockerRegistryError as e :
521571 raise ServerClientError (
522572 f"Error pulling configuration for image { image !r} from the docker registry: { e } "
523573 )
574+ return image_config .config , cpu_architectures
0 commit comments