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.
- dstack/_internal/cli/commands/offer.py +1 -1
- dstack/_internal/cli/services/configurators/run.py +1 -5
- dstack/_internal/core/backends/aws/compute.py +8 -5
- dstack/_internal/core/backends/azure/compute.py +9 -6
- dstack/_internal/core/backends/base/compute.py +40 -17
- dstack/_internal/core/backends/base/offers.py +7 -1
- dstack/_internal/core/backends/datacrunch/compute.py +9 -6
- dstack/_internal/core/backends/gcp/compute.py +151 -6
- dstack/_internal/core/backends/gcp/models.py +10 -0
- dstack/_internal/core/backends/gcp/resources.py +87 -5
- dstack/_internal/core/backends/hotaisle/compute.py +11 -1
- dstack/_internal/core/backends/kubernetes/compute.py +161 -83
- dstack/_internal/core/backends/kubernetes/models.py +4 -2
- dstack/_internal/core/backends/nebius/compute.py +9 -6
- dstack/_internal/core/backends/oci/compute.py +9 -6
- dstack/_internal/core/backends/runpod/compute.py +14 -7
- dstack/_internal/core/backends/vastai/compute.py +3 -1
- dstack/_internal/core/backends/vastai/configurator.py +0 -1
- dstack/_internal/core/compatibility/runs.py +25 -4
- dstack/_internal/core/models/fleets.py +1 -1
- dstack/_internal/core/models/instances.py +2 -1
- dstack/_internal/core/models/profiles.py +1 -1
- dstack/_internal/core/models/runs.py +4 -2
- dstack/_internal/core/models/users.py +10 -0
- dstack/_internal/core/services/configs/__init__.py +1 -0
- dstack/_internal/core/services/ssh/key_manager.py +56 -0
- dstack/_internal/server/background/tasks/process_instances.py +5 -1
- dstack/_internal/server/background/tasks/process_running_jobs.py +1 -0
- dstack/_internal/server/migrations/versions/ff1d94f65b08_user_ssh_key.py +34 -0
- dstack/_internal/server/models.py +6 -0
- dstack/_internal/server/routers/metrics.py +6 -2
- dstack/_internal/server/routers/runs.py +5 -1
- dstack/_internal/server/routers/users.py +21 -2
- dstack/_internal/server/services/jobs/__init__.py +18 -9
- dstack/_internal/server/services/offers.py +1 -0
- dstack/_internal/server/services/runs.py +13 -4
- dstack/_internal/server/services/users.py +35 -2
- dstack/_internal/server/statics/index.html +1 -1
- dstack/_internal/server/statics/main-720ce3a11140daa480cc.css +3 -0
- dstack/_internal/server/statics/{main-c51afa7f243e24d3e446.js → main-e79754c136f1d8e4e7e6.js} +12632 -8039
- dstack/_internal/server/statics/{main-c51afa7f243e24d3e446.js.map → main-e79754c136f1d8e4e7e6.js.map} +1 -1
- dstack/_internal/server/testing/common.py +4 -0
- dstack/api/_public/__init__.py +8 -11
- dstack/api/_public/repos.py +0 -21
- dstack/api/_public/runs.py +61 -9
- dstack/api/server/__init__.py +4 -0
- dstack/api/server/_users.py +17 -2
- dstack/version.py +2 -2
- {dstack-0.19.32.dist-info → dstack-0.19.34.dist-info}/METADATA +2 -2
- {dstack-0.19.32.dist-info → dstack-0.19.34.dist-info}/RECORD +53 -51
- dstack/_internal/server/statics/main-56191fbfe77f49b251de.css +0 -3
- {dstack-0.19.32.dist-info → dstack-0.19.34.dist-info}/WHEEL +0 -0
- {dstack-0.19.32.dist-info → dstack-0.19.34.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
52
|
-
|
|
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":
|
|
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
|
|
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
|
|
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
|
|
@@ -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=
|
|
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
|
-
|
|
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,
|
|
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
|
)
|
|
@@ -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(
|
|
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>
|