dstack 0.19.16__py3-none-any.whl → 0.19.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dstack might be problematic. Click here for more details.

Files changed (55) hide show
  1. dstack/_internal/cli/commands/secrets.py +92 -0
  2. dstack/_internal/cli/main.py +2 -0
  3. dstack/_internal/cli/services/completion.py +5 -0
  4. dstack/_internal/cli/services/configurators/run.py +59 -17
  5. dstack/_internal/cli/utils/secrets.py +25 -0
  6. dstack/_internal/core/backends/__init__.py +10 -4
  7. dstack/_internal/core/compatibility/runs.py +29 -2
  8. dstack/_internal/core/models/configurations.py +11 -0
  9. dstack/_internal/core/models/files.py +67 -0
  10. dstack/_internal/core/models/runs.py +14 -0
  11. dstack/_internal/core/models/secrets.py +9 -2
  12. dstack/_internal/server/app.py +2 -0
  13. dstack/_internal/server/background/tasks/process_running_jobs.py +109 -12
  14. dstack/_internal/server/background/tasks/process_runs.py +15 -3
  15. dstack/_internal/server/migrations/versions/5f1707c525d2_add_filearchivemodel.py +39 -0
  16. dstack/_internal/server/migrations/versions/644b8a114187_add_secretmodel.py +49 -0
  17. dstack/_internal/server/models.py +33 -0
  18. dstack/_internal/server/routers/files.py +67 -0
  19. dstack/_internal/server/routers/secrets.py +57 -15
  20. dstack/_internal/server/schemas/files.py +5 -0
  21. dstack/_internal/server/schemas/runner.py +2 -0
  22. dstack/_internal/server/schemas/secrets.py +7 -11
  23. dstack/_internal/server/services/backends/__init__.py +1 -1
  24. dstack/_internal/server/services/files.py +91 -0
  25. dstack/_internal/server/services/jobs/__init__.py +19 -8
  26. dstack/_internal/server/services/jobs/configurators/base.py +20 -2
  27. dstack/_internal/server/services/jobs/configurators/dev.py +3 -3
  28. dstack/_internal/server/services/proxy/repo.py +3 -0
  29. dstack/_internal/server/services/runner/client.py +8 -0
  30. dstack/_internal/server/services/runs.py +52 -7
  31. dstack/_internal/server/services/secrets.py +204 -0
  32. dstack/_internal/server/services/storage/base.py +21 -0
  33. dstack/_internal/server/services/storage/gcs.py +28 -6
  34. dstack/_internal/server/services/storage/s3.py +27 -9
  35. dstack/_internal/server/settings.py +2 -2
  36. dstack/_internal/server/statics/index.html +1 -1
  37. dstack/_internal/server/statics/{main-a4eafa74304e587d037c.js → main-d151637af20f70b2e796.js} +56 -8
  38. dstack/_internal/server/statics/{main-a4eafa74304e587d037c.js.map → main-d151637af20f70b2e796.js.map} +1 -1
  39. dstack/_internal/server/statics/{main-f53d6d0d42f8d61df1de.css → main-d48635d8fe670d53961c.css} +1 -1
  40. dstack/_internal/server/statics/static/media/google.b194b06fafd0a52aeb566922160ea514.svg +1 -0
  41. dstack/_internal/server/testing/common.py +43 -5
  42. dstack/_internal/settings.py +4 -0
  43. dstack/_internal/utils/files.py +69 -0
  44. dstack/_internal/utils/nested_list.py +47 -0
  45. dstack/_internal/utils/path.py +12 -4
  46. dstack/api/_public/runs.py +67 -7
  47. dstack/api/server/__init__.py +6 -0
  48. dstack/api/server/_files.py +18 -0
  49. dstack/api/server/_secrets.py +15 -15
  50. dstack/version.py +1 -1
  51. {dstack-0.19.16.dist-info → dstack-0.19.17.dist-info}/METADATA +3 -4
  52. {dstack-0.19.16.dist-info → dstack-0.19.17.dist-info}/RECORD +55 -42
  53. {dstack-0.19.16.dist-info → dstack-0.19.17.dist-info}/WHEEL +0 -0
  54. {dstack-0.19.16.dist-info → dstack-0.19.17.dist-info}/entry_points.txt +0 -0
  55. {dstack-0.19.16.dist-info → dstack-0.19.17.dist-info}/licenses/LICENSE.md +0 -0
@@ -0,0 +1,92 @@
1
+ import argparse
2
+
3
+ from dstack._internal.cli.commands import APIBaseCommand
4
+ from dstack._internal.cli.services.completion import SecretNameCompleter
5
+ from dstack._internal.cli.utils.common import (
6
+ confirm_ask,
7
+ console,
8
+ )
9
+ from dstack._internal.cli.utils.secrets import print_secrets_table
10
+
11
+
12
+ class SecretCommand(APIBaseCommand):
13
+ NAME = "secret"
14
+ DESCRIPTION = "Manage secrets"
15
+
16
+ def _register(self):
17
+ super()._register()
18
+ self._parser.set_defaults(subfunc=self._list)
19
+ subparsers = self._parser.add_subparsers(dest="action")
20
+
21
+ list_parser = subparsers.add_parser(
22
+ "list", help="List secrets", formatter_class=self._parser.formatter_class
23
+ )
24
+ list_parser.set_defaults(subfunc=self._list)
25
+
26
+ get_parser = subparsers.add_parser(
27
+ "get", help="Get secret value", formatter_class=self._parser.formatter_class
28
+ )
29
+ get_parser.add_argument(
30
+ "name",
31
+ help="The name of the secret",
32
+ ).completer = SecretNameCompleter()
33
+ get_parser.set_defaults(subfunc=self._get)
34
+
35
+ set_parser = subparsers.add_parser(
36
+ "set", help="Set secret", formatter_class=self._parser.formatter_class
37
+ )
38
+ set_parser.add_argument(
39
+ "name",
40
+ help="The name of the secret",
41
+ )
42
+ set_parser.add_argument(
43
+ "value",
44
+ help="The value of the secret",
45
+ )
46
+ set_parser.set_defaults(subfunc=self._set)
47
+
48
+ delete_parser = subparsers.add_parser(
49
+ "delete",
50
+ help="Delete secrets",
51
+ formatter_class=self._parser.formatter_class,
52
+ )
53
+ delete_parser.add_argument(
54
+ "name",
55
+ help="The name of the secret",
56
+ ).completer = SecretNameCompleter()
57
+ delete_parser.add_argument(
58
+ "-y", "--yes", help="Don't ask for confirmation", action="store_true"
59
+ )
60
+ delete_parser.set_defaults(subfunc=self._delete)
61
+
62
+ def _command(self, args: argparse.Namespace):
63
+ super()._command(args)
64
+ args.subfunc(args)
65
+
66
+ def _list(self, args: argparse.Namespace):
67
+ secrets = self.api.client.secrets.list(self.api.project)
68
+ print_secrets_table(secrets)
69
+
70
+ def _get(self, args: argparse.Namespace):
71
+ secret = self.api.client.secrets.get(self.api.project, name=args.name)
72
+ print_secrets_table([secret])
73
+
74
+ def _set(self, args: argparse.Namespace):
75
+ self.api.client.secrets.create_or_update(
76
+ self.api.project,
77
+ name=args.name,
78
+ value=args.value,
79
+ )
80
+ console.print("[grey58]OK[/]")
81
+
82
+ def _delete(self, args: argparse.Namespace):
83
+ if not args.yes and not confirm_ask(f"Delete the secret [code]{args.name}[/]?"):
84
+ console.print("\nExiting...")
85
+ return
86
+
87
+ with console.status("Deleting secret..."):
88
+ self.api.client.secrets.delete(
89
+ project_name=self.api.project,
90
+ names=[args.name],
91
+ )
92
+ console.print("[grey58]OK[/]")
@@ -17,6 +17,7 @@ from dstack._internal.cli.commands.metrics import MetricsCommand
17
17
  from dstack._internal.cli.commands.offer import OfferCommand
18
18
  from dstack._internal.cli.commands.project import ProjectCommand
19
19
  from dstack._internal.cli.commands.ps import PsCommand
20
+ from dstack._internal.cli.commands.secrets import SecretCommand
20
21
  from dstack._internal.cli.commands.server import ServerCommand
21
22
  from dstack._internal.cli.commands.stats import StatsCommand
22
23
  from dstack._internal.cli.commands.stop import StopCommand
@@ -72,6 +73,7 @@ def main():
72
73
  MetricsCommand.register(subparsers)
73
74
  ProjectCommand.register(subparsers)
74
75
  PsCommand.register(subparsers)
76
+ SecretCommand.register(subparsers)
75
77
  ServerCommand.register(subparsers)
76
78
  StatsCommand.register(subparsers)
77
79
  StopCommand.register(subparsers)
@@ -75,6 +75,11 @@ class GatewayNameCompleter(BaseAPINameCompleter):
75
75
  return [r.name for r in api.client.gateways.list(api.project)]
76
76
 
77
77
 
78
+ class SecretNameCompleter(BaseAPINameCompleter):
79
+ def fetch_resource_names(self, api: Client) -> Iterable[str]:
80
+ return [r.name for r in api.client.secrets.list(api.project)]
81
+
82
+
78
83
  class ProjectNameCompleter(BaseCompleter):
79
84
  """
80
85
  Completer for local project names.
@@ -41,12 +41,13 @@ from dstack._internal.core.models.configurations import (
41
41
  )
42
42
  from dstack._internal.core.models.repos.base import Repo
43
43
  from dstack._internal.core.models.resources import CPUSpec
44
- from dstack._internal.core.models.runs import JobStatus, JobSubmission, RunStatus
44
+ from dstack._internal.core.models.runs import JobStatus, JobSubmission, RunSpec, RunStatus
45
45
  from dstack._internal.core.services.configs import ConfigManager
46
46
  from dstack._internal.core.services.diff import diff_models
47
47
  from dstack._internal.utils.common import local_time
48
48
  from dstack._internal.utils.interpolator import InterpolatorError, VariablesInterpolator
49
49
  from dstack._internal.utils.logging import get_logger
50
+ from dstack._internal.utils.nested_list import NestedList, NestedListItem
50
51
  from dstack.api._public.repos import get_ssh_keypair
51
52
  from dstack.api._public.runs import Run
52
53
  from dstack.api.utils import load_profile
@@ -102,25 +103,20 @@ class BaseRunConfigurator(ApplyEnvVarsConfiguratorMixin, BaseApplyConfigurator):
102
103
  confirm_message = f"Submit the run [code]{conf.name}[/]?"
103
104
  stop_run_name = None
104
105
  if run_plan.current_resource is not None:
105
- changed_fields = []
106
- if run_plan.action == ApplyAction.UPDATE:
107
- diff = diff_models(
108
- run_plan.get_effective_run_spec().configuration,
109
- run_plan.current_resource.run_spec.configuration,
110
- )
111
- changed_fields = list(diff.keys())
112
- if run_plan.action == ApplyAction.UPDATE and len(changed_fields) > 0:
106
+ diff = render_run_spec_diff(
107
+ run_plan.get_effective_run_spec(),
108
+ run_plan.current_resource.run_spec,
109
+ )
110
+ if run_plan.action == ApplyAction.UPDATE and diff is not None:
113
111
  console.print(
114
112
  f"Active run [code]{conf.name}[/] already exists."
115
- " Detected configuration changes that can be updated in-place:"
116
- f" {changed_fields}"
113
+ f" Detected changes that [code]can[/] be updated in-place:\n{diff}"
117
114
  )
118
115
  confirm_message = "Update the run?"
119
- elif run_plan.action == ApplyAction.UPDATE and len(changed_fields) == 0:
116
+ elif run_plan.action == ApplyAction.UPDATE and diff is None:
120
117
  stop_run_name = run_plan.current_resource.run_spec.run_name
121
118
  console.print(
122
- f"Active run [code]{conf.name}[/] already exists."
123
- " Detected no configuration changes."
119
+ f"Active run [code]{conf.name}[/] already exists. Detected no changes."
124
120
  )
125
121
  if command_args.yes and not command_args.force:
126
122
  console.print("Use --force to apply anyway.")
@@ -129,7 +125,8 @@ class BaseRunConfigurator(ApplyEnvVarsConfiguratorMixin, BaseApplyConfigurator):
129
125
  elif not run_plan.current_resource.status.is_finished():
130
126
  stop_run_name = run_plan.current_resource.run_spec.run_name
131
127
  console.print(
132
- f"Active run [code]{conf.name}[/] already exists and cannot be updated in-place."
128
+ f"Active run [code]{conf.name}[/] already exists."
129
+ f" Detected changes that [error]cannot[/] be updated in-place:\n{diff}"
133
130
  )
134
131
  confirm_message = "Stop and override the run?"
135
132
 
@@ -398,9 +395,10 @@ class BaseRunConfigurator(ApplyEnvVarsConfiguratorMixin, BaseApplyConfigurator):
398
395
  else:
399
396
  has_amd_gpu = vendor == gpuhunt.AcceleratorVendor.AMD
400
397
  has_tt_gpu = vendor == gpuhunt.AcceleratorVendor.TENSTORRENT
401
- if has_amd_gpu and conf.image is None:
398
+ # When docker=True, the system uses Docker-in-Docker image, so no custom image is required
399
+ if has_amd_gpu and conf.image is None and conf.docker is not True:
402
400
  raise ConfigurationError("`image` is required if `resources.gpu.vendor` is `amd`")
403
- if has_tt_gpu and conf.image is None:
401
+ if has_tt_gpu and conf.image is None and conf.docker is not True:
404
402
  raise ConfigurationError(
405
403
  "`image` is required if `resources.gpu.vendor` is `tenstorrent`"
406
404
  )
@@ -610,3 +608,47 @@ def _run_resubmitted(run: Run, current_job_submission: Optional[JobSubmission])
610
608
  not run.status.is_finished()
611
609
  and run._run.latest_job_submission.submitted_at > current_job_submission.submitted_at
612
610
  )
611
+
612
+
613
+ def render_run_spec_diff(old_spec: RunSpec, new_spec: RunSpec) -> Optional[str]:
614
+ changed_spec_fields = list(diff_models(old_spec, new_spec))
615
+ if not changed_spec_fields:
616
+ return None
617
+ friendly_spec_field_names = {
618
+ "repo_id": "Repo ID",
619
+ "repo_code_hash": "Repo files",
620
+ "repo_data": "Repo state (branch, commit, or other)",
621
+ "ssh_key_pub": "Public SSH key",
622
+ }
623
+ nested_list = NestedList()
624
+ for spec_field in changed_spec_fields:
625
+ if spec_field == "merged_profile":
626
+ continue
627
+ elif spec_field == "configuration":
628
+ if type(old_spec.configuration) is not type(new_spec.configuration):
629
+ item = NestedListItem("Configuration type")
630
+ else:
631
+ item = NestedListItem(
632
+ "Configuration properties:",
633
+ children=[
634
+ NestedListItem(field)
635
+ for field in diff_models(old_spec.configuration, new_spec.configuration)
636
+ ],
637
+ )
638
+ elif spec_field == "profile":
639
+ if type(old_spec.profile) is not type(new_spec.profile):
640
+ item = NestedListItem("Profile")
641
+ else:
642
+ item = NestedListItem(
643
+ "Profile properties:",
644
+ children=[
645
+ NestedListItem(field)
646
+ for field in diff_models(old_spec.profile, new_spec.profile)
647
+ ],
648
+ )
649
+ elif spec_field in friendly_spec_field_names:
650
+ item = NestedListItem(friendly_spec_field_names[spec_field])
651
+ else:
652
+ item = NestedListItem(spec_field.replace("_", " ").capitalize())
653
+ nested_list.children.append(item)
654
+ return nested_list.render()
@@ -0,0 +1,25 @@
1
+ from typing import List
2
+
3
+ from rich.table import Table
4
+
5
+ from dstack._internal.cli.utils.common import add_row_from_dict, console
6
+ from dstack._internal.core.models.secrets import Secret
7
+
8
+
9
+ def print_secrets_table(secrets: List[Secret]) -> None:
10
+ console.print(get_secrets_table(secrets))
11
+ console.print()
12
+
13
+
14
+ def get_secrets_table(secrets: List[Secret]) -> Table:
15
+ table = Table(box=None)
16
+ table.add_column("NAME", no_wrap=True)
17
+ table.add_column("VALUE")
18
+
19
+ for secret in secrets:
20
+ row = {
21
+ "NAME": secret.name,
22
+ "VALUE": secret.value or "*" * 6,
23
+ }
24
+ add_row_from_dict(table, row)
25
+ return table
@@ -9,18 +9,25 @@ from dstack._internal.core.backends.base.compute import (
9
9
  )
10
10
  from dstack._internal.core.backends.base.configurator import Configurator
11
11
  from dstack._internal.core.backends.configurators import list_available_configurator_classes
12
+ from dstack._internal.core.backends.local.compute import LocalCompute
12
13
  from dstack._internal.core.models.backends.base import BackendType
14
+ from dstack._internal.settings import LOCAL_BACKEND_ENABLED
13
15
 
14
16
 
15
17
  def _get_backends_with_compute_feature(
16
18
  configurator_classes: list[type[Configurator]],
17
19
  compute_feature_class: type,
18
20
  ) -> list[BackendType]:
21
+ backend_types_and_computes = [
22
+ (configurator_class.TYPE, configurator_class.BACKEND_CLASS.COMPUTE_CLASS)
23
+ for configurator_class in configurator_classes
24
+ ]
25
+ if LOCAL_BACKEND_ENABLED:
26
+ backend_types_and_computes.append((BackendType.LOCAL, LocalCompute))
19
27
  backend_types = []
20
- for configurator_class in configurator_classes:
21
- compute_class = configurator_class.BACKEND_CLASS.COMPUTE_CLASS
28
+ for backend_type, compute_class in backend_types_and_computes:
22
29
  if issubclass(compute_class, compute_feature_class):
23
- backend_types.append(configurator_class.TYPE)
30
+ backend_types.append(backend_type)
24
31
  return backend_types
25
32
 
26
33
 
@@ -28,7 +35,6 @@ _configurator_classes = list_available_configurator_classes()
28
35
 
29
36
 
30
37
  # The following backend lists do not include unavailable backends (i.e. backends missing deps).
31
- # TODO: Add LocalBackend to lists if it's enabled
32
38
  BACKENDS_WITH_CREATE_INSTANCE_SUPPORT = _get_backends_with_compute_feature(
33
39
  configurator_classes=_configurator_classes,
34
40
  compute_feature_class=ComputeWithCreateInstanceSupport,
@@ -1,7 +1,7 @@
1
1
  from typing import Any, Dict, Optional
2
2
 
3
3
  from dstack._internal.core.models.configurations import ServiceConfiguration
4
- from dstack._internal.core.models.runs import ApplyRunPlanInput, JobSubmission, RunSpec
4
+ from dstack._internal.core.models.runs import ApplyRunPlanInput, JobSpec, JobSubmission, RunSpec
5
5
  from dstack._internal.server.schemas.runs import GetRunPlanRequest
6
6
 
7
7
 
@@ -25,7 +25,10 @@ def get_apply_plan_excludes(plan: ApplyRunPlanInput) -> Optional[Dict]:
25
25
  current_resource_excludes["run_spec"] = get_run_spec_excludes(current_resource.run_spec)
26
26
  job_submissions_excludes = {}
27
27
  current_resource_excludes["jobs"] = {
28
- "__all__": {"job_submissions": {"__all__": job_submissions_excludes}}
28
+ "__all__": {
29
+ "job_spec": get_job_spec_excludes([job.job_spec for job in current_resource.jobs]),
30
+ "job_submissions": {"__all__": job_submissions_excludes},
31
+ }
29
32
  }
30
33
  job_submissions = [js for j in current_resource.jobs for js in j.job_submissions]
31
34
  if all(map(_should_exclude_job_submission_jpd_cpu_arch, job_submissions)):
@@ -109,6 +112,10 @@ def get_run_spec_excludes(run_spec: RunSpec) -> Optional[Dict]:
109
112
  configuration_excludes["stop_criteria"] = True
110
113
  if profile is not None and profile.stop_criteria is None:
111
114
  profile_excludes.add("stop_criteria")
115
+ if not configuration.files:
116
+ configuration_excludes["files"] = True
117
+ if not run_spec.file_archives:
118
+ spec_excludes["file_archives"] = True
112
119
 
113
120
  if configuration_excludes:
114
121
  spec_excludes["configuration"] = configuration_excludes
@@ -119,6 +126,26 @@ def get_run_spec_excludes(run_spec: RunSpec) -> Optional[Dict]:
119
126
  return None
120
127
 
121
128
 
129
+ def get_job_spec_excludes(job_specs: list[JobSpec]) -> Optional[dict]:
130
+ """
131
+ Returns `job_spec` exclude mapping to exclude certain fields from the request.
132
+ Use this method to exclude new fields when they are not set to keep
133
+ clients backward-compatibility with older servers.
134
+ """
135
+ spec_excludes: dict[str, Any] = {}
136
+
137
+ if all(s.repo_code_hash is None for s in job_specs):
138
+ spec_excludes["repo_code_hash"] = True
139
+ if all(s.repo_data is None for s in job_specs):
140
+ spec_excludes["repo_data"] = True
141
+ if all(not s.file_archives for s in job_specs):
142
+ spec_excludes["file_archives"] = True
143
+
144
+ if spec_excludes:
145
+ return spec_excludes
146
+ return None
147
+
148
+
122
149
  def _should_exclude_job_submission_jpd_cpu_arch(job_submission: JobSubmission) -> bool:
123
150
  try:
124
151
  return job_submission.job_provisioning_data.instance_type.resources.cpu_arch is None
@@ -10,6 +10,7 @@ from typing_extensions import Annotated, Literal
10
10
  from dstack._internal.core.errors import ConfigurationError
11
11
  from dstack._internal.core.models.common import CoreModel, Duration, RegistryAuth
12
12
  from dstack._internal.core.models.envs import Env
13
+ from dstack._internal.core.models.files import FilePathMapping
13
14
  from dstack._internal.core.models.fleets import FleetConfiguration
14
15
  from dstack._internal.core.models.gateways import GatewayConfiguration
15
16
  from dstack._internal.core.models.profiles import ProfileParams, parse_off_duration
@@ -252,6 +253,10 @@ class BaseRunConfiguration(CoreModel):
252
253
  description="Use Docker inside the container. Mutually exclusive with `image`, `python`, and `nvcc`. Overrides `privileged`"
253
254
  ),
254
255
  ] = None
256
+ files: Annotated[
257
+ list[Union[FilePathMapping, str]],
258
+ Field(description="The local to container file path mappings"),
259
+ ] = []
255
260
  # deprecated since 0.18.31; task, service -- no effect; dev-environment -- executed right before `init`
256
261
  setup: CommandsList = []
257
262
 
@@ -285,6 +290,12 @@ class BaseRunConfiguration(CoreModel):
285
290
  return parse_mount_point(v)
286
291
  return v
287
292
 
293
+ @validator("files", each_item=True)
294
+ def convert_files(cls, v) -> FilePathMapping:
295
+ if isinstance(v, str):
296
+ return FilePathMapping.parse(v)
297
+ return v
298
+
288
299
  @validator("user")
289
300
  def validate_user(cls, v) -> Optional[str]:
290
301
  if v is None:
@@ -0,0 +1,67 @@
1
+ import pathlib
2
+ import string
3
+ from uuid import UUID
4
+
5
+ from pydantic import Field, validator
6
+ from typing_extensions import Annotated, Self
7
+
8
+ from dstack._internal.core.models.common import CoreModel
9
+
10
+
11
+ class FileArchive(CoreModel):
12
+ id: UUID
13
+ hash: str
14
+
15
+
16
+ class FilePathMapping(CoreModel):
17
+ local_path: Annotated[
18
+ str,
19
+ Field(
20
+ description=(
21
+ "The path on the user's machine. Relative paths are resolved relative to"
22
+ " the parent directory of the the configuration file"
23
+ )
24
+ ),
25
+ ]
26
+ path: Annotated[
27
+ str,
28
+ Field(
29
+ description=(
30
+ "The path in the container. Relative paths are resolved relative to"
31
+ " the repo directory (`/workflow`)"
32
+ )
33
+ ),
34
+ ]
35
+
36
+ @classmethod
37
+ def parse(cls, v: str) -> Self:
38
+ local_path: str
39
+ path: str
40
+ parts = v.split(":")
41
+ # A special case for Windows paths, e.g., `C:\path\to`, 'c:/path/to'
42
+ if (
43
+ len(parts) > 1
44
+ and len(parts[0]) == 1
45
+ and parts[0] in string.ascii_letters
46
+ and parts[1][:1] in ["\\", "/"]
47
+ ):
48
+ parts = [f"{parts[0]}:{parts[1]}", *parts[2:]]
49
+ if len(parts) == 1:
50
+ local_path = path = parts[0]
51
+ elif len(parts) == 2:
52
+ local_path, path = parts
53
+ else:
54
+ raise ValueError(f"invalid file path mapping: {v}")
55
+ return cls(local_path=local_path, path=path)
56
+
57
+ @validator("path")
58
+ def validate_path(cls, v) -> str:
59
+ # True for `C:/.*`, False otherwise, including `/abs/unix/path`, `rel\windows\path`, etc.
60
+ if pathlib.PureWindowsPath(v).is_absolute():
61
+ raise ValueError(f"path must be a Unix file path: {v}")
62
+ return v
63
+
64
+
65
+ class FileArchiveMapping(CoreModel):
66
+ id: Annotated[UUID, Field(description="The File archive ID")]
67
+ path: Annotated[str, Field(description="The path in the container")]
@@ -12,6 +12,7 @@ from dstack._internal.core.models.configurations import (
12
12
  AnyRunConfiguration,
13
13
  RunConfiguration,
14
14
  )
15
+ from dstack._internal.core.models.files import FileArchiveMapping
15
16
  from dstack._internal.core.models.instances import (
16
17
  InstanceOfferWithAvailability,
17
18
  InstanceType,
@@ -217,6 +218,15 @@ class JobSpec(CoreModel):
217
218
  volumes: Optional[List[MountPoint]] = None
218
219
  ssh_key: Optional[JobSSHKey] = None
219
220
  working_dir: Optional[str]
221
+ # `repo_data` is optional for client compatibility with pre-0.19.17 servers and for compatibility
222
+ # with jobs submitted before 0.19.17. All new jobs are expected to have non-None `repo_data`.
223
+ # For --no-repo runs, `repo_data` is `VirtualRunRepoData()`.
224
+ repo_data: Annotated[Optional[AnyRunRepoData], Field(discriminator="repo_type")] = None
225
+ # `repo_code_hash` can be None because it is not used for the repo or because the job was
226
+ # submitted before 0.19.17. See `_get_repo_code_hash` on how to get the correct `repo_code_hash`
227
+ # TODO: drop this comment when supporting jobs submitted before 0.19.17 is no longer relevant.
228
+ repo_code_hash: Optional[str] = None
229
+ file_archives: list[FileArchiveMapping] = []
220
230
 
221
231
 
222
232
  class JobProvisioningData(CoreModel):
@@ -413,6 +423,10 @@ class RunSpec(CoreModel):
413
423
  Optional[str],
414
424
  Field(description="The hash of the repo diff. Can be omitted if there is no repo diff."),
415
425
  ] = None
426
+ file_archives: Annotated[
427
+ list[FileArchiveMapping],
428
+ Field(description="The list of file archive ID to container path mappings"),
429
+ ] = []
416
430
  working_dir: Annotated[
417
431
  Optional[str],
418
432
  Field(
@@ -1,9 +1,16 @@
1
+ from typing import Optional
2
+ from uuid import UUID
3
+
1
4
  from dstack._internal.core.models.common import CoreModel
2
5
 
3
6
 
4
7
  class Secret(CoreModel):
8
+ id: UUID
5
9
  name: str
6
- value: str
10
+ value: Optional[str] = None
7
11
 
8
12
  def __str__(self) -> str:
9
- return f'Secret(name="{self.name}", value={"*" * len(self.value)})'
13
+ displayed_value = "*"
14
+ if self.value is not None:
15
+ displayed_value = "*" * len(self.value)
16
+ return f'Secret(name="{self.name}", value={displayed_value})'
@@ -23,6 +23,7 @@ from dstack._internal.server.background import start_background_tasks
23
23
  from dstack._internal.server.db import get_db, get_session_ctx, migrate
24
24
  from dstack._internal.server.routers import (
25
25
  backends,
26
+ files,
26
27
  fleets,
27
28
  gateways,
28
29
  instances,
@@ -197,6 +198,7 @@ def register_routes(app: FastAPI, ui: bool = True):
197
198
  app.include_router(service_proxy.router, prefix="/proxy/services", tags=["service-proxy"])
198
199
  app.include_router(model_proxy.router, prefix="/proxy/models", tags=["model-proxy"])
199
200
  app.include_router(prometheus.router)
201
+ app.include_router(files.router)
200
202
 
201
203
  @app.exception_handler(ForbiddenError)
202
204
  async def forbidden_error_handler(request: Request, exc: ForbiddenError):