dstack 0.18.40__py3-none-any.whl → 0.18.41__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 (98) 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 +21 -9
  11. dstack/_internal/core/backends/aws/compute.py +92 -52
  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 +30 -23
  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 +20 -0
  28. dstack/_internal/core/models/volumes.py +3 -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 +72 -33
  37. dstack/_internal/server/background/tasks/process_metrics.py +9 -9
  38. dstack/_internal/server/background/tasks/process_running_jobs.py +73 -26
  39. dstack/_internal/server/background/tasks/process_runs.py +2 -12
  40. dstack/_internal/server/background/tasks/process_submitted_jobs.py +109 -42
  41. dstack/_internal/server/background/tasks/process_terminating_jobs.py +1 -1
  42. dstack/_internal/server/migrations/versions/1338b788b612_reverse_job_instance_relationship.py +71 -0
  43. dstack/_internal/server/migrations/versions/1e76fb0dde87_add_jobmodel_inactivity_secs.py +32 -0
  44. dstack/_internal/server/migrations/versions/51d45659d574_add_instancemodel_blocks_fields.py +43 -0
  45. dstack/_internal/server/migrations/versions/63c3f19cb184_add_jobterminationreason_inactivity_.py +83 -0
  46. dstack/_internal/server/models.py +10 -4
  47. dstack/_internal/server/routers/runs.py +1 -0
  48. dstack/_internal/server/schemas/runner.py +1 -0
  49. dstack/_internal/server/services/backends/configurators/azure.py +34 -8
  50. dstack/_internal/server/services/config.py +9 -0
  51. dstack/_internal/server/services/fleets.py +27 -2
  52. dstack/_internal/server/services/gateways/client.py +9 -1
  53. dstack/_internal/server/services/jobs/__init__.py +215 -43
  54. dstack/_internal/server/services/jobs/configurators/base.py +47 -2
  55. dstack/_internal/server/services/offers.py +91 -5
  56. dstack/_internal/server/services/pools.py +95 -11
  57. dstack/_internal/server/services/proxy/repo.py +17 -3
  58. dstack/_internal/server/services/runner/client.py +1 -1
  59. dstack/_internal/server/services/runner/ssh.py +33 -5
  60. dstack/_internal/server/services/runs.py +48 -179
  61. dstack/_internal/server/services/services/__init__.py +9 -1
  62. dstack/_internal/server/statics/index.html +1 -1
  63. dstack/_internal/server/statics/{main-11ec5e4a00ea6ec833e3.js → main-2ac66bfcbd2e39830b88.js} +30 -31
  64. dstack/_internal/server/statics/{main-11ec5e4a00ea6ec833e3.js.map → main-2ac66bfcbd2e39830b88.js.map} +1 -1
  65. dstack/_internal/server/statics/{main-fc56d1f4af8e57522a1c.css → main-ad5150a441de98cd8987.css} +1 -1
  66. dstack/_internal/server/testing/common.py +117 -52
  67. dstack/_internal/utils/common.py +22 -8
  68. dstack/_internal/utils/env.py +14 -0
  69. dstack/_internal/utils/ssh.py +1 -1
  70. dstack/api/server/_fleets.py +25 -1
  71. dstack/api/server/_runs.py +23 -2
  72. dstack/api/server/_volumes.py +12 -1
  73. dstack/version.py +1 -1
  74. {dstack-0.18.40.dist-info → dstack-0.18.41.dist-info}/METADATA +1 -1
  75. {dstack-0.18.40.dist-info → dstack-0.18.41.dist-info}/RECORD +98 -89
  76. tests/_internal/cli/services/configurators/test_profile.py +3 -3
  77. tests/_internal/core/services/ssh/test_tunnel.py +56 -4
  78. tests/_internal/proxy/gateway/routers/test_registry.py +30 -7
  79. tests/_internal/server/background/tasks/test_process_instances.py +138 -20
  80. tests/_internal/server/background/tasks/test_process_metrics.py +12 -0
  81. tests/_internal/server/background/tasks/test_process_running_jobs.py +192 -0
  82. tests/_internal/server/background/tasks/test_process_runs.py +27 -3
  83. tests/_internal/server/background/tasks/test_process_submitted_jobs.py +48 -3
  84. tests/_internal/server/background/tasks/test_process_terminating_jobs.py +126 -13
  85. tests/_internal/server/routers/test_fleets.py +15 -2
  86. tests/_internal/server/routers/test_pools.py +6 -0
  87. tests/_internal/server/routers/test_runs.py +27 -0
  88. tests/_internal/server/services/jobs/__init__.py +0 -0
  89. tests/_internal/server/services/jobs/configurators/__init__.py +0 -0
  90. tests/_internal/server/services/jobs/configurators/test_base.py +72 -0
  91. tests/_internal/server/services/test_pools.py +4 -0
  92. tests/_internal/server/services/test_runs.py +5 -41
  93. tests/_internal/utils/test_common.py +21 -0
  94. tests/_internal/utils/test_env.py +38 -0
  95. {dstack-0.18.40.dist-info → dstack-0.18.41.dist-info}/LICENSE.md +0 -0
  96. {dstack-0.18.40.dist-info → dstack-0.18.41.dist-info}/WHEEL +0 -0
  97. {dstack-0.18.40.dist-info → dstack-0.18.41.dist-info}/entry_points.txt +0 -0
  98. {dstack-0.18.40.dist-info → dstack-0.18.41.dist-info}/top_level.txt +0 -0
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
5
5
  from typing import Dict, List, Optional, Union
