Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mkdocs/docs/concepts/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ Preset c83375b4 exported to qwen.dstack.yml (16 files). Deploy it with `dstack a

</div>

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`:

<div class="termy">

Expand Down
6 changes: 6 additions & 0 deletions src/dstack/_internal/cli/commands/preset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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}[/]"
Expand Down
8 changes: 6 additions & 2 deletions src/dstack/_internal/cli/models/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,12 @@ class VerifiedPreset(Preset):
context_length: PositiveInt
# The session's `trials/<n>` 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
Expand Down
38 changes: 2 additions & 36 deletions src/dstack/_internal/cli/services/presets/build.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Any, Optional, TypeVar
from typing import Optional, TypeVar

import gpuhunt

Expand All @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions src/dstack/_internal/cli/services/presets/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
]
)
Expand Down Expand Up @@ -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
Expand Down
32 changes: 20 additions & 12 deletions src/dstack/_internal/cli/services/presets/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions src/dstack/_internal/cli/services/presets/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}.",
Expand Down
34 changes: 34 additions & 0 deletions src/tests/_internal/cli/services/presets/test_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, [])],
Expand Down
45 changes: 45 additions & 0 deletions src/tests/_internal/cli/services/presets/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] == [
Expand All @@ -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
Expand Down
13 changes: 6 additions & 7 deletions src/tests/_internal/cli/services/presets/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading