Skip to content

Commit 188f080

Browse files
authored
Support global exports (#3879)
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. ```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.
1 parent ea6dcb6 commit 188f080

19 files changed

Lines changed: 644 additions & 15 deletions

File tree

mkdocs/docs/concepts/exports.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,23 @@ Export my-export deleted
114114

115115
Use `-y` to skip the confirmation prompt.
116116

117+
### Global exports
118+
119+
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.
120+
121+
<div class="termy">
122+
123+
```shell
124+
$ dstack export create global-export --gateway shared-gateway --global
125+
NAME FLEETS GATEWAYS IMPORTERS
126+
global-export - shared-gateway *
127+
128+
```
129+
130+
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.
131+
132+
</div>
133+
117134
## Access imported resources
118135

119136
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:

src/dstack/_internal/cli/commands/export.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ def _register(self):
5050
help="Gateway name to export (can be specified multiple times)",
5151
default=[],
5252
)
53+
create_parser.add_argument(
54+
"--global",
55+
dest="is_global",
56+
action="store_true",
57+
help="Make this export global (automatically imported into all projects)",
58+
default=False,
59+
)
5360
create_parser.set_defaults(subfunc=self._create)
5461

5562
update_parser = subparsers.add_parser(
@@ -101,6 +108,21 @@ def _register(self):
101108
help="Gateway name to remove (can be specified multiple times)",
102109
default=[],
103110
)
111+
global_group = update_parser.add_mutually_exclusive_group()
112+
global_group.add_argument(
113+
"--set-global",
114+
dest="set_global",
115+
action="store_true",
116+
help="Make this export global (automatically imported into all projects)",
117+
default=False,
118+
)
119+
global_group.add_argument(
120+
"--unset-global",
121+
dest="unset_global",
122+
action="store_true",
123+
help="Remove the global flag from this export",
124+
default=False,
125+
)
104126
update_parser.set_defaults(subfunc=self._update)
105127

106128
delete_parser = subparsers.add_parser(
@@ -128,6 +150,7 @@ def _create(self, args: argparse.Namespace):
128150
export = self.api.client.exports.create(
129151
project_name=self.api.project,
130152
name=args.name,
153+
is_global=args.is_global,
131154
importer_projects=args.importers,
132155
exported_fleets=args.fleets,
133156
exported_gateways=args.gateways,
@@ -139,6 +162,8 @@ def _update(self, args: argparse.Namespace):
139162
export = self.api.client.exports.update(
140163
project_name=self.api.project,
141164
name=args.name,
165+
set_global=args.set_global,
166+
unset_global=args.unset_global,
142167
add_importer_projects=args.add_importers,
143168
remove_importer_projects=args.remove_importers,
144169
add_exported_fleets=args.add_fleets,
@@ -175,7 +200,12 @@ def print_exports_table(exports: list[Export]):
175200
if export.exported_gateways
176201
else "-"
177202
)
178-
importers = ", ".join([i.project_name for i in export.imports]) if export.imports else "-"
203+
if export.is_global:
204+
importers = "*"
205+
else:
206+
importers = (
207+
", ".join([i.project_name for i in export.imports]) if export.imports else "-"
208+
)
179209

180210
row = {
181211
"NAME": export.name,

src/dstack/_internal/core/compatibility/exports.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@
44

55
def get_create_export_excludes(request: CreateExportRequest) -> IncludeExcludeDictType:
66
excludes: IncludeExcludeDictType = {}
7+
if not request.is_global:
8+
excludes["is_global"] = True
79
if not request.exported_gateways:
810
excludes["exported_gateways"] = True
911
return excludes
1012

1113

1214
def get_update_export_excludes(request: UpdateExportRequest) -> IncludeExcludeDictType:
1315
excludes: IncludeExcludeDictType = {}
16+
if not request.set_global:
17+
excludes["set_global"] = True
18+
if not request.unset_global:
19+
excludes["unset_global"] = True
1420
if not request.add_exported_gateways:
1521
excludes["add_exported_gateways"] = True
1622
if not request.remove_exported_gateways:

src/dstack/_internal/core/models/exports.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class ExportedGateway(CoreModel):
2020
class Export(CoreModel):
2121
id: uuid.UUID
2222
name: str
23+
is_global: bool = False
2324
imports: list[ExportImport]
2425
exported_fleets: list[ExportedFleet]
2526
exported_gateways: list[ExportedGateway] = []
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
GLOBAL_EXPORTS_LOCK_NAMESPACE = "global_exports"
2+
"""
3+
Lock used to avoid race conditions between promoting an export to global and creating new projects.
4+
Ensures that all projects always import all global exports.
5+
"""
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Add ExportModel.is_global
2+
3+
Revision ID: 201cb7ccd0d3
4+
Revises: 205690dfeec2
5+
Create Date: 2026-05-13 07:24:06.321892+00:00
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "201cb7ccd0d3"
14+
down_revision = "205690dfeec2"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
with op.batch_alter_table("exports", schema=None) as batch_op:
22+
batch_op.add_column(
23+
sa.Column("is_global", sa.Boolean(), server_default=sa.false(), nullable=False)
24+
)
25+
26+
# ### end Alembic commands ###
27+
28+
29+
def downgrade() -> None:
30+
# ### commands auto generated by Alembic - please adjust! ###
31+
with op.batch_alter_table("exports", schema=None) as batch_op:
32+
batch_op.drop_column("is_global")
33+
34+
# ### end Alembic commands ###

src/dstack/_internal/server/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,6 +1139,7 @@ class ExportModel(BaseModel):
11391139
ForeignKey("projects.id", ondelete="CASCADE"), index=True
11401140
)
11411141
project: Mapped["ProjectModel"] = relationship()
1142+
is_global: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
11421143
created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)
11431144
imports: Mapped[List["ImportModel"]] = relationship(
11441145
back_populates="export",

src/dstack/_internal/server/routers/exports.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ async def create_export(
3434
project=project,
3535
user=user,
3636
name=body.name,
37+
is_global=body.is_global,
3738
importer_project_names=body.importer_projects,
3839
exported_fleet_names=body.exported_fleets,
3940
exported_gateway_names=body.exported_gateways,
@@ -52,6 +53,8 @@ async def update_export(
5253
project=project,
5354
user=user,
5455
name=body.name,
56+
set_global=body.set_global,
57+
unset_global=body.unset_global,
5558
add_importer_project_names=body.add_importer_projects,
5659
remove_importer_project_names=body.remove_importer_projects,
5760
add_exported_fleet_names=body.add_exported_fleets,

src/dstack/_internal/server/schemas/exports.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33

44
class CreateExportRequest(CoreModel):
55
name: str
6+
is_global: bool = False
67
importer_projects: list[str] = []
78
exported_fleets: list[str] = []
89
exported_gateways: list[str] = []
910

1011

1112
class UpdateExportRequest(CoreModel):
1213
name: str
14+
set_global: bool = False
15+
unset_global: bool = False
1316
add_importer_projects: list[str] = []
1417
remove_importer_projects: list[str] = []
1518
add_exported_fleets: list[str] = []

src/dstack/_internal/server/services/exports.py

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sqlalchemy.orm import selectinload
88

99
from dstack._internal.core.errors import (
10+
ForbiddenError,
1011
ResourceExistsError,
1112
ResourceNotExistsError,
1213
ServerClientError,
@@ -20,6 +21,7 @@
2021
)
2122
from dstack._internal.core.models.users import GlobalRole
2223
from dstack._internal.core.services import validate_dstack_resource_name
24+
from dstack._internal.server.const import GLOBAL_EXPORTS_LOCK_NAMESPACE
2325
from dstack._internal.server.db import get_db, is_db_postgres, is_db_sqlite
2426
from dstack._internal.server.models import (
2527
ExportedFleetModel,
@@ -34,9 +36,14 @@
3436
)
3537
from dstack._internal.server.services.fleets import get_fleet_spec, list_project_fleet_models
3638
from dstack._internal.server.services.gateways import list_project_gateway_models
37-
from dstack._internal.server.services.locking import get_locker, string_to_lock_id
39+
from dstack._internal.server.services.locking import (
40+
advisory_lock_ctx,
41+
get_locker,
42+
string_to_lock_id,
43+
)
3844
from dstack._internal.server.services.projects import (
3945
get_user_project_role,
46+
list_project_models,
4047
list_user_project_models,
4148
)
4249

@@ -105,11 +112,17 @@ async def create_export(
105112
project: ProjectModel,
106113
user: UserModel,
107114
name: str,
115+
is_global: bool,
108116
importer_project_names: list[str],
109117
exported_fleet_names: list[str],
110118
exported_gateway_names: list[str],
111119
) -> Export:
112120
validate_dstack_resource_name(name)
121+
if is_global and importer_project_names:
122+
raise ServerClientError(
123+
"Do not specify any importer projects when creating a global export."
124+
" Global exports are automatically imported in all projects"
125+
)
113126

114127
lock_namespace = f"export_names_{project.name}"
115128
if is_db_sqlite():
@@ -129,6 +142,7 @@ async def create_export(
129142
export = ExportModel(
130143
name=name,
131144
project=project,
145+
is_global=False,
132146
imports=[],
133147
exported_fleets=[],
134148
exported_gateways=[],
@@ -137,7 +151,14 @@ async def create_export(
137151
await add_exported_fleets(session, export, exported_fleet_names)
138152
await add_exported_gateways(session, export, exported_gateway_names)
139153
session.add(export)
140-
await session.commit()
154+
if is_global:
155+
async with advisory_lock_ctx(
156+
session, get_db().dialect_name, GLOBAL_EXPORTS_LOCK_NAMESPACE
157+
):
158+
await set_as_global(session, export, user)
159+
await session.commit() # commit before releasing the lock
160+
else:
161+
await session.commit()
141162
return export_model_to_export(export)
142163

143164

@@ -146,6 +167,8 @@ async def update_export(
146167
project: ProjectModel,
147168
user: UserModel,
148169
name: str,
170+
set_global: bool,
171+
unset_global: bool,
149172
add_importer_project_names: list[str],
150173
remove_importer_project_names: list[str],
151174
add_exported_fleet_names: list[str],
@@ -158,14 +181,24 @@ async def update_export(
158181
raise ResourceNotExistsError(f"Export {name!r} not found in project {project.name!r}")
159182

160183
if (
161-
not add_importer_project_names
184+
not set_global
185+
and not unset_global
186+
and not add_importer_project_names
162187
and not remove_importer_project_names
163188
and not add_exported_fleet_names
164189
and not remove_exported_fleet_names
165190
and not add_exported_gateway_names
166191
and not remove_exported_gateway_names
167192
):
168193
raise ServerClientError("No changes specified")
194+
if set_global and unset_global:
195+
raise ServerClientError("Cannot set and unset global at the same time")
196+
if (set_global or unset_global) and (
197+
add_importer_project_names or remove_importer_project_names
198+
):
199+
raise ServerClientError(
200+
"Cannot change global status and add/remove importers at the same time"
201+
)
169202

170203
add_importer_project_names = list(map(str.lower, add_importer_project_names))
171204
remove_importer_project_names = list(map(str.lower, remove_importer_project_names))
@@ -201,11 +234,48 @@ async def update_export(
201234
await remove_importer_projects(export, remove_importer_project_names)
202235
await remove_exported_fleets(export, remove_exported_fleet_names)
203236
await remove_exported_gateways(export, remove_exported_gateway_names)
204-
205-
await session.commit()
237+
if unset_global:
238+
await unset_as_global(export)
239+
if set_global:
240+
async with advisory_lock_ctx(
241+
session, get_db().dialect_name, GLOBAL_EXPORTS_LOCK_NAMESPACE
242+
):
243+
await set_as_global(session, export, user)
244+
await session.commit() # commit before releasing the lock
245+
else:
246+
await session.commit()
206247
return export_model_to_export(export)
207248

208249

250+
async def set_as_global(session: AsyncSession, export: ExportModel, user: UserModel) -> None:
251+
"""
252+
**NOTE**:
253+
Should be called with the `GLOBAL_EXPORTS_LOCK_NAMESPACE` lock acquired to prevent new
254+
projects from being created while this export is being imported into existing ones.
255+
"""
256+
if export.is_global:
257+
raise ServerClientError("The export is already global")
258+
if user.global_role != GlobalRole.ADMIN:
259+
raise ForbiddenError("Only global admins can make the export global")
260+
all_projects = await list_project_models(
261+
session, load_only_attrs=[ProjectModel.id, ProjectModel.name]
262+
)
263+
already_importing = {imp.project_id for imp in export.imports}
264+
for project in all_projects:
265+
if project.id == export.project.id:
266+
continue
267+
if project.id in already_importing:
268+
continue
269+
export.imports.append(ImportModel(project=project))
270+
export.is_global = True
271+
272+
273+
async def unset_as_global(export: ExportModel) -> None:
274+
if not export.is_global:
275+
raise ServerClientError("The export is already not global")
276+
export.is_global = False
277+
278+
209279
async def add_importer_projects(
210280
session: AsyncSession, user: UserModel, export: ExportModel, names: list[str]
211281
) -> None:
@@ -270,6 +340,10 @@ async def add_exported_fleets(
270340

271341

272342
async def remove_importer_projects(export: ExportModel, names: list[str]) -> None:
343+
if not names:
344+
return
345+
if export.is_global:
346+
raise ServerClientError("Cannot remove importers from a global export")
273347
names = list(map(str.lower, names))
274348
if len(names) != len(set(names)):
275349
raise ServerClientError("Some importer projects are listed for removal more than once")
@@ -364,6 +438,7 @@ def export_model_to_export(export_model: ExportModel) -> Export:
364438
return Export(
365439
id=export_model.id,
366440
name=export_model.name,
441+
is_global=export_model.is_global,
367442
imports=[
368443
ExportImport(
369444
project_name=import_model.project.name,

0 commit comments

Comments
 (0)