6
6
  from uuid import UUID
7
7
 
8
+ import gpuhunt
8
9
  from sqlalchemy.ext.asyncio import AsyncSession
9
10
 
10
11
  from dstack._internal.core.models.backends.base import BackendType
@@ -13,14 +14,20 @@ from dstack._internal.core.models.configurations import (
13
14
  AnyRunConfiguration,
14
15
  DevEnvironmentConfiguration,
15
16
  )
17
+ from dstack._internal.core.models.envs import Env
16
18
  from dstack._internal.core.models.fleets import FleetConfiguration, FleetSpec, FleetStatus
17
19
  from dstack._internal.core.models.gateways import GatewayStatus
18
20
  from dstack._internal.core.models.instances import (
21
+ Disk,
22
+ Gpu,
23
+ InstanceAvailability,
19
24
  InstanceConfiguration,
25
+ InstanceOfferWithAvailability,
20
26
  InstanceStatus,
21
27
  InstanceType,
22
28
  RemoteConnectionInfo,
23
29
  Resources,
30
+ SSHKey,
24
31
  )
25
32
  from dstack._internal.core.models.placement import (
26
33
  PlacementGroupConfiguration,
@@ -311,17 +318,30 @@ async def create_job(
311
318
  return job
312
319
 
313
320
 
314
- def get_job_provisioning_data(dockerized: bool = False) -> JobProvisioningData:
321
+ def get_job_provisioning_data(
322
+ dockerized: bool = False,
323
+ backend: BackendType = BackendType.AWS,
324
+ region: str = "us-east-1",
325
+ gpu_count: int = 0,
326
+ cpu_count: int = 1,
327
+ memory_gib: float = 0.5,
328
+ spot: bool = False,
329
+ hostname: str = "127.0.0.4",
330
+ internal_ip: Optional[str] = "127.0.0.4",
331
+ ) -> JobProvisioningData:
332
+ gpus = [Gpu(name="T4", memory_mib=16384, vendor=gpuhunt.AcceleratorVendor.NVIDIA)] * gpu_count
315
333
  return JobProvisioningData(
316
- backend=BackendType.AWS,
334
+ backend=backend,
317
335
  instance_type=InstanceType(
318
336
  name="instance",
319
- resources=Resources(cpus=1, memory_mib=512, spot=False, gpus=[]),
337
+ resources=Resources(
338
+ cpus=cpu_count, memory_mib=int(memory_gib * 1024), spot=spot, gpus=gpus
339
+ ),
320
340
  ),
321
341
  instance_id="instance_id",
322
- hostname="127.0.0.4",
323
- internal_ip="127.0.0.4",
324
- region="us-east-1",
342
+ hostname=hostname,
343
+ internal_ip=internal_ip,
344
+ region=region,
325
345
  price=10.5,
326
346
  username="ubuntu",
327
347
  ssh_port=22,
@@ -337,6 +357,8 @@ def get_job_runtime_data(
337
357
  gpu: Optional[int] = None,
338
358
  memory: Optional[float] = None,
339
359
  ports: Optional[dict[int, int]] = None,
360
+ offer: Optional[InstanceOfferWithAvailability] = None,
361
+ volume_names: Optional[list[str]] = None,
340
362
  ) -> JobRuntimeData:
341
363
  return JobRuntimeData(
342
364
  network_mode=NetworkMode(network_mode),
@@ -344,6 +366,8 @@ def get_job_runtime_data(
344
366
  gpu=gpu,
345
367
  memory=Memory(memory) if memory is not None else None,
346
368
  ports=ports,
369
+ offer=offer,
370
+ volume_names=volume_names,
347
371
  )
348
372
 
349
373
 
@@ -481,56 +505,26 @@ async def create_instance(
481
505
  termination_idle_time: int = DEFAULT_POOL_TERMINATION_IDLE_TIME,
482
506
  region: str = "eu-west",
483
507
  remote_connection_info: Optional[RemoteConnectionInfo] = None,
508
+ offer: Optional[InstanceOfferWithAvailability] = None,
484
509
  job_provisioning_data: Optional[JobProvisioningData] = None,
510
+ total_blocks: Optional[int] = 1,
511
+ busy_blocks: int = 0,
485
512
  name: str = "test_instance",
486
513
  volumes: Optional[List[VolumeModel]] = None,
487
514
  ) -> InstanceModel:
488
515
  if instance_id is None:
489
516
  instance_id = uuid.uuid4()
490
517
  if job_provisioning_data is None:
491
- job_provisioning_data_dict = {
492
- "backend": backend.value,
493
- "instance_type": {
494
- "name": "instance",
495
- "resources": {
496
- "cpus": 1,
497
- "memory_mib": 512,
498
- "gpus": [],
499
- "spot": spot,
500
- "disk": {"size_mib": 102400},
501
- "description": "",
502
- },
503
- },
504
- "instance_id": "running_instance.id",
505
- "ssh_proxy": None,
506
- "hostname": "running_instance.ip",
507
- "region": region,
508
- "price": 0.1,
509
- "username": "root",
510
- "ssh_port": 22,
511
- "dockerized": True,
512
- "backend_data": None,
513
- }
514
- else:
515
- job_provisioning_data_dict = job_provisioning_data.dict()
516
- offer = {
517
- "backend": backend.value,
518
- "instance": {
519
- "name": "instance",
520
- "resources": {
521
- "cpus": 2,
522
- "memory_mib": 12000,
523
- "gpus": [],
524
- "spot": spot,
525
- "disk": {"size_mib": 102400},
526
- "description": "",
527
- },
528
- },
529
- "region": region,
530
- "price": 1,
531
- "availability": "available",
532
- }
533
-
518
+ job_provisioning_data = get_job_provisioning_data(
519
+ dockerized=True,
520
+ backend=backend,
521
+ region=region,
522
+ spot=spot,
523
+ hostname="running_instance.ip",
524
+ internal_ip=None,
525
+ )
526
+ if offer is None:
527
+ offer = get_instance_offer_with_availability(backend=backend, region=region, spot=spot)
534
528
  if profile is None:
535
529
  profile = Profile(name="test_name")
536
530
 
@@ -561,8 +555,8 @@ async def create_instance(
561
555
  created_at=created_at,
562
556
  started_at=created_at,
563
557
  finished_at=finished_at,
564
- job_provisioning_data=json.dumps(job_provisioning_data_dict),
565
- offer=json.dumps(offer),
558
+ job_provisioning_data=job_provisioning_data.json(),
559
+ offer=offer.json(),
566
560
  price=1,
567
561
  region=region,
568
562
  backend=backend,
@@ -572,14 +566,85 @@ async def create_instance(
572
566
  requirements=requirements.json(),
573
567
  instance_configuration=instance_configuration.json(),
574
568
  remote_connection_info=remote_connection_info.json() if remote_connection_info else None,
575
- job=job,
576
569
  volumes=volumes,
570
+ total_blocks=total_blocks,
571
+ busy_blocks=busy_blocks,
577
572
  )
573
+ if job:
574
+ im.jobs.append(job)
578
575
  session.add(im)
579
576
  await session.commit()
580
577
  return im
581
578
 
582
579
 
580
+ def get_instance_offer_with_availability(
581
+ backend: BackendType = BackendType.AWS,
582
+ region: str = "eu-west",
583
+ gpu_count: int = 0,
584
+ cpu_count: int = 2,
585
+ memory_gib: float = 12,
586
+ disk_gib: float = 100.0,
587
+ spot: bool = False,
588
+ blocks: int = 1,
589
+ total_blocks: int = 1,
590
+ ):
591
+ gpus = [Gpu(name="T4", memory_mib=16384, vendor=gpuhunt.AcceleratorVendor.NVIDIA)] * gpu_count
592
+ return InstanceOfferWithAvailability(
593
+ backend=backend,
594
+ instance=InstanceType(
595
+ name="instance",
596
+ resources=Resources(
597
+ cpus=cpu_count,
598
+ memory_mib=int(memory_gib * 1024),
599
+ gpus=gpus,
600
+ spot=spot,
601
+ disk=Disk(size_mib=int(disk_gib * 1024)),
602
+ description="",
603
+ ),
604
+ ),
605
+ region=region,
606
+ price=1,
607
+ availability=InstanceAvailability.AVAILABLE,
608
+ blocks=blocks,
609
+ total_blocks=total_blocks,
610
+ )
611
+
612
+
613
+ def get_remote_connection_info(
614
+ host: str = "10.0.0.10",
615
+ port: int = 22,
616
+ ssh_user: str = "ubuntu",
617
+ ssh_keys: Optional[list[SSHKey]] = None,
618
+ env: Optional[Union[Env, dict]] = None,
619
+ ):
620
+ if ssh_keys is None:
621
+ ssh_keys = [
622
+ SSHKey(
623
+ public="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO6mJxVbNtm0zXgMLvByrhXJCmJRveSrJxLB5/OzcyCk",
624
+ private="""
625
+ -----BEGIN OPENSSH PRIVATE KEY-----
626
+ b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
627
+ QyNTUxOQAAACDupicVWzbZtM14DC7wcq4VyQpiUb3kqycSwefzs3MgpAAAAJCiWa5Volmu
628
+ VQAAAAtzc2gtZWQyNTUxOQAAACDupicVWzbZtM14DC7wcq4VyQpiUb3kqycSwefzs3MgpA
629
+ AAAEAncHi4AhS6XdMp5Gzd+IMse/4ekyQ54UngByf0Sp0uH+6mJxVbNtm0zXgMLvByrhXJ
630
+ CmJRveSrJxLB5/OzcyCkAAAACWRlZkBkZWZwYwECAwQ=
631
+ -----END OPENSSH PRIVATE KEY-----
632
+ """,
633
+ )
634
+ ]
635
+ if env is None:
636
+ env = Env()
637
+ elif isinstance(env, dict):
638
+ env = Env.parse_obj(env)
639
+ return RemoteConnectionInfo(
640
+ host=host,
641
+ port=port,
642
+ ssh_user=ssh_user,
643
+ ssh_keys=ssh_keys,
644
+ env=env,
645
+ )
646
+
647
+
583
648
  async def create_volume(
584
649
  session: AsyncSession,
585
650
  project: ProjectModel,
@@ -157,24 +157,38 @@ def parse_pretty_duration(duration: str) -> int:
157
157
  return amount * multiplier
158
158
 
159
159
 
160
+ DURATION_UNITS_DESC = [
161
+ ("w", 7 * 24 * 3600),
162
+ ("d", 24 * 3600),
163
+ ("h", 3600),
164
+ ("m", 60),
165
+ ("s", 1),
166
+ ]
167
+
168
+
160
169
  def format_pretty_duration(seconds: int) -> str:
161
170
  if seconds == 0:
162
171
  return "0s"
163
172
  if seconds < 0:
164
173
  raise ValueError("Seconds cannot be negative")
165
- units = [
166
- ("w", 7 * 24 * 3600),
167
- ("d", 24 * 3600),
168
- ("h", 3600),
169
- ("m", 60),
170
- ("s", 1),
171
- ]
172
- for unit, multiplier in units:
174
+ for unit, multiplier in DURATION_UNITS_DESC:
173
175
  if seconds % multiplier == 0:
174
176
  return f"{seconds // multiplier}{unit}"
175
177
  return f"{seconds}s" # Fallback to seconds if no larger unit fits perfectly
176
178
 
177
179
 
180
+ def format_duration_multiunit(seconds: int) -> str:
181
+ """90 -> 1m 30s, 4545 -> 1h 15m 45s, etc"""
182
+ if seconds < 0:
183
+ raise ValueError("Seconds cannot be negative")
184
+ result = ""
185
+ for unit, multiplier in DURATION_UNITS_DESC:
186
+ if unit_value := seconds // multiplier:
187
+ result += f" {unit_value}{unit}"
188
+ seconds -= unit_value * multiplier
189
+ return result.lstrip() or "0s"
190
+
191
+
178
192
  def sizeof_fmt(num, suffix="B"):
179
193
  for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
180
194
  if abs(num) < 1024.0:
@@ -0,0 +1,14 @@
1
+ import os
2
+
3
+
4
+ def get_bool(name: str, default: bool = False) -> bool:
5
+ try:
6
+ value = os.environ[name]
7
+ except KeyError:
8
+ return default
9
+ value = value.lower()
10
+ if value in ["0", "false", "off"]:
11
+ return False
12
+ if value in ["1", "true", "on"]:
13
+ return True
14
+ raise ValueError(f"Invalid bool value: {name}={value}")
@@ -159,7 +159,7 @@ def get_ssh_config(path: PathLike, host: str) -> Optional[Dict[str, str]]:
159
159
  return None
160
160
 
161
161
 
162
- def update_ssh_config(path: PathLike, host: str, options: Dict[str, Union[str, FilePath]]):
162
+ def update_ssh_config(path: PathLike, host: str, options: Dict[str, Union[str, int, FilePath]]):
163
163
  Path(path).parent.mkdir(parents=True, exist_ok=True)
164
164
  with FileLock(str(path) + ".lock"):
165
165
  copy_mode = True
@@ -62,16 +62,29 @@ def _get_fleet_spec_excludes(fleet_spec: FleetSpec) -> Optional[_ExcludeDict]:
62
62
  spec_excludes: _ExcludeDict = {}
63
63
  configuration_excludes: _ExcludeDict = {}
64
64
  profile_excludes: set[str] = set()
65
+ ssh_config_excludes: _ExcludeDict = {}
66
+ ssh_hosts_excludes: set[str] = set()
65
67
 
66
68
  # TODO: Can be removed in 0.19
67
69
  if fleet_spec.configuration_path is None:
68
70
  spec_excludes["configuration_path"] = True
69
71
  if fleet_spec.configuration.ssh_config is not None:
72
+ if fleet_spec.configuration.ssh_config.proxy_jump is None:
73
+ ssh_config_excludes["proxy_jump"] = True
74
+ if all(
75
+ isinstance(h, str) or h.proxy_jump is None
76
+ for h in fleet_spec.configuration.ssh_config.hosts
77
+ ):
78
+ ssh_hosts_excludes.add("proxy_jump")
70
79
  if all(
71
80
  isinstance(h, str) or h.internal_ip is None
72
81
  for h in fleet_spec.configuration.ssh_config.hosts
73
82
  ):
74
- configuration_excludes["ssh_config"] = {"hosts": {"__all__": {"internal_ip"}}}
83
+ ssh_hosts_excludes.add("internal_ip")
84
+ if all(
85
+ isinstance(h, str) or h.blocks == 1 for h in fleet_spec.configuration.ssh_config.hosts
86
+ ):
87
+ ssh_hosts_excludes.add("blocks")
75
88
  # client >= 0.18.30 / server <= 0.18.29 compatibility tweak
76
89
  if fleet_spec.configuration.reservation is None:
77
90
  configuration_excludes["reservation"] = True
@@ -84,7 +97,18 @@ def _get_fleet_spec_excludes(fleet_spec: FleetSpec) -> Optional[_ExcludeDict]:
84
97
  # client >= 0.18.38 / server <= 0.18.37 compatibility tweak
85
98
  if fleet_spec.profile is not None and fleet_spec.profile.stop_duration is None:
86
99
  profile_excludes.add("stop_duration")
100
+ # client >= 0.18.41 / server <= 0.18.40 compatibility tweak
101
+ if fleet_spec.configuration.availability_zones is None:
102
+ configuration_excludes["availability_zones"] = True
103
+ if fleet_spec.profile is not None and fleet_spec.profile.availability_zones is None:
104
+ profile_excludes.add("availability_zones")
105
+ if fleet_spec.configuration.blocks == 1:
106
+ configuration_excludes["blocks"] = True
87
107
 
108
+ if ssh_hosts_excludes:
109
+ ssh_config_excludes["hosts"] = {"__all__": ssh_hosts_excludes}
110
+ if ssh_config_excludes:
111
+ configuration_excludes["ssh_config"] = ssh_config_excludes
88
112
  if configuration_excludes:
89
113
  spec_excludes["configuration"] = configuration_excludes
90
114
  if profile_excludes:
@@ -7,6 +7,7 @@ from pydantic import parse_obj_as
7
7
  from dstack._internal.core.models.common import is_core_model_instance
8
8
  from dstack._internal.core.models.configurations import (
9
9
  STRIP_PREFIX_DEFAULT,
10
+ DevEnvironmentConfiguration,
10
11
  ServiceConfiguration,
11
12
  )
12
13
  from dstack._internal.core.models.pools import Instance
@@ -82,7 +83,10 @@ class RunsAPIClient(APIClientGroup):
82
83
  ) -> Run:
83
84
  plan_input: ApplyRunPlanInput = ApplyRunPlanInput.__response__.parse_obj(plan)
84
85
  body = ApplyRunPlanRequest(plan=plan_input, force=force)
85
- resp = self._request(f"/api/project/{project_name}/runs/apply", body=body.json())
86
+ resp = self._request(
87
+ f"/api/project/{project_name}/runs/apply",
88
+ body=body.json(exclude=_get_apply_plan_excludes(plan_input)),
89
+ )
86
90
  return parse_obj_as(Run.__response__, resp.json())
87
91
 
88
92
  def submit(self, project_name: str, run_spec: RunSpec) -> Run:
@@ -121,8 +125,15 @@ class RunsAPIClient(APIClientGroup):
121
125
  return parse_obj_as(Instance.__response__, resp.json())
122
126
 
123
127
 
128
+ def _get_apply_plan_excludes(plan: ApplyRunPlanInput) -> Optional[dict]:
129
+ run_spec_excludes = _get_run_spec_excludes(plan.run_spec)
130
+ if run_spec_excludes is not None:
131
+ return {"plan": run_spec_excludes}
132
+ return None
133
+
134
+
124
135
  def _get_run_spec_excludes(run_spec: RunSpec) -> Optional[dict]:
125
- spec_excludes: dict[str, set[str]] = {}
136
+ spec_excludes: dict[str, Any] = {}
126
137
  configuration_excludes: dict[str, Any] = {}
127
138
  profile_excludes: set[str] = set()
128
139
  configuration = run_spec.configuration
@@ -164,6 +175,16 @@ def _get_run_spec_excludes(run_spec: RunSpec) -> Optional[dict]:
164
175
  for v in configuration.volumes
165
176
  ):
166
177
  configuration_excludes["volumes"] = {"__all__": {"optional"}}
178
+ # client >= 0.18.41 / server <= 0.18.40 compatibility tweak
179
+ if configuration.availability_zones is None:
180
+ configuration_excludes["availability_zones"] = True
181
+ if profile is not None and profile.availability_zones is None:
182
+ profile_excludes.add("availability_zones")
183
+ if (
184
+ is_core_model_instance(configuration, DevEnvironmentConfiguration)
185
+ and configuration.inactivity_duration is None
186
+ ):
187
+ configuration_excludes["inactivity_duration"] = True
167
188
 
168
189
  if configuration_excludes:
169
190
  spec_excludes["configuration"] = configuration_excludes
@@ -27,9 +27,20 @@ class VolumesAPIClient(APIClientGroup):
27
27
  configuration: VolumeConfiguration,
28
28
  ) -> Volume:
29
29
  body = CreateVolumeRequest(configuration=configuration)
30
- resp = self._request(f"/api/project/{project_name}/volumes/create", body=body.json())
30
+ resp = self._request(
31
+ f"/api/project/{project_name}/volumes/create",
32
+ body=body.json(exclude=_get_volume_configuration_excludes(configuration)),
33
+ )
31
34
  return parse_obj_as(Volume.__response__, resp.json())
32
35
 
33
36
  def delete(self, project_name: str, names: List[str]) -> None:
34
37
  body = DeleteVolumesRequest(names=names)
35
38
  self._request(f"/api/project/{project_name}/volumes/delete", body=body.json())
39
+
40
+
41
+ def _get_volume_configuration_excludes(configuration: VolumeConfiguration) -> dict:
42
+ configuration_excludes = {}
43
+ # client >= 0.18.41 / server <= 0.18.40 compatibility tweak
44
+ if configuration.availability_zone is None:
45
+ configuration_excludes["availability_zone"] = True
46
+ return {"configuration": configuration_excludes}
dstack/version.py CHANGED
@@ -1,3 +1,3 @@
1
- __version__ = "0.18.40"
1
+ __version__ = "0.18.41"
2
2
  __is_release__ = True
3
3
  base_image = "0.6"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dstack
3
- Version: 0.18.40
3
+ Version: 0.18.41
4
4
  Summary: dstack is an open-source orchestration engine for running AI workloads on any cloud or on-premises.
5
5
  Home-page: https://dstack.ai
6
6
  Author: Andrey Cheptsov