Skip to content

Commit 5ef905e

Browse files
committed
Support in-place update for gateway default
The `default` gateway configuration property can now be updated in-place. ```shell $ dstack apply -f gateway.dstack.yml Found gateway my-gateway. Detected changes that can be updated in-place: - default Update the gateway? [y/n]: ``` When a gateway is updated in-place, `default` has the following semantics: - If `true`, the gateway will become (or remain) the project's default. - If `false`, the gateway will become (or remain) non-default. - If omitted, the project's default gateway won't change. When a new gateway is created, `default` has the following semantics: - If `true`, the gateway will become the project's default (existing behavior before this PR). - If `false`, the gateway will not become the project's default (new behavior, previously `false` was equivalent to omitting). - If omitted, the gateway will become the project's default unless there is already another default gateway (existing behavior before this PR).
1 parent 26247fd commit 5ef905e

9 files changed

Lines changed: 722 additions & 32 deletions

File tree

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

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
GatewaySpec,
2525
GatewayStatus,
2626
)
27-
from dstack._internal.core.services.diff import diff_models
27+
from dstack._internal.core.services.gateways import diff_gateway_configurations
2828
from dstack._internal.utils.common import local_time
2929
from dstack._internal.utils.logging import get_logger
3030
from dstack._internal.utils.nested_list import NestedList, NestedListItem
@@ -60,15 +60,11 @@ def apply_configuration(
6060
confirm_message += "Create the gateway?"
6161
else:
6262
action_message += f"Found gateway [code]{plan.effective_spec.configuration.name}[/]."
63-
diff = diff_models(
63+
diff = diff_gateway_configurations(
6464
plan.current_resource.configuration,
6565
plan.effective_spec.configuration,
6666
)
67-
changed_fields = list(diff.keys())
68-
if (
69-
plan.current_resource.configuration == plan.effective_spec.configuration
70-
or changed_fields == ["default"]
71-
):
67+
if not diff:
7268
if command_args.yes and not command_args.force:
7369
# --force is required only with --yes,
7470
# otherwise we may ask for force apply interactively.

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,30 @@
44
GatewayConfiguration,
55
GatewaySpec,
66
)
7-
from dstack._internal.server.schemas.gateways import SetDefaultGatewayRequest
7+
from dstack._internal.server.schemas.gateways import (
8+
GetGatewayPlanRequest,
9+
SetDefaultGatewayRequest,
10+
)
11+
12+
13+
def get_get_plan_excludes(body: GetGatewayPlanRequest) -> IncludeExcludeDictType:
14+
return {"spec": get_gateway_spec_excludes(body.spec)}
815

916

1017
def get_apply_plan_excludes(plan_input: ApplyGatewayPlanInput) -> IncludeExcludeDictType:
11-
apply_plan_excludes: IncludeExcludeDictType = {}
18+
apply_plan_excludes: IncludeExcludeDictType = {
19+
"spec": get_gateway_spec_excludes(plan_input.spec)
20+
}
1221
if plan_input.current_resource is not None:
1322
# `Gateway.backend` and `Gateway.region` are deprecated and never set since 0.21.
1423
# Not sending them lets 0.22 drop the fields without breaking 0.21 clients.
15-
apply_plan_excludes["current_resource"] = {"backend": True, "region": True}
24+
apply_plan_excludes["current_resource"] = {
25+
"backend": True,
26+
"region": True,
27+
"configuration": _get_gateway_configuration_excludes(
28+
plan_input.current_resource.configuration
29+
),
30+
}
1631
return {"plan": apply_plan_excludes}
1732

1833

@@ -49,4 +64,8 @@ def _get_gateway_configuration_excludes(
4964
configuration: GatewayConfiguration,
5065
) -> IncludeExcludeDictType:
5166
configuration_excludes: IncludeExcludeDictType = {}
67+
68+
if configuration.default is None:
69+
configuration_excludes["default"] = True
70+
5271
return configuration_excludes

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,18 @@ class GatewayCertificate(RootModel[Annotated[AnyGatewayCertificate, Field(discri
5555
class GatewayConfiguration(CoreModel):
5656
type: Literal["gateway"] = "gateway"
5757
name: Annotated[Optional[str], Field(description="The gateway name")] = None
58-
default: Annotated[bool, Field(description="Make the gateway default")] = False
58+
default: Annotated[
59+
Optional[bool],
60+
Field(
61+
description=(
62+
"Whether the gateway is the project's default. Can be updated in-place."
63+
" If unset when creating a new gateway,"
64+
" the gateway will become the default unless there is already a default gateway."
65+
" If unset when updating the gateway in-place,"
66+
" the gateway's default status will not change"
67+
)
68+
),
69+
] = None
5970
backend: Annotated[BackendType, Field(description="The gateway backend")]
6071
region: Annotated[str, Field(description="The gateway region")]
6172
instance_type: Annotated[
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from dstack._internal.core.models.gateways import GatewayConfiguration
2+
from dstack._internal.core.services.diff import ModelDiff, diff_models
3+
4+
5+
def diff_gateway_configurations(old: GatewayConfiguration, new: GatewayConfiguration) -> ModelDiff:
6+
return diff_models(
7+
old,
8+
new,
9+
# default=None => default should stay unchanged => shouldn't be in the diff
10+
reset={"default"} if new.default is None else {},
11+
)

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

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

33
from packaging.version import Version
44

5-
from dstack._internal.core.models.gateways import Gateway, GatewayPlan
5+
from dstack._internal.core.models.gateways import (
6+
Gateway,
7+
GatewayConfiguration,
8+
GatewayPlan,
9+
GatewaySpec,
10+
)
11+
12+
13+
def patch_gateway_spec_in_request(spec: GatewaySpec, client_version: Optional[Version]) -> None:
14+
if client_version is None:
15+
return
16+
if client_version < Version("0.21.1") and spec.configuration.default is False:
17+
# Pre-0.21.1 clients send `default=false` both when `default` was omitted and when it was
18+
# set to `false` explicitly. Assume it was omitted, which is more common and more useful.
19+
spec.configuration.default = None
620

721

822
def patch_gateway(gateway: Gateway, client_version: Optional[Version]) -> None:
923
if client_version is None:
1024
return
25+
_patch_gateway_configuration(gateway.configuration, client_version)
1126
if client_version < Version("0.20.25"):
1227
gateway.instance_id = ""
1328
gateway.ip_address = "\n".join(r.hostname for r in gateway.replicas if r.hostname)
@@ -26,5 +41,16 @@ def patch_gateway(gateway: Gateway, client_version: Optional[Version]) -> None:
2641
def patch_gateway_plan(plan: GatewayPlan, client_version: Optional[Version]) -> None:
2742
if client_version is None:
2843
return
44+
_patch_gateway_configuration(plan.spec.configuration, client_version)
45+
_patch_gateway_configuration(plan.effective_spec.configuration, client_version)
2946
if plan.current_resource is not None:
3047
patch_gateway(plan.current_resource, client_version)
48+
49+
50+
def _patch_gateway_configuration(
51+
configuration: GatewayConfiguration, client_version: Optional[Version]
52+
):
53+
if client_version is None:
54+
return
55+
if client_version < Version("0.21.1") and configuration.default is None:
56+
configuration.default = False

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
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, patch_gateway_plan
12+
from dstack._internal.server.compatibility.gateways import (
13+
patch_gateway,
14+
patch_gateway_plan,
15+
patch_gateway_spec_in_request,
16+
)
1317
from dstack._internal.server.db import get_session
1418
from dstack._internal.server.deps import Project
1519
from dstack._internal.server.models import ProjectModel, UserModel
@@ -83,6 +87,7 @@ async def get_plan(
8387
This is an optional step before calling `/apply`.
8488
"""
8589
user, project = user_project
90+
patch_gateway_spec_in_request(body.spec, client_version)
8691
plan = await gateways.get_plan(
8792
session=session,
8893
project=project,
@@ -105,6 +110,7 @@ async def apply_plan(
105110
Creates a new gateway or updates an existing gateway in-place.
106111
"""
107112
user, project = user_project
113+
patch_gateway_spec_in_request(body.plan.spec, client_version)
108114
gateway = await gateways.apply_plan(
109115
session=session,
110116
user=user,

src/dstack/_internal/server/services/gateways/__init__.py

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,8 @@
4949
LetsEncryptGatewayCertificate,
5050
)
5151
from dstack._internal.core.services import validate_dstack_resource_name
52-
from dstack._internal.core.services.diff import (
53-
ModelDiff,
54-
diff_models,
55-
format_diff_fields_for_event,
56-
)
52+
from dstack._internal.core.services.diff import ModelDiff, format_diff_fields_for_event
53+
from dstack._internal.core.services.gateways import diff_gateway_configurations
5754
from dstack._internal.proxy.gateway.const import SERVICE_SCALING_WINDOWS
5855
from dstack._internal.proxy.gateway.schemas.stats import PerWindowStats, Stat
5956
from dstack._internal.server import settings
@@ -92,7 +89,7 @@
9289
from dstack._internal.utils.logging import get_logger
9390

9491
logger = get_logger(__name__)
95-
_CONF_UPDATABLE_FIELDS = frozenset({"domain"})
92+
_CONF_UPDATABLE_FIELDS = frozenset({"domain", "default"})
9693
if FeatureFlags.GATEWAY_SCALING:
9794
_CONF_UPDATABLE_FIELDS |= {"replicas"}
9895

@@ -292,7 +289,7 @@ async def create_gateway(
292289
await session.commit()
293290

294291
default_gateway = await get_project_default_gateway_model(session=session, project=project)
295-
if default_gateway is None or configuration.default:
292+
if default_gateway is None and configuration.default is None or configuration.default:
296293
await set_default_gateway(
297294
session=session,
298295
project=project,
@@ -309,7 +306,9 @@ async def create_gateway(
309306
load_backend_type=True,
310307
)
311308
assert gateway is not None
312-
return gateway_model_to_gateway(gateway, default_gateway_id=default_gateway.id)
309+
return gateway_model_to_gateway(
310+
gateway, default_gateway_id=default_gateway.id if default_gateway is not None else None
311+
)
313312

314313

315314
async def connect_to_gateway_with_retry(
@@ -430,7 +429,11 @@ async def set_gateway_wildcard_domain(
430429

431430

432431
async def set_default_gateway(
433-
session: AsyncSession, project: ProjectModel, ref: EntityReference, user: Optional[UserModel]
432+
session: AsyncSession,
433+
project: ProjectModel,
434+
ref: EntityReference,
435+
user: Optional[UserModel],
436+
commit: bool = True,
434437
):
435438
gateway = await get_project_gateway_model_by_reference(
436439
session=session, project=project, ref=ref
@@ -470,7 +473,28 @@ async def set_default_gateway(
470473
events.Target.from_model(project),
471474
],
472475
)
473-
await session.commit()
476+
if commit:
477+
await session.commit()
478+
479+
480+
async def unset_default_gateway(
481+
session: AsyncSession, project: ProjectModel, expect_gateway_id: uuid.UUID, user: UserModel
482+
) -> None:
483+
gateway = await get_project_default_gateway_model(session, project)
484+
if gateway is None or gateway.id != expect_gateway_id:
485+
return
486+
await session.execute(
487+
update(ProjectModel).where(ProjectModel.id == project.id).values(default_gateway_id=None)
488+
)
489+
events.emit(
490+
session,
491+
"Gateway unset as project default",
492+
actor=events.UserActor.from_user(user),
493+
targets=[
494+
events.Target.from_model(gateway),
495+
events.Target.from_model(project),
496+
],
497+
)
474498

475499

476500
async def list_project_gateway_models(
@@ -849,7 +873,6 @@ def get_gateway_configuration(gateway_model: GatewayModel) -> GatewayConfigurati
849873
# Handle gateways created before GatewayConfiguration was introduced
850874
return GatewayConfiguration(
851875
name=gateway_model.name,
852-
default=False,
853876
backend=gateway_model.backend.type,
854877
region=gateway_model.region,
855878
domain=gateway_model.wildcard_domain,
@@ -979,7 +1002,10 @@ async def get_plan(
9791002
current_gateway_model, default_gateway_id=project.default_gateway_id
9801003
)
9811004
if _can_update_gateway_in_place(
982-
diff_models(current_gateway.configuration, effective_spec.configuration)
1005+
diff_gateway_configurations(
1006+
current_gateway.configuration,
1007+
effective_spec.configuration,
1008+
)
9831009
):
9841010
action = ApplyAction.UPDATE
9851011

@@ -1055,7 +1081,10 @@ async def apply_plan(
10551081
"Failed to apply plan. Resource has been changed. Try again or use force apply."
10561082
)
10571083

1058-
diff = diff_models(current_configuration, new_configuration)
1084+
diff = diff_gateway_configurations(
1085+
current_configuration,
1086+
new_configuration,
1087+
)
10591088
if not _can_update_gateway_in_place(diff):
10601089
raise ServerClientError(
10611090
f"Gateway {new_configuration.name!r} cannot be updated in-place."
@@ -1069,6 +1098,21 @@ async def apply_plan(
10691098
if new_configuration.replicas is not None
10701099
else GATEWAY_REPLICAS_DEFAULT
10711100
)
1101+
if new_configuration.default is True:
1102+
await set_default_gateway(
1103+
session=session,
1104+
project=project,
1105+
ref=EntityReference(name=gateway_model.name, project=None),
1106+
user=user,
1107+
commit=False,
1108+
)
1109+
elif new_configuration.default is False:
1110+
await unset_default_gateway(
1111+
session=session,
1112+
project=project,
1113+
expect_gateway_id=gateway_model.id,
1114+
user=user,
1115+
)
10721116
gateway_model.configuration = new_configuration.model_dump_json()
10731117
gateway_model.last_update_at = get_current_datetime()
10741118
events.emit(

src/dstack/api/server/_gateways.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from dstack._internal.core.compatibility.gateways import (
44
get_apply_plan_excludes,
55
get_create_gateway_excludes,
6+
get_get_plan_excludes,
67
get_set_default_gateway_excludes,
78
)
89
from dstack._internal.core.models.common import validate_extra_ignore
@@ -46,7 +47,8 @@ def get(self, project_name: str, gateway_name: str) -> Gateway:
4647
def get_plan(self, project_name: str, spec: GatewaySpec) -> GatewayPlan:
4748
body = GetGatewayPlanRequest(spec=spec)
4849
resp = self._request(
49-
f"/api/project/{project_name}/gateways/get_plan", body=body.model_dump_json()
50+
f"/api/project/{project_name}/gateways/get_plan",
51+
body=body.model_dump_json(exclude=get_get_plan_excludes(body)),
5052
)
5153
return validate_extra_ignore(GatewayPlan, resp.json())
5254

0 commit comments

Comments
 (0)