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.
- dstack/_internal/cli/commands/secrets.py +92 -0
- dstack/_internal/cli/main.py +2 -0
- dstack/_internal/cli/services/completion.py +5 -0
- dstack/_internal/cli/services/configurators/run.py +59 -17
- dstack/_internal/cli/utils/secrets.py +25 -0
- dstack/_internal/core/backends/__init__.py +10 -4
- dstack/_internal/core/compatibility/runs.py +29 -2
- dstack/_internal/core/models/configurations.py +11 -0
- dstack/_internal/core/models/files.py +67 -0
- dstack/_internal/core/models/runs.py +14 -0
- dstack/_internal/core/models/secrets.py +9 -2
- dstack/_internal/server/app.py +2 -0
- dstack/_internal/server/background/tasks/process_running_jobs.py +109 -12
- dstack/_internal/server/background/tasks/process_runs.py +15 -3
- dstack/_internal/server/migrations/versions/5f1707c525d2_add_filearchivemodel.py +39 -0
- dstack/_internal/server/migrations/versions/644b8a114187_add_secretmodel.py +49 -0
- dstack/_internal/server/models.py +33 -0
- dstack/_internal/server/routers/files.py +67 -0
- dstack/_internal/server/routers/secrets.py +57 -15
- dstack/_internal/server/schemas/files.py +5 -0
- dstack/_internal/server/schemas/runner.py +2 -0
- dstack/_internal/server/schemas/secrets.py +7 -11
- dstack/_internal/server/services/backends/__init__.py +1 -1
- dstack/_internal/server/services/files.py +91 -0
- dstack/_internal/server/services/jobs/__init__.py +19 -8
- dstack/_internal/server/services/jobs/configurators/base.py +20 -2
- dstack/_internal/server/services/jobs/configurators/dev.py +3 -3
- dstack/_internal/server/services/proxy/repo.py +3 -0
- dstack/_internal/server/services/runner/client.py +8 -0
- dstack/_internal/server/services/runs.py +52 -7
- dstack/_internal/server/services/secrets.py +204 -0
- dstack/_internal/server/services/storage/base.py +21 -0
- dstack/_internal/server/services/storage/gcs.py +28 -6
- dstack/_internal/server/services/storage/s3.py +27 -9
- dstack/_internal/server/settings.py +2 -2
- dstack/_internal/server/statics/index.html +1 -1
- dstack/_internal/server/statics/{main-a4eafa74304e587d037c.js → main-d151637af20f70b2e796.js} +56 -8
- dstack/_internal/server/statics/{main-a4eafa74304e587d037c.js.map → main-d151637af20f70b2e796.js.map} +1 -1
- dstack/_internal/server/statics/{main-f53d6d0d42f8d61df1de.css → main-d48635d8fe670d53961c.css} +1 -1
- dstack/_internal/server/statics/static/media/google.b194b06fafd0a52aeb566922160ea514.svg +1 -0
- dstack/_internal/server/testing/common.py +43 -5
- dstack/_internal/settings.py +4 -0
- dstack/_internal/utils/files.py +69 -0
- dstack/_internal/utils/nested_list.py +47 -0
- dstack/_internal/utils/path.py +12 -4
- dstack/api/_public/runs.py +67 -7
- dstack/api/server/__init__.py +6 -0
- dstack/api/server/_files.py +18 -0
- dstack/api/server/_secrets.py +15 -15
- dstack/version.py +1 -1
- {dstack-0.19.16.dist-info → dstack-0.19.17.dist-info}/METADATA +3 -4
- {dstack-0.19.16.dist-info → dstack-0.19.17.dist-info}/RECORD +55 -42
- {dstack-0.19.16.dist-info → dstack-0.19.17.dist-info}/WHEEL +0 -0
- {dstack-0.19.16.dist-info → dstack-0.19.17.dist-info}/entry_points.txt +0 -0
- {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[/]")
|
dstack/_internal/cli/main.py
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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__": {
|
|
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
|
-
|
|
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})'
|
dstack/_internal/server/app.py
CHANGED
|
@@ -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):
|