diff --git a/mkdocs/docs/concepts/presets.md b/mkdocs/docs/concepts/presets.md
index 3f625b5a3..e091dd28f 100644
--- a/mkdocs/docs/concepts/presets.md
+++ b/mkdocs/docs/concepts/presets.md
@@ -214,7 +214,7 @@ Preset c83375b4 exported to qwen.dstack.yml (16 files). Deploy it with `dstack a
-The command writes the service configuration along with any files it references, such as patches. Set the service `name` and, optionally, a [gateway](gateways.md) in the exported configuration, then submit it with `dstack apply`:
+The command writes the service configuration along with any files it references, such as patches. The service is named after the preset; pass `-n` to override. Optionally, set a [gateway](gateways.md) in the exported configuration, then submit it with `dstack apply`:
diff --git a/src/dstack/_internal/cli/commands/preset.py b/src/dstack/_internal/cli/commands/preset.py
index 7e630b9a4..c006a4121 100644
--- a/src/dstack/_internal/cli/commands/preset.py
+++ b/src/dstack/_internal/cli/commands/preset.py
@@ -165,6 +165,11 @@ def _register(self) -> None:
dest="destination",
help="The service configuration file to write",
)
+ export_parser.add_argument(
+ "-n",
+ "--name",
+ help="The service name. Defaults to the preset name if set",
+ )
export_parser.add_argument("--force", action="store_true", help="Overwrite existing files")
export_parser.set_defaults(subfunc=self._export)
@@ -343,6 +348,7 @@ def _export(self, args: argparse.Namespace) -> None:
preset_dir=store.root / preset.id,
destination=Path(args.destination),
force=args.force,
+ name=args.name,
)
console.print(
f"Preset [code]{preset.id}[/] exported to [code]{args.destination}[/]"
diff --git a/src/dstack/_internal/cli/models/presets.py b/src/dstack/_internal/cli/models/presets.py
index e7c50ac9e..c5ff005d2 100644
--- a/src/dstack/_internal/cli/models/presets.py
+++ b/src/dstack/_internal/cli/models/presets.py
@@ -123,8 +123,12 @@ class VerifiedPreset(Preset):
context_length: PositiveInt
# The session's `trials/` that won verification and became this preset.
best_trial: PositiveInt
- # The service that passed verification, stripped of this machine's deployment
- # choices; `apply` submits it with the user's own name, gateway, and profile.
+ # The verified run's spec configuration, not the agent's files. The
+ # validator below keeps `name`, `gateway`, and profile params unset (the
+ # deployer's choices) and requires `model` and resources. Env keys the
+ # user declared as passthroughs hold `EnvSentinel` references, not the
+ # resolved secrets; other env values are stored as-is. `files` paths are
+ # stored relative to the preset directory, absolute after load.
service: ServiceConfiguration
benchmark: PresetBenchmark
# The hardware it was verified on: the actual resources of every running
diff --git a/src/dstack/_internal/cli/services/presets/build.py b/src/dstack/_internal/cli/services/presets/build.py
index 00c37a9a8..f3fb37246 100644
--- a/src/dstack/_internal/cli/services/presets/build.py
+++ b/src/dstack/_internal/cli/services/presets/build.py
@@ -1,5 +1,5 @@
from datetime import datetime
-from typing import Any, Optional, TypeVar
+from typing import Optional, TypeVar
import gpuhunt
@@ -10,7 +10,7 @@
VerifiedPreset,
)
from dstack._internal.core.models.configurations import ServiceConfiguration
-from dstack._internal.core.models.envs import Env, EnvSentinel
+from dstack._internal.core.models.envs import Env
from dstack._internal.core.models.instances import Resources
from dstack._internal.core.models.presets import PresetConfiguration
from dstack._internal.core.models.resources import (
@@ -59,34 +59,6 @@ def build_preset(
)
-def preset_to_yaml_dict(preset: VerifiedPreset) -> dict[str, Any]:
- """`VerifiedPreset` in the plain types `yaml.safe_dump` accepts."""
- return {
- # A saved preset is verified by definition; `status` is wire-only.
- **preset.model_dump(mode="json", exclude_none=True, exclude={"status"}),
- "service": service_configuration_to_yaml_dict(preset.service),
- }
-
-
-def service_configuration_to_yaml_dict(
- configuration: ServiceConfiguration,
-) -> dict[str, Any]:
- """The service as a preset stores it.
-
- Env is rewritten as `key=value` because dumping it writes a passthrough
- variable as `HF_TOKEN: {key: HF_TOKEN}`, and this file is meant to be read."""
- service = configuration.model_dump(
- mode="json",
- exclude={"type", *PRESET_EXCLUDED_FIELDS},
- exclude_none=True,
- )
- if configuration.env:
- service["env"] = [
- _env_item_to_yaml(key, value) for key, value in sorted(configuration.env.items())
- ]
- return {field: value for field, value in service.items() if value not in ({}, [])}
-
-
def resources_spec_from_instance_resources(resources: Resources) -> ResourcesSpec:
gpu = GPUSpec(count=Range[int](min=0, max=0))
if resources.gpus:
@@ -142,12 +114,6 @@ def _without_excluded_fields(configuration: ConfigurationT) -> ConfigurationT:
return configuration.model_copy(deep=True, update=dict.fromkeys(PRESET_EXCLUDED_FIELDS))
-def _env_item_to_yaml(key: str, value: str | EnvSentinel) -> str:
- if isinstance(value, EnvSentinel):
- return key
- return f"{key}={value}"
-
-
def _get_verification_group_gpu_vendor(
verified_on: list[PresetVerificationReplicaGroup],
group_name: str,
diff --git a/src/dstack/_internal/cli/services/presets/create.py b/src/dstack/_internal/cli/services/presets/create.py
index 490e9520d..b15c1ad15 100644
--- a/src/dstack/_internal/cli/services/presets/create.py
+++ b/src/dstack/_internal/cli/services/presets/create.py
@@ -32,7 +32,6 @@
run_preset_agent,
terminate_agent_process,
)
-from dstack._internal.cli.services.presets.build import preset_to_yaml_dict
from dstack._internal.cli.services.presets.prompt import get_preset_agent_system_prompt
from dstack._internal.cli.services.presets.redaction import (
contains_redacted_value,
@@ -585,7 +584,15 @@ async def _create_preset(
[
token,
(setup.auth.api_key if setup.auth is not None else None) or "",
- *preset_env.values(),
+ # Passthrough values are resolved from the caller's environment and
+ # are secrets; literal values are the user's own configuration text.
+ # The passthrough keys come from the source configuration, since
+ # `configuration` here is the resolved copy with no sentinels left.
+ *(
+ preset_env[key]
+ for key, value in source_configuration.env.items()
+ if isinstance(value, EnvSentinel) and key in preset_env
+ ),
*get_sensitive_inherited_env_values(),
]
)
@@ -670,7 +677,7 @@ async def _create_preset(
name=_read_claimed_name(session),
submitted_at=session.created_at,
)
- if contains_redacted_value(preset_to_yaml_dict(preset), redacted_values):
+ if contains_redacted_value(preset.model_dump(mode="json"), redacted_values):
raise CLIError("Generated preset contains a secret value")
preset_path = store.save(preset)
creation_succeeded = True
diff --git a/src/dstack/_internal/cli/services/presets/export.py b/src/dstack/_internal/cli/services/presets/export.py
index 213d01fa3..237f5ce59 100644
--- a/src/dstack/_internal/cli/services/presets/export.py
+++ b/src/dstack/_internal/cli/services/presets/export.py
@@ -4,22 +4,35 @@
import yaml
from dstack._internal.cli.models.presets import VerifiedPreset
-from dstack._internal.cli.services.presets.build import service_configuration_to_yaml_dict
-from dstack._internal.core.errors import CLIError
+from dstack._internal.core.errors import CLIError, ServerClientError
+from dstack._internal.core.services import validate_dstack_resource_name
+# TODO: Human-readable service serialization: short syntax, defaults dropped
def export_preset(
preset: VerifiedPreset,
*,
preset_dir: Path,
destination: Path,
force: bool,
+ name: str | None = None,
) -> list[Path]:
- """Writes the preset's service as a `type: service` configuration at
- `destination` and copies the files it references next to it, keeping their
- relative paths, so the result deploys with plain `dstack apply -f`.
- Returns every path written."""
+ """Writes the exact dump of the service at `destination`, changing only
+ `name` (from `name` or the preset's name) and the `files` paths; `gateway`
+ and profile params are unset by `VerifiedPreset`, not stripped here.
+ Files under `preset_dir` are copied next to `destination` at their
+ `preset_dir`-relative paths and `files` is rewritten to match; other files
+ pass through absolute. Fails before any write: invalid name, or existing
+ targets without `force`. Returns written paths."""
+ if name is None:
+ name = preset.name
+ if name is not None:
+ try:
+ validate_dstack_resource_name(name)
+ except ServerClientError as e:
+ raise CLIError(str(e)) from e
service = preset.service.model_copy(deep=True)
+ service.name = name
copies: list[tuple[Path, Path]] = []
for mapping in service.files:
source = Path(mapping.local_path)
@@ -36,12 +49,7 @@ def export_preset(
if target.exists():
raise CLIError(f"{target} already exists. Use --force to overwrite")
destination.parent.mkdir(parents=True, exist_ok=True)
- destination.write_text(
- yaml.safe_dump(
- {"type": "service", **service_configuration_to_yaml_dict(service)},
- sort_keys=False,
- )
- )
+ destination.write_text(yaml.safe_dump(service.model_dump(mode="json"), sort_keys=False))
for source, target in copies:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
diff --git a/src/dstack/_internal/cli/services/presets/store.py b/src/dstack/_internal/cli/services/presets/store.py
index 6787d43b4..f069f16a6 100644
--- a/src/dstack/_internal/cli/services/presets/store.py
+++ b/src/dstack/_internal/cli/services/presets/store.py
@@ -8,7 +8,6 @@
from pydantic import ValidationError
from dstack._internal.cli.models.presets import PRESET_EXCLUDED_FIELDS, VerifiedPreset
-from dstack._internal.cli.services.presets.build import preset_to_yaml_dict
from dstack._internal.cli.utils.common import warn
from dstack._internal.core.errors import CLIError, ConfigurationError
from dstack._internal.core.models.configurations import ServiceConfiguration
@@ -84,7 +83,7 @@ def save(self, preset: VerifiedPreset) -> Path:
preset = preset.model_copy(deep=True)
for mapping in preset.service.files:
mapping.local_path = _relative_to_preset_dir(mapping.local_path, directory)
- content = yaml.safe_dump(preset_to_yaml_dict(preset), sort_keys=False)
+ content = yaml.safe_dump(preset.model_dump(mode="json"), sort_keys=False)
fd, temporary_path = tempfile.mkstemp(
dir=directory,
prefix=f".{preset.id}.",
diff --git a/src/tests/_internal/cli/services/presets/test_create.py b/src/tests/_internal/cli/services/presets/test_create.py
index ba3098dc5..aa21760f6 100644
--- a/src/tests/_internal/cli/services/presets/test_create.py
+++ b/src/tests/_internal/cli/services/presets/test_create.py
@@ -309,6 +309,40 @@ async def cleanup_runs(**kwargs):
assert cleanup_calls == []
+ @pytest.mark.asyncio
+ async def test_redacts_resolved_passthrough_env_values(
+ self, creation_context, monkeypatch, tmp_path
+ ):
+ """A passthrough (`- LICENSE`) resolves from the caller's environment, so its
+ value is a secret and must reach the redactor. A literal must not: the saved
+ preset legitimately contains it."""
+ captured = {}
+
+ async def run_agent(**kwargs):
+ captured["redacted_values"] = kwargs["redacted_values"]
+ return PresetAgentProcessOutput(
+ report_data=json.loads(
+ get_successful_preset_report(creation_context.run).model_dump_json()
+ )
+ )
+
+ monkeypatch.setattr(
+ "dstack._internal.cli.services.presets.create.run_preset_agent",
+ run_agent,
+ )
+
+ await _create_preset(
+ api=creation_context.api,
+ configuration=creation_context.configuration,
+ source_configuration=creation_context.source_configuration,
+ store=creation_context.store,
+ build_name="qwen-build",
+ session=_agent_session(tmp_path),
+ )
+
+ assert "license-secret" in captured["redacted_values"]
+ assert "false" not in captured["redacted_values"]
+
@pytest.mark.parametrize(
("keep_service", "stopped_names"),
[(False, ["qwen-build-2"]), (True, [])],
diff --git a/src/tests/_internal/cli/services/presets/test_export.py b/src/tests/_internal/cli/services/presets/test_export.py
index 88187a8dd..123b8c62f 100644
--- a/src/tests/_internal/cli/services/presets/test_export.py
+++ b/src/tests/_internal/cli/services/presets/test_export.py
@@ -38,6 +38,8 @@ def test_exports_a_deployable_service_configuration_with_its_files(self, tmp_pat
]
data = yaml.safe_load(destination.read_text())
assert data["type"] == "service"
+ # An unnamed preset exports an unnamed service.
+ assert data["name"] is None
# Relative to the configuration file, which is how `dstack apply`
# resolves `files` paths.
assert data["files"] == [
@@ -48,6 +50,49 @@ def test_exports_a_deployable_service_configuration_with_its_files(self, tmp_pat
)
assert ServiceConfiguration.model_validate(data).model is not None
+ def test_names_the_service_after_the_preset(self, tmp_path: Path):
+ store = PresetStore(tmp_path / "presets")
+ preset = get_preset().model_copy(update={"name": "qwen-fast"})
+ preset_dir = store.save(preset).parent
+ destination = tmp_path / "qwen.dstack.yml"
+
+ export_preset(preset, preset_dir=preset_dir, destination=destination, force=False)
+
+ data = yaml.safe_load(destination.read_text())
+ assert data["name"] == "qwen-fast"
+
+ def test_names_the_service_after_the_name_option(self, tmp_path: Path):
+ store = PresetStore(tmp_path / "presets")
+ preset = get_preset().model_copy(update={"name": "qwen-fast"})
+ preset_dir = store.save(preset).parent
+ destination = tmp_path / "qwen.dstack.yml"
+
+ export_preset(
+ preset,
+ preset_dir=preset_dir,
+ destination=destination,
+ force=False,
+ name="qwen-prod",
+ )
+
+ assert yaml.safe_load(destination.read_text())["name"] == "qwen-prod"
+
+ def test_rejects_an_invalid_service_name(self, tmp_path: Path):
+ store = PresetStore(tmp_path / "presets")
+ preset = get_preset()
+ preset_dir = store.save(preset).parent
+ destination = tmp_path / "qwen.dstack.yml"
+
+ with pytest.raises(CLIError):
+ export_preset(
+ preset,
+ preset_dir=preset_dir,
+ destination=destination,
+ force=False,
+ name="Not_Valid!",
+ )
+ assert not destination.exists()
+
def test_refuses_to_overwrite_without_force(self, tmp_path: Path):
store = PresetStore(tmp_path / "presets")
preset_dir = store.save(get_preset()).parent
diff --git a/src/tests/_internal/cli/services/presets/test_store.py b/src/tests/_internal/cli/services/presets/test_store.py
index 18640a06c..0fababf9b 100644
--- a/src/tests/_internal/cli/services/presets/test_store.py
+++ b/src/tests/_internal/cli/services/presets/test_store.py
@@ -29,8 +29,7 @@ def test_saves_and_lists_self_contained_preset(self, tmp_path: Path):
assert data["id"] == preset.id
assert data["model"] == preset.model
assert data["submitted_at"] == "2026-01-02T03:04:00Z"
- # Wire-only: the stored file never carries `status`.
- assert "status" not in data
+ assert data["status"] == "verified"
assert "presets" not in data
assert store.list() == [preset]
assert store.get(preset.id) == preset
@@ -310,12 +309,12 @@ def test_preserves_literal_env_values(self, tmp_path: Path):
}
)
- path = store.save(preset)
- env = yaml.safe_load(path.read_text())["service"]["env"]
+ store.save(preset)
+ env = store.get(preset.id).service.env
- assert "TOKENIZERS_PARALLELISM=false" in env
- assert "MODEL_LABEL=monkey" in env
- assert "HF_TOKEN" in env
+ assert env["TOKENIZERS_PARALLELISM"] == "false"
+ assert env["MODEL_LABEL"] == "monkey"
+ assert env["HF_TOKEN"] == EnvSentinel(key="HF_TOKEN")
class TestParsePresetConfiguration: