diff --git a/docs/docs/reference/cli/dstack/import.md b/docs/docs/reference/cli/dstack/import.md index 18e5309a0..168b681c9 100644 --- a/docs/docs/reference/cli/dstack/import.md +++ b/docs/docs/reference/cli/dstack/import.md @@ -17,3 +17,18 @@ $ dstack import list --help ``` + +## dstack import delete + +The `dstack import delete` command deletes the specified import. This makes the imported resources unavailable in your project, while they still exist in the host project. + +##### Usage + +
+ +```shell +$ dstack import delete --help +#GENERATE# +``` + +
diff --git a/src/dstack/_internal/cli/commands/import_.py b/src/dstack/_internal/cli/commands/import_.py index 2f10d5acc..2d8c4a103 100644 --- a/src/dstack/_internal/cli/commands/import_.py +++ b/src/dstack/_internal/cli/commands/import_.py @@ -3,7 +3,8 @@ from rich.table import Table from dstack._internal.cli.commands import APIBaseCommand -from dstack._internal.cli.utils.common import add_row_from_dict, console +from dstack._internal.cli.services.completion import ImportNameCompleter +from dstack._internal.cli.utils.common import add_row_from_dict, confirm_ask, console from dstack._internal.core.models.imports import Import @@ -21,6 +22,18 @@ def _register(self): ) list_parser.set_defaults(subfunc=self._list) + delete_parser = subparsers.add_parser( + "delete", help="Delete an import", formatter_class=self._parser.formatter_class + ) + delete_parser.add_argument( + "name", + help="The import to delete, in `export-project/export-name` format", + ).completer = ImportNameCompleter() # 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) @@ -29,6 +42,27 @@ def _list(self, args: argparse.Namespace): imports = self.api.client.imports.list(self.api.project) print_imports_table(imports) + def _delete(self, args: argparse.Namespace): + parts = args.name.split("/") + if len(parts) != 2 or not parts[0] or not parts[1]: + self._parser.error( + f"Invalid format: {args.name!r}. Expected /" + ) + export_project_name, export_name = parts + + if not args.yes and not confirm_ask(f"Delete the import [code]{args.name}[/]?"): + console.print("\nExiting...") + return + + with console.status("Deleting import..."): + self.api.client.imports.delete( + project_name=self.api.project, + export_project_name=export_project_name, + export_name=export_name, + ) + + console.print(f"Import [code]{args.name}[/] deleted") + def print_imports_table(imports: list[Import]): table = Table(box=None) diff --git a/src/dstack/_internal/cli/services/completion.py b/src/dstack/_internal/cli/services/completion.py index aee036835..4fa276945 100644 --- a/src/dstack/_internal/cli/services/completion.py +++ b/src/dstack/_internal/cli/services/completion.py @@ -85,6 +85,14 @@ def fetch_resource_names(self, api: Client) -> Iterable[str]: return [r.name for r in api.client.exports.list(api.project)] +class ImportNameCompleter(BaseAPINameCompleter): + def fetch_resource_names(self, api: Client) -> Iterable[str]: + return [ + f"{imp.export.project_name}/{imp.export.name}" + for imp in api.client.imports.list(api.project) + ] + + class ProjectNameCompleter(BaseCompleter): """ Completer for local project names. diff --git a/src/dstack/_internal/server/routers/imports.py b/src/dstack/_internal/server/routers/imports.py index f39afab3b..eddedba76 100644 --- a/src/dstack/_internal/server/routers/imports.py +++ b/src/dstack/_internal/server/routers/imports.py @@ -6,7 +6,8 @@ from dstack._internal.core.models.imports import Import from dstack._internal.server.db import get_session from dstack._internal.server.models import ProjectModel, UserModel -from dstack._internal.server.security.permissions import ProjectMember +from dstack._internal.server.schemas.imports import DeleteImportRequest +from dstack._internal.server.security.permissions import ProjectAdmin, ProjectMember from dstack._internal.server.services import imports as imports_services from dstack._internal.server.utils.routers import get_base_api_additional_responses @@ -17,6 +18,21 @@ ) +@project_router.post("/delete") +async def delete_import( + body: DeleteImportRequest, + session: Annotated[AsyncSession, Depends(get_session)], + user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectAdmin())], +): + _, project = user_project + await imports_services.delete_import( + session=session, + project=project, + export_name=body.export_name, + export_project_name=body.export_project_name, + ) + + @project_router.post("/list", response_model=list[Import]) async def list_imports( session: Annotated[AsyncSession, Depends(get_session)], diff --git a/src/dstack/_internal/server/schemas/imports.py b/src/dstack/_internal/server/schemas/imports.py new file mode 100644 index 000000000..3f2d71243 --- /dev/null +++ b/src/dstack/_internal/server/schemas/imports.py @@ -0,0 +1,10 @@ +from dstack._internal.core.models.common import CoreModel + + +class DeleteImportRequest(CoreModel): + """ + Imports are unnamed, so they are deleted using the name and project of their export. + """ + + export_name: str + export_project_name: str diff --git a/src/dstack/_internal/server/services/imports.py b/src/dstack/_internal/server/services/imports.py index 925e31fac..f6d8b6c7a 100644 --- a/src/dstack/_internal/server/services/imports.py +++ b/src/dstack/_internal/server/services/imports.py @@ -2,6 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, selectinload +from dstack._internal.core.errors import ResourceNotExistsError from dstack._internal.core.models.imports import Import, ImportExport, ImportExportedFleet from dstack._internal.server.models import ( ExportedFleetModel, @@ -10,6 +11,8 @@ ImportModel, ProjectModel, ) +from dstack._internal.server.services.exports import get_export_model_by_name_for_update +from dstack._internal.server.services.projects import get_project_model_by_name async def list_imports(session: AsyncSession, project: ProjectModel) -> list[Import]: @@ -36,6 +39,33 @@ async def list_imports(session: AsyncSession, project: ProjectModel) -> list[Imp return [import_model_to_import(imp) for imp in imports] +async def delete_import( + session: AsyncSession, + project: ProjectModel, + export_name: str, + export_project_name: str, +) -> None: + # Always the same error, so as not to expose the existence of exports + # that are not imported in this project. + not_found_error = ResourceNotExistsError( + f"Import '{export_project_name}/{export_name}' not found in project {project.name!r}" + ) + exporter_project = await get_project_model_by_name(session, export_project_name) + if exporter_project is None: + raise not_found_error + async with get_export_model_by_name_for_update( + session, exporter_project, export_name + ) as export: + if export is None: + raise not_found_error + if project.name.lower() not in {imp.project.name.lower() for imp in export.imports}: + raise not_found_error + export.imports = [ + imp for imp in export.imports if imp.project.name.lower() != project.name.lower() + ] + await session.commit() + + def import_model_to_import(import_model: ImportModel) -> Import: return Import( id=import_model.id, diff --git a/src/dstack/api/server/_imports.py b/src/dstack/api/server/_imports.py index 746803518..bcc1abb16 100644 --- a/src/dstack/api/server/_imports.py +++ b/src/dstack/api/server/_imports.py @@ -3,6 +3,7 @@ from pydantic import parse_obj_as from dstack._internal.core.models.imports import Import +from dstack._internal.server.schemas.imports import DeleteImportRequest from dstack.api.server._group import APIClientGroup @@ -10,3 +11,9 @@ class ImportsAPIClient(APIClientGroup): def list(self, project_name: str) -> List[Import]: resp = self._request(f"/api/project/{project_name}/imports/list") return parse_obj_as(List[Import.__response__], resp.json()) + + def delete(self, *, project_name: str, export_project_name: str, export_name: str) -> None: + body = DeleteImportRequest( + export_project_name=export_project_name, export_name=export_name + ) + self._request(f"/api/project/{project_name}/imports/delete", body=body.json()) diff --git a/src/tests/_internal/server/routers/test_imports.py b/src/tests/_internal/server/routers/test_imports.py index 89b0b211a..1551c5031 100644 --- a/src/tests/_internal/server/routers/test_imports.py +++ b/src/tests/_internal/server/routers/test_imports.py @@ -2,9 +2,11 @@ import pytest from httpx import AsyncClient +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from dstack._internal.core.models.users import GlobalRole, ProjectRole +from dstack._internal.server.models import ExportModel, ImportModel from dstack._internal.server.services.projects import add_project_member from dstack._internal.server.testing.common import ( create_export, @@ -23,6 +25,119 @@ ] +class TestDeleteImport: + async def test_returns_403_if_not_authenticated(self, client: AsyncClient): + response = await client.post( + "/api/project/TestProject/imports/delete", + json={"export_name": "test-export", "export_project_name": "ExporterProject"}, + ) + assert response.status_code in [401, 403] + + async def test_returns_403_if_not_admin(self, session: AsyncSession, client: AsyncClient): + user = await create_user(session=session, global_role=GlobalRole.USER) + exporter_project = await create_project( + session=session, name="ExporterProject", owner=user + ) + importer_project = await create_project( + session=session, name="ImporterProject", owner=user + ) + # The user is admin of the exporter project, but not of the importer + await add_project_member( + session=session, project=exporter_project, user=user, project_role=ProjectRole.ADMIN + ) + await add_project_member( + session=session, project=importer_project, user=user, project_role=ProjectRole.USER + ) + response = await client.post( + f"/api/project/{importer_project.name}/imports/delete", + headers=get_auth_headers(user.token), + json={"export_name": "test-export", "export_project_name": "ExporterProject"}, + ) + assert response.status_code == 403 + + async def test_deletes_import(self, session: AsyncSession, client: AsyncClient): + user = await create_user(session=session, global_role=GlobalRole.USER) + importer_project = await create_project( + session=session, name="ImporterProject", owner=user + ) + await add_project_member( + session=session, project=importer_project, user=user, project_role=ProjectRole.ADMIN + ) + exporter_project = await create_project(session=session, name="ExporterProject") + fleet = await create_fleet( + session=session, + project=exporter_project, + name="fleet1", + spec=get_fleet_spec(get_ssh_fleet_configuration()), + ) + await create_export( + session=session, + exporter_project=exporter_project, + importer_projects=[importer_project], + exported_fleets=[fleet], + name="test-export", + ) + + response = await client.post( + f"/api/project/{importer_project.name}/imports/delete", + headers=get_auth_headers(user.token), + json={ + "export_name": "test-export", + "export_project_name": "ExPoRtErPrOjEcT", # case-insensitive + }, + ) + assert response.status_code == 200 + + res = await session.execute(select(func.count()).select_from(ImportModel)) + assert res.scalar_one() == 0 + res = await session.execute(select(func.count()).select_from(ExportModel)) + assert res.scalar_one() == 1 + + async def test_returns_400_for_nonexistent_import( + self, session: AsyncSession, client: AsyncClient + ): + user = await create_user(session=session, global_role=GlobalRole.USER) + importer_project = await create_project( + session=session, name="ImporterProject", owner=user + ) + await add_project_member( + session=session, project=importer_project, user=user, project_role=ProjectRole.ADMIN + ) + + exporter_project = await create_project( + session=session, name="ExporterProject", owner=user + ) + await create_export( + session=session, + exporter_project=exporter_project, + importer_projects=[], + exported_fleets=[], + name="test-export", + ) + + async def assert_not_found(export_project_name, export_name): + response = await client.post( + f"/api/project/{importer_project.name}/imports/delete", + headers=get_auth_headers(user.token), + json={"export_name": export_name, "export_project_name": export_project_name}, + ) + assert response.status_code == 400 + assert response.json()["detail"][0]["code"] == "resource_not_exists" + # The error should be the same regardless of what wasn't found + # (the exporter, the export, or the import), + # so that users cannot infer the existence of exports they are not given access to. + assert response.json()["detail"][0]["msg"] == ( + f"Import '{export_project_name}/{export_name}' not found in project 'ImporterProject'" + ) + + # Exporter not found + await assert_not_found(export_project_name="WrongProject", export_name="test-export") + # Export not found + await assert_not_found(export_project_name="ExporterProject", export_name="wrong-export") + # Import not found + await assert_not_found(export_project_name="ExporterProject", export_name="test-export") + + class TestListImports: async def test_returns_403_if_not_authenticated(self, client: AsyncClient): response = await client.post(