dstack 0.19.27__py3-none-any.whl → 0.19.29__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dstack might be problematic. Click here for more details.

Files changed (74) hide show
  1. dstack/_internal/cli/commands/__init__.py +11 -8
  2. dstack/_internal/cli/commands/apply.py +6 -3
  3. dstack/_internal/cli/commands/completion.py +3 -1
  4. dstack/_internal/cli/commands/config.py +1 -0
  5. dstack/_internal/cli/commands/init.py +2 -2
  6. dstack/_internal/cli/commands/offer.py +1 -1
  7. dstack/_internal/cli/commands/project.py +1 -0
  8. dstack/_internal/cli/commands/server.py +2 -2
  9. dstack/_internal/cli/main.py +1 -1
  10. dstack/_internal/cli/services/configurators/base.py +2 -4
  11. dstack/_internal/cli/services/configurators/fleet.py +4 -5
  12. dstack/_internal/cli/services/configurators/gateway.py +3 -5
  13. dstack/_internal/cli/services/configurators/run.py +51 -27
  14. dstack/_internal/cli/services/configurators/volume.py +3 -5
  15. dstack/_internal/core/backends/aws/compute.py +51 -36
  16. dstack/_internal/core/backends/azure/compute.py +10 -7
  17. dstack/_internal/core/backends/base/compute.py +96 -14
  18. dstack/_internal/core/backends/base/offers.py +34 -4
  19. dstack/_internal/core/backends/cloudrift/compute.py +5 -7
  20. dstack/_internal/core/backends/cudo/compute.py +4 -2
  21. dstack/_internal/core/backends/datacrunch/compute.py +13 -11
  22. dstack/_internal/core/backends/digitalocean_base/compute.py +4 -5
  23. dstack/_internal/core/backends/gcp/compute.py +12 -7
  24. dstack/_internal/core/backends/hotaisle/compute.py +4 -7
  25. dstack/_internal/core/backends/kubernetes/compute.py +6 -4
  26. dstack/_internal/core/backends/lambdalabs/compute.py +4 -5
  27. dstack/_internal/core/backends/local/compute.py +1 -3
  28. dstack/_internal/core/backends/nebius/compute.py +10 -7
  29. dstack/_internal/core/backends/oci/compute.py +10 -7
  30. dstack/_internal/core/backends/runpod/compute.py +15 -6
  31. dstack/_internal/core/backends/template/compute.py.jinja +3 -1
  32. dstack/_internal/core/backends/tensordock/compute.py +1 -3
  33. dstack/_internal/core/backends/tensordock/models.py +2 -0
  34. dstack/_internal/core/backends/vastai/compute.py +7 -3
  35. dstack/_internal/core/backends/vultr/compute.py +5 -5
  36. dstack/_internal/core/compatibility/runs.py +2 -0
  37. dstack/_internal/core/models/common.py +67 -43
  38. dstack/_internal/core/models/configurations.py +88 -62
  39. dstack/_internal/core/models/fleets.py +41 -24
  40. dstack/_internal/core/models/instances.py +5 -5
  41. dstack/_internal/core/models/profiles.py +66 -47
  42. dstack/_internal/core/models/projects.py +8 -0
  43. dstack/_internal/core/models/repos/remote.py +21 -16
  44. dstack/_internal/core/models/resources.py +69 -65
  45. dstack/_internal/core/models/runs.py +17 -9
  46. dstack/_internal/server/app.py +5 -0
  47. dstack/_internal/server/background/tasks/process_fleets.py +8 -0
  48. dstack/_internal/server/background/tasks/process_instances.py +3 -2
  49. dstack/_internal/server/background/tasks/process_submitted_jobs.py +97 -34
  50. dstack/_internal/server/models.py +6 -5
  51. dstack/_internal/server/schemas/gateways.py +10 -9
  52. dstack/_internal/server/services/backends/__init__.py +1 -1
  53. dstack/_internal/server/services/backends/handlers.py +2 -0
  54. dstack/_internal/server/services/docker.py +8 -7
  55. dstack/_internal/server/services/projects.py +63 -4
  56. dstack/_internal/server/services/runs.py +2 -0
  57. dstack/_internal/server/settings.py +46 -0
  58. dstack/_internal/server/statics/index.html +1 -1
  59. dstack/_internal/server/statics/main-56191fbfe77f49b251de.css +3 -0
  60. dstack/_internal/server/statics/{main-4eecc75fbe64067eb1bc.js → main-c51afa7f243e24d3e446.js} +61115 -49101
  61. dstack/_internal/server/statics/{main-4eecc75fbe64067eb1bc.js.map → main-c51afa7f243e24d3e446.js.map} +1 -1
  62. dstack/_internal/utils/env.py +85 -11
  63. dstack/version.py +1 -1
  64. {dstack-0.19.27.dist-info → dstack-0.19.29.dist-info}/METADATA +1 -1
  65. {dstack-0.19.27.dist-info → dstack-0.19.29.dist-info}/RECORD +68 -73
  66. dstack/_internal/core/backends/tensordock/__init__.py +0 -0
  67. dstack/_internal/core/backends/tensordock/api_client.py +0 -104
  68. dstack/_internal/core/backends/tensordock/backend.py +0 -16
  69. dstack/_internal/core/backends/tensordock/configurator.py +0 -74
  70. dstack/_internal/server/statics/main-56191c63d516fd0041c4.css +0 -3
  71. dstack/_internal/server/statics/static/media/github.1f7102513534c83a9d8d735d2b8c12a2.svg +0 -3
  72. {dstack-0.19.27.dist-info → dstack-0.19.29.dist-info}/WHEEL +0 -0
  73. {dstack-0.19.27.dist-info → dstack-0.19.29.dist-info}/entry_points.txt +0 -0
  74. {dstack-0.19.27.dist-info → dstack-0.19.29.dist-info}/licenses/LICENSE.md +0 -0
