dstack 0.19.20__py3-none-any.whl → 0.19.22__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 (93) hide show
  1. dstack/_internal/cli/commands/apply.py +8 -3
  2. dstack/_internal/cli/services/configurators/__init__.py +8 -0
  3. dstack/_internal/cli/services/configurators/fleet.py +1 -1
  4. dstack/_internal/cli/services/configurators/gateway.py +1 -1
  5. dstack/_internal/cli/services/configurators/run.py +11 -1
  6. dstack/_internal/cli/services/configurators/volume.py +1 -1
  7. dstack/_internal/cli/utils/common.py +48 -5
  8. dstack/_internal/cli/utils/fleet.py +5 -5
  9. dstack/_internal/cli/utils/run.py +32 -0
  10. dstack/_internal/core/backends/__init__.py +0 -65
  11. dstack/_internal/core/backends/configurators.py +9 -0
  12. dstack/_internal/core/backends/features.py +64 -0
  13. dstack/_internal/core/backends/hotaisle/__init__.py +1 -0
  14. dstack/_internal/core/backends/hotaisle/api_client.py +109 -0
  15. dstack/_internal/core/backends/hotaisle/backend.py +16 -0
  16. dstack/_internal/core/backends/hotaisle/compute.py +225 -0
  17. dstack/_internal/core/backends/hotaisle/configurator.py +60 -0
  18. dstack/_internal/core/backends/hotaisle/models.py +45 -0
  19. dstack/_internal/core/backends/lambdalabs/compute.py +2 -1
  20. dstack/_internal/core/backends/models.py +8 -0
  21. dstack/_internal/core/compatibility/fleets.py +2 -0
  22. dstack/_internal/core/compatibility/runs.py +12 -0
  23. dstack/_internal/core/models/backends/base.py +2 -0
  24. dstack/_internal/core/models/configurations.py +139 -1
  25. dstack/_internal/core/models/health.py +28 -0
  26. dstack/_internal/core/models/instances.py +2 -0
  27. dstack/_internal/core/models/logs.py +2 -1
  28. dstack/_internal/core/models/profiles.py +37 -0
  29. dstack/_internal/core/models/runs.py +21 -1
  30. dstack/_internal/core/services/ssh/tunnel.py +7 -0
  31. dstack/_internal/server/app.py +26 -10
  32. dstack/_internal/server/background/__init__.py +9 -6
  33. dstack/_internal/server/background/tasks/process_fleets.py +52 -38
  34. dstack/_internal/server/background/tasks/process_gateways.py +2 -2
  35. dstack/_internal/server/background/tasks/process_idle_volumes.py +5 -4
  36. dstack/_internal/server/background/tasks/process_instances.py +168 -103
  37. dstack/_internal/server/background/tasks/process_metrics.py +9 -2
  38. dstack/_internal/server/background/tasks/process_placement_groups.py +2 -0
  39. dstack/_internal/server/background/tasks/process_probes.py +164 -0
  40. dstack/_internal/server/background/tasks/process_prometheus_metrics.py +14 -2
  41. dstack/_internal/server/background/tasks/process_running_jobs.py +142 -124
  42. dstack/_internal/server/background/tasks/process_runs.py +84 -34
  43. dstack/_internal/server/background/tasks/process_submitted_jobs.py +12 -10
  44. dstack/_internal/server/background/tasks/process_terminating_jobs.py +12 -4
  45. dstack/_internal/server/background/tasks/process_volumes.py +4 -1
  46. dstack/_internal/server/migrations/versions/25479f540245_add_probes.py +43 -0
  47. dstack/_internal/server/migrations/versions/50dd7ea98639_index_status_columns.py +55 -0
  48. dstack/_internal/server/migrations/versions/728b1488b1b4_add_instance_health.py +50 -0
  49. dstack/_internal/server/migrations/versions/ec02a26a256c_add_runmodel_next_triggered_at.py +38 -0
  50. dstack/_internal/server/models.py +57 -16
  51. dstack/_internal/server/routers/instances.py +33 -5
  52. dstack/_internal/server/schemas/health/dcgm.py +56 -0
  53. dstack/_internal/server/schemas/instances.py +32 -0
  54. dstack/_internal/server/schemas/runner.py +5 -0
  55. dstack/_internal/server/services/fleets.py +19 -10
  56. dstack/_internal/server/services/gateways/__init__.py +17 -17
  57. dstack/_internal/server/services/instances.py +113 -15
  58. dstack/_internal/server/services/jobs/__init__.py +18 -13
  59. dstack/_internal/server/services/jobs/configurators/base.py +26 -0
  60. dstack/_internal/server/services/logging.py +4 -2
  61. dstack/_internal/server/services/logs/aws.py +13 -1
  62. dstack/_internal/server/services/logs/gcp.py +16 -1
  63. dstack/_internal/server/services/offers.py +3 -3
  64. dstack/_internal/server/services/probes.py +6 -0
  65. dstack/_internal/server/services/projects.py +51 -19
  66. dstack/_internal/server/services/prometheus/client_metrics.py +3 -0
  67. dstack/_internal/server/services/prometheus/custom_metrics.py +2 -3
  68. dstack/_internal/server/services/runner/client.py +52 -20
  69. dstack/_internal/server/services/runner/ssh.py +4 -4
  70. dstack/_internal/server/services/runs.py +115 -39
  71. dstack/_internal/server/services/services/__init__.py +4 -1
  72. dstack/_internal/server/services/ssh.py +66 -0
  73. dstack/_internal/server/services/users.py +2 -3
  74. dstack/_internal/server/services/volumes.py +11 -11
  75. dstack/_internal/server/settings.py +16 -0
  76. dstack/_internal/server/statics/index.html +1 -1
  77. dstack/_internal/server/statics/{main-8f9ee218d3eb45989682.css → main-03e818b110e1d5705378.css} +1 -1
  78. dstack/_internal/server/statics/{main-39a767528976f8078166.js → main-cc067b7fd1a8f33f97da.js} +26 -15
  79. dstack/_internal/server/statics/{main-39a767528976f8078166.js.map → main-cc067b7fd1a8f33f97da.js.map} +1 -1
  80. dstack/_internal/server/testing/common.py +51 -0
  81. dstack/_internal/{core/backends/remote → server/utils}/provisioning.py +22 -17
  82. dstack/_internal/server/utils/sentry_utils.py +12 -0
  83. dstack/_internal/settings.py +3 -0
  84. dstack/_internal/utils/common.py +15 -0
  85. dstack/_internal/utils/cron.py +5 -0
  86. dstack/api/server/__init__.py +1 -1
  87. dstack/version.py +1 -1
  88. {dstack-0.19.20.dist-info → dstack-0.19.22.dist-info}/METADATA +13 -22
  89. {dstack-0.19.20.dist-info → dstack-0.19.22.dist-info}/RECORD +93 -75
  90. /dstack/_internal/{core/backends/remote → server/schemas/health}/__init__.py +0 -0
  91. {dstack-0.19.20.dist-info → dstack-0.19.22.dist-info}/WHEEL +0 -0
  92. {dstack-0.19.20.dist-info → dstack-0.19.22.dist-info}/entry_points.txt +0 -0
  93. {dstack-0.19.20.dist-info → dstack-0.19.22.dist-info}/licenses/LICENSE.md +0 -0
