dstack 0.19.31__py3-none-any.whl → 0.19.33__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 (53) hide show
  1. dstack/_internal/cli/commands/offer.py +1 -1
  2. dstack/_internal/cli/services/configurators/run.py +1 -5
  3. dstack/_internal/core/backends/aws/compute.py +8 -5
  4. dstack/_internal/core/backends/azure/compute.py +9 -6
  5. dstack/_internal/core/backends/base/compute.py +40 -17
  6. dstack/_internal/core/backends/base/offers.py +5 -1
  7. dstack/_internal/core/backends/datacrunch/compute.py +9 -6
  8. dstack/_internal/core/backends/gcp/compute.py +137 -7
  9. dstack/_internal/core/backends/gcp/models.py +7 -0
  10. dstack/_internal/core/backends/gcp/resources.py +87 -5
  11. dstack/_internal/core/backends/hotaisle/compute.py +30 -0
  12. dstack/_internal/core/backends/kubernetes/compute.py +218 -77
  13. dstack/_internal/core/backends/kubernetes/models.py +4 -2
  14. dstack/_internal/core/backends/nebius/compute.py +24 -6
  15. dstack/_internal/core/backends/nebius/configurator.py +15 -0
  16. dstack/_internal/core/backends/nebius/models.py +57 -5
  17. dstack/_internal/core/backends/nebius/resources.py +45 -2
  18. dstack/_internal/core/backends/oci/compute.py +9 -6
  19. dstack/_internal/core/backends/runpod/compute.py +10 -6
  20. dstack/_internal/core/backends/vastai/compute.py +3 -1
  21. dstack/_internal/core/backends/vastai/configurator.py +0 -1
  22. dstack/_internal/core/compatibility/runs.py +8 -0
  23. dstack/_internal/core/models/fleets.py +1 -1
  24. dstack/_internal/core/models/profiles.py +12 -5
  25. dstack/_internal/core/models/runs.py +3 -2
  26. dstack/_internal/core/models/users.py +10 -0
  27. dstack/_internal/core/services/configs/__init__.py +1 -0
  28. dstack/_internal/server/background/tasks/process_fleets.py +75 -17
  29. dstack/_internal/server/background/tasks/process_instances.py +6 -4
  30. dstack/_internal/server/background/tasks/process_running_jobs.py +1 -0
  31. dstack/_internal/server/background/tasks/process_runs.py +27 -23
  32. dstack/_internal/server/background/tasks/process_submitted_jobs.py +63 -20
  33. dstack/_internal/server/migrations/versions/ff1d94f65b08_user_ssh_key.py +34 -0
  34. dstack/_internal/server/models.py +3 -0
  35. dstack/_internal/server/routers/runs.py +5 -1
  36. dstack/_internal/server/routers/users.py +14 -2
  37. dstack/_internal/server/services/runs.py +9 -4
  38. dstack/_internal/server/services/users.py +35 -2
  39. dstack/_internal/server/statics/index.html +1 -1
  40. dstack/_internal/server/statics/main-720ce3a11140daa480cc.css +3 -0
  41. dstack/_internal/server/statics/{main-c51afa7f243e24d3e446.js → main-97c7e184573ca23f9fe4.js} +12218 -7625
  42. dstack/_internal/server/statics/{main-c51afa7f243e24d3e446.js.map → main-97c7e184573ca23f9fe4.js.map} +1 -1
  43. dstack/api/_public/__init__.py +9 -12
  44. dstack/api/_public/repos.py +0 -21
  45. dstack/api/_public/runs.py +64 -9
  46. dstack/api/server/_users.py +17 -2
  47. dstack/version.py +2 -2
  48. {dstack-0.19.31.dist-info → dstack-0.19.33.dist-info}/METADATA +12 -14
  49. {dstack-0.19.31.dist-info → dstack-0.19.33.dist-info}/RECORD +52 -51
  50. dstack/_internal/server/statics/main-56191fbfe77f49b251de.css +0 -3
  51. {dstack-0.19.31.dist-info → dstack-0.19.33.dist-info}/WHEEL +0 -0
  52. {dstack-0.19.31.dist-info → dstack-0.19.33.dist-info}/entry_points.txt +0 -0
  53. {dstack-0.19.31.dist-info → dstack-0.19.33.dist-info}/licenses/LICENSE.md +0 -0
