Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add dstack destroy command and improve dstack apply #1271

Merged
merged 2 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion docs/docs/reference/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ $ dstack run . --help

### dstack apply

This command applies a given configuration. If a resources does not exist, `dstack apply` creates the resource.
This command applies a given configuration. If a resource does not exist, `dstack apply` creates the resource.
If a resource exists, `dstack apply` updates the resource in-place or re-creates the resource if the update is not possible.

<div class="termy">
Expand All @@ -77,6 +77,23 @@ $ dstack apply --help
The `dstack apply` command currently supports only `gateway` configurations.
Support for other configuration types is coming soon.

### dstack destroy

This command destroys the resources defined by a given configuration.

<div class="termy">

```shell
$ dstack destroy --help
#GENERATE#
```

</div>

!!! info "NOTE:"
The `dstack destroy` command currently supports only `gateway` configurations.
Support for other configuration types is coming soon.

### dstack ps

This command shows the status of runs.
Expand Down
29 changes: 8 additions & 21 deletions src/dstack/_internal/cli/commands/apply.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
import argparse
from pathlib import Path

import yaml

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.configurators import (
get_apply_configurator_class,
load_apply_configuration,
)
from dstack._internal.cli.utils.common import cli_error
from dstack._internal.core.errors import ConfigurationError
from dstack._internal.core.models.configurations import (
AnyApplyConfiguration,
parse_apply_configuration,
)


