From d2f57f0a7b1a9a4141f7b8a48cc085a7f8e0492b Mon Sep 17 00:00:00 2001 From: tytv2 Date: Thu, 6 Aug 2026 09:32:37 +0700 Subject: [PATCH] feat(vks): update_nodegroup disable_auto_scale sends autoScaleConfig:null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend's autoScaleConfig is JsonNullable, but the MCP tool's Optional[AutoScaleConfig]=None + model_dump(exclude_none=True) could only express two states — omit (keep) and object (set). Disabling autoscaling (send autoScaleConfig: null to delete the current config) was impossible. Add a bool disable_auto_scale sentinel to UpdateNodeGroupDto (mirroring the greennode-cli --disable-auto-scale flag). The handler rejects it together with an autoScaleConfig object (mutually exclusive), strips it from the payload, and sets an explicit autoScaleConfig: None so the wire body carries "autoScaleConfig": null. The other two CLI gaps are already handled by the pydantic models: AutoScaleConfig marks minSize/maxSize required (fail-fast on a partial object at parse time) and UpgradeConfig defaults maxSurge=1/maxUnavailable=0. Only the disable state needed work. Tests pin all three states: - disable alone sends "autoScaleConfig": null, strips the sentinel - disable + object rejected before any HTTP call - autoScaleConfig omitted keeps the field off the wire - autoScaleConfig object set carries the object --- .../greennode/vks_mcp_server/models.py | 7 ++ .../vks_mcp_server/nodegroup_handler.py | 15 +++- .../tests/test_nodegroup_tools.py | 84 +++++++++++++++++++ 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/vks-mcp-server/greennode/vks_mcp_server/models.py b/src/vks-mcp-server/greennode/vks_mcp_server/models.py index bf8739f..5070747 100644 --- a/src/vks-mcp-server/greennode/vks_mcp_server/models.py +++ b/src/vks-mcp-server/greennode/vks_mcp_server/models.py @@ -1040,6 +1040,13 @@ class UpdateNodeGroupDto(BaseModel): numNodes: Optional[int] = Field(None, ge=0, le=10, description="Number of nodes (0-10)") securityGroups: Optional[list[str]] = Field(None, description="Security group IDs") autoScaleConfig: Optional[AutoScaleConfig] = Field(None, description="Autoscaling bounds") + disable_auto_scale: bool = Field( + False, + description=( + "Disable autoscaling — sends autoScaleConfig: null, deleting the current " + "config. Mutually exclusive with autoScaleConfig." + ), + ) upgradeConfig: Optional[UpgradeConfig] = Field(None, description="Upgrade config") diff --git a/src/vks-mcp-server/greennode/vks_mcp_server/nodegroup_handler.py b/src/vks-mcp-server/greennode/vks_mcp_server/nodegroup_handler.py index 97af9c0..b7c5f85 100644 --- a/src/vks-mcp-server/greennode/vks_mcp_server/nodegroup_handler.py +++ b/src/vks-mcp-server/greennode/vks_mcp_server/nodegroup_handler.py @@ -390,7 +390,8 @@ async def update_nodegroup( ..., description=( "Update body. No fields required. Optional: numNodes (0-10), securityGroups, " - "autoScaleConfig, upgradeConfig. To change labels/tags/taints use " + "autoScaleConfig (object to set, or disable_auto_scale=true to delete it), " + "disable_auto_scale, upgradeConfig. To change labels/tags/taints use " "update_nodegroup_metadata." ), ), @@ -406,12 +407,20 @@ async def update_nodegroup( """ validate_id(cluster_id, "cluster_id") validate_id(nodegroup_id, "nodegroup_id") + if body.disable_auto_scale and body.autoScaleConfig is not None: + return ( + "autoScaleConfig and disable_auto_scale are mutually exclusive: pass an " + "object to set autoscaling, or disable_auto_scale=true to disable it." + ) payload = body.model_dump(exclude_none=True) + payload.pop("disable_auto_scale", None) # internal flag, never sent on the wire + if body.disable_auto_scale: + payload["autoScaleConfig"] = None # explicit null → backend deletes the config if not payload: return ( "Nothing to update: provide at least one of numNodes, securityGroups, " - "autoScaleConfig, or upgradeConfig (use update_nodegroup_metadata for " - "labels/tags/taints)." + "autoScaleConfig, disable_auto_scale, or upgradeConfig (use " + "update_nodegroup_metadata for labels/tags/taints)." ) result = await self.client.put( f"/v1/clusters/{cluster_id}/node-groups/{nodegroup_id}", diff --git a/src/vks-mcp-server/tests/test_nodegroup_tools.py b/src/vks-mcp-server/tests/test_nodegroup_tools.py index efd0709..4ab6004 100644 --- a/src/vks-mcp-server/tests/test_nodegroup_tools.py +++ b/src/vks-mcp-server/tests/test_nodegroup_tools.py @@ -10,6 +10,7 @@ from greennode.vks_mcp_server.client import VksClient from greennode.vks_mcp_server.config import load_config from greennode.vks_mcp_server.models import ( + AutoScaleConfig, CreateNodeGroupDto, NodeGroupDetail, NodeGroupListData, @@ -430,6 +431,89 @@ async def test_nodegroup_update_empty_body_guarded(handler_write, respx_mock): assert "nothing to update" in result.lower() +@respx.mock +@pytest.mark.asyncio +async def test_nodegroup_update_disable_autoscale_sends_null(handler_write, respx_mock): + """disable_auto_scale=True sends autoScaleConfig: null on the wire and strips the sentinel.""" + _mock_iam(respx_mock) + cluster_id, nodegroup_id = "k8s-abc", "ng-001" + respx_mock.put(f"{VKS_BASE}/v1/clusters/{cluster_id}/node-groups/{nodegroup_id}").mock( + return_value=httpx.Response(200, json={"uid": nodegroup_id, "status": "ACTIVE"}) + ) + + result = await handler_write.update_nodegroup( + cluster_id=cluster_id, + nodegroup_id=nodegroup_id, + body=UpdateNodeGroupDto(disable_auto_scale=True), + region=None, + ) + sent = _json.loads(respx_mock.calls.last.request.content) + assert sent == {"autoScaleConfig": None} + assert "disable_auto_scale" not in sent + assert "updated successfully" in result.lower() + + +@respx.mock +@pytest.mark.asyncio +async def test_nodegroup_update_disable_and_object_is_mutually_exclusive( + handler_write, respx_mock +): + """disable_auto_scale with an autoScaleConfig object is rejected before any HTTP call.""" + _mock_iam(respx_mock) + route = respx_mock.put(f"{VKS_BASE}/v1/clusters/k8s-abc/node-groups/ng-001").mock( + return_value=httpx.Response(200, json={}) + ) + + result = await handler_write.update_nodegroup( + cluster_id="k8s-abc", + nodegroup_id="ng-001", + body=UpdateNodeGroupDto( + disable_auto_scale=True, + autoScaleConfig=AutoScaleConfig(minSize=1, maxSize=3), + ), + region=None, + ) + assert not route.called + assert "mutually exclusive" in result.lower() + + +@respx.mock +@pytest.mark.asyncio +async def test_nodegroup_update_omits_autoscale_when_unset(handler_write, respx_mock): + """autoScaleConfig omitted → wire body has no autoScaleConfig key (keep current). Pin.""" + _mock_iam(respx_mock) + respx_mock.put(f"{VKS_BASE}/v1/clusters/k8s-abc/node-groups/ng-001").mock( + return_value=httpx.Response(200, json={"uid": "ng-001"}) + ) + await handler_write.update_nodegroup( + cluster_id="k8s-abc", + nodegroup_id="ng-001", + body=UpdateNodeGroupDto(numNodes=5), + region=None, + ) + sent = _json.loads(respx_mock.calls.last.request.content) + assert "autoScaleConfig" not in sent + assert "disable_auto_scale" not in sent + + +@respx.mock +@pytest.mark.asyncio +async def test_nodegroup_update_sends_autoscale_object(handler_write, respx_mock): + """autoScaleConfig object set → wire body carries the object. Pin.""" + _mock_iam(respx_mock) + respx_mock.put(f"{VKS_BASE}/v1/clusters/k8s-abc/node-groups/ng-001").mock( + return_value=httpx.Response(200, json={"uid": "ng-001"}) + ) + await handler_write.update_nodegroup( + cluster_id="k8s-abc", + nodegroup_id="ng-001", + body=UpdateNodeGroupDto(autoScaleConfig=AutoScaleConfig(minSize=1, maxSize=3)), + region=None, + ) + sent = _json.loads(respx_mock.calls.last.request.content) + assert sent["autoScaleConfig"] == {"minSize": 1, "maxSize": 3} + + @respx.mock @pytest.mark.asyncio async def test_nodegroup_update_metadata_sends_patch(handler_write, respx_mock):