From 4857471dc9461e8ffa9c561c98e9fac8a58d4cd6 Mon Sep 17 00:00:00 2001 From: Jvst Me Date: Thu, 19 Mar 2026 17:23:56 +0100 Subject: [PATCH] `/imports/list` API and `dstack import list` CLI Add API and CLI for viewing imports. ```shell $ dstack import list NAME FLEETS team-a/my-export my-fleet, another-fleet ``` --- docs/docs/concepts/exports.md | 21 +- docs/docs/reference/cli/dstack/import.md | 19 ++ mkdocs.yml | 1 + src/dstack/_internal/cli/commands/import_.py | 54 +++++ src/dstack/_internal/cli/main.py | 2 + src/dstack/_internal/core/models/imports.py | 20 ++ src/dstack/_internal/server/app.py | 2 + .../_internal/server/routers/imports.py | 29 +++ .../_internal/server/services/imports.py | 54 +++++ src/dstack/api/server/__init__.py | 5 + src/dstack/api/server/_imports.py | 12 + .../_internal/server/routers/test_imports.py | 221 ++++++++++++++++++ 12 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 docs/docs/reference/cli/dstack/import.md create mode 100644 src/dstack/_internal/cli/commands/import_.py create mode 100644 src/dstack/_internal/core/models/imports.py create mode 100644 src/dstack/_internal/server/routers/imports.py create mode 100644 src/dstack/_internal/server/services/imports.py create mode 100644 src/dstack/api/server/_imports.py create mode 100644 src/tests/_internal/server/routers/test_imports.py diff --git a/docs/docs/concepts/exports.md b/docs/docs/concepts/exports.md index 3b3194146..924b8bfe4 100644 --- a/docs/docs/concepts/exports.md +++ b/docs/docs/concepts/exports.md @@ -116,8 +116,20 @@ Use `-y` to skip the confirmation prompt. ## Access imported fleets -From the importer project's perspective, exported fleets appear automatically in `dstack fleet list` -with a `/` prefix: +From the importer project's perspective, use `dstack import list` (or simply `dstack import`) to list all imports in the project — i.e., all exports from other projects that this project has been granted access to: + +
+ +```shell +$ dstack import list + NAME FLEETS + team-a/my-export my-fleet, another-fleet + +``` + +
+ +Imported fleets also appear in `dstack fleet list` in the `/` format:
@@ -139,5 +151,6 @@ Imported fleets can be used for runs just like the project's own fleets. !!! info "What's next?" 1. Check the [`dstack export` CLI reference](../reference/cli/dstack/export.md) - 2. Learn how to manage [fleets](fleets.md) - 3. Read about [projects](projects.md) and project roles + 1. Check the [`dstack import` CLI reference](../reference/cli/dstack/import.md) + 1. Learn how to manage [fleets](fleets.md) + 1. Read about [projects](projects.md) and project roles diff --git a/docs/docs/reference/cli/dstack/import.md b/docs/docs/reference/cli/dstack/import.md new file mode 100644 index 000000000..18e5309a0 --- /dev/null +++ b/docs/docs/reference/cli/dstack/import.md @@ -0,0 +1,19 @@ +# dstack import + +The `dstack import` commands list resources imported into the project from other projects. +See [Exports](../../../concepts/exports.md) for details. + +## dstack import list + +The `dstack import list` command lists all imports in the project. + +##### Usage + +
+ +```shell +$ dstack import list --help +#GENERATE# +``` + +
diff --git a/mkdocs.yml b/mkdocs.yml index 3c9f1ee1d..6b6dd3792 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -273,6 +273,7 @@ nav: - dstack gateway: docs/reference/cli/dstack/gateway.md - dstack secret: docs/reference/cli/dstack/secret.md - dstack export: docs/reference/cli/dstack/export.md + - dstack import: docs/reference/cli/dstack/import.md - API: - Python API: docs/reference/api/python/index.md - REST API: docs/reference/api/rest/index.md diff --git a/src/dstack/_internal/cli/commands/import_.py b/src/dstack/_internal/cli/commands/import_.py new file mode 100644 index 000000000..09752bbaa --- /dev/null +++ b/src/dstack/_internal/cli/commands/import_.py @@ -0,0 +1,54 @@ +import argparse +from typing import Any, Union + +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.core.models.imports import Import + + +class ImportCommand(APIBaseCommand): + NAME = "import" + DESCRIPTION = "Manage imports" + + 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 imports", formatter_class=self._parser.formatter_class + ) + list_parser.set_defaults(subfunc=self._list) + + def _command(self, args: argparse.Namespace): + super()._command(args) + args.subfunc(args) + + def _list(self, args: argparse.Namespace): + imports = self.api.client.imports.list(self.api.project) + print_imports_table(imports) + + +def print_imports_table(imports: list[Import]): + table = Table(box=None) + table.add_column("NAME", no_wrap=True) + table.add_column("FLEETS") + + for imp in imports: + name = f"{imp.export.project_name}/{imp.export.name}" + fleets = ( + ", ".join([f.name for f in imp.export.exported_fleets]) + if imp.export.exported_fleets + else "-" + ) + + row: dict[Union[str, int], Any] = { + "NAME": name, + "FLEETS": fleets, + } + add_row_from_dict(table, row) + + console.print(table) + console.print() diff --git a/src/dstack/_internal/cli/main.py b/src/dstack/_internal/cli/main.py index be1f2605d..32f15a95f 100644 --- a/src/dstack/_internal/cli/main.py +++ b/src/dstack/_internal/cli/main.py @@ -12,6 +12,7 @@ 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.import_ import ImportCommand from dstack._internal.cli.commands.init import InitCommand from dstack._internal.cli.commands.login import LoginCommand from dstack._internal.cli.commands.logs import LogsCommand @@ -69,6 +70,7 @@ def main(): EventCommand.register(subparsers) ExportCommand.register(subparsers) FleetCommand.register(subparsers) + ImportCommand.register(subparsers) GatewayCommand.register(subparsers) InitCommand.register(subparsers) OfferCommand.register(subparsers) diff --git a/src/dstack/_internal/core/models/imports.py b/src/dstack/_internal/core/models/imports.py new file mode 100644 index 000000000..7a79bde7b --- /dev/null +++ b/src/dstack/_internal/core/models/imports.py @@ -0,0 +1,20 @@ +import uuid + +from dstack._internal.core.models.common import CoreModel + + +class ImportExportedFleet(CoreModel): + id: uuid.UUID + name: str + + +class ImportExport(CoreModel): + id: uuid.UUID + name: str + project_name: str + exported_fleets: list[ImportExportedFleet] + + +class Import(CoreModel): + id: uuid.UUID + export: ImportExport diff --git a/src/dstack/_internal/server/app.py b/src/dstack/_internal/server/app.py index 0a39d5e41..02d536d9c 100644 --- a/src/dstack/_internal/server/app.py +++ b/src/dstack/_internal/server/app.py @@ -36,6 +36,7 @@ fleets, gateways, gpus, + imports, instances, logs, metrics, @@ -256,6 +257,7 @@ def register_routes(app: FastAPI, ui: bool = True): app.include_router(events.root_router) app.include_router(templates.router) app.include_router(exports.project_router) + app.include_router(imports.project_router) app.include_router(sshproxy.router) @app.exception_handler(ForbiddenError) diff --git a/src/dstack/_internal/server/routers/imports.py b/src/dstack/_internal/server/routers/imports.py new file mode 100644 index 000000000..f39afab3b --- /dev/null +++ b/src/dstack/_internal/server/routers/imports.py @@ -0,0 +1,29 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +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.services import imports as imports_services +from dstack._internal.server.utils.routers import get_base_api_additional_responses + +project_router = APIRouter( + prefix="/api/project/{project_name}/imports", + tags=["imports"], + responses=get_base_api_additional_responses(), +) + + +@project_router.post("/list", response_model=list[Import]) +async def list_imports( + session: Annotated[AsyncSession, Depends(get_session)], + user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectMember())], +): + _, project = user_project + return await imports_services.list_imports( + session=session, + project=project, + ) diff --git a/src/dstack/_internal/server/services/imports.py b/src/dstack/_internal/server/services/imports.py new file mode 100644 index 000000000..925e31fac --- /dev/null +++ b/src/dstack/_internal/server/services/imports.py @@ -0,0 +1,54 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload, selectinload + +from dstack._internal.core.models.imports import Import, ImportExport, ImportExportedFleet +from dstack._internal.server.models import ( + ExportedFleetModel, + ExportModel, + FleetModel, + ImportModel, + ProjectModel, +) + + +async def list_imports(session: AsyncSession, project: ProjectModel) -> list[Import]: + res = await session.execute( + select(ImportModel) + .where(ImportModel.project_id == project.id) + .options( + joinedload(ImportModel.export) + .load_only(ExportModel.id, ExportModel.name) + .options( + joinedload(ExportModel.project).load_only(ProjectModel.name), + selectinload( + ExportModel.exported_fleets.and_( + ExportedFleetModel.fleet.has(FleetModel.deleted == False) + ) + ) + .joinedload(ExportedFleetModel.fleet) + .load_only(FleetModel.id, FleetModel.name), + ) + ) + .order_by(ImportModel.created_at.desc()) + ) + imports = res.scalars().all() + return [import_model_to_import(imp) for imp in imports] + + +def import_model_to_import(import_model: ImportModel) -> Import: + return Import( + id=import_model.id, + export=ImportExport( + id=import_model.export.id, + name=import_model.export.name, + project_name=import_model.export.project.name, + exported_fleets=[ + ImportExportedFleet( + id=ef.fleet.id, + name=ef.fleet.name, + ) + for ef in import_model.export.exported_fleets + ], + ), + ) diff --git a/src/dstack/api/server/__init__.py b/src/dstack/api/server/__init__.py index caa1e3419..82b009863 100644 --- a/src/dstack/api/server/__init__.py +++ b/src/dstack/api/server/__init__.py @@ -22,6 +22,7 @@ from dstack.api.server._fleets import FleetsAPIClient from dstack.api.server._gateways import GatewaysAPIClient from dstack.api.server._gpus import GpusAPIClient +from dstack.api.server._imports import ImportsAPIClient from dstack.api.server._logs import LogsAPIClient from dstack.api.server._metrics import MetricsAPIClient from dstack.api.server._projects import ProjectsAPIClient @@ -132,6 +133,10 @@ def volumes(self) -> VolumesAPIClient: def exports(self) -> ExportsAPIClient: return ExportsAPIClient(self._request, self._logger) + @property + def imports(self) -> ImportsAPIClient: + return ImportsAPIClient(self._request, self._logger) + @property def files(self) -> FilesAPIClient: return FilesAPIClient(self._request, self._logger) diff --git a/src/dstack/api/server/_imports.py b/src/dstack/api/server/_imports.py new file mode 100644 index 000000000..746803518 --- /dev/null +++ b/src/dstack/api/server/_imports.py @@ -0,0 +1,12 @@ +from typing import List + +from pydantic import parse_obj_as + +from dstack._internal.core.models.imports import Import +from dstack.api.server._group import APIClientGroup + + +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()) diff --git a/src/tests/_internal/server/routers/test_imports.py b/src/tests/_internal/server/routers/test_imports.py new file mode 100644 index 000000000..89b0b211a --- /dev/null +++ b/src/tests/_internal/server/routers/test_imports.py @@ -0,0 +1,221 @@ +from typing import Optional + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from dstack._internal.core.models.users import GlobalRole, ProjectRole +from dstack._internal.server.services.projects import add_project_member +from dstack._internal.server.testing.common import ( + create_export, + create_fleet, + create_project, + create_user, + get_auth_headers, + get_fleet_spec, + get_ssh_fleet_configuration, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.usefixtures("test_db"), + pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True), +] + + +class TestListImports: + async def test_returns_403_if_not_authenticated(self, client: AsyncClient): + response = await client.post( + "/api/project/TestProject/imports/list", + ) + assert response.status_code in [401, 403] + + async def test_returns_403_if_not_member(self, 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}/imports/list", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 403 + + @pytest.mark.parametrize( + "global_role, project_role", + [ + (GlobalRole.ADMIN, None), + (GlobalRole.USER, ProjectRole.USER), + ], + ) + async def test_lists_imports( + self, + session: AsyncSession, + client: AsyncClient, + global_role: GlobalRole, + project_role: Optional[ProjectRole], + ): + user = await create_user(session=session, global_role=global_role) + importer_project = await create_project( + session=session, name="ImporterProject", owner=user + ) + if project_role: + await add_project_member( + session=session, project=importer_project, user=user, project_role=project_role + ) + + exporter_project1 = await create_project( + session=session, name="ExporterProject1", owner=user + ) + exporter_project2 = await create_project( + session=session, name="ExporterProject2", owner=user + ) + fleet1 = await create_fleet( + session=session, + project=exporter_project1, + name="fleet1", + spec=get_fleet_spec(get_ssh_fleet_configuration()), + ) + fleet2 = await create_fleet( + session=session, + project=exporter_project2, + name="fleet2", + spec=get_fleet_spec(get_ssh_fleet_configuration()), + ) + await create_export( + session=session, + exporter_project=exporter_project1, + importer_projects=[importer_project], + exported_fleets=[fleet1], + name="export1", + ) + await create_export( + session=session, + exporter_project=exporter_project2, + importer_projects=[importer_project], + exported_fleets=[fleet2], + name="export2", + ) + + response = await client.post( + f"/api/project/{importer_project.name}/imports/list", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + imports = response.json() + assert len(imports) == 2 + imports.sort(key=lambda i: i["export"]["name"]) + + assert imports[0]["export"]["name"] == "export1" + assert imports[0]["export"]["project_name"] == "ExporterProject1" + assert len(imports[0]["export"]["exported_fleets"]) == 1 + assert imports[0]["export"]["exported_fleets"][0]["name"] == "fleet1" + + assert imports[1]["export"]["name"] == "export2" + assert imports[1]["export"]["project_name"] == "ExporterProject2" + assert len(imports[1]["export"]["exported_fleets"]) == 1 + assert imports[1]["export"]["exported_fleets"][0]["name"] == "fleet2" + + @pytest.mark.parametrize( + "global_role, project_role", + [ + (GlobalRole.ADMIN, None), + (GlobalRole.USER, ProjectRole.USER), + ], + ) + async def test_returns_empty_list_when_no_imports( + self, + session: AsyncSession, + client: AsyncClient, + global_role: GlobalRole, + project_role: Optional[ProjectRole], + ): + user = await create_user(session=session, global_role=global_role) + project = await create_project(session=session, owner=user) + if project_role: + await add_project_member( + session=session, project=project, user=user, project_role=project_role + ) + + response = await client.post( + f"/api/project/{project.name}/imports/list", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + assert response.json() == [] + + async def test_not_includes_deleted_fleets(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.USER + ) + exporter_project = await create_project( + session=session, name="ExporterProject", owner=user + ) + + fleet = await create_fleet( + session=session, + project=exporter_project, + name="fleet", + spec=get_fleet_spec(get_ssh_fleet_configuration()), + ) + deleted_fleet = await create_fleet( + session=session, + project=exporter_project, + name="deleted-fleet", + spec=get_fleet_spec(get_ssh_fleet_configuration()), + deleted=True, + ) + await create_export( + session=session, + exporter_project=exporter_project, + importer_projects=[importer_project], + exported_fleets=[fleet, deleted_fleet], + name="test-export", + ) + + response = await client.post( + f"/api/project/{importer_project.name}/imports/list", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + imports = response.json() + assert len(imports) == 1 + assert imports[0]["export"]["name"] == "test-export" + assert len(imports[0]["export"]["exported_fleets"]) == 1 + assert imports[0]["export"]["exported_fleets"][0]["name"] == "fleet" + + async def test_does_not_include_other_projects_imports( + self, 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 + ) + other_project = await create_project(session=session, name="OtherProject", owner=user) + exporter_project = await create_project( + session=session, name="ExporterProject", owner=user + ) + + fleet = await create_fleet( + session=session, + project=exporter_project, + name="fleet", + spec=get_fleet_spec(get_ssh_fleet_configuration()), + ) + await create_export( + session=session, + exporter_project=exporter_project, + importer_projects=[other_project], + exported_fleets=[fleet], + name="test-export", + ) + + response = await client.post( + f"/api/project/{project.name}/imports/list", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + assert response.json() == []