@@ -1,10 +1,12 @@
1
1
  import itertools
2
2
  import math
3
3
  import uuid
4
+ from collections.abc import Iterable
4
5
  from datetime import datetime, timezone
5
6
  from typing import List, Optional
6
7
 
7
8
  import pydantic
9
+ from apscheduler.triggers.cron import CronTrigger
8
10
  from sqlalchemy import and_, func, or_, select, update
9
11
  from sqlalchemy.ext.asyncio import AsyncSession
10
12
  from sqlalchemy.orm import joinedload, selectinload
@@ -16,7 +18,11 @@ from dstack._internal.core.errors import (
16
18
  ServerClientError,
17
19
  )
18
20
  from dstack._internal.core.models.common import ApplyAction
19
- from dstack._internal.core.models.configurations import RUN_PRIORITY_DEFAULT, AnyRunConfiguration
21
+ from dstack._internal.core.models.configurations import (
22
+ RUN_PRIORITY_DEFAULT,
23
+ AnyRunConfiguration,
24
+ ServiceConfiguration,
25
+ )
20
26
  from dstack._internal.core.models.instances import (
21
27
  InstanceAvailability,
22
28
  InstanceOfferWithAvailability,
@@ -42,7 +48,6 @@ from dstack._internal.core.models.runs import (
42
48
  RunTerminationReason,
43
49
  ServiceSpec,
44
50
  )
45
- from dstack._internal.core.models.users import GlobalRole
46
51
  from dstack._internal.core.models.volumes import (
47
52
  InstanceMountPoint,
48
53
  Volume,
@@ -81,7 +86,7 @@ from dstack._internal.server.services.locking import get_locker, string_to_lock_
81
86
  from dstack._internal.server.services.logging import fmt
82
87
  from dstack._internal.server.services.offers import get_offers_by_requirements
83
88
  from dstack._internal.server.services.plugins import apply_plugin_policies
84
- from dstack._internal.server.services.projects import list_project_models, list_user_project_models
89
+ from dstack._internal.server.services.projects import list_user_project_models
85
90
  from dstack._internal.server.services.resources import set_resources_defaults
86
91
  from dstack._internal.server.services.secrets import get_project_secrets_mapping
87
92
  from dstack._internal.server.services.users import get_user_model_by_name
@@ -115,10 +120,11 @@ async def list_user_runs(
115
120
  ) -> List[Run]:
116
121
  if project_name is None and repo_id is not None:
117
122
  return []
118
- if user.global_role == GlobalRole.ADMIN:
119
- projects = await list_project_models(session=session)
120
- else:
121
- projects = await list_user_project_models(session=session, user=user)
123
+ projects = await list_user_project_models(
124
+ session=session,
125
+ user=user,
126
+ only_names=True,
127
+ )
122
128
  runs_user = None
123
129
  if username is not None:
124
130
  runs_user = await get_user_model_by_name(session=session, username=username)
@@ -217,9 +223,10 @@ async def list_projects_run_models(
217
223
  res = await session.execute(
218
224
  select(RunModel)
219
225
  .where(*filters)
226
+ .options(joinedload(RunModel.user).load_only(UserModel.name))
227
+ .options(selectinload(RunModel.jobs).joinedload(JobModel.probes))
220
228
  .order_by(*order_by)
221
229
  .limit(limit)
222
- .options(selectinload(RunModel.user))
223
230
  )
224
231
  run_models = list(res.scalars().all())
225
232
  return run_models
@@ -259,6 +266,7 @@ async def get_run_by_name(
259
266
  RunModel.deleted == False,
260
267
  )
261
268
  .options(joinedload(RunModel.user))
269
+ .options(selectinload(RunModel.jobs).joinedload(JobModel.probes))
262
270
  )
263
271
  run_model = res.scalar()
264
272
  if run_model is None:
@@ -278,6 +286,7 @@ async def get_run_by_id(
278
286
  RunModel.id == run_id,
279
287
  )
280
288
  .options(joinedload(RunModel.user))
289
+ .options(selectinload(RunModel.jobs).joinedload(JobModel.probes))
281
290
  )
282
291
  run_model = res.scalar()
283
292
  if run_model is None:
@@ -511,6 +520,14 @@ async def submit_run(
511
520
  )
512
521
 
513
522
  submitted_at = common_utils.get_current_datetime()
523
+ initial_status = RunStatus.SUBMITTED
524
+ initial_replicas = 1
525
+ if run_spec.merged_profile.schedule is not None:
526
+ initial_status = RunStatus.PENDING
527
+ initial_replicas = 0
528
+ elif run_spec.configuration.type == "service":
529
+ initial_replicas = run_spec.configuration.replicas.min
530
+
514
531
  run_model = RunModel(
515
532
  id=uuid.uuid4(),
516
533
  project_id=project.id,
@@ -519,21 +536,20 @@ async def submit_run(
519
536
  user_id=user.id,
520
537
  run_name=run_spec.run_name,
521
538
  submitted_at=submitted_at,
522
- status=RunStatus.SUBMITTED,
539
+ status=initial_status,
523
540
  run_spec=run_spec.json(),
524
541
  last_processed_at=submitted_at,
525
542
  priority=run_spec.configuration.priority,
526
543
  deployment_num=0,
527
544
  desired_replica_count=1, # a relevant value will be set in process_runs.py
545
+ next_triggered_at=_get_next_triggered_at(run_spec),
528
546
  )
529
547
  session.add(run_model)
530
548
 
531
- replicas = 1
532
549
  if run_spec.configuration.type == "service":
533
- replicas = run_spec.configuration.replicas.min
534
550
  await services.register_service(session, run_model, run_spec)
535
551
 
536
- for replica_num in range(replicas):
552
+ for replica_num in range(initial_replicas):
537
553
  jobs = await get_jobs_from_run_spec(
538
554
  run_spec=run_spec,
539
555
  secrets=secrets,
@@ -549,8 +565,8 @@ async def submit_run(
549
565
  await session.commit()
550
566
  await session.refresh(run_model)
551
567
 
552
- run = run_model_to_run(run_model, return_in_api=True)
553
- return run
568
+ run = await get_run_by_id(session, project, run_model.id)
569
+ return common_utils.get_or_error(run)
554
570
 
555
571
 
556
572
  def create_job_model_for_new_submission(
@@ -575,6 +591,7 @@ def create_job_model_for_new_submission(
575
591
  termination_reason=None,
576
592
  job_spec_data=job.job_spec.json(),
577
593
  job_provisioning_data=None,
594
+ probes=[],
578
595
  )
579
596
 
580
597
 
@@ -693,8 +710,8 @@ def run_model_to_run(
693
710
  id=run_model.id,
694
711
  project_name=run_model.project.name,
695
712
  user=run_model.user.name,
696
- submitted_at=run_model.submitted_at.replace(tzinfo=timezone.utc),
697
- last_processed_at=run_model.last_processed_at.replace(tzinfo=timezone.utc),
713
+ submitted_at=run_model.submitted_at,
714
+ last_processed_at=run_model.last_processed_at,
698
715
  status=run_model.status,
699
716
  status_message=status_message,
700
717
  termination_reason=run_model.termination_reason,
@@ -732,7 +749,9 @@ def _get_run_jobs_with_submissions(
732
749
  job_models = list(job_models)[-job_submissions_limit:]
733
750
  for job_model in job_models:
734
751
  if job_submissions_limit != 0:
735
- job_submission = job_model_to_job_submission(job_model)
752
+ job_submission = job_model_to_job_submission(
753
+ job_model, include_probes=return_in_api
754
+ )
736
755
  if return_in_api:
737
756
  # Set default non-None values for 0.18 backward-compatibility
738
757
  # Remove in 0.19
@@ -972,6 +991,22 @@ def _validate_run_spec_and_set_defaults(run_spec: RunSpec):
972
991
  raise ServerClientError(
973
992
  f"Maximum utilization_policy.time_window is {settings.SERVER_METRICS_RUNNING_TTL_SECONDS}s"
974
993
  )
994
+ if isinstance(run_spec.configuration, ServiceConfiguration):
995
+ if run_spec.merged_profile.schedule and run_spec.configuration.replicas.min == 0:
996
+ raise ServerClientError(
997
+ "Scheduled services with autoscaling to zero are not supported"
998
+ )
999
+ if len(run_spec.configuration.probes) > settings.MAX_PROBES_PER_JOB:
1000
+ raise ServerClientError(
1001
+ f"Cannot configure more than {settings.MAX_PROBES_PER_JOB} probes"
1002
+ )
1003
+ if any(
1004
+ p.timeout is not None and p.timeout > settings.MAX_PROBE_TIMEOUT
1005
+ for p in run_spec.configuration.probes
1006
+ ):
1007
+ raise ServerClientError(
1008
+ f"Probe timeout cannot be longer than {settings.MAX_PROBE_TIMEOUT}s"
1009
+ )
975
1010
  if run_spec.configuration.priority is None:
976
1011
  run_spec.configuration.priority = RUN_PRIORITY_DEFAULT
977
1012
  set_resources_defaults(run_spec.configuration.resources)
@@ -997,6 +1032,7 @@ _TYPE_SPECIFIC_CONF_UPDATABLE_FIELDS = {
997
1032
  # rolling deployment
998
1033
  # NOTE: keep this list in sync with the "Rolling deployment" section in services.md
999
1034
  "port",
1035
+ "probes",
1000
1036
  "resources",
1001
1037
  "volumes",
1002
1038
  "docker",
@@ -1059,7 +1095,7 @@ def _check_can_update_configuration(
1059
1095
  )
1060
1096
 
1061
1097
 
1062
- async def process_terminating_run(session: AsyncSession, run: RunModel):
1098
+ async def process_terminating_run(session: AsyncSession, run_model: RunModel):
1063
1099
  """
1064
1100
  Used by both `process_runs` and `stop_run` to process a TERMINATING run.
1065
1101
  Stops the jobs gracefully and marks them as TERMINATING.
@@ -1067,44 +1103,54 @@ async def process_terminating_run(session: AsyncSession, run: RunModel):
1067
1103
  When all jobs are terminated, assigns a finished status to the run.
1068
1104
  Caller must acquire the lock on run.
1069
1105
  """
1070
- assert run.termination_reason is not None
1071
- job_termination_reason = run.termination_reason.to_job_termination_reason()
1106
+ assert run_model.termination_reason is not None
1107
+ run = run_model_to_run(run_model, include_jobs=False)
1108
+ job_termination_reason = run_model.termination_reason.to_job_termination_reason()
1072
1109
 
1073
1110
  unfinished_jobs_count = 0
1074
- for job in run.jobs:
1075
- if job.status.is_finished():
1111
+ for job_model in run_model.jobs:
1112
+ if job_model.status.is_finished():
1076
1113
  continue
1077
1114
  unfinished_jobs_count += 1
1078
- if job.status == JobStatus.TERMINATING:
1115
+ if job_model.status == JobStatus.TERMINATING:
1079
1116
  if job_termination_reason == JobTerminationReason.ABORTED_BY_USER:
1080
1117
  # Override termination reason so that
1081
1118
  # abort actions such as volume force detach are triggered
1082
- job.termination_reason = job_termination_reason
1119
+ job_model.termination_reason = job_termination_reason
1083
1120
  continue
1084
1121
 
1085
- if job.status == JobStatus.RUNNING and job_termination_reason not in {
1122
+ if job_model.status == JobStatus.RUNNING and job_termination_reason not in {
1086
1123
  JobTerminationReason.ABORTED_BY_USER,
1087
1124
  JobTerminationReason.DONE_BY_RUNNER,
1088
1125
  }:
1089
1126
  # Send a signal to stop the job gracefully
1090
- await stop_runner(session, job)
1091
- delay_job_instance_termination(job)
1092
- job.status = JobStatus.TERMINATING
1093
- job.termination_reason = job_termination_reason
1094
- job.last_processed_at = common_utils.get_current_datetime()
1127
+ await stop_runner(session, job_model)
1128
+ delay_job_instance_termination(job_model)
1129
+ job_model.status = JobStatus.TERMINATING
1130
+ job_model.termination_reason = job_termination_reason
1131
+ job_model.last_processed_at = common_utils.get_current_datetime()
1095
1132
 
1096
1133
  if unfinished_jobs_count == 0:
1097
- if run.service_spec is not None:
1134
+ if run_model.service_spec is not None:
1098
1135
  try:
1099
- await services.unregister_service(session, run)
1136
+ await services.unregister_service(session, run_model)
1100
1137
  except Exception as e:
1101
- logger.warning("%s: failed to unregister service: %s", fmt(run), repr(e))
1102
- run.status = run.termination_reason.to_status()
1138
+ logger.warning("%s: failed to unregister service: %s", fmt(run_model), repr(e))
1139
+ if (
1140
+ run.run_spec.merged_profile.schedule is not None
1141
+ and run_model.termination_reason
1142
+ not in [RunTerminationReason.ABORTED_BY_USER, RunTerminationReason.STOPPED_BY_USER]
1143
+ ):
1144
+ run_model.next_triggered_at = _get_next_triggered_at(run.run_spec)
1145
+ run_model.status = RunStatus.PENDING
1146
+ else:
1147
+ run_model.status = run_model.termination_reason.to_status()
1148
+
1103
1149
  logger.info(
1104
1150
  "%s: run status has changed TERMINATING -> %s, reason: %s",
1105
- fmt(run),
1106
- run.status.name,
1107
- run.termination_reason.name,
1151
+ fmt(run_model),
1152
+ run_model.status.name,
1153
+ run_model.termination_reason.name,
1108
1154
  )
1109
1155
 
1110
1156
 
@@ -1137,9 +1183,12 @@ async def scale_run_replicas(session: AsyncSession, run_model: RunModel, replica
1137
1183
  elif {JobStatus.PROVISIONING, JobStatus.PULLING} & statuses:
1138
1184
  # if there are any provisioning or pulling jobs, the replica is active and has the importance of 1
1139
1185
  active_replicas.append((1, is_out_of_date, replica_num, replica_jobs))
1140
- else:
1141
- # all jobs are running, the replica is active and has the importance of 2
1186
+ elif not is_replica_ready(replica_jobs):
1187
+ # all jobs are running, but probes are failing, the replica is active and has the importance of 2
1142
1188
  active_replicas.append((2, is_out_of_date, replica_num, replica_jobs))
1189
+ else:
1190
+ # all jobs are running and ready, the replica is active and has the importance of 3
1191
+ active_replicas.append((3, is_out_of_date, replica_num, replica_jobs))
1143
1192
 
1144
1193
  # sort by is_out_of_date (up-to-date first), importance (desc), and replica_num (asc)
1145
1194
  active_replicas.sort(key=lambda r: (r[1], -r[0], r[2]))
@@ -1222,5 +1271,32 @@ async def retry_run_replica_jobs(
1222
1271
  session.add(new_job_model)
1223
1272
 
1224
1273
 
1274
+ def is_replica_ready(jobs: Iterable[JobModel]) -> bool:
1275
+ if not all(job.status == JobStatus.RUNNING for job in jobs):
1276
+ return False
1277
+ for job in jobs:
1278
+ job_spec: JobSpec = JobSpec.__response__.parse_raw(job.job_spec_data)
1279
+ for probe_spec, probe in zip(job_spec.probes, job.probes):
1280
+ if probe.success_streak < probe_spec.ready_after:
1281
+ return False
1282
+ return True
1283
+
1284
+
1225
1285
  def _remove_job_spec_sensitive_info(spec: JobSpec):
1226
1286
  spec.ssh_key = None
1287
+
1288
+
1289
+ def _get_next_triggered_at(run_spec: RunSpec) -> Optional[datetime]:
1290
+ if run_spec.merged_profile.schedule is None:
1291
+ return None
1292
+ now = common_utils.get_current_datetime()
1293
+ fire_times = []
1294
+ for cron in run_spec.merged_profile.schedule.crons:
1295
+ cron_trigger = CronTrigger.from_crontab(cron, timezone=timezone.utc)
1296
+ fire_times.append(
1297
+ cron_trigger.get_next_fire_time(
1298
+ previous_fire_time=None,
1299
+ now=now,
1300
+ )
1301
+ )
1302
+ return min(fire_times)
@@ -28,6 +28,7 @@ from dstack._internal.server.models import GatewayModel, JobModel, ProjectModel,
28
28
  from dstack._internal.server.services.gateways import (
29
29
  get_gateway_configuration,
30
30
  get_or_add_gateway_connection,
31
+ get_project_default_gateway_model,
31
32
  get_project_gateway_model_by_name,
32
33
  )
33
34
  from dstack._internal.server.services.logging import fmt
@@ -52,7 +53,9 @@ async def register_service(session: AsyncSession, run_model: RunModel, run_spec:
52
53
  elif run_spec.configuration.gateway == False:
53
54
  gateway = None
54
55
  else:
55
- gateway = run_model.project.default_gateway
56
+ gateway = await get_project_default_gateway_model(
57
+ session=session, project=run_model.project
58
+ )
56
59
 
57
60
  if gateway is not None:
58
61
  service_spec = await _register_service_in_gateway(session, run_model, run_spec, gateway)
@@ -0,0 +1,66 @@
1
+ from collections.abc import Iterable
2
+ from typing import Optional
3
+
4
+ import dstack._internal.server.services.jobs as jobs_services
5
+ from dstack._internal.core.consts import DSTACK_RUNNER_SSH_PORT
6
+ from dstack._internal.core.models.backends.base import BackendType
7
+ from dstack._internal.core.models.instances import RemoteConnectionInfo, SSHConnectionParams
8
+ from dstack._internal.core.models.runs import JobProvisioningData
9
+ from dstack._internal.core.services.ssh.tunnel import SSH_DEFAULT_OPTIONS, SocketPair, SSHTunnel
10
+ from dstack._internal.server.models import JobModel
11
+ from dstack._internal.utils.common import get_or_error
12
+ from dstack._internal.utils.path import FileContent
13
+
14
+
15
+ def container_ssh_tunnel(
16
+ job: JobModel,
17
+ forwarded_sockets: Iterable[SocketPair] = (),
18
+ options: dict[str, str] = SSH_DEFAULT_OPTIONS,
19
+ ) -> SSHTunnel:
20
+ """
21
+ Build SSHTunnel for connecting to the container running the specified job.
22
+ """
23
+
24
+ jpd: JobProvisioningData = JobProvisioningData.__response__.parse_raw(
25
+ job.job_provisioning_data
26
+ )
27
+ if not jpd.dockerized:
28
+ ssh_destination = f"{jpd.username}@{jpd.hostname}"
29
+ ssh_port = jpd.ssh_port
30
+ ssh_proxy = jpd.ssh_proxy
31
+ else:
32
+ ssh_destination = "root@localhost" # TODO(#1535): support non-root images properly
33
+ ssh_port = DSTACK_RUNNER_SSH_PORT
34
+ job_submission = jobs_services.job_model_to_job_submission(job)
35
+ jrd = job_submission.job_runtime_data
36
+ if jrd is not None and jrd.ports is not None:
37
+ ssh_port = jrd.ports.get(ssh_port, ssh_port)
38
+ ssh_proxy = SSHConnectionParams(
39
+ hostname=jpd.hostname,
40
+ username=jpd.username,
41
+ port=jpd.ssh_port,
42
+ )
43
+ if jpd.backend == BackendType.LOCAL:
44
+ ssh_proxy = None
45
+ ssh_head_proxy: Optional[SSHConnectionParams] = None
46
+ ssh_head_proxy_private_key: Optional[str] = None
47
+ instance = get_or_error(job.instance)
48
+ if instance.remote_connection_info is not None:
49
+ rci = RemoteConnectionInfo.__response__.parse_raw(instance.remote_connection_info)
50
+ if rci.ssh_proxy is not None:
51
+ ssh_head_proxy = rci.ssh_proxy
52
+ ssh_head_proxy_private_key = get_or_error(rci.ssh_proxy_keys)[0].private
53
+ ssh_proxies = []
54
+ if ssh_head_proxy is not None:
55
+ ssh_head_proxy_private_key = get_or_error(ssh_head_proxy_private_key)
56
+ ssh_proxies.append((ssh_head_proxy, FileContent(ssh_head_proxy_private_key)))
57
+ if ssh_proxy is not None:
58
+ ssh_proxies.append((ssh_proxy, None))
59
+ return SSHTunnel(
60
+ destination=ssh_destination,
61
+ port=ssh_port,
62
+ ssh_proxies=ssh_proxies,
63
+ identity=FileContent(instance.project.ssh_private_key),
64
+ forwarded_sockets=forwarded_sockets,
65
+ options=options,
66
+ )
@@ -2,7 +2,6 @@ import hashlib
2
2
  import os
3
3
  import re
4
4
  import uuid
5
- from datetime import timezone
6
5
  from typing import Awaitable, Callable, List, Optional, Tuple
7
6
 
8
7
  from sqlalchemy import delete, select, update
@@ -195,7 +194,7 @@ def user_model_to_user(user_model: UserModel) -> User:
195
194
  return User(
196
195
  id=user_model.id,
197
196
  username=user_model.name,
198
- created_at=user_model.created_at.replace(tzinfo=timezone.utc),
197
+ created_at=user_model.created_at,
199
198
  global_role=user_model.global_role,
200
199
  email=user_model.email,
201
200
  active=user_model.active,
@@ -207,7 +206,7 @@ def user_model_to_user_with_creds(user_model: UserModel) -> UserWithCreds:
207
206
  return UserWithCreds(
208
207
  id=user_model.id,
209
208
  username=user_model.name,
210
- created_at=user_model.created_at.replace(tzinfo=timezone.utc),
209
+ created_at=user_model.created_at,
211
210
  global_role=user_model.global_role,
212
211
  email=user_model.email,
213
212
  active=user_model.active,
@@ -1,19 +1,18 @@
1
1
  import uuid
2
- from datetime import datetime, timedelta, timezone
2
+ from datetime import datetime, timedelta
3
3
  from typing import List, Optional
4
4
 
5
5
  from sqlalchemy import and_, func, or_, select, update
6
6
  from sqlalchemy.ext.asyncio import AsyncSession
7
7
  from sqlalchemy.orm import joinedload, selectinload
8
8
 
9
- from dstack._internal.core.backends import BACKENDS_WITH_VOLUMES_SUPPORT
10
9
  from dstack._internal.core.backends.base.compute import ComputeWithVolumeSupport
10
+ from dstack._internal.core.backends.features import BACKENDS_WITH_VOLUMES_SUPPORT
11
11
  from dstack._internal.core.errors import (
12
12
  BackendNotAvailable,
13
13
  ResourceExistsError,
14
14
  ServerClientError,
15
15
  )
16
- from dstack._internal.core.models.users import GlobalRole
17
16
  from dstack._internal.core.models.volumes import (
18
17
  Volume,
19
18
  VolumeAttachment,
@@ -40,7 +39,7 @@ from dstack._internal.server.services.locking import (
40
39
  string_to_lock_id,
41
40
  )
42
41
  from dstack._internal.server.services.plugins import apply_plugin_policies
43
- from dstack._internal.server.services.projects import list_project_models, list_user_project_models
42
+ from dstack._internal.server.services.projects import list_user_project_models
44
43
  from dstack._internal.utils import common, random_names
45
44
  from dstack._internal.utils.logging import get_logger
46
45
 
@@ -57,10 +56,11 @@ async def list_volumes(
57
56
  limit: int,
58
57
  ascending: bool,
59
58
  ) -> List[Volume]:
60
- if user.global_role == GlobalRole.ADMIN:
61
- projects = await list_project_models(session=session)
62
- else:
63
- projects = await list_user_project_models(session=session, user=user)
59
+ projects = await list_user_project_models(
60
+ session=session,
61
+ user=user,
62
+ only_names=True,
63
+ )
64
64
  if project_name is not None:
65
65
  projects = [p for p in projects if p.name == project_name]
66
66
  volume_models = await list_projects_volume_models(
@@ -320,15 +320,15 @@ def volume_model_to_volume(volume_model: VolumeModel) -> Volume:
320
320
  )
321
321
  deleted_at = None
322
322
  if volume_model.deleted_at is not None:
323
- deleted_at = volume_model.deleted_at.replace(tzinfo=timezone.utc)
323
+ deleted_at = volume_model.deleted_at
324
324
  volume = Volume(
325
325
  name=volume_model.name,
326
326
  project_name=volume_model.project.name,
327
327
  user=volume_model.user.name,
328
328
  configuration=configuration,
329
329
  external=configuration.volume_id is not None,
330
- created_at=volume_model.created_at.replace(tzinfo=timezone.utc),
331
- last_processed_at=volume_model.last_processed_at.replace(tzinfo=timezone.utc),
330
+ created_at=volume_model.created_at,
331
+ last_processed_at=volume_model.last_processed_at,
332
332
  status=volume_model.status,
333
333
  status_message=volume_model.status_message,
334
334
  deleted=volume_model.deleted,
@@ -1,3 +1,7 @@
1
+ """
2
+ Environment variables read by the dstack server. Documented in reference/environment-variables.md
3
+ """
4
+
1
5
  import os
2
6
  import warnings
3
7
  from pathlib import Path
@@ -50,6 +54,8 @@ SERVER_BACKGROUND_PROCESSING_ENABLED = not SERVER_BACKGROUND_PROCESSING_DISABLED
50
54
  SERVER_EXECUTOR_MAX_WORKERS = int(os.getenv("DSTACK_SERVER_EXECUTOR_MAX_WORKERS", 128))
51
55
 
52
56
  MAX_OFFERS_TRIED = int(os.getenv("DSTACK_SERVER_MAX_OFFERS_TRIED", 25))
57
+ MAX_PROBES_PER_JOB = int(os.getenv("DSTACK_SERVER_MAX_PROBES_PER_JOB", 10))
58
+ MAX_PROBE_TIMEOUT = int(os.getenv("DSTACK_SERVER_MAX_PROBE_TIMEOUT", 60 * 5))
53
59
 
54
60
  SERVER_CONFIG_DISABLED = os.getenv("DSTACK_SERVER_CONFIG_DISABLED") is not None
55
61
  SERVER_CONFIG_ENABLED = not SERVER_CONFIG_DISABLED
@@ -87,12 +93,22 @@ SERVER_METRICS_FINISHED_TTL_SECONDS = int(
87
93
  os.getenv("DSTACK_SERVER_METRICS_FINISHED_TTL_SECONDS", 7 * 24 * 3600)
88
94
  )
89
95
 
96
+ SERVER_INSTANCE_HEALTH_TTL_SECONDS = int(
97
+ os.getenv("DSTACK_SERVER_INSTANCE_HEALTH_TTL_SECONDS", 7 * 24 * 3600)
98
+ )
99
+ SERVER_INSTANCE_HEALTH_MIN_COLLECT_INTERVAL_SECONDS = int(
100
+ os.getenv("DSTACK_SERVER_INSTANCE_HEALTH_MIN_COLLECT_INTERVAL_SECONDS", 60)
101
+ )
102
+
90
103
  SERVER_KEEP_SHIM_TASKS = os.getenv("DSTACK_SERVER_KEEP_SHIM_TASKS") is not None
91
104
 
92
105
  DEFAULT_PROJECT_NAME = "main"
93
106
 
94
107
  SENTRY_DSN = os.getenv("DSTACK_SENTRY_DSN")
95
108
  SENTRY_TRACES_SAMPLE_RATE = float(os.getenv("DSTACK_SENTRY_TRACES_SAMPLE_RATE", 0.1))
109
+ SENTRY_TRACES_BACKGROUND_SAMPLE_RATE = float(
110
+ os.getenv("DSTACK_SENTRY_TRACES_BACKGROUND_SAMPLE_RATE", 0.01)
111
+ )
96
112
  SENTRY_PROFILES_SAMPLE_RATE = float(os.getenv("DSTACK_SENTRY_PROFILES_SAMPLE_RATE", 0))
97
113
 
98
114
  DEFAULT_CREDS_DISABLED = os.getenv("DSTACK_DEFAULT_CREDS_DISABLED") is not None
@@ -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-39a767528976f8078166.js"></script><link href="/main-8f9ee218d3eb45989682.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-cc067b7fd1a8f33f97da.js"></script><link href="/main-03e818b110e1d5705378.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>