dstack 0.19.15rc1__py3-none-any.whl → 0.19.17__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/secrets.py +92 -0
  2. dstack/_internal/cli/main.py +2 -0
  3. dstack/_internal/cli/services/completion.py +5 -0
  4. dstack/_internal/cli/services/configurators/run.py +59 -17
  5. dstack/_internal/cli/utils/secrets.py +25 -0
  6. dstack/_internal/core/backends/__init__.py +10 -4
  7. dstack/_internal/core/backends/cloudrift/__init__.py +0 -0
  8. dstack/_internal/core/backends/cloudrift/api_client.py +208 -0
  9. dstack/_internal/core/backends/cloudrift/backend.py +16 -0
  10. dstack/_internal/core/backends/cloudrift/compute.py +138 -0
  11. dstack/_internal/core/backends/cloudrift/configurator.py +66 -0
  12. dstack/_internal/core/backends/cloudrift/models.py +40 -0
  13. dstack/_internal/core/backends/configurators.py +9 -0
  14. dstack/_internal/core/backends/models.py +7 -0
  15. dstack/_internal/core/compatibility/logs.py +15 -0
  16. dstack/_internal/core/compatibility/runs.py +31 -2
  17. dstack/_internal/core/models/backends/base.py +2 -0
  18. dstack/_internal/core/models/configurations.py +33 -2
  19. dstack/_internal/core/models/files.py +67 -0
  20. dstack/_internal/core/models/logs.py +2 -1
  21. dstack/_internal/core/models/runs.py +24 -1
  22. dstack/_internal/core/models/secrets.py +9 -2
  23. dstack/_internal/server/app.py +2 -0
  24. dstack/_internal/server/background/tasks/process_fleets.py +1 -1
  25. dstack/_internal/server/background/tasks/process_gateways.py +1 -1
  26. dstack/_internal/server/background/tasks/process_instances.py +1 -1
  27. dstack/_internal/server/background/tasks/process_placement_groups.py +1 -1
  28. dstack/_internal/server/background/tasks/process_running_jobs.py +110 -13
  29. dstack/_internal/server/background/tasks/process_runs.py +36 -5
  30. dstack/_internal/server/background/tasks/process_submitted_jobs.py +10 -4
  31. dstack/_internal/server/background/tasks/process_terminating_jobs.py +2 -2
  32. dstack/_internal/server/background/tasks/process_volumes.py +1 -1
  33. dstack/_internal/server/migrations/versions/5f1707c525d2_add_filearchivemodel.py +39 -0
  34. dstack/_internal/server/migrations/versions/644b8a114187_add_secretmodel.py +49 -0
  35. dstack/_internal/server/models.py +33 -0
  36. dstack/_internal/server/routers/files.py +67 -0
  37. dstack/_internal/server/routers/gateways.py +6 -3
  38. dstack/_internal/server/routers/projects.py +63 -0
  39. dstack/_internal/server/routers/prometheus.py +5 -5
  40. dstack/_internal/server/routers/secrets.py +57 -15
  41. dstack/_internal/server/schemas/files.py +5 -0
  42. dstack/_internal/server/schemas/logs.py +10 -1
  43. dstack/_internal/server/schemas/projects.py +12 -0
  44. dstack/_internal/server/schemas/runner.py +2 -0
  45. dstack/_internal/server/schemas/secrets.py +7 -11
  46. dstack/_internal/server/security/permissions.py +75 -2
  47. dstack/_internal/server/services/backends/__init__.py +1 -1
  48. dstack/_internal/server/services/files.py +91 -0
  49. dstack/_internal/server/services/fleets.py +1 -1
  50. dstack/_internal/server/services/gateways/__init__.py +1 -1
  51. dstack/_internal/server/services/jobs/__init__.py +19 -8
  52. dstack/_internal/server/services/jobs/configurators/base.py +27 -3
  53. dstack/_internal/server/services/jobs/configurators/dev.py +3 -3
  54. dstack/_internal/server/services/logs/aws.py +38 -38
  55. dstack/_internal/server/services/logs/filelog.py +48 -14
  56. dstack/_internal/server/services/logs/gcp.py +17 -16
  57. dstack/_internal/server/services/projects.py +164 -5
  58. dstack/_internal/server/services/prometheus/__init__.py +0 -0
  59. dstack/_internal/server/services/prometheus/client_metrics.py +52 -0
  60. dstack/_internal/server/services/proxy/repo.py +3 -0
  61. dstack/_internal/server/services/runner/client.py +8 -0
  62. dstack/_internal/server/services/runs.py +55 -10
  63. dstack/_internal/server/services/secrets.py +204 -0
  64. dstack/_internal/server/services/services/__init__.py +2 -1
  65. dstack/_internal/server/services/storage/base.py +21 -0
  66. dstack/_internal/server/services/storage/gcs.py +28 -6
  67. dstack/_internal/server/services/storage/s3.py +27 -9
  68. dstack/_internal/server/services/users.py +1 -3
  69. dstack/_internal/server/services/volumes.py +1 -1
  70. dstack/_internal/server/settings.py +2 -2
  71. dstack/_internal/server/statics/index.html +1 -1
  72. dstack/_internal/server/statics/{main-0ac1e1583684417ae4d1.js → main-d151637af20f70b2e796.js} +104 -48
  73. dstack/_internal/server/statics/{main-0ac1e1583684417ae4d1.js.map → main-d151637af20f70b2e796.js.map} +1 -1
  74. dstack/_internal/server/statics/{main-f39c418b05fe14772dd8.css → main-d48635d8fe670d53961c.css} +1 -1
  75. dstack/_internal/server/statics/static/media/google.b194b06fafd0a52aeb566922160ea514.svg +1 -0
  76. dstack/_internal/server/testing/common.py +43 -5
  77. dstack/_internal/settings.py +5 -0
  78. dstack/_internal/utils/files.py +69 -0
  79. dstack/_internal/utils/nested_list.py +47 -0
  80. dstack/_internal/utils/path.py +12 -4
  81. dstack/api/_public/runs.py +73 -12
  82. dstack/api/server/__init__.py +6 -0
  83. dstack/api/server/_files.py +18 -0
  84. dstack/api/server/_logs.py +5 -1
  85. dstack/api/server/_projects.py +24 -0
  86. dstack/api/server/_secrets.py +15 -15
  87. dstack/version.py +1 -1
  88. {dstack-0.19.15rc1.dist-info → dstack-0.19.17.dist-info}/METADATA +3 -4
  89. {dstack-0.19.15rc1.dist-info → dstack-0.19.17.dist-info}/RECORD +93 -71
  90. /dstack/_internal/server/services/{prometheus.py → prometheus/custom_metrics.py} +0 -0
  91. {dstack-0.19.15rc1.dist-info → dstack-0.19.17.dist-info}/WHEEL +0 -0
  92. {dstack-0.19.15rc1.dist-info → dstack-0.19.17.dist-info}/entry_points.txt +0 -0
  93. {dstack-0.19.15rc1.dist-info → dstack-0.19.17.dist-info}/licenses/LICENSE.md +0 -0
