dstack 0.19.4rc3__py3-none-any.whl → 0.19.6rc1__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 (183) hide show
  1. dstack/_internal/cli/commands/attach.py +22 -20
  2. dstack/_internal/cli/commands/offer.py +116 -0
  3. dstack/_internal/cli/main.py +2 -0
  4. dstack/_internal/cli/services/configurators/base.py +1 -2
  5. dstack/_internal/cli/services/configurators/fleet.py +43 -20
  6. dstack/_internal/cli/services/configurators/run.py +3 -3
  7. dstack/_internal/cli/utils/run.py +43 -38
  8. dstack/_internal/core/backends/aws/auth.py +1 -2
  9. dstack/_internal/core/backends/aws/compute.py +24 -9
  10. dstack/_internal/core/backends/aws/configurator.py +2 -3
  11. dstack/_internal/core/backends/aws/resources.py +10 -0
  12. dstack/_internal/core/backends/azure/auth.py +1 -2
  13. dstack/_internal/core/backends/azure/compute.py +15 -5
  14. dstack/_internal/core/backends/azure/configurator.py +4 -5
  15. dstack/_internal/core/backends/azure/resources.py +14 -0
  16. dstack/_internal/core/backends/base/compute.py +99 -31
  17. dstack/_internal/core/backends/gcp/auth.py +1 -2
  18. dstack/_internal/core/backends/gcp/compute.py +58 -14
  19. dstack/_internal/core/backends/gcp/configurator.py +2 -3
  20. dstack/_internal/core/backends/gcp/features/tcpx.py +31 -0
  21. dstack/_internal/core/backends/gcp/resources.py +10 -0
  22. dstack/_internal/core/backends/nebius/compute.py +6 -2
  23. dstack/_internal/core/backends/nebius/configurator.py +4 -10
  24. dstack/_internal/core/backends/nebius/models.py +14 -1
  25. dstack/_internal/core/backends/nebius/resources.py +91 -10
  26. dstack/_internal/core/backends/oci/auth.py +1 -2
  27. dstack/_internal/core/backends/oci/configurator.py +1 -2
  28. dstack/_internal/core/backends/runpod/compute.py +1 -1
  29. dstack/_internal/core/errors.py +4 -0
  30. dstack/_internal/core/models/common.py +2 -14
  31. dstack/_internal/core/models/configurations.py +24 -2
  32. dstack/_internal/core/models/envs.py +2 -2
  33. dstack/_internal/core/models/fleets.py +34 -3
  34. dstack/_internal/core/models/gateways.py +18 -4
  35. dstack/_internal/core/models/instances.py +2 -1
  36. dstack/_internal/core/models/profiles.py +12 -0
  37. dstack/_internal/core/models/runs.py +6 -0
  38. dstack/_internal/core/models/secrets.py +1 -1
  39. dstack/_internal/core/models/volumes.py +17 -1
  40. dstack/_internal/proxy/gateway/resources/nginx/service.jinja2 +3 -3
  41. dstack/_internal/proxy/gateway/services/nginx.py +0 -1
  42. dstack/_internal/proxy/gateway/services/registry.py +0 -1
  43. dstack/_internal/server/background/tasks/process_instances.py +12 -9
  44. dstack/_internal/server/background/tasks/process_running_jobs.py +66 -15
  45. dstack/_internal/server/routers/fleets.py +22 -0
  46. dstack/_internal/server/routers/runs.py +1 -0
  47. dstack/_internal/server/schemas/fleets.py +12 -2
  48. dstack/_internal/server/schemas/runner.py +6 -0
  49. dstack/_internal/server/schemas/runs.py +3 -0
  50. dstack/_internal/server/services/docker.py +1 -2
  51. dstack/_internal/server/services/fleets.py +30 -12
  52. dstack/_internal/server/services/gateways/__init__.py +1 -0
  53. dstack/_internal/server/services/instances.py +3 -1
  54. dstack/_internal/server/services/jobs/__init__.py +1 -2
  55. dstack/_internal/server/services/jobs/configurators/base.py +17 -8
  56. dstack/_internal/server/services/locking.py +16 -1
  57. dstack/_internal/server/services/projects.py +1 -2
  58. dstack/_internal/server/services/proxy/repo.py +1 -2
  59. dstack/_internal/server/services/runner/client.py +3 -0
  60. dstack/_internal/server/services/runs.py +19 -16
  61. dstack/_internal/server/services/services/__init__.py +1 -2
  62. dstack/_internal/server/services/volumes.py +29 -2
  63. dstack/_internal/server/statics/00a6e1fb461ed2929fb9.png +0 -0
  64. dstack/_internal/server/statics/0cae4d9f0a36034984a7.png +0 -0
  65. dstack/_internal/server/statics/391de232cc0e30cae513.png +0 -0
  66. dstack/_internal/server/statics/4e0eead8c1a73689ef9d.svg +1 -0
  67. dstack/_internal/server/statics/544afa2f63428c2235b0.png +0 -0
  68. dstack/_internal/server/statics/54a4f50f74c6b9381530.svg +7 -0
  69. dstack/_internal/server/statics/68dd1360a7d2611e0132.svg +4 -0
  70. dstack/_internal/server/statics/69544b4c81973b54a66f.png +0 -0
  71. dstack/_internal/server/statics/77a8b02b17af19e39266.png +0 -0
  72. dstack/_internal/server/statics/83a93a8871c219104367.svg +9 -0
  73. dstack/_internal/server/statics/8f28bb8e9999e5e6a48b.svg +4 -0
  74. dstack/_internal/server/statics/9124086961ab8c366bc4.svg +9 -0
  75. dstack/_internal/server/statics/9a9ebaeb54b025dbac0a.svg +5 -0
  76. dstack/_internal/server/statics/a3428392dc534f3b15c4.svg +7 -0
  77. dstack/_internal/server/statics/ae22625574d69361f72c.png +0 -0
  78. dstack/_internal/server/statics/assets/android-chrome-144x144.png +0 -0
  79. dstack/_internal/server/statics/assets/android-chrome-192x192.png +0 -0
  80. dstack/_internal/server/statics/assets/android-chrome-256x256.png +0 -0
  81. dstack/_internal/server/statics/assets/android-chrome-36x36.png +0 -0
  82. dstack/_internal/server/statics/assets/android-chrome-384x384.png +0 -0
  83. dstack/_internal/server/statics/assets/android-chrome-48x48.png +0 -0
  84. dstack/_internal/server/statics/assets/android-chrome-512x512.png +0 -0
  85. dstack/_internal/server/statics/assets/android-chrome-72x72.png +0 -0
  86. dstack/_internal/server/statics/assets/android-chrome-96x96.png +0 -0
  87. dstack/_internal/server/statics/assets/apple-touch-icon-1024x1024.png +0 -0
  88. dstack/_internal/server/statics/assets/apple-touch-icon-114x114.png +0 -0
  89. dstack/_internal/server/statics/assets/apple-touch-icon-120x120.png +0 -0
  90. dstack/_internal/server/statics/assets/apple-touch-icon-144x144.png +0 -0
  91. dstack/_internal/server/statics/assets/apple-touch-icon-152x152.png +0 -0
  92. dstack/_internal/server/statics/assets/apple-touch-icon-167x167.png +0 -0
  93. dstack/_internal/server/statics/assets/apple-touch-icon-180x180.png +0 -0
  94. dstack/_internal/server/statics/assets/apple-touch-icon-57x57.png +0 -0
  95. dstack/_internal/server/statics/assets/apple-touch-icon-60x60.png +0 -0
  96. dstack/_internal/server/statics/assets/apple-touch-icon-72x72.png +0 -0
  97. dstack/_internal/server/statics/assets/apple-touch-icon-76x76.png +0 -0
  98. dstack/_internal/server/statics/assets/apple-touch-icon-precomposed.png +0 -0
  99. dstack/_internal/server/statics/assets/apple-touch-icon.png +0 -0
  100. dstack/_internal/server/statics/assets/apple-touch-startup-image-1125x2436.png +0 -0
  101. dstack/_internal/server/statics/assets/apple-touch-startup-image-1136x640.png +0 -0
  102. dstack/_internal/server/statics/assets/apple-touch-startup-image-1170x2532.png +0 -0
  103. dstack/_internal/server/statics/assets/apple-touch-startup-image-1179x2556.png +0 -0
  104. dstack/_internal/server/statics/assets/apple-touch-startup-image-1242x2208.png +0 -0
  105. dstack/_internal/server/statics/assets/apple-touch-startup-image-1242x2688.png +0 -0
  106. dstack/_internal/server/statics/assets/apple-touch-startup-image-1284x2778.png +0 -0
  107. dstack/_internal/server/statics/assets/apple-touch-startup-image-1290x2796.png +0 -0
  108. dstack/_internal/server/statics/assets/apple-touch-startup-image-1334x750.png +0 -0
  109. dstack/_internal/server/statics/assets/apple-touch-startup-image-1488x2266.png +0 -0
  110. dstack/_internal/server/statics/assets/apple-touch-startup-image-1536x2048.png +0 -0
  111. dstack/_internal/server/statics/assets/apple-touch-startup-image-1620x2160.png +0 -0
  112. dstack/_internal/server/statics/assets/apple-touch-startup-image-1640x2160.png +0 -0
  113. dstack/_internal/server/statics/assets/apple-touch-startup-image-1668x2224.png +0 -0
  114. dstack/_internal/server/statics/assets/apple-touch-startup-image-1668x2388.png +0 -0
  115. dstack/_internal/server/statics/assets/apple-touch-startup-image-1792x828.png +0 -0
  116. dstack/_internal/server/statics/assets/apple-touch-startup-image-2048x1536.png +0 -0
  117. dstack/_internal/server/statics/assets/apple-touch-startup-image-2048x2732.png +0 -0
  118. dstack/_internal/server/statics/assets/apple-touch-startup-image-2160x1620.png +0 -0
  119. dstack/_internal/server/statics/assets/apple-touch-startup-image-2160x1640.png +0 -0
  120. dstack/_internal/server/statics/assets/apple-touch-startup-image-2208x1242.png +0 -0
  121. dstack/_internal/server/statics/assets/apple-touch-startup-image-2224x1668.png +0 -0
  122. dstack/_internal/server/statics/assets/apple-touch-startup-image-2266x1488.png +0 -0
  123. dstack/_internal/server/statics/assets/apple-touch-startup-image-2388x1668.png +0 -0
  124. dstack/_internal/server/statics/assets/apple-touch-startup-image-2436x1125.png +0 -0
  125. dstack/_internal/server/statics/assets/apple-touch-startup-image-2532x1170.png +0 -0
  126. dstack/_internal/server/statics/assets/apple-touch-startup-image-2556x1179.png +0 -0
  127. dstack/_internal/server/statics/assets/apple-touch-startup-image-2688x1242.png +0 -0
  128. dstack/_internal/server/statics/assets/apple-touch-startup-image-2732x2048.png +0 -0
  129. dstack/_internal/server/statics/assets/apple-touch-startup-image-2778x1284.png +0 -0
  130. dstack/_internal/server/statics/assets/apple-touch-startup-image-2796x1290.png +0 -0
  131. dstack/_internal/server/statics/assets/apple-touch-startup-image-640x1136.png +0 -0
  132. dstack/_internal/server/statics/assets/apple-touch-startup-image-750x1334.png +0 -0
  133. dstack/_internal/server/statics/assets/apple-touch-startup-image-828x1792.png +0 -0
  134. dstack/_internal/server/statics/assets/browserconfig.xml +12 -0
  135. dstack/_internal/server/statics/assets/favicon-16x16.png +0 -0
  136. dstack/_internal/server/statics/assets/favicon-32x32.png +0 -0
  137. dstack/_internal/server/statics/assets/favicon-48x48.png +0 -0
  138. dstack/_internal/server/statics/assets/favicon.ico +0 -0
  139. dstack/_internal/server/statics/assets/manifest.webmanifest +67 -0
  140. dstack/_internal/server/statics/assets/mstile-144x144.png +0 -0
  141. dstack/_internal/server/statics/assets/mstile-150x150.png +0 -0
  142. dstack/_internal/server/statics/assets/mstile-310x150.png +0 -0
  143. dstack/_internal/server/statics/assets/mstile-310x310.png +0 -0
  144. dstack/_internal/server/statics/assets/mstile-70x70.png +0 -0
  145. dstack/_internal/server/statics/assets/yandex-browser-50x50.png +0 -0
  146. dstack/_internal/server/statics/assets/yandex-browser-manifest.json +9 -0
  147. dstack/_internal/server/statics/b7ae68f44193474fc578.png +0 -0
  148. dstack/_internal/server/statics/d2f008c75b2b5b191f3f.png +0 -0
  149. dstack/_internal/server/statics/d44c33e1b92e05c379fd.png +0 -0
  150. dstack/_internal/server/statics/dd43ff0552815179d7ab.png +0 -0
  151. dstack/_internal/server/statics/dd4e7166c0b9aac197d7.png +0 -0
  152. dstack/_internal/server/statics/e30b27916930d43d2271.png +0 -0
  153. dstack/_internal/server/statics/e467d7d60aae81ab198b.svg +6 -0
  154. dstack/_internal/server/statics/eb9b344b73818fe2b71a.png +0 -0
  155. dstack/_internal/server/statics/f517dd626eb964120de0.png +0 -0
  156. dstack/_internal/server/statics/f958aecddee5d8e3222c.png +0 -0
  157. dstack/_internal/server/statics/index.html +3 -0
  158. dstack/_internal/server/statics/main-8f9c66f404e9c7e7e020.css +3 -0
  159. dstack/_internal/server/statics/main-b4f65323f5df007e1664.js +136480 -0
  160. dstack/_internal/server/statics/main-b4f65323f5df007e1664.js.map +1 -0
  161. dstack/_internal/server/statics/manifest.json +16 -0
  162. dstack/_internal/server/statics/robots.txt +3 -0
  163. dstack/_internal/server/statics/static/media/entraID.d65d1f3e9486a8e56d24fc07b3230885.svg +9 -0
  164. dstack/_internal/server/statics/static/media/github.1f7102513534c83a9d8d735d2b8c12a2.svg +3 -0
  165. dstack/_internal/server/statics/static/media/logo.f602feeb138844eda97c8cb641461448.svg +124 -0
  166. dstack/_internal/server/statics/static/media/okta.12f178e6873a1100965f2a4dbd18fcec.svg +2 -0
  167. dstack/_internal/server/statics/static/media/theme.3994c817bb7dda191c1c9640dee0bf42.svg +3 -0
  168. dstack/_internal/server/testing/common.py +10 -0
  169. dstack/_internal/utils/tags.py +42 -0
  170. dstack/api/server/__init__.py +3 -1
  171. dstack/api/server/_fleets.py +52 -9
  172. dstack/api/server/_gateways.py +17 -2
  173. dstack/api/server/_runs.py +34 -11
  174. dstack/api/server/_volumes.py +2 -3
  175. dstack/version.py +1 -1
  176. {dstack-0.19.4rc3.dist-info → dstack-0.19.6rc1.dist-info}/METADATA +2 -2
  177. {dstack-0.19.4rc3.dist-info → dstack-0.19.6rc1.dist-info}/RECORD +180 -76
  178. dstack-0.19.4rc3.data/data/dstack/_internal/proxy/gateway/resources/nginx/00-log-format.conf +0 -1
  179. dstack-0.19.4rc3.data/data/dstack/_internal/proxy/gateway/resources/nginx/entrypoint.jinja2 +0 -27
  180. dstack-0.19.4rc3.data/data/dstack/_internal/proxy/gateway/resources/nginx/service.jinja2 +0 -88
  181. {dstack-0.19.4rc3.dist-info → dstack-0.19.6rc1.dist-info}/WHEEL +0 -0
  182. {dstack-0.19.4rc3.dist-info → dstack-0.19.6rc1.dist-info}/entry_points.txt +0 -0
  183. {dstack-0.19.4rc3.dist-info → dstack-0.19.6rc1.dist-info}/licenses/LICENSE.md +0 -0