@@ -260,7 +260,6 @@ async def _process_submitted_job(session: AsyncSession, job_model: JobModel):
260
260
 
261
261
  instance_filters = [
262
262
  InstanceModel.deleted == False,
263
- InstanceModel.total_blocks > InstanceModel.busy_blocks,
264
263
  InstanceModel.id.not_in(detaching_instances_ids),
265
264
  ]
266
265
 
@@ -514,9 +513,6 @@ async def _find_optimal_fleet_with_offers(
514
513
  )
515
514
  return run_model.fleet, fleet_instances_with_pool_offers
516
515
 
517
- if len(fleet_models) == 0:
518
- return None, []
519
-
520
516
  nodes_required_num = _get_nodes_required_num_for_run(run_spec)
521
517
  # The current strategy is first to consider fleets that can accommodate
522
518
  # the run without additional provisioning and choose the one with the cheapest pool offer.
@@ -534,6 +530,7 @@ async def _find_optimal_fleet_with_offers(
534
530
  ]
535
531
  ] = []
536
532
  for candidate_fleet_model in fleet_models:
533
+ candidate_fleet = fleet_model_to_fleet(candidate_fleet_model)
537
534
  fleet_instances_with_pool_offers = _get_fleet_instances_with_pool_offers(
538
535
  fleet_model=candidate_fleet_model,
539
536
  run_spec=run_spec,
@@ -541,24 +538,21 @@ async def _find_optimal_fleet_with_offers(
541
538
  master_job_provisioning_data=master_job_provisioning_data,
542
539
  volumes=volumes,
543
540
  )
544
- fleet_has_available_capacity = nodes_required_num <= len(fleet_instances_with_pool_offers)
541
+ fleet_has_pool_capacity = nodes_required_num <= len(fleet_instances_with_pool_offers)
545
542
  fleet_cheapest_pool_offer = math.inf
546
543
  if len(fleet_instances_with_pool_offers) > 0:
547
544
  fleet_cheapest_pool_offer = fleet_instances_with_pool_offers[0][1].price
548
545
 
549
- candidate_fleet = fleet_model_to_fleet(candidate_fleet_model)
550
- profile = None
551
- requirements = None
552
546
  try:
547
+ _check_can_create_new_instance_in_fleet(candidate_fleet)
553
548
  profile, requirements = _get_run_profile_and_requirements_in_fleet(
554
549
  job=job,
555
550
  run_spec=run_spec,
556
551
  fleet=candidate_fleet,
557
552
  )
558
553
  except ValueError:
559
- pass
560
- fleet_backend_offers = []
561
- if profile is not None and requirements is not None:
554
+ fleet_backend_offers = []
555
+ else:
562
556
  multinode = (
563
557
  candidate_fleet.spec.configuration.placement == InstanceGroupPlacement.CLUSTER
564
558
  or job.job_spec.jobs_per_replica > 1
@@ -579,8 +573,12 @@ async def _find_optimal_fleet_with_offers(
579
573
  if len(fleet_backend_offers) > 0:
580
574
  fleet_cheapest_backend_offer = fleet_backend_offers[0][1].price
581
575
 
576
+ if not _run_can_fit_into_fleet(run_spec, candidate_fleet):
577
+ logger.debug("Skipping fleet %s from consideration: run cannot fit into fleet")
578
+ continue
579
+
582
580
  fleet_priority = (
583
- not fleet_has_available_capacity,
581
+ not fleet_has_pool_capacity,
584
582
  fleet_cheapest_pool_offer,
585
583
  fleet_cheapest_backend_offer,
586
584
  )
@@ -593,10 +591,13 @@ async def _find_optimal_fleet_with_offers(
593
591
  fleet_priority,
594
592
  )
595
593
  )
594
+ if len(candidate_fleets_with_offers) == 0:
595
+ return None, []
596
596
  if run_spec.merged_profile.fleets is None and all(
597
597
  t[2] == 0 and t[3] == 0 for t in candidate_fleets_with_offers
598
598
  ):
599
- # If fleets are not specified and no fleets have available pool or backend offers, create a new fleet.
599
+ # If fleets are not specified and no fleets have available pool
600
+ # or backend offers, create a new fleet.
600
601
  # This is for compatibility with non-fleet-first UX when runs created new fleets
601
602
  # if there are no instances to reuse.
602
603
  return None, []
@@ -616,6 +617,39 @@ def _get_nodes_required_num_for_run(run_spec: RunSpec) -> int:
616
617
  return nodes_required_num
617
618
 
618
619
 
620
+ def _run_can_fit_into_fleet(run_spec: RunSpec, fleet: Fleet) -> bool:
621
+ """
622
+ Returns `False` if the run cannot fit into fleet for sure.
623
+ This is helpful heuristic to avoid even considering fleets too small for a run.
624
+ A run may not fit even if this function returns `True`.
625
+ This will lead to some jobs failing due to exceeding `nodes.max`
626
+ or more than `nodes.max` instances being provisioned
627
+ and eventually removed by the fleet consolidation logic.
628
+ """
629
+ # No check for cloud fleets with blocks > 1 since we don't know
630
+ # how many jobs such fleets can accommodate.
631
+ nodes_required_num = _get_nodes_required_num_for_run(run_spec)
632
+ if (
633
+ fleet.spec.configuration.nodes is not None
634
+ and fleet.spec.configuration.blocks == 1
635
+ and fleet.spec.configuration.nodes.max is not None
636
+ ):
637
+ busy_instances = [i for i in fleet.instances if i.busy_blocks > 0]
638
+ fleet_available_capacity = fleet.spec.configuration.nodes.max - len(busy_instances)
639
+ if fleet_available_capacity < nodes_required_num:
640
+ return False
641
+ elif fleet.spec.configuration.ssh_config is not None:
642
+ # Currently assume that each idle block can run a job.
643
+ # TODO: Take resources / eligible offers into account.
644
+ total_idle_blocks = 0
645
+ for instance in fleet.instances:
646
+ total_blocks = instance.total_blocks or 1
647
+ total_idle_blocks += total_blocks - instance.busy_blocks
648
+ if total_idle_blocks < nodes_required_num:
649
+ return False
650
+ return True
651
+
652
+
619
653
  def _get_fleet_instances_with_pool_offers(
620
654
  fleet_model: FleetModel,
621
655
  run_spec: RunSpec,
@@ -713,6 +747,7 @@ async def _run_job_on_new_instance(
713
747
  if fleet_model is not None:
714
748
  fleet = fleet_model_to_fleet(fleet_model)
715
749
  try:
750
+ _check_can_create_new_instance_in_fleet(fleet)
716
751
  profile, requirements = _get_run_profile_and_requirements_in_fleet(
717
752
  job=job,
718
753
  run_spec=run.run_spec,
@@ -787,8 +822,6 @@ def _get_run_profile_and_requirements_in_fleet(
787
822
  run_spec: RunSpec,
788
823
  fleet: Fleet,
789
824
  ) -> tuple[Profile, Requirements]:
790
- if not _check_can_create_new_instance_in_fleet(fleet):
791
- raise ValueError("Cannot fit new instance into fleet")
792
825
  profile = combine_fleet_and_run_profiles(fleet.spec.merged_profile, run_spec.merged_profile)
793
826
  if profile is None:
794
827
  raise ValueError("Cannot combine fleet profile")
@@ -801,13 +834,23 @@ def _get_run_profile_and_requirements_in_fleet(
801
834
  return profile, requirements
802
835
 
803
836
 
804
- def _check_can_create_new_instance_in_fleet(fleet: Fleet) -> bool:
837
+ def _check_can_create_new_instance_in_fleet(fleet: Fleet):
838
+ if not _can_create_new_instance_in_fleet(fleet):
839
+ raise ValueError("Cannot fit new instance into fleet")
840
+
841
+
842
+ def _can_create_new_instance_in_fleet(fleet: Fleet) -> bool:
805
843
  if fleet.spec.configuration.ssh_config is not None:
806
844
  return False
807
- # TODO: Respect nodes.max
808
- # Ensure concurrent provisioning does not violate nodes.max
809
- # E.g. lock fleet and split instance model creation
810
- # and instance provisioning into separate transactions.
845
+ active_instances = [i for i in fleet.instances if i.status.is_active()]
846
+ # nodes.max is a soft limit that can be exceeded when provisioning concurrently.
847
+ # The fleet consolidation logic will remove redundant nodes eventually.
848
+ if (
849
+ fleet.spec.configuration.nodes is not None
850
+ and fleet.spec.configuration.nodes.max is not None
851
+ and len(active_instances) >= fleet.spec.configuration.nodes.max
852
+ ):
853
+ return False
811
854
  return True
812
855
 
813
856
 
@@ -0,0 +1,34 @@
1
+ """user.ssh_key
2
+
3
+ Revision ID: ff1d94f65b08
4
+ Revises: 2498ab323443
5
+ Create Date: 2025-10-09 20:31:31.166786
6
+
7
+ """
8
+
9
+ import sqlalchemy as sa
10
+ from alembic import op
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = "ff1d94f65b08"
14
+ down_revision = "2498ab323443"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ # ### commands auto generated by Alembic - please adjust! ###
21
+ with op.batch_alter_table("users", schema=None) as batch_op:
22
+ batch_op.add_column(sa.Column("ssh_private_key", sa.Text(), nullable=True))
23
+ batch_op.add_column(sa.Column("ssh_public_key", sa.Text(), nullable=True))
24
+
25
+ # ### end Alembic commands ###
26
+
27
+
28
+ def downgrade() -> None:
29
+ # ### commands auto generated by Alembic - please adjust! ###
30
+ with op.batch_alter_table("users", schema=None) as batch_op:
31
+ batch_op.drop_column("ssh_public_key")
32
+ batch_op.drop_column("ssh_private_key")
33
+
34
+ # ### end Alembic commands ###
@@ -190,6 +190,9 @@ class UserModel(BaseModel):
190
190
  # deactivated users cannot access API
191
191
  active: Mapped[bool] = mapped_column(Boolean, default=True)
192
192
 
193
+ ssh_private_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
194
+ ssh_public_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
195
+
193
196
  email: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
194
197
 
195
198
  projects_quota: Mapped[int] = mapped_column(
@@ -17,7 +17,7 @@ from dstack._internal.server.schemas.runs import (
17
17
  SubmitRunRequest,
18
18
  )
19
19
  from dstack._internal.server.security.permissions import Authenticated, ProjectMember
20
- from dstack._internal.server.services import runs
20
+ from dstack._internal.server.services import runs, users
21
21
  from dstack._internal.server.utils.routers import (
22
22
  CustomORJSONResponse,
23
23
  get_base_api_additional_responses,
@@ -111,6 +111,8 @@ async def get_plan(
111
111
  This is an optional step before calling `/apply`.
112
112
  """
113
113
  user, project = user_project
114
+ if not user.ssh_public_key and not body.run_spec.ssh_key_pub:
115
+ await users.refresh_ssh_key(session=session, user=user, username=user.name)
114
116
  run_plan = await runs.get_plan(
115
117
  session=session,
116
118
  project=project,
@@ -137,6 +139,8 @@ async def apply_plan(
137
139
  If the existing run is active and cannot be updated, it must be stopped first.
138
140
  """
139
141
  user, project = user_project
142
+ if not user.ssh_public_key and not body.plan.run_spec.ssh_key_pub:
143
+ await users.refresh_ssh_key(session=session, user=user, username=user.name)
140
144
  return CustomORJSONResponse(
141
145
  await runs.apply_plan(
142
146
  session=session,
@@ -36,11 +36,11 @@ async def list_users(
36
36
  return CustomORJSONResponse(await users.list_users_for_user(session=session, user=user))
37
37
 
38
38
 
39
- @router.post("/get_my_user", response_model=User)
39
+ @router.post("/get_my_user", response_model=UserWithCreds)
40
40
  async def get_my_user(
41
41
  user: UserModel = Depends(Authenticated()),
42
42
  ):
43
- return CustomORJSONResponse(users.user_model_to_user(user))
43
+ return CustomORJSONResponse(users.user_model_to_user_with_creds(user))
44
44
 
45
45
 
46
46
  @router.post("/get_user", response_model=UserWithCreds)
@@ -91,6 +91,18 @@ async def update_user(
91
91
  return CustomORJSONResponse(users.user_model_to_user(res))
92
92
 
93
93
 
94
+ @router.post("/refresh_ssh_key", response_model=UserWithCreds)
95
+ async def refresh_ssh_key(
96
+ body: RefreshTokenRequest,
97
+ session: AsyncSession = Depends(get_session),
98
+ user: UserModel = Depends(Authenticated()),
99
+ ):
100
+ res = await users.refresh_ssh_key(session=session, user=user, username=body.username)
101
+ if res is None:
102
+ raise ResourceNotExistsError()
103
+ return CustomORJSONResponse(users.user_model_to_user_with_creds(res))
104
+
105
+
94
106
  @router.post("/refresh_token", response_model=UserWithCreds)
95
107
  async def refresh_token(
96
108
  body: RefreshTokenRequest,
@@ -317,7 +317,7 @@ async def get_plan(
317
317
  spec=effective_run_spec,
318
318
  )
319
319
  effective_run_spec = RunSpec.parse_obj(effective_run_spec.dict())
320
- _validate_run_spec_and_set_defaults(effective_run_spec)
320
+ _validate_run_spec_and_set_defaults(user, effective_run_spec)
321
321
 
322
322
  profile = effective_run_spec.merged_profile
323
323
  creation_policy = profile.creation_policy
@@ -422,7 +422,7 @@ async def apply_plan(
422
422
  )
423
423
  # Spec must be copied by parsing to calculate merged_profile
424
424
  run_spec = RunSpec.parse_obj(run_spec.dict())
425
- _validate_run_spec_and_set_defaults(run_spec)
425
+ _validate_run_spec_and_set_defaults(user, run_spec)
426
426
  if run_spec.run_name is None:
427
427
  return await submit_run(
428
428
  session=session,
@@ -489,7 +489,7 @@ async def submit_run(
489
489
  project: ProjectModel,
490
490
  run_spec: RunSpec,
491
491
  ) -> Run:
492
- _validate_run_spec_and_set_defaults(run_spec)
492
+ _validate_run_spec_and_set_defaults(user, run_spec)
493
493
  repo = await _get_run_repo_or_error(
494
494
  session=session,
495
495
  project=project,
@@ -981,7 +981,7 @@ def _get_job_submission_cost(job_submission: JobSubmission) -> float:
981
981
  return job_submission.job_provisioning_data.price * duration_hours
982
982
 
983
983
 
984
- def _validate_run_spec_and_set_defaults(run_spec: RunSpec):
984
+ def _validate_run_spec_and_set_defaults(user: UserModel, run_spec: RunSpec):
985
985
  # This function may set defaults for null run_spec values,
986
986
  # although most defaults are resolved when building job_spec
987
987
  # so that we can keep both the original user-supplied value (null in run_spec)
@@ -1031,6 +1031,11 @@ def _validate_run_spec_and_set_defaults(run_spec: RunSpec):
1031
1031
  if run_spec.configuration.priority is None:
1032
1032
  run_spec.configuration.priority = RUN_PRIORITY_DEFAULT
1033
1033
  set_resources_defaults(run_spec.configuration.resources)
1034
+ if run_spec.ssh_key_pub is None:
1035
+ if user.ssh_public_key:
1036
+ run_spec.ssh_key_pub = user.ssh_public_key
1037
+ else:
1038
+ raise ServerClientError("ssh_key_pub must be set if the user has no ssh_public_key")
1034
1039
 
1035
1040
 
1036
1041
  _UPDATABLE_SPEC_FIELDS = ["configuration_path", "configuration"]
@@ -12,6 +12,7 @@ from dstack._internal.core.errors import ResourceExistsError, ServerClientError
12
12
  from dstack._internal.core.models.users import (
13
13
  GlobalRole,
14
14
  User,
15
+ UserHookConfig,
15
16
  UserPermissions,
16
17
  UserTokenCreds,
17
18
  UserWithCreds,
@@ -19,6 +20,8 @@ from dstack._internal.core.models.users import (
19
20
  from dstack._internal.server.models import DecryptedString, UserModel
20
21
  from dstack._internal.server.services.permissions import get_default_permissions
21
22
  from dstack._internal.server.utils.routers import error_forbidden
23
+ from dstack._internal.utils.common import run_async
24
+ from dstack._internal.utils.crypto import generate_rsa_key_pair_bytes
22
25
  from dstack._internal.utils.logging import get_logger
23
26
 
24
27
  logger = get_logger(__name__)
@@ -77,6 +80,7 @@ async def create_user(
77
80
  email: Optional[str] = None,
78
81
  active: bool = True,
79
82
  token: Optional[str] = None,
83
+ config: Optional[UserHookConfig] = None,
80
84
  ) -> UserModel:
81
85
  validate_username(username)
82
86
  user_model = await get_user_model_by_name(session=session, username=username, ignore_case=True)
@@ -84,6 +88,7 @@ async def create_user(
84
88
  raise ResourceExistsError()
85
89
  if token is None:
86
90
  token = str(uuid.uuid4())
91
+ private_bytes, public_bytes = await run_async(generate_rsa_key_pair_bytes, username)
87
92
  user = UserModel(
88
93
  id=uuid.uuid4(),
89
94
  name=username,
@@ -92,11 +97,13 @@ async def create_user(
92
97
  token_hash=get_token_hash(token),
93
98
  email=email,
94
99
  active=active,
100
+ ssh_private_key=private_bytes.decode(),
101
+ ssh_public_key=public_bytes.decode(),
95
102
  )
96
103
  session.add(user)
97
104
  await session.commit()
98
105
  for func in _CREATE_USER_HOOKS:
99
- await func(session, user)
106
+ await func(session, user, config)
100
107
  return user
101
108
 
102
109
 
@@ -120,6 +127,27 @@ async def update_user(
120
127
  return await get_user_model_by_name_or_error(session=session, username=username)
121
128
 
122
129
 
130
+ async def refresh_ssh_key(
131
+ session: AsyncSession,
132
+ user: UserModel,
133
+ username: str,
134
+ ) -> Optional[UserModel]:
135
+ logger.debug("Refreshing SSH key for user [code]%s[/code]", username)
136
+ if user.global_role != GlobalRole.ADMIN and user.name != username:
137
+ raise error_forbidden()
138
+ private_bytes, public_bytes = await run_async(generate_rsa_key_pair_bytes, username)
139
+ await session.execute(
140
+ update(UserModel)
141
+ .where(UserModel.name == username)
142
+ .values(
143
+ ssh_private_key=private_bytes.decode(),
144
+ ssh_public_key=public_bytes.decode(),
145
+ )
146
+ )
147
+ await session.commit()
148
+ return await get_user_model_by_name(session=session, username=username)
149
+
150
+
123
151
  async def refresh_user_token(
124
152
  session: AsyncSession,
125
153
  user: UserModel,
@@ -199,6 +227,7 @@ def user_model_to_user(user_model: UserModel) -> User:
199
227
  email=user_model.email,
200
228
  active=user_model.active,
201
229
  permissions=get_user_permissions(user_model),
230
+ ssh_public_key=user_model.ssh_public_key,
202
231
  )
203
232
 
204
233
 
@@ -211,7 +240,9 @@ def user_model_to_user_with_creds(user_model: UserModel) -> UserWithCreds:
211
240
  email=user_model.email,
212
241
  active=user_model.active,
213
242
  permissions=get_user_permissions(user_model),
243
+ ssh_public_key=user_model.ssh_public_key,
214
244
  creds=UserTokenCreds(token=user_model.token.get_plaintext_or_error()),
245
+ ssh_private_key=user_model.ssh_private_key,
215
246
  )
216
247
 
217
248
 
@@ -238,7 +269,9 @@ def is_valid_username(username: str) -> bool:
238
269
  _CREATE_USER_HOOKS = []
239
270
 
240
271
 
241
- def register_create_user_hook(func: Callable[[AsyncSession, UserModel], Awaitable[None]]):
272
+ def register_create_user_hook(
273
+ func: Callable[[AsyncSession, UserModel, Optional[UserHookConfig]], Awaitable[None]],
274
+ ):
242
275
  _CREATE_USER_HOOKS.append(func)
243
276
 
244
277
 
@@ -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-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>
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-97c7e184573ca23f9fe4.js"></script><link href="/main-720ce3a11140daa480cc.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>