@@ -5,7 +5,7 @@ import uuid
5
5
  from datetime import datetime, timedelta
6
6
  from typing import List, Optional, Tuple
7
7
 
8
- from sqlalchemy import and_, not_, or_, select
8
+ from sqlalchemy import and_, func, not_, or_, select
9
9
  from sqlalchemy.ext.asyncio import AsyncSession
10
10
  from sqlalchemy.orm import contains_eager, joinedload, load_only, noload, selectinload
11
11
 
@@ -54,6 +54,7 @@ from dstack._internal.server.models import (
54
54
  from dstack._internal.server.services.backends import get_project_backend_by_type_or_error
55
55
  from dstack._internal.server.services.fleets import (
56
56
  fleet_model_to_fleet,
57
+ generate_fleet_name,
57
58
  get_fleet_requirements,
58
59
  get_next_instance_num,
59
60
  )
@@ -71,7 +72,7 @@ from dstack._internal.server.services.jobs import (
71
72
  get_job_configured_volumes,
72
73
  get_job_runtime_data,
73
74
  )
74
- from dstack._internal.server.services.locking import get_locker
75
+ from dstack._internal.server.services.locking import get_locker, string_to_lock_id
75
76
  from dstack._internal.server.services.logging import fmt
76
77
  from dstack._internal.server.services.offers import get_offers_by_requirements
77
78
  from dstack._internal.server.services.requirements.combine import (
@@ -87,7 +88,6 @@ from dstack._internal.server.services.volumes import (
87
88
  )
88
89
  from dstack._internal.server.utils import sentry_utils
89
90
  from dstack._internal.utils import common as common_utils
90
- from dstack._internal.utils import env as env_utils
91
91
  from dstack._internal.utils.logging import get_logger
92
92
 
93
93
  logger = get_logger(__name__)
@@ -188,6 +188,7 @@ async def _process_submitted_job(session: AsyncSession, job_model: JobModel):
188
188
  run_spec = run.run_spec
189
189
  profile = run_spec.merged_profile
190
190
  job = find_job(run.jobs, job_model.replica_num, job_model.job_num)
191
+ multinode = job.job_spec.jobs_per_replica > 1
191
192
 
192
193
  # Master job chooses fleet for the run.
193
194
  # Due to two-step processing, it's saved to job_model.fleet.
@@ -288,7 +289,8 @@ async def _process_submitted_job(session: AsyncSession, job_model: JobModel):
288
289
  instance_filters=instance_filters,
289
290
  )
290
291
  fleet_models = fleet_models_with_instances + fleet_models_without_instances
291
- fleet_model, fleet_instances_with_offers = _find_optimal_fleet_with_offers(
292
+ fleet_model, fleet_instances_with_offers = await _find_optimal_fleet_with_offers(
293
+ project=project,
292
294
  fleet_models=fleet_models,
293
295
  run_model=run_model,
294
296
  run_spec=run.run_spec,
@@ -310,6 +312,7 @@ async def _process_submitted_job(session: AsyncSession, job_model: JobModel):
310
312
  session=session,
311
313
  instances_with_offers=fleet_instances_with_offers,
312
314
  job_model=job_model,
315
+ multinode=multinode,
313
316
  )
314
317
  job_model.fleet = fleet_model
315
318
  job_model.instance_assigned = True
@@ -363,7 +366,8 @@ async def _process_submitted_job(session: AsyncSession, job_model: JobModel):
363
366
  job_model.job_provisioning_data = job_provisioning_data.json()
364
367
  job_model.status = JobStatus.PROVISIONING
365
368
  if fleet_model is None:
366
- fleet_model = _create_fleet_model_for_job(
369
+ fleet_model = await _create_fleet_model_for_job(
370
+ session=session,
367
371
  project=project,
368
372
  run=run,
369
373
  )
@@ -385,7 +389,7 @@ async def _process_submitted_job(session: AsyncSession, job_model: JobModel):
385
389
  offer=offer,
386
390
  instance_num=instance_num,
387
391
  )
388
- job_model.job_runtime_data = _prepare_job_runtime_data(offer).json()
392
+ job_model.job_runtime_data = _prepare_job_runtime_data(offer, multinode).json()
389
393
  # Both this task and process_fleets can add instances to fleets.
390
394
  # TODO: Ensure this does not violate nodes.max when it's enforced.
391
395
  instance.fleet_id = fleet_model.id
@@ -489,7 +493,8 @@ async def _refetch_fleet_models_with_instances(
489
493
  return fleet_models
490
494
 
491
495
 
492
- def _find_optimal_fleet_with_offers(
496
+ async def _find_optimal_fleet_with_offers(
497
+ project: ProjectModel,
493
498
  fleet_models: list[FleetModel],
494
499
  run_model: RunModel,
495
500
  run_spec: RunSpec,
@@ -499,58 +504,99 @@ def _find_optimal_fleet_with_offers(
499
504
  ) -> tuple[Optional[FleetModel], list[tuple[InstanceModel, InstanceOfferWithAvailability]]]:
500
505
  if run_model.fleet is not None:
501
506
  # Using the fleet that was already chosen by the master job
502
- fleet_instances_with_offers = _get_fleet_instances_with_offers(
507
+ fleet_instances_with_pool_offers = _get_fleet_instances_with_pool_offers(
503
508
  fleet_model=run_model.fleet,
504
509
  run_spec=run_spec,
505
510
  job=job,
506
511
  master_job_provisioning_data=master_job_provisioning_data,
507
512
  volumes=volumes,
508
513
  )
509
- return run_model.fleet, fleet_instances_with_offers
514
+ return run_model.fleet, fleet_instances_with_pool_offers
510
515
 
511
516
  if len(fleet_models) == 0:
512
517
  return None, []
513
518
 
514
519
  nodes_required_num = _get_nodes_required_num_for_run(run_spec)
515
- # The current strategy is to first consider fleets that can accommodate
516
- # the run without additional provisioning and choose the one with the cheapest offer.
517
- # Fallback to fleet with the cheapest offer among all fleets with offers.
520
+ # The current strategy is first to consider fleets that can accommodate
521
+ # the run without additional provisioning and choose the one with the cheapest pool offer.
522
+ # Then choose a fleet with the cheapest pool offer among all fleets with pool offers.
523
+ # If there are no fleets with pool offers, choose a fleet with a cheapest backend offer.
524
+ # Fallback to autocreated fleet if fleets have no pool or backend offers.
525
+ # TODO: Consider trying all backend offers and then choosing a fleet.
518
526
  candidate_fleets_with_offers: list[
519
527
  tuple[
520
528
  Optional[FleetModel],
521
529
  list[tuple[InstanceModel, InstanceOfferWithAvailability]],
522
530
  int,
523
- tuple[int, float],
531
+ int,
532
+ tuple[int, float, float],
524
533
  ]
525
534
  ] = []
526
535
  for candidate_fleet_model in fleet_models:
527
- fleet_instances_with_offers = _get_fleet_instances_with_offers(
536
+ fleet_instances_with_pool_offers = _get_fleet_instances_with_pool_offers(
528
537
  fleet_model=candidate_fleet_model,
529
538
  run_spec=run_spec,
530
539
  job=job,
531
540
  master_job_provisioning_data=master_job_provisioning_data,
532
541
  volumes=volumes,
533
542
  )
534
- fleet_available_offers = [
535
- o for _, o in fleet_instances_with_offers if o.availability.is_available()
536
- ]
537
- fleet_has_available_capacity = nodes_required_num <= len(fleet_available_offers)
538
- fleet_cheapest_offer = math.inf
539
- if len(fleet_available_offers) > 0:
540
- fleet_cheapest_offer = fleet_available_offers[0].price
541
- fleet_priority = (not fleet_has_available_capacity, fleet_cheapest_offer)
543
+ fleet_has_available_capacity = nodes_required_num <= len(fleet_instances_with_pool_offers)
544
+ fleet_cheapest_pool_offer = math.inf
545
+ if len(fleet_instances_with_pool_offers) > 0:
546
+ fleet_cheapest_pool_offer = fleet_instances_with_pool_offers[0][1].price
547
+
548
+ candidate_fleet = fleet_model_to_fleet(candidate_fleet_model)
549
+ profile = combine_fleet_and_run_profiles(
550
+ candidate_fleet.spec.merged_profile, run_spec.merged_profile
551
+ )
552
+ fleet_requirements = get_fleet_requirements(candidate_fleet.spec)
553
+ requirements = combine_fleet_and_run_requirements(
554
+ fleet_requirements, job.job_spec.requirements
555
+ )
556
+ multinode = (
557
+ candidate_fleet.spec.configuration.placement == InstanceGroupPlacement.CLUSTER
558
+ or job.job_spec.jobs_per_replica > 1
559
+ )
560
+ fleet_backend_offers = []
561
+ if (
562
+ _check_can_create_new_instance_in_fleet(candidate_fleet)
563
+ and profile is not None
564
+ and requirements is not None
565
+ ):
566
+ fleet_backend_offers = await get_offers_by_requirements(
567
+ project=project,
568
+ profile=profile,
569
+ requirements=requirements,
570
+ exclude_not_available=True,
571
+ multinode=multinode,
572
+ master_job_provisioning_data=master_job_provisioning_data,
573
+ volumes=volumes,
574
+ privileged=job.job_spec.privileged,
575
+ instance_mounts=check_run_spec_requires_instance_mounts(run_spec),
576
+ )
577
+
578
+ fleet_cheapest_backend_offer = math.inf
579
+ if len(fleet_backend_offers) > 0:
580
+ fleet_cheapest_backend_offer = fleet_backend_offers[0][1].price
581
+
582
+ fleet_priority = (
583
+ not fleet_has_available_capacity,
584
+ fleet_cheapest_pool_offer,
585
+ fleet_cheapest_backend_offer,
586
+ )
542
587
  candidate_fleets_with_offers.append(
543
588
  (
544
589
  candidate_fleet_model,
545
- fleet_instances_with_offers,
546
- len(fleet_available_offers),
590
+ fleet_instances_with_pool_offers,
591
+ len(fleet_instances_with_pool_offers),
592
+ len(fleet_backend_offers),
547
593
  fleet_priority,
548
594
  )
549
595
  )
550
596
  if run_spec.merged_profile.fleets is None and all(
551
- t[2] == 0 for t in candidate_fleets_with_offers
597
+ t[2] == 0 and t[3] == 0 for t in candidate_fleets_with_offers
552
598
  ):
553
- # If fleets are not specified and no fleets have available offers, create a new fleet.
599
+ # If fleets are not specified and no fleets have available pool or backend offers, create a new fleet.
554
600
  # This is for compatibility with non-fleet-first UX when runs created new fleets
555
601
  # if there are no instances to reuse.
556
602
  return None, []
@@ -570,7 +616,7 @@ def _get_nodes_required_num_for_run(run_spec: RunSpec) -> int:
570
616
  return nodes_required_num
571
617
 
572
618
 
573
- def _get_fleet_instances_with_offers(
619
+ def _get_fleet_instances_with_pool_offers(
574
620
  fleet_model: FleetModel,
575
621
  run_spec: RunSpec,
576
622
  job: Job,
@@ -614,6 +660,7 @@ async def _assign_job_to_fleet_instance(
614
660
  session: AsyncSession,
615
661
  instances_with_offers: list[tuple[InstanceModel, InstanceOfferWithAvailability]],
616
662
  job_model: JobModel,
663
+ multinode: bool,
617
664
  ) -> Optional[InstanceModel]:
618
665
  if len(instances_with_offers) == 0:
619
666
  return None
@@ -643,7 +690,7 @@ async def _assign_job_to_fleet_instance(
643
690
  job_model.instance = instance
644
691
  job_model.used_instance_id = instance.id
645
692
  job_model.job_provisioning_data = instance.job_provisioning_data
646
- job_model.job_runtime_data = _prepare_job_runtime_data(offer).json()
693
+ job_model.job_runtime_data = _prepare_job_runtime_data(offer, multinode).json()
647
694
  return instance
648
695
 
649
696
 
@@ -752,7 +799,8 @@ def _check_can_create_new_instance_in_fleet(fleet: Fleet) -> bool:
752
799
  return True
753
800
 
754
801
 
755
- def _create_fleet_model_for_job(
802
+ async def _create_fleet_model_for_job(
803
+ session: AsyncSession,
756
804
  project: ProjectModel,
757
805
  run: Run,
758
806
  ) -> FleetModel:
@@ -760,9 +808,19 @@ def _create_fleet_model_for_job(
760
808
  if run.run_spec.configuration.type == "task" and run.run_spec.configuration.nodes > 1:
761
809
  placement = InstanceGroupPlacement.CLUSTER
762
810
  nodes = _get_nodes_required_num_for_run(run.run_spec)
811
+
812
+ lock_namespace = f"fleet_names_{project.name}"
813
+ # TODO: Lock fleet names on SQLite.
814
+ # Needs some refactoring so that the lock is released after commit.
815
+ if get_db().dialect_name == "postgresql":
816
+ await session.execute(
817
+ select(func.pg_advisory_xact_lock(string_to_lock_id(lock_namespace)))
818
+ )
819
+ fleet_name = await generate_fleet_name(session=session, project=project)
820
+
763
821
  spec = FleetSpec(
764
822
  configuration=FleetConfiguration(
765
- name=run.run_spec.run_name,
823
+ name=fleet_name,
766
824
  placement=placement,
767
825
  reservation=run.run_spec.configuration.reservation,
768
826
  nodes=FleetNodesSpec(
@@ -776,7 +834,7 @@ def _create_fleet_model_for_job(
776
834
  )
777
835
  fleet_model = FleetModel(
778
836
  id=uuid.uuid4(),
779
- name=run.run_spec.run_name,
837
+ name=fleet_name,
780
838
  project=project,
781
839
  status=FleetStatus.ACTIVE,
782
840
  spec=spec.json(),
@@ -839,12 +897,17 @@ def _create_instance_model_for_job(
839
897
  return instance
840
898
 
841
899
 
842
- def _prepare_job_runtime_data(offer: InstanceOfferWithAvailability) -> JobRuntimeData:
900
+ def _prepare_job_runtime_data(
901
+ offer: InstanceOfferWithAvailability, multinode: bool
902
+ ) -> JobRuntimeData:
843
903
  if offer.blocks == offer.total_blocks:
844
- if env_utils.get_bool("DSTACK_FORCE_BRIDGE_NETWORK"):
904
+ if settings.JOB_NETWORK_MODE == settings.JobNetworkMode.FORCED_BRIDGE:
845
905
  network_mode = NetworkMode.BRIDGE
846
- else:
906
+ elif settings.JOB_NETWORK_MODE == settings.JobNetworkMode.HOST_WHEN_POSSIBLE:
847
907
  network_mode = NetworkMode.HOST
908
+ else:
909
+ assert settings.JOB_NETWORK_MODE == settings.JobNetworkMode.HOST_FOR_MULTINODE_ONLY
910
+ network_mode = NetworkMode.HOST if multinode else NetworkMode.BRIDGE
848
911
  return JobRuntimeData(
849
912
  network_mode=network_mode,
850
913
  offer=offer,
@@ -24,7 +24,7 @@ from sqlalchemy_utils import UUIDType
24
24
 
25
25
  from dstack._internal.core.errors import DstackError
26
26
  from dstack._internal.core.models.backends.base import BackendType
27
- from dstack._internal.core.models.common import CoreModel
27
+ from dstack._internal.core.models.common import CoreConfig, generate_dual_core_model
28
28
  from dstack._internal.core.models.fleets import FleetStatus
29
29
  from dstack._internal.core.models.gateways import GatewayStatus
30
30
  from dstack._internal.core.models.health import HealthStatus
@@ -71,7 +71,11 @@ class NaiveDateTime(TypeDecorator):
71
71
  return value.replace(tzinfo=timezone.utc)
72
72
 
73
73
 
74
- class DecryptedString(CoreModel):
74
+ class DecryptedStringConfig(CoreConfig):
75
+ arbitrary_types_allowed = True
76
+
77
+
78
+ class DecryptedString(generate_dual_core_model(DecryptedStringConfig)):
75
79
  """
76
80
  A type for representing plaintext strings encrypted with `EncryptedString`.
77
81
  Besides the string, stores information if the decryption was successful.
@@ -84,9 +88,6 @@ class DecryptedString(CoreModel):
84
88
  decrypted: bool = True
85
89
  exc: Optional[Exception] = None
86
90
 
87
- class Config(CoreModel.Config):
88
- arbitrary_types_allowed = True
89
-
90
91
  def get_plaintext_or_error(self) -> str:
91
92
  if self.decrypted and self.plaintext is not None:
92
93
  return self.plaintext
@@ -3,24 +3,25 @@ from typing import Annotated, Any, Dict, List, Optional
3
3
  from pydantic import Field
4
4
 
5
5
  from dstack._internal.core.models.backends.base import BackendType
6
- from dstack._internal.core.models.common import CoreModel
6
+ from dstack._internal.core.models.common import CoreConfig, CoreModel, generate_dual_core_model
7
7
  from dstack._internal.core.models.gateways import GatewayConfiguration
8
8
 
9
9
 
10
- class CreateGatewayRequest(CoreModel):
10
+ class CreateGatewayRequestConfig(CoreConfig):
11
+ @staticmethod
12
+ def schema_extra(schema: Dict[str, Any]):
13
+ del schema["properties"]["name"]
14
+ del schema["properties"]["backend_type"]
15
+ del schema["properties"]["region"]
16
+
17
+
18
+ class CreateGatewayRequest(generate_dual_core_model(CreateGatewayRequestConfig)):
11
19
  configuration: GatewayConfiguration
12
20
  # Deprecated and unused. Left for compatibility with 0.18 clients.
13
21
  name: Annotated[Optional[str], Field(exclude=True)] = None
14
22
  backend_type: Annotated[Optional[BackendType], Field(exclude=True)] = None
15
23
  region: Annotated[Optional[str], Field(exclude=True)] = None
16
24
 
17
- class Config(CoreModel.Config):
18
- @staticmethod
19
- def schema_extra(schema: Dict[str, Any]) -> None:
20
- del schema["properties"]["name"]
21
- del schema["properties"]["backend_type"]
22
- del schema["properties"]["region"]
23
-
24
25
 
25
26
  class GetGatewayRequest(CoreModel):
26
27
  name: str
@@ -345,7 +345,7 @@ async def get_instance_offers(
345
345
  Returns list of instances satisfying minimal resource requirements sorted by price
346
346
  """
347
347
  logger.info("Requesting instance offers from backends: %s", [b.TYPE.value for b in backends])
348
- tasks = [run_async(backend.compute().get_offers_cached, requirements) for backend in backends]
348
+ tasks = [run_async(backend.compute().get_offers, requirements) for backend in backends]
349
349
  offers_by_backend = []
350
350
  for backend, result in zip(backends, await asyncio.gather(*tasks, return_exceptions=True)):
351
351
  if isinstance(result, BackendError):
@@ -20,6 +20,8 @@ async def delete_backends_safe(
20
20
  error: bool = True,
21
21
  ):
22
22
  try:
23
+ # FIXME: The checks are not under lock,
24
+ # so there can be dangling active resources due to race conditions.
23
25
  await _check_active_instances(
24
26
  session=session,
25
27
  project=project,
@@ -9,7 +9,11 @@ from pydantic import Field, ValidationError, validator
9
9
  from typing_extensions import Annotated
10
10
 
11
11
  from dstack._internal.core.errors import DockerRegistryError
12
- from dstack._internal.core.models.common import CoreModel, RegistryAuth
12
+ from dstack._internal.core.models.common import (
13
+ CoreModel,
14
+ FrozenCoreModel,
15
+ RegistryAuth,
16
+ )
13
17
  from dstack._internal.server.utils.common import join_byte_stream_checked
14
18
  from dstack._internal.utils.dxf import PatchedDXF
15
19
 
@@ -31,15 +35,12 @@ class DXFAuthAdapter:
31
35
  )
32
36
 
33
37
 
34
- class DockerImage(CoreModel):
38
+ class DockerImage(FrozenCoreModel):
35
39
  image: str
36
- registry: Optional[str]
40
+ registry: Optional[str] = None
37
41
  repo: str
38
42
  tag: str
39
- digest: Optional[str]
40
-
41
- class Config(CoreModel.Config):
42
- frozen = True
43
+ digest: Optional[str] = None
43
44
 
44
45
 
45
46
  class ImageConfig(CoreModel):
@@ -13,9 +13,22 @@ from dstack._internal.core.backends.dstack.models import (
13
13
  )
14
14
  from dstack._internal.core.backends.models import BackendInfo
15
15
  from dstack._internal.core.errors import ForbiddenError, ResourceExistsError, ServerClientError
16
- from dstack._internal.core.models.projects import Member, MemberPermissions, Project
16
+ from dstack._internal.core.models.projects import (
17
+ Member,
18
+ MemberPermissions,
19
+ Project,
20
+ ProjectHookConfig,
21
+ )
22
+ from dstack._internal.core.models.runs import RunStatus
17
23
  from dstack._internal.core.models.users import GlobalRole, ProjectRole
18
- from dstack._internal.server.models import MemberModel, ProjectModel, UserModel
24
+ from dstack._internal.server.models import (
25
+ FleetModel,
26
+ MemberModel,
27
+ ProjectModel,
28
+ RunModel,
29
+ UserModel,
30
+ VolumeModel,
31
+ )
19
32
  from dstack._internal.server.schemas.projects import MemberSetting
20
33
  from dstack._internal.server.services import users
21
34
  from dstack._internal.server.services.backends import (
@@ -112,6 +125,7 @@ async def create_project(
112
125
  user: UserModel,
113
126
  project_name: str,
114
127
  is_public: bool = False,
128
+ config: Optional[ProjectHookConfig] = None,
115
129
  ) -> Project:
116
130
  user_permissions = users.get_user_permissions(user)
117
131
  if not user_permissions.can_create_projects:
@@ -139,7 +153,7 @@ async def create_project(
139
153
  session=session, project_name=project_name
140
154
  )
141
155
  for hook in _CREATE_PROJECT_HOOKS:
142
- await hook(session, project_model)
156
+ await hook(session, project_model, config)
143
157
  # a hook may change project
144
158
  session.expire(project_model)
145
159
  project_model = await get_project_model_by_name_or_error(
@@ -178,6 +192,19 @@ async def delete_projects(
178
192
  raise ForbiddenError()
179
193
  if all(name in projects_names for name in user_project_names):
180
194
  raise ServerClientError("Cannot delete the only project")
195
+
196
+ res = await session.execute(
197
+ select(ProjectModel.id).where(ProjectModel.name.in_(projects_names))
198
+ )
199
+ project_ids = res.scalars().all()
200
+ if len(project_ids) != len(projects_names):
201
+ raise ServerClientError("Failed to delete non-existent projects")
202
+
203
+ for project_id in project_ids:
204
+ # FIXME: The checks are not under lock,
205
+ # so there can be dangling active resources due to race conditions.
206
+ await _check_project_has_active_resources(session=session, project_id=project_id)
207
+
181
208
  timestamp = str(int(get_current_datetime().timestamp()))
182
209
  new_project_name = "_deleted_" + timestamp + ProjectModel.name
183
210
  await session.execute(
@@ -588,7 +615,9 @@ def get_member_permissions(member_model: MemberModel) -> MemberPermissions:
588
615
  _CREATE_PROJECT_HOOKS = []
589
616
 
590
617
 
591
- def register_create_project_hook(func: Callable[[AsyncSession, ProjectModel], Awaitable[None]]):
618
+ def register_create_project_hook(
619
+ func: Callable[[AsyncSession, ProjectModel, Optional[ProjectHookConfig]], Awaitable[None]],
620
+ ):
592
621
  _CREATE_PROJECT_HOOKS.append(func)
593
622
 
594
623
 
@@ -614,6 +643,36 @@ def _is_project_admin(
614
643
  return False
615
644
 
616
645
 
646
+ async def _check_project_has_active_resources(session: AsyncSession, project_id: uuid.UUID):
647
+ res = await session.execute(
648
+ select(RunModel.run_name).where(
649
+ RunModel.project_id == project_id,
650
+ RunModel.status.not_in(RunStatus.finished_statuses()),
651
+ )
652
+ )
653
+ run_names = list(res.scalars().all())
654
+ if len(run_names) > 0:
655
+ raise ServerClientError(f"Failed to delete project with active runs: {run_names}")
656
+ res = await session.execute(
657
+ select(FleetModel.name).where(
658
+ FleetModel.project_id == project_id,
659
+ FleetModel.deleted.is_(False),
660
+ )
661
+ )
662
+ fleet_names = list(res.scalars().all())
663
+ if len(fleet_names) > 0:
664
+ raise ServerClientError(f"Failed to delete project with active fleets: {fleet_names}")
665
+ res = await session.execute(
666
+ select(VolumeModel.name).where(
667
+ VolumeModel.project_id == project_id,
668
+ VolumeModel.deleted.is_(False),
669
+ )
670
+ )
671
+ volume_names = list(res.scalars().all())
672
+ if len(volume_names) > 0:
673
+ raise ServerClientError(f"Failed to delete project with active volumes: {volume_names}")
674
+
675
+
617
676
  async def remove_project_members(
618
677
  session: AsyncSession,
619
678
  user: UserModel,
@@ -1164,6 +1164,8 @@ async def process_terminating_run(session: AsyncSession, run_model: RunModel):
1164
1164
  ):
1165
1165
  run_model.next_triggered_at = _get_next_triggered_at(run.run_spec)
1166
1166
  run_model.status = RunStatus.PENDING
1167
+ # Unassign run from fleet so that the new fleet can be chosen on the next submission
1168
+ run_model.fleet = None
1167
1169
  else:
1168
1170
  run_model.status = run_model.termination_reason.to_status()
1169
1171
 
@@ -4,8 +4,14 @@ Environment variables read by the dstack server. Documented in reference/environ
4
4
 
5
5
  import os
6
6
  import warnings
7
+ from enum import Enum
7
8
  from pathlib import Path
8
9
 
10
+ from dstack._internal.utils.env import environ
11
+ from dstack._internal.utils.logging import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
9
15
  DSTACK_DIR_PATH = Path("~/.dstack/").expanduser()
10
16
 
11
17
  SERVER_DIR_PATH = Path(os.getenv("DSTACK_SERVER_DIR", DSTACK_DIR_PATH / "server"))
@@ -136,3 +142,43 @@ UPDATE_DEFAULT_PROJECT = os.getenv("DSTACK_UPDATE_DEFAULT_PROJECT") is not None
136
142
  DO_NOT_UPDATE_DEFAULT_PROJECT = os.getenv("DSTACK_DO_NOT_UPDATE_DEFAULT_PROJECT") is not None
137
143
  SKIP_GATEWAY_UPDATE = os.getenv("DSTACK_SKIP_GATEWAY_UPDATE") is not None
138
144
  ENABLE_PROMETHEUS_METRICS = os.getenv("DSTACK_ENABLE_PROMETHEUS_METRICS") is not None
145
+
146
+
147
+ class JobNetworkMode(Enum):
148
+ # "host" for multinode runs only, "bridge" otherwise. Opt-in new defaut
149
+ HOST_FOR_MULTINODE_ONLY = 1
150
+ # "bridge" if the job occupies only a part of the instance, "host" otherswise. Current default
151
+ HOST_WHEN_POSSIBLE = 2
152
+ # Always "bridge", even for multinode runs. Same as legacy DSTACK_FORCE_BRIDGE_NETWORK=true
153
+ FORCED_BRIDGE = 3
154
+
155
+
156
+ def _get_job_network_mode() -> JobNetworkMode:
157
+ # Current default
158
+ mode = JobNetworkMode.HOST_WHEN_POSSIBLE
159
+ bridge_var = "DSTACK_FORCE_BRIDGE_NETWORK"
160
+ force_bridge = environ.get_bool(bridge_var)
161
+ mode_var = "DSTACK_SERVER_JOB_NETWORK_MODE"
162
+ mode_from_env = environ.get_enum(mode_var, JobNetworkMode, value_type=int)
163
+ if mode_from_env is not None:
164
+ if force_bridge is not None:
165
+ logger.warning(
166
+ f"{bridge_var} is deprecated since 0.19.27 and ignored when {mode_var} is set"
167
+ )
168
+ return mode_from_env
169
+ if force_bridge is not None:
170
+ if force_bridge:
171
+ mode = JobNetworkMode.FORCED_BRIDGE
172
+ logger.warning(
173
+ (
174
+ f"{bridge_var} is deprecated since 0.19.27."
175
+ f" Set {mode_var} to {mode.value} and remove {bridge_var}"
176
+ )
177
+ )
178
+ else:
179
+ logger.warning(f"{bridge_var} is deprecated since 0.19.27. Remove {bridge_var}")
180
+ return mode
181
+
182
+
183
+ JOB_NETWORK_MODE = _get_job_network_mode()
184
+ del _get_job_network_mode
@@ -1,3 +1,3 @@
1
1
  <!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><title>dstack</title><meta name="description" content="Get GPUs at the best prices and availability from a wide range of providers. No cloud account of your own is required.
2
2
  "/><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet"><meta name="og:title" content="dstack"><meta name="og:type" content="article"><meta name="og:image" content="/splash_thumbnail.png"><meta name="og:description" content="Get GPUs at the best prices and availability from a wide range of providers. No cloud account of your own is required.
3
- "><link rel="icon" type="image/x-icon" href="/assets/favicon.ico"><link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"><link rel="icon" type="image/png" sizes="48x48" href="/assets/favicon-48x48.png"><link rel="manifest" href="/assets/manifest.webmanifest"><meta name="mobile-web-app-capable" content="yes"><meta name="theme-color" content="#fff"><meta name="application-name" content="dstackai"><link rel="apple-touch-icon" sizes="57x57" href="/assets/apple-touch-icon-57x57.png"><link rel="apple-touch-icon" sizes="60x60" href="/assets/apple-touch-icon-60x60.png"><link rel="apple-touch-icon" sizes="72x72" href="/assets/apple-touch-icon-72x72.png"><link rel="apple-touch-icon" sizes="76x76" href="/assets/apple-touch-icon-76x76.png"><link rel="apple-touch-icon" sizes="114x114" href="/assets/apple-touch-icon-114x114.png"><link rel="apple-touch-icon" sizes="120x120" href="/assets/apple-touch-icon-120x120.png"><link rel="apple-touch-icon" sizes="144x144" href="/assets/apple-touch-icon-144x144.png"><link rel="apple-touch-icon" sizes="152x152" href="/assets/apple-touch-icon-152x152.png"><link rel="apple-touch-icon" sizes="167x167" href="/assets/apple-touch-icon-167x167.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-180x180.png"><link rel="apple-touch-icon" sizes="1024x1024" href="/assets/apple-touch-icon-1024x1024.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="dstackai"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-640x1136.png"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1136x640.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-750x1334.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1334x750.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1125x2436.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2436x1125.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1170x2532.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2532x1170.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1179x2556.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2556x1179.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-828x1792.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1792x828.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2688.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2688x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2208.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2208x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1284x2778.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2778x1284.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1290x2796.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2796x1290.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1488x2266.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2266x1488.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1536x2048.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2048x1536.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1620x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1620.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1640x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1640.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2388.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2388x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2224.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2224x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-2048x2732.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2732x2048.png"><meta name="msapplication-TileColor" content="#fff"><meta name="msapplication-TileImage" content="/assets/mstile-144x144.png"><meta name="msapplication-config" content="/assets/browserconfig.xml"><link rel="yandex-tableau-widget" href="/assets/yandex-browser-manifest.json"><script defer="defer" src="/main-4eecc75fbe64067eb1bc.js"></script><link href="/main-56191c63d516fd0041c4.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div class="b-page-header" id="header"></div><div id="root"></div><script async src="https://widget.kapa.ai/kapa-widget.bundle.js" data-website-id="11a9339d-20ce-4ddb-9ba3-1b6e29afe8eb" data-project-name="dstack" data-project-color="rgba(0, 0, 0, 0.87)" data-font-size-lg="0.78rem" data-button-hide="true" data-modal-image="/logo-notext.svg" data-modal-z-index="1100" data-modal-title="Ask me anything" data-project-logo="/assets/images/kapa.svg" data-modal-disclaimer="This is a custom LLM for dstack with access to Documentation, API references and GitHub issues. This feature is experimental - Give it a try!" data-user-analytics-fingerprint-enabled="true"></script></body></html>
3
+ "><link rel="icon" type="image/x-icon" href="/assets/favicon.ico"><link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"><link rel="icon" type="image/png" sizes="48x48" href="/assets/favicon-48x48.png"><link rel="manifest" href="/assets/manifest.webmanifest"><meta name="mobile-web-app-capable" content="yes"><meta name="theme-color" content="#fff"><meta name="application-name" content="dstackai"><link rel="apple-touch-icon" sizes="57x57" href="/assets/apple-touch-icon-57x57.png"><link rel="apple-touch-icon" sizes="60x60" href="/assets/apple-touch-icon-60x60.png"><link rel="apple-touch-icon" sizes="72x72" href="/assets/apple-touch-icon-72x72.png"><link rel="apple-touch-icon" sizes="76x76" href="/assets/apple-touch-icon-76x76.png"><link rel="apple-touch-icon" sizes="114x114" href="/assets/apple-touch-icon-114x114.png"><link rel="apple-touch-icon" sizes="120x120" href="/assets/apple-touch-icon-120x120.png"><link rel="apple-touch-icon" sizes="144x144" href="/assets/apple-touch-icon-144x144.png"><link rel="apple-touch-icon" sizes="152x152" href="/assets/apple-touch-icon-152x152.png"><link rel="apple-touch-icon" sizes="167x167" href="/assets/apple-touch-icon-167x167.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-180x180.png"><link rel="apple-touch-icon" sizes="1024x1024" href="/assets/apple-touch-icon-1024x1024.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="dstackai"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-640x1136.png"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1136x640.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-750x1334.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1334x750.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1125x2436.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2436x1125.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1170x2532.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2532x1170.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1179x2556.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2556x1179.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-828x1792.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1792x828.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2688.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2688x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2208.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2208x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1284x2778.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2778x1284.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1290x2796.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2796x1290.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1488x2266.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2266x1488.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1536x2048.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2048x1536.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1620x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1620.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1640x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1640.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2388.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2388x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2224.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2224x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-2048x2732.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2732x2048.png"><meta name="msapplication-TileColor" content="#fff"><meta name="msapplication-TileImage" content="/assets/mstile-144x144.png"><meta name="msapplication-config" content="/assets/browserconfig.xml"><link rel="yandex-tableau-widget" href="/assets/yandex-browser-manifest.json"><script defer="defer" src="/main-c51afa7f243e24d3e446.js"></script><link href="/main-56191fbfe77f49b251de.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div class="b-page-header" id="header"></div><div id="root"></div><script async src="https://widget.kapa.ai/kapa-widget.bundle.js" data-website-id="11a9339d-20ce-4ddb-9ba3-1b6e29afe8eb" data-project-name="dstack" data-project-color="rgba(0, 0, 0, 0.87)" data-font-size-lg="0.78rem" data-button-hide="true" data-modal-image="/logo-notext.svg" data-modal-z-index="1100" data-modal-title="Ask me anything" data-font-family='metro-web, Metro, -apple-system, "system-ui", "Segoe UI", Roboto' data-project-logo="/assets/images/kapa.svg" data-modal-disclaimer="This is a custom LLM for dstack with access to Documentation, API references and GitHub issues. This feature is experimental - Give it a try!" data-user-analytics-fingerprint-enabled="true"></script></body></html>