dstack 0.19.32__py3-none-any.whl → 0.19.34__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 (54) 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 +7 -1
  7. dstack/_internal/core/backends/datacrunch/compute.py +9 -6
  8. dstack/_internal/core/backends/gcp/compute.py +151 -6
  9. dstack/_internal/core/backends/gcp/models.py +10 -0
  10. dstack/_internal/core/backends/gcp/resources.py +87 -5
  11. dstack/_internal/core/backends/hotaisle/compute.py +11 -1
  12. dstack/_internal/core/backends/kubernetes/compute.py +161 -83
  13. dstack/_internal/core/backends/kubernetes/models.py +4 -2
  14. dstack/_internal/core/backends/nebius/compute.py +9 -6
  15. dstack/_internal/core/backends/oci/compute.py +9 -6
  16. dstack/_internal/core/backends/runpod/compute.py +14 -7
  17. dstack/_internal/core/backends/vastai/compute.py +3 -1
  18. dstack/_internal/core/backends/vastai/configurator.py +0 -1
  19. dstack/_internal/core/compatibility/runs.py +25 -4
  20. dstack/_internal/core/models/fleets.py +1 -1
  21. dstack/_internal/core/models/instances.py +2 -1
  22. dstack/_internal/core/models/profiles.py +1 -1
  23. dstack/_internal/core/models/runs.py +4 -2
  24. dstack/_internal/core/models/users.py +10 -0
  25. dstack/_internal/core/services/configs/__init__.py +1 -0
  26. dstack/_internal/core/services/ssh/key_manager.py +56 -0
  27. dstack/_internal/server/background/tasks/process_instances.py +5 -1
  28. dstack/_internal/server/background/tasks/process_running_jobs.py +1 -0
  29. dstack/_internal/server/migrations/versions/ff1d94f65b08_user_ssh_key.py +34 -0
  30. dstack/_internal/server/models.py +6 -0
  31. dstack/_internal/server/routers/metrics.py +6 -2
  32. dstack/_internal/server/routers/runs.py +5 -1
  33. dstack/_internal/server/routers/users.py +21 -2
  34. dstack/_internal/server/services/jobs/__init__.py +18 -9
  35. dstack/_internal/server/services/offers.py +1 -0
  36. dstack/_internal/server/services/runs.py +13 -4
  37. dstack/_internal/server/services/users.py +35 -2
  38. dstack/_internal/server/statics/index.html +1 -1
  39. dstack/_internal/server/statics/main-720ce3a11140daa480cc.css +3 -0
  40. dstack/_internal/server/statics/{main-c51afa7f243e24d3e446.js → main-e79754c136f1d8e4e7e6.js} +12632 -8039
  41. dstack/_internal/server/statics/{main-c51afa7f243e24d3e446.js.map → main-e79754c136f1d8e4e7e6.js.map} +1 -1
  42. dstack/_internal/server/testing/common.py +4 -0
  43. dstack/api/_public/__init__.py +8 -11
  44. dstack/api/_public/repos.py +0 -21
  45. dstack/api/_public/runs.py +61 -9
  46. dstack/api/server/__init__.py +4 -0
  47. dstack/api/server/_users.py +17 -2
  48. dstack/version.py +2 -2
  49. {dstack-0.19.32.dist-info → dstack-0.19.34.dist-info}/METADATA +2 -2
  50. {dstack-0.19.32.dist-info → dstack-0.19.34.dist-info}/RECORD +53 -51
  51. dstack/_internal/server/statics/main-56191fbfe77f49b251de.css +0 -3
  52. {dstack-0.19.32.dist-info → dstack-0.19.34.dist-info}/WHEEL +0 -0
  53. {dstack-0.19.32.dist-info → dstack-0.19.34.dist-info}/entry_points.txt +0 -0
  54. {dstack-0.19.32.dist-info → dstack-0.19.34.dist-info}/licenses/LICENSE.md +0 -0
