Skip to content

Commit

Permalink
Implement dstack apply for gateways (#1223)
Browse files Browse the repository at this point in the history
* Implement basic dstack apply for gateways

* Compare gateway configuration when running apply

* Fix tests

* Generate json schema for GatewayConfiguration

* Add documentation on dstack apply and gateway configuration
  • Loading branch information
r4victor authored May 15, 2024
1 parent 7b5dda4 commit d897cb8
Show file tree
Hide file tree
Showing 38 changed files with 527 additions and 126 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ jobs:
run: pip install .
- name: Generate json schema
run: |
python -c "from dstack._internal.core.models.configurations import RunConfiguration; print(RunConfiguration.schema_json(indent=2))" > configuration.json
python -c "from dstack._internal.core.models.configurations import DstackConfiguration; print(DstackConfiguration.schema_json(indent=2))" > configuration.json
python -c "from dstack._internal.core.models.profiles import ProfilesConfig; print(ProfilesConfig.schema_json(indent=2))" > profiles.json
- name: Upload json schema to S3
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ jobs:
run: pip install .
- name: Generate json schema
run: |
python -c "from dstack._internal.core.models.configurations import RunConfiguration; print(RunConfiguration.schema_json(indent=2))" > configuration.json
python -c "from dstack._internal.core.models.configurations import DstackConfiguration; print(DstackConfiguration.schema_json(indent=2))" > configuration.json
python -c "from dstack._internal.core.models.profiles import ProfilesConfig; print(ProfilesConfig.schema_json(indent=2))" > profiles.json
- name: Upload json schema to S3
run: |
Expand Down
18 changes: 18 additions & 0 deletions docs/docs/reference/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@ $ dstack run . --help

If there are large files, consider creating a `.gitignore` file to exclude them for better performance.

### dstack apply

This command applies a given configuration. If a resources 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">

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

</div>

!!! info "NOTE:"
The `dstack apply` 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
7 changes: 4 additions & 3 deletions docs/docs/reference/dstack.yml/dev-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

The `dev-environment` configuration type allows running [dev environments](../../concepts/dev-environments.md).

> Configuration files must have a name ending with `.dstack.yml` (e.g., `.dstack.yml` or `dev.dstack.yml` are both acceptable)
> and can be located in the project's root directory or any nested folder.
> Any configuration can be run via [`dstack run . -f PATH`](../cli/index.md#dstack-run).
!!! info "Filename"
Configuration files must have a name ending with `.dstack.yml` (e.g., `.dstack.yml` or `serve.dstack.yml` are both acceptable)
and can be located in the project's root directory or any nested folder.
Any configuration can be run via [`dstack run`](../cli/index.md#dstack-run).

## Examples

Expand Down
31 changes: 31 additions & 0 deletions docs/docs/reference/dstack.yml/gateway.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# gateway

The `gateway` configuration type allows creating and updating [gateways](../../concepts/services.md).

!!! info "Filename"
Configuration files must have a name ending with `.dstack.yml` (e.g., `.dstack.yml` or `serve.dstack.yml` are both acceptable)
and can be located in the project's root directory or any nested folder.
Any configuration can be applied via [`dstack apply`](../cli/index.md#dstack-apply).

## Examples

<div editor-title="gateway.dstack.yml">

```yaml
type: gateway
name: example-gateway
backend: aws
region: eu-west-1
domain: '*.example.com'
```
</div>
## Root reference
#SCHEMA# dstack._internal.core.models.gateways.GatewayConfiguration
overrides:
show_root_heading: false
type:
required: true
7 changes: 4 additions & 3 deletions docs/docs/reference/dstack.yml/task.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

The `task` configuration type allows running [tasks](../../concepts/tasks.md).

> Configuration files must have a name ending with `.dstack.yml` (e.g., `.dstack.yml` or `train.dstack.yml` are both acceptable)
> and can be located in the project's root directory or any nested folder.
> Any configuration can be run via [`dstack run . -f PATH`](../cli/index.md#dstack-run).
!!! info "Filename"
Configuration files must have a name ending with `.dstack.yml` (e.g., `.dstack.yml` or `serve.dstack.yml` are both acceptable)
and can be located in the project's root directory or any nested folder.
Any configuration can be run via [`dstack run`](../cli/index.md#dstack-run).

## Examples

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ nav:
- dev-environment: docs/reference/dstack.yml/dev-environment.md
- task: docs/reference/dstack.yml/task.md
- service: docs/reference/dstack.yml/service.md
- gateway: docs/reference/dstack.yml/gateway.md
- profiles.yml: docs/reference/profiles.yml.md
- CLI: docs/reference/cli/index.md
- server/config.yml: docs/reference/server/config.yml.md
Expand Down
60 changes: 60 additions & 0 deletions src/dstack/_internal/cli/commands/apply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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,
)
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):
NAME = "apply"
DESCRIPTION = "Apply dstack configuration"

def _register(self):
super()._register()
self._parser.add_argument(
"configuration_file",
help="The path to the configuration file",
)
self._parser.add_argument(
"--force",
help="Force apply when no changes detected",
action="store_true",
)
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_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
2 changes: 1 addition & 1 deletion src/dstack/_internal/cli/commands/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.args import cpu_spec, disk_spec, gpu_spec, memory_spec
from dstack._internal.cli.services.configurators.profile import (
from dstack._internal.cli.services.profile import (
apply_profile_args,
register_profile_args,
)
Expand Down
18 changes: 9 additions & 9 deletions src/dstack/_internal/cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@
from typing import Optional

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.configurators.profile import (
apply_profile_args,
register_profile_args,
)
from dstack._internal.cli.services.configurators.run import (
BaseRunConfigurator,
run_configurators_mapping,
)
from dstack._internal.cli.services.profile import (
apply_profile_args,
register_profile_args,
)
from dstack._internal.cli.utils.common import confirm_ask, console
from dstack._internal.cli.utils.run import print_run_plan
from dstack._internal.core.errors import CLIError, ConfigurationError, ServerClientError
from dstack._internal.core.models.configurations import ConfigurationType
from dstack._internal.core.models.configurations import RunConfigurationType
from dstack._internal.core.models.runs import JobTerminationReason
from dstack._internal.core.services.configs import ConfigManager
from dstack._internal.utils.logging import get_logger
Expand All @@ -39,7 +39,7 @@ def _register(self):
"-h",
"--help",
nargs="?",
type=ConfigurationType,
type=RunConfigurationType,
default=NOTSET,
help="Show this help message and exit. TYPE is one of [code]task[/], [code]dev-environment[/], [code]service[/]",
dest="help",
Expand Down Expand Up @@ -83,7 +83,7 @@ def _register(self):
def _command(self, args: argparse.Namespace):
if args.help is not NOTSET:
if args.help is not None:
run_configurators_mapping[ConfigurationType(args.help)].register(self._parser)
run_configurators_mapping[RunConfigurationType(args.help)].register(self._parser)
else:
BaseRunConfigurator.register(self._parser)
self._parser.print_help()
Expand All @@ -102,7 +102,7 @@ def _command(self, args: argparse.Namespace):
apply_profile_args(args, conf)
logger.debug("Configuration loaded: %s", configuration_path)
parser = argparse.ArgumentParser()
configurator = run_configurators_mapping[ConfigurationType(conf.type)]
configurator = run_configurators_mapping[RunConfigurationType(conf.type)]
configurator.register(parser)
known, unknown = parser.parse_known_args(args.unknown)
configurator.apply(known, unknown, conf)
Expand Down Expand Up @@ -176,7 +176,7 @@ def _command(self, args: argparse.Namespace):
)

if run.status in (RunStatus.RUNNING, RunStatus.DONE):
if run._run.run_spec.configuration.type == ConfigurationType.SERVICE.value:
if run._run.run_spec.configuration.type == RunConfigurationType.SERVICE.value:
console.print(
f"Service is published at [link={run.service_url}]{run.service_url}[/]\n"
)
Expand Down
2 changes: 2 additions & 0 deletions src/dstack/_internal/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from rich.markup import escape
from rich_argparse import RichHelpFormatter

from dstack._internal.cli.commands.apply import ApplyCommand
from dstack._internal.cli.commands.config import ConfigCommand
from dstack._internal.cli.commands.gateway import GatewayCommand
from dstack._internal.cli.commands.init import InitCommand
Expand Down Expand Up @@ -50,6 +51,7 @@ def main():
parser.set_defaults(func=lambda _: parser.print_help())

subparsers = parser.add_subparsers(metavar="COMMAND")
ApplyCommand.register(subparsers)
ConfigCommand.register(subparsers)
GatewayCommand.register(subparsers)
PoolCommand.register(subparsers)
Expand Down
13 changes: 13 additions & 0 deletions src/dstack/_internal/cli/services/configurators/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from typing import Dict, Type

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

apply_configurators_mapping: Dict[ApplyConfigurationType, Type[BaseApplyConfigurator]] = {
cls.TYPE: cls for cls in [GatewayConfigurator]
}


def get_apply_configurator_class(configurator_type: str) -> Type[BaseApplyConfigurator]:
return apply_configurators_mapping[ApplyConfigurationType(configurator_type)]
28 changes: 28 additions & 0 deletions src/dstack/_internal/cli/services/configurators/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import argparse
from abc import ABC, abstractmethod
from typing import List

from dstack._internal.core.models.configurations import (
AnyApplyConfiguration,
ApplyConfigurationType,
)
from dstack.api._public import Client


class BaseApplyConfigurator(ABC):
TYPE: ApplyConfigurationType

def __init__(self, api_client: Client):
self.api_client = api_client

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

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

def apply_args(
self, args: argparse.Namespace, unknown: List[str], conf: AnyApplyConfiguration
):
pass
58 changes: 58 additions & 0 deletions src/dstack/_internal/cli/services/configurators/gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import argparse

from dstack._internal.cli.services.configurators.base import BaseApplyConfigurator
from dstack._internal.cli.utils.common import confirm_ask, console
from dstack._internal.cli.utils.gateway import print_gateways_table
from dstack._internal.core.errors import ResourceNotExistsError
from dstack._internal.core.models.configurations import ApplyConfigurationType
from dstack._internal.core.models.gateways import GatewayConfiguration


class GatewayConfigurator(BaseApplyConfigurator):
TYPE: ApplyConfigurationType = ApplyConfigurationType.GATEWAY

def apply_configuration(self, conf: GatewayConfiguration, args: argparse.Namespace):
# TODO: Show apply plan
# TODO: Update gateway in-place when domain/default change
confirmed = False
if conf.name is not None:
try:
gateway = self.api_client.client.gateways.get(
project_name=self.api_client.project, gateway_name=conf.name
)
except ResourceNotExistsError:
pass
else:
if gateway.configuration == conf:
if not args.force:
console.print(
"Gateway configuration has not changed. Use --force to recreate the gateway."
)
return
if not args.yes and not confirm_ask(
"Gateway configuration has not changed. Re-create the gateway?"
):
console.print("\nExiting...")
return
elif not args.yes and not confirm_ask(
f"Gateway [code]{conf.name}[/] already exist. Re-create the gateway?"
):
console.print("\nExiting...")
return
confirmed = True
with console.status("Deleting gateway..."):
self.api_client.client.gateways.delete(
project_name=self.api_client.project, gateways_names=[conf.name]
)
if not confirmed and not args.yes:
if not confirm_ask(
f"Gateway [code]{conf.name}[/] does not exist yet. Create the gateway?"
):
console.print("\nExiting...")
return
with console.status("Creating gateway..."):
gateway = self.api_client.client.gateways.create(
project_name=self.api_client.project,
configuration=conf,
)
print_gateways_table([gateway])
Loading

0 comments on commit d897cb8

Please sign in to comment.