diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 78fcd72c3..89451648d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -87,7 +87,6 @@ export const API = { RUN_GET_PLAN: (projectName: IProject['project_name']) => `${API.PROJECTS.RUNS(projectName)}/get_plan`, RUNS_DELETE: (projectName: IProject['project_name']) => `${API.PROJECTS.RUNS(projectName)}/delete`, RUNS_STOP: (projectName: IProject['project_name']) => `${API.PROJECTS.RUNS(projectName)}/stop`, - RUNS_SUBMIT: (projectName: IProject['project_name']) => `${API.PROJECTS.RUNS(projectName)}/submit`, RUNS_APPLY: (projectName: IProject['project_name']) => `${API.PROJECTS.RUNS(projectName)}/apply`, // Logs diff --git a/frontend/src/types/gateway.d.ts b/frontend/src/types/gateway.d.ts index 1442cf4d6..05d383e90 100644 --- a/frontend/src/types/gateway.d.ts +++ b/frontend/src/types/gateway.d.ts @@ -5,12 +5,10 @@ declare interface IGatewayReplica { } declare interface IGateway { - backend: string, name: string, project_name?: string, ip_address: string, instance_id: string, - region:string hostname?: string, wildcard_domain?: string default: boolean diff --git a/frontend/src/types/run.d.ts b/frontend/src/types/run.d.ts index 4f39d76ed..a47a7ae57 100644 --- a/frontend/src/types/run.d.ts +++ b/frontend/src/types/run.d.ts @@ -270,9 +270,6 @@ declare interface IResources { disk?: IDisk; cpu_arch?: string | null; - - /** @deprecated Use formatResources() from libs/resources instead. Remove in 0.21. */ - description?: string; } declare interface InstanceType { diff --git a/src/dstack/_internal/cli/commands/event.py b/src/dstack/_internal/cli/commands/event.py index 1b8a094ae..9b16bf3f3 100644 --- a/src/dstack/_internal/cli/commands/event.py +++ b/src/dstack/_internal/cli/commands/event.py @@ -11,7 +11,6 @@ from dstack._internal.cli.utils.common import ( get_start_time, ) -from dstack._internal.core.errors import CLIError from dstack._internal.core.models.common import EntityReference from dstack._internal.core.models.events import EventTargetType from dstack._internal.server.schemas.events import LIST_EVENTS_DEFAULT_LIMIT @@ -159,12 +158,6 @@ def _build_filters(args: argparse.Namespace, api: Client) -> EventListFilters: filters.target_gateways = [] for name in args.target_gateways: id = api.client.gateways.get(api.project, name).id - if id is None: - # TODO(0.21): Remove this check once `Gateway.id` is required. - raise CLIError( - "Cannot determine gateway ID, most likely due to an outdated dstack server." - " Update the server to 0.20.7 or higher or remove --target-gateway." - ) filters.target_gateways.append(id) elif args.target_secrets: filters.target_secrets = [ diff --git a/src/dstack/_internal/cli/services/configurators/gateway.py b/src/dstack/_internal/cli/services/configurators/gateway.py index 76472b11f..d13697866 100644 --- a/src/dstack/_internal/cli/services/configurators/gateway.py +++ b/src/dstack/_internal/cli/services/configurators/gateway.py @@ -12,7 +12,6 @@ from dstack._internal.cli.utils.gateway import get_gateways_table from dstack._internal.cli.utils.rich import MultiItemStatus from dstack._internal.core.errors import ( - MethodNotAllowedError, ResourceNotExistsError, ) from dstack._internal.core.models.common import ApplyAction @@ -29,7 +28,6 @@ from dstack._internal.utils.common import local_time from dstack._internal.utils.logging import get_logger from dstack._internal.utils.nested_list import NestedList, NestedListItem -from dstack.api._public import Client logger = get_logger(__name__) @@ -50,13 +48,7 @@ def apply_configuration( configuration_path=configuration_path, ) with console.status("Getting apply plan..."): - try: - plan = self.api.client.gateways.get_plan(project_name=self.api.project, spec=spec) - use_legacy_api = False - except MethodNotAllowedError: - # pre-0.20.27 server - plan = _get_plan_legacy(self.api, spec) - use_legacy_api = True + plan = self.api.client.gateways.get_plan(project_name=self.api.project, spec=spec) _print_plan_header(plan) action_message = "" @@ -123,19 +115,13 @@ def apply_configuration( time.sleep(1) with console.status("Applying plan..."): - if use_legacy_api: - gateway = self.api.client.gateways.create( - project_name=self.api.project, - configuration=conf, - ) - else: - gateway = self.api.client.gateways.apply_plan( - project_name=self.api.project, - plan=ApplyGatewayPlanInput( - spec=spec, - current_resource=plan.current_resource, - ), - ) + gateway = self.api.client.gateways.apply_plan( + project_name=self.api.project, + plan=ApplyGatewayPlanInput( + spec=spec, + current_resource=plan.current_resource, + ), + ) if plan.action == ApplyAction.UPDATE and delete_gateway_name is None: console.print(get_gateways_table([gateway], current_project=self.api.project)) @@ -222,27 +208,6 @@ def apply_args(self, conf: GatewayConfiguration, args: argparse.Namespace): conf.name = args.name -def _get_plan_legacy(api: Client, spec: GatewaySpec) -> GatewayPlan: - user = api.client.users.get_my_user() - current_resource = None - if spec.configuration.name is not None: - try: - current_resource = api.client.gateways.get( - project_name=api.project, - gateway_name=spec.configuration.name, - ) - except ResourceNotExistsError: - pass - return GatewayPlan( - project_name=api.project, - user=user.username, - spec=spec, - effective_spec=spec, - current_resource=current_resource, - action=ApplyAction.CREATE, - ) - - def _print_plan_header(plan: GatewayPlan): def th(s: str) -> str: return f"[bold]{s}[/bold]" diff --git a/src/dstack/_internal/cli/utils/gateway.py b/src/dstack/_internal/cli/utils/gateway.py index 704741c89..d966c68b1 100644 --- a/src/dstack/_internal/cli/utils/gateway.py +++ b/src/dstack/_internal/cli/utils/gateway.py @@ -30,12 +30,7 @@ def get_gateway_relative_to_project( # `get` would resolve `Gateway.default` relative to the gateway's host project gateways = client.list(project, include_imported=True) for gateway in gateways: - if gateway.name == gateway_name and ( - gateway_project == gateway.project_name - # Compatibility with pre-0.20.20 servers: - # gateway.project_name is None means the gateway is in the current `project` - or (gateway.project_name is None and gateway_project == project) - ): + if gateway.name == gateway_name and gateway_project == gateway.project_name: return gateway ref = EntityReference(name=gateway_name, project=gateway_project) raise ResourceNotExistsError(msg=f"Gateway {ref.format()!r} not found in project {project!r}") @@ -78,17 +73,11 @@ def get_gateways_table( for gateway in gateways: name = format_entity_reference( gateway.name, - # project_name == None means pre-0.20.20 server, which means no gateway exports support, - # which means the gateway is from the current project - gateway.project_name if gateway.project_name is not None else current_project, + gateway.project_name, current_project, ) domain = gateway.wildcard_domain - if ( - gateway.project_name is not None - and gateway.project_name != current_project - and domain is not None - ): + if gateway.project_name != current_project and domain is not None: domain = interpolate_gateway_domain( domain=domain, run_project_name=current_project, diff --git a/src/dstack/_internal/core/compatibility/common.py b/src/dstack/_internal/core/compatibility/common.py index 8a34b4057..57d4313b7 100644 --- a/src/dstack/_internal/core/compatibility/common.py +++ b/src/dstack/_internal/core/compatibility/common.py @@ -1,27 +1,9 @@ from typing import Optional -from dstack._internal.core.models.common import EntityReference, IncludeExcludeSetType +from dstack._internal.core.models.common import IncludeExcludeSetType from dstack._internal.core.models.profiles import ProfileParams def get_profile_excludes(profile: Optional[ProfileParams]) -> IncludeExcludeSetType: excludes: IncludeExcludeSetType = set() - if profile is None: - return excludes - if profile.backend_options is None: - excludes.add("backend_options") - if profile.instances is None: - excludes.add("instances") return excludes - - -def patch_profile_params(params: ProfileParams) -> None: - # If there are no project-prefixed fleets, replace all EntityReference with str - # for compatibility with pre-0.20.14 servers that don't support EntityReference. - if params.fleets is not None and all( - EntityReference.parse(f).project is None for f in params.fleets - ): - params.fleets = [ - fleet_ref.format() if isinstance(fleet_ref, EntityReference) else fleet_ref - for fleet_ref in params.fleets - ] diff --git a/src/dstack/_internal/core/compatibility/events.py b/src/dstack/_internal/core/compatibility/events.py index b28db1158..e053a0a2a 100644 --- a/src/dstack/_internal/core/compatibility/events.py +++ b/src/dstack/_internal/core/compatibility/events.py @@ -4,10 +4,4 @@ def get_list_events_excludes(request: ListEventsRequest) -> IncludeExcludeDictType: list_gpus_excludes: IncludeExcludeDictType = {} - if request.target_volumes is None: - list_gpus_excludes["target_volumes"] = True - if request.target_gateways is None: - list_gpus_excludes["target_gateways"] = True - if request.target_secrets is None: - list_gpus_excludes["target_secrets"] = True return list_gpus_excludes diff --git a/src/dstack/_internal/core/compatibility/exports.py b/src/dstack/_internal/core/compatibility/exports.py index 92f1d2dc6..cf6e66594 100644 --- a/src/dstack/_internal/core/compatibility/exports.py +++ b/src/dstack/_internal/core/compatibility/exports.py @@ -4,21 +4,9 @@ def get_create_export_excludes(request: CreateExportRequest) -> IncludeExcludeDictType: excludes: IncludeExcludeDictType = {} - if not request.is_global: - excludes["is_global"] = True - if not request.exported_gateways: - excludes["exported_gateways"] = True return excludes def get_update_export_excludes(request: UpdateExportRequest) -> IncludeExcludeDictType: excludes: IncludeExcludeDictType = {} - if not request.set_global: - excludes["set_global"] = True - if not request.unset_global: - excludes["unset_global"] = True - if not request.add_exported_gateways: - excludes["add_exported_gateways"] = True - if not request.remove_exported_gateways: - excludes["remove_exported_gateways"] = True return excludes diff --git a/src/dstack/_internal/core/compatibility/fleets.py b/src/dstack/_internal/core/compatibility/fleets.py index 36c933af4..ffa6592b9 100644 --- a/src/dstack/_internal/core/compatibility/fleets.py +++ b/src/dstack/_internal/core/compatibility/fleets.py @@ -1,6 +1,6 @@ from typing import Optional -from dstack._internal.core.compatibility.common import get_profile_excludes, patch_profile_params +from dstack._internal.core.compatibility.common import get_profile_excludes from dstack._internal.core.models.common import IncludeExcludeDictType from dstack._internal.core.models.fleets import ApplyFleetPlanInput, FleetSpec @@ -20,22 +20,19 @@ def get_apply_plan_excludes(plan_input: ApplyFleetPlanInput) -> IncludeExcludeDi apply_plan_excludes["spec"] = spec_excludes current_resource = plan_input.current_resource if current_resource is not None: - current_resource_excludes = {} + current_resource_excludes: IncludeExcludeDictType = {} current_resource_spec_excludes = get_fleet_spec_excludes(current_resource.spec) if current_resource_spec_excludes: current_resource_excludes["spec"] = current_resource_spec_excludes + # `Resources.description` is deprecated and never set since 0.21. Not sending it lets 0.22 + # drop the field without breaking 0.21 clients. + current_resource_excludes["instances"] = { + "__all__": {"instance_type": {"resources": {"description": True}}} + } apply_plan_excludes["current_resource"] = current_resource_excludes return {"plan": apply_plan_excludes} -def get_create_fleet_excludes(fleet_spec: FleetSpec) -> IncludeExcludeDictType: - create_fleet_excludes: IncludeExcludeDictType = {} - spec_excludes = get_fleet_spec_excludes(fleet_spec) - if spec_excludes: - create_fleet_excludes["spec"] = spec_excludes - return create_fleet_excludes - - def get_fleet_spec_excludes(fleet_spec: FleetSpec) -> Optional[IncludeExcludeDictType]: """ Returns `fleet_spec` exclude mapping to exclude certain fields from the request. @@ -43,21 +40,11 @@ def get_fleet_spec_excludes(fleet_spec: FleetSpec) -> Optional[IncludeExcludeDic clients backward-compatibility with older servers. """ spec_excludes: IncludeExcludeDictType = {} - configuration_excludes: IncludeExcludeDictType = {} profile_excludes = get_profile_excludes(fleet_spec.profile) spec_excludes["autocreated"] = True - if fleet_spec.configuration.backend_options is None: - configuration_excludes["backend_options"] = True - - if configuration_excludes: - spec_excludes["configuration"] = configuration_excludes if profile_excludes: spec_excludes["profile"] = profile_excludes if spec_excludes: return spec_excludes return None - - -def patch_fleet_spec(spec: FleetSpec) -> None: - patch_profile_params(spec.profile) diff --git a/src/dstack/_internal/core/compatibility/gateways.py b/src/dstack/_internal/core/compatibility/gateways.py index 8c0aa141c..6e72546f0 100644 --- a/src/dstack/_internal/core/compatibility/gateways.py +++ b/src/dstack/_internal/core/compatibility/gateways.py @@ -1,8 +1,21 @@ from dstack._internal.core.models.common import IncludeExcludeDictType -from dstack._internal.core.models.gateways import GatewayConfiguration, GatewaySpec +from dstack._internal.core.models.gateways import ( + ApplyGatewayPlanInput, + GatewayConfiguration, + GatewaySpec, +) from dstack._internal.server.schemas.gateways import SetDefaultGatewayRequest +def get_apply_plan_excludes(plan_input: ApplyGatewayPlanInput) -> IncludeExcludeDictType: + apply_plan_excludes: IncludeExcludeDictType = {} + if plan_input.current_resource is not None: + # `Gateway.backend` and `Gateway.region` are deprecated and never set since 0.21. + # Not sending them lets 0.22 drop the fields without breaking 0.21 clients. + apply_plan_excludes["current_resource"] = {"backend": True, "region": True} + return {"plan": apply_plan_excludes} + + def get_gateway_spec_excludes(gateway_spec: GatewaySpec) -> IncludeExcludeDictType: """ Returns `gateway_spec` exclude mapping to exclude certain fields from the request. @@ -29,8 +42,6 @@ def get_create_gateway_excludes(configuration: GatewayConfiguration) -> IncludeE def get_set_default_gateway_excludes(request: SetDefaultGatewayRequest) -> IncludeExcludeDictType: excludes: IncludeExcludeDictType = {} - if request.gateway_project is None: - excludes["gateway_project"] = True return excludes @@ -38,8 +49,4 @@ def _get_gateway_configuration_excludes( configuration: GatewayConfiguration, ) -> IncludeExcludeDictType: configuration_excludes: IncludeExcludeDictType = {} - - if configuration.replicas is None: - configuration_excludes["replicas"] = True - return configuration_excludes diff --git a/src/dstack/_internal/core/compatibility/gpus.py b/src/dstack/_internal/core/compatibility/gpus.py index ba33a4668..388500127 100644 --- a/src/dstack/_internal/core/compatibility/gpus.py +++ b/src/dstack/_internal/core/compatibility/gpus.py @@ -7,10 +7,6 @@ def get_list_gpus_excludes(request: ListGpusRequest) -> Optional[IncludeExcludeDictType]: list_gpus_excludes: IncludeExcludeDictType = {} - if not request.full_offers: - list_gpus_excludes["full_offers"] = True - if not request.unallocated_resources: - list_gpus_excludes["unallocated_resources"] = True run_spec_excludes = get_run_spec_excludes(request.run_spec) if run_spec_excludes is not None: list_gpus_excludes["run_spec"] = run_spec_excludes diff --git a/src/dstack/_internal/core/compatibility/runs.py b/src/dstack/_internal/core/compatibility/runs.py index b8edc35c7..33cf19a06 100644 --- a/src/dstack/_internal/core/compatibility/runs.py +++ b/src/dstack/_internal/core/compatibility/runs.py @@ -1,17 +1,11 @@ from typing import Optional -from dstack._internal.core.compatibility.common import get_profile_excludes, patch_profile_params +from dstack._internal.core.compatibility.common import get_profile_excludes from dstack._internal.core.models.common import ( - EntityReference, IncludeExcludeDictType, IncludeExcludeSetType, ) -from dstack._internal.core.models.configurations import ( - ServiceConfiguration, -) from dstack._internal.core.models.runs import ( - DEFAULT_PROBE_UNTIL_READY, - DEFAULT_REPLICA_GROUP_NAME, ApplyRunPlanInput, JobSpec, JobSubmission, @@ -69,10 +63,6 @@ def get_get_plan_excludes(request: GetRunPlanRequest) -> Optional[IncludeExclude clients backward-compatibility with older servers. """ get_plan_excludes: IncludeExcludeDictType = {} - if not request.full_offers: - get_plan_excludes["full_offers"] = True - if not request.unallocated_resources: - get_plan_excludes["unallocated_resources"] = True run_spec_excludes = get_run_spec_excludes(request.run_spec) if run_spec_excludes is not None: get_plan_excludes["run_spec"] = run_spec_excludes @@ -90,54 +80,6 @@ def get_run_spec_excludes(run_spec: RunSpec) -> IncludeExcludeDictType: profile_excludes = get_profile_excludes(run_spec.profile) for field in get_profile_excludes(run_spec.configuration): configuration_excludes[field] = True - - if run_spec.configuration.backend_options is None: - configuration_excludes["backend_options"] = True - - if not run_spec.configuration.dstack: - configuration_excludes["dstack"] = True - - if isinstance(run_spec.configuration, ServiceConfiguration): - if run_spec.configuration.probes: - probe_excludes: IncludeExcludeDictType = {} - configuration_excludes["probes"] = {"__all__": probe_excludes} - if all(p.until_ready is None for p in run_spec.configuration.probes): - probe_excludes["until_ready"] = True - elif run_spec.configuration.probes is None: - # Servers prior to 0.20.8 do not support probes=None - configuration_excludes["probes"] = True - - if run_spec.configuration.https is None: - configuration_excludes["https"] = True - - replicas = run_spec.configuration.replicas - if isinstance(replicas, list): - replica_group_excludes: IncludeExcludeDictType = {} - if all(g.router is None for g in replicas): - replica_group_excludes["router"] = True - if all(g.scaling is None or g.scaling.window is None for g in replicas): - replica_group_excludes["scaling"] = {"window": True} - if all(g.image is None for g in replicas): - replica_group_excludes["image"] = True - if all(g.docker is None for g in replicas): - replica_group_excludes["docker"] = True - if all(g.python is None for g in replicas): - replica_group_excludes["python"] = True - if all(g.nvcc is None for g in replicas): - replica_group_excludes["nvcc"] = True - if all(g.privileged is None for g in replicas): - replica_group_excludes["privileged"] = True - if all(g.spot_policy is None for g in replicas): - replica_group_excludes["spot_policy"] = True - if all(g.reservation is None for g in replicas): - replica_group_excludes["reservation"] = True - if replica_group_excludes: - configuration_excludes["replicas"] = {"__all__": replica_group_excludes} - - scaling = run_spec.configuration.scaling - if scaling is not None and scaling.window is None: - configuration_excludes["scaling"] = {"window": True} - if configuration_excludes: spec_excludes["configuration"] = configuration_excludes if profile_excludes: @@ -152,48 +94,14 @@ def get_job_spec_excludes(job_specs: list[JobSpec]) -> IncludeExcludeDictType: clients backward-compatibility with older servers. """ spec_excludes: IncludeExcludeDictType = {} - if all(s.replica_group == DEFAULT_REPLICA_GROUP_NAME for s in job_specs): - spec_excludes["replica_group"] = True - - probe_excludes: IncludeExcludeDictType = {} - spec_excludes["probes"] = {"__all__": probe_excludes} - if all(all(p.until_ready == DEFAULT_PROBE_UNTIL_READY for p in s.probes) for s in job_specs): - probe_excludes["until_ready"] = True - - if all(s.requirements.backend_options is None for s in job_specs): - spec_excludes["requirements"] = {"backend_options": True} - return spec_excludes def get_job_submission_excludes(job_submissions: list[JobSubmission]) -> IncludeExcludeDictType: submission_excludes: IncludeExcludeDictType = {} - - if any(s.job_runtime_data is not None for s in job_submissions): - jrd_excludes = {} - if all( - s.job_runtime_data is None or s.job_runtime_data.username is None - for s in job_submissions - ): - jrd_excludes["username"] = True - if all( - s.job_runtime_data is None or s.job_runtime_data.working_dir is None - for s in job_submissions - ): - jrd_excludes["working_dir"] = True - submission_excludes["job_runtime_data"] = jrd_excludes - - if all(s.image_pull_progress is None for s in job_submissions): - submission_excludes["image_pull_progress"] = True - + # `Resources.description` is deprecated and never set since 0.21. Not sending it lets 0.22 + # drop the field without breaking 0.21 clients. + submission_excludes["job_provisioning_data"] = { + "instance_type": {"resources": {"description": True}} + } return submission_excludes - - -def patch_run_spec(run_spec: RunSpec) -> None: - patch_profile_params(run_spec.configuration) - if run_spec.profile is not None: - patch_profile_params(run_spec.profile) - if isinstance(run_spec.configuration, ServiceConfiguration): - if isinstance(run_spec.configuration.gateway, EntityReference): - # Pre-0.20.20 servers do not support `EntityReference` in `gateway` - run_spec.configuration.gateway = run_spec.configuration.gateway.format() diff --git a/src/dstack/_internal/core/compatibility/volumes.py b/src/dstack/_internal/core/compatibility/volumes.py index a0afabf1c..63d530c4a 100644 --- a/src/dstack/_internal/core/compatibility/volumes.py +++ b/src/dstack/_internal/core/compatibility/volumes.py @@ -1,7 +1,6 @@ from dstack._internal.core.models.common import IncludeExcludeDictType from dstack._internal.core.models.volumes import ( AnyVolumeConfiguration, - KubernetesVolumeConfiguration, VolumeSpec, ) @@ -32,11 +31,4 @@ def _get_volume_configuration_excludes( configuration: AnyVolumeConfiguration, ) -> IncludeExcludeDictType: configuration_excludes: IncludeExcludeDictType = {} - - if isinstance(configuration, KubernetesVolumeConfiguration): - if not configuration.read_only: - configuration_excludes["read_only"] = True - if configuration.region == "": - configuration_excludes["region"] = True - return configuration_excludes diff --git a/src/dstack/_internal/core/models/gateways.py b/src/dstack/_internal/core/models/gateways.py index 4daf17dac..02b3e1c32 100644 --- a/src/dstack/_internal/core/models/gateways.py +++ b/src/dstack/_internal/core/models/gateways.py @@ -121,18 +121,14 @@ class GatewayReplica(CoreModel): backend: Optional[BackendType] = None region: Optional[str] = None created_at: datetime.datetime - status: Optional[GatewayReplicaStatus] = None - """`status` is only optional on the client side for compatibility with 0.20.25 and 0.20.26 servers""" + status: GatewayReplicaStatus status_message: Optional[str] = None class Gateway(CoreModel): - # TODO(0.21): Make `id` required. - id: Optional[uuid.UUID] = None - """`id` is only optional on the client side for compatibility with pre-0.20.7 servers.""" + id: uuid.UUID name: str - project_name: Optional[str] = None - """`project_name` is only optional on the client side for compatibility with pre-0.20.20 servers.""" + project_name: str configuration: GatewayConfiguration created_at: datetime.datetime status: GatewayStatus @@ -145,16 +141,13 @@ class Gateway(CoreModel): wildcard_domain: Optional[str] = None default: bool replicas: list[GatewayReplica] = [] + # TODO: remove `backend` and `region` in 0.22. backend: Optional[BackendType] = None - """`backend` duplicates a configuration field on the top level for backward compatibility - with 0.19.x clients that expect it to be required. - Remove after 0.21. + """Never set since 0.21, use `configuration.backend`. Kept because pre-0.21 clients echo it + back inside `current_resource` on apply, and requests reject extra fields. """ region: Optional[str] = None - """`region` duplicates a configuration field on the top level for backward compatibility - with 0.19.x clients that expect it to be required. - Remove after 0.21. - """ + """Never set since 0.21, use `configuration.region`. See `backend`.""" ip_address: Optional[str] = None """Deprecated in favor of `replicas[i].hostname`, only set for pre-0.20.25 clients.""" instance_id: Optional[str] = None diff --git a/src/dstack/_internal/core/models/instances.py b/src/dstack/_internal/core/models/instances.py index 38fc80310..d4f5900eb 100644 --- a/src/dstack/_internal/core/models/instances.py +++ b/src/dstack/_internal/core/models/instances.py @@ -1,10 +1,10 @@ import datetime from enum import Enum -from typing import Annotated, Any, Dict, List, Optional +from typing import Any, Dict, List, Optional from uuid import UUID import gpuhunt -from pydantic import Field, model_validator +from pydantic import model_validator from dstack._internal.core.models.backends.base import BackendType from dstack._internal.core.models.common import ( @@ -66,11 +66,11 @@ class Resources(CoreModel): disk: Disk = Disk(size_mib=102400) """`disk` defaults to 100GB for backward compatibility.""" cpu_arch: Optional[gpuhunt.CPUArchitecture] = None - # TODO: remove `description` in 0.21. - description: Annotated[ - str, - Field(description="Deprecated: generated client-side. Will be removed in 0.21."), - ] = "" + # TODO: remove `description` in 0.22. + description: str = "" + """Never set by the server since 0.21. Kept because pre-0.21 clients echo it back inside + `current_resource` on apply, and requests reject extra fields. + """ def pretty_format(self, include_spot: bool = False, gpu_only: bool = False) -> str: return Resources._pretty_format( diff --git a/src/dstack/_internal/server/compatibility/common.py b/src/dstack/_internal/server/compatibility/common.py index ce982b673..8f76d189e 100644 --- a/src/dstack/_internal/server/compatibility/common.py +++ b/src/dstack/_internal/server/compatibility/common.py @@ -3,10 +3,6 @@ from packaging.version import Version from dstack._internal.core.models.common import EntityReference -from dstack._internal.core.models.instances import ( - InstanceAvailability, - InstanceOfferWithAvailability, -) from dstack._internal.core.models.profiles import ProfileParams @@ -19,15 +15,3 @@ def patch_profile_params(params: ProfileParams, client_version: Optional[Version fleet_ref.format() if isinstance(fleet_ref, EntityReference) else fleet_ref for fleet_ref in params.fleets ] - - -def patch_offers_list( - offers: list[InstanceOfferWithAvailability], client_version: Optional[Version] -) -> None: - if client_version is None: - return - # CLIs prior to 0.20.4 incorrectly display the `no_balance` availability in the run/fleet plan - if client_version < Version("0.20.4"): - for offer in offers: - if offer.availability == InstanceAvailability.NO_BALANCE: - offer.availability = InstanceAvailability.NOT_AVAILABLE diff --git a/src/dstack/_internal/server/compatibility/fleets.py b/src/dstack/_internal/server/compatibility/fleets.py index ddd90d14d..e39233a72 100644 --- a/src/dstack/_internal/server/compatibility/fleets.py +++ b/src/dstack/_internal/server/compatibility/fleets.py @@ -3,7 +3,7 @@ from packaging.version import Version from dstack._internal.core.models.fleets import Fleet, FleetPlan, FleetSpec -from dstack._internal.server.compatibility.common import patch_offers_list, patch_profile_params +from dstack._internal.server.compatibility.common import patch_profile_params def patch_fleet_plan(fleet_plan: FleetPlan, client_version: Optional[Version]) -> None: @@ -12,7 +12,6 @@ def patch_fleet_plan(fleet_plan: FleetPlan, client_version: Optional[Version]) - patch_fleet_spec(fleet_plan.effective_spec, client_version) if fleet_plan.current_resource is not None: patch_fleet(fleet_plan.current_resource, client_version) - patch_offers_list(fleet_plan.offers, client_version) def patch_fleet(fleet: Fleet, client_version: Optional[Version]) -> None: diff --git a/src/dstack/_internal/server/compatibility/gpus.py b/src/dstack/_internal/server/compatibility/gpus.py deleted file mode 100644 index 8548e58bf..000000000 --- a/src/dstack/_internal/server/compatibility/gpus.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Optional - -from packaging.version import Version - -from dstack._internal.core.models.instances import InstanceAvailability -from dstack._internal.server.schemas.gpus import ListGpusResponse - - -def patch_list_gpus_response( - response: ListGpusResponse, client_version: Optional[Version] -) -> None: - if client_version is None: - return - # CLIs prior to 0.20.4 incorrectly display the `no_balance` availability in `dstack offer --group-by gpu` - if client_version < Version("0.20.4"): - for gpu in response.gpus: - if InstanceAvailability.NO_BALANCE in gpu.availability: - gpu.availability = [ - a for a in gpu.availability if a != InstanceAvailability.NO_BALANCE - ] - if InstanceAvailability.NOT_AVAILABLE not in gpu.availability: - gpu.availability.append(InstanceAvailability.NOT_AVAILABLE) diff --git a/src/dstack/_internal/server/compatibility/runs.py b/src/dstack/_internal/server/compatibility/runs.py index 39a0521ca..b17931971 100644 --- a/src/dstack/_internal/server/compatibility/runs.py +++ b/src/dstack/_internal/server/compatibility/runs.py @@ -5,7 +5,7 @@ from dstack._internal.core.models.common import EntityReference from dstack._internal.core.models.configurations import SERVICE_HTTPS_DEFAULT, ServiceConfiguration from dstack._internal.core.models.runs import Run, RunPlan, RunSpec -from dstack._internal.server.compatibility.common import patch_offers_list, patch_profile_params +from dstack._internal.server.compatibility.common import patch_profile_params def patch_run_plan(run_plan: RunPlan, client_version: Optional[Version]) -> None: @@ -16,8 +16,6 @@ def patch_run_plan(run_plan: RunPlan, client_version: Optional[Version]) -> None patch_run_spec(run_plan.effective_run_spec, client_version) if run_plan.current_resource is not None: patch_run(run_plan.current_resource, client_version) - for job_plan in run_plan.job_plans: - patch_offers_list(job_plan.offers, client_version) def patch_run(run: Run, client_version: Optional[Version]) -> None: diff --git a/src/dstack/_internal/server/routers/fleets.py b/src/dstack/_internal/server/routers/fleets.py index 5abf7ce19..cd18c3b46 100644 --- a/src/dstack/_internal/server/routers/fleets.py +++ b/src/dstack/_internal/server/routers/fleets.py @@ -13,7 +13,6 @@ from dstack._internal.server.models import ProjectModel, UserModel from dstack._internal.server.schemas.fleets import ( ApplyFleetPlanRequest, - CreateFleetRequest, DeleteFleetInstancesRequest, DeleteFleetsRequest, GetFleetPlanRequest, @@ -172,29 +171,6 @@ async def apply_plan( return CustomJSONResponse(fleet) -@project_router.post("/create", summary="Create fleet", response_model=Fleet, deprecated=True) -async def create_fleet( - body: CreateFleetRequest, - session: AsyncSession = Depends(get_session), - user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), - pipeline_hinter: PipelineHinterProtocol = Depends(get_pipeline_hinter), - client_version: Optional[Version] = Depends(get_client_version), -): - """ - Creates a fleet given a fleet configuration. - """ - user, project = user_project - fleet = await fleets_services.create_fleet( - session=session, - project=project, - user=user, - spec=body.spec, - pipeline_hinter=pipeline_hinter, - ) - patch_fleet(fleet, client_version) - return CustomJSONResponse(fleet) - - @project_router.post("/delete", summary="Delete fleets") async def delete_fleets( body: DeleteFleetsRequest, diff --git a/src/dstack/_internal/server/routers/gpus.py b/src/dstack/_internal/server/routers/gpus.py index 0866beb09..37853e3bb 100644 --- a/src/dstack/_internal/server/routers/gpus.py +++ b/src/dstack/_internal/server/routers/gpus.py @@ -1,10 +1,8 @@ -from typing import Annotated, Optional, Tuple +from typing import Annotated, Tuple from fastapi import APIRouter, Depends -from packaging.version import Version from sqlalchemy.ext.asyncio import AsyncSession -from dstack._internal.server.compatibility.gpus import patch_list_gpus_response from dstack._internal.server.db import get_session from dstack._internal.server.models import ProjectModel, UserModel from dstack._internal.server.schemas.gpus import ListGpusRequest, ListGpusResponse @@ -12,7 +10,6 @@ from dstack._internal.server.services.gpus import list_gpus_grouped from dstack._internal.server.utils.routers import ( get_base_api_additional_responses, - get_client_version, ) project_router = APIRouter( @@ -28,7 +25,6 @@ async def list_gpus( body: ListGpusRequest, session: Annotated[AsyncSession, Depends(get_session)], - client_version: Annotated[Optional[Version], Depends(get_client_version)], user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), ) -> ListGpusResponse: _, project = user_project @@ -40,5 +36,4 @@ async def list_gpus( full_offers=body.full_offers, unallocated_resources=body.unallocated_resources, ) - patch_list_gpus_response(resp, client_version) return resp diff --git a/src/dstack/_internal/server/routers/runs.py b/src/dstack/_internal/server/routers/runs.py index a2b68f490..91d4a4794 100644 --- a/src/dstack/_internal/server/routers/runs.py +++ b/src/dstack/_internal/server/routers/runs.py @@ -21,7 +21,6 @@ GetRunRequest, ListRunsRequest, StopRunsRequest, - SubmitRunRequest, ) from dstack._internal.server.security.permissions import Authenticated, ProjectMember from dstack._internal.server.services import runs, users @@ -219,21 +218,3 @@ async def delete_runs( """ user, project = user_project await runs.delete_runs(session=session, user=user, project=project, runs_names=body.runs_names) - - -# apply_plan replaces submit_run since it can create new runs. -@project_router.post("/submit", deprecated=True) -async def submit_run( - body: SubmitRunRequest, - session: AsyncSession = Depends(get_session), - user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), - pipeline_hinter: PipelineHinterProtocol = Depends(get_pipeline_hinter), -) -> Run: - user, project = user_project - return await runs.submit_run( - session=session, - user=user, - project=project, - run_spec=body.run_spec, - pipeline_hinter=pipeline_hinter, - ) diff --git a/src/dstack/_internal/server/schemas/fleets.py b/src/dstack/_internal/server/schemas/fleets.py index f3edd3eae..297492f71 100644 --- a/src/dstack/_internal/server/schemas/fleets.py +++ b/src/dstack/_internal/server/schemas/fleets.py @@ -51,10 +51,6 @@ class ApplyFleetPlanRequest(CoreModel): ] -class CreateFleetRequest(CoreModel): - spec: FleetSpec - - class DeleteFleetsRequest(CoreModel): names: List[str] diff --git a/src/dstack/_internal/server/schemas/runs.py b/src/dstack/_internal/server/schemas/runs.py index fcfca906f..e21962b14 100644 --- a/src/dstack/_internal/server/schemas/runs.py +++ b/src/dstack/_internal/server/schemas/runs.py @@ -60,10 +60,6 @@ class GetRunPlanRequest(CoreModel): ] = False -class SubmitRunRequest(CoreModel): - run_spec: RunSpec - - class ApplyRunPlanRequest(CoreModel): plan: ApplyRunPlanInput force: Annotated[ diff --git a/src/dstack/_internal/server/services/gateways/__init__.py b/src/dstack/_internal/server/services/gateways/__init__.py index 225f7355d..dddf5f919 100644 --- a/src/dstack/_internal/server/services/gateways/__init__.py +++ b/src/dstack/_internal/server/services/gateways/__init__.py @@ -931,8 +931,6 @@ def gateway_model_to_gateway( name=gateway_model.name, project_name=gateway_model.project.name, hostname=gateway_model.hostname, - backend=gateway_model.backend.type, - region=gateway_model.region, wildcard_domain=gateway_model.wildcard_domain, default=is_default, created_at=gateway_model.created_at, diff --git a/src/dstack/_internal/server/services/jobs/__init__.py b/src/dstack/_internal/server/services/jobs/__init__.py index 24ceec355..cff90a97e 100644 --- a/src/dstack/_internal/server/services/jobs/__init__.py +++ b/src/dstack/_internal/server/services/jobs/__init__.py @@ -236,10 +236,6 @@ def job_model_to_job_submission( ) -> JobSubmission: job_provisioning_data = get_job_provisioning_data(job_model) if job_provisioning_data is not None: - # TODO remove after transitioning to computed fields - job_provisioning_data.instance_type.resources.description = ( - job_provisioning_data.instance_type.resources.pretty_format() - ) # TODO do we really still need this magic? See https://github.com/dstackai/dstack/pull/1682 # i.e., replacing `jpd.backend` with `jpd.get_base_backend()` should give the same result if ( diff --git a/src/dstack/_internal/server/services/offers.py b/src/dstack/_internal/server/services/offers.py index 0aaa7f3a1..cb50fe044 100644 --- a/src/dstack/_internal/server/services/offers.py +++ b/src/dstack/_internal/server/services/offers.py @@ -164,7 +164,6 @@ def generate_shared_offer( gpus=full_resources.gpus[: len(full_resources.gpus) // total_blocks * blocks], spot=full_resources.spot, disk=full_resources.disk, - description=full_resources.description, ) return InstanceOfferWithAvailability( backend=offer.backend, diff --git a/src/dstack/_internal/server/testing/common.py b/src/dstack/_internal/server/testing/common.py index 46b51a189..46969a8e0 100644 --- a/src/dstack/_internal/server/testing/common.py +++ b/src/dstack/_internal/server/testing/common.py @@ -1019,7 +1019,6 @@ def get_instance_offer_with_availability( gpus=gpus, spot=spot, disk=Disk(size_mib=int(disk_gib * 1024)), - description="", ), ), region=region, diff --git a/src/dstack/api/server/_fleets.py b/src/dstack/api/server/_fleets.py index e4780890e..c0bb3d548 100644 --- a/src/dstack/api/server/_fleets.py +++ b/src/dstack/api/server/_fleets.py @@ -4,15 +4,12 @@ from dstack._internal.core.compatibility.fleets import ( get_apply_plan_excludes, - get_create_fleet_excludes, get_get_plan_excludes, - patch_fleet_spec, ) from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.fleets import ApplyFleetPlanInput, Fleet, FleetPlan, FleetSpec from dstack._internal.server.schemas.fleets import ( ApplyFleetPlanRequest, - CreateFleetRequest, DeleteFleetInstancesRequest, DeleteFleetsRequest, GetFleetPlanRequest, @@ -51,7 +48,6 @@ def get_plan( ) -> FleetPlan: body = GetFleetPlanRequest(spec=spec) body = copy.deepcopy(body) - patch_fleet_spec(body.spec) body_json = body.model_dump_json(exclude=get_get_plan_excludes(spec)) resp = self._request(f"/api/project/{project_name}/fleets/get_plan", body=body_json) return validate_extra_ignore(FleetPlan, resp.json()) @@ -65,9 +61,6 @@ def apply_plan( plan_input = validate_extra_ignore(ApplyFleetPlanInput, plan) body = ApplyFleetPlanRequest(plan=plan_input, force=force) body = copy.deepcopy(body) - patch_fleet_spec(body.plan.spec) - if body.plan.current_resource is not None: - patch_fleet_spec(body.plan.current_resource.spec) body_json = body.model_dump_json(exclude=get_apply_plan_excludes(plan_input)) resp = self._request(f"/api/project/{project_name}/fleets/apply", body=body_json) return validate_extra_ignore(Fleet, resp.json()) @@ -81,17 +74,3 @@ def delete_instances(self, project_name: str, name: str, instance_nums: List[int self._request( f"/api/project/{project_name}/fleets/delete_instances", body=body.model_dump_json() ) - - # Deprecated - # TODO: Remove in 0.21 - def create( - self, - project_name: str, - spec: FleetSpec, - ) -> Fleet: - body = CreateFleetRequest(spec=spec) - body = copy.deepcopy(body) - patch_fleet_spec(body.spec) - body_json = body.model_dump_json(exclude=get_create_fleet_excludes(spec)) - resp = self._request(f"/api/project/{project_name}/fleets/create", body=body_json) - return validate_extra_ignore(Fleet, resp.json()) diff --git a/src/dstack/api/server/_gateways.py b/src/dstack/api/server/_gateways.py index 715be0f5a..31c351459 100644 --- a/src/dstack/api/server/_gateways.py +++ b/src/dstack/api/server/_gateways.py @@ -1,6 +1,7 @@ from typing import List, Optional from dstack._internal.core.compatibility.gateways import ( + get_apply_plan_excludes, get_create_gateway_excludes, get_set_default_gateway_excludes, ) @@ -54,7 +55,8 @@ def apply_plan( ) -> Gateway: body = ApplyGatewayPlanRequest(plan=plan, force=force) resp = self._request( - f"/api/project/{project_name}/gateways/apply", body=body.model_dump_json() + f"/api/project/{project_name}/gateways/apply", + body=body.model_dump_json(exclude=get_apply_plan_excludes(plan)), ) return validate_extra_ignore(Gateway, resp.json()) @@ -77,8 +79,6 @@ def delete(self, project_name: str, gateways_names: List[str]) -> None: def set_default( self, project_name: str, gateway_name: str, *, gateway_project: Optional[str] = None ) -> None: - if gateway_project == project_name: - gateway_project = None # omit for compatibility with pre-0.20.20 servers body = SetDefaultGatewayRequest(name=gateway_name, gateway_project=gateway_project) self._request( f"/api/project/{project_name}/gateways/set_default", diff --git a/src/dstack/api/server/_projects.py b/src/dstack/api/server/_projects.py index 8feff6630..79f9c06db 100644 --- a/src/dstack/api/server/_projects.py +++ b/src/dstack/api/server/_projects.py @@ -62,7 +62,7 @@ def list( limit: Optional[int] = None, ascending: Optional[bool] = None, ) -> ProjectsInfoListOrProjectsList: - # Passing only non-None fields for backward compatibility with 0.20 servers. + # `None` means "use the server default", so unset fields are omitted from the request. body: dict[str, Any] = { "include_not_joined": include_not_joined, } diff --git a/src/dstack/api/server/_runs.py b/src/dstack/api/server/_runs.py index 9790c3987..5ec2a8e59 100644 --- a/src/dstack/api/server/_runs.py +++ b/src/dstack/api/server/_runs.py @@ -7,7 +7,6 @@ get_apply_plan_excludes, get_get_plan_excludes, get_list_runs_excludes, - patch_run_spec, ) from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.runs import ( @@ -87,7 +86,6 @@ def get_plan( for_offers_only=for_offers_only, ) body = copy.deepcopy(body) - patch_run_spec(body.run_spec) resp = self._request( f"/api/project/{project_name}/runs/get_plan", body=body.model_dump_json(exclude=get_get_plan_excludes(body)), @@ -103,9 +101,6 @@ def apply_plan( plan_input = validate_extra_ignore(ApplyRunPlanInput, plan) body = ApplyRunPlanRequest(plan=plan_input, force=force) body = copy.deepcopy(body) - patch_run_spec(body.plan.run_spec) - if body.plan.current_resource is not None: - patch_run_spec(body.plan.current_resource.run_spec) resp = self._request( f"/api/project/{project_name}/runs/apply", body=body.model_dump_json(exclude=get_apply_plan_excludes(plan_input)), diff --git a/src/dstack/api/server/_users.py b/src/dstack/api/server/_users.py index 91ac21853..b68e3fb0e 100644 --- a/src/dstack/api/server/_users.py +++ b/src/dstack/api/server/_users.py @@ -31,7 +31,7 @@ def list( limit: Optional[int] = None, ascending: Optional[bool] = None, ) -> UsersInfoListOrUsersList: - # Passing only non-None fields for backward compatibility with 0.20 servers. + # `None` means "use the server default", so unset fields are omitted from the request. body: dict[str, Any] = {} if return_total_count is not None: body["return_total_count"] = return_total_count @@ -45,10 +45,7 @@ def list( body["limit"] = limit if ascending is not None: body["ascending"] = ascending - if body: - resp = self._request("/api/users/list", body=to_json(body)) - else: - resp = self._request("/api/users/list") + resp = self._request("/api/users/list", body=to_json(body)) resp_json = resp.json() if isinstance(resp_json, list): return validate_extra_ignore(List[User], resp_json) diff --git a/src/tests/_internal/core/models/test_profiles.py b/src/tests/_internal/core/models/test_profiles.py index fc789c825..eee809d67 100644 --- a/src/tests/_internal/core/models/test_profiles.py +++ b/src/tests/_internal/core/models/test_profiles.py @@ -2,7 +2,6 @@ from pydantic import ValidationError from dstack._internal.core.backends.vastai.profile_options import VastAIProfileOptions -from dstack._internal.core.compatibility.common import get_profile_excludes from dstack._internal.core.models.common import EntityReference from dstack._internal.core.models.profiles import ( FleetInstanceSelector, @@ -94,17 +93,3 @@ def test_invalid_selector_is_rejected(self, value): def test_empty_instances_list_is_rejected(self): with pytest.raises(ValidationError): Profile.model_validate({"instances": []}) - - -class TestProfileInstancesCompatibilityExcludes: - def test_excludes_unset_instances(self): - profile = Profile() - - assert "instances" not in profile.model_dump(exclude=get_profile_excludes(profile)) - - def test_preserves_configured_instances(self): - profile = Profile(instances=[InstanceNameSelector(name="my-fleet-1")]) - - assert profile.model_dump(exclude=get_profile_excludes(profile))["instances"] == [ - {"name": "my-fleet-1"} - ] diff --git a/src/tests/_internal/core/models/test_runs.py b/src/tests/_internal/core/models/test_runs.py index 6380f4694..1f8215c9d 100644 --- a/src/tests/_internal/core/models/test_runs.py +++ b/src/tests/_internal/core/models/test_runs.py @@ -1,13 +1,7 @@ import pytest from pydantic import ValidationError -from dstack._internal.core.compatibility.runs import get_run_spec_excludes from dstack._internal.core.models.common import validate_extra_ignore -from dstack._internal.core.models.configurations import ( - DevEnvironmentConfiguration, - ServiceConfiguration, - TaskConfiguration, -) from dstack._internal.core.models.profiles import ( CreationPolicy, Profile, @@ -35,23 +29,6 @@ def test_run_termination_reason_to_status_works_with_all_enum_variants(): assert isinstance(run_status, RunStatus) -@pytest.mark.parametrize("configuration_type", ["task", "dev-environment", "service"]) -@pytest.mark.parametrize("dstack", [False, True]) -def test_server_access_run_spec_compatibility(configuration_type: str, dstack: bool): - if configuration_type == "task": - configuration = TaskConfiguration(commands=["true"], dstack=dstack) - elif configuration_type == "service": - configuration = ServiceConfiguration(commands=["true"], port=8000, dstack=dstack) - else: - configuration = DevEnvironmentConfiguration(dstack=dstack) - configuration_excludes = get_run_spec_excludes(RunSpec(configuration=configuration)).get( - "configuration" - ) - - assert isinstance(configuration_excludes, dict) - assert ("dstack" in configuration_excludes) is not dstack - - def test_job_termination_reason_to_status_works_with_all_enum_variants(): for job_termination_reason in JobTerminationReason: job_status = job_termination_reason.to_status() diff --git a/src/tests/_internal/server/compatibility/test_gateways.py b/src/tests/_internal/server/compatibility/test_gateways.py index ed5e929c1..ec45bc71b 100644 --- a/src/tests/_internal/server/compatibility/test_gateways.py +++ b/src/tests/_internal/server/compatibility/test_gateways.py @@ -34,8 +34,6 @@ def _make_gateway(replicas=None, hostname=None) -> Gateway: id=uuid.uuid4(), name="test", project_name="proj", - backend=BackendType.AWS, - region="us", created_at=_CREATED_AT, status=GatewayStatus.RUNNING, status_message=None, diff --git a/src/tests/_internal/server/routers/test_fleets.py b/src/tests/_internal/server/routers/test_fleets.py index 68378938b..eb817d936 100644 --- a/src/tests/_internal/server/routers/test_fleets.py +++ b/src/tests/_internal/server/routers/test_fleets.py @@ -2505,68 +2505,6 @@ async def test_returns_create_plan_for_existing_fleet( "action": "create", } - @pytest.mark.parametrize( - ("client_version", "expected_availability"), - [ - ("0.20.3", InstanceAvailability.NOT_AVAILABLE), - ("0.20.4", InstanceAvailability.NO_BALANCE), - (None, InstanceAvailability.NO_BALANCE), - ], - ) - @pytest.mark.asyncio - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_replaces_no_balance_with_not_available_for_old_clients( - self, - test_db, - session: AsyncSession, - client: AsyncClient, - client_version: Optional[str], - expected_availability: InstanceAvailability, - ): - user = await create_user(session=session) - project = await create_project(session=session, owner=user) - offers = [ - InstanceOfferWithAvailability( - backend=BackendType.AWS, - instance=InstanceType( - name="instance-1", - resources=Resources(cpus=1, memory_mib=512, spot=False, gpus=[]), - ), - region="us", - price=1.0, - availability=InstanceAvailability.AVAILABLE, - ), - InstanceOfferWithAvailability( - backend=BackendType.AWS, - instance=InstanceType( - name="instance-2", - resources=Resources(cpus=2, memory_mib=1024, spot=False, gpus=[]), - ), - region="us", - price=2.0, - availability=InstanceAvailability.NO_BALANCE, - ), - ] - headers = get_auth_headers(user.token) - if client_version is not None: - headers["X-API-Version"] = client_version - with patch("dstack._internal.server.services.backends.get_project_backends") as m: - backend_mock = Mock() - m.return_value = [backend_mock] - backend_mock.TYPE = BackendType.AWS - backend_mock.compute.return_value.get_offers.return_value = offers - response = await client.post( - f"/api/project/{project.name}/fleets/get_plan", - headers=headers, - json={"spec": get_fleet_spec().model_dump()}, - ) - - assert response.status_code == 200 - offers = response.json()["offers"] - assert len(offers) == 2 - assert offers[0]["availability"] == InstanceAvailability.AVAILABLE.value - assert offers[1]["availability"] == expected_availability.value - @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) async def test_importer_member_cannot_get_plan_for_imported_fleet( diff --git a/src/tests/_internal/server/routers/test_gateways.py b/src/tests/_internal/server/routers/test_gateways.py index 19b39908d..e60e25bdc 100644 --- a/src/tests/_internal/server/routers/test_gateways.py +++ b/src/tests/_internal/server/routers/test_gateways.py @@ -78,7 +78,7 @@ async def test_list( { "id": SomeUUID4Str(), "project_name": project.name, - "backend": backend.type.value, + "backend": None, "created_at": response.json()[0]["created_at"], "default": False, "status": "submitted", @@ -98,7 +98,7 @@ async def test_list( "ip_address": None, "hostname": None, "name": gateway.name, - "region": gateway.region, + "region": None, "wildcard_domain": gateway.wildcard_domain, "configuration": { "type": "gateway", @@ -164,7 +164,7 @@ async def test_get( assert response.json() == { "id": SomeUUID4Str(), "project_name": project.name, - "backend": backend.type.value, + "backend": None, "created_at": response.json()["created_at"], "default": False, "status": "submitted", @@ -184,7 +184,7 @@ async def test_get( "ip_address": None, "hostname": None, "name": gateway.name, - "region": gateway.region, + "region": None, "wildcard_domain": gateway.wildcard_domain, "configuration": { "type": "gateway", @@ -516,8 +516,8 @@ async def test_create_gateway(self, test_db, session: AsyncSession, client: Asyn "id": SomeUUID4Str(), "project_name": project.name, "name": "test", - "backend": "aws", - "region": "us", + "backend": None, + "region": None, "status": "submitted", "status_message": None, "replicas": [], @@ -606,8 +606,8 @@ async def test_create_gateway_without_name( "id": SomeUUID4Str(), "project_name": project.name, "name": "random-name", - "backend": "aws", - "region": "us", + "backend": None, + "region": None, "status": "submitted", "status_message": None, "replicas": [], @@ -840,7 +840,7 @@ async def test_set_default_gateway( assert response.json() == { "id": SomeUUID4Str(), "project_name": project.name, - "backend": backend.type.value, + "backend": None, "created_at": response.json()["created_at"], "default": True, "status": "submitted", @@ -860,7 +860,7 @@ async def test_set_default_gateway( "ip_address": None, "hostname": None, "name": gateway.name, - "region": gateway.region, + "region": None, "wildcard_domain": gateway.wildcard_domain, "configuration": { "type": "gateway", @@ -1236,7 +1236,7 @@ async def test_set_wildcard_domain( assert response.json() == { "id": SomeUUID4Str(), "project_name": project.name, - "backend": backend.type.value, + "backend": None, "created_at": response.json()["created_at"], "status": "submitted", "status_message": None, @@ -1256,7 +1256,7 @@ async def test_set_wildcard_domain( "ip_address": None, "hostname": None, "name": gateway.name, - "region": gateway.region, + "region": None, "wildcard_domain": "new.example", "configuration": { "type": "gateway", diff --git a/src/tests/_internal/server/routers/test_gpus.py b/src/tests/_internal/server/routers/test_gpus.py index 85b38257e..8ed36a637 100644 --- a/src/tests/_internal/server/routers/test_gpus.py +++ b/src/tests/_internal/server/routers/test_gpus.py @@ -137,7 +137,6 @@ async def call_gpus_api( user_token: str, run_spec: RunSpec, group_by: Optional[List[str]] = None, - client_version: Optional[str] = None, full_offers: Optional[bool] = None, unallocated_resources: Optional[bool] = None, ): @@ -149,13 +148,10 @@ async def call_gpus_api( json_data["full_offers"] = full_offers if unallocated_resources is not None: json_data["unallocated_resources"] = unallocated_resources - headers = get_auth_headers(user_token) - if client_version is not None: - headers["X-API-Version"] = client_version return await client.post( f"/api/project/{project_name}/gpus/list", - headers=headers, + headers=get_auth_headers(user_token), json=json_data, ) @@ -783,44 +779,3 @@ async def test_exact_aggregation_values( assert rtx_runpod_euwest1["region"] == "eu-west-1" assert rtx_runpod_euwest1["price"]["min"] == 0.65 assert rtx_runpod_euwest1["price"]["max"] == 0.65 - - @pytest.mark.asyncio - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - @pytest.mark.parametrize( - ("client_version", "expected_availability"), - [ - ("0.20.3", InstanceAvailability.NOT_AVAILABLE), - ("0.20.4", InstanceAvailability.NO_BALANCE), - (None, InstanceAvailability.NO_BALANCE), - ], - ) - async def test_replaces_no_balance_with_not_available_for_old_clients( - self, - test_db, - session: AsyncSession, - client: AsyncClient, - client_version: Optional[str], - expected_availability: InstanceAvailability, - ): - user, project, repo, run_spec = await gpu_test_setup(session) - - available_offer = create_gpu_offer( - BackendType.AWS, "T4", 16384, 0.50, availability=InstanceAvailability.AVAILABLE - ) - no_balance_offer = create_gpu_offer( - BackendType.AWS, "L4", 24 * 1024, 1.0, availability=InstanceAvailability.NO_BALANCE - ) - offers_by_backend = {BackendType.AWS: [available_offer, no_balance_offer]} - mocked_backends = create_mock_backends_with_offers(offers_by_backend) - - with patch("dstack._internal.server.services.backends.get_project_backends") as m: - m.return_value = mocked_backends - response = await call_gpus_api( - client, project.name, user.token, run_spec, client_version=client_version - ) - - assert response.status_code == 200 - response_data = response.json() - assert len(response_data["gpus"]) == 2 - assert response_data["gpus"][0]["availability"] == [InstanceAvailability.AVAILABLE.value] - assert response_data["gpus"][1]["availability"] == [expected_availability.value] diff --git a/src/tests/_internal/server/routers/test_runs.py b/src/tests/_internal/server/routers/test_runs.py index 1d637888d..5a40f6159 100644 --- a/src/tests/_internal/server/routers/test_runs.py +++ b/src/tests/_internal/server/routers/test_runs.py @@ -3017,79 +3017,6 @@ async def test_collects_offers_only_if_requested_by_for_offers_only( not expected_offer_collection ) - @pytest.mark.parametrize( - ("client_version", "expected_availability"), - [ - ("0.20.3", InstanceAvailability.NOT_AVAILABLE), - ("0.20.4", InstanceAvailability.NO_BALANCE), - (None, InstanceAvailability.NO_BALANCE), - ], - ) - @pytest.mark.asyncio - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_replaces_no_balance_with_not_available_for_old_clients( - self, - test_db, - session: AsyncSession, - client: AsyncClient, - client_version: Optional[str], - expected_availability: InstanceAvailability, - ) -> None: - user = await create_user(session=session) - project = await create_project(session=session, owner=user) - fleet_spec = get_fleet_spec() - fleet_spec.configuration.nodes = FleetNodesSpec(min=0, target=0, max=None) - await create_fleet(session=session, project=project, spec=fleet_spec) - repo = await create_repo(session=session, project_id=project.id) - offers = [ - InstanceOfferWithAvailability( - backend=BackendType.AWS, - instance=InstanceType( - name="instance-1", - resources=Resources(cpus=1, memory_mib=512, spot=False, gpus=[]), - ), - region="us", - price=1.0, - availability=InstanceAvailability.AVAILABLE, - ), - InstanceOfferWithAvailability( - backend=BackendType.AWS, - instance=InstanceType( - name="instance-2", - resources=Resources(cpus=2, memory_mib=1024, spot=False, gpus=[]), - ), - region="us", - price=2.0, - availability=InstanceAvailability.NO_BALANCE, - ), - ] - run_plan_dict = get_dev_env_run_plan_dict( - project_name=project.name, - username=user.name, - repo_id=repo.name, - offers=offers, - total_offers=1, - max_price=1.0, - ) - body = {"run_spec": run_plan_dict["run_spec"]} - headers = get_auth_headers(user.token) - if client_version is not None: - headers["X-API-Version"] = client_version - with patch("dstack._internal.server.services.backends.get_project_backends") as m: - backend_mock = Mock() - backend_mock.TYPE = BackendType.AWS - backend_mock.compute.return_value.get_offers.return_value = offers - m.return_value = [backend_mock] - response = await client.post( - f"/api/project/{project.name}/runs/get_plan", - headers=headers, - json=body, - ) - offers = response.json()["job_plans"][0]["offers"] - assert len(offers) == 2 - assert offers[0]["availability"] == InstanceAvailability.AVAILABLE.value - assert offers[1]["availability"] == expected_availability.value - @pytest.mark.asyncio @pytest.mark.parametrize( ("old_conf", "new_conf", "action"), @@ -3292,9 +3219,14 @@ async def test_returns_403_if_not_project_member( assert response.status_code == 403 @pytest.mark.asyncio + @pytest.mark.parametrize("privileged", [None, False, True]) @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) async def test_submits_new_run_if_no_current_resource( - self, test_db, session: AsyncSession, client: AsyncClient + self, + test_db, + session: AsyncSession, + client: AsyncClient, + privileged: Optional[bool], ): user = await create_user(session=session, global_role=GlobalRole.USER) project = await create_project(session=session, owner=user) @@ -3315,7 +3247,11 @@ async def test_submits_new_run_if_no_current_resource( finished_at=None, run_name="test-run", repo_id=repo.name, + privileged=bool(privileged), ) + run_spec = copy.deepcopy(run_dict["run_spec"]) + if privileged is None: + del run_spec["configuration"]["privileged"] with patch("dstack._internal.utils.common.get_current_datetime") as datetime_mock: datetime_mock.return_value = submitted_at response = await client.post( @@ -3323,7 +3259,7 @@ async def test_submits_new_run_if_no_current_resource( headers=get_auth_headers(user.token), json={ "plan": { - "run_spec": run_dict["run_spec"], + "run_spec": run_spec, "current_resource": None, }, "force": False, @@ -3338,6 +3274,180 @@ async def test_submits_new_run_if_no_current_resource( job = res.scalar() assert job is not None + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_submits_new_run_docker_true( + self, test_db, session: AsyncSession, client: AsyncClient + ): + user = await create_user(session=session, global_role=GlobalRole.USER) + project = await create_project(session=session, owner=user) + await add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.USER + ) + submitted_at = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc) + submitted_at_formatted = "2023-01-02T03:04:00Z" + repo = await create_repo(session=session, project_id=project.id) + run_dict = get_dev_env_run_dict( + run_id=SomeUUID4Str(), + job_id=SomeUUID4Str(), + project_name=project.name, + username=user.name, + submitted_at=submitted_at_formatted, + last_processed_at=submitted_at_formatted, + finished_at=None, + run_name="test-run", + repo_id=repo.name, + docker=True, + privileged=True, # docker=True automatically enables privileged mode + ) + with patch("dstack._internal.utils.common.get_current_datetime") as datetime_mock: + datetime_mock.return_value = submitted_at + response = await client.post( + f"/api/project/{project.name}/runs/apply", + headers=get_auth_headers(user.token), + json={ + "plan": { + "run_spec": run_dict["run_spec"], + "current_resource": None, + }, + "force": False, + }, + ) + assert response.status_code == 200, response.json() + assert response.json() == run_dict + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_submits_new_run_without_run_name( + self, test_db, session: AsyncSession, client: AsyncClient + ): + user = await create_user(session=session, global_role=GlobalRole.USER) + project = await create_project(session=session, owner=user) + await add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.USER + ) + repo = await create_repo(session=session, project_id=project.id) + run_dict = get_dev_env_run_dict( + project_name=project.name, + username=user.name, + run_name=None, + repo_id=repo.name, + ) + response = await client.post( + f"/api/project/{project.name}/runs/apply", + headers=get_auth_headers(user.token), + json={ + "plan": { + "run_spec": run_dict["run_spec"], + "current_resource": None, + }, + "force": False, + }, + ) + assert response.status_code == 200, response.json() + assert response.json()["run_spec"]["run_name"] is not None + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + @pytest.mark.parametrize( + "run_name", + [ + "run_with_underscores", + "RunWithUppercase", + "тест_ран", + ], + ) + async def test_returns_400_if_bad_run_name( + self, test_db, session: AsyncSession, client: AsyncClient, run_name: str + ): + user = await create_user(session=session, global_role=GlobalRole.USER) + project = await create_project(session=session, owner=user) + await add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.USER + ) + repo = await create_repo(session=session, project_id=project.id) + run_dict = get_dev_env_run_dict( + project_name=project.name, + username=user.name, + run_name=run_name, + repo_id=repo.name, + ) + response = await client.post( + f"/api/project/{project.name}/runs/apply", + headers=get_auth_headers(user.token), + json={ + "plan": { + "run_spec": run_dict["run_spec"], + "current_resource": None, + }, + "force": False, + }, + ) + assert response.status_code == 400 + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_returns_400_if_dstack_in_runs_forbidden( + self, test_db, session: AsyncSession, client: AsyncClient + ): + user = await create_user(session=session, global_role=GlobalRole.USER) + project = await create_project(session=session, owner=user) + await add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.USER + ) + repo = await create_repo(session=session, project_id=project.id) + run_dict = get_dev_env_run_dict( + project_name=project.name, + username=user.name, + run_name="test-run", + repo_id=repo.name, + ) + run_dict["run_spec"]["configuration"]["dstack"] = True + with patch( + "dstack._internal.server.services.runs.server_settings.FORBID_DSTACK_IN_RUNS", True + ): + response = await client.post( + f"/api/project/{project.name}/runs/apply", + headers=get_auth_headers(user.token), + json={ + "plan": { + "run_spec": run_dict["run_spec"], + "current_resource": None, + }, + "force": False, + }, + ) + assert response.status_code == 400 + assert "forbids" in response.json()["detail"][0]["msg"] + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_returns_400_if_repo_does_not_exist( + self, test_db, session: AsyncSession, client: AsyncClient + ): + user = await create_user(session=session, global_role=GlobalRole.USER) + project = await create_project(session=session, owner=user) + await add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.USER + ) + run_dict = get_dev_env_run_dict( + project_name=project.name, + username=user.name, + repo_id="repo1234", + ) + response = await client.post( + f"/api/project/{project.name}/runs/apply", + headers=get_auth_headers(user.token), + json={ + "plan": { + "run_spec": run_dict["run_spec"], + "current_resource": None, + }, + "force": False, + }, + ) + assert response.status_code == 400 + @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) async def test_updates_run(self, test_db, session: AsyncSession, client: AsyncClient): @@ -3519,232 +3629,6 @@ async def test_patches_service_configuration_probes_for_old_clients( assert response.json()["run_spec"]["configuration"]["probes"] == expected_probes -class TestSubmitRun: - @pytest.mark.asyncio - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_returns_403_if_not_project_member( - self, test_db, session: AsyncSession, client: AsyncClient - ): - user = await create_user(session=session, global_role=GlobalRole.USER) - project = await create_project(session=session, owner=user) - response = await client.post( - f"/api/project/{project.name}/runs/submit", - headers=get_auth_headers(user.token), - ) - assert response.status_code == 403 - - @pytest.mark.asyncio - @pytest.mark.parametrize("privileged", [None, False, True]) - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_submits_run( - self, test_db, session: AsyncSession, client: AsyncClient, privileged: Optional[bool] - ): - user = await create_user(session=session, global_role=GlobalRole.USER) - project = await create_project(session=session, owner=user) - await add_project_member( - session=session, project=project, user=user, project_role=ProjectRole.USER - ) - submitted_at = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc) - submitted_at_formatted = "2023-01-02T03:04:00Z" - last_processed_at_formatted = submitted_at_formatted - repo = await create_repo(session=session, project_id=project.id) - run_dict = get_dev_env_run_dict( - run_id=SomeUUID4Str(), - job_id=SomeUUID4Str(), - project_name=project.name, - username=user.name, - submitted_at=submitted_at_formatted, - last_processed_at=last_processed_at_formatted, - finished_at=None, - run_name="test-run", - repo_id=repo.name, - privileged=bool(privileged), - ) - run_spec = copy.deepcopy(run_dict["run_spec"]) - if privileged is None: - del run_spec["configuration"]["privileged"] - body = {"run_spec": run_spec} - with patch("dstack._internal.utils.common.get_current_datetime") as datetime_mock: - datetime_mock.return_value = submitted_at - response = await client.post( - f"/api/project/{project.name}/runs/submit", - headers=get_auth_headers(user.token), - json=body, - ) - assert response.status_code == 200, response.json() - assert response.json() == run_dict - res = await session.execute(select(RunModel)) - run = res.scalar() - assert run is not None - res = await session.execute(select(JobModel)) - job = res.scalar() - assert job is not None - - @pytest.mark.asyncio - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_submits_run_docker_true( - self, test_db, session: AsyncSession, client: AsyncClient - ): - user = await create_user(session=session, global_role=GlobalRole.USER) - project = await create_project(session=session, owner=user) - await add_project_member( - session=session, project=project, user=user, project_role=ProjectRole.USER - ) - submitted_at = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc) - submitted_at_formatted = "2023-01-02T03:04:00Z" - last_processed_at_formatted = submitted_at_formatted - repo = await create_repo(session=session, project_id=project.id) - run_dict = get_dev_env_run_dict( - run_id=SomeUUID4Str(), - job_id=SomeUUID4Str(), - project_name=project.name, - username=user.name, - submitted_at=submitted_at_formatted, - last_processed_at=last_processed_at_formatted, - finished_at=None, - run_name="test-run", - repo_id=repo.name, - docker=True, - privileged=True, # docker=True automatically enables privileged mode - ) - body = {"run_spec": run_dict["run_spec"]} - with patch("dstack._internal.utils.common.get_current_datetime") as datetime_mock: - datetime_mock.return_value = submitted_at - response = await client.post( - f"/api/project/{project.name}/runs/submit", - headers=get_auth_headers(user.token), - json=body, - ) - assert response.status_code == 200, response.json() - assert response.json() == run_dict - res = await session.execute(select(RunModel)) - run = res.scalar() - assert run is not None - res = await session.execute(select(JobModel)) - job = res.scalar() - assert job is not None - - @pytest.mark.asyncio - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_submits_run_without_run_name( - self, test_db, session: AsyncSession, client: AsyncClient - ): - user = await create_user(session=session, global_role=GlobalRole.USER) - project = await create_project(session=session, owner=user) - await add_project_member( - session=session, project=project, user=user, project_role=ProjectRole.USER - ) - repo = await create_repo(session=session, project_id=project.id) - run_dict = get_dev_env_run_dict( - project_name=project.name, - username=user.name, - run_name=None, - repo_id=repo.name, - ) - body = {"run_spec": run_dict["run_spec"]} - response = await client.post( - f"/api/project/{project.name}/runs/submit", - headers=get_auth_headers(user.token), - json=body, - ) - assert response.status_code == 200 - assert response.json()["run_spec"]["run_name"] is not None - res = await session.execute(select(RunModel)) - run = res.scalar() - assert run is not None - res = await session.execute(select(JobModel)) - job = res.scalar() - assert job is not None - - @pytest.mark.asyncio - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - @pytest.mark.parametrize( - "run_name", - [ - "run_with_underscores", - "RunWithUppercase", - "тест_ран", - ], - ) - async def test_returns_400_if_bad_run_name( - self, test_db, session: AsyncSession, client: AsyncClient, run_name: str - ): - user = await create_user(session=session, global_role=GlobalRole.USER) - project = await create_project(session=session, owner=user) - await add_project_member( - session=session, project=project, user=user, project_role=ProjectRole.USER - ) - repo = await create_repo(session=session, project_id=project.id) - run_dict = get_dev_env_run_dict( - project_name=project.name, - username=user.name, - run_name=run_name, - repo_id=repo.name, - ) - body = {"run_spec": run_dict["run_spec"]} - with patch("uuid.uuid4") as uuid_mock: - uuid_mock.return_value = UUID(run_dict["id"]) - response = await client.post( - f"/api/project/{project.name}/runs/submit", - headers=get_auth_headers(user.token), - json=body, - ) - assert response.status_code == 400 - - @pytest.mark.asyncio - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_returns_400_if_dstack_in_runs_forbidden( - self, test_db, session: AsyncSession, client: AsyncClient - ): - user = await create_user(session=session, global_role=GlobalRole.USER) - project = await create_project(session=session, owner=user) - await add_project_member( - session=session, project=project, user=user, project_role=ProjectRole.USER - ) - repo = await create_repo(session=session, project_id=project.id) - run_dict = get_dev_env_run_dict( - project_name=project.name, - username=user.name, - run_name="test-run", - repo_id=repo.name, - ) - run_dict["run_spec"]["configuration"]["dstack"] = True - body = {"run_spec": run_dict["run_spec"]} - with patch( - "dstack._internal.server.services.runs.server_settings.FORBID_DSTACK_IN_RUNS", True - ): - response = await client.post( - f"/api/project/{project.name}/runs/submit", - headers=get_auth_headers(user.token), - json=body, - ) - assert response.status_code == 400 - assert "forbids" in response.json()["detail"][0]["msg"] - - @pytest.mark.asyncio - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_returns_400_if_repo_does_not_exist( - self, test_db, session: AsyncSession, client: AsyncClient - ): - user = await create_user(session=session, global_role=GlobalRole.USER) - project = await create_project(session=session, owner=user) - await add_project_member( - session=session, project=project, user=user, project_role=ProjectRole.USER - ) - run_dict = get_dev_env_run_dict( - project_name=project.name, - username=user.name, - repo_id="repo1234", - ) - body = {"run_spec": run_dict["run_spec"]} - response = await client.post( - f"/api/project/{project.name}/runs/submit", - headers=get_auth_headers(user.token), - json=body, - ) - assert response.status_code == 400 - - class TestStopRuns: @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) @@ -4080,9 +3964,15 @@ async def test_submit_to_correct_proxy( model=model, ) response = await client.post( - f"/api/project/{project.name}/runs/submit", + f"/api/project/{project.name}/runs/apply", headers=get_auth_headers(user.token), - json={"run_spec": run_spec}, + json={ + "plan": { + "run_spec": run_spec, + "current_resource": None, + }, + "force": False, + }, ) assert response.status_code == 200 assert response.json()["service"]["url"] == expected_service_url @@ -4127,9 +4017,15 @@ async def test_submit_to_gateway_by_name( gateway="my-gateway", ) response = await client.post( - f"/api/project/{project.name}/runs/submit", + f"/api/project/{project.name}/runs/apply", headers=get_auth_headers(user.token), - json={"run_spec": run_spec}, + json={ + "plan": { + "run_spec": run_spec, + "current_resource": None, + }, + "force": False, + }, ) assert response.status_code == 200 assert response.json()["service"]["url"] == "https://test-service.my-gateway.example" @@ -4148,9 +4044,15 @@ async def test_return_error_if_specified_gateway_not_exists( repo = await create_repo(session=session, project_id=project.id) run_spec = get_service_run_spec(repo_id=repo.name, gateway="nonexistent") response = await client.post( - f"/api/project/{project.name}/runs/submit", + f"/api/project/{project.name}/runs/apply", headers=get_auth_headers(user.token), - json={"run_spec": run_spec}, + json={ + "plan": { + "run_spec": run_spec, + "current_resource": None, + }, + "force": False, + }, ) assert response.status_code == 400 assert response.json() == { @@ -4174,9 +4076,15 @@ async def test_return_error_if_specified_gateway_is_true_and_no_gateway_exists( repo = await create_repo(session=session, project_id=project.id) run_spec = get_service_run_spec(repo_id=repo.name, gateway=True) response = await client.post( - f"/api/project/{project.name}/runs/submit", + f"/api/project/{project.name}/runs/apply", headers=get_auth_headers(user.token), - json={"run_spec": run_spec}, + json={ + "plan": { + "run_spec": run_spec, + "current_resource": None, + }, + "force": False, + }, ) assert response.status_code == 400 assert response.json() == { @@ -4250,9 +4158,15 @@ async def test_submit_to_foreign_gateway_only_if_imported( gateway="exporter-project/exported-gateway", ) response = await client.post( - f"/api/project/{importer_project.name}/runs/submit", + f"/api/project/{importer_project.name}/runs/apply", headers=get_auth_headers(importer_user.token), - json={"run_spec": importer_run_spec}, + json={ + "plan": { + "run_spec": importer_run_spec, + "current_resource": None, + }, + "force": False, + }, ) assert response.status_code == 200 assert response.json()["service"]["url"] == "https://test-service.exported-gateway.example" @@ -4262,9 +4176,15 @@ async def test_submit_to_foreign_gateway_only_if_imported( gateway="exporter-project/exported-gateway", ) response = await client.post( - f"/api/project/{not_importer_project.name}/runs/submit", + f"/api/project/{not_importer_project.name}/runs/apply", headers=get_auth_headers(not_importer_user.token), - json={"run_spec": not_importer_run_spec}, + json={ + "plan": { + "run_spec": not_importer_run_spec, + "current_resource": None, + }, + "force": False, + }, ) assert response.status_code == 400 assert response.json() == { @@ -4310,9 +4230,15 @@ async def test_not_submits_to_default_gateway_if_not_imported( gateway=True, ) response = await client.post( - f"/api/project/{service_project.name}/runs/submit", + f"/api/project/{service_project.name}/runs/apply", headers=get_auth_headers(user.token), - json={"run_spec": run_spec}, + json={ + "plan": { + "run_spec": run_spec, + "current_resource": None, + }, + "force": False, + }, ) assert response.status_code == 400 assert response.json() == { @@ -4372,9 +4298,15 @@ async def test_interpolates_project_name_in_imported_gateway_domain( gateway="exporter-project/exported-gateway", ) response = await client.post( - f"/api/project/{importer_project.name}/runs/submit", + f"/api/project/{importer_project.name}/runs/apply", headers=get_auth_headers(importer_user.token), - json={"run_spec": run_spec}, + json={ + "plan": { + "run_spec": run_spec, + "current_resource": None, + }, + "force": False, + }, ) assert response.status_code == 200 assert ( @@ -4430,9 +4362,15 @@ async def test_returns_error_if_imported_gateway_domain_has_unknown_variable( gateway="exporter-project/exported-gateway", ) response = await client.post( - f"/api/project/{importer_project.name}/runs/submit", + f"/api/project/{importer_project.name}/runs/apply", headers=get_auth_headers(importer_user.token), - json={"run_spec": run_spec}, + json={ + "plan": { + "run_spec": run_spec, + "current_resource": None, + }, + "force": False, + }, ) assert response.status_code == 400 assert response.json() == { @@ -4479,9 +4417,15 @@ async def test_unregister_dangling_service( ] response = await client.post( - "/api/project/test-project/runs/submit", + f"/api/project/{project.name}/runs/apply", headers=get_auth_headers(user.token), - json={"run_spec": get_service_run_spec(repo_id=repo.name, run_name="test-service")}, + json={ + "plan": { + "run_spec": get_service_run_spec(repo_id=repo.name, run_name="test-service"), + "current_resource": None, + }, + "force": False, + }, ) assert response.status_code == 200 @@ -4521,9 +4465,15 @@ async def test_return_error_if_default_gateway_forbids_new_services( await session.commit() response = await client.post( - "/api/project/test-project/runs/submit", + f"/api/project/{project.name}/runs/apply", headers=get_auth_headers(user.token), - json={"run_spec": get_service_run_spec(repo_id=repo.name, run_name="test-service")}, + json={ + "plan": { + "run_spec": get_service_run_spec(repo_id=repo.name, run_name="test-service"), + "current_resource": None, + }, + "force": False, + }, ) assert response.status_code == 400 @@ -4557,14 +4507,18 @@ async def test_return_error_if_explicitly_specified_gateway_forbids_new_services await create_gateway_compute(session=session, backend_id=backend.id, gateway_id=gateway.id) response = await client.post( - "/api/project/test-project/runs/submit", + f"/api/project/{project.name}/runs/apply", headers=get_auth_headers(user.token), json={ - "run_spec": get_service_run_spec( - repo_id=repo.name, - run_name="test-service", - gateway="restricted-gateway", - ) + "plan": { + "run_spec": get_service_run_spec( + repo_id=repo.name, + run_name="test-service", + gateway="restricted-gateway", + ), + "current_resource": None, + }, + "force": False, }, )