dstack 0.18.40rc1__py3-none-any.whl → 0.18.42__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.
Files changed (104) hide show
  1. dstack/_internal/cli/commands/apply.py +8 -5
  2. dstack/_internal/cli/services/configurators/base.py +4 -2
  3. dstack/_internal/cli/services/configurators/fleet.py +21 -9
  4. dstack/_internal/cli/services/configurators/gateway.py +15 -0
  5. dstack/_internal/cli/services/configurators/run.py +6 -5
  6. dstack/_internal/cli/services/configurators/volume.py +15 -0
  7. dstack/_internal/cli/services/repos.py +3 -3
  8. dstack/_internal/cli/utils/fleet.py +44 -33
  9. dstack/_internal/cli/utils/run.py +27 -7
  10. dstack/_internal/cli/utils/volume.py +30 -9
  11. dstack/_internal/core/backends/aws/compute.py +94 -53
  12. dstack/_internal/core/backends/aws/resources.py +22 -12
  13. dstack/_internal/core/backends/azure/compute.py +2 -0
  14. dstack/_internal/core/backends/base/compute.py +20 -2
  15. dstack/_internal/core/backends/gcp/compute.py +32 -24
  16. dstack/_internal/core/backends/gcp/resources.py +0 -15
  17. dstack/_internal/core/backends/oci/compute.py +10 -5
  18. dstack/_internal/core/backends/oci/resources.py +23 -26
  19. dstack/_internal/core/backends/remote/provisioning.py +65 -27
  20. dstack/_internal/core/backends/runpod/compute.py +1 -0
  21. dstack/_internal/core/models/backends/azure.py +3 -1
  22. dstack/_internal/core/models/configurations.py +24 -1
  23. dstack/_internal/core/models/fleets.py +46 -0
  24. dstack/_internal/core/models/instances.py +5 -1
  25. dstack/_internal/core/models/pools.py +4 -1
  26. dstack/_internal/core/models/profiles.py +10 -4
  27. dstack/_internal/core/models/runs.py +23 -3
  28. dstack/_internal/core/models/volumes.py +26 -0
  29. dstack/_internal/core/services/ssh/attach.py +92 -53
  30. dstack/_internal/core/services/ssh/tunnel.py +58 -31
  31. dstack/_internal/proxy/gateway/routers/registry.py +2 -0
  32. dstack/_internal/proxy/gateway/schemas/registry.py +2 -0
  33. dstack/_internal/proxy/gateway/services/registry.py +4 -0
  34. dstack/_internal/proxy/lib/models.py +3 -0
  35. dstack/_internal/proxy/lib/services/service_connection.py +8 -1
  36. dstack/_internal/server/background/tasks/process_instances.py +73 -35
  37. dstack/_internal/server/background/tasks/process_metrics.py +9 -9
  38. dstack/_internal/server/background/tasks/process_running_jobs.py +77 -26
  39. dstack/_internal/server/background/tasks/process_runs.py +2 -12
  40. dstack/_internal/server/background/tasks/process_submitted_jobs.py +121 -49
  41. dstack/_internal/server/background/tasks/process_terminating_jobs.py +14 -3
  42. dstack/_internal/server/background/tasks/process_volumes.py +11 -1
  43. dstack/_internal/server/migrations/versions/1338b788b612_reverse_job_instance_relationship.py +71 -0
  44. dstack/_internal/server/migrations/versions/1e76fb0dde87_add_jobmodel_inactivity_secs.py +32 -0
  45. dstack/_internal/server/migrations/versions/51d45659d574_add_instancemodel_blocks_fields.py +43 -0
  46. dstack/_internal/server/migrations/versions/63c3f19cb184_add_jobterminationreason_inactivity_.py +83 -0
  47. dstack/_internal/server/migrations/versions/a751ef183f27_move_attachment_data_to_volumes_.py +34 -0
  48. dstack/_internal/server/models.py +27 -23
  49. dstack/_internal/server/routers/runs.py +1 -0
  50. dstack/_internal/server/schemas/runner.py +1 -0
  51. dstack/_internal/server/services/backends/configurators/azure.py +34 -8
  52. dstack/_internal/server/services/config.py +9 -0
  53. dstack/_internal/server/services/fleets.py +32 -3
  54. dstack/_internal/server/services/gateways/client.py +9 -1
  55. dstack/_internal/server/services/jobs/__init__.py +217 -45
  56. dstack/_internal/server/services/jobs/configurators/base.py +47 -2
  57. dstack/_internal/server/services/offers.py +96 -10
  58. dstack/_internal/server/services/pools.py +98 -14
  59. dstack/_internal/server/services/proxy/repo.py +17 -3
  60. dstack/_internal/server/services/runner/client.py +9 -6
  61. dstack/_internal/server/services/runner/ssh.py +33 -5
  62. dstack/_internal/server/services/runs.py +48 -179
  63. dstack/_internal/server/services/services/__init__.py +9 -1
  64. dstack/_internal/server/services/volumes.py +68 -9
  65. dstack/_internal/server/statics/index.html +1 -1
  66. dstack/_internal/server/statics/{main-11ec5e4a00ea6ec833e3.js → main-2ac66bfcbd2e39830b88.js} +30 -31
  67. dstack/_internal/server/statics/{main-11ec5e4a00ea6ec833e3.js.map → main-2ac66bfcbd2e39830b88.js.map} +1 -1
  68. dstack/_internal/server/statics/{main-fc56d1f4af8e57522a1c.css → main-ad5150a441de98cd8987.css} +1 -1
  69. dstack/_internal/server/testing/common.py +130 -61
  70. dstack/_internal/utils/common.py +22 -8
  71. dstack/_internal/utils/env.py +14 -0
  72. dstack/_internal/utils/ssh.py +1 -1
  73. dstack/api/server/_fleets.py +25 -1
  74. dstack/api/server/_runs.py +23 -2
  75. dstack/api/server/_volumes.py +12 -1
  76. dstack/version.py +1 -1
  77. {dstack-0.18.40rc1.dist-info → dstack-0.18.42.dist-info}/METADATA +1 -1
  78. {dstack-0.18.40rc1.dist-info → dstack-0.18.42.dist-info}/RECORD +104 -93
  79. tests/_internal/cli/services/configurators/test_profile.py +3 -3
  80. tests/_internal/core/services/ssh/test_tunnel.py +56 -4
  81. tests/_internal/proxy/gateway/routers/test_registry.py +30 -7
  82. tests/_internal/server/background/tasks/test_process_instances.py +138 -20
  83. tests/_internal/server/background/tasks/test_process_metrics.py +12 -0
  84. tests/_internal/server/background/tasks/test_process_running_jobs.py +193 -0
  85. tests/_internal/server/background/tasks/test_process_runs.py +27 -3
  86. tests/_internal/server/background/tasks/test_process_submitted_jobs.py +53 -6
  87. tests/_internal/server/background/tasks/test_process_terminating_jobs.py +135 -17
  88. tests/_internal/server/routers/test_fleets.py +15 -2
  89. tests/_internal/server/routers/test_pools.py +6 -0
  90. tests/_internal/server/routers/test_runs.py +27 -0
  91. tests/_internal/server/routers/test_volumes.py +9 -2
  92. tests/_internal/server/services/jobs/__init__.py +0 -0
  93. tests/_internal/server/services/jobs/configurators/__init__.py +0 -0
  94. tests/_internal/server/services/jobs/configurators/test_base.py +72 -0
  95. tests/_internal/server/services/runner/test_client.py +22 -3
  96. tests/_internal/server/services/test_offers.py +167 -0
  97. tests/_internal/server/services/test_pools.py +109 -1
  98. tests/_internal/server/services/test_runs.py +5 -41
  99. tests/_internal/utils/test_common.py +21 -0
  100. tests/_internal/utils/test_env.py +38 -0
  101. {dstack-0.18.40rc1.dist-info → dstack-0.18.42.dist-info}/LICENSE.md +0 -0
  102. {dstack-0.18.40rc1.dist-info → dstack-0.18.42.dist-info}/WHEEL +0 -0
  103. {dstack-0.18.40rc1.dist-info → dstack-0.18.42.dist-info}/entry_points.txt +0 -0
  104. {dstack-0.18.40rc1.dist-info → dstack-0.18.42.dist-info}/top_level.txt +0 -0
@@ -29,7 +29,6 @@ from dstack._internal.core.models.runs import (
29
29
  ApplyRunPlanInput,
30
30
  Job,
31
31
  JobPlan,
32
- JobProvisioningData,
33
32
  JobSpec,
34
33
  JobStatus,
35
34
  JobSubmission,
@@ -45,8 +44,6 @@ from dstack._internal.core.models.users import GlobalRole
45
44
  from dstack._internal.core.models.volumes import (
46
45
  InstanceMountPoint,
47
46
  Volume,
48
- VolumeMountPoint,
49
- VolumeStatus,
50
47
  )
51
48
  from dstack._internal.core.services import validate_dstack_resource_name
52
49
  from dstack._internal.core.services.diff import diff_models
@@ -58,15 +55,15 @@ from dstack._internal.server.models import (
58
55
  RepoModel,
59
56
  RunModel,
60
57
  UserModel,
61
- VolumeModel,
62
58
  )
63
59
  from dstack._internal.server.services import repos as repos_services
64
60
  from dstack._internal.server.services import services
65
- from dstack._internal.server.services import volumes as volumes_services
66
61
  from dstack._internal.server.services.docker import is_valid_docker_volume_target
67
62
  from dstack._internal.server.services.jobs import (
63
+ check_can_attach_job_volumes,
68
64
  delay_job_instance_termination,
69
65
  get_instances_ids_with_detaching_volumes,
66
+ get_job_configured_volumes,
70
67
  get_jobs_from_run_spec,
71
68
  group_jobs_by_replica_latest,
72
69
  job_model_to_job_submission,
@@ -80,6 +77,7 @@ from dstack._internal.server.services.pools import (
80
77
  get_instance_offer,
81
78
  get_or_create_pool_by_name,
82
79
  get_pool_instances,
80
+ get_shared_pool_instances_with_offers,
83
81
  )
84
82
  from dstack._internal.server.services.projects import list_project_models, list_user_project_models
85
83
  from dstack._internal.server.services.users import get_user_model_by_name
@@ -164,7 +162,8 @@ async def list_projects_run_models(
164
162
  limit: int,
165
163
  ascending: bool,
166
164
  ) -> List[RunModel]:
167
- filters = [RunModel.deleted == False, RunModel.project_id.in_(p.id for p in projects)]
165
+ filters = []
166
+ filters.append(RunModel.project_id.in_(p.id for p in projects))
168
167
  if repo is not None:
169
168
  filters.append(RunModel.repo_id == repo.id)
170
169
  if runs_user is not None:
@@ -300,10 +299,11 @@ async def get_plan(
300
299
  # TODO(egor-s): do we need to generate all replicas here?
301
300
  jobs = await get_jobs_from_run_spec(run_spec, replica_num=0)
302
301
 
303
- volumes = await get_run_volumes(
302
+ volumes = await get_job_configured_volumes(
304
303
  session=session,
305
304
  project=project,
306
305
  run_spec=run_spec,
306
+ job_num=0,
307
307
  )
308
308
 
309
309
  pool = await get_or_create_pool_by_name(
@@ -450,7 +450,7 @@ async def submit_run(
450
450
  else:
451
451
  await delete_runs(session=session, project=project, runs_names=[run_spec.run_name])
452
452
 
453
- await validate_run(
453
+ await _validate_run(
454
454
  session=session,
455
455
  user=user,
456
456
  project=project,
@@ -677,27 +677,40 @@ async def _get_pool_offers(
677
677
  run_spec: RunSpec,
678
678
  job: Job,
679
679
  volumes: List[List[Volume]],
680
- ) -> List[InstanceOfferWithAvailability]:
680
+ ) -> list[InstanceOfferWithAvailability]:
681
+ pool_offers: list[InstanceOfferWithAvailability] = []
682
+
681
683
  detaching_instances_ids = await get_instances_ids_with_detaching_volumes(session)
682
684
  pool_instances = [i for i in get_pool_instances(pool) if i.id not in detaching_instances_ids]
683
- pool_filtered_instances = filter_pool_instances(
685
+ multinode = job.job_spec.jobs_per_replica > 1
686
+
687
+ if not multinode:
688
+ shared_instances_with_offers = get_shared_pool_instances_with_offers(
689
+ pool_instances=pool_instances,
690
+ profile=run_spec.merged_profile,
691
+ requirements=job.job_spec.requirements,
692
+ volumes=volumes,
693
+ )
694
+ for _, offer in shared_instances_with_offers:
695
+ pool_offers.append(offer)
696
+
697
+ nonshared_instances = filter_pool_instances(
684
698
  pool_instances=pool_instances,
685
699
  profile=run_spec.merged_profile,
686
700
  requirements=job.job_spec.requirements,
687
- multinode=job.job_spec.jobs_per_replica > 1,
701
+ multinode=multinode,
688
702
  volumes=volumes,
703
+ shared=False,
689
704
  )
690
- pool_offers: List[InstanceOfferWithAvailability] = []
691
- for instance in pool_filtered_instances:
705
+ for instance in nonshared_instances:
692
706
  offer = get_instance_offer(instance)
693
707
  if offer is None:
694
708
  continue
695
709
  offer.availability = InstanceAvailability.BUSY
696
710
  if instance.status == InstanceStatus.IDLE:
697
711
  offer.availability = InstanceAvailability.IDLE
698
- if instance.unreachable:
699
- offer.availability = InstanceAvailability.NOT_AVAILABLE
700
712
  pool_offers.append(offer)
713
+
701
714
  pool_offers.sort(key=lambda offer: offer.price)
702
715
  return pool_offers
703
716
 
@@ -722,186 +735,42 @@ async def _generate_run_name(
722
735
  idx += 1
723
736
 
724
737
 
725
- async def validate_run(
726
- session: AsyncSession,
727
- user: UserModel,
728
- project: ProjectModel,
729
- run_spec: RunSpec,
730
- ):
731
- volumes = await get_run_volumes(
732
- session=session,
733
- project=project,
734
- run_spec=run_spec,
735
- )
736
- check_can_attach_run_volumes(
737
- run_spec=run_spec,
738
- volumes=volumes,
738
+ def check_run_spec_requires_instance_mounts(run_spec: RunSpec) -> bool:
739
+ return any(
740
+ is_core_model_instance(mp, InstanceMountPoint) and not mp.optional
741
+ for mp in run_spec.configuration.volumes
739
742
  )
740
743
 
741
744
 
742
- async def get_run_volumes(
745
+ async def _validate_run(
743
746
  session: AsyncSession,
747
+ user: UserModel,
744
748
  project: ProjectModel,
745
749
  run_spec: RunSpec,
746
- ) -> List[List[Volume]]:
747
- """
748
- Returns list of run volumes grouped by mount points.
749
- """
750
- volume_models = await get_run_volume_models(
750
+ ):
751
+ await _validate_run_volumes(
751
752
  session=session,
752
753
  project=project,
753
754
  run_spec=run_spec,
754
755
  )
755
- return [
756
- [volumes_services.volume_model_to_volume(v) for v in mount_point_volume_models]
757
- for mount_point_volume_models in volume_models
758
- ]
759
756
 
760
757
 
761
- async def get_run_volume_models(
758
+ async def _validate_run_volumes(
762
759
  session: AsyncSession,
763
760
  project: ProjectModel,
764
761
  run_spec: RunSpec,
765
- ) -> List[List[VolumeModel]]:
766
- """
767
- Returns list of run volume models grouped by mount points.
768
- """
769
- if len(run_spec.configuration.volumes) == 0:
770
- return []
771
- volume_models = []
772
- for mount_point in run_spec.configuration.volumes:
773
- if not is_core_model_instance(mount_point, VolumeMountPoint):
774
- continue
775
- if isinstance(mount_point.name, str):
776
- names = [mount_point.name]
777
- else:
778
- names = mount_point.name
779
- mount_point_volume_models = []
780
- for name in names:
781
- volume_model = await volumes_services.get_project_volume_model_by_name(
782
- session=session,
783
- project=project,
784
- name=name,
785
- )
786
- if volume_model is None:
787
- raise ResourceNotExistsError(f"Volume {mount_point.name} not found")
788
- mount_point_volume_models.append(volume_model)
789
- volume_models.append(mount_point_volume_models)
790
- return volume_models
791
-
792
-
793
- def check_can_attach_run_volumes(
794
- run_spec: RunSpec,
795
- volumes: List[List[Volume]],
796
762
  ):
797
- """
798
- Performs basic checks if volumes can be attached.
799
- This is useful to show error ASAP (when user submits the run).
800
- If the attachment is to fail anyway, the error will be handled when proccessing submitted jobs.
801
- """
802
- if len(volumes) == 0:
803
- return
804
- expected_backends = {v.configuration.backend for v in volumes[0]}
805
- expected_regions = {v.configuration.region for v in volumes[0]}
806
- for mount_point_volumes in volumes:
807
- backends = {v.configuration.backend for v in mount_point_volumes}
808
- regions = {v.configuration.region for v in mount_point_volumes}
809
- if backends != expected_backends:
810
- raise ServerClientError(
811
- "Volumes from different backends specified for different mount points"
812
- )
813
- if regions != expected_regions:
814
- raise ServerClientError(
815
- "Volumes from different regions specified for different mount points"
816
- )
817
- for volume in mount_point_volumes:
818
- if volume.status != VolumeStatus.ACTIVE:
819
- raise ServerClientError(f"Cannot mount volumes that are not active: {volume.name}")
820
- volumes_names = [v.name for vs in volumes for v in vs]
821
- if len(volumes_names) != len(set(volumes_names)):
822
- raise ServerClientError("Cannot attach the same volume at different mount points")
823
-
824
-
825
- async def get_job_volumes(
826
- session: AsyncSession,
827
- project: ProjectModel,
828
- run_spec: RunSpec,
829
- job_provisioning_data: JobProvisioningData,
830
- ) -> List[Volume]:
831
- """
832
- Returns volumes attached to the job.
833
- """
834
- run_volumes = await get_run_volumes(
835
- session=session,
836
- project=project,
837
- run_spec=run_spec,
838
- )
839
- job_volumes = []
840
- for mount_point_volumes in run_volumes:
841
- job_volumes.append(get_job_mount_point_volume(mount_point_volumes, job_provisioning_data))
842
- return job_volumes
843
-
844
-
845
- def get_job_mount_point_volume(
846
- volumes: List[Volume],
847
- job_provisioning_data: JobProvisioningData,
848
- ) -> Volume:
849
- """
850
- Returns the volume attached to the job among the list of possible mount point volumes.
851
- """
852
- for volume in volumes:
853
- if (
854
- volume.configuration.backend != job_provisioning_data.get_base_backend()
855
- or volume.configuration.region != job_provisioning_data.region
856
- ):
857
- continue
858
- if (
859
- volume.provisioning_data is not None
860
- and volume.provisioning_data.availability_zone is not None
861
- and job_provisioning_data.availability_zone is not None
862
- and volume.provisioning_data.availability_zone
863
- != job_provisioning_data.availability_zone
864
- ):
865
- continue
866
- return volume
867
- raise ServerClientError("Failed to find an eligible volume for the mount point")
868
-
869
-
870
- def get_offer_volumes(
871
- volumes: List[List[Volume]],
872
- offer: InstanceOfferWithAvailability,
873
- ) -> List[Volume]:
874
- """
875
- Returns volumes suitable for the offer for each mount point.
876
- """
877
- offer_volumes = []
878
- for mount_point_volumes in volumes:
879
- offer_volumes.append(get_offer_mount_point_volume(mount_point_volumes, offer))
880
- return offer_volumes
881
-
882
-
883
- def get_offer_mount_point_volume(
884
- volumes: List[Volume],
885
- offer: InstanceOfferWithAvailability,
886
- ) -> Volume:
887
- """
888
- Returns the first suitable volume for the offer among possible mount point volumes.
889
- """
890
- for volume in volumes:
891
- if (
892
- volume.configuration.backend != offer.backend
893
- or volume.configuration.region != offer.region
894
- ):
895
- continue
896
- return volume
897
- raise ServerClientError("Failed to find an eligible volume for the mount point")
898
-
899
-
900
- def check_run_spec_requires_instance_mounts(run_spec: RunSpec) -> bool:
901
- return any(
902
- is_core_model_instance(mp, InstanceMountPoint) and not mp.optional
903
- for mp in run_spec.configuration.volumes
904
- )
763
+ # The volumes validation should be done here and not in job configurator
764
+ # since potentially we may need to validate volumes for jobs/replicas
765
+ # that won't be created immediately (e.g. range of replicas or nodes).
766
+ nodes = 1
767
+ if run_spec.configuration.type == "task":
768
+ nodes = run_spec.configuration.nodes
769
+ for job_num in range(nodes):
770
+ volumes = await get_job_configured_volumes(
771
+ session=session, project=project, run_spec=run_spec, job_num=job_num
772
+ )
773
+ check_can_attach_job_volumes(volumes=volumes)
905
774
 
906
775
 
907
776
  async def _get_run_repo_or_error(
@@ -21,6 +21,7 @@ from dstack._internal.core.errors import (
21
21
  from dstack._internal.core.models.common import is_core_model_instance
22
22
  from dstack._internal.core.models.configurations import SERVICE_HTTPS_DEFAULT, ServiceConfiguration
23
23
  from dstack._internal.core.models.gateways import GatewayConfiguration, GatewayStatus
24
+ from dstack._internal.core.models.instances import SSHConnectionParams
24
25
  from dstack._internal.core.models.runs import Run, RunSpec, ServiceModelSpec, ServiceSpec
25
26
  from dstack._internal.server import settings
26
27
  from dstack._internal.server.models import GatewayModel, JobModel, ProjectModel, RunModel
@@ -155,7 +156,12 @@ def get_service_spec(
155
156
 
156
157
 
157
158
  async def register_replica(
158
- session: AsyncSession, gateway_id: Optional[uuid.UUID], run: Run, job_model: JobModel
159
+ session: AsyncSession,
160
+ gateway_id: Optional[uuid.UUID],
161
+ run: Run,
162
+ job_model: JobModel,
163
+ ssh_head_proxy: Optional[SSHConnectionParams],
164
+ ssh_head_proxy_private_key: Optional[str],
159
165
  ):
160
166
  if gateway_id is None: # in-server proxy
161
167
  return
@@ -167,6 +173,8 @@ async def register_replica(
167
173
  await client.register_replica(
168
174
  run=run,
169
175
  job_submission=job_submission,
176
+ ssh_head_proxy=ssh_head_proxy,
177
+ ssh_head_proxy_private_key=ssh_head_proxy_private_key,
170
178
  )
171
179
  logger.info("%s: replica is registered for service %s", fmt(job_model), run.id.hex)
172
180
  except (httpx.RequestError, SSHError) as e:
@@ -15,19 +15,28 @@ from dstack._internal.core.errors import (
15
15
  from dstack._internal.core.models.users import GlobalRole
16
16
  from dstack._internal.core.models.volumes import (
17
17
  Volume,
18
+ VolumeAttachment,
18
19
  VolumeAttachmentData,
19
20
  VolumeConfiguration,
21
+ VolumeInstance,
20
22
  VolumeProvisioningData,
21
23
  VolumeStatus,
22
24
  )
23
25
  from dstack._internal.core.services import validate_dstack_resource_name
24
26
  from dstack._internal.server.db import get_db
25
- from dstack._internal.server.models import ProjectModel, UserModel, VolumeModel
27
+ from dstack._internal.server.models import (
28
+ InstanceModel,
29
+ ProjectModel,
30
+ UserModel,
31
+ VolumeAttachmentModel,
32
+ VolumeModel,
33
+ )
26
34
  from dstack._internal.server.services import backends as backends_services
27
35
  from dstack._internal.server.services.locking import (
28
36
  get_locker,
29
37
  string_to_lock_id,
30
38
  )
39
+ from dstack._internal.server.services.pools import get_instance_provisioning_data
31
40
  from dstack._internal.server.services.projects import list_project_models, list_user_project_models
32
41
  from dstack._internal.utils import common, random_names
33
42
  from dstack._internal.utils.logging import get_logger
@@ -106,8 +115,13 @@ async def list_projects_volume_models(
106
115
  .order_by(*order_by)
107
116
  .limit(limit)
108
117
  .options(joinedload(VolumeModel.user))
118
+ .options(
119
+ joinedload(VolumeModel.attachments)
120
+ .joinedload(VolumeAttachmentModel.instance)
121
+ .joinedload(InstanceModel.fleet)
122
+ )
109
123
  )
110
- volume_models = list(res.scalars().all())
124
+ volume_models = list(res.unique().scalars().all())
111
125
  return volume_models
112
126
 
113
127
 
@@ -134,9 +148,16 @@ async def list_project_volume_models(
134
148
  if not include_deleted:
135
149
  filters.append(VolumeModel.deleted == False)
136
150
  res = await session.execute(
137
- select(VolumeModel).where(*filters).options(joinedload(VolumeModel.user))
151
+ select(VolumeModel)
152
+ .where(*filters)
153
+ .options(joinedload(VolumeModel.user))
154
+ .options(
155
+ joinedload(VolumeModel.attachments)
156
+ .joinedload(VolumeAttachmentModel.instance)
157
+ .joinedload(InstanceModel.fleet)
158
+ )
138
159
  )
139
- return list(res.scalars().all())
160
+ return list(res.unique().scalars().all())
140
161
 
141
162
 
142
163
  async def get_volume_by_name(
@@ -163,9 +184,16 @@ async def get_project_volume_model_by_name(
163
184
  if not include_deleted:
164
185
  filters.append(VolumeModel.deleted == False)
165
186
  res = await session.execute(
166
- select(VolumeModel).where(*filters).options(joinedload(VolumeModel.user))
187
+ select(VolumeModel)
188
+ .where(*filters)
189
+ .options(joinedload(VolumeModel.user))
190
+ .options(
191
+ joinedload(VolumeModel.attachments)
192
+ .joinedload(VolumeAttachmentModel.instance)
193
+ .joinedload(InstanceModel.fleet)
194
+ )
167
195
  )
168
- return res.scalar_one_or_none()
196
+ return res.unique().scalar_one_or_none()
169
197
 
170
198
 
171
199
  async def create_volume(
@@ -205,10 +233,10 @@ async def create_volume(
205
233
  project=project,
206
234
  status=VolumeStatus.SUBMITTED,
207
235
  configuration=configuration.json(),
236
+ attachments=[],
208
237
  )
209
238
  session.add(volume_model)
210
239
  await session.commit()
211
- await session.refresh(volume_model)
212
240
  return volume_model_to_volume(volume_model)
213
241
 
214
242
 
@@ -234,13 +262,13 @@ async def delete_volumes(session: AsyncSession, project: ProjectModel, names: Li
234
262
  VolumeModel.deleted == False,
235
263
  )
236
264
  .options(selectinload(VolumeModel.user))
237
- .options(selectinload(VolumeModel.instances))
265
+ .options(selectinload(VolumeModel.attachments))
238
266
  .execution_options(populate_existing=True)
239
267
  .with_for_update()
240
268
  )
241
269
  volume_models = res.scalars().unique().all()
242
270
  for volume_model in volume_models:
243
- if len(volume_model.instances) > 0:
271
+ if len(volume_model.attachments) > 0:
244
272
  raise ServerClientError(
245
273
  f"Failed to delete volume {volume_model.name}. Volume is in use."
246
274
  )
@@ -270,6 +298,15 @@ def volume_model_to_volume(volume_model: VolumeModel) -> Volume:
270
298
  # Initially VolumeProvisionigData lacked backend
271
299
  if vpd is not None and vpd.backend is None:
272
300
  vpd.backend = configuration.backend
301
+ attachments = []
302
+ for volume_attachment_model in volume_model.attachments:
303
+ instance = volume_attachment_model.instance
304
+ attachments.append(
305
+ VolumeAttachment(
306
+ instance=instance_model_to_volume_instance(instance),
307
+ attachment_data=get_attachment_data(volume_attachment_model),
308
+ )
309
+ )
273
310
  return Volume(
274
311
  name=volume_model.name,
275
312
  project_name=volume_model.project.name,
@@ -282,6 +319,7 @@ def volume_model_to_volume(volume_model: VolumeModel) -> Volume:
282
319
  deleted=volume_model.deleted,
283
320
  volume_id=vpd.volume_id if vpd is not None else None,
284
321
  provisioning_data=vpd,
322
+ attachments=attachments,
285
323
  attachment_data=vad,
286
324
  id=volume_model.id,
287
325
  )
@@ -303,6 +341,27 @@ def get_volume_attachment_data(volume_model: VolumeModel) -> Optional[VolumeAtta
303
341
  return VolumeAttachmentData.__response__.parse_raw(volume_model.volume_attachment_data)
304
342
 
305
343
 
344
+ def get_attachment_data(
345
+ volume_attachment_model: VolumeAttachmentModel,
346
+ ) -> Optional[VolumeAttachmentData]:
347
+ if volume_attachment_model.attachment_data is None:
348
+ return None
349
+ return VolumeAttachmentData.__response__.parse_raw(volume_attachment_model.attachment_data)
350
+
351
+
352
+ def instance_model_to_volume_instance(instance_model: InstanceModel) -> VolumeInstance:
353
+ instance_id = None
354
+ jpd = get_instance_provisioning_data(instance_model)
355
+ if jpd is not None:
356
+ instance_id = jpd.instance_id
357
+ return VolumeInstance(
358
+ name=instance_model.name,
359
+ fleet_name=instance_model.fleet.name if instance_model.fleet else None,
360
+ instance_num=instance_model.instance_num,
361
+ instance_id=instance_id,
362
+ )
363
+
364
+
306
365
  async def generate_volume_name(session: AsyncSession, project: ProjectModel) -> str:
307
366
  volume_models = await list_project_volume_models(session=session, project=project)
308
367
  names = {v.name for v in volume_models}
@@ -1,3 +1,3 @@
1
1
  <!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><title>dstack</title><meta name="description" content="Get GPUs at the best prices and availability from a wide range of providers. No cloud account of your own is required.
2
2
  "/><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet"><meta name="og:title" content="dstack"><meta name="og:type" content="article"><meta name="og:image" content="/splash_thumbnail.png"><meta name="og:description" content="Get GPUs at the best prices and availability from a wide range of providers. No cloud account of your own is required.
3
- "><link rel="icon" type="image/x-icon" href="/assets/favicon.ico"><link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"><link rel="icon" type="image/png" sizes="48x48" href="/assets/favicon-48x48.png"><link rel="manifest" href="/assets/manifest.webmanifest"><meta name="mobile-web-app-capable" content="yes"><meta name="theme-color" content="#fff"><meta name="application-name" content="dstackai"><link rel="apple-touch-icon" sizes="57x57" href="/assets/apple-touch-icon-57x57.png"><link rel="apple-touch-icon" sizes="60x60" href="/assets/apple-touch-icon-60x60.png"><link rel="apple-touch-icon" sizes="72x72" href="/assets/apple-touch-icon-72x72.png"><link rel="apple-touch-icon" sizes="76x76" href="/assets/apple-touch-icon-76x76.png"><link rel="apple-touch-icon" sizes="114x114" href="/assets/apple-touch-icon-114x114.png"><link rel="apple-touch-icon" sizes="120x120" href="/assets/apple-touch-icon-120x120.png"><link rel="apple-touch-icon" sizes="144x144" href="/assets/apple-touch-icon-144x144.png"><link rel="apple-touch-icon" sizes="152x152" href="/assets/apple-touch-icon-152x152.png"><link rel="apple-touch-icon" sizes="167x167" href="/assets/apple-touch-icon-167x167.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-180x180.png"><link rel="apple-touch-icon" sizes="1024x1024" href="/assets/apple-touch-icon-1024x1024.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="dstackai"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-640x1136.png"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1136x640.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-750x1334.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1334x750.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1125x2436.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2436x1125.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1170x2532.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2532x1170.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1179x2556.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2556x1179.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-828x1792.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1792x828.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2688.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2688x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2208.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2208x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1284x2778.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2778x1284.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1290x2796.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2796x1290.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1488x2266.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2266x1488.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1536x2048.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2048x1536.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1620x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1620.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1640x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1640.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2388.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2388x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2224.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2224x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-2048x2732.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2732x2048.png"><meta name="msapplication-TileColor" content="#fff"><meta name="msapplication-TileImage" content="/assets/mstile-144x144.png"><meta name="msapplication-config" content="/assets/browserconfig.xml"><link rel="yandex-tableau-widget" href="/assets/yandex-browser-manifest.json"><script defer="defer" src="/main-11ec5e4a00ea6ec833e3.js"></script><link href="/main-fc56d1f4af8e57522a1c.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-2ac66bfcbd2e39830b88.js"></script><link href="/main-ad5150a441de98cd8987.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>