Skip to content

Commit f70b5b2

Browse files
authored
Add in-place update for gateway domain (#3997)
Introduce gateway in-place update mechanism. For now, only `domain` can be updated. ```yaml $ dstack apply -f test.dstack.yml Found gateway test-gateway. Detected changes that can be updated in-place: - domain Update the gateway? [y/n]: y NAME BACKEND HOSTNAME DOMAIN DEFAULT STATUS test-gateway gcp (us-west4) 34.125.56.225 new.example.com running ``` Add new API methods: - `/api/project/{project_name}/gateways/get_plan` - `/api/project/{project_name}/gateways/apply` Deprecate API methods: - `/api/project/{project_name}/gateways/create` - `/api/project/{project_name}/gateways/set_wildcard_domain` Deprecate CLI arguments: - `dstack gateway update --domain`
1 parent e549271 commit f70b5b2

9 files changed

Lines changed: 1223 additions & 50 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def _register(self):
8585
update_parser.add_argument(
8686
"--set-default", action="store_true", help="Set it the default gateway for the project"
8787
)
88-
update_parser.add_argument("--domain", help="Set the domain for the gateway")
88+
update_parser.add_argument("--domain", help="(deprecated) Set the domain for the gateway")
8989

9090
get_parser = subparsers.add_parser(
9191
"get", help="Get a gateway", formatter_class=self._parser.formatter_class
@@ -170,6 +170,10 @@ def _delete(self, args: argparse.Namespace):
170170
def _update(self, args: argparse.Namespace):
171171
with console.status("Updating gateway..."):
172172
if args.domain:
173+
logger.warning(
174+
"`dstack gateway update --domain` is deprecated and may be disallowed in the future."
175+
" Use `dstack apply` to update domains or other gateway configuration properties"
176+
)
173177
if args.name.project is not None:
174178
console.print(
175179
"The [code]<project>/<gateway>[/] format is not supported for gateway names"

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

Lines changed: 67 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@
1111
)
1212
from dstack._internal.cli.utils.gateway import get_gateways_table
1313
from dstack._internal.cli.utils.rich import MultiItemStatus
14-
from dstack._internal.core.errors import ResourceNotExistsError
14+
from dstack._internal.core.errors import (
15+
MethodNotAllowedError,
16+
ResourceNotExistsError,
17+
)
18+
from dstack._internal.core.models.common import ApplyAction
1519
from dstack._internal.core.models.configurations import ApplyConfigurationType
1620
from dstack._internal.core.models.gateways import (
21+
ApplyGatewayPlanInput,
1722
Gateway,
1823
GatewayConfiguration,
1924
GatewayPlan,
@@ -23,6 +28,7 @@
2328
from dstack._internal.core.services.diff import diff_models
2429
from dstack._internal.utils.common import local_time
2530
from dstack._internal.utils.logging import get_logger
31+
from dstack._internal.utils.nested_list import NestedList, NestedListItem
2632
from dstack.api._public import Client
2733

2834
logger = get_logger(__name__)
@@ -51,26 +57,31 @@ def apply_configuration(
5157
" https://dstack.ai/docs/concepts/services/#pd-disaggregation"
5258
)
5359
with console.status("Getting apply plan..."):
54-
plan = _get_plan(api=self.api, spec=spec)
60+
try:
61+
plan = self.api.client.gateways.get_plan(project_name=self.api.project, spec=spec)
62+
use_legacy_api = False
63+
except MethodNotAllowedError:
64+
# pre-0.20.27 server
65+
plan = _get_plan_legacy(self.api, spec)
66+
use_legacy_api = True
5567
_print_plan_header(plan)
5668

5769
action_message = ""
5870
confirm_message = ""
71+
delete_gateway_name = None
5972
if plan.current_resource is None:
60-
if plan.spec.configuration.name is not None:
61-
action_message += (
62-
f"Gateway [code]{plan.spec.configuration.name}[/] does not exist yet."
63-
)
73+
if plan.effective_spec.configuration.name is not None:
74+
action_message += f"Gateway [code]{plan.effective_spec.configuration.name}[/] does not exist yet."
6475
confirm_message += "Create the gateway?"
6576
else:
66-
action_message += f"Found gateway [code]{plan.spec.configuration.name}[/]."
77+
action_message += f"Found gateway [code]{plan.effective_spec.configuration.name}[/]."
6778
diff = diff_models(
68-
plan.spec.configuration,
6979
plan.current_resource.configuration,
80+
plan.effective_spec.configuration,
7081
)
7182
changed_fields = list(diff.keys())
7283
if (
73-
plan.current_resource.configuration == plan.spec.configuration
84+
plan.current_resource.configuration == plan.effective_spec.configuration
7485
or changed_fields == ["default"]
7586
):
7687
if command_args.yes and not command_args.force:
@@ -82,38 +93,61 @@ def apply_configuration(
8293
return
8394
action_message += " No configuration changes detected."
8495
confirm_message += "Re-create the gateway?"
96+
delete_gateway_name = plan.current_resource.name
8597
else:
86-
action_message += " Configuration changes detected."
87-
confirm_message += "Re-create the gateway?"
98+
formatted_diff = NestedList(
99+
children=[NestedListItem(field) for field in diff]
100+
).render()
101+
if plan.action == ApplyAction.UPDATE:
102+
action_message += f" Detected changes that [code]can[/] be updated in-place:\n{formatted_diff}"
103+
confirm_message += "Update the gateway?"
104+
else:
105+
action_message += f" Detected changes that [error]cannot[/] be updated in-place:\n{formatted_diff}"
106+
confirm_message += "Re-create the gateway?"
107+
delete_gateway_name = plan.current_resource.name
88108

89109
console.print(action_message)
90110
if not command_args.yes and not confirm_ask(confirm_message):
91111
console.print("\nExiting...")
92112
return
93113

94-
if plan.current_resource is not None:
114+
if delete_gateway_name is not None:
95115
with console.status("Deleting existing gateway..."):
96116
self.api.client.gateways.delete(
97117
project_name=self.api.project,
98-
gateways_names=[plan.current_resource.name],
118+
gateways_names=[delete_gateway_name],
99119
)
100120
# Gateway deletion is async. Wait for gateway to be deleted.
101121
while True:
102122
try:
103123
self.api.client.gateways.get(
104124
project_name=self.api.project,
105-
gateway_name=plan.current_resource.name,
125+
gateway_name=delete_gateway_name,
106126
)
107127
except ResourceNotExistsError:
108128
break
109129
else:
110130
time.sleep(1)
111131

112-
with console.status("Creating gateway..."):
113-
gateway = self.api.client.gateways.create(
114-
project_name=self.api.project,
115-
configuration=conf,
116-
)
132+
with console.status("Applying plan..."):
133+
if use_legacy_api:
134+
gateway = self.api.client.gateways.create(
135+
project_name=self.api.project,
136+
configuration=conf,
137+
)
138+
else:
139+
gateway = self.api.client.gateways.apply_plan(
140+
project_name=self.api.project,
141+
plan=ApplyGatewayPlanInput(
142+
spec=spec,
143+
current_resource=plan.current_resource,
144+
),
145+
)
146+
147+
if plan.action == ApplyAction.UPDATE and delete_gateway_name is None:
148+
console.print(get_gateways_table([gateway], current_project=self.api.project))
149+
return
150+
117151
if command_args.detach:
118152
console.print("Gateway configuration submitted. Exiting...")
119153
return
@@ -195,8 +229,7 @@ def apply_args(self, conf: GatewayConfiguration, args: argparse.Namespace):
195229
conf.name = args.name
196230

197231

198-
def _get_plan(api: Client, spec: GatewaySpec) -> GatewayPlan:
199-
# TODO: Implement server-side /get_plan with an offer included
232+
def _get_plan_legacy(api: Client, spec: GatewaySpec) -> GatewayPlan:
200233
user = api.client.users.get_my_user()
201234
current_resource = None
202235
if spec.configuration.name is not None:
@@ -211,7 +244,9 @@ def _get_plan(api: Client, spec: GatewaySpec) -> GatewayPlan:
211244
project_name=api.project,
212245
user=user.username,
213246
spec=spec,
247+
effective_spec=spec,
214248
current_resource=current_resource,
249+
action=ApplyAction.CREATE,
215250
)
216251

217252

@@ -225,20 +260,22 @@ def th(s: str) -> str:
225260

226261
configuration_table.add_row(th("Project"), plan.project_name)
227262
configuration_table.add_row(th("User"), plan.user)
228-
configuration_table.add_row(th("Configuration"), plan.spec.configuration_path)
229-
configuration_table.add_row(th("Type"), plan.spec.configuration.type)
263+
configuration_table.add_row(th("Configuration"), plan.effective_spec.configuration_path)
264+
configuration_table.add_row(th("Type"), plan.effective_spec.configuration.type)
230265

231266
domain = "-"
232-
if plan.spec.configuration.domain is not None:
233-
domain = plan.spec.configuration.domain
267+
if plan.effective_spec.configuration.domain is not None:
268+
domain = plan.effective_spec.configuration.domain
234269

235-
configuration_table.add_row(th("Backend"), plan.spec.configuration.backend.value)
236-
configuration_table.add_row(th("Region"), plan.spec.configuration.region)
270+
configuration_table.add_row(th("Backend"), plan.effective_spec.configuration.backend.value)
271+
configuration_table.add_row(th("Region"), plan.effective_spec.configuration.region)
237272
configuration_table.add_row(th("Domain"), domain)
238273

239-
if plan.spec.configuration.replicas is not None:
240-
assert isinstance(plan.spec.configuration.replicas, int)
241-
configuration_table.add_row(th("Replicas"), str(plan.spec.configuration.replicas))
274+
if plan.effective_spec.configuration.replicas is not None:
275+
assert isinstance(plan.effective_spec.configuration.replicas, int)
276+
configuration_table.add_row(
277+
th("Replicas"), str(plan.effective_spec.configuration.replicas)
278+
)
242279

243280
console.print(configuration_table)
244281
console.print()

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing_extensions import Annotated, Literal
88

99
from dstack._internal.core.models.backends.base import BackendType
10-
from dstack._internal.core.models.common import CoreModel
10+
from dstack._internal.core.models.common import ApplyAction, CoreModel
1111
from dstack._internal.core.models.routers import AnyGatewayRouterConfig
1212
from dstack._internal.utils.tags import tags_validator
1313

@@ -88,7 +88,8 @@ class GatewayConfiguration(CoreModel):
8888
"The gateway wildcard domain name, e.g. `example.com`."
8989
" Service domain names are constructed as `<run name>.<gateway domain`."
9090
" The domain name can use the `${{ run.project_name }}` variable"
91-
" to include the service’s project name"
91+
" to include the service’s project name."
92+
" Can be updated in-place. Updates do not affect existing services"
9293
)
9394
),
9495
] = None
@@ -176,7 +177,22 @@ class GatewayPlan(CoreModel):
176177
project_name: str
177178
user: str
178179
spec: GatewaySpec
180+
effective_spec: GatewaySpec
179181
current_resource: Optional[Gateway] = None
182+
action: ApplyAction
183+
184+
185+
class ApplyGatewayPlanInput(CoreModel):
186+
spec: GatewaySpec
187+
current_resource: Annotated[
188+
Optional[Gateway],
189+
Field(
190+
description=(
191+
"The expected current resource."
192+
" If the resource has changed, the apply fails unless `force: true`."
193+
)
194+
),
195+
] = None
180196

181197

182198
class GatewayComputeConfiguration(CoreModel):

src/dstack/_internal/server/compatibility/gateways.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from packaging.version import Version
44

5-
from dstack._internal.core.models.gateways import Gateway
5+
from dstack._internal.core.models.gateways import Gateway, GatewayPlan
66

77

88
def patch_gateway(gateway: Gateway, client_version: Optional[Version]) -> None:
@@ -21,3 +21,10 @@ def patch_gateway(gateway: Gateway, client_version: Optional[Version]) -> None:
2121
replica.region = ""
2222
if replica.backend is None:
2323
replica.backend = gateway.configuration.backend
24+
25+
26+
def patch_gateway_plan(plan: GatewayPlan, client_version: Optional[Version]) -> None:
27+
if client_version is None:
28+
return
29+
if plan.current_resource is not None:
30+
patch_gateway(plan.current_resource, client_version)

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

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import List, Optional, Tuple
1+
from typing import Annotated, List, Optional, Tuple
22

33
from fastapi import APIRouter, Depends
44
from packaging.version import Version
@@ -9,7 +9,7 @@
99
import dstack._internal.server.services.gateways as gateways
1010
from dstack._internal.core.errors import ResourceNotExistsError
1111
from dstack._internal.core.models.common import EntityReference
12-
from dstack._internal.server.compatibility.gateways import patch_gateway
12+
from dstack._internal.server.compatibility.gateways import patch_gateway, patch_gateway_plan
1313
from dstack._internal.server.db import get_session
1414
from dstack._internal.server.deps import Project
1515
from dstack._internal.server.models import ProjectModel, UserModel
@@ -71,14 +71,63 @@ async def get_gateway(
7171
return CustomORJSONResponse(gateway)
7272

7373

74-
@router.post("/create", summary="Create gateway", response_model=models.Gateway)
74+
@router.post("/get_plan", summary="Get gateway plan", response_model=models.GatewayPlan)
75+
async def get_plan(
76+
body: schemas.GetGatewayPlanRequest,
77+
session: Annotated[AsyncSession, Depends(get_session)],
78+
user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectAdmin())],
79+
client_version: Annotated[Optional[Version], Depends(get_client_version)],
80+
):
81+
"""
82+
Returns a gateway plan for the given gateway spec.
83+
This is an optional step before calling `/apply`.
84+
"""
85+
user, project = user_project
86+
plan = await gateways.get_plan(
87+
session=session,
88+
project=project,
89+
user=user,
90+
spec=body.spec,
91+
)
92+
patch_gateway_plan(plan, client_version)
93+
return CustomORJSONResponse(plan)
94+
95+
96+
@router.post("/apply", summary="Apply gateway plan", response_model=models.Gateway)
97+
async def apply_plan(
98+
body: schemas.ApplyGatewayPlanRequest,
99+
session: Annotated[AsyncSession, Depends(get_session)],
100+
user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectAdmin())],
101+
pipeline_hinter: Annotated[PipelineHinterProtocol, Depends(get_pipeline_hinter)],
102+
client_version: Annotated[Optional[Version], Depends(get_client_version)],
103+
):
104+
"""
105+
Creates a new gateway or updates an existing gateway in-place.
106+
"""
107+
user, project = user_project
108+
gateway = await gateways.apply_plan(
109+
session=session,
110+
user=user,
111+
project=project,
112+
plan=body.plan,
113+
force=body.force,
114+
pipeline_hinter=pipeline_hinter,
115+
)
116+
patch_gateway(gateway, client_version)
117+
return CustomORJSONResponse(gateway)
118+
119+
120+
@router.post("/create", summary="Create gateway", response_model=models.Gateway, deprecated=True)
75121
async def create_gateway(
76122
body: schemas.CreateGatewayRequest,
77123
session: AsyncSession = Depends(get_session),
78124
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectAdmin()),
79125
pipeline_hinter: PipelineHinterProtocol = Depends(get_pipeline_hinter),
80126
client_version: Optional[Version] = Depends(get_client_version),
81127
):
128+
"""
129+
Deprecated in favor of `/apply`.
130+
"""
82131
user, project = user_project
83132
gateway = await gateways.create_gateway(
84133
session=session,
@@ -121,13 +170,21 @@ async def set_default_gateway(
121170
)
122171

123172

124-
@router.post("/set_wildcard_domain", summary="Set wildcard domain", response_model=models.Gateway)
173+
@router.post(
174+
"/set_wildcard_domain",
175+
summary="Set wildcard domain",
176+
response_model=models.Gateway,
177+
deprecated=True,
178+
)
125179
async def set_gateway_wildcard_domain(
126180
body: schemas.SetWildcardDomainRequest,
127181
session: AsyncSession = Depends(get_session),
128182
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectAdmin()),
129183
client_version: Optional[Version] = Depends(get_client_version),
130184
):
185+
"""
186+
Deprecated in favor of `/apply` (in-place update).
187+
"""
131188
user, project = user_project
132189
gateway = await gateways.set_gateway_wildcard_domain(
133190
session=session,

0 commit comments

Comments
 (0)