class ApplyCommand(APIBaseCommand):
Expand All @@ -22,8 +17,12 @@ class ApplyCommand(APIBaseCommand):
def _register(self):
super()._register()
self._parser.add_argument(
"configuration_file",
help="The path to the configuration file",
"-f",
"--file",
type=Path,
metavar="FILE",
help="The path to the configuration file. Defaults to [code]$PWD/.dstack.yml[/]",
dest="configuration_file",
)
self._parser.add_argument(
"--force",
Expand All @@ -40,21 +39,9 @@ def _register(self):
def _command(self, args: argparse.Namespace):
super()._command(args)
try:
configuration = _load_configuration(args.configuration_file)
configuration = load_apply_configuration(args.configuration_file)
except ConfigurationError as e:
raise cli_error(e)
configurator_class = get_apply_configurator_class(configuration.type)
configurator = configurator_class(api_client=self.api)
configurator.apply_configuration(conf=configuration, args=args)


def _load_configuration(configuration_file: str) -> AnyApplyConfiguration:
configuration_path = Path(configuration_file)
if not configuration_path.exists():
raise ConfigurationError(f"Configuration file {configuration_file} does not exist")
try:
with open(configuration_path, "r") as f:
conf = parse_apply_configuration(yaml.safe_load(f))
except OSError:
raise ConfigurationError(f"Failed to load configuration from {configuration_path}")
return conf
42 changes: 42 additions & 0 deletions src/dstack/_internal/cli/commands/destroy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import argparse
from pathlib import Path

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.configurators import (
get_apply_configurator_class,
load_apply_configuration,
)
from dstack._internal.cli.utils.common import cli_error
from dstack._internal.core.errors import ConfigurationError


class DestroyCommand(APIBaseCommand):
NAME = "destroy"
DESCRIPTION = "Destroy resources defined by dstack configuration"

def _register(self):
super()._register()
self._parser.add_argument(
"-f",
"--file",
type=Path,
metavar="FILE",
help="The path to the configuration file. Defaults to [code]$PWD/.dstack.yml[/]",
dest="configuration_file",
)
self._parser.add_argument(
"-y",
"--yes",
help="Do not ask for confirmation",
action="store_true",
)

def _command(self, args: argparse.Namespace):
super()._command(args)
try:
configuration = load_apply_configuration(args.configuration_file)
except ConfigurationError as e:
raise cli_error(e)
configurator_class = get_apply_configurator_class(configuration.type)
configurator = configurator_class(api_client=self.api)
configurator.destroy_configuration(conf=configuration, args=args)
2 changes: 2 additions & 0 deletions src/dstack/_internal/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from dstack._internal.cli.commands.apply import ApplyCommand
from dstack._internal.cli.commands.config import ConfigCommand
from dstack._internal.cli.commands.destroy import DestroyCommand
from dstack._internal.cli.commands.gateway import GatewayCommand
from dstack._internal.cli.commands.init import InitCommand
from dstack._internal.cli.commands.logs import LogsCommand
Expand Down Expand Up @@ -53,6 +54,7 @@ def main():
subparsers = parser.add_subparsers(metavar="COMMAND")
ApplyCommand.register(subparsers)
ConfigCommand.register(subparsers)
DestroyCommand.register(subparsers)
GatewayCommand.register(subparsers)
PoolCommand.register(subparsers)
InitCommand.register(subparsers)
Expand Down
33 changes: 31 additions & 2 deletions src/dstack/_internal/cli/services/configurators/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
from typing import Dict, Type
from pathlib import Path
from typing import Dict, Optional, Type

import yaml

from dstack._internal.cli.services.configurators.base import BaseApplyConfigurator
from dstack._internal.cli.services.configurators.gateway import GatewayConfigurator
from dstack._internal.core.models.configurations import ApplyConfigurationType
from dstack._internal.core.errors import ConfigurationError
from dstack._internal.core.models.configurations import (
AnyApplyConfiguration,
ApplyConfigurationType,
parse_apply_configuration,
)

apply_configurators_mapping: Dict[ApplyConfigurationType, Type[BaseApplyConfigurator]] = {
cls.TYPE: cls for cls in [GatewayConfigurator]
Expand All @@ -11,3 +19,24 @@

def get_apply_configurator_class(configurator_type: str) -> Type[BaseApplyConfigurator]:
return apply_configurators_mapping[ApplyConfigurationType(configurator_type)]


def load_apply_configuration(configuration_file: Optional[str]) -> AnyApplyConfiguration:
if configuration_file is None:
configuration_path = Path.cwd() / ".dstack.yml"
if not configuration_path.exists():
configuration_path = configuration_path.with_suffix(".yaml")
if not configuration_path.exists():
raise ConfigurationError(
"No configuration file specified via `-f` and no default .dstack.yml configuration found"
)
else:
configuration_path = Path(configuration_file)
if not configuration_path.exists():
raise ConfigurationError(f"Configuration file {configuration_file} does not exist")
try:
with open(configuration_path, "r") as f:
conf = parse_apply_configuration(yaml.safe_load(f))
except OSError:
raise ConfigurationError(f"Failed to load configuration from {configuration_path}")
return conf
4 changes: 4 additions & 0 deletions src/dstack/_internal/cli/services/configurators/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ def __init__(self, api_client: Client):
def apply_configuration(self, conf: AnyApplyConfiguration, args: argparse.Namespace):
pass

@abstractmethod
def destroy_configuration(self, conf: AnyApplyConfiguration, args: argparse.Namespace):
pass

def register_args(self, parser: argparse.ArgumentParser):
pass

Expand Down
24 changes: 24 additions & 0 deletions src/dstack/_internal/cli/services/configurators/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,27 @@ def apply_configuration(self, conf: GatewayConfiguration, args: argparse.Namespa
configuration=conf,
)
print_gateways_table([gateway])

def destroy_configuration(self, conf: GatewayConfiguration, args: argparse.Namespace):
if conf.name is None:
console.print("[error]Configuration specifies no gateway to destroy[/]")
return

try:
self.api_client.client.gateways.get(
project_name=self.api_client.project, gateway_name=conf.name
)
except ResourceNotExistsError:
console.print(f"Gateway [code]{conf.name}[/] does not exist")
return

if not args.yes and not confirm_ask(f"Delete the gateway [code]{conf.name}[/]?"):
console.print("\nExiting...")
return

with console.status("Deleting gateway..."):
self.api_client.client.gateways.delete(
project_name=self.api_client.project, gateways_names=[conf.name]
)

console.print(f"Gateway [code]{conf.name}[/] deleted")
5 changes: 3 additions & 2 deletions src/dstack/_internal/server/services/gateways/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ async def delete_gateways(session: AsyncSession, project: ProjectModel, gateways
if gateway.name not in gateways_names:
continue
backend = await get_project_backend_by_type_or_error(project, gateway.backend.type)
tasks.append(_terminate_gateway(gateway=gateway, backend=backend))
tasks.append(_terminate_gateway(session=session, gateway=gateway, backend=backend))
gateways.append(gateway)
logger.info("Deleting gateways: %s", [g.name for g in gateways])
# terminate in parallel
Expand All @@ -231,8 +231,9 @@ async def delete_gateways(session: AsyncSession, project: ProjectModel, gateways
await session.commit()


async def _terminate_gateway(gateway: GatewayModel, backend: Backend):
async def _terminate_gateway(session: AsyncSession, gateway: GatewayModel, backend: Backend):
await wait_to_lock(PROCESSING_GATEWAYS_LOCK, PROCESSING_GATEWAYS_IDS, gateway.id)
await session.refresh(gateway)
gateway_compute_configuration = get_gateway_compute_configuration(gateway)
if gateway.gateway_compute is not None and gateway_compute_configuration is not None:
logger.info("Deleting gateway compute for %s...", gateway.name)
Expand Down
Loading