Skip to content
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
17 changes: 17 additions & 0 deletions mkdocs/docs/concepts/exports.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@ Export my-export deleted

Use `-y` to skip the confirmation prompt.

### Global exports

Users with the global admin role can mark any export as a global export. Global exports are automatically imported into all projects, and their imports cannot be deleted.

<div class="termy">

```shell
$ dstack export create global-export --gateway shared-gateway --global
NAME FLEETS GATEWAYS IMPORTERS
global-export - shared-gateway *

```

Only promoting an export to global requires the global admin role. Regular project admins can add or remove resources, remove global status, or delete the export.

</div>

## Access imported resources

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:
Expand Down
32 changes: 31 additions & 1 deletion src/dstack/_internal/cli/commands/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ def _register(self):
help="Gateway name to export (can be specified multiple times)",
default=[],
)
create_parser.add_argument(
"--global",
dest="is_global",
action="store_true",
help="Make this export global (automatically imported into all projects)",
default=False,
)
create_parser.set_defaults(subfunc=self._create)

update_parser = subparsers.add_parser(
Expand Down Expand Up @@ -101,6 +108,21 @@ def _register(self):
help="Gateway name to remove (can be specified multiple times)",
default=[],
)
global_group = update_parser.add_mutually_exclusive_group()
global_group.add_argument(
"--set-global",
dest="set_global",
action="store_true",
help="Make this export global (automatically imported into all projects)",
default=False,
)
global_group.add_argument(
"--unset-global",
dest="unset_global",
action="store_true",
help="Remove the global flag from this export",
default=False,
)
update_parser.set_defaults(subfunc=self._update)

delete_parser = subparsers.add_parser(
Expand Down Expand Up @@ -128,6 +150,7 @@ def _create(self, args: argparse.Namespace):
export = self.api.client.exports.create(
project_name=self.api.project,
name=args.name,
is_global=args.is_global,
importer_projects=args.importers,
exported_fleets=args.fleets,
exported_gateways=args.gateways,
Expand All @@ -139,6 +162,8 @@ def _update(self, args: argparse.Namespace):
export = self.api.client.exports.update(
project_name=self.api.project,
name=args.name,
set_global=args.set_global,
unset_global=args.unset_global,
add_importer_projects=args.add_importers,
remove_importer_projects=args.remove_importers,
add_exported_fleets=args.add_fleets,
Expand Down Expand Up @@ -175,7 +200,12 @@ def print_exports_table(exports: list[Export]):
if export.exported_gateways
else "-"
)
importers = ", ".join([i.project_name for i in export.imports]) if export.imports else "-"
if export.is_global:
importers = "*"
else:
importers = (
", ".join([i.project_name for i in export.imports]) if export.imports else "-"
)

row = {
"NAME": export.name,
Expand Down
6 changes: 6 additions & 0 deletions src/dstack/_internal/core/compatibility/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@

def get_create_export_excludes(request: CreateExportRequest) -> IncludeExcludeDictType:
excludes: IncludeExcludeDictType = {}
if not request.is_global:
excludes["is_global"] = True
if not request.exported_gateways:
excludes["exported_gateways"] = True
return excludes


def get_update_export_excludes(request: UpdateExportRequest) -> IncludeExcludeDictType:
excludes: IncludeExcludeDictType = {}
if not request.set_global:
excludes["set_global"] = True
if not request.unset_global:
excludes["unset_global"] = True
if not request.add_exported_gateways:
excludes["add_exported_gateways"] = True
if not request.remove_exported_gateways:
Expand Down
1 change: 1 addition & 0 deletions src/dstack/_internal/core/models/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class ExportedGateway(CoreModel):
class Export(CoreModel):
id: uuid.UUID
name: str
is_global: bool = False
imports: list[ExportImport]
exported_fleets: list[ExportedFleet]
exported_gateways: list[ExportedGateway] = []
5 changes: 5 additions & 0 deletions src/dstack/_internal/server/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
GLOBAL_EXPORTS_LOCK_NAMESPACE = "global_exports"
"""
Lock used to avoid race conditions between promoting an export to global and creating new projects.
Ensures that all projects always import all global exports.
"""
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Add ExportModel.is_global

Revision ID: 201cb7ccd0d3
Revises: 205690dfeec2
Create Date: 2026-05-13 07:24:06.321892+00:00

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "201cb7ccd0d3"
down_revision = "205690dfeec2"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("exports", schema=None) as batch_op:
batch_op.add_column(
sa.Column("is_global", sa.Boolean(), server_default=sa.false(), nullable=False)
)

# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("exports", schema=None) as batch_op:
batch_op.drop_column("is_global")

# ### end Alembic commands ###
1 change: 1 addition & 0 deletions src/dstack/_internal/server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,7 @@ class ExportModel(BaseModel):
ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
project: Mapped["ProjectModel"] = relationship()
is_global: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)
imports: Mapped[List["ImportModel"]] = relationship(
back_populates="export",
Expand Down
3 changes: 3 additions & 0 deletions src/dstack/_internal/server/routers/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ async def create_export(
project=project,
user=user,
name=body.name,
is_global=body.is_global,
importer_project_names=body.importer_projects,
exported_fleet_names=body.exported_fleets,
exported_gateway_names=body.exported_gateways,
Expand All @@ -52,6 +53,8 @@ async def update_export(
project=project,
user=user,
name=body.name,
set_global=body.set_global,
unset_global=body.unset_global,
add_importer_project_names=body.add_importer_projects,
remove_importer_project_names=body.remove_importer_projects,
add_exported_fleet_names=body.add_exported_fleets,
Expand Down
3 changes: 3 additions & 0 deletions src/dstack/_internal/server/schemas/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@

class CreateExportRequest(CoreModel):
name: str
is_global: bool = False
importer_projects: list[str] = []
exported_fleets: list[str] = []
exported_gateways: list[str] = []


class UpdateExportRequest(CoreModel):
name: str
set_global: bool = False
unset_global: bool = False
add_importer_projects: list[str] = []
remove_importer_projects: list[str] = []
add_exported_fleets: list[str] = []
Expand Down
85 changes: 80 additions & 5 deletions src/dstack/_internal/server/services/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from sqlalchemy.orm import selectinload

from dstack._internal.core.errors import (
ForbiddenError,
ResourceExistsError,
ResourceNotExistsError,
ServerClientError,
Expand All @@ -20,6 +21,7 @@
)
from dstack._internal.core.models.users import GlobalRole
from dstack._internal.core.services import validate_dstack_resource_name
from dstack._internal.server.const import GLOBAL_EXPORTS_LOCK_NAMESPACE
from dstack._internal.server.db import get_db, is_db_postgres, is_db_sqlite
from dstack._internal.server.models import (
ExportedFleetModel,
Expand All @@ -34,9 +36,14 @@
)
from dstack._internal.server.services.fleets import get_fleet_spec, list_project_fleet_models
from dstack._internal.server.services.gateways import list_project_gateway_models
from dstack._internal.server.services.locking import get_locker, string_to_lock_id
from dstack._internal.server.services.locking import (
advisory_lock_ctx,
get_locker,
string_to_lock_id,
)
from dstack._internal.server.services.projects import (
get_user_project_role,
list_project_models,
list_user_project_models,
)

Expand Down Expand Up @@ -105,11 +112,17 @@ async def create_export(
project: ProjectModel,
user: UserModel,
name: str,
is_global: bool,
importer_project_names: list[str],
exported_fleet_names: list[str],
exported_gateway_names: list[str],
) -> Export:
validate_dstack_resource_name(name)
if is_global and importer_project_names:
raise ServerClientError(
"Do not specify any importer projects when creating a global export."
" Global exports are automatically imported in all projects"
)

lock_namespace = f"export_names_{project.name}"
if is_db_sqlite():
Expand All @@ -129,6 +142,7 @@ async def create_export(
export = ExportModel(
name=name,
project=project,
is_global=False,
imports=[],
exported_fleets=[],
exported_gateways=[],
Expand All @@ -137,7 +151,14 @@ async def create_export(
await add_exported_fleets(session, export, exported_fleet_names)
await add_exported_gateways(session, export, exported_gateway_names)
session.add(export)
await session.commit()
if is_global:
async with advisory_lock_ctx(
session, get_db().dialect_name, GLOBAL_EXPORTS_LOCK_NAMESPACE
):
await set_as_global(session, export, user)
await session.commit() # commit before releasing the lock
else:
await session.commit()
return export_model_to_export(export)


Expand All @@ -146,6 +167,8 @@ async def update_export(
project: ProjectModel,
user: UserModel,
name: str,
set_global: bool,
unset_global: bool,
add_importer_project_names: list[str],
remove_importer_project_names: list[str],
add_exported_fleet_names: list[str],
Expand All @@ -158,14 +181,24 @@ async def update_export(
raise ResourceNotExistsError(f"Export {name!r} not found in project {project.name!r}")

if (
not add_importer_project_names
not set_global
and not unset_global
and not add_importer_project_names
and not remove_importer_project_names
and not add_exported_fleet_names
and not remove_exported_fleet_names
and not add_exported_gateway_names
and not remove_exported_gateway_names
):
raise ServerClientError("No changes specified")
if set_global and unset_global:
raise ServerClientError("Cannot set and unset global at the same time")
if (set_global or unset_global) and (
add_importer_project_names or remove_importer_project_names
):
raise ServerClientError(
"Cannot change global status and add/remove importers at the same time"
)

add_importer_project_names = list(map(str.lower, add_importer_project_names))
remove_importer_project_names = list(map(str.lower, remove_importer_project_names))
Expand Down Expand Up @@ -201,11 +234,48 @@ async def update_export(
await remove_importer_projects(export, remove_importer_project_names)
await remove_exported_fleets(export, remove_exported_fleet_names)
await remove_exported_gateways(export, remove_exported_gateway_names)

await session.commit()
if unset_global:
await unset_as_global(export)
if set_global:
async with advisory_lock_ctx(
session, get_db().dialect_name, GLOBAL_EXPORTS_LOCK_NAMESPACE
):
await set_as_global(session, export, user)
await session.commit() # commit before releasing the lock
else:
await session.commit()
return export_model_to_export(export)


async def set_as_global(session: AsyncSession, export: ExportModel, user: UserModel) -> None:
"""
**NOTE**:
Should be called with the `GLOBAL_EXPORTS_LOCK_NAMESPACE` lock acquired to prevent new
projects from being created while this export is being imported into existing ones.
"""
if export.is_global:
raise ServerClientError("The export is already global")
if user.global_role != GlobalRole.ADMIN:
raise ForbiddenError("Only global admins can make the export global")
all_projects = await list_project_models(
session, load_only_attrs=[ProjectModel.id, ProjectModel.name]
)
already_importing = {imp.project_id for imp in export.imports}
for project in all_projects:
if project.id == export.project.id:
continue
if project.id in already_importing:
continue
export.imports.append(ImportModel(project=project))
export.is_global = True


async def unset_as_global(export: ExportModel) -> None:
if not export.is_global:
raise ServerClientError("The export is already not global")
export.is_global = False


async def add_importer_projects(
session: AsyncSession, user: UserModel, export: ExportModel, names: list[str]
) -> None:
Expand Down Expand Up @@ -270,6 +340,10 @@ async def add_exported_fleets(


async def remove_importer_projects(export: ExportModel, names: list[str]) -> None:
if not names:
return
if export.is_global:
raise ServerClientError("Cannot remove importers from a global export")
names = list(map(str.lower, names))
if len(names) != len(set(names)):
raise ServerClientError("Some importer projects are listed for removal more than once")
Expand Down Expand Up @@ -364,6 +438,7 @@ def export_model_to_export(export_model: ExportModel) -> Export:
return Export(
id=export_model.id,
name=export_model.name,
is_global=export_model.is_global,
imports=[
ExportImport(
project_name=import_model.project.name,
Expand Down
Loading
Loading