Skip to content
Open
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
159 changes: 159 additions & 0 deletions src/dstack/_internal/cli/commands/export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import argparse
from typing import Any, Union

from rich.table import Table

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.completion import ExportNameCompleter
from dstack._internal.cli.utils.common import add_row_from_dict, confirm_ask, console
from dstack._internal.core.models.exports import Export


class ExportCommand(APIBaseCommand):
NAME = "export"
DESCRIPTION = "Manage exports"

def _register(self):
super()._register()
self._parser.set_defaults(subfunc=self._list)
subparsers = self._parser.add_subparsers(dest="action")

list_parser = subparsers.add_parser(
"list", help="List exports", formatter_class=self._parser.formatter_class
)
list_parser.set_defaults(subfunc=self._list)

create_parser = subparsers.add_parser(
"create", help="Create an export", formatter_class=self._parser.formatter_class
)
create_parser.add_argument(
"name",
help="The name of the export",
)
create_parser.add_argument(
"--importer",
action="append",
dest="importers",
help="Importer project name (can be specified multiple times)",
default=[],
)
create_parser.add_argument(
"--fleet",
action="append",
dest="fleets",
help="Fleet name to export (can be specified multiple times)",
default=[],
)
create_parser.set_defaults(subfunc=self._create)

update_parser = subparsers.add_parser(
"update", help="Update an export", formatter_class=self._parser.formatter_class
)
update_parser.add_argument(
"name",
help="The name of the export",
).completer = ExportNameCompleter() # type: ignore[attr-defined]
update_parser.add_argument(
"--add-importer",
action="append",
dest="add_importers",
help="Importer project name to add (can be specified multiple times)",
default=[],
)
update_parser.add_argument(
"--remove-importer",
action="append",
dest="remove_importers",
help="Importer project name to remove (can be specified multiple times)",
default=[],
)
update_parser.add_argument(
"--add-fleet",
action="append",
dest="add_fleets",
help="Fleet name to add (can be specified multiple times)",
default=[],
)
update_parser.add_argument(
"--remove-fleet",
action="append",
dest="remove_fleets",
help="Fleet name to remove (can be specified multiple times)",
default=[],
)
update_parser.set_defaults(subfunc=self._update)

delete_parser = subparsers.add_parser(
"delete", help="Delete an export", formatter_class=self._parser.formatter_class
)
delete_parser.add_argument(
"name",
help="The name of the export",
).completer = ExportNameCompleter() # type: ignore[attr-defined]
delete_parser.add_argument(
"-y", "--yes", help="Don't ask for confirmation", action="store_true"
)
delete_parser.set_defaults(subfunc=self._delete)

def _command(self, args: argparse.Namespace):
super()._command(args)
args.subfunc(args)

def _list(self, args: argparse.Namespace):
exports = self.api.client.exports.list(self.api.project)
print_exports_table(exports)

def _create(self, args: argparse.Namespace):
with console.status("Creating export..."):
export = self.api.client.exports.create(
project_name=self.api.project,
name=args.name,
importer_projects=args.importers,
exported_fleets=args.fleets,
)
print_exports_table([export])

def _update(self, args: argparse.Namespace):
with console.status("Updating export..."):
export = self.api.client.exports.update(
project_name=self.api.project,
name=args.name,
add_importer_projects=args.add_importers,
remove_importer_projects=args.remove_importers,
add_exported_fleets=args.add_fleets,
remove_exported_fleets=args.remove_fleets,
)
print_exports_table([export])

def _delete(self, args: argparse.Namespace):
if not args.yes and not confirm_ask(f"Delete the export [code]{args.name}[/]?"):
console.print("\nExiting...")
return

with console.status("Deleting export..."):
self.api.client.exports.delete(project_name=self.api.project, name=args.name)

console.print(f"Export [code]{args.name}[/] deleted")


def print_exports_table(exports: list[Export]):
table = Table(box=None)
table.add_column("NAME", no_wrap=True)
table.add_column("FLEETS")
table.add_column("IMPORTERS")

for export in exports:
fleets = (
", ".join([f.name for f in export.exported_fleets]) if export.exported_fleets else "-"
)
importers = ", ".join([i.project_name for i in export.imports]) if export.imports else "-"

row: dict[Union[str, int], Any] = {
"NAME": export.name,
"FLEETS": fleets,
"IMPORTERS": importers,
}
add_row_from_dict(table, row)

console.print(table)
console.print()
2 changes: 2 additions & 0 deletions src/dstack/_internal/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from dstack._internal.cli.commands.completion import CompletionCommand
from dstack._internal.cli.commands.delete import DeleteCommand
from dstack._internal.cli.commands.event import EventCommand
from dstack._internal.cli.commands.export import ExportCommand
from dstack._internal.cli.commands.fleet import FleetCommand
from dstack._internal.cli.commands.gateway import GatewayCommand
from dstack._internal.cli.commands.init import InitCommand
Expand Down Expand Up @@ -66,6 +67,7 @@ def main():
AttachCommand.register(subparsers)
DeleteCommand.register(subparsers)
EventCommand.register(subparsers)
ExportCommand.register(subparsers)
FleetCommand.register(subparsers)
GatewayCommand.register(subparsers)
InitCommand.register(subparsers)
Expand Down
5 changes: 5 additions & 0 deletions src/dstack/_internal/cli/services/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ def fetch_resource_names(self, api: Client) -> Iterable[str]:
return [r.name for r in api.client.secrets.list(api.project)]


class ExportNameCompleter(BaseAPINameCompleter):
def fetch_resource_names(self, api: Client) -> Iterable[str]:
return [r.name for r in api.client.exports.list(api.project)]