@@ -33,6 +33,8 @@ def get_apply_plan_excludes(plan: ApplyRunPlanInput) -> Optional[IncludeExcludeD
33
33
  current_resource_excludes["deployment_num"] = True
34
34
  if current_resource.fleet is None:
35
35
  current_resource_excludes["fleet"] = True
36
+ if current_resource.next_triggered_at is None:
37
+ current_resource_excludes["next_triggered_at"] = True
36
38
  apply_plan_excludes["current_resource"] = current_resource_excludes
37
39
  current_resource_excludes["run_spec"] = get_run_spec_excludes(current_resource.run_spec)
38
40
  job_submissions_excludes: IncludeExcludeDictType = {}
@@ -47,10 +49,20 @@ def get_apply_plan_excludes(plan: ApplyRunPlanInput) -> Optional[IncludeExcludeD
47
49
  job_submissions_excludes["job_provisioning_data"] = {
48
50
  "instance_type": {"resources": {"cpu_arch"}}
49
51
  }
52
+ jrd_offer_excludes = {}
53
+ if any(
54
+ js.job_runtime_data and js.job_runtime_data.offer for js in job_submissions
55
+ ) and all(
56
+ not js.job_runtime_data
57
+ or not js.job_runtime_data.offer
58
+ or not js.job_runtime_data.offer.backend_data
59
+ for js in job_submissions
60
+ ):
61
+ jrd_offer_excludes["backend_data"] = True
50
62
  if all(map(_should_exclude_job_submission_jrd_cpu_arch, job_submissions)):
51
- job_submissions_excludes["job_runtime_data"] = {
52
- "offer": {"instance": {"resources": {"cpu_arch"}}}
53
- }
63
+ jrd_offer_excludes["instance"] = {"resources": {"cpu_arch"}}
64
+ if jrd_offer_excludes:
65
+ job_submissions_excludes["job_runtime_data"] = {"offer": jrd_offer_excludes}
54
66
  if all(js.exit_status is None for js in job_submissions):
55
67
  job_submissions_excludes["exit_status"] = True
56
68
  if all(js.status_message == "" for js in job_submissions):
@@ -69,9 +81,18 @@ def get_apply_plan_excludes(plan: ApplyRunPlanInput) -> Optional[IncludeExcludeD
69
81
  latest_job_submission_excludes["job_provisioning_data"] = {
70
82
  "instance_type": {"resources": {"cpu_arch"}}
71
83
  }
84
+ latest_job_submission_jrd_offer_excludes = {}
85
+ if (
86
+ latest_job_submission.job_runtime_data
87
+ and latest_job_submission.job_runtime_data.offer
88
+ and not latest_job_submission.job_runtime_data.offer.backend_data
89
+ ):
90
+ latest_job_submission_jrd_offer_excludes["backend_data"] = True
72
91
  if _should_exclude_job_submission_jrd_cpu_arch(latest_job_submission):
92
+ latest_job_submission_jrd_offer_excludes["instance"] = {"resources": {"cpu_arch"}}
93
+ if latest_job_submission_jrd_offer_excludes:
73
94
  latest_job_submission_excludes["job_runtime_data"] = {
74
- "offer": {"instance": {"resources": {"cpu_arch"}}}
95
+ "offer": latest_job_submission_jrd_offer_excludes
75
96
  }
76
97
  if latest_job_submission.exit_status is None:
77
98
  latest_job_submission_excludes["exit_status"] = True
@@ -244,7 +244,7 @@ class InstanceGroupParams(CoreModel):
244
244
  Field(
245
245
  description=(
246
246
  "The existing reservation to use for instance provisioning."
247
- " Supports AWS Capacity Reservations and Capacity Blocks"
247
+ " Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations"
248
248
  )
249
249
  ),
250
250
  ] = None
@@ -1,6 +1,6 @@
1
1
  import datetime
2
2
  from enum import Enum
3
- from typing import Dict, List, Optional
3
+ from typing import Any, Dict, List, Optional
4
4
  from uuid import UUID
5
5
 
6
6
  import gpuhunt
@@ -184,6 +184,7 @@ class InstanceOffer(CoreModel):
184
184
  instance: InstanceType
185
185
  region: str
186
186
  price: float
187
+ backend_data: dict[str, Any] = {}
187
188
 
188
189
 
189
190
  class InstanceOfferWithAvailability(InstanceOffer):
@@ -283,7 +283,7 @@ class ProfileParams(CoreModel):
283
283
  Field(
284
284
  description=(
285
285
  "The existing reservation to use for instance provisioning."
286
- " Supports AWS Capacity Reservations and Capacity Blocks"
286
+ " Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations"
287
287
  )
288
288
  ),
289
289
  ] = None
@@ -462,11 +462,12 @@ class RunSpec(generate_dual_core_model(RunSpecConfig)):
462
462
  configuration: Annotated[AnyRunConfiguration, Field(discriminator="type")]
463
463
  profile: Annotated[Optional[Profile], Field(description="The profile parameters")] = None
464
464
  ssh_key_pub: Annotated[
465
- str,
465
+ Optional[str],
466
466
  Field(
467
467
  description="The contents of the SSH public key that will be used to connect to the run."
468
+ " Can be empty only before the run is submitted."
468
469
  ),
469
- ]
470
+ ] = None
470
471
  # merged_profile stores profile parameters merged from profile and configuration.