@@ -82,6 +82,7 @@ from dstack._internal.server.services.offers import get_offers_by_requirements
82
82
  from dstack._internal.server.services.plugins import apply_plugin_policies
83
83
  from dstack._internal.server.services.projects import list_project_models, list_user_project_models
84
84
  from dstack._internal.server.services.resources import set_resources_defaults
85
+ from dstack._internal.server.services.secrets import get_project_secrets_mapping
85
86
  from dstack._internal.server.services.users import get_user_model_by_name
86
87
  from dstack._internal.utils.logging import get_logger
87
88
  from dstack._internal.utils.random_names import generate_name
@@ -311,7 +312,12 @@ async def get_plan(
311
312
  ):
312
313
  action = ApplyAction.UPDATE
313
314
 
314
- jobs = await get_jobs_from_run_spec(effective_run_spec, replica_num=0)
315
+ secrets = await get_project_secrets_mapping(session=session, project=project)
316
+ jobs = await get_jobs_from_run_spec(
317
+ run_spec=effective_run_spec,
318
+ secrets=secrets,
319
+ replica_num=0,
320
+ )
315
321
 
316
322
  volumes = await get_job_configured_volumes(
317
323
  session=session,
@@ -462,6 +468,10 @@ async def submit_run(
462
468
  project=project,
463
469
  run_spec=run_spec,
464
470
  )
471
+ secrets = await get_project_secrets_mapping(
472
+ session=session,
473
+ project=project,
474
+ )
465
475
 
466
476
  lock_namespace = f"run_names_{project.name}"
467
477
  if get_db().dialect_name == "sqlite":
@@ -513,7 +523,11 @@ async def submit_run(
513
523
  await services.register_service(session, run_model, run_spec)
514
524
 
515
525
  for replica_num in range(replicas):
516
- jobs = await get_jobs_from_run_spec(run_spec, replica_num=replica_num)
526
+ jobs = await get_jobs_from_run_spec(
527
+ run_spec=run_spec,
528
+ secrets=secrets,
529
+ replica_num=replica_num,
530
+ )
517
531
  for job in jobs:
518
532
  job_model = create_job_model_for_new_submission(
519
533
  run_model=run_model,
@@ -589,7 +603,7 @@ async def stop_run(session: AsyncSession, run_model: RunModel, abort: bool):
589
603
  select(RunModel)
590
604
  .where(RunModel.id == run_model.id)
591
605
  .order_by(RunModel.id) # take locks in order
592
- .with_for_update()
606
+ .with_for_update(key_share=True)
593
607
  .execution_options(populate_existing=True)
594
608
  )
595
609
  run_model = res.scalar_one()
@@ -597,7 +611,7 @@ async def stop_run(session: AsyncSession, run_model: RunModel, abort: bool):
597
611
  select(JobModel)
598
612
  .where(JobModel.run_id == run_model.id)
599
613
  .order_by(JobModel.id) # take locks in order
600
- .with_for_update()
614
+ .with_for_update(key_share=True)
601
615
  .execution_options(populate_existing=True)
602
616
  )
603
617
  if run_model.status.is_finished():
@@ -633,7 +647,7 @@ async def delete_runs(
633
647
  select(RunModel)
634
648
  .where(RunModel.id.in_(run_ids))
635
649
  .order_by(RunModel.id) # take locks in order
636
- .with_for_update()
650
+ .with_for_update(key_share=True)
637
651
  )
638
652
  run_models = res.scalars().all()
639
653
  active_runs = [r for r in run_models if not r.status.is_finished()]
@@ -898,7 +912,16 @@ def _validate_run_spec_and_set_defaults(run_spec: RunSpec):
898
912
  set_resources_defaults(run_spec.configuration.resources)
899
913
 
900
914
 
901
- _UPDATABLE_SPEC_FIELDS = ["repo_code_hash", "configuration"]
915
+ _UPDATABLE_SPEC_FIELDS = ["configuration_path", "configuration"]
916
+ _TYPE_SPECIFIC_UPDATABLE_SPEC_FIELDS = {
917
+ "service": [
918
+ # rolling deployment
919
+ "repo_data",
920
+ "repo_code_hash",
921
+ "file_archives",
922
+ "working_dir",
923
+ ],
924
+ }
902
925
  _CONF_UPDATABLE_FIELDS = ["priority"]
903
926
  _TYPE_SPECIFIC_CONF_UPDATABLE_FIELDS = {
904
927
  "dev-environment": ["inactivity_duration"],
@@ -909,10 +932,13 @@ _TYPE_SPECIFIC_CONF_UPDATABLE_FIELDS = {
909
932
  # rolling deployment
910
933
  "resources",
911
934
  "volumes",
935
+ "docker",
936
+ "files",
912
937
  "image",
913
938
  "user",
914
939
  "privileged",
915
940
  "entrypoint",
941
+ "working_dir",
916
942
  "python",
917
943
  "nvcc",
918
944
  "single_branch",
@@ -935,11 +961,14 @@ def _can_update_run_spec(current_run_spec: RunSpec, new_run_spec: RunSpec) -> bo
935
961
  def _check_can_update_run_spec(current_run_spec: RunSpec, new_run_spec: RunSpec):
936
962
  spec_diff = diff_models(current_run_spec, new_run_spec)
937
963
  changed_spec_fields = list(spec_diff.keys())
964
+ updatable_spec_fields = _UPDATABLE_SPEC_FIELDS + _TYPE_SPECIFIC_UPDATABLE_SPEC_FIELDS.get(
965
+ new_run_spec.configuration.type, []
966
+ )
938
967
  for key in changed_spec_fields:
939
- if key not in _UPDATABLE_SPEC_FIELDS:
968
+ if key not in updatable_spec_fields:
940
969
  raise ServerClientError(
941
970
  f"Failed to update fields {changed_spec_fields}."
942
- f" Can only update {_UPDATABLE_SPEC_FIELDS}."
971
+ f" Can only update {updatable_spec_fields}."
943
972
  )
944
973
  _check_can_update_configuration(current_run_spec.configuration, new_run_spec.configuration)
945
974
 
@@ -1068,10 +1097,20 @@ async def scale_run_replicas(session: AsyncSession, run_model: RunModel, replica
1068
1097
  await retry_run_replica_jobs(session, run_model, replica_jobs, only_failed=False)
1069
1098
  scheduled_replicas += 1
1070
1099
 
1100
+ secrets = await get_project_secrets_mapping(
1101
+ session=session,
1102
+ project=run_model.project,
1103
+ )
1104
+
1071
1105
  for replica_num in range(
1072
1106
  len(active_replicas) + scheduled_replicas, len(active_replicas) + replicas_diff
1073
1107
  ):
1074
- jobs = await get_jobs_from_run_spec(run_spec, replica_num=replica_num)
1108
+ # FIXME: Handle getting image configuration errors or skip it.
1109
+ jobs = await get_jobs_from_run_spec(
1110
+ run_spec=run_spec,
1111
+ secrets=secrets,
1112
+ replica_num=replica_num,
1113
+ )
1075
1114
  for job in jobs:
1076
1115
  job_model = create_job_model_for_new_submission(
1077
1116
  run_model=run_model,
@@ -1084,8 +1123,14 @@ async def scale_run_replicas(session: AsyncSession, run_model: RunModel, replica
1084
1123
  async def retry_run_replica_jobs(
1085
1124
  session: AsyncSession, run_model: RunModel, latest_jobs: List[JobModel], *, only_failed: bool
1086
1125
  ):
1126
+ # FIXME: Handle getting image configuration errors or skip it.
1127
+ secrets = await get_project_secrets_mapping(
1128
+ session=session,
1129
+ project=run_model.project,
1130
+ )
1087
1131
  new_jobs = await get_jobs_from_run_spec(
1088
- RunSpec.__response__.parse_raw(run_model.run_spec),
1132
+ run_spec=RunSpec.__response__.parse_raw(run_model.run_spec),
1133
+ secrets=secrets,
1089
1134
  replica_num=latest_jobs[0].replica_num,
1090
1135
  )
1091
1136
  assert len(new_jobs) == len(latest_jobs), (
@@ -0,0 +1,204 @@
1
+ import re
2
+ from typing import Dict, List, Optional
3
+
4
+ import sqlalchemy.exc
5
+ from sqlalchemy import delete, select, update
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from dstack._internal.core.errors import (
9
+ ResourceExistsError,
10
+ ResourceNotExistsError,
11
+ ServerClientError,
12
+ )
13
+ from dstack._internal.core.models.secrets import Secret
14
+ from dstack._internal.server.models import DecryptedString, ProjectModel, SecretModel
15
+ from dstack._internal.utils.logging import get_logger
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ _SECRET_NAME_REGEX = "^[A-Za-z0-9-_]{1,200}$"
21
+ _SECRET_VALUE_MAX_LENGTH = 2000
22
+
23
+
24
+ async def list_secrets(
25
+ session: AsyncSession,
26
+ project: ProjectModel,
27
+ ) -> List[Secret]:
28
+ secret_models = await list_project_secret_models(session=session, project=project)
29
+ return [secret_model_to_secret(s, include_value=False) for s in secret_models]
30
+
31
+
32
+ async def get_project_secrets_mapping(
33
+ session: AsyncSession,
34
+ project: ProjectModel,
35
+ ) -> Dict[str, str]:
36
+ secret_models = await list_project_secret_models(session=session, project=project)
37
+ return {s.name: s.value.get_plaintext_or_error() for s in secret_models}
38
+
39
+
40
+ async def get_secret(
41
+ session: AsyncSession,
42
+ project: ProjectModel,
43
+ name: str,
44
+ ) -> Optional[Secret]:
45
+ secret_model = await get_project_secret_model_by_name(
46
+ session=session,
47
+ project=project,
48
+ name=name,
49
+ )
50
+ if secret_model is None:
51
+ return None
52
+ return secret_model_to_secret(secret_model, include_value=True)
53
+
54
+
55
+ async def create_or_update_secret(
56
+ session: AsyncSession,
57
+ project: ProjectModel,
58
+ name: str,
59
+ value: str,
60
+ ) -> Secret:
61
+ _validate_secret(name=name, value=value)
62
+ try:
63
+ secret_model = await create_secret(
64
+ session=session,
65
+ project=project,
66
+ name=name,
67
+ value=value,
68
+ )
69
+ except ResourceExistsError:
70
+ secret_model = await update_secret(
71
+ session=session,
72
+ project=project,
73
+ name=name,
74
+ value=value,
75
+ )
76
+ return secret_model_to_secret(secret_model, include_value=True)
77
+
78
+
79
+ async def delete_secrets(
80
+ session: AsyncSession,
81
+ project: ProjectModel,
82
+ names: List[str],
83
+ ):
84
+ existing_secrets_query = await session.execute(
85
+ select(SecretModel).where(
86
+ SecretModel.project_id == project.id,
87
+ SecretModel.name.in_(names),
88
+ )
89
+ )
90
+ existing_names = [s.name for s in existing_secrets_query.scalars().all()]
91
+ missing_names = set(names) - set(existing_names)
92
+ if missing_names:
93
+ raise ResourceNotExistsError(f"Secrets not found: {', '.join(missing_names)}")
94
+
95
+ await session.execute(
96
+ delete(SecretModel).where(
97
+ SecretModel.project_id == project.id,
98
+ SecretModel.name.in_(names),
99
+ )
100
+ )
101
+ await session.commit()
102
+ logger.info("Deleted secrets %s in project %s", names, project.name)
103
+
104
+
105
+ def secret_model_to_secret(secret_model: SecretModel, include_value: bool = False) -> Secret:
106
+ value = None
107
+ if include_value:
108
+ value = secret_model.value.get_plaintext_or_error()
109
+ return Secret(
110
+ id=secret_model.id,
111
+ name=secret_model.name,
112
+ value=value,
113
+ )
114
+
115
+
116
+ async def list_project_secret_models(
117
+ session: AsyncSession,
118
+ project: ProjectModel,
119
+ ) -> List[SecretModel]:
120
+ res = await session.execute(
121
+ select(SecretModel)
122
+ .where(
123
+ SecretModel.project_id == project.id,
124
+ )
125
+ .order_by(SecretModel.created_at.desc())
126
+ )
127
+ secret_models = list(res.scalars().all())
128
+ return secret_models
129
+
130
+
131
+ async def get_project_secret_model_by_name(
132
+ session: AsyncSession,
133
+ project: ProjectModel,
134
+ name: str,
135
+ ) -> Optional[SecretModel]:
136
+ res = await session.execute(
137
+ select(SecretModel).where(
138
+ SecretModel.project_id == project.id,
139
+ SecretModel.name == name,
140
+ )
141
+ )
142
+ return res.scalar_one_or_none()
143
+
144
+
145
+ async def create_secret(
146
+ session: AsyncSession,
147
+ project: ProjectModel,
148
+ name: str,
149
+ value: str,
150
+ ) -> SecretModel:
151
+ secret_model = SecretModel(
152
+ project_id=project.id,
153
+ name=name,
154
+ value=DecryptedString(plaintext=value),
155
+ )
156
+ try:
157
+ async with session.begin_nested():
158
+ session.add(secret_model)
159
+ except sqlalchemy.exc.IntegrityError:
160
+ raise ResourceExistsError()
161
+ await session.commit()
162
+ return secret_model
163
+
164
+
165
+ async def update_secret(
166
+ session: AsyncSession,
167
+ project: ProjectModel,
168
+ name: str,
169
+ value: str,
170
+ ) -> SecretModel:
171
+ await session.execute(
172
+ update(SecretModel)
173
+ .where(
174
+ SecretModel.project_id == project.id,
175
+ SecretModel.name == name,
176
+ )
177
+ .values(
178
+ value=DecryptedString(plaintext=value),
179
+ )
180
+ )
181
+ await session.commit()
182
+ secret_model = await get_project_secret_model_by_name(
183
+ session=session,
184
+ project=project,
185
+ name=name,
186
+ )
187
+ if secret_model is None:
188
+ raise ResourceNotExistsError()
189
+ return secret_model
190
+
191
+
192
+ def _validate_secret(name: str, value: str):
193
+ _validate_secret_name(name)
194
+ _validate_secret_value(value)
195
+
196
+
197
+ def _validate_secret_name(name: str):
198
+ if re.match(_SECRET_NAME_REGEX, name) is None:
199
+ raise ServerClientError(f"Secret name should match regex '{_SECRET_NAME_REGEX}")
200
+
201
+
202
+ def _validate_secret_value(value: str):
203
+ if len(value) > _SECRET_VALUE_MAX_LENGTH:
204
+ raise ServerClientError(f"Secret value length must not exceed {_SECRET_VALUE_MAX_LENGTH}")
@@ -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
@@ -22,6 +22,27 @@ class BaseStorage(ABC):
22
22
  ) -> Optional[bytes]:
23
23
  pass
24
24
 
25
+ @abstractmethod
26
+ def upload_archive(
27
+ self,
28
+ user_id: str,
29
+ archive_hash: str,
30
+ blob: bytes,
31
+ ):
32
+ pass
33
+
34
+ @abstractmethod
35
+ def get_archive(
36
+ self,
37
+ user_id: str,
38
+ archive_hash: str,
39
+ ) -> Optional[bytes]:
40
+ pass
41
+
25
42
  @staticmethod
26
43
  def _get_code_key(project_id: str, repo_id: str, code_hash: str) -> str:
27
44
  return f"data/projects/{project_id}/codes/{repo_id}/{code_hash}"
45
+
46
+ @staticmethod
47
+ def _get_archive_key(user_id: str, archive_hash: str) -> str:
48
+ return f"data/users/{user_id}/file_archives/{archive_hash}"
@@ -25,9 +25,8 @@ class GCSStorage(BaseStorage):
25
25
  code_hash: str,
26
26
  blob: bytes,
27
27
  ):
28
- blob_name = self._get_code_key(project_id, repo_id, code_hash)
29
- blob_obj = self._bucket.blob(blob_name)
30
- blob_obj.upload_from_string(blob)
28
+ key = self._get_code_key(project_id, repo_id, code_hash)
29
+ self._upload(key, blob)
31
30
 
32
31
  def get_code(
33
32
  self,
@@ -35,10 +34,33 @@ class GCSStorage(BaseStorage):
35
34
  repo_id: str,
36
35
  code_hash: str,
37
36
  ) -> Optional[bytes]:
37
+ key = self._get_code_key(project_id, repo_id, code_hash)
38
+ return self._get(key)
39
+
40
+ def upload_archive(
41
+ self,
42
+ user_id: str,
43
+ archive_hash: str,
44
+ blob: bytes,
45
+ ):
46
+ key = self._get_archive_key(user_id, archive_hash)
47
+ self._upload(key, blob)
48
+
49
+ def get_archive(
50
+ self,
51
+ user_id: str,
52
+ archive_hash: str,
53
+ ) -> Optional[bytes]:
54
+ key = self._get_archive_key(user_id, archive_hash)
55
+ return self._get(key)
56
+
57
+ def _upload(self, key: str, blob: bytes):
58
+ blob_obj = self._bucket.blob(key)
59
+ blob_obj.upload_from_string(blob)
60
+
61
+ def _get(self, key: str) -> Optional[bytes]:
38
62
  try:
39
- blob_name = self._get_code_key(project_id, repo_id, code_hash)
40
- blob = self._bucket.blob(blob_name)
63
+ blob = self._bucket.blob(key)
41
64
  except NotFound:
42
65
  return None
43
-
44
66
  return blob.download_as_bytes()
@@ -27,11 +27,8 @@ class S3Storage(BaseStorage):
27
27
  code_hash: str,
28
28
  blob: bytes,
29
29
  ):
30
- self._client.put_object(
31
- Bucket=self.bucket,
32
- Key=self._get_code_key(project_id, repo_id, code_hash),
33
- Body=blob,
34
- )
30
+ key = self._get_code_key(project_id, repo_id, code_hash)
31
+ self._upload(key, blob)
35
32
 
36
33
  def get_code(
37
34
  self,
@@ -39,11 +36,32 @@ class S3Storage(BaseStorage):
39
36
  repo_id: str,
40
37
  code_hash: str,
41
38
  ) -> Optional[bytes]:
39
+ key = self._get_code_key(project_id, repo_id, code_hash)
40
+ return self._get(key)
41
+
42
+ def upload_archive(
43
+ self,
44
+ user_id: str,
45
+ archive_hash: str,
46
+ blob: bytes,
47
+ ):
48
+ key = self._get_archive_key(user_id, archive_hash)
49
+ self._upload(key, blob)
50
+
51
+ def get_archive(
52
+ self,
53
+ user_id: str,
54
+ archive_hash: str,
55
+ ) -> Optional[bytes]:
56
+ key = self._get_archive_key(user_id, archive_hash)
57
+ return self._get(key)
58
+
59
+ def _upload(self, key: str, blob: bytes):
60
+ self._client.put_object(Bucket=self.bucket, Key=key, Body=blob)
61
+
62
+ def _get(self, key: str) -> Optional[bytes]:
42
63
  try:
43
- response = self._client.get_object(
44
- Bucket=self.bucket,
45
- Key=self._get_code_key(project_id, repo_id, code_hash),
46
- )
64
+ response = self._client.get_object(Bucket=self.bucket, Key=key)
47
65
  except botocore.exceptions.ClientError as e:
48
66
  if e.response["Error"]["Code"] == "NoSuchKey":
49
67
  return 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:
@@ -70,6 +70,8 @@ SERVER_METRICS_FINISHED_TTL_SECONDS = int(
70
70
  os.getenv("DSTACK_SERVER_METRICS_FINISHED_TTL_SECONDS", 7 * 24 * 3600)
71
71
  )
72
72
 
73
+ SERVER_KEEP_SHIM_TASKS = os.getenv("DSTACK_SERVER_KEEP_SHIM_TASKS") is not None
74
+
73
75
  DEFAULT_PROJECT_NAME = "main"
74
76
 
75
77
  SENTRY_DSN = os.getenv("DSTACK_SENTRY_DSN")
@@ -95,8 +97,6 @@ SERVER_CODE_UPLOAD_LIMIT = int(os.getenv("DSTACK_SERVER_CODE_UPLOAD_LIMIT", 2 *
95
97
 
96
98
  SQL_ECHO_ENABLED = os.getenv("DSTACK_SQL_ECHO_ENABLED") is not None
97
99
 
98
- LOCAL_BACKEND_ENABLED = os.getenv("DSTACK_LOCAL_BACKEND_ENABLED") is not None
99
-
100
100
  UPDATE_DEFAULT_PROJECT = os.getenv("DSTACK_UPDATE_DEFAULT_PROJECT") is not None
101
101
  DO_NOT_UPDATE_DEFAULT_PROJECT = os.getenv("DSTACK_DO_NOT_UPDATE_DEFAULT_PROJECT") is not None
102
102
  SKIP_GATEWAY_UPDATE = os.getenv("DSTACK_SKIP_GATEWAY_UPDATE", None) 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-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-d151637af20f70b2e796.js"></script><link href="/main-d48635d8fe670d53961c.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>