@@ -69,6 +69,7 @@ class AttachCommand(APIBaseCommand):
69
69
  run = self.api.runs.get(args.run_name)
70
70
  if run is None:
71
71
  raise CLIError(f"Run {args.run_name} not found")
72
+ exit_code = 0
72
73
  try:
73
74
  attached = run.attach(
74
75
  ssh_identity_file=args.ssh_identity_file,
@@ -90,35 +91,36 @@ class AttachCommand(APIBaseCommand):
90
91
  replica_num=args.replica,
91
92
  job_num=args.job,
92
93
  )
93
- try:
94
- for log in logs:
95
- sys.stdout.buffer.write(log)
96
- sys.stdout.buffer.flush()
97
- except KeyboardInterrupt:
98
- pass
94
+ for log in logs:
95
+ sys.stdout.buffer.write(log)
96
+ sys.stdout.buffer.flush()
97
+ _print_finished_message_when_available(run)
98
+ exit_code = get_run_exit_code(run)
99
99
  else:
100
- try:
101
- while True:
102
- time.sleep(10)
103
- except KeyboardInterrupt:
104
- pass
100
+ while True:
101
+ time.sleep(10)
102
+ except KeyboardInterrupt:
103
+ console.print("\nDetached")
105
104
  finally:
106
105
  run.detach()