class ProjectNameCompleter(BaseCompleter):
"""
Completer for local project names.
Expand Down
19 changes: 19 additions & 0 deletions src/dstack/_internal/core/models/exports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import uuid

from dstack._internal.core.models.common import CoreModel


class ExportImport(CoreModel):
project_name: str


class ExportedFleet(CoreModel):
id: uuid.UUID
name: str


class Export(CoreModel):
id: uuid.UUID
name: str
imports: list[ExportImport]
exported_fleets: list[ExportedFleet]
2 changes: 2 additions & 0 deletions src/dstack/_internal/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
auth,
backends,
events,
exports,
files,
fleets,
gateways,
Expand Down Expand Up @@ -253,6 +254,7 @@ def register_routes(app: FastAPI, ui: bool = True):
app.include_router(files.router)
app.include_router(events.root_router)
app.include_router(templates.router)
app.include_router(exports.project_router)

@app.exception_handler(ForbiddenError)
async def forbidden_error_handler(request: Request, exc: ForbiddenError):
Expand Down
18 changes: 12 additions & 6 deletions src/dstack/_internal/server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
from dstack._internal.utils.logging import get_logger

logger = get_logger(__name__)
# Default options (save-update, merge) + delete-orphan + delete (required by delete-orphan)
# delete-orphan allows to automatically delete entities removed from the relationship
CASCADE_DEFAULT_WITH_DELETE_ORPHAN = "save-update, merge, delete-orphan, delete"


class NaiveDateTime(TypeDecorator):
Expand Down Expand Up @@ -760,10 +763,7 @@ class InstanceModel(PipelineModelMixin, BaseModel):

volume_attachments: Mapped[List["VolumeAttachmentModel"]] = relationship(
back_populates="instance",
# Add delete-orphan option so that removing entries from volume_attachments
# automatically marks them for deletion.
# SQLAlchemy requires delete when using delete-orphan.
cascade="save-update, merge, delete-orphan, delete",
cascade=CASCADE_DEFAULT_WITH_DELETE_ORPHAN,
)

__table_args__ = (
Expand Down Expand Up @@ -1043,8 +1043,14 @@ class ExportModel(BaseModel):
)
project: Mapped["ProjectModel"] = relationship()
created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)
imports: Mapped[List["ImportModel"]] = relationship(back_populates="export")
exported_fleets: Mapped[List["ExportedFleetModel"]] = relationship(back_populates="export")
imports: Mapped[List["ImportModel"]] = relationship(
back_populates="export",
cascade=CASCADE_DEFAULT_WITH_DELETE_ORPHAN,
)
exported_fleets: Mapped[List["ExportedFleetModel"]] = relationship(
back_populates="export",
cascade=CASCADE_DEFAULT_WITH_DELETE_ORPHAN,
)


class ImportModel(BaseModel):
Expand Down
84 changes: 84 additions & 0 deletions src/dstack/_internal/server/routers/exports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from typing import Annotated

from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from dstack._internal.core.models.exports import Export
from dstack._internal.server.db import get_session
from dstack._internal.server.models import ProjectModel, UserModel
from dstack._internal.server.schemas.exports import (
CreateExportRequest,
DeleteExportRequest,
UpdateExportRequest,
)
from dstack._internal.server.security.permissions import ProjectAdmin, ProjectMember
from dstack._internal.server.services import exports as exports_services
from dstack._internal.server.utils.routers import get_base_api_additional_responses

project_router = APIRouter(
prefix="/api/project/{project_name}/exports",
tags=["exports"],
responses=get_base_api_additional_responses(),
)


@project_router.post("/create", response_model=Export)
async def create_export(
body: CreateExportRequest,
session: Annotated[AsyncSession, Depends(get_session)],
user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectAdmin())],
):
user, project = user_project
return await exports_services.create_export(
session=session,
project=project,
user=user,
name=body.name,
importer_project_names=body.importer_projects,
exported_fleet_names=body.exported_fleets,
)


@project_router.post("/update", response_model=Export)
async def update_export(
body: UpdateExportRequest,
session: Annotated[AsyncSession, Depends(get_session)],
user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectAdmin())],
):
user, project = user_project
return await exports_services.update_export(
session=session,
project=project,
user=user,
name=body.name,
add_importer_project_names=body.add_importer_projects,
remove_importer_project_names=body.remove_importer_projects,
add_exported_fleet_names=body.add_exported_fleets,
remove_exported_fleet_names=body.remove_exported_fleets,
)


@project_router.post("/delete")
async def delete_export(
body: DeleteExportRequest,
session: Annotated[AsyncSession, Depends(get_session)],
user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectAdmin())],
):
_, project = user_project
await exports_services.delete_export(
session=session,
project=project,
name=body.name,
)


@project_router.post("/list", response_model=list[Export])
async def list_exports(
session: Annotated[AsyncSession, Depends(get_session)],
user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectMember())],
):
_, project = user_project
return await exports_services.list_exports(
session=session,
project=project,
)
19 changes: 19 additions & 0 deletions src/dstack/_internal/server/schemas/exports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from dstack._internal.core.models.common import CoreModel


class CreateExportRequest(CoreModel):
name: str
importer_projects: list[str] = []
exported_fleets: list[str] = []


class UpdateExportRequest(CoreModel):
name: str
add_importer_projects: list[str] = []
remove_importer_projects: list[str] = []
add_exported_fleets: list[str] = []
remove_exported_fleets: list[str] = []


class DeleteExportRequest(CoreModel):
name: str
Loading