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: