From 25f76e1044f45af0ca105cd7e98d3c6de2ccbf95 Mon Sep 17 00:00:00 2001 From: Jvst Me Date: Fri, 14 Aug 2026 14:21:28 +0200 Subject: [PATCH] Fix server compatibility with 0.20.[27-29] clients Ignore `GatewayConfiguration.router` in client requests if `null`, which is what 0.20.27, 0.20.28, and 0.20.29 clients send by default. --- src/dstack/_internal/core/models/common.py | 19 ++++++++++ .../_internal/server/schemas/gateways.py | 20 ++++++++-- .../_internal/core/models/test_common.py | 38 ++++++++++++++++++- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/dstack/_internal/core/models/common.py b/src/dstack/_internal/core/models/common.py index 69e4297dca..a649420edb 100644 --- a/src/dstack/_internal/core/models/common.py +++ b/src/dstack/_internal/core/models/common.py @@ -57,6 +57,25 @@ class FrozenCoreModel(CoreModel): model_config = ConfigDict(frozen=True) +def pop_null_field(values: Any, *path: str) -> Any: + """ + Drop a field at `path` from the `values` nested dict if it's set to `null`. + + Mutates and returns the same `values` object. + """ + if not isinstance(values, dict): + return values + node = values + for key in path[:-1]: + node = node.get(key) + if not isinstance(node, dict): + return values + field = path[-1] + if field in node and node[field] is None: + del node[field] + return values + + T = TypeVar("T") _type_adapters: dict[Any, TypeAdapter] = {} diff --git a/src/dstack/_internal/server/schemas/gateways.py b/src/dstack/_internal/server/schemas/gateways.py index 3c13ba6ae6..fa1e82ab89 100644 --- a/src/dstack/_internal/server/schemas/gateways.py +++ b/src/dstack/_internal/server/schemas/gateways.py @@ -1,8 +1,8 @@ -from typing import Annotated, List, Optional +from typing import Annotated, Any, List, Optional -from pydantic import Field +from pydantic import Field, model_validator -from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.common import CoreModel, pop_null_field from dstack._internal.core.models.gateways import ( ApplyGatewayPlanInput, GatewayConfiguration, @@ -25,6 +25,12 @@ class GetGatewayRequest(CoreModel): class GetGatewayPlanRequest(CoreModel): spec: GatewaySpec + @model_validator(mode="before") + @classmethod + def _drop_null_router(cls, values: Any) -> Any: + # Compatibility with 0.20.27, 0.20.28, 0.20.29 clients + return pop_null_field(values, "spec", "configuration", "router") + class ApplyGatewayPlanRequest(CoreModel): plan: ApplyGatewayPlanInput @@ -35,6 +41,14 @@ class ApplyGatewayPlanRequest(CoreModel): ), ] + @model_validator(mode="before") + @classmethod + def _drop_null_router(cls, values: Any) -> Any: + # Compatibility with 0.20.27, 0.20.28, 0.20.29 clients + values = pop_null_field(values, "plan", "spec", "configuration", "router") + values = pop_null_field(values, "plan", "current_resource", "configuration", "router") + return values + class DeleteGatewaysRequest(CoreModel): names: List[str] diff --git a/src/tests/_internal/core/models/test_common.py b/src/tests/_internal/core/models/test_common.py index 8cc1e50032..60dd34616a 100644 --- a/src/tests/_internal/core/models/test_common.py +++ b/src/tests/_internal/core/models/test_common.py @@ -1,6 +1,6 @@ import pytest -from dstack._internal.core.models.common import EntityReference +from dstack._internal.core.models.common import EntityReference, pop_null_field class TestEntityReferenceParse: @@ -25,3 +25,39 @@ def test_valid(self, value, expected): def test_invalid(self, value: str): with pytest.raises(ValueError, match="Invalid entity reference"): EntityReference.parse(value) + + +class TestPopNullField: + def test_drops_null_field_at_top_level(self): + values = {"router": None, "backend": "aws"} + assert pop_null_field(values, "router") == {"backend": "aws"} + + def test_drops_null_field_in_nested_dict(self): + values = {"spec": {"configuration": {"router": None, "backend": "aws"}}} + assert pop_null_field(values, "spec", "configuration", "router") == { + "spec": {"configuration": {"backend": "aws"}} + } + + def test_leaves_non_null_field_untouched(self): + values = {"configuration": {"router": "some-router"}} + assert pop_null_field(values, "configuration", "router") == { + "configuration": {"router": "some-router"} + } + + def test_field_absent(self): + values = {"configuration": {"backend": "aws"}} + assert pop_null_field(values, "configuration", "router") == { + "configuration": {"backend": "aws"} + } + + def test_intermediate_path_missing(self): + values = {"backend": "aws"} + assert pop_null_field(values, "configuration", "router") == {"backend": "aws"} + + def test_intermediate_path_not_a_dict(self): + values = {"configuration": None} + assert pop_null_field(values, "configuration", "router") == {"configuration": None} + + def test_top_level_not_a_dict(self): + values = "not-a-dict" + assert pop_null_field(values, "configuration", "router") == "not-a-dict"