107
106
  # TODO: Handle run resubmissions similar to dstack apply
107
+ exit(exit_code)
108
108
 
109
- # After reading the logs, the run may not be marked as finished immediately.
110
- # Give the run some time to transition to a finished state before exiting.
111
- for _ in range(30):
112
- run.refresh()
113
- if run.status.is_finished():
114
- print_finished_message(run)
115
- exit(get_run_exit_code(run))
116
- time.sleep(1)
109
+
110
+ def _print_finished_message_when_available(run: Run) -> None:
111
+ # After reading the logs, the run may not be marked as finished immediately.
112
+ # Give the run some time to transition to a finished state before exiting.
113
+ for _ in range(30):
114
+ run.refresh()
115
+ if run.status.is_finished():
116
+ print_finished_message(run)
117
+ break
118
+ time.sleep(1)
119
+ else:
117
120
  console.print(
118
121
  "[error]Lost run connection. Timed out waiting for run final status."
119
122
  " Check `dstack ps` to see if it's done or failed."
120
123
  )
121
- exit(1)
122
124
 
123
125
 
124
126
  _IGNORED_PORTS = [DSTACK_RUNNER_HTTP_PORT]
@@ -0,0 +1,116 @@
1
+ import argparse
2
+ import contextlib
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from dstack._internal.cli.commands import APIBaseCommand
7
+ from dstack._internal.cli.services.configurators.run import (
8
+ BaseRunConfigurator,
9
+ )
10
+ from dstack._internal.cli.utils.common import console
11
+ from dstack._internal.cli.utils.run import print_run_plan
12
+ from dstack._internal.core.models.configurations import (
13
+ ApplyConfigurationType,
14
+ TaskConfiguration,
15
+ )
16
+ from dstack._internal.core.models.runs import RunSpec
17
+ from dstack.api.utils import load_profile
18
+
19
+
20
+ class OfferConfigurator(BaseRunConfigurator):
21
+ # TODO: The command currently uses `BaseRunConfigurator` to register arguments.
22
+ # This includes --env, --retry-policy, and other arguments that are unnecessary for this command.
23
+ # Eventually, we should introduce a base `OfferConfigurator` that doesn't include those arguments—
24
+ # `BaseRunConfigurator` will inherit from `OfferConfigurator`.
25
+ #
26
+ # Additionally, it should have its own type: `ApplyConfigurationType.OFFER`.
27
+ TYPE = ApplyConfigurationType.TASK
28
+
29
+ @classmethod
30
+ def register_args(
31
+ cls,
32
+ parser: argparse.ArgumentParser,
33
+ ):
34
+ super().register_args(parser, default_max_offers=50)
35
+
36
+
37
+ # TODO: Support aggregated offers
38
+ # TODO: Add tests
39
+ class OfferCommand(APIBaseCommand):
40
+ NAME = "offer"
41
+ DESCRIPTION = "List offers"
42
+
43
+ def _register(self):
44
+ super()._register()
45
+ self._parser.add_argument(
46
+ "--format",
47
+ choices=["plain", "json"],
48
+ default="plain",
49
+ help="Output format (default: plain)",
50
+ )
51
+ self._parser.add_argument(
52
+ "--json",
53
+ action="store_const",
54
+ const="json",
55
+ dest="format",
56
+ help="Output in JSON format (equivalent to --format json)",
57
+ )
58
+ OfferConfigurator.register_args(self._parser)
59
+
60
+ def _command(self, args: argparse.Namespace):
61
+ super()._command(args)
62
+ conf = TaskConfiguration(commands=[":"])
63
+
64
+ configurator = OfferConfigurator(api_client=self.api)
65
+ configurator.apply_args(conf, args, [])
66
+ profile = load_profile(Path.cwd(), profile_name=args.profile)
67
+
68
+ run_spec = RunSpec(
69
+ configuration=conf,
70
+ ssh_key_pub="(dummy)",
71
+ profile=profile,
72
+ )
73
+ if args.format == "plain":
74
+ status = console.status("Getting offers...")
75
+ else:
76
+ status = contextlib.nullcontext()
77
+ with status:
78
+ run_plan = self.api.client.runs.get_plan(
79
+ self.api.project,
80
+ run_spec,
81
+ max_offers=args.max_offers,
82
+ )
83
+
84
+ job_plan = run_plan.job_plans[0]
85
+
86
+ if args.format == "json":
87
+ output = {
88
+ "project": run_plan.project_name,
89
+ "user": run_plan.user,
90
+ "resources": job_plan.job_spec.requirements.resources.dict(),
91
+ "max_price": (job_plan.job_spec.requirements.max_price),
92
+ "spot": run_spec.configuration.spot_policy,
93
+ "reservation": run_plan.run_spec.configuration.reservation,
94
+ "offers": [],
95
+ "total_offers": job_plan.total_offers,
96
+ }
97
+
98
+ for offer in job_plan.offers:
99
+ output["offers"].append(
100
+ {
101
+ "backend": (
102
+ "ssh" if offer.backend.value == "remote" else offer.backend.value
103
+ ),
104
+ "region": offer.region,
105
+ "instance_type": offer.instance.name,
106
+ "resources": offer.instance.resources.dict(),
107
+ "spot": offer.instance.resources.spot,
108
+ "price": float(offer.price),
109
+ "availability": offer.availability.value,
110
+ }
111
+ )
112
+
113
+ print(json.dumps(output, indent=2))
114
+ return
115
+ else:
116
+ print_run_plan(run_plan, include_run_properties=False)
@@ -14,6 +14,7 @@ from dstack._internal.cli.commands.gateway import GatewayCommand
14
14
  from dstack._internal.cli.commands.init import InitCommand
15
15
  from dstack._internal.cli.commands.logs import LogsCommand
16
16
  from dstack._internal.cli.commands.metrics import MetricsCommand
17
+ from dstack._internal.cli.commands.offer import OfferCommand
17
18
  from dstack._internal.cli.commands.ps import PsCommand
18
19
  from dstack._internal.cli.commands.server import ServerCommand
19
20
  from dstack._internal.cli.commands.stats import StatsCommand
@@ -65,6 +66,7 @@ def main():
65
66
  FleetCommand.register(subparsers)
66
67
  GatewayCommand.register(subparsers)
67
68
  InitCommand.register(subparsers)
69
+ OfferCommand.register(subparsers)
68
70
  LogsCommand.register(subparsers)
69
71
  MetricsCommand.register(subparsers)
70
72
  PsCommand.register(subparsers)
@@ -5,7 +5,6 @@ from typing import List, Optional, Union, cast
5
5
 
6
6
  from dstack._internal.cli.services.args import env_var
7
7
  from dstack._internal.core.errors import ConfigurationError
8
- from dstack._internal.core.models.common import is_core_model_instance
9
8
  from dstack._internal.core.models.configurations import (
10
9
  AnyApplyConfiguration,
11
10
  ApplyConfigurationType,
@@ -100,7 +99,7 @@ class ApplyEnvVarsConfiguratorMixin:
100
99
  for k, v in cast(List[EnvVarTuple], configurator_args.env_vars):
101
100
  env[k] = v
102
101
  for k, v in env.items():
103
- if is_core_model_instance(v, EnvSentinel):
102
+ if isinstance(v, EnvSentinel):
104
103
  try:
105
104
  env[k] = v.from_env(os.environ)
106
105
  except ValueError as e:
@@ -17,7 +17,13 @@ from dstack._internal.cli.utils.common import (
17
17
  )
18
18
  from dstack._internal.cli.utils.fleet import get_fleets_table
19
19
  from dstack._internal.cli.utils.rich import MultiItemStatus
20
- from dstack._internal.core.errors import ConfigurationError, ResourceNotExistsError
20
+ from dstack._internal.core.errors import (
21
+ CLIError,
22
+ ConfigurationError,
23
+ ResourceNotExistsError,
24
+ ServerClientError,
25
+ URLNotFoundError,
26
+ )
21
27
  from dstack._internal.core.models.configurations import ApplyConfigurationType
22
28
  from dstack._internal.core.models.fleets import (
23
29
  Fleet,
@@ -31,6 +37,7 @@ from dstack._internal.core.models.repos.base import Repo
31
37
  from dstack._internal.utils.common import local_time
32
38
  from dstack._internal.utils.logging import get_logger
33
39
  from dstack._internal.utils.ssh import convert_ssh_key_to_pem, generate_public_key, pkey_from_str
40
+ from dstack.api._public import Client
34
41
  from dstack.api.utils import load_profile
35
42
 
36
43
  logger = get_logger(__name__)
@@ -109,11 +116,11 @@ class FleetConfigurator(ApplyEnvVarsConfiguratorMixin, BaseApplyConfigurator):
109
116
  else:
110
117
  time.sleep(1)
111
118
 
112
- with console.status("Creating fleet..."):
113
- fleet = self.api.client.fleets.create(
114
- project_name=self.api.project,
115
- spec=spec,
116
- )
119
+ try:
120
+ with console.status("Applying plan..."):
121
+ fleet = _apply_plan(self.api, plan)
122
+ except ServerClientError as e:
123
+ raise CLIError(e.msg)
117
124
  if command_args.detach:
118
125
  console.print("Fleet configuration submitted. Exiting...")
119
126
  return
@@ -239,32 +246,34 @@ def _print_plan_header(plan: FleetPlan):
239
246
  def th(s: str) -> str:
240
247
  return f"[bold]{s}[/bold]"
241
248
 
249
+ spec = plan.get_effective_spec()
250
+
242
251
  configuration_table = Table(box=None, show_header=False)
243
252
  configuration_table.add_column(no_wrap=True) # key
244
253
  configuration_table.add_column() # value
245
254
 
246
255
  configuration_table.add_row(th("Project"), plan.project_name)
247
256
  configuration_table.add_row(th("User"), plan.user)
248
- configuration_table.add_row(th("Configuration"), plan.spec.configuration_path or "?")
249
- configuration_table.add_row(th("Type"), plan.spec.configuration.type)
257
+ configuration_table.add_row(th("Configuration"), spec.configuration_path or "?")
258
+ configuration_table.add_row(th("Type"), spec.configuration.type)
250
259
 
251
260
  fleet_type = "cloud"
252
- nodes = plan.spec.configuration.nodes or "-"
253
- placement = plan.spec.configuration.placement or InstanceGroupPlacement.ANY
254
- reservation = plan.spec.configuration.reservation
261
+ nodes = spec.configuration.nodes or "-"
262
+ placement = spec.configuration.placement or InstanceGroupPlacement.ANY
263
+ reservation = spec.configuration.reservation
255
264
  backends = None
256
- if plan.spec.configuration.backends is not None:
257
- backends = ", ".join(b.value for b in plan.spec.configuration.backends)
265
+ if spec.configuration.backends is not None:
266
+ backends = ", ".join(b.value for b in spec.configuration.backends)
258
267
  regions = None
259
- if plan.spec.configuration.regions is not None:
260
- regions = ", ".join(plan.spec.configuration.regions)
268
+ if spec.configuration.regions is not None:
269
+ regions = ", ".join(spec.configuration.regions)
261
270
  resources = None
262
- if plan.spec.configuration.resources is not None:
263
- resources = plan.spec.configuration.resources.pretty_format()
264
- spot_policy = plan.spec.merged_profile.spot_policy
265
- if plan.spec.configuration.ssh_config is not None:
271
+ if spec.configuration.resources is not None:
272
+ resources = spec.configuration.resources.pretty_format()
273
+ spot_policy = spec.merged_profile.spot_policy
274
+ if spec.configuration.ssh_config is not None:
266
275
  fleet_type = "ssh"
267
- nodes = len(plan.spec.configuration.ssh_config.hosts)
276
+ nodes = len(spec.configuration.ssh_config.hosts)
268
277
  resources = None
269
278
  spot_policy = None
270
279
 
@@ -350,3 +359,17 @@ def _failed_provisioning(fleet: Fleet) -> bool:
350
359
  if instance.status == InstanceStatus.TERMINATED:
351
360
  return True
352
361
  return False
362
+
363
+
364
+ def _apply_plan(api: Client, plan: FleetPlan) -> Fleet:
365
+ try:
366
+ return api.client.fleets.apply_plan(
367
+ project_name=api.project,
368
+ plan=plan,
369
+ )
370
+ except URLNotFoundError:
371
+ # TODO: Remove in 0.20
372
+ return api.client.fleets.create(
373
+ project_name=api.project,
374
+ spec=plan.spec,
375
+ )
@@ -92,7 +92,7 @@ class BaseRunConfigurator(ApplyEnvVarsConfiguratorMixin, BaseApplyConfigurator):
92
92
  profile=profile,
93
93
  )
94
94
 
95
- print_run_plan(run_plan, offers_limit=configurator_args.max_offers)
95
+ print_run_plan(run_plan, max_offers=configurator_args.max_offers)
96
96
 
97
97
  confirm_message = "Submit a new run?"
98
98
  stop_run_name = None
@@ -274,7 +274,7 @@ class BaseRunConfigurator(ApplyEnvVarsConfiguratorMixin, BaseApplyConfigurator):
274
274
  console.print(f"Run [code]{conf.name}[/] deleted")
275
275
 
276
276
  @classmethod
277
- def register_args(cls, parser: argparse.ArgumentParser):
277
+ def register_args(cls, parser: argparse.ArgumentParser, default_max_offers: int = 3):
278
278
  configuration_group = parser.add_argument_group(f"{cls.TYPE.value} Options")
279
279
  configuration_group.add_argument(
280
280
  "-n",
@@ -286,7 +286,7 @@ class BaseRunConfigurator(ApplyEnvVarsConfiguratorMixin, BaseApplyConfigurator):
286
286
  "--max-offers",
287
287
  help="Number of offers to show in the run plan",
288
288
  type=int,
289
- default=3,
289
+ default=default_max_offers,
290
290
  )
291
291
  cls.register_env_args(configuration_group)
292
292
  configuration_group.add_argument(
@@ -1,10 +1,9 @@
1
- from typing import Any, Dict, List, Union
1
+ from typing import Any, Dict, List, Optional, Union
2
2
 
3
3
  from rich.markup import escape
4
4
  from rich.table import Table
5
5
 
6
6
  from dstack._internal.cli.utils.common import NO_OFFERS_WARNING, add_row_from_dict, console
7
- from dstack._internal.core.models.common import is_core_model_instance
8
7
  from dstack._internal.core.models.configurations import DevEnvironmentConfiguration
9
8
  from dstack._internal.core.models.instances import InstanceAvailability
10
9
  from dstack._internal.core.models.profiles import (
@@ -25,7 +24,10 @@ from dstack._internal.utils.common import (
25
24
  from dstack.api import Run
26
25
 
27
26
 
28
- def print_run_plan(run_plan: RunPlan, offers_limit: int = 3):
27
+ def print_run_plan(
28
+ run_plan: RunPlan, max_offers: Optional[int] = None, include_run_properties: bool = True
29
+ ):
30
+ run_spec = run_plan.get_effective_run_spec()
29
31
  job_plan = run_plan.job_plans[0]
30
32
 
31
33
  props = Table(box=None, show_header=False)
@@ -40,29 +42,30 @@ def print_run_plan(run_plan: RunPlan, offers_limit: int = 3):
40
42
  if job_plan.job_spec.max_duration
41
43
  else "-"
42
44
  )
43
- inactivity_duration = None
44
- if is_core_model_instance(run_plan.run_spec.configuration, DevEnvironmentConfiguration):
45
- inactivity_duration = "-"
46
- if isinstance(run_plan.run_spec.configuration.inactivity_duration, int):
47
- inactivity_duration = format_pretty_duration(
48
- run_plan.run_spec.configuration.inactivity_duration
49
- )
50
- if job_plan.job_spec.retry is None:
51
- retry = "-"
52
- else:
53
- retry = escape(job_plan.job_spec.retry.pretty_format())
54
-
55
- profile = run_plan.run_spec.merged_profile
56
- creation_policy = profile.creation_policy
57
- # FIXME: This assumes the default idle_duration is the same for client and server.
58
- # If the server changes idle_duration, old clients will see incorrect value.
59
- termination_policy, termination_idle_time = get_termination(
60
- profile, DEFAULT_RUN_TERMINATION_IDLE_TIME
61
- )
62
- if termination_policy == TerminationPolicy.DONT_DESTROY:
63
- idle_duration = "-"
64
- else:
65
- idle_duration = format_pretty_duration(termination_idle_time)
45
+ if include_run_properties:
46
+ inactivity_duration = None
47
+ if isinstance(run_spec.configuration, DevEnvironmentConfiguration):
48
+ inactivity_duration = "-"
49
+ if isinstance(run_spec.configuration.inactivity_duration, int):
50
+ inactivity_duration = format_pretty_duration(
51
+ run_spec.configuration.inactivity_duration
52
+ )
53
+ if job_plan.job_spec.retry is None:
54
+ retry = "-"
55
+ else:
56
+ retry = escape(job_plan.job_spec.retry.pretty_format())
57
+
58
+ profile = run_spec.merged_profile
59
+ creation_policy = profile.creation_policy
60
+ # FIXME: This assumes the default idle_duration is the same for client and server.
61
+ # If the server changes idle_duration, old clients will see incorrect value.
62
+ termination_policy, termination_idle_time = get_termination(
63
+ profile, DEFAULT_RUN_TERMINATION_IDLE_TIME
64
+ )
65
+ if termination_policy == TerminationPolicy.DONT_DESTROY:
66
+ idle_duration = "-"
67
+ else:
68
+ idle_duration = format_pretty_duration(termination_idle_time)
66
69
 
67
70
  if req.spot is None:
68
71
  spot_policy = "auto"
@@ -76,30 +79,32 @@ def print_run_plan(run_plan: RunPlan, offers_limit: int = 3):
76
79
 
77
80
  props.add_row(th("Project"), run_plan.project_name)
78
81
  props.add_row(th("User"), run_plan.user)
79
- props.add_row(th("Configuration"), run_plan.run_spec.configuration_path)
80
- props.add_row(th("Type"), run_plan.run_spec.configuration.type)
82
+ if include_run_properties:
83
+ props.add_row(th("Configuration"), run_spec.configuration_path)
84
+ props.add_row(th("Type"), run_spec.configuration.type)
81
85
  props.add_row(th("Resources"), pretty_req)
82
- props.add_row(th("Max price"), max_price)
83
- props.add_row(th("Max duration"), max_duration)
84
- if inactivity_duration is not None: # None means n/a
85
- props.add_row(th("Inactivity duration"), inactivity_duration)
86
86
  props.add_row(th("Spot policy"), spot_policy)
87
- props.add_row(th("Retry policy"), retry)
88
- props.add_row(th("Creation policy"), creation_policy)
89
- props.add_row(th("Idle duration"), idle_duration)
90
- props.add_row(th("Reservation"), run_plan.run_spec.configuration.reservation or "-")
87
+ props.add_row(th("Max price"), max_price)
88
+ if include_run_properties:
89
+ props.add_row(th("Retry policy"), retry)
90
+ props.add_row(th("Creation policy"), creation_policy)
91
+ props.add_row(th("Idle duration"), idle_duration)
92
+ props.add_row(th("Max duration"), max_duration)
93
+ if inactivity_duration is not None: # None means n/a
94
+ props.add_row(th("Inactivity duration"), inactivity_duration)
95
+ props.add_row(th("Reservation"), run_spec.configuration.reservation or "-")
91
96
 
92
97
  offers = Table(box=None)
93
98
  offers.add_column("#")
94
99
  offers.add_column("BACKEND")
95
100
  offers.add_column("REGION")
96
- offers.add_column("INSTANCE")
101
+ offers.add_column("INSTANCE TYPE")
97
102
  offers.add_column("RESOURCES")
98
103
  offers.add_column("SPOT")
99
104
  offers.add_column("PRICE")
100
105
  offers.add_column()
101
106
 
102
- job_plan.offers = job_plan.offers[:offers_limit]
107
+ job_plan.offers = job_plan.offers[:max_offers] if max_offers else job_plan.offers
103
108
 
104
109
  for i, offer in enumerate(job_plan.offers, start=1):
105
110
  r = offer.instance.resources
@@ -4,7 +4,6 @@ from boto3.session import Session
4
4
 
5
5
  from dstack._internal.core.backends.aws.models import AnyAWSCreds, AWSAccessKeyCreds
6
6
  from dstack._internal.core.errors import BackendAuthError
7
- from dstack._internal.core.models.common import is_core_model_instance
8
7
 
9
8
 
10
9
  def authenticate(creds: AnyAWSCreds, region: str) -> Session:
@@ -14,7 +13,7 @@ def authenticate(creds: AnyAWSCreds, region: str) -> Session:
14
13
 
15
14
 
16
15
  def get_session(creds: AnyAWSCreds, region: str) -> Session:
17
- if is_core_model_instance(creds, AWSAccessKeyCreds):
16
+ if isinstance(creds, AWSAccessKeyCreds):
18
17
  return boto3.session.Session(
19
18
  region_name=region,
20
19
  aws_access_key_id=creds.access_key,
@@ -28,7 +28,7 @@ from dstack._internal.core.backends.base.compute import (
28
28
  from dstack._internal.core.backends.base.offers import get_catalog_offers
29
29
  from dstack._internal.core.errors import ComputeError, NoCapacityError, PlacementGroupInUseError
30
30
  from dstack._internal.core.models.backends.base import BackendType
31
- from dstack._internal.core.models.common import CoreModel, is_core_model_instance
31
+ from dstack._internal.core.models.common import CoreModel
32
32
  from dstack._internal.core.models.gateways import (
33
33
  GatewayComputeConfiguration,
34
34
  GatewayProvisioningData,
@@ -79,7 +79,7 @@ class AWSCompute(
79
79
  def __init__(self, config: AWSConfig):
80
80
  super().__init__()
81
81
  self.config = config
82
- if is_core_model_instance(config.creds, AWSAccessKeyCreds):
82
+ if isinstance(config.creds, AWSAccessKeyCreds):
83
83
  self.session = boto3.Session(
84
84
  aws_access_key_id=config.creds.access_key,
85
85
  aws_secret_access_key=config.creds.secret_key,
@@ -169,14 +169,19 @@ class AWSCompute(
169
169
  raise NoCapacityError("No eligible availability zones")
170
170
 
171
171
  instance_name = generate_unique_instance_name(instance_config)
172
- tags = {
172
+ base_tags = {
173
173
  "Name": instance_name,
174
174
  "owner": "dstack",
175
175
  "dstack_project": project_name,
176
176
  "dstack_name": instance_config.instance_name,
177
177
  "dstack_user": instance_config.user,
178
178
  }
179
- tags = merge_tags(tags=tags, backend_tags=self.config.tags)
179
+ tags = merge_tags(
180
+ base_tags=base_tags,
181
+ backend_tags=self.config.tags,
182
+ resource_tags=instance_config.tags,
183
+ )
184
+ tags = aws_resources.filter_invalid_tags(tags)
180
185
 
181
186
  disk_size = round(instance_offer.instance.resources.disk.size_mib / 1024)
182
187
  max_efa_interfaces = _get_maximum_efa_interfaces(
@@ -326,15 +331,20 @@ class AWSCompute(
326
331
  ec2_client = self.session.client("ec2", region_name=configuration.region)
327
332
 
328
333
  instance_name = generate_unique_gateway_instance_name(configuration)
329
- tags = {
334
+ base_tags = {
330
335
  "Name": instance_name,
331
336
  "owner": "dstack",
332
337
  "dstack_project": configuration.project_name,
333
338
  "dstack_name": configuration.instance_name,
334
339
  }
335
340
  if settings.DSTACK_VERSION is not None:
336
- tags["dstack_version"] = settings.DSTACK_VERSION
337
- tags = merge_tags(tags=tags, backend_tags=self.config.tags)
341
+ base_tags["dstack_version"] = settings.DSTACK_VERSION
342
+ tags = merge_tags(
343
+ base_tags=base_tags,
344
+ backend_tags=self.config.tags,
345
+ resource_tags=configuration.tags,
346
+ )
347
+ tags = aws_resources.filter_invalid_tags(tags)
338
348
  tags = aws_resources.make_tags(tags)
339
349
 
340
350
  vpc_id, subnets_ids = get_vpc_id_subnet_id_or_error(
@@ -522,14 +532,19 @@ class AWSCompute(
522
532
  ec2_client = self.session.client("ec2", region_name=volume.configuration.region)
523
533
 
524
534
  volume_name = generate_unique_volume_name(volume)
525
- tags = {
535
+ base_tags = {
526
536
  "Name": volume_name,
527
537
  "owner": "dstack",
528
538
  "dstack_project": volume.project_name,
529
539
  "dstack_name": volume.name,
530
540
  "dstack_user": volume.user,
531
541
  }
532
- tags = merge_tags(tags=tags, backend_tags=self.config.tags)
542
+ tags = merge_tags(
543
+ base_tags=base_tags,
544
+ backend_tags=self.config.tags,
545
+ resource_tags=volume.configuration.tags,
546
+ )
547
+ tags = aws_resources.filter_invalid_tags(tags)
533
548
 
534
549
  zones = aws_resources.get_availability_zones(
535
550
  ec2_client=ec2_client, region=volume.configuration.region
@@ -29,7 +29,6 @@ from dstack._internal.core.errors import (
29
29
  from dstack._internal.core.models.backends.base import (
30
30
  BackendType,
31
31
  )
32
- from dstack._internal.core.models.common import is_core_model_instance
33
32
  from dstack._internal.utils.logging import get_logger
34
33
 
35
34
  logger = get_logger(__name__)
@@ -58,12 +57,12 @@ class AWSConfigurator(Configurator):
58
57
  BACKEND_CLASS = AWSBackend
59
58
 
60
59
  def validate_config(self, config: AWSBackendConfigWithCreds, default_creds_enabled: bool):
61
- if is_core_model_instance(config.creds, AWSDefaultCreds) and not default_creds_enabled:
60
+ if isinstance(config.creds, AWSDefaultCreds) and not default_creds_enabled:
62
61
  raise_invalid_credentials_error(fields=[["creds"]])
63
62
  try:
64
63
  session = auth.authenticate(creds=config.creds, region=MAIN_REGION)
65
64
  except Exception:
66
- if is_core_model_instance(config.creds, AWSAccessKeyCreds):
65
+ if isinstance(config.creds, AWSAccessKeyCreds):
67
66
  raise_invalid_credentials_error(
68
67
  fields=[
69
68
  ["creds", "access_key"],
@@ -448,6 +448,16 @@ def make_tags(tags: Dict[str, str]) -> List[Dict[str, str]]:
448
448
  return tags_list
449
449
 
450
450
 
451
+ def filter_invalid_tags(tags: Dict[str, str]) -> Dict[str, str]:
452
+ filtered_tags = {}
453
+ for k, v in tags.items():
454
+ if not _is_valid_tag(k, v):
455
+ logger.warning("Skipping invalid tag '%s: %s'", k, v)
456
+ continue
457
+ filtered_tags[k] = v
458
+ return filtered_tags
459
+
460
+
451
461
  def validate_tags(tags: Dict[str, str]):
452
462
  for k, v in tags.items():
453
463
  if not _is_valid_tag(k, v):
@@ -9,7 +9,6 @@ from dstack._internal.core.backends.azure.models import (
9
9
  AzureClientCreds,
10
10
  )
11
11
  from dstack._internal.core.errors import BackendAuthError
12
- from dstack._internal.core.models.common import is_core_model_instance
13
12
 
14
13
  AzureCredential = Union[ClientSecretCredential, DefaultAzureCredential]
15
14
 
@@ -21,7 +20,7 @@ def authenticate(creds: AnyAzureCreds) -> Tuple[AzureCredential, str]:
21
20
 
22
21
 
23
22
  def get_credential(creds: AnyAzureCreds) -> Tuple[AzureCredential, str]:
24
- if is_core_model_instance(creds, AzureClientCreds):
23
+ if isinstance(creds, AzureClientCreds):
25
24
  credential = ClientSecretCredential(
26
25
  tenant_id=creds.tenant_id,
27
26
  client_id=creds.client_id,