471
472
  # Read profile parameters from merged_profile instead of profile directly.
472
473
  # TODO: make merged_profile a computed field after migrating to pydanticV2
@@ -552,6 +553,7 @@ class Run(CoreModel):
552
553
  deployment_num: int = 0 # default for compatibility with pre-0.19.14 servers
553
554
  error: Optional[str] = None
554
555
  deleted: Optional[bool] = None
556
+ next_triggered_at: Optional[datetime] = None
555
557
 
556
558
  def is_deployment_in_progress(self) -> bool:
557
559
  return any(
@@ -30,6 +30,7 @@ class User(CoreModel):
30
30
  email: Optional[str]
31
31
  active: bool
32
32
  permissions: UserPermissions
33
+ ssh_public_key: Optional[str] = None
33
34
 
34
35
 
35
36
  class UserTokenCreds(CoreModel):
@@ -38,3 +39,12 @@ class UserTokenCreds(CoreModel):
38
39
 
39
40
  class UserWithCreds(User):
40
41
  creds: UserTokenCreds
42
+ ssh_private_key: Optional[str] = None
43
+
44
+
45
+ class UserHookConfig(CoreModel):
46
+ """
47
+ This class can be inherited to extend the user creation configuration passed to the hooks.
48
+ """
49
+
50
+ pass
@@ -117,6 +117,7 @@ class ConfigManager:
117
117
 
118
118
  @property
119
119
  def dstack_key_path(self) -> Path:
120
+ # TODO: Remove since 0.19.40
120
121
  return self.dstack_ssh_dir / "id_rsa"
121
122
 
122
123
  @property
@@ -0,0 +1,56 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+ from datetime import datetime, timedelta
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Optional
6
+
7
+ from dstack._internal.core.models.users import UserWithCreds
8
+
9
+ if TYPE_CHECKING:
10
+ from dstack.api.server import APIClient
11
+
12
+ KEY_REFRESH_RATE = timedelta(minutes=10) # redownload the key periodically in case it was rotated
13
+
14
+
15
+ @dataclass
16
+ class UserSSHKey:
17
+ public_key: str
18
+ private_key_path: Path
19
+
20
+
21
+ class UserSSHKeyManager:
22
+ def __init__(self, api_client: "APIClient", ssh_keys_dir: Path) -> None:
23
+ self._api_client = api_client
24
+ self._key_path = ssh_keys_dir / api_client.get_token_hash()
25
+ self._pub_key_path = self._key_path.with_suffix(".pub")
26
+
27
+ def get_user_key(self) -> Optional[UserSSHKey]:
28
+ """
29
+ Return the up-to-date user key, or None if the user has no key (if created before 0.19.33)
30
+ """
31
+ if (
32
+ not self._key_path.exists()
33
+ or not self._pub_key_path.exists()
34
+ or datetime.now() - datetime.fromtimestamp(self._key_path.stat().st_mtime)
35
+ > KEY_REFRESH_RATE
36
+ ):
37
+ if not self._download_user_key():
38
+ return None
39
+ return UserSSHKey(
40
+ public_key=self._pub_key_path.read_text(), private_key_path=self._key_path
41
+ )
42
+
43
+ def _download_user_key(self) -> bool:
44
+ user = self._api_client.users.get_my_user()
45
+ if not (isinstance(user, UserWithCreds) and user.ssh_public_key and user.ssh_private_key):
46
+ return False
47
+
48
+ def key_opener(path, flags):
49
+ return os.open(path, flags, 0o600)
50
+
51
+ with open(self._key_path, "w", opener=key_opener) as f:
52
+ f.write(user.ssh_private_key)
53
+ with open(self._pub_key_path, "w") as f:
54
+ f.write(user.ssh_public_key)
55
+
56
+ return True
@@ -558,10 +558,14 @@ async def _create_instance(session: AsyncSession, instance: InstanceModel) -> No
558
558
  if (
559
559
  _is_fleet_master_instance(instance)
560
560
  and instance_offer.backend in BACKENDS_WITH_PLACEMENT_GROUPS_SUPPORT
561
+ and isinstance(compute, ComputeWithPlacementGroupSupport)
562
+ and (
563
+ compute.are_placement_groups_compatible_with_reservations(instance_offer.backend)
564
+ or instance_configuration.reservation is None
565
+ )
561
566
  and instance.fleet
562
567
  and _is_cloud_cluster(instance.fleet)
563
568
  ):
564
- assert isinstance(compute, ComputeWithPlacementGroupSupport)
565
569
  placement_group_model = _find_suitable_placement_group(
566
570
  placement_groups=placement_group_models,
567
571
  instance_offer=instance_offer,
@@ -243,6 +243,7 @@ async def _process_running_job(session: AsyncSession, job_model: JobModel):
243
243
  job_submission.age,
244
244
  )
245
245
  ssh_user = job_provisioning_data.username
246
+ assert run.run_spec.ssh_key_pub is not None
246
247
  user_ssh_key = run.run_spec.ssh_key_pub.strip()
247
248
  public_keys = [project.ssh_public_key.strip(), user_ssh_key]
248
249
  if job_provisioning_data.backend == BackendType.LOCAL:
@@ -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,12 @@ class UserModel(BaseModel):
190
190
  # deactivated users cannot access API
191
191
  active: Mapped[bool] = mapped_column(Boolean, default=True)
192
192
 
193
+ # SSH keys can be null for users created before 0.19.33.
194
+ # Keys for those users are being gradually generated on /get_my_user calls.
195
+ # TODO: make keys required in a future version.
196
+ ssh_private_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
197
+ ssh_public_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
198
+
193
199
  email: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
194
200
 
195
201
  projects_quota: Mapped[int] = mapped_column(
@@ -1,5 +1,6 @@
1
1
  from datetime import datetime
2
2
  from typing import Optional, Tuple
3
+ from uuid import UUID
3
4
 
4
5
  from fastapi import APIRouter, Depends
5
6
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -29,6 +30,7 @@ router = APIRouter(
29
30
  )
30
31
  async def get_job_metrics(
31
32
  run_name: str,
33
+ run_id: Optional[UUID] = None,
32
34
  replica_num: int = 0,
33
35
  job_num: int = 0,
34
36
  limit: int = 1,
@@ -39,8 +41,9 @@ async def get_job_metrics(
39
41
  ):
40
42
  """
41
43
  Returns job-level metrics such as hardware utilization
42
- given `run_name`, `replica_num`, and `job_num`.
43
- If only `run_name` is specified, returns metrics of `(replica_num=0, job_num=0)`.
44
+ given `run_name`, `run_id`, `replica_num`, and `job_num`.
45
+ If only `run_name` is specified, returns metrics of `(replica_num=0, job_num=0)`
46
+ of the latest run with the given name.
44
47
  By default, returns one latest sample. To control time window/number of samples, use
45
48
  `limit`, `after`, `before`.
46
49
 
@@ -61,6 +64,7 @@ async def get_job_metrics(
61
64
  session=session,
62
65
  project=project,
63
66
  run_name=run_name,
67
+ run_id=run_id,
64
68
  replica_num=replica_num,
65
69
  job_num=job_num,
66
70
  )
@@ -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,18 @@ 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
+ session: AsyncSession = Depends(get_session),
41
42
  user: UserModel = Depends(Authenticated()),
42
43
  ):
43
- return CustomORJSONResponse(users.user_model_to_user(user))
44
+ if user.ssh_private_key is None or user.ssh_public_key is None:
45
+ # Generate keys for pre-0.19.33 users
46
+ updated_user = await users.refresh_ssh_key(session=session, user=user, username=user.name)
47
+ if updated_user is None:
48
+ raise ResourceNotExistsError()
49
+ user = updated_user
50
+ return CustomORJSONResponse(users.user_model_to_user_with_creds(user))
44
51
 
45
52
 
46
53
  @router.post("/get_user", response_model=UserWithCreds)
@@ -91,6 +98,18 @@ async def update_user(
91
98
  return CustomORJSONResponse(users.user_model_to_user(res))
92
99
 
93
100
 
101
+ @router.post("/refresh_ssh_key", response_model=UserWithCreds)
102
+ async def refresh_ssh_key(
103
+ body: RefreshTokenRequest,
104
+ session: AsyncSession = Depends(get_session),
105
+ user: UserModel = Depends(Authenticated()),
106
+ ):
107
+ res = await users.refresh_ssh_key(session=session, user=user, username=body.username)
108
+ if res is None:
109
+ raise ResourceNotExistsError()
110
+ return CustomORJSONResponse(users.user_model_to_user_with_creds(res))
111
+
112
+
94
113
  @router.post("/refresh_token", response_model=UserWithCreds)
95
114
  async def refresh_token(
96
115
  body: RefreshTokenRequest,
@@ -97,19 +97,28 @@ def find_job(jobs: List[Job], replica_num: int, job_num: int) -> Job:
97
97
 
98
98
 
99
99
  async def get_run_job_model(
100
- session: AsyncSession, project: ProjectModel, run_name: str, replica_num: int, job_num: int
100
+ session: AsyncSession,
101
+ project: ProjectModel,
102
+ run_name: str,
103
+ run_id: Optional[UUID],
104
+ replica_num: int,
105
+ job_num: int,
101
106
  ) -> Optional[JobModel]:
107
+ filters = [
108
+ RunModel.project_id == project.id,
109
+ RunModel.run_name == run_name,
110
+ JobModel.replica_num == replica_num,
111
+ JobModel.job_num == job_num,
112
+ ]
113
+ if run_id is not None:
114
+ filters.append(RunModel.id == run_id)
115
+ else:
116
+ # Assuming run_name is unique for non-deleted runs
117
+ filters.append(RunModel.deleted == False)
102
118
  res = await session.execute(
103
119
  select(JobModel)
104
120
  .join(JobModel.run)
105
- .where(
106
- RunModel.project_id == project.id,
107
- # assuming run_name is unique for non-deleted runs
108
- RunModel.run_name == run_name,
109
- RunModel.deleted == False,
110
- JobModel.replica_num == replica_num,
111
- JobModel.job_num == job_num,
112
- )
121
+ .where(*filters)
113
122
  .order_by(JobModel.submission_num.desc())
114
123
  .limit(1)
115
124
  )
@@ -215,6 +215,7 @@ def generate_shared_offer(
215
215
  ),
216
216
  region=offer.region,
217
217
  price=offer.price,
218
+ backend_data=offer.backend_data,
218
219
  availability=offer.availability,
219
220
  blocks=blocks,
220
221
  total_blocks=total_blocks,
@@ -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,
@@ -715,6 +715,9 @@ def run_model_to_run(
715
715
  status_message = _get_run_status_message(run_model)
716
716
  error = _get_run_error(run_model)
717
717
  fleet = _get_run_fleet(run_model)
718
+ next_triggered_at = None
719
+ if not run_model.status.is_finished():
720
+ next_triggered_at = _get_next_triggered_at(run_spec)
718
721
  run = Run(
719
722
  id=run_model.id,
720
723
  project_name=run_model.project.name,
@@ -734,6 +737,7 @@ def run_model_to_run(
734
737
  deployment_num=run_model.deployment_num,
735
738
  error=error,
736
739
  deleted=run_model.deleted,
740
+ next_triggered_at=next_triggered_at,
737
741
  )
738
742
  run.cost = _get_run_cost(run)
739
743
  return run
@@ -981,7 +985,7 @@ def _get_job_submission_cost(job_submission: JobSubmission) -> float:
981
985
  return job_submission.job_provisioning_data.price * duration_hours
982
986
 
983
987
 
984
- def _validate_run_spec_and_set_defaults(run_spec: RunSpec):
988
+ def _validate_run_spec_and_set_defaults(user: UserModel, run_spec: RunSpec):
985
989
  # This function may set defaults for null run_spec values,
986
990
  # although most defaults are resolved when building job_spec
987
991
  # so that we can keep both the original user-supplied value (null in run_spec)
@@ -1031,6 +1035,11 @@ def _validate_run_spec_and_set_defaults(run_spec: RunSpec):
1031
1035
  if run_spec.configuration.priority is None:
1032
1036
  run_spec.configuration.priority = RUN_PRIORITY_DEFAULT
1033
1037
  set_resources_defaults(run_spec.configuration.resources)
1038
+ if run_spec.ssh_key_pub is None:
1039
+ if user.ssh_public_key:
1040
+ run_spec.ssh_key_pub = user.ssh_public_key
1041
+ else:
1042
+ raise ServerClientError("ssh_key_pub must be set if the user has no ssh_public_key")
1034
1043
 
1035
1044
 
1036
1045
  _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 import crypto
24
+ from dstack._internal.utils.common import run_async
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(crypto.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(crypto.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-e79754c136f1d8e4e7e6.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>