dstack 0.19.31__py3-none-any.whl → 0.19.33__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of dstack might be problematic. Click here for more details.
- 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 +5 -1
- dstack/_internal/core/backends/datacrunch/compute.py +9 -6
- dstack/_internal/core/backends/gcp/compute.py +137 -7
- dstack/_internal/core/backends/gcp/models.py +7 -0
- dstack/_internal/core/backends/gcp/resources.py +87 -5
- dstack/_internal/core/backends/hotaisle/compute.py +30 -0
- dstack/_internal/core/backends/kubernetes/compute.py +218 -77
- dstack/_internal/core/backends/kubernetes/models.py +4 -2
- dstack/_internal/core/backends/nebius/compute.py +24 -6
- dstack/_internal/core/backends/nebius/configurator.py +15 -0
- dstack/_internal/core/backends/nebius/models.py +57 -5
- dstack/_internal/core/backends/nebius/resources.py +45 -2
- dstack/_internal/core/backends/oci/compute.py +9 -6
- dstack/_internal/core/backends/runpod/compute.py +10 -6
- dstack/_internal/core/backends/vastai/compute.py +3 -1
- dstack/_internal/core/backends/vastai/configurator.py +0 -1
- dstack/_internal/core/compatibility/runs.py +8 -0
- dstack/_internal/core/models/fleets.py +1 -1
- dstack/_internal/core/models/profiles.py +12 -5
- dstack/_internal/core/models/runs.py +3 -2
- dstack/_internal/core/models/users.py +10 -0
- dstack/_internal/core/services/configs/__init__.py +1 -0
- dstack/_internal/server/background/tasks/process_fleets.py +75 -17
- dstack/_internal/server/background/tasks/process_instances.py +6 -4
- dstack/_internal/server/background/tasks/process_running_jobs.py +1 -0
- dstack/_internal/server/background/tasks/process_runs.py +27 -23
- dstack/_internal/server/background/tasks/process_submitted_jobs.py +63 -20
- dstack/_internal/server/migrations/versions/ff1d94f65b08_user_ssh_key.py +34 -0
- dstack/_internal/server/models.py +3 -0
- dstack/_internal/server/routers/runs.py +5 -1
- dstack/_internal/server/routers/users.py +14 -2
- dstack/_internal/server/services/runs.py +9 -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-97c7e184573ca23f9fe4.js} +12218 -7625
- dstack/_internal/server/statics/{main-c51afa7f243e24d3e446.js.map → main-97c7e184573ca23f9fe4.js.map} +1 -1
- dstack/api/_public/__init__.py +9 -12
- dstack/api/_public/repos.py +0 -21
- dstack/api/_public/runs.py +64 -9
- dstack/api/server/_users.py +17 -2
- dstack/version.py +2 -2
- {dstack-0.19.31.dist-info → dstack-0.19.33.dist-info}/METADATA +12 -14
- {dstack-0.19.31.dist-info → dstack-0.19.33.dist-info}/RECORD +52 -51
- dstack/_internal/server/statics/main-56191fbfe77f49b251de.css +0 -3
- {dstack-0.19.31.dist-info → dstack-0.19.33.dist-info}/WHEEL +0 -0
- {dstack-0.19.31.dist-info → dstack-0.19.33.dist-info}/entry_points.txt +0 -0
- {dstack-0.19.31.dist-info → dstack-0.19.33.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -260,7 +260,6 @@ async def _process_submitted_job(session: AsyncSession, job_model: JobModel):
|
|
|
260
260
|
|
|
261
261
|
instance_filters = [
|
|
262
262
|
InstanceModel.deleted == False,
|
|
263
|
-
InstanceModel.total_blocks > InstanceModel.busy_blocks,
|
|
264
263
|
InstanceModel.id.not_in(detaching_instances_ids),
|
|
265
264
|
]
|
|
266
265
|
|
|
@@ -514,9 +513,6 @@ async def _find_optimal_fleet_with_offers(
|
|
|
514
513
|
)
|
|
515
514
|
return run_model.fleet, fleet_instances_with_pool_offers
|
|
516
515
|
|
|
517
|
-
if len(fleet_models) == 0:
|
|
518
|
-
return None, []
|
|
519
|
-
|
|
520
516
|
nodes_required_num = _get_nodes_required_num_for_run(run_spec)
|
|
521
517
|
# The current strategy is first to consider fleets that can accommodate
|
|
522
518
|
# the run without additional provisioning and choose the one with the cheapest pool offer.
|
|
@@ -534,6 +530,7 @@ async def _find_optimal_fleet_with_offers(
|
|
|
534
530
|
]
|
|
535
531
|
] = []
|
|
536
532
|
for candidate_fleet_model in fleet_models:
|
|
533
|
+
candidate_fleet = fleet_model_to_fleet(candidate_fleet_model)
|
|
537
534
|
fleet_instances_with_pool_offers = _get_fleet_instances_with_pool_offers(
|
|
538
535
|
fleet_model=candidate_fleet_model,
|
|
539
536
|
run_spec=run_spec,
|
|
@@ -541,24 +538,21 @@ async def _find_optimal_fleet_with_offers(
|
|
|
541
538
|
master_job_provisioning_data=master_job_provisioning_data,
|
|
542
539
|
volumes=volumes,
|
|
543
540
|
)
|
|
544
|
-
|
|
541
|
+
fleet_has_pool_capacity = nodes_required_num <= len(fleet_instances_with_pool_offers)
|
|
545
542
|
fleet_cheapest_pool_offer = math.inf
|
|
546
543
|
if len(fleet_instances_with_pool_offers) > 0:
|
|
547
544
|
fleet_cheapest_pool_offer = fleet_instances_with_pool_offers[0][1].price
|
|
548
545
|
|
|
549
|
-
candidate_fleet = fleet_model_to_fleet(candidate_fleet_model)
|
|
550
|
-
profile = None
|
|
551
|
-
requirements = None
|
|
552
546
|
try:
|
|
547
|
+
_check_can_create_new_instance_in_fleet(candidate_fleet)
|
|
553
548
|
profile, requirements = _get_run_profile_and_requirements_in_fleet(
|
|
554
549
|
job=job,
|
|
555
550
|
run_spec=run_spec,
|
|
556
551
|
fleet=candidate_fleet,
|
|
557
552
|
)
|
|
558
553
|
except ValueError:
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if profile is not None and requirements is not None:
|
|
554
|
+
fleet_backend_offers = []
|
|
555
|
+
else:
|
|
562
556
|
multinode = (
|
|
563
557
|
candidate_fleet.spec.configuration.placement == InstanceGroupPlacement.CLUSTER
|
|
564
558
|
or job.job_spec.jobs_per_replica > 1
|
|
@@ -579,8 +573,12 @@ async def _find_optimal_fleet_with_offers(
|
|
|
579
573
|
if len(fleet_backend_offers) > 0:
|
|
580
574
|
fleet_cheapest_backend_offer = fleet_backend_offers[0][1].price
|
|
581
575
|
|
|
576
|
+
if not _run_can_fit_into_fleet(run_spec, candidate_fleet):
|
|
577
|
+
logger.debug("Skipping fleet %s from consideration: run cannot fit into fleet")
|
|
578
|
+
continue
|
|
579
|
+
|
|
582
580
|
fleet_priority = (
|
|
583
|
-
not
|
|
581
|
+
not fleet_has_pool_capacity,
|
|
584
582
|
fleet_cheapest_pool_offer,
|
|
585
583
|
fleet_cheapest_backend_offer,
|
|
586
584
|
)
|
|
@@ -593,10 +591,13 @@ async def _find_optimal_fleet_with_offers(
|
|
|
593
591
|
fleet_priority,
|
|
594
592
|
)
|
|
595
593
|
)
|
|
594
|
+
if len(candidate_fleets_with_offers) == 0:
|
|
595
|
+
return None, []
|
|
596
596
|
if run_spec.merged_profile.fleets is None and all(
|
|
597
597
|
t[2] == 0 and t[3] == 0 for t in candidate_fleets_with_offers
|
|
598
598
|
):
|
|
599
|
-
# If fleets are not specified and no fleets have available pool
|
|
599
|
+
# If fleets are not specified and no fleets have available pool
|
|
600
|
+
# or backend offers, create a new fleet.
|
|
600
601
|
# This is for compatibility with non-fleet-first UX when runs created new fleets
|
|
601
602
|
# if there are no instances to reuse.
|
|
602
603
|
return None, []
|
|
@@ -616,6 +617,39 @@ def _get_nodes_required_num_for_run(run_spec: RunSpec) -> int:
|
|
|
616
617
|
return nodes_required_num
|
|
617
618
|
|
|
618
619
|
|
|
620
|
+
def _run_can_fit_into_fleet(run_spec: RunSpec, fleet: Fleet) -> bool:
|
|
621
|
+
"""
|
|
622
|
+
Returns `False` if the run cannot fit into fleet for sure.
|
|
623
|
+
This is helpful heuristic to avoid even considering fleets too small for a run.
|
|
624
|
+
A run may not fit even if this function returns `True`.
|
|
625
|
+
This will lead to some jobs failing due to exceeding `nodes.max`
|
|
626
|
+
or more than `nodes.max` instances being provisioned
|
|
627
|
+
and eventually removed by the fleet consolidation logic.
|
|
628
|
+
"""
|
|
629
|
+
# No check for cloud fleets with blocks > 1 since we don't know
|
|
630
|
+
# how many jobs such fleets can accommodate.
|
|
631
|
+
nodes_required_num = _get_nodes_required_num_for_run(run_spec)
|
|
632
|
+
if (
|
|
633
|
+
fleet.spec.configuration.nodes is not None
|
|
634
|
+
and fleet.spec.configuration.blocks == 1
|
|
635
|
+
and fleet.spec.configuration.nodes.max is not None
|
|
636
|
+
):
|
|
637
|
+
busy_instances = [i for i in fleet.instances if i.busy_blocks > 0]
|
|
638
|
+
fleet_available_capacity = fleet.spec.configuration.nodes.max - len(busy_instances)
|
|
639
|
+
if fleet_available_capacity < nodes_required_num:
|
|
640
|
+
return False
|
|
641
|
+
elif fleet.spec.configuration.ssh_config is not None:
|
|
642
|
+
# Currently assume that each idle block can run a job.
|
|
643
|
+
# TODO: Take resources / eligible offers into account.
|
|
644
|
+
total_idle_blocks = 0
|
|
645
|
+
for instance in fleet.instances:
|
|
646
|
+
total_blocks = instance.total_blocks or 1
|
|
647
|
+
total_idle_blocks += total_blocks - instance.busy_blocks
|
|
648
|
+
if total_idle_blocks < nodes_required_num:
|
|
649
|
+
return False
|
|
650
|
+
return True
|
|
651
|
+
|
|
652
|
+
|
|
619
653
|
def _get_fleet_instances_with_pool_offers(
|
|
620
654
|
fleet_model: FleetModel,
|
|
621
655
|
run_spec: RunSpec,
|
|
@@ -713,6 +747,7 @@ async def _run_job_on_new_instance(
|
|
|
713
747
|
if fleet_model is not None:
|
|
714
748
|
fleet = fleet_model_to_fleet(fleet_model)
|
|
715
749
|
try:
|
|
750
|
+
_check_can_create_new_instance_in_fleet(fleet)
|
|
716
751
|
profile, requirements = _get_run_profile_and_requirements_in_fleet(
|
|
717
752
|
job=job,
|
|
718
753
|
run_spec=run.run_spec,
|
|
@@ -787,8 +822,6 @@ def _get_run_profile_and_requirements_in_fleet(
|
|
|
787
822
|
run_spec: RunSpec,
|
|
788
823
|
fleet: Fleet,
|
|
789
824
|
) -> tuple[Profile, Requirements]:
|
|
790
|
-
if not _check_can_create_new_instance_in_fleet(fleet):
|
|
791
|
-
raise ValueError("Cannot fit new instance into fleet")
|
|
792
825
|
profile = combine_fleet_and_run_profiles(fleet.spec.merged_profile, run_spec.merged_profile)
|
|
793
826
|
if profile is None:
|
|
794
827
|
raise ValueError("Cannot combine fleet profile")
|
|
@@ -801,13 +834,23 @@ def _get_run_profile_and_requirements_in_fleet(
|
|
|
801
834
|
return profile, requirements
|
|
802
835
|
|
|
803
836
|
|
|
804
|
-
def _check_can_create_new_instance_in_fleet(fleet: Fleet)
|
|
837
|
+
def _check_can_create_new_instance_in_fleet(fleet: Fleet):
|
|
838
|
+
if not _can_create_new_instance_in_fleet(fleet):
|
|
839
|
+
raise ValueError("Cannot fit new instance into fleet")
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _can_create_new_instance_in_fleet(fleet: Fleet) -> bool:
|
|
805
843
|
if fleet.spec.configuration.ssh_config is not None:
|
|
806
844
|
return False
|
|
807
|
-
|
|
808
|
-
#
|
|
809
|
-
#
|
|
810
|
-
|
|
845
|
+
active_instances = [i for i in fleet.instances if i.status.is_active()]
|
|
846
|
+
# nodes.max is a soft limit that can be exceeded when provisioning concurrently.
|
|
847
|
+
# The fleet consolidation logic will remove redundant nodes eventually.
|
|
848
|
+
if (
|
|
849
|
+
fleet.spec.configuration.nodes is not None
|
|
850
|
+
and fleet.spec.configuration.nodes.max is not None
|
|
851
|
+
and len(active_instances) >= fleet.spec.configuration.nodes.max
|
|
852
|
+
):
|
|
853
|
+
return False
|
|
811
854
|
return True
|
|
812
855
|
|
|
813
856
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""user.ssh_key
|
|
2
|
+
|
|
3
|
+
Revision ID: ff1d94f65b08
|
|
4
|
+
Revises: 2498ab323443
|
|
5
|
+
Create Date: 2025-10-09 20:31:31.166786
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
from alembic import op
|
|
11
|
+
|
|
12
|
+
# revision identifiers, used by Alembic.
|
|
13
|
+
revision = "ff1d94f65b08"
|
|
14
|
+
down_revision = "2498ab323443"
|
|
15
|
+
branch_labels = None
|
|
16
|
+
depends_on = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade() -> None:
|
|
20
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
21
|
+
with op.batch_alter_table("users", schema=None) as batch_op:
|
|
22
|
+
batch_op.add_column(sa.Column("ssh_private_key", sa.Text(), nullable=True))
|
|
23
|
+
batch_op.add_column(sa.Column("ssh_public_key", sa.Text(), nullable=True))
|
|
24
|
+
|
|
25
|
+
# ### end Alembic commands ###
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def downgrade() -> None:
|
|
29
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
30
|
+
with op.batch_alter_table("users", schema=None) as batch_op:
|
|
31
|
+
batch_op.drop_column("ssh_public_key")
|
|
32
|
+
batch_op.drop_column("ssh_private_key")
|
|
33
|
+
|
|
34
|
+
# ### end Alembic commands ###
|
|
@@ -190,6 +190,9 @@ class UserModel(BaseModel):
|
|
|
190
190
|
# deactivated users cannot access API
|
|
191
191
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
192
192
|
|
|
193
|
+
ssh_private_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
194
|
+
ssh_public_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
195
|
+
|
|
193
196
|
email: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
|
194
197
|
|
|
195
198
|
projects_quota: Mapped[int] = mapped_column(
|
|
@@ -17,7 +17,7 @@ from dstack._internal.server.schemas.runs import (
|
|
|
17
17
|
SubmitRunRequest,
|
|
18
18
|
)
|
|
19
19
|
from dstack._internal.server.security.permissions import Authenticated, ProjectMember
|
|
20
|
-
from dstack._internal.server.services import runs
|
|
20
|
+
from dstack._internal.server.services import runs, users
|
|
21
21
|
from dstack._internal.server.utils.routers import (
|
|
22
22
|
CustomORJSONResponse,
|
|
23
23
|
get_base_api_additional_responses,
|
|
@@ -111,6 +111,8 @@ async def get_plan(
|
|
|
111
111
|
This is an optional step before calling `/apply`.
|
|
112
112
|
"""
|
|
113
113
|
user, project = user_project
|
|
114
|
+
if not user.ssh_public_key and not body.run_spec.ssh_key_pub:
|
|
115
|
+
await users.refresh_ssh_key(session=session, user=user, username=user.name)
|
|
114
116
|
run_plan = await runs.get_plan(
|
|
115
117
|
session=session,
|
|
116
118
|
project=project,
|
|
@@ -137,6 +139,8 @@ async def apply_plan(
|
|
|
137
139
|
If the existing run is active and cannot be updated, it must be stopped first.
|
|
138
140
|
"""
|
|
139
141
|
user, project = user_project
|
|
142
|
+
if not user.ssh_public_key and not body.plan.run_spec.ssh_key_pub:
|
|
143
|
+
await users.refresh_ssh_key(session=session, user=user, username=user.name)
|
|
140
144
|
return CustomORJSONResponse(
|
|
141
145
|
await runs.apply_plan(
|
|
142
146
|
session=session,
|
|
@@ -36,11 +36,11 @@ async def list_users(
|
|
|
36
36
|
return CustomORJSONResponse(await users.list_users_for_user(session=session, user=user))
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
@router.post("/get_my_user", response_model=
|
|
39
|
+
@router.post("/get_my_user", response_model=UserWithCreds)
|
|
40
40
|
async def get_my_user(
|
|
41
41
|
user: UserModel = Depends(Authenticated()),
|
|
42
42
|
):
|
|
43
|
-
return CustomORJSONResponse(users.
|
|
43
|
+
return CustomORJSONResponse(users.user_model_to_user_with_creds(user))
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
@router.post("/get_user", response_model=UserWithCreds)
|
|
@@ -91,6 +91,18 @@ async def update_user(
|
|
|
91
91
|
return CustomORJSONResponse(users.user_model_to_user(res))
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
@router.post("/refresh_ssh_key", response_model=UserWithCreds)
|
|
95
|
+
async def refresh_ssh_key(
|
|
96
|
+
body: RefreshTokenRequest,
|
|
97
|
+
session: AsyncSession = Depends(get_session),
|
|
98
|
+
user: UserModel = Depends(Authenticated()),
|
|
99
|
+
):
|
|
100
|
+
res = await users.refresh_ssh_key(session=session, user=user, username=body.username)
|
|
101
|
+
if res is None:
|
|
102
|
+
raise ResourceNotExistsError()
|
|
103
|
+
return CustomORJSONResponse(users.user_model_to_user_with_creds(res))
|
|
104
|
+
|
|
105
|
+
|
|
94
106
|
@router.post("/refresh_token", response_model=UserWithCreds)
|
|
95
107
|
async def refresh_token(
|
|
96
108
|
body: RefreshTokenRequest,
|
|
@@ -317,7 +317,7 @@ async def get_plan(
|
|
|
317
317
|
spec=effective_run_spec,
|
|
318
318
|
)
|
|
319
319
|
effective_run_spec = RunSpec.parse_obj(effective_run_spec.dict())
|
|
320
|
-
_validate_run_spec_and_set_defaults(effective_run_spec)
|
|
320
|
+
_validate_run_spec_and_set_defaults(user, effective_run_spec)
|
|
321
321
|
|
|
322
322
|
profile = effective_run_spec.merged_profile
|
|
323
323
|
creation_policy = profile.creation_policy
|
|
@@ -422,7 +422,7 @@ async def apply_plan(
|
|
|
422
422
|
)
|
|
423
423
|
# Spec must be copied by parsing to calculate merged_profile
|
|
424
424
|
run_spec = RunSpec.parse_obj(run_spec.dict())
|
|
425
|
-
_validate_run_spec_and_set_defaults(run_spec)
|
|
425
|
+
_validate_run_spec_and_set_defaults(user, run_spec)
|
|
426
426
|
if run_spec.run_name is None:
|
|
427
427
|
return await submit_run(
|
|
428
428
|
session=session,
|
|
@@ -489,7 +489,7 @@ async def submit_run(
|
|
|
489
489
|
project: ProjectModel,
|
|
490
490
|
run_spec: RunSpec,
|
|
491
491
|
) -> Run:
|
|
492
|
-
_validate_run_spec_and_set_defaults(run_spec)
|
|
492
|
+
_validate_run_spec_and_set_defaults(user, run_spec)
|
|
493
493
|
repo = await _get_run_repo_or_error(
|
|
494
494
|
session=session,
|
|
495
495
|
project=project,
|
|
@@ -981,7 +981,7 @@ def _get_job_submission_cost(job_submission: JobSubmission) -> float:
|
|
|
981
981
|
return job_submission.job_provisioning_data.price * duration_hours
|
|
982
982
|
|
|
983
983
|
|
|
984
|
-
def _validate_run_spec_and_set_defaults(run_spec: RunSpec):
|
|
984
|
+
def _validate_run_spec_and_set_defaults(user: UserModel, run_spec: RunSpec):
|
|
985
985
|
# This function may set defaults for null run_spec values,
|
|
986
986
|
# although most defaults are resolved when building job_spec
|
|
987
987
|
# so that we can keep both the original user-supplied value (null in run_spec)
|
|
@@ -1031,6 +1031,11 @@ def _validate_run_spec_and_set_defaults(run_spec: RunSpec):
|
|
|
1031
1031
|
if run_spec.configuration.priority is None:
|
|
1032
1032
|
run_spec.configuration.priority = RUN_PRIORITY_DEFAULT
|
|
1033
1033
|
set_resources_defaults(run_spec.configuration.resources)
|
|
1034
|
+
if run_spec.ssh_key_pub is None:
|
|
1035
|
+
if user.ssh_public_key:
|
|
1036
|
+
run_spec.ssh_key_pub = user.ssh_public_key
|
|
1037
|
+
else:
|
|
1038
|
+
raise ServerClientError("ssh_key_pub must be set if the user has no ssh_public_key")
|
|
1034
1039
|
|
|
1035
1040
|
|
|
1036
1041
|
_UPDATABLE_SPEC_FIELDS = ["configuration_path", "configuration"]
|
|
@@ -12,6 +12,7 @@ from dstack._internal.core.errors import ResourceExistsError, ServerClientError
|
|
|
12
12
|
from dstack._internal.core.models.users import (
|
|
13
13
|
GlobalRole,
|
|
14
14
|
User,
|
|
15
|
+
UserHookConfig,
|
|
15
16
|
UserPermissions,
|
|
16
17
|
UserTokenCreds,
|
|
17
18
|
UserWithCreds,
|
|
@@ -19,6 +20,8 @@ from dstack._internal.core.models.users import (
|
|
|
19
20
|
from dstack._internal.server.models import DecryptedString, UserModel
|
|
20
21
|
from dstack._internal.server.services.permissions import get_default_permissions
|
|
21
22
|
from dstack._internal.server.utils.routers import error_forbidden
|
|
23
|
+
from dstack._internal.utils.common import run_async
|
|
24
|
+
from dstack._internal.utils.crypto import generate_rsa_key_pair_bytes
|
|
22
25
|
from dstack._internal.utils.logging import get_logger
|
|
23
26
|
|
|
24
27
|
logger = get_logger(__name__)
|
|
@@ -77,6 +80,7 @@ async def create_user(
|
|
|
77
80
|
email: Optional[str] = None,
|
|
78
81
|
active: bool = True,
|
|
79
82
|
token: Optional[str] = None,
|
|
83
|
+
config: Optional[UserHookConfig] = None,
|
|
80
84
|
) -> UserModel:
|
|
81
85
|
validate_username(username)
|
|
82
86
|
user_model = await get_user_model_by_name(session=session, username=username, ignore_case=True)
|
|
@@ -84,6 +88,7 @@ async def create_user(
|
|
|
84
88
|
raise ResourceExistsError()
|
|
85
89
|
if token is None:
|
|
86
90
|
token = str(uuid.uuid4())
|
|
91
|
+
private_bytes, public_bytes = await run_async(generate_rsa_key_pair_bytes, username)
|
|
87
92
|
user = UserModel(
|
|
88
93
|
id=uuid.uuid4(),
|
|
89
94
|
name=username,
|
|
@@ -92,11 +97,13 @@ async def create_user(
|
|
|
92
97
|
token_hash=get_token_hash(token),
|
|
93
98
|
email=email,
|
|
94
99
|
active=active,
|
|
100
|
+
ssh_private_key=private_bytes.decode(),
|
|
101
|
+
ssh_public_key=public_bytes.decode(),
|
|
95
102
|
)
|
|
96
103
|
session.add(user)
|
|
97
104
|
await session.commit()
|
|
98
105
|
for func in _CREATE_USER_HOOKS:
|
|
99
|
-
await func(session, user)
|
|
106
|
+
await func(session, user, config)
|
|
100
107
|
return user
|
|
101
108
|
|
|
102
109
|
|
|
@@ -120,6 +127,27 @@ async def update_user(
|
|
|
120
127
|
return await get_user_model_by_name_or_error(session=session, username=username)
|
|
121
128
|
|
|
122
129
|
|
|
130
|
+
async def refresh_ssh_key(
|
|
131
|
+
session: AsyncSession,
|
|
132
|
+
user: UserModel,
|
|
133
|
+
username: str,
|
|
134
|
+
) -> Optional[UserModel]:
|
|
135
|
+
logger.debug("Refreshing SSH key for user [code]%s[/code]", username)
|
|
136
|
+
if user.global_role != GlobalRole.ADMIN and user.name != username:
|
|
137
|
+
raise error_forbidden()
|
|
138
|
+
private_bytes, public_bytes = await run_async(generate_rsa_key_pair_bytes, username)
|
|
139
|
+
await session.execute(
|
|
140
|
+
update(UserModel)
|
|
141
|
+
.where(UserModel.name == username)
|
|
142
|
+
.values(
|
|
143
|
+
ssh_private_key=private_bytes.decode(),
|
|
144
|
+
ssh_public_key=public_bytes.decode(),
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
await session.commit()
|
|
148
|
+
return await get_user_model_by_name(session=session, username=username)
|
|
149
|
+
|
|
150
|
+
|
|
123
151
|
async def refresh_user_token(
|
|
124
152
|
session: AsyncSession,
|
|
125
153
|
user: UserModel,
|
|
@@ -199,6 +227,7 @@ def user_model_to_user(user_model: UserModel) -> User:
|
|
|
199
227
|
email=user_model.email,
|
|
200
228
|
active=user_model.active,
|
|
201
229
|
permissions=get_user_permissions(user_model),
|
|
230
|
+
ssh_public_key=user_model.ssh_public_key,
|
|
202
231
|
)
|
|
203
232
|
|
|
204
233
|
|
|
@@ -211,7 +240,9 @@ def user_model_to_user_with_creds(user_model: UserModel) -> UserWithCreds:
|
|
|
211
240
|
email=user_model.email,
|
|
212
241
|
active=user_model.active,
|
|
213
242
|
permissions=get_user_permissions(user_model),
|
|
243
|
+
ssh_public_key=user_model.ssh_public_key,
|
|
214
244
|
creds=UserTokenCreds(token=user_model.token.get_plaintext_or_error()),
|
|
245
|
+
ssh_private_key=user_model.ssh_private_key,
|
|
215
246
|
)
|
|
216
247
|
|
|
217
248
|
|
|
@@ -238,7 +269,9 @@ def is_valid_username(username: str) -> bool:
|
|
|
238
269
|
_CREATE_USER_HOOKS = []
|
|
239
270
|
|
|
240
271
|
|
|
241
|
-
def register_create_user_hook(
|
|
272
|
+
def register_create_user_hook(
|
|
273
|
+
func: Callable[[AsyncSession, UserModel, Optional[UserHookConfig]], Awaitable[None]],
|
|
274
|
+
):
|
|
242
275
|
_CREATE_USER_HOOKS.append(func)
|
|
243
276
|
|
|
244
277
|
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><title>dstack</title><meta name="description" content="Get GPUs at the best prices and availability from a wide range of providers. No cloud account of your own is required.
|
|
2
2
|
"/><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet"><meta name="og:title" content="dstack"><meta name="og:type" content="article"><meta name="og:image" content="/splash_thumbnail.png"><meta name="og:description" content="Get GPUs at the best prices and availability from a wide range of providers. No cloud account of your own is required.
|
|
3
|
-
"><link rel="icon" type="image/x-icon" href="/assets/favicon.ico"><link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"><link rel="icon" type="image/png" sizes="48x48" href="/assets/favicon-48x48.png"><link rel="manifest" href="/assets/manifest.webmanifest"><meta name="mobile-web-app-capable" content="yes"><meta name="theme-color" content="#fff"><meta name="application-name" content="dstackai"><link rel="apple-touch-icon" sizes="57x57" href="/assets/apple-touch-icon-57x57.png"><link rel="apple-touch-icon" sizes="60x60" href="/assets/apple-touch-icon-60x60.png"><link rel="apple-touch-icon" sizes="72x72" href="/assets/apple-touch-icon-72x72.png"><link rel="apple-touch-icon" sizes="76x76" href="/assets/apple-touch-icon-76x76.png"><link rel="apple-touch-icon" sizes="114x114" href="/assets/apple-touch-icon-114x114.png"><link rel="apple-touch-icon" sizes="120x120" href="/assets/apple-touch-icon-120x120.png"><link rel="apple-touch-icon" sizes="144x144" href="/assets/apple-touch-icon-144x144.png"><link rel="apple-touch-icon" sizes="152x152" href="/assets/apple-touch-icon-152x152.png"><link rel="apple-touch-icon" sizes="167x167" href="/assets/apple-touch-icon-167x167.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-180x180.png"><link rel="apple-touch-icon" sizes="1024x1024" href="/assets/apple-touch-icon-1024x1024.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="dstackai"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-640x1136.png"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1136x640.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-750x1334.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1334x750.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1125x2436.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2436x1125.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1170x2532.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2532x1170.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1179x2556.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2556x1179.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-828x1792.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1792x828.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2688.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2688x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2208.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2208x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1284x2778.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2778x1284.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1290x2796.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2796x1290.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1488x2266.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2266x1488.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1536x2048.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2048x1536.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1620x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1620.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1640x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1640.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2388.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2388x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2224.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2224x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-2048x2732.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2732x2048.png"><meta name="msapplication-TileColor" content="#fff"><meta name="msapplication-TileImage" content="/assets/mstile-144x144.png"><meta name="msapplication-config" content="/assets/browserconfig.xml"><link rel="yandex-tableau-widget" href="/assets/yandex-browser-manifest.json"><script defer="defer" src="/main-c51afa7f243e24d3e446.js"></script><link href="/main-56191fbfe77f49b251de.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div class="b-page-header" id="header"></div><div id="root"></div><script async src="https://widget.kapa.ai/kapa-widget.bundle.js" data-website-id="11a9339d-20ce-4ddb-9ba3-1b6e29afe8eb" data-project-name="dstack" data-project-color="rgba(0, 0, 0, 0.87)" data-font-size-lg="0.78rem" data-button-hide="true" data-modal-image="/logo-notext.svg" data-modal-z-index="1100" data-modal-title="Ask me anything" data-font-family='metro-web, Metro, -apple-system, "system-ui", "Segoe UI", Roboto' data-project-logo="/assets/images/kapa.svg" data-modal-disclaimer="This is a custom LLM for dstack with access to Documentation, API references and GitHub issues. This feature is experimental - Give it a try!" data-user-analytics-fingerprint-enabled="true"></script></body></html>
|
|
3
|
+
"><link rel="icon" type="image/x-icon" href="/assets/favicon.ico"><link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"><link rel="icon" type="image/png" sizes="48x48" href="/assets/favicon-48x48.png"><link rel="manifest" href="/assets/manifest.webmanifest"><meta name="mobile-web-app-capable" content="yes"><meta name="theme-color" content="#fff"><meta name="application-name" content="dstackai"><link rel="apple-touch-icon" sizes="57x57" href="/assets/apple-touch-icon-57x57.png"><link rel="apple-touch-icon" sizes="60x60" href="/assets/apple-touch-icon-60x60.png"><link rel="apple-touch-icon" sizes="72x72" href="/assets/apple-touch-icon-72x72.png"><link rel="apple-touch-icon" sizes="76x76" href="/assets/apple-touch-icon-76x76.png"><link rel="apple-touch-icon" sizes="114x114" href="/assets/apple-touch-icon-114x114.png"><link rel="apple-touch-icon" sizes="120x120" href="/assets/apple-touch-icon-120x120.png"><link rel="apple-touch-icon" sizes="144x144" href="/assets/apple-touch-icon-144x144.png"><link rel="apple-touch-icon" sizes="152x152" href="/assets/apple-touch-icon-152x152.png"><link rel="apple-touch-icon" sizes="167x167" href="/assets/apple-touch-icon-167x167.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-180x180.png"><link rel="apple-touch-icon" sizes="1024x1024" href="/assets/apple-touch-icon-1024x1024.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="dstackai"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-640x1136.png"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1136x640.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-750x1334.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1334x750.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1125x2436.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2436x1125.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1170x2532.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2532x1170.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1179x2556.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2556x1179.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-828x1792.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1792x828.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2688.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2688x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2208.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2208x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1284x2778.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2778x1284.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1290x2796.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2796x1290.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1488x2266.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2266x1488.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1536x2048.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2048x1536.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1620x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1620.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1640x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1640.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2388.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2388x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2224.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2224x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-2048x2732.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2732x2048.png"><meta name="msapplication-TileColor" content="#fff"><meta name="msapplication-TileImage" content="/assets/mstile-144x144.png"><meta name="msapplication-config" content="/assets/browserconfig.xml"><link rel="yandex-tableau-widget" href="/assets/yandex-browser-manifest.json"><script defer="defer" src="/main-97c7e184573ca23f9fe4.js"></script><link href="/main-720ce3a11140daa480cc.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div class="b-page-header" id="header"></div><div id="root"></div><script async src="https://widget.kapa.ai/kapa-widget.bundle.js" data-website-id="11a9339d-20ce-4ddb-9ba3-1b6e29afe8eb" data-project-name="dstack" data-project-color="rgba(0, 0, 0, 0.87)" data-font-size-lg="0.78rem" data-button-hide="true" data-modal-image="/logo-notext.svg" data-modal-z-index="1100" data-modal-title="Ask me anything" data-font-family='metro-web, Metro, -apple-system, "system-ui", "Segoe UI", Roboto' data-project-logo="/assets/images/kapa.svg" data-modal-disclaimer="This is a custom LLM for dstack with access to Documentation, API references and GitHub issues. This feature is experimental - Give it a try!" data-user-analytics-fingerprint-enabled="true"></script></body></html>
|