Skip to content

Commit a9b1db9

Browse files
authored
Gateway exports (#3845)
Allow exporting gateways to other projects. ``` $ dstack export --project main NAME FLEETS GATEWAYS IMPORTERS my-export - main-gateway team $ dstack import --project team NAME FLEETS GATEWAYS main/my-export - main-gateway $ dstack gateway --project team NAME BACKEND HOSTNAME DOMAIN DEFAULT STATUS main/main-gateway aws (eu-west-1) 108.131.126.35 gtw.mycompany.example running ``` Imported gateways can be used by specifying them in the `gateway` property in service configurations. ```yaml type: service port: 80 image: nginx gateway: main/main-gateway ``` Limitations: - Setting an imported gateway as the default gateway is not yet supported. - Evicting services from a gateway that is no longer imported is not yet supported.
1 parent 72eeafb commit a9b1db9

35 files changed

Lines changed: 1197 additions & 70 deletions

File tree

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ def _register(self):
4343
help="Fleet name to export (can be specified multiple times)",
4444
default=[],
4545
)
46+
create_parser.add_argument(
47+
"--gateway",
48+
action="append",
49+
dest="gateways",
50+
help="Gateway name to export (can be specified multiple times)",
51+
default=[],
52+
)
4653
create_parser.set_defaults(subfunc=self._create)
4754

4855
update_parser = subparsers.add_parser(
@@ -80,6 +87,20 @@ def _register(self):
8087
help="Fleet name to remove (can be specified multiple times)",
8188
default=[],
8289
)
90+
update_parser.add_argument(
91+
"--add-gateway",
92+
action="append",
93+
dest="add_gateways",
94+
help="Gateway name to add (can be specified multiple times)",
95+
default=[],
96+
)
97+
update_parser.add_argument(
98+
"--remove-gateway",
99+
action="append",
100+
dest="remove_gateways",
101+
help="Gateway name to remove (can be specified multiple times)",
102+
default=[],
103+
)
83104
update_parser.set_defaults(subfunc=self._update)
84105

85106
delete_parser = subparsers.add_parser(
@@ -109,6 +130,7 @@ def _create(self, args: argparse.Namespace):
109130
name=args.name,
110131
importer_projects=args.importers,
111132
exported_fleets=args.fleets,
133+
exported_gateways=args.gateways,
112134
)
113135
print_exports_table([export])
114136

@@ -121,6 +143,8 @@ def _update(self, args: argparse.Namespace):
121143
remove_importer_projects=args.remove_importers,
122144
add_exported_fleets=args.add_fleets,
123145
remove_exported_fleets=args.remove_fleets,
146+
add_exported_gateways=args.add_gateways,
147+
remove_exported_gateways=args.remove_gateways,
124148
)
125149
print_exports_table([export])
126150

@@ -139,17 +163,24 @@ def print_exports_table(exports: list[Export]):
139163
table = Table(box=None)
140164
table.add_column("NAME", no_wrap=True)
141165
table.add_column("FLEETS")
166+
table.add_column("GATEWAYS")
142167
table.add_column("IMPORTERS")
143168

144169
for export in exports:
145170
fleets = (
146171
", ".join([f.name for f in export.exported_fleets]) if export.exported_fleets else "-"
147172
)
173+
gateways = (
174+
", ".join([g.name for g in export.exported_gateways])
175+
if export.exported_gateways
176+
else "-"
177+
)
148178
importers = ", ".join([i.project_name for i in export.imports]) if export.imports else "-"
149179

150180
row = {
151181
"NAME": export.name,
152182
"FLEETS": fleets,
183+
"GATEWAYS": gateways,
153184
"IMPORTERS": importers,
154185
}
155186
add_row_from_dict(table, row)

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

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
print_gateways_table,
1818
)
1919
from dstack._internal.core.errors import CLIError, ResourceNotExistsError
20+
from dstack._internal.core.models.common import EntityReference
2021
from dstack._internal.core.models.gateways import GatewayStatus
2122
from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent
2223
from dstack._internal.utils.logging import get_logger
@@ -67,7 +68,7 @@ def _register(self):
6768
)
6869
delete_parser.set_defaults(subfunc=self._delete)
6970
delete_parser.add_argument(
70-
"name", help="The name of the gateway"
71+
"name", type=EntityReference.parse, help="The name of the gateway"
7172
).completer = GatewayNameCompleter() # type: ignore[attr-defined]
7273
delete_parser.add_argument(
7374
"-y", "--yes", action="store_true", help="Don't ask for confirmation"
@@ -78,7 +79,7 @@ def _register(self):
7879
)
7980
update_parser.set_defaults(subfunc=self._update)
8081
update_parser.add_argument(
81-
"name", help="The name of the gateway"
82+
"name", type=EntityReference.parse, help="The name of the gateway"
8283
).completer = GatewayNameCompleter() # type: ignore[attr-defined]
8384
update_parser.add_argument(
8485
"--set-default", action="store_true", help="Set it the default gateway for the project"
@@ -89,7 +90,7 @@ def _register(self):
8990
"get", help="Get a gateway", formatter_class=self._parser.formatter_class
9091
)
9192
get_parser.add_argument(
92-
"name", metavar="NAME", help="The name of the gateway"
93+
"name", metavar="NAME", type=EntityReference.parse, help="The name of the gateway"
9394
).completer = GatewayNameCompleter() # type: ignore[attr-defined]
9495
get_parser.add_argument(
9596
"--json",
@@ -108,7 +109,7 @@ def _list(self, args: argparse.Namespace):
108109
if args.watch and args.format == "json":
109110
raise CLIError("JSON output is not supported together with --watch")
110111

111-
gateways = self.api.client.gateways.list(self.api.project)
112+
gateways = self.api.client.gateways.list(self.api.project, include_imported=True)
112113
deprecated_router_gateways = [
113114
g.name
114115
for g in gateways
@@ -127,45 +128,66 @@ def _list(self, args: argparse.Namespace):
127128
if args.format == "json":
128129
print_gateways_json(gateways, project=self.api.project)
129130
else:
130-
print_gateways_table(gateways, verbose=args.verbose)
131+
print_gateways_table(
132+
gateways, current_project=self.api.project, verbose=args.verbose
133+
)
131134
return
132135

133136
try:
134137
with Live(console=console, refresh_per_second=LIVE_TABLE_REFRESH_RATE_PER_SEC) as live:
135138
while True:
136-
live.update(get_gateways_table(gateways, verbose=args.verbose))
139+
live.update(
140+
get_gateways_table(
141+
gateways, current_project=self.api.project, verbose=args.verbose
142+
)
143+
)
137144
time.sleep(LIVE_TABLE_PROVISION_INTERVAL_SECS)
138-
gateways = self.api.client.gateways.list(self.api.project)
145+
gateways = self.api.client.gateways.list(
146+
self.api.project, include_imported=True
147+
)
139148
except KeyboardInterrupt:
140149
pass
141150

142151
def _delete(self, args: argparse.Namespace):
143-
gateway = self.api.client.gateways.get(self.api.project, args.name)
144-
print_gateways_table([gateway])
152+
if args.name.project is not None:
153+
console.print(
154+
"The [code]<project>/<gateway>[/] format is not supported for gateway names."
155+
" Can only delete gateways owned by the current project"
156+
)
157+
exit(1)
158+
name = args.name.name
159+
gateway = self.api.client.gateways.get(self.api.project, name)
160+
print_gateways_table([gateway], current_project=self.api.project)
145161
if args.yes or confirm_ask("Do you want to delete the gateway?"):
146162
with console.status("Deleting gateway..."):
147-
self.api.client.gateways.delete(self.api.project, [args.name])
163+
self.api.client.gateways.delete(self.api.project, [name])
148164
console.print("Gateway deleted")
149165
else:
150166
console.print("Exiting...")
151167
return
152168

153169
def _update(self, args: argparse.Namespace):
170+
if args.name.project is not None:
171+
console.print(
172+
"The [code]<project>/<gateway>[/] format is not supported for gateway names."
173+
" Can only update gateways owned by the current project"
174+
)
175+
exit(1)
176+
name = args.name.name
154177
with console.status("Updating gateway..."):
155178
if args.set_default:
156-
self.api.client.gateways.set_default(self.api.project, args.name)
179+
self.api.client.gateways.set_default(self.api.project, name)
157180
if args.domain:
158-
self.api.client.gateways.set_wildcard_domain(
159-
self.api.project, args.name, args.domain
160-
)
161-
gateway = self.api.client.gateways.get(self.api.project, args.name)
162-
print_gateways_table([gateway])
181+
self.api.client.gateways.set_wildcard_domain(self.api.project, name, args.domain)
182+
gateway = self.api.client.gateways.get(self.api.project, name)
183+
print_gateways_table([gateway], current_project=self.api.project)
163184

164185
def _get(self, args: argparse.Namespace):
165186
# TODO: Implement non-json output format
166187
try:
167188
gateway = self.api.client.gateways.get(
168-
project_name=self.api.project, gateway_name=args.name
189+
project_name=args.name.project or self.api.project,
190+
gateway_name=args.name.name,
169191
)
170192
except ResourceNotExistsError:
171193
console.print("Gateway not found")

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def print_imports_table(imports: list[Import]):
6868
table = Table(box=None)
6969
table.add_column("NAME", no_wrap=True)
7070
table.add_column("FLEETS")
71+
table.add_column("GATEWAYS")
7172

7273
for imp in imports:
7374
name = f"{imp.export.project_name}/{imp.export.name}"
@@ -76,10 +77,16 @@ def print_imports_table(imports: list[Import]):
7677
if imp.export.exported_fleets
7778
else "-"
7879
)
80+
gateways = (
81+
", ".join([g.name for g in imp.export.exported_gateways])
82+
if imp.export.exported_gateways
83+
else "-"
84+
)
7985

8086
row = {
8187
"NAME": name,
8288
"FLEETS": fleets,
89+
"GATEWAYS": gateways,
8390
}
8491
add_row_from_dict(table, row)
8592

src/dstack/_internal/cli/services/configurators/gateway.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ def apply_configuration(
122122
f"Provisioning [code]{gateway.name}[/]...", console=console
123123
) as live:
124124
while not _finished_provisioning(gateway):
125-
table = get_gateways_table([gateway], include_created=True)
125+
table = get_gateways_table(
126+
[gateway], current_project=self.api.project, include_created=True
127+
)
126128
live.update(table)
127129
time.sleep(LIVE_TABLE_PROVISION_INTERVAL_SECS)
128130
gateway = self.api.client.gateways.get(self.api.project, gateway.name)
@@ -139,6 +141,7 @@ def apply_configuration(
139141
console.print(
140142
get_gateways_table(
141143
[gateway],
144+
current_project=self.api.project,
142145
verbose=gateway.status == GatewayStatus.FAILED,
143146
include_created=True,
144147
format_date=local_time,

src/dstack/_internal/cli/utils/common.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ def resolve_url(url: str, timeout: float = 5.0) -> str:
151151
return response.url
152152

153153

154+
def format_entity_reference(name: str, project: str, current_project: str) -> str:
155+
if current_project == project:
156+
return name
157+
else:
158+
return f"{project}/{name}"
159+
160+
154161
def format_instance_availability(v: InstanceAvailability) -> str:
155162
if v in (InstanceAvailability.UNKNOWN, InstanceAvailability.AVAILABLE):
156163
return ""

src/dstack/_internal/cli/utils/fleet.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from rich.table import Table
44

5-
from dstack._internal.cli.utils.common import add_row_from_dict, console
5+
from dstack._internal.cli.utils.common import add_row_from_dict, console, format_entity_reference
66
from dstack._internal.core.models.backends.base import BackendType
77
from dstack._internal.core.models.fleets import Fleet, FleetNodesSpec, FleetStatus
88
from dstack._internal.core.models.instances import Instance, InstanceStatus
@@ -43,10 +43,6 @@ def get_fleets_table(
4343
config = fleet.spec.configuration
4444
merged_profile = fleet.spec.merged_profile
4545

46-
name = fleet.name
47-
if fleet.project_name != current_project:
48-
name = f"{fleet.project_name}/{fleet.name}"
49-
5046
# Detect SSH fleet vs backend fleet
5147
if config.ssh_config is not None:
5248
# SSH fleet: fixed number of hosts, no cloud billing
@@ -76,7 +72,7 @@ def get_fleets_table(
7672
nodes = f"{nodes} (cluster)"
7773

7874
fleet_row = {
79-
"NAME": name,
75+
"NAME": format_entity_reference(fleet.name, fleet.project_name, current_project),
8076
"NODES": nodes,
8177
"RESOURCES": resources,
8278
"GPU": gpu,

src/dstack/_internal/cli/utils/gateway.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
from rich.table import Table
44

55
from dstack._internal.cli.models.gateways import GatewayCommandOutput
6-
from dstack._internal.cli.utils.common import add_row_from_dict, console
6+
from dstack._internal.cli.utils.common import add_row_from_dict, console, format_entity_reference
77
from dstack._internal.core.models.gateways import Gateway
88
from dstack._internal.utils.common import DateFormatter, pretty_date
99

1010

11-
def print_gateways_table(gateways: List[Gateway], verbose: bool = False):
12-
table = get_gateways_table(gateways, verbose=verbose)
11+
def print_gateways_table(gateways: List[Gateway], current_project: str, verbose: bool = False):
12+
table = get_gateways_table(gateways, current_project, verbose=verbose)
1313
console.print(table)
1414
console.print()
1515

@@ -25,6 +25,7 @@ def print_gateways_json(gateways: List[Gateway], project: str) -> None:
2525

2626
def get_gateways_table(
2727
gateways: List[Gateway],
28+
current_project: str,
2829
verbose: bool = False,
2930
include_created: bool = False,
3031
format_date: DateFormatter = pretty_date,
@@ -42,8 +43,15 @@ def get_gateways_table(
4243
table.add_column("ERROR")
4344

4445
for gateway in gateways:
46+
name = format_entity_reference(
47+
gateway.name,
48+
# project_name == None means pre-0.20.20 server, which means no gateway exports support,
49+
# which means the gateway is from the current project
50+
gateway.project_name if gateway.project_name is not None else current_project,
51+
current_project,
52+
)
4553
row = {
46-
"NAME": gateway.name,
54+
"NAME": name,
4755
"BACKEND": f"{gateway.configuration.backend.value} ({gateway.configuration.region})",
4856
"HOSTNAME": gateway.hostname,
4957
"DOMAIN": gateway.wildcard_domain,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from dstack._internal.core.models.common import IncludeExcludeDictType
2+
from dstack._internal.server.schemas.exports import CreateExportRequest, UpdateExportRequest
3+
4+
5+
def get_create_export_excludes(request: CreateExportRequest) -> IncludeExcludeDictType:
6+
excludes: IncludeExcludeDictType = {}
7+
if not request.exported_gateways:
8+
excludes["exported_gateways"] = True
9+
return excludes
10+
11+
12+
def get_update_export_excludes(request: UpdateExportRequest) -> IncludeExcludeDictType:
13+
excludes: IncludeExcludeDictType = {}
14+
if not request.add_exported_gateways:
15+
excludes["add_exported_gateways"] = True
16+
if not request.remove_exported_gateways:
17+
excludes["remove_exported_gateways"] = True
18+
return excludes

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from dstack._internal.core.compatibility.common import patch_profile_params
44
from dstack._internal.core.models.common import (
5+
EntityReference,
56
IncludeExcludeDictType,
67
IncludeExcludeSetType,
78
)
@@ -177,3 +178,7 @@ def patch_run_spec(run_spec: RunSpec) -> None:
177178
patch_profile_params(run_spec.configuration)
178179
if run_spec.profile is not None:
179180
patch_profile_params(run_spec.profile)
181+
if isinstance(run_spec.configuration, ServiceConfiguration):
182+
if isinstance(run_spec.configuration.gateway, EntityReference):
183+
# Pre-0.20.20 servers do not support `EntityReference` in `gateway`
184+
run_spec.configuration.gateway = run_spec.configuration.gateway.format()

0 commit comments

Comments
 (0)