Skip to content
Open
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
10 changes: 3 additions & 7 deletions src/dstack/_internal/cli/services/configurators/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
GatewaySpec,
GatewayStatus,
)
from dstack._internal.core.services.diff import diff_models
from dstack._internal.core.services.gateways import diff_gateway_configurations
from dstack._internal.utils.common import local_time
from dstack._internal.utils.logging import get_logger
from dstack._internal.utils.nested_list import NestedList, NestedListItem
Expand Down Expand Up @@ -60,15 +60,11 @@ def apply_configuration(
confirm_message += "Create the gateway?"
else:
action_message += f"Found gateway [code]{plan.effective_spec.configuration.name}[/]."
diff = diff_models(
diff = diff_gateway_configurations(
plan.current_resource.configuration,
plan.effective_spec.configuration,
)
changed_fields = list(diff.keys())
if (
plan.current_resource.configuration == plan.effective_spec.configuration
or changed_fields == ["default"]
):
if not diff:
if command_args.yes and not command_args.force:
# --force is required only with --yes,
# otherwise we may ask for force apply interactively.
Expand Down
25 changes: 22 additions & 3 deletions src/dstack/_internal/core/compatibility/gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,30 @@
GatewayConfiguration,
GatewaySpec,
)
from dstack._internal.server.schemas.gateways import SetDefaultGatewayRequest
from dstack._internal.server.schemas.gateways import (
GetGatewayPlanRequest,
SetDefaultGatewayRequest,
)


def get_get_plan_excludes(body: GetGatewayPlanRequest) -> IncludeExcludeDictType:
return {"spec": get_gateway_spec_excludes(body.spec)}


def get_apply_plan_excludes(plan_input: ApplyGatewayPlanInput) -> IncludeExcludeDictType:
apply_plan_excludes: IncludeExcludeDictType = {}
apply_plan_excludes: IncludeExcludeDictType = {
"spec": get_gateway_spec_excludes(plan_input.spec)
}
if plan_input.current_resource is not None:
# `Gateway.backend` and `Gateway.region` are deprecated and never set since 0.21.
# Not sending them lets 0.22 drop the fields without breaking 0.21 clients.
apply_plan_excludes["current_resource"] = {"backend": True, "region": True}
apply_plan_excludes["current_resource"] = {
"backend": True,
"region": True,
"configuration": _get_gateway_configuration_excludes(
plan_input.current_resource.configuration
),
}
return {"plan": apply_plan_excludes}


Expand Down Expand Up @@ -49,4 +64,8 @@ def _get_gateway_configuration_excludes(
configuration: GatewayConfiguration,
) -> IncludeExcludeDictType:
configuration_excludes: IncludeExcludeDictType = {}

if configuration.default is None:
configuration_excludes["default"] = True

return configuration_excludes
13 changes: 12 additions & 1 deletion src/dstack/_internal/core/models/gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,18 @@ class GatewayCertificate(RootModel[Annotated[AnyGatewayCertificate, Field(discri
class GatewayConfiguration(CoreModel):
type: Literal["gateway"] = "gateway"
name: Annotated[Optional[str], Field(description="The gateway name")] = None
default: Annotated[bool, Field(description="Make the gateway default")] = False
default: Annotated[
Optional[bool],
Field(
description=(
"Whether the gateway is the project's default. Can be updated in-place."
" If unset when creating a new gateway,"
" the gateway will become the default unless there is already a default gateway."
" If unset when updating the gateway in-place,"
" the gateway's default status will not change"
)
),
] = None
backend: Annotated[BackendType, Field(description="The gateway backend")]
region: Annotated[str, Field(description="The gateway region")]
instance_type: Annotated[
Expand Down
11 changes: 11 additions & 0 deletions src/dstack/_internal/core/services/gateways.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from dstack._internal.core.models.gateways import GatewayConfiguration
from dstack._internal.core.services.diff import ModelDiff, diff_models


def diff_gateway_configurations(old: GatewayConfiguration, new: GatewayConfiguration) -> ModelDiff:
return diff_models(
old,
new,
# default=None => default should stay unchanged => shouldn't be in the diff
reset={"default"} if new.default is None else {},
)
28 changes: 27 additions & 1 deletion src/dstack/_internal/server/compatibility/gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,27 @@

from packaging.version import Version

from dstack._internal.core.models.gateways import Gateway, GatewayPlan
from dstack._internal.core.models.gateways import (
Gateway,
GatewayConfiguration,
GatewayPlan,
GatewaySpec,
)


def patch_gateway_spec_in_request(spec: GatewaySpec, client_version: Optional[Version]) -> None:
if client_version is None:
return
if client_version < Version("0.21.1") and spec.configuration.default is False:
# Pre-0.21.1 clients send `default=false` both when `default` was omitted and when it was
# set to `false` explicitly. Assume it was omitted, which is more common and more useful.
spec.configuration.default = None


def patch_gateway(gateway: Gateway, client_version: Optional[Version]) -> None:
if client_version is None:
return
_patch_gateway_configuration(gateway.configuration, client_version)
if client_version < Version("0.20.25"):
gateway.instance_id = ""
gateway.ip_address = "\n".join(r.hostname for r in gateway.replicas if r.hostname)
Expand All @@ -26,5 +41,16 @@ def patch_gateway(gateway: Gateway, client_version: Optional[Version]) -> None:
def patch_gateway_plan(plan: GatewayPlan, client_version: Optional[Version]) -> None:
if client_version is None:
return
_patch_gateway_configuration(plan.spec.configuration, client_version)
_patch_gateway_configuration(plan.effective_spec.configuration, client_version)
if plan.current_resource is not None:
patch_gateway(plan.current_resource, client_version)


def _patch_gateway_configuration(
configuration: GatewayConfiguration, client_version: Optional[Version]
):
if client_version is None:
return
if client_version < Version("0.21.1") and configuration.default is None:
configuration.default = False
8 changes: 7 additions & 1 deletion src/dstack/_internal/server/routers/gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
import dstack._internal.server.services.gateways as gateways
from dstack._internal.core.errors import ResourceNotExistsError
from dstack._internal.core.models.common import EntityReference
from dstack._internal.server.compatibility.gateways import patch_gateway, patch_gateway_plan
from dstack._internal.server.compatibility.gateways import (
patch_gateway,
patch_gateway_plan,
patch_gateway_spec_in_request,
)
from dstack._internal.server.db import get_session
from dstack._internal.server.deps import Project
from dstack._internal.server.models import ProjectModel, UserModel
Expand Down Expand Up @@ -83,6 +87,7 @@ async def get_plan(
This is an optional step before calling `/apply`.
"""
user, project = user_project
patch_gateway_spec_in_request(body.spec, client_version)
plan = await gateways.get_plan(
session=session,
project=project,
Expand All @@ -105,6 +110,7 @@ async def apply_plan(
Creates a new gateway or updates an existing gateway in-place.
"""
user, project = user_project
patch_gateway_spec_in_request(body.plan.spec, client_version)
gateway = await gateways.apply_plan(
session=session,
user=user,
Expand Down
70 changes: 57 additions & 13 deletions src/dstack/_internal/server/services/gateways/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,8 @@
LetsEncryptGatewayCertificate,
)
from dstack._internal.core.services import validate_dstack_resource_name
from dstack._internal.core.services.diff import (
ModelDiff,
diff_models,
format_diff_fields_for_event,
)
from dstack._internal.core.services.diff import ModelDiff, format_diff_fields_for_event
from dstack._internal.core.services.gateways import diff_gateway_configurations
from dstack._internal.proxy.gateway.const import SERVICE_SCALING_WINDOWS
from dstack._internal.proxy.gateway.schemas.stats import PerWindowStats, Stat
from dstack._internal.server import settings
Expand Down Expand Up @@ -92,7 +89,7 @@
from dstack._internal.utils.logging import get_logger

logger = get_logger(__name__)
_CONF_UPDATABLE_FIELDS = frozenset({"domain"})
_CONF_UPDATABLE_FIELDS = frozenset({"domain", "default"})
if FeatureFlags.GATEWAY_SCALING:
_CONF_UPDATABLE_FIELDS |= {"replicas"}

Expand Down Expand Up @@ -292,7 +289,7 @@ async def create_gateway(
await session.commit()

default_gateway = await get_project_default_gateway_model(session=session, project=project)
if default_gateway is None or configuration.default:
if default_gateway is None and configuration.default is None or configuration.default:
await set_default_gateway(
session=session,
project=project,
Expand All @@ -309,7 +306,9 @@ async def create_gateway(
load_backend_type=True,
)
assert gateway is not None
return gateway_model_to_gateway(gateway, default_gateway_id=default_gateway.id)
return gateway_model_to_gateway(
gateway, default_gateway_id=default_gateway.id if default_gateway is not None else None
)


async def connect_to_gateway_with_retry(
Expand Down Expand Up @@ -430,7 +429,11 @@ async def set_gateway_wildcard_domain(


async def set_default_gateway(
session: AsyncSession, project: ProjectModel, ref: EntityReference, user: Optional[UserModel]
session: AsyncSession,
project: ProjectModel,
ref: EntityReference,
user: Optional[UserModel],
commit: bool = True,
):
gateway = await get_project_gateway_model_by_reference(
session=session, project=project, ref=ref
Expand Down Expand Up @@ -470,7 +473,28 @@ async def set_default_gateway(
events.Target.from_model(project),
],
)
await session.commit()
if commit:
await session.commit()


async def unset_default_gateway(
session: AsyncSession, project: ProjectModel, expect_gateway_id: uuid.UUID, user: UserModel
) -> None:
gateway = await get_project_default_gateway_model(session, project)
if gateway is None or gateway.id != expect_gateway_id:
return
await session.execute(
update(ProjectModel).where(ProjectModel.id == project.id).values(default_gateway_id=None)
)
events.emit(
session,
"Gateway unset as project default",
actor=events.UserActor.from_user(user),
targets=[
events.Target.from_model(gateway),
events.Target.from_model(project),
],
)


async def list_project_gateway_models(
Expand Down Expand Up @@ -849,7 +873,6 @@ def get_gateway_configuration(gateway_model: GatewayModel) -> GatewayConfigurati
# Handle gateways created before GatewayConfiguration was introduced
return GatewayConfiguration(
name=gateway_model.name,
default=False,
backend=gateway_model.backend.type,
region=gateway_model.region,
domain=gateway_model.wildcard_domain,
Expand Down Expand Up @@ -979,7 +1002,10 @@ async def get_plan(
current_gateway_model, default_gateway_id=project.default_gateway_id
)
if _can_update_gateway_in_place(
diff_models(current_gateway.configuration, effective_spec.configuration)
diff_gateway_configurations(
current_gateway.configuration,
effective_spec.configuration,
)
):
action = ApplyAction.UPDATE

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

diff = diff_models(current_configuration, new_configuration)
diff = diff_gateway_configurations(
current_configuration,
new_configuration,
)
if not _can_update_gateway_in_place(diff):
raise ServerClientError(
f"Gateway {new_configuration.name!r} cannot be updated in-place."
Expand All @@ -1069,6 +1098,21 @@ async def apply_plan(
if new_configuration.replicas is not None
else GATEWAY_REPLICAS_DEFAULT
)
if new_configuration.default is True:
await set_default_gateway(
session=session,
project=project,
ref=EntityReference(name=gateway_model.name, project=None),
user=user,
commit=False,
)
elif new_configuration.default is False:
await unset_default_gateway(
session=session,
project=project,
expect_gateway_id=gateway_model.id,
user=user,
)
gateway_model.configuration = new_configuration.model_dump_json()
gateway_model.last_update_at = get_current_datetime()
events.emit(
Expand Down
4 changes: 3 additions & 1 deletion src/dstack/api/server/_gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from dstack._internal.core.compatibility.gateways import (
get_apply_plan_excludes,
get_create_gateway_excludes,
get_get_plan_excludes,
get_set_default_gateway_excludes,
)
from dstack._internal.core.models.common import validate_extra_ignore
Expand Down Expand Up @@ -46,7 +47,8 @@ def get(self, project_name: str, gateway_name: str) -> Gateway:
def get_plan(self, project_name: str, spec: GatewaySpec) -> GatewayPlan:
body = GetGatewayPlanRequest(spec=spec)
resp = self._request(
f"/api/project/{project_name}/gateways/get_plan", body=body.model_dump_json()
f"/api/project/{project_name}/gateways/get_plan",
body=body.model_dump_json(exclude=get_get_plan_excludes(body)),
)
return validate_extra_ignore(GatewayPlan, resp.json())

Expand Down
Loading
Loading