dstack 0.19.15rc1__py3-none-any.whl → 0.19.16__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (57) hide show
  1. dstack/_internal/core/backends/cloudrift/__init__.py +0 -0
  2. dstack/_internal/core/backends/cloudrift/api_client.py +208 -0
  3. dstack/_internal/core/backends/cloudrift/backend.py +16 -0
  4. dstack/_internal/core/backends/cloudrift/compute.py +138 -0
  5. dstack/_internal/core/backends/cloudrift/configurator.py +66 -0
  6. dstack/_internal/core/backends/cloudrift/models.py +40 -0
  7. dstack/_internal/core/backends/configurators.py +9 -0
  8. dstack/_internal/core/backends/models.py +7 -0
  9. dstack/_internal/core/compatibility/logs.py +15 -0
  10. dstack/_internal/core/compatibility/runs.py +2 -0
  11. dstack/_internal/core/models/backends/base.py +2 -0
  12. dstack/_internal/core/models/configurations.py +22 -2
  13. dstack/_internal/core/models/logs.py +2 -1
  14. dstack/_internal/core/models/runs.py +10 -1
  15. dstack/_internal/server/background/tasks/process_fleets.py +1 -1
  16. dstack/_internal/server/background/tasks/process_gateways.py +1 -1
  17. dstack/_internal/server/background/tasks/process_instances.py +1 -1
  18. dstack/_internal/server/background/tasks/process_placement_groups.py +1 -1
  19. dstack/_internal/server/background/tasks/process_running_jobs.py +1 -1
  20. dstack/_internal/server/background/tasks/process_runs.py +21 -2
  21. dstack/_internal/server/background/tasks/process_submitted_jobs.py +10 -4
  22. dstack/_internal/server/background/tasks/process_terminating_jobs.py +2 -2
  23. dstack/_internal/server/background/tasks/process_volumes.py +1 -1
  24. dstack/_internal/server/routers/gateways.py +6 -3
  25. dstack/_internal/server/routers/projects.py +63 -0
  26. dstack/_internal/server/routers/prometheus.py +5 -5
  27. dstack/_internal/server/schemas/logs.py +10 -1
  28. dstack/_internal/server/schemas/projects.py +12 -0
  29. dstack/_internal/server/security/permissions.py +75 -2
  30. dstack/_internal/server/services/fleets.py +1 -1
  31. dstack/_internal/server/services/gateways/__init__.py +1 -1
  32. dstack/_internal/server/services/jobs/configurators/base.py +7 -1
  33. dstack/_internal/server/services/logs/aws.py +38 -38
  34. dstack/_internal/server/services/logs/filelog.py +48 -14
  35. dstack/_internal/server/services/logs/gcp.py +17 -16
  36. dstack/_internal/server/services/projects.py +164 -5
  37. dstack/_internal/server/services/prometheus/__init__.py +0 -0
  38. dstack/_internal/server/services/prometheus/client_metrics.py +52 -0
  39. dstack/_internal/server/services/runs.py +3 -3
  40. dstack/_internal/server/services/services/__init__.py +2 -1
  41. dstack/_internal/server/services/users.py +1 -3
  42. dstack/_internal/server/services/volumes.py +1 -1
  43. dstack/_internal/server/statics/index.html +1 -1
  44. dstack/_internal/server/statics/{main-0ac1e1583684417ae4d1.js → main-a4eafa74304e587d037c.js} +51 -43
  45. dstack/_internal/server/statics/{main-0ac1e1583684417ae4d1.js.map → main-a4eafa74304e587d037c.js.map} +1 -1
  46. dstack/_internal/server/statics/{main-f39c418b05fe14772dd8.css → main-f53d6d0d42f8d61df1de.css} +1 -1
  47. dstack/_internal/settings.py +1 -0
  48. dstack/api/_public/runs.py +6 -5
  49. dstack/api/server/_logs.py +5 -1
  50. dstack/api/server/_projects.py +24 -0
  51. dstack/version.py +1 -1
  52. {dstack-0.19.15rc1.dist-info → dstack-0.19.16.dist-info}/METADATA +1 -1
  53. {dstack-0.19.15rc1.dist-info → dstack-0.19.16.dist-info}/RECORD +57 -48
  54. /dstack/_internal/server/services/{prometheus.py → prometheus/custom_metrics.py} +0 -0
  55. {dstack-0.19.15rc1.dist-info → dstack-0.19.16.dist-info}/WHEEL +0 -0
  56. {dstack-0.19.15rc1.dist-info → dstack-0.19.16.dist-info}/entry_points.txt +0 -0
  57. {dstack-0.19.15rc1.dist-info → dstack-0.19.16.dist-info}/licenses/LICENSE.md +0 -0
@@ -14,6 +14,7 @@ from dstack._internal.server.schemas.logs import PollLogsRequest
14
14
  from dstack._internal.server.schemas.runner import LogEvent as RunnerLogEvent
15
15
  from dstack._internal.server.services.logs.base import (
16
16
  LogStorage,
17
+ LogStorageError,
17
18
  b64encode_raw_message,
18
19
  unix_time_ms_to_datetime,
19
20
  )
@@ -29,7 +30,9 @@ class FileLogStorage(LogStorage):
29
30
  self.root = Path(root)
30
31
 
31
32
  def poll_logs(self, project: ProjectModel, request: PollLogsRequest) -> JobSubmissionLogs:
32
- # TODO Respect request.limit to support pagination
33
+ if request.descending:
34
+ raise LogStorageError("descending: true is not supported")
35
+
33
36
  log_producer = LogProducer.RUNNER if request.diagnose else LogProducer.JOB
34
37
  log_file_path = self._get_log_file_path(
35
38
  project_name=project.name,
@@ -37,22 +40,53 @@ class FileLogStorage(LogStorage):
37
40
  job_submission_id=request.job_submission_id,
38
41
  producer=log_producer,
39
42
  )
43
+
44
+ start_line = 0
45
+ if request.next_token:
46
+ try:
47
+ start_line = int(request.next_token)
48
+ if start_line < 0:
49
+ raise LogStorageError(
50
+ f"Invalid next_token: {request.next_token}. Must be a non-negative integer."
51
+ )
52
+ except ValueError:
53
+ raise LogStorageError(
54
+ f"Invalid next_token: {request.next_token}. Must be a valid integer."
55
+ )
56
+
40
57
  logs = []
58
+ next_token = None
59
+ current_line = 0
60
+
41
61
  try:
42
62
  with open(log_file_path) as f:
43
- for line in f:
44
- log_event = LogEvent.__response__.parse_raw(line)
45
- if request.start_time and log_event.timestamp <= request.start_time:
46
- continue
47
- if request.end_time is None or log_event.timestamp < request.end_time:
48
- logs.append(log_event)
49
- else:
50
- break
51
- except IOError:
52
- pass
53
- if request.descending:
54
- logs = list(reversed(logs))
55
- return JobSubmissionLogs(logs=logs)
63
+ lines = f.readlines()
64
+
65
+ for i, line in enumerate(lines):
66
+ if current_line < start_line:
67
+ current_line += 1
68
+ continue
69
+
70
+ log_event = LogEvent.__response__.parse_raw(line)
71
+ current_line += 1
72
+
73
+ if request.start_time and log_event.timestamp <= request.start_time:
74
+ continue
75
+ if request.end_time is not None and log_event.timestamp >= request.end_time:
76
+ break
77
+
78
+ logs.append(log_event)
79
+
80
+ if len(logs) >= request.limit:
81
+ # Only set next_token if there are more lines to read
82
+ if current_line < len(lines):
83
+ next_token = str(current_line)
84
+ break
85
+
86
+ except IOError as e:
87
+ raise LogStorageError(f"Failed to read log file {log_file_path}: {e}")
88
+
89
+ return JobSubmissionLogs(logs=logs, next_token=next_token)
56
90
 
57
91
  def write_logs(
58
92
  self,
@@ -1,5 +1,4 @@
1
- import time
2
- from typing import Iterable, List
1
+ from typing import List
3
2
  from uuid import UUID
4
3
 
5
4
  from dstack._internal.core.errors import ServerClientError
@@ -25,7 +24,8 @@ GCP_LOGGING_AVAILABLE = True
25
24
  try:
26
25
  import google.api_core.exceptions
27
26
  import google.auth.exceptions
28
- from google.cloud import logging
27
+ from google.cloud import logging_v2
28
+ from google.cloud.logging_v2.types import ListLogEntriesRequest
29
29
  except ImportError:
30
30
  GCP_LOGGING_AVAILABLE = False
31
31
 
@@ -50,7 +50,7 @@ class GCPLogStorage(LogStorage):
50
50
 
51
51
  def __init__(self, project_id: str):
52
52
  try:
53
- self.client = logging.Client(project=project_id)
53
+ self.client = logging_v2.Client(project=project_id)
54
54
  self.logger = self.client.logger(name=self.LOG_NAME)
55
55
  self.logger.list_entries(max_results=1)
56
56
  # Python client doesn't seem to support dry_run,
@@ -64,6 +64,7 @@ class GCPLogStorage(LogStorage):
64
64
  raise LogStorageError("Insufficient permissions")
65
65
 
66
66
  def poll_logs(self, project: ProjectModel, request: PollLogsRequest) -> JobSubmissionLogs:
67
+ # TODO: GCP may return logs in random order when events have the same timestamp.
67
68
  producer = LogProducer.RUNNER if request.diagnose else LogProducer.JOB
68
69
  stream_name = self._get_stream_name(
69
70
  project_name=project.name,
@@ -78,23 +79,27 @@ class GCPLogStorage(LogStorage):
78
79
  log_filters.append(f'timestamp < "{request.end_time.isoformat()}"')
79
80
  log_filter = " AND ".join(log_filters)
80
81
 
81
- order_by = logging.DESCENDING if request.descending else logging.ASCENDING
82
+ order_by = logging_v2.DESCENDING if request.descending else logging_v2.ASCENDING
82
83
  try:
83
- entries: Iterable[logging.LogEntry] = self.logger.list_entries(
84
- filter_=log_filter,
84
+ # Use low-level API to get access to next_page_token
85
+ request_obj = ListLogEntriesRequest(
86
+ resource_names=[f"projects/{self.client.project}"],
87
+ filter=log_filter,
85
88
  order_by=order_by,
86
- max_results=request.limit,
87
- # Specify max possible page_size (<=1000) to reduce number of API calls.
88
89
  page_size=request.limit,
90
+ page_token=request.next_token,
89
91
  )
92
+ response = self.client._logging_api._gapic_api.list_log_entries(request=request_obj)
93
+
90
94
  logs = [
91
95
  LogEvent(
92
96
  timestamp=entry.timestamp,
93
- message=entry.payload["message"],
97
+ message=entry.json_payload.get("message"),
94
98
  log_source=LogEventSource.STDOUT,
95
99
  )
96
- for entry in entries
100
+ for entry in response.entries
97
101
  ]
102
+ next_token = response.next_page_token or None
98
103
  except google.api_core.exceptions.ResourceExhausted as e:
99
104
  logger.warning("GCP Logging exception: %s", repr(e))
100
105
  # GCP Logging has severely low quota of 60 reads/min for entries.list
@@ -102,11 +107,7 @@ class GCPLogStorage(LogStorage):
102
107
  "GCP Logging read request limit exceeded."
103
108
  " It's recommended to increase default entries.list request quota from 60 per minute."
104
109
  )
105
- # We intentionally make reading logs slow to prevent hitting GCP quota.
106
- # This doesn't help with many concurrent clients but
107
- # should help with one client reading all logs sequentially.
108
- time.sleep(1)
109
- return JobSubmissionLogs(logs=logs)
110
+ return JobSubmissionLogs(logs=logs, next_token=next_token if len(logs) > 0 else None)
110
111
 
111
112
  def write_logs(
112
113
  self,
@@ -74,8 +74,8 @@ async def list_user_accessible_projects(
74
74
  ) -> List[Project]:
75
75
  """
76
76
  Returns all projects accessible to the user:
77
- - For global admins: ALL projects in the system
78
- - For regular users: Projects where user is a member + public projects where user is NOT a member
77
+ - Projects where user is a member (public or private)
78
+ - Public projects where user is NOT a member
79
79
  """
80
80
  if user.global_role == GlobalRole.ADMIN:
81
81
  projects = await list_project_models(session=session)
@@ -150,6 +150,17 @@ async def create_project(
150
150
  return project_model_to_project(project_model)
151
151
 
152
152
 
153
+ async def update_project(
154
+ session: AsyncSession,
155
+ user: UserModel,
156
+ project: ProjectModel,
157
+ is_public: bool,
158
+ ):
159
+ """Update project visibility (public/private)."""
160
+ project.is_public = is_public
161
+ await session.commit()
162
+
163
+
153
164
  async def delete_projects(
154
165
  session: AsyncSession,
155
166
  user: UserModel,
@@ -163,7 +174,8 @@ async def delete_projects(
163
174
  for project_name in projects_names:
164
175
  if project_name not in user_project_names:
165
176
  raise ForbiddenError()
166
- for project in user_projects:
177
+ projects_to_delete = [p for p in user_projects if p.name in projects_names]
178
+ for project in projects_to_delete:
167
179
  if not _is_project_admin(user=user, project=project):
168
180
  raise ForbiddenError()
169
181
  if all(name in projects_names for name in user_project_names):
@@ -187,7 +199,6 @@ async def set_project_members(
187
199
  project: ProjectModel,
188
200
  members: List[MemberSetting],
189
201
  ):
190
- # reload with members
191
202
  project = await get_project_model_by_name_or_error(
192
203
  session=session,
193
204
  project_name=project.name,
@@ -212,7 +223,6 @@ async def set_project_members(
212
223
  select(UserModel).where((UserModel.name.in_(names)) | (UserModel.email.in_(names)))
213
224
  )
214
225
  users = res.scalars().all()
215
- # Create lookup maps for both username and email
216
226
  username_to_user = {user.name: user for user in users}
217
227
  email_to_user = {user.email: user for user in users if user.email}
218
228
  for i, member in enumerate(members):
@@ -230,6 +240,77 @@ async def set_project_members(
230
240
  await session.commit()
231
241
 
232
242
 
243
+ async def add_project_members(
244
+ session: AsyncSession,
245
+ user: UserModel,
246
+ project: ProjectModel,
247
+ members: List[MemberSetting],
248
+ ):
249
+ """Add multiple members to a project."""
250
+ project = await get_project_model_by_name_or_error(
251
+ session=session,
252
+ project_name=project.name,
253
+ )
254
+ requesting_user_role = get_user_project_role(user=user, project=project)
255
+
256
+ is_self_join_to_public = (
257
+ len(members) == 1
258
+ and project.is_public
259
+ and (members[0].username == user.name or members[0].username == user.email)
260
+ and requesting_user_role is None
261
+ )
262
+
263
+ if not is_self_join_to_public:
264
+ if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]:
265
+ raise ForbiddenError("Access denied: insufficient permissions to add members")
266
+
267
+ if user.global_role != GlobalRole.ADMIN and requesting_user_role == ProjectRole.MANAGER:
268
+ for member in members:
269
+ if member.project_role == ProjectRole.ADMIN:
270
+ raise ForbiddenError(
271
+ "Access denied: only global admins can add project admins"
272
+ )
273
+ else:
274
+ if members[0].project_role != ProjectRole.USER:
275
+ raise ForbiddenError("Access denied: can only join public projects as user role")
276
+
277
+ usernames = [member.username for member in members]
278
+
279
+ res = await session.execute(
280
+ select(UserModel).where((UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames)))
281
+ )
282
+ users_found = res.scalars().all()
283
+
284
+ username_to_user = {user.name: user for user in users_found}
285
+ email_to_user = {user.email: user for user in users_found if user.email}
286
+
287
+ member_by_user_id = {m.user_id: m for m in project.members}
288
+
289
+ for member_setting in members:
290
+ user_to_add = username_to_user.get(member_setting.username) or email_to_user.get(
291
+ member_setting.username
292
+ )
293
+ if user_to_add is None:
294
+ raise ServerClientError(f"User not found: {member_setting.username}")
295
+
296
+ if user_to_add.id in member_by_user_id:
297
+ existing_member = member_by_user_id[user_to_add.id]
298
+ if existing_member.project_role != member_setting.project_role:
299
+ existing_member.project_role = member_setting.project_role
300
+ else:
301
+ await add_project_member(
302
+ session=session,
303
+ project=project,
304
+ user=user_to_add,
305
+ project_role=member_setting.project_role,
306
+ member_num=None,
307
+ commit=False,
308
+ )
309
+ member_by_user_id[user_to_add.id] = None
310
+
311
+ await session.commit()
312
+
313
+
233
314
  async def add_project_member(
234
315
  session: AsyncSession,
235
316
  project: ProjectModel,
@@ -497,8 +578,86 @@ def _is_project_admin(
497
578
  user: UserModel,
498
579
  project: ProjectModel,
499
580
  ) -> bool:
581
+ if user.id == project.owner_id:
582
+ return True
583
+
500
584
  for m in project.members:
501
585
  if user.id == m.user_id:
502
586
  if m.project_role == ProjectRole.ADMIN:
503
587
  return True
504
588
  return False
589
+
590
+
591
+ async def remove_project_members(
592
+ session: AsyncSession,
593
+ user: UserModel,
594
+ project: ProjectModel,
595
+ usernames: List[str],
596
+ ):
597
+ """Remove multiple members from a project."""
598
+ project = await get_project_model_by_name_or_error(
599
+ session=session,
600
+ project_name=project.name,
601
+ )
602
+ requesting_user_role = get_user_project_role(user=user, project=project)
603
+
604
+ is_self_leave = (
605
+ len(usernames) == 1
606
+ and (usernames[0] == user.name or usernames[0] == user.email)
607
+ and requesting_user_role is not None
608
+ )
609
+
610
+ if not is_self_leave:
611
+ if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]:
612
+ raise ForbiddenError("Access denied: insufficient permissions to remove members")
613
+
614
+ res = await session.execute(
615
+ select(UserModel).where((UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames)))
616
+ )
617
+ users_found = res.scalars().all()
618
+
619
+ username_to_user = {user.name: user for user in users_found}
620
+ email_to_user = {user.email: user for user in users_found if user.email}
621
+
622
+ member_by_user_id = {m.user_id: m for m in project.members}
623
+
624
+ members_to_remove = []
625
+ admin_removals = 0
626
+
627
+ for username in usernames:
628
+ user_to_remove = username_to_user.get(username) or email_to_user.get(username)
629
+ if user_to_remove is None:
630
+ raise ServerClientError(f"User not found: {username}")
631
+
632
+ if user_to_remove.id not in member_by_user_id:
633
+ raise ServerClientError(f"User is not a member of this project: {username}")
634
+
635
+ member_to_remove = member_by_user_id[user_to_remove.id]
636
+
637
+ if member_to_remove.project_role == ProjectRole.ADMIN:
638
+ if is_self_leave:
639
+ total_admins = sum(
640
+ 1 for member in project.members if member.project_role == ProjectRole.ADMIN
641
+ )
642
+ if total_admins <= 1:
643
+ raise ServerClientError("Cannot leave project: you are the last admin")
644
+ else:
645
+ if user.global_role != GlobalRole.ADMIN:
646
+ raise ForbiddenError(
647
+ f"Access denied: only global admins can remove project admins (user: {username})"
648
+ )
649
+ admin_removals += 1
650
+
651
+ members_to_remove.append(member_to_remove)
652
+
653
+ if not is_self_leave:
654
+ total_admins = sum(
655
+ 1 for member in project.members if member.project_role == ProjectRole.ADMIN
656
+ )
657
+ if admin_removals >= total_admins:
658
+ raise ServerClientError("Cannot remove all project admins")
659
+
660
+ for member in members_to_remove:
661
+ await session.delete(member)
662
+
663
+ await session.commit()
@@ -0,0 +1,52 @@
1
+ from prometheus_client import Counter, Histogram
2
+
3
+
4
+ class RunMetrics:
5
+ """Wrapper class for run-related Prometheus metrics."""
6
+
7
+ def __init__(self):
8
+ self._submit_to_provision_duration = Histogram(
9
+ "dstack_submit_to_provision_duration_seconds",
10
+ "Time from when a run has been submitted and first job provisioning",
11
+ # Buckets optimized for percentile calculation
12
+ buckets=[
13
+ 15,
14
+ 30,
15
+ 45,
16
+ 60,
17
+ 90,
18
+ 120,
19
+ 180,
20
+ 240,
21
+ 300,
22
+ 360,
23
+ 420,
24
+ 480,
25
+ 540,
26
+ 600,
27
+ 900,
28
+ 1200,
29
+ 1800,
30
+ float("inf"),
31
+ ],
32
+ labelnames=["project_name", "run_type"],
33
+ )
34
+
35
+ self._pending_runs_total = Counter(
36
+ "dstack_pending_runs_total",
37
+ "Number of pending runs",
38
+ labelnames=["project_name", "run_type"],
39
+ )
40
+
41
+ def log_submit_to_provision_duration(
42
+ self, duration_seconds: float, project_name: str, run_type: str
43
+ ):
44
+ self._submit_to_provision_duration.labels(
45
+ project_name=project_name, run_type=run_type
46
+ ).observe(duration_seconds)
47
+
48
+ def increment_pending_runs(self, project_name: str, run_type: str):
49
+ self._pending_runs_total.labels(project_name=project_name, run_type=run_type).inc()
50
+
51
+
52
+ run_metrics = RunMetrics()
@@ -589,7 +589,7 @@ async def stop_run(session: AsyncSession, run_model: RunModel, abort: bool):
589
589
  select(RunModel)
590
590
  .where(RunModel.id == run_model.id)
591
591
  .order_by(RunModel.id) # take locks in order
592
- .with_for_update()
592
+ .with_for_update(key_share=True)
593
593
  .execution_options(populate_existing=True)
594
594
  )
595
595
  run_model = res.scalar_one()
@@ -597,7 +597,7 @@ async def stop_run(session: AsyncSession, run_model: RunModel, abort: bool):
597
597
  select(JobModel)
598
598
  .where(JobModel.run_id == run_model.id)
599
599
  .order_by(JobModel.id) # take locks in order
600
- .with_for_update()
600
+ .with_for_update(key_share=True)
601
601
  .execution_options(populate_existing=True)
602
602
  )
603
603
  if run_model.status.is_finished():
@@ -633,7 +633,7 @@ async def delete_runs(
633
633
  select(RunModel)
634
634
  .where(RunModel.id.in_(run_ids))
635
635
  .order_by(RunModel.id) # take locks in order
636
- .with_for_update()
636
+ .with_for_update(key_share=True)
637
637
  )
638
638
  run_models = res.scalars().all()
639
639
  active_runs = [r for r in run_models if not r.status.is_finished()]
@@ -3,6 +3,7 @@ Application logic related to `type: service` runs.
3
3
  """
4
4
 
5
5
  import uuid
6
+ from datetime import datetime
6
7
  from typing import Optional
7
8
  from urllib.parse import urlparse
8
9
 
@@ -265,7 +266,7 @@ async def update_service_desired_replica_count(
265
266
  session: AsyncSession,
266
267
  run_model: RunModel,
267
268
  configuration: ServiceConfiguration,
268
- last_scaled_at: Optional[int],
269
+ last_scaled_at: Optional[datetime],
269
270
  ) -> None:
270
271
  scaler = get_service_scaler(configuration)
271
272
  stats = None
@@ -44,9 +44,7 @@ async def list_users_for_user(
44
44
  session: AsyncSession,
45
45
  user: UserModel,
46
46
  ) -> List[User]:
47
- if user.global_role == GlobalRole.ADMIN:
48
- return await list_all_users(session=session)
49
- return [user_model_to_user(user)]
47
+ return await list_all_users(session=session)
50
48
 
51
49
 
52
50
  async def list_all_users(
@@ -275,7 +275,7 @@ async def delete_volumes(session: AsyncSession, project: ProjectModel, names: Li
275
275
  .options(selectinload(VolumeModel.attachments))
276
276
  .execution_options(populate_existing=True)
277
277
  .order_by(VolumeModel.id) # take locks in order
278
- .with_for_update()
278
+ .with_for_update(key_share=True)
279
279
  )
280
280
  volume_models = res.scalars().unique().all()
281
281
  for volume_model in volume_models:
@@ -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-0ac1e1583684417ae4d1.js"></script><link href="/main-f39c418b05fe14772dd8.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></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-a4eafa74304e587d037c.js"></script><link href="/main-f53d6d0d42f8d61df1de.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></body></html>