From fe2f3fb885656119a5e1b024e1bb0ea56d62712a Mon Sep 17 00:00:00 2001 From: jopemachine Date: Wed, 18 Mar 2026 17:10:36 +0900 Subject: [PATCH 01/10] feat(BA-4631): Add update_deployment_policy GQL mutation Add Strawberry GraphQL mutation for updating deployment policies, internally using the existing upsert mechanism. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../manager/api/gql/deployment/__init__.py | 9 ++++ .../api/gql/deployment/resolver/__init__.py | 5 ++ .../api/gql/deployment/resolver/policy.py | 38 +++++++++++++ .../api/gql/deployment/types/__init__.py | 4 ++ .../api/gql/deployment/types/policy.py | 53 +++++++++++++++++++ src/ai/backend/manager/api/gql/schema.py | 2 + 6 files changed, 111 insertions(+) create mode 100644 src/ai/backend/manager/api/gql/deployment/resolver/policy.py diff --git a/src/ai/backend/manager/api/gql/deployment/__init__.py b/src/ai/backend/manager/api/gql/deployment/__init__.py index e45322e0a81..31e5fe6c464 100644 --- a/src/ai/backend/manager/api/gql/deployment/__init__.py +++ b/src/ai/backend/manager/api/gql/deployment/__init__.py @@ -40,6 +40,8 @@ routes, sync_replicas, update_auto_scaling_rule, + # Policy + update_deployment_policy, update_model_deployment, update_route_traffic_status, ) @@ -144,6 +146,9 @@ UpdateAutoScalingRulePayload, UpdateDeploymentInput, UpdateDeploymentPayload, + # Policy (mutation types) + UpdateDeploymentPolicyInputGQL, + UpdateDeploymentPolicyPayloadGQL, UpdateRouteTrafficStatusInputGQL, UpdateRouteTrafficStatusPayloadGQL, get_route_pagination_spec, @@ -204,6 +209,8 @@ "DeploymentStrategyTypeGQL", "RollingUpdateConfigInputGQL", "RollingUpdateStrategySpecGQL", + "UpdateDeploymentPolicyInputGQL", + "UpdateDeploymentPolicyPayloadGQL", # Replica Types "ActivenessStatus", "LivenessStatus", @@ -267,6 +274,8 @@ "deployments", "sync_replicas", "update_model_deployment", + # Resolvers - Policy + "update_deployment_policy", # Resolvers - Replica "replica", "replica_status_changed", diff --git a/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py b/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py index 61b2de6c007..56201b26565 100644 --- a/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py +++ b/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py @@ -21,6 +21,9 @@ sync_replicas, update_model_deployment, ) +from .policy import ( + update_deployment_policy, +) from .replica import ( replica, replica_status_changed, @@ -55,6 +58,8 @@ "delete_model_deployment", "sync_replicas", "deployment_status_changed", + # Policy + "update_deployment_policy", # Replica "replicas", "replica", diff --git a/src/ai/backend/manager/api/gql/deployment/resolver/policy.py b/src/ai/backend/manager/api/gql/deployment/resolver/policy.py new file mode 100644 index 00000000000..28925813478 --- /dev/null +++ b/src/ai/backend/manager/api/gql/deployment/resolver/policy.py @@ -0,0 +1,38 @@ +"""Deployment policy resolver functions.""" + +from __future__ import annotations + +from uuid import UUID + +import strawberry +from strawberry import Info + +from ai.backend.manager.api.gql.deployment.types.policy import ( + DeploymentPolicyGQL, + UpdateDeploymentPolicyInputGQL, + UpdateDeploymentPolicyPayloadGQL, +) +from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.services.deployment.actions.deployment_policy.upsert_deployment_policy import ( + UpsertDeploymentPolicyAction, +) + + +@strawberry.mutation(description="Added in 26.4.0") # type: ignore[misc] +async def update_deployment_policy( + input: UpdateDeploymentPolicyInputGQL, + info: Info[StrawberryGQLContext], +) -> UpdateDeploymentPolicyPayloadGQL: + """Update (upsert) a deployment policy for a deployment.""" + deployment_uuid = UUID(str(input.deployment_id)) + upserter = input.to_upserter(deployment_uuid) + + processor = info.context.processors.deployment + result = await processor.upsert_deployment_policy.wait_for_complete( + UpsertDeploymentPolicyAction(upserter=upserter) + ) + + return UpdateDeploymentPolicyPayloadGQL( + deployment_policy=DeploymentPolicyGQL.from_data(result.data), + created=result.created, + ) diff --git a/src/ai/backend/manager/api/gql/deployment/types/__init__.py b/src/ai/backend/manager/api/gql/deployment/types/__init__.py index 3fb76bc9ab6..4296bc0309a 100644 --- a/src/ai/backend/manager/api/gql/deployment/types/__init__.py +++ b/src/ai/backend/manager/api/gql/deployment/types/__init__.py @@ -57,6 +57,8 @@ DeploymentStrategyTypeGQL, RollingUpdateConfigInputGQL, RollingUpdateStrategySpecGQL, + UpdateDeploymentPolicyInputGQL, + UpdateDeploymentPolicyPayloadGQL, ) from .replica import ( ActivenessStatus, @@ -166,6 +168,8 @@ "DeploymentStrategyTypeGQL", "RollingUpdateConfigInputGQL", "RollingUpdateStrategySpecGQL", + "UpdateDeploymentPolicyInputGQL", + "UpdateDeploymentPolicyPayloadGQL", # Replica "ActivenessStatus", "LivenessStatus", diff --git a/src/ai/backend/manager/api/gql/deployment/types/policy.py b/src/ai/backend/manager/api/gql/deployment/types/policy.py index 4823dd8ecd3..6c4e76f6612 100644 --- a/src/ai/backend/manager/api/gql/deployment/types/policy.py +++ b/src/ai/backend/manager/api/gql/deployment/types/policy.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import Self +from uuid import UUID import strawberry from strawberry import ID @@ -11,6 +12,7 @@ from ai.backend.common.data.model_deployment.types import DeploymentStrategy from ai.backend.manager.data.deployment.types import DeploymentPolicyData +from ai.backend.manager.data.deployment.upserter import DeploymentPolicyUpserter from ai.backend.manager.errors.deployment import InvalidDeploymentStrategySpec from ai.backend.manager.models.deployment_policy import BlueGreenSpec, RollingUpdateSpec @@ -129,3 +131,54 @@ def to_spec(self) -> BlueGreenSpec: auto_promote=self.auto_promote, promote_delay_seconds=self.promote_delay_seconds, ) + + +# ========== Mutation Input/Payload Types ========== + + +@strawberry.input( + name="UpdateDeploymentPolicyInput", + description="Added in 26.3.0. Input for updating a deployment policy. Internally upserts the policy.", +) +class UpdateDeploymentPolicyInputGQL: + deployment_id: ID + strategy: DeploymentStrategyTypeGQL + rollback_on_failure: bool = False + rolling_update: RollingUpdateConfigInputGQL | None = None + blue_green: BlueGreenConfigInputGQL | None = None + + def to_upserter(self, deployment_uuid: UUID) -> DeploymentPolicyUpserter: + """Convert to DeploymentPolicyUpserter for the service layer.""" + from ai.backend.manager.errors.api import InvalidAPIParameters + + strategy = DeploymentStrategy(self.strategy.value) + strategy_spec: RollingUpdateSpec | BlueGreenSpec + match strategy: + case DeploymentStrategy.ROLLING: + if self.rolling_update is None: + strategy_spec = RollingUpdateSpec(max_surge=1, max_unavailable=0) + else: + strategy_spec = self.rolling_update.to_spec() + case DeploymentStrategy.BLUE_GREEN: + if self.blue_green is None: + strategy_spec = BlueGreenSpec(auto_promote=False, promote_delay_seconds=0) + else: + strategy_spec = self.blue_green.to_spec() + case _: + raise InvalidAPIParameters(f"Unsupported deployment strategy: {strategy}") + + return DeploymentPolicyUpserter( + deployment_id=deployment_uuid, + strategy=strategy, + strategy_spec=strategy_spec, + rollback_on_failure=self.rollback_on_failure, + ) + + +@strawberry.type( + name="UpdateDeploymentPolicyPayload", + description="Added in 26.3.0. Result of updating a deployment policy.", +) +class UpdateDeploymentPolicyPayloadGQL: + deployment_policy: DeploymentPolicyGQL + created: bool diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index b07814bdf3c..559c0bbb705 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -76,6 +76,7 @@ routes, sync_replicas, update_auto_scaling_rule, + update_deployment_policy, update_model_deployment, update_route_traffic_status, ) @@ -430,6 +431,7 @@ class Mutation: delete_model_deployment = delete_model_deployment sync_replicas = sync_replicas add_model_revision = add_model_revision + update_deployment_policy = update_deployment_policy # Notification - Admin APIs admin_create_notification_channel = admin_create_notification_channel admin_update_notification_channel = admin_update_notification_channel From b7d988b91ce23493bb60a5fb134ab37158c7153d Mon Sep 17 00:00:00 2001 From: jopemachine Date: Wed, 18 Mar 2026 17:17:58 +0900 Subject: [PATCH 02/10] wip --- changes/10300.feature.md | 1 + .../graphql-reference/supergraph.graphql | 24 +++++++++++++++++++ .../graphql-reference/v2-schema.graphql | 20 ++++++++++++++++ .../manager/api/gql/deployment/__init__.py | 6 ++--- .../api/gql/deployment/resolver/__init__.py | 2 +- .../api/gql/deployment/resolver/policy.py | 4 +++- .../api/gql/deployment/types/policy.py | 4 ++-- src/ai/backend/manager/api/gql/schema.py | 4 ++-- 8 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 changes/10300.feature.md diff --git a/changes/10300.feature.md b/changes/10300.feature.md new file mode 100644 index 00000000000..df8dc67edd8 --- /dev/null +++ b/changes/10300.feature.md @@ -0,0 +1 @@ +Add `admin_update_deployment_policy` GQL mutation diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 88989077722..4991ea12604 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -7718,6 +7718,9 @@ type Mutation """Added in 25.16.0""" addModelRevision(input: AddRevisionInput!): AddRevisionPayload! @join__field(graph: STRAWBERRY) + """Added in 26.4.0""" + adminUpdateDeploymentPolicy(input: UpdateDeploymentPolicyInput!): UpdateDeploymentPolicyPayload! @join__field(graph: STRAWBERRY) + """Create a new notification channel (admin only)""" adminCreateNotificationChannel(input: CreateNotificationChannelInput!): CreateNotificationChannelPayload! @join__field(graph: STRAWBERRY) @@ -12948,6 +12951,27 @@ type UpdateDeploymentPayload deployment: ModelDeployment! } +""" +Added in 26.3.0. Input for updating a deployment policy. Internally upserts the policy. +""" +input UpdateDeploymentPolicyInput + @join__type(graph: STRAWBERRY) +{ + deploymentId: ID! + strategy: DeploymentStrategyType! + rollbackOnFailure: Boolean! = false + rollingUpdate: RollingUpdateConfigInput = null + blueGreen: BlueGreenConfigInput = null +} + +"""Added in 26.3.0. Result of updating a deployment policy.""" +type UpdateDeploymentPolicyPayload + @join__type(graph: STRAWBERRY) +{ + deploymentPolicy: DeploymentPolicy! + created: Boolean! +} + """Added in 25.14.0""" input UpdateHuggingFaceRegistryInput @join__type(graph: STRAWBERRY) diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index f334b31750e..35e77e52dcd 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -4074,6 +4074,9 @@ type Mutation { """Added in 25.16.0""" addModelRevision(input: AddRevisionInput!): AddRevisionPayload! + """Added in 26.4.0""" + adminUpdateDeploymentPolicy(input: UpdateDeploymentPolicyInput!): UpdateDeploymentPolicyPayload! + """Create a new notification channel (admin only)""" adminCreateNotificationChannel(input: CreateNotificationChannelInput!): CreateNotificationChannelPayload! @@ -7891,6 +7894,23 @@ type UpdateDeploymentPayload { deployment: ModelDeployment! } +""" +Added in 26.3.0. Input for updating a deployment policy. Internally upserts the policy. +""" +input UpdateDeploymentPolicyInput { + deploymentId: ID! + strategy: DeploymentStrategyType! + rollbackOnFailure: Boolean! = false + rollingUpdate: RollingUpdateConfigInput = null + blueGreen: BlueGreenConfigInput = null +} + +"""Added in 26.3.0. Result of updating a deployment policy.""" +type UpdateDeploymentPolicyPayload { + deploymentPolicy: DeploymentPolicy! + created: Boolean! +} + """Added in 25.14.0""" input UpdateHuggingFaceRegistryInput { id: ID! diff --git a/src/ai/backend/manager/api/gql/deployment/__init__.py b/src/ai/backend/manager/api/gql/deployment/__init__.py index 31e5fe6c464..83a9053a544 100644 --- a/src/ai/backend/manager/api/gql/deployment/__init__.py +++ b/src/ai/backend/manager/api/gql/deployment/__init__.py @@ -16,6 +16,8 @@ # Revision activate_deployment_revision, add_model_revision, + # Policy + admin_update_deployment_policy, # Access Token create_access_token, # Auto Scaling @@ -40,8 +42,6 @@ routes, sync_replicas, update_auto_scaling_rule, - # Policy - update_deployment_policy, update_model_deployment, update_route_traffic_status, ) @@ -275,7 +275,7 @@ "sync_replicas", "update_model_deployment", # Resolvers - Policy - "update_deployment_policy", + "admin_update_deployment_policy", # Resolvers - Replica "replica", "replica_status_changed", diff --git a/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py b/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py index 56201b26565..1eae1c9bb5a 100644 --- a/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py +++ b/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py @@ -22,7 +22,7 @@ update_model_deployment, ) from .policy import ( - update_deployment_policy, + admin_update_deployment_policy, ) from .replica import ( replica, diff --git a/src/ai/backend/manager/api/gql/deployment/resolver/policy.py b/src/ai/backend/manager/api/gql/deployment/resolver/policy.py index 28925813478..f842e28085e 100644 --- a/src/ai/backend/manager/api/gql/deployment/resolver/policy.py +++ b/src/ai/backend/manager/api/gql/deployment/resolver/policy.py @@ -13,17 +13,19 @@ UpdateDeploymentPolicyPayloadGQL, ) from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.api.gql.utils import check_admin_only from ai.backend.manager.services.deployment.actions.deployment_policy.upsert_deployment_policy import ( UpsertDeploymentPolicyAction, ) @strawberry.mutation(description="Added in 26.4.0") # type: ignore[misc] -async def update_deployment_policy( +async def admin_update_deployment_policy( input: UpdateDeploymentPolicyInputGQL, info: Info[StrawberryGQLContext], ) -> UpdateDeploymentPolicyPayloadGQL: """Update (upsert) a deployment policy for a deployment.""" + check_admin_only() deployment_uuid = UUID(str(input.deployment_id)) upserter = input.to_upserter(deployment_uuid) diff --git a/src/ai/backend/manager/api/gql/deployment/types/policy.py b/src/ai/backend/manager/api/gql/deployment/types/policy.py index 6c4e76f6612..3df58147c3c 100644 --- a/src/ai/backend/manager/api/gql/deployment/types/policy.py +++ b/src/ai/backend/manager/api/gql/deployment/types/policy.py @@ -138,7 +138,7 @@ def to_spec(self) -> BlueGreenSpec: @strawberry.input( name="UpdateDeploymentPolicyInput", - description="Added in 26.3.0. Input for updating a deployment policy. Internally upserts the policy.", + description="Added in 26.4.0. Input for updating a deployment policy. Internally upserts the policy.", ) class UpdateDeploymentPolicyInputGQL: deployment_id: ID @@ -177,7 +177,7 @@ def to_upserter(self, deployment_uuid: UUID) -> DeploymentPolicyUpserter: @strawberry.type( name="UpdateDeploymentPolicyPayload", - description="Added in 26.3.0. Result of updating a deployment policy.", + description="Added in 26.4.0. Result of updating a deployment policy.", ) class UpdateDeploymentPolicyPayloadGQL: deployment_policy: DeploymentPolicyGQL diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 559c0bbb705..5ec93306b5c 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -52,6 +52,7 @@ # Revision activate_deployment_revision, add_model_revision, + admin_update_deployment_policy, # Access Token create_access_token, # Auto Scaling @@ -76,7 +77,6 @@ routes, sync_replicas, update_auto_scaling_rule, - update_deployment_policy, update_model_deployment, update_route_traffic_status, ) @@ -431,7 +431,7 @@ class Mutation: delete_model_deployment = delete_model_deployment sync_replicas = sync_replicas add_model_revision = add_model_revision - update_deployment_policy = update_deployment_policy + admin_update_deployment_policy = admin_update_deployment_policy # Notification - Admin APIs admin_create_notification_channel = admin_create_notification_channel admin_update_notification_channel = admin_update_notification_channel From 59efaeb5ec326378a6db5275535d8279413e6d66 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Wed, 18 Mar 2026 08:21:05 +0000 Subject: [PATCH 03/10] chore: update api schema dump Co-authored-by: octodog --- docs/manager/graphql-reference/supergraph.graphql | 4 ++-- docs/manager/graphql-reference/v2-schema.graphql | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 4991ea12604..20a9f14697a 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -12952,7 +12952,7 @@ type UpdateDeploymentPayload } """ -Added in 26.3.0. Input for updating a deployment policy. Internally upserts the policy. +Added in 26.4.0. Input for updating a deployment policy. Internally upserts the policy. """ input UpdateDeploymentPolicyInput @join__type(graph: STRAWBERRY) @@ -12964,7 +12964,7 @@ input UpdateDeploymentPolicyInput blueGreen: BlueGreenConfigInput = null } -"""Added in 26.3.0. Result of updating a deployment policy.""" +"""Added in 26.4.0. Result of updating a deployment policy.""" type UpdateDeploymentPolicyPayload @join__type(graph: STRAWBERRY) { diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 35e77e52dcd..03ff449a2e5 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -7895,7 +7895,7 @@ type UpdateDeploymentPayload { } """ -Added in 26.3.0. Input for updating a deployment policy. Internally upserts the policy. +Added in 26.4.0. Input for updating a deployment policy. Internally upserts the policy. """ input UpdateDeploymentPolicyInput { deploymentId: ID! @@ -7905,7 +7905,7 @@ input UpdateDeploymentPolicyInput { blueGreen: BlueGreenConfigInput = null } -"""Added in 26.3.0. Result of updating a deployment policy.""" +"""Added in 26.4.0. Result of updating a deployment policy.""" type UpdateDeploymentPolicyPayload { deploymentPolicy: DeploymentPolicy! created: Boolean! From e251b49460b6d0f381e77452cc7023e9911add99 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Wed, 18 Mar 2026 17:20:46 +0900 Subject: [PATCH 04/10] update: Address Copilot review feedback for update_deployment_policy - Rename to admin_update_deployment_policy with check_admin_only() - Remove redundant deployment_uuid param from to_upserter(), derive from self.deployment_id - Add explicit validation requiring matching config for strategy type - Align version markers to 26.4.0 - Add unit tests for to_upserter() conversion and validation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/gql/deployment/resolver/policy.py | 5 +- .../api/gql/deployment/types/policy.py | 16 ++-- .../test_update_deployment_policy.py | 74 +++++++++++++++++++ 3 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py diff --git a/src/ai/backend/manager/api/gql/deployment/resolver/policy.py b/src/ai/backend/manager/api/gql/deployment/resolver/policy.py index f842e28085e..1cf58f46c32 100644 --- a/src/ai/backend/manager/api/gql/deployment/resolver/policy.py +++ b/src/ai/backend/manager/api/gql/deployment/resolver/policy.py @@ -2,8 +2,6 @@ from __future__ import annotations -from uuid import UUID - import strawberry from strawberry import Info @@ -26,8 +24,7 @@ async def admin_update_deployment_policy( ) -> UpdateDeploymentPolicyPayloadGQL: """Update (upsert) a deployment policy for a deployment.""" check_admin_only() - deployment_uuid = UUID(str(input.deployment_id)) - upserter = input.to_upserter(deployment_uuid) + upserter = input.to_upserter() processor = info.context.processors.deployment result = await processor.upsert_deployment_policy.wait_for_complete( diff --git a/src/ai/backend/manager/api/gql/deployment/types/policy.py b/src/ai/backend/manager/api/gql/deployment/types/policy.py index 3df58147c3c..9f92e699426 100644 --- a/src/ai/backend/manager/api/gql/deployment/types/policy.py +++ b/src/ai/backend/manager/api/gql/deployment/types/policy.py @@ -147,7 +147,7 @@ class UpdateDeploymentPolicyInputGQL: rolling_update: RollingUpdateConfigInputGQL | None = None blue_green: BlueGreenConfigInputGQL | None = None - def to_upserter(self, deployment_uuid: UUID) -> DeploymentPolicyUpserter: + def to_upserter(self) -> DeploymentPolicyUpserter: """Convert to DeploymentPolicyUpserter for the service layer.""" from ai.backend.manager.errors.api import InvalidAPIParameters @@ -156,19 +156,19 @@ def to_upserter(self, deployment_uuid: UUID) -> DeploymentPolicyUpserter: match strategy: case DeploymentStrategy.ROLLING: if self.rolling_update is None: - strategy_spec = RollingUpdateSpec(max_surge=1, max_unavailable=0) - else: - strategy_spec = self.rolling_update.to_spec() + raise InvalidAPIParameters( + "rolling_update config required for ROLLING strategy" + ) + strategy_spec = self.rolling_update.to_spec() case DeploymentStrategy.BLUE_GREEN: if self.blue_green is None: - strategy_spec = BlueGreenSpec(auto_promote=False, promote_delay_seconds=0) - else: - strategy_spec = self.blue_green.to_spec() + raise InvalidAPIParameters("blue_green config required for BLUE_GREEN strategy") + strategy_spec = self.blue_green.to_spec() case _: raise InvalidAPIParameters(f"Unsupported deployment strategy: {strategy}") return DeploymentPolicyUpserter( - deployment_id=deployment_uuid, + deployment_id=UUID(str(self.deployment_id)), strategy=strategy, strategy_spec=strategy_spec, rollback_on_failure=self.rollback_on_failure, diff --git a/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py b/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py new file mode 100644 index 00000000000..6c574e1e1f5 --- /dev/null +++ b/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py @@ -0,0 +1,74 @@ +"""Unit tests for UpdateDeploymentPolicyInputGQL.to_upserter().""" + +from __future__ import annotations + +import pytest +from strawberry import ID + +from ai.backend.common.data.model_deployment.types import DeploymentStrategy +from ai.backend.manager.api.gql.deployment.types.policy import ( + BlueGreenConfigInputGQL, + RollingUpdateConfigInputGQL, + UpdateDeploymentPolicyInputGQL, +) +from ai.backend.manager.errors.api import InvalidAPIParameters +from ai.backend.manager.models.deployment_policy import BlueGreenSpec, RollingUpdateSpec + + +class TestUpdateDeploymentPolicyInputGQL: + """Tests for UpdateDeploymentPolicyInputGQL.to_upserter().""" + + def test_rolling_strategy_with_config(self) -> None: + input_gql = UpdateDeploymentPolicyInputGQL( + deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + strategy=DeploymentStrategy.ROLLING, + rollback_on_failure=True, + rolling_update=RollingUpdateConfigInputGQL(max_surge=2, max_unavailable=1), + ) + upserter = input_gql.to_upserter() + + assert upserter.strategy == DeploymentStrategy.ROLLING + assert isinstance(upserter.strategy_spec, RollingUpdateSpec) + assert upserter.strategy_spec.max_surge == 2 + assert upserter.strategy_spec.max_unavailable == 1 + assert upserter.rollback_on_failure is True + + def test_blue_green_strategy_with_config(self) -> None: + input_gql = UpdateDeploymentPolicyInputGQL( + deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + strategy=DeploymentStrategy.BLUE_GREEN, + blue_green=BlueGreenConfigInputGQL(auto_promote=True, promote_delay_seconds=30), + ) + upserter = input_gql.to_upserter() + + assert upserter.strategy == DeploymentStrategy.BLUE_GREEN + assert isinstance(upserter.strategy_spec, BlueGreenSpec) + assert upserter.strategy_spec.auto_promote is True + assert upserter.strategy_spec.promote_delay_seconds == 30 + assert upserter.rollback_on_failure is False + + def test_rolling_strategy_missing_config_raises(self) -> None: + input_gql = UpdateDeploymentPolicyInputGQL( + deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + strategy=DeploymentStrategy.ROLLING, + ) + with pytest.raises(InvalidAPIParameters, match="rolling_update"): + input_gql.to_upserter() + + def test_blue_green_strategy_missing_config_raises(self) -> None: + input_gql = UpdateDeploymentPolicyInputGQL( + deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + strategy=DeploymentStrategy.BLUE_GREEN, + ) + with pytest.raises(InvalidAPIParameters, match="blue_green"): + input_gql.to_upserter() + + def test_deployment_id_is_converted_to_uuid(self) -> None: + input_gql = UpdateDeploymentPolicyInputGQL( + deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + strategy=DeploymentStrategy.ROLLING, + rolling_update=RollingUpdateConfigInputGQL(), + ) + upserter = input_gql.to_upserter() + + assert str(upserter.deployment_id) == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" From 3e3a65f135b665164ba970d94fc74a0e29aa590a Mon Sep 17 00:00:00 2001 From: jopemachine Date: Wed, 18 Mar 2026 17:21:17 +0900 Subject: [PATCH 05/10] fix: Update __all__ to use admin_update_deployment_policy Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai/backend/manager/api/gql/deployment/resolver/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py b/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py index 1eae1c9bb5a..97e2f46a88d 100644 --- a/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py +++ b/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py @@ -59,7 +59,7 @@ "sync_replicas", "deployment_status_changed", # Policy - "update_deployment_policy", + "admin_update_deployment_policy", # Replica "replicas", "replica", From b96bf52683e4f768c151b7cb4a5b23551e47b81f Mon Sep 17 00:00:00 2001 From: jopemachine Date: Wed, 18 Mar 2026 17:30:08 +0900 Subject: [PATCH 06/10] update: Add resolver-level tests for admin_update_deployment_policy Add mock processor/context tests following test_bulk_upsert_mutations pattern: processor call verification, created flag, and superadmin check. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test_update_deployment_policy.py | 189 +++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py b/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py index 6c574e1e1f5..7ad135ab529 100644 --- a/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py +++ b/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py @@ -1,24 +1,90 @@ -"""Unit tests for UpdateDeploymentPolicyInputGQL.to_upserter().""" +"""Tests for admin_update_deployment_policy GQL mutation.""" from __future__ import annotations +import uuid +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + import pytest +from aiohttp import web from strawberry import ID from ai.backend.common.data.model_deployment.types import DeploymentStrategy +from ai.backend.manager.api.gql import utils as gql_utils +from ai.backend.manager.api.gql.deployment.resolver import policy as policy_resolver from ai.backend.manager.api.gql.deployment.types.policy import ( BlueGreenConfigInputGQL, RollingUpdateConfigInputGQL, UpdateDeploymentPolicyInputGQL, + UpdateDeploymentPolicyPayloadGQL, ) +from ai.backend.manager.data.deployment.types import DeploymentPolicyData from ai.backend.manager.errors.api import InvalidAPIParameters from ai.backend.manager.models.deployment_policy import BlueGreenSpec, RollingUpdateSpec +from ai.backend.manager.services.deployment.actions.deployment_policy.upsert_deployment_policy import ( + UpsertDeploymentPolicyActionResult, +) + +# --- Fixtures --- + + +@pytest.fixture +def mock_superadmin_user() -> MagicMock: + """Create mock superadmin user.""" + user = MagicMock() + user.is_superadmin = True + return user + + +@pytest.fixture +def mock_regular_user() -> MagicMock: + """Create mock regular (non-superadmin) user.""" + user = MagicMock() + user.is_superadmin = False + return user + + +@pytest.fixture +def mock_upsert_processor() -> AsyncMock: + """Create mock upsert_deployment_policy processor.""" + return AsyncMock() + + +def _create_mock_info(processor: AsyncMock) -> MagicMock: + """Create mock strawberry.Info with deployment processors.""" + info = MagicMock() + info.context.processors.deployment.upsert_deployment_policy = processor + return info + + +def _make_policy_data( + *, + strategy: DeploymentStrategy = DeploymentStrategy.ROLLING, + strategy_spec: RollingUpdateSpec | BlueGreenSpec | None = None, +) -> DeploymentPolicyData: + """Create a DeploymentPolicyData for mock results.""" + if strategy_spec is None: + strategy_spec = RollingUpdateSpec(max_surge=1, max_unavailable=0) + return DeploymentPolicyData( + id=uuid.uuid4(), + endpoint=uuid.uuid4(), + strategy=strategy, + strategy_spec=strategy_spec, + rollback_on_failure=False, + created_at=datetime(2026, 1, 1, tzinfo=UTC), + updated_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + + +# --- Input type conversion tests --- class TestUpdateDeploymentPolicyInputGQL: """Tests for UpdateDeploymentPolicyInputGQL.to_upserter().""" def test_rolling_strategy_with_config(self) -> None: + """Test conversion with ROLLING strategy and config provided.""" input_gql = UpdateDeploymentPolicyInputGQL( deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), strategy=DeploymentStrategy.ROLLING, @@ -34,6 +100,7 @@ def test_rolling_strategy_with_config(self) -> None: assert upserter.rollback_on_failure is True def test_blue_green_strategy_with_config(self) -> None: + """Test conversion with BLUE_GREEN strategy and config provided.""" input_gql = UpdateDeploymentPolicyInputGQL( deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), strategy=DeploymentStrategy.BLUE_GREEN, @@ -48,6 +115,7 @@ def test_blue_green_strategy_with_config(self) -> None: assert upserter.rollback_on_failure is False def test_rolling_strategy_missing_config_raises(self) -> None: + """Test that ROLLING strategy without rolling_update config raises.""" input_gql = UpdateDeploymentPolicyInputGQL( deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), strategy=DeploymentStrategy.ROLLING, @@ -56,6 +124,7 @@ def test_rolling_strategy_missing_config_raises(self) -> None: input_gql.to_upserter() def test_blue_green_strategy_missing_config_raises(self) -> None: + """Test that BLUE_GREEN strategy without blue_green config raises.""" input_gql = UpdateDeploymentPolicyInputGQL( deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), strategy=DeploymentStrategy.BLUE_GREEN, @@ -64,6 +133,7 @@ def test_blue_green_strategy_missing_config_raises(self) -> None: input_gql.to_upserter() def test_deployment_id_is_converted_to_uuid(self) -> None: + """Test that deployment_id string is correctly converted to UUID.""" input_gql = UpdateDeploymentPolicyInputGQL( deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), strategy=DeploymentStrategy.ROLLING, @@ -72,3 +142,120 @@ def test_deployment_id_is_converted_to_uuid(self) -> None: upserter = input_gql.to_upserter() assert str(upserter.deployment_id) == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +# --- Resolver tests --- + + +class TestAdminUpdateDeploymentPolicyMutation: + """Tests for admin_update_deployment_policy resolver.""" + + async def test_mutation_calls_processor_with_correct_action( + self, + mock_superadmin_user: MagicMock, + mock_upsert_processor: AsyncMock, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test that mutation calls processor with correct action parameters.""" + # Given + policy_data = _make_policy_data( + strategy=DeploymentStrategy.ROLLING, + strategy_spec=RollingUpdateSpec(max_surge=2, max_unavailable=1), + ) + mock_upsert_processor.wait_for_complete.return_value = UpsertDeploymentPolicyActionResult( + data=policy_data, + created=True, + ) + mock_info = _create_mock_info(mock_upsert_processor) + + monkeypatch.setattr( + gql_utils, + "current_user", + lambda: mock_superadmin_user, + ) + + input_data = UpdateDeploymentPolicyInputGQL( + deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + strategy=DeploymentStrategy.ROLLING, + rollback_on_failure=True, + rolling_update=RollingUpdateConfigInputGQL(max_surge=2, max_unavailable=1), + ) + + # When + resolver_fn = policy_resolver.admin_update_deployment_policy.base_resolver + result = await resolver_fn(input_data, mock_info) + + # Then + mock_upsert_processor.wait_for_complete.assert_called_once() + call_args = mock_upsert_processor.wait_for_complete.call_args + action = call_args[0][0] + + assert str(action.upserter.deployment_id) == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assert action.upserter.strategy == DeploymentStrategy.ROLLING + assert action.upserter.rollback_on_failure is True + + assert isinstance(result, UpdateDeploymentPolicyPayloadGQL) + assert result.created is True + + async def test_mutation_returns_created_false_on_update( + self, + mock_superadmin_user: MagicMock, + mock_upsert_processor: AsyncMock, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test that mutation returns created=False when policy already exists.""" + # Given + policy_data = _make_policy_data() + mock_upsert_processor.wait_for_complete.return_value = UpsertDeploymentPolicyActionResult( + data=policy_data, + created=False, + ) + mock_info = _create_mock_info(mock_upsert_processor) + + monkeypatch.setattr( + gql_utils, + "current_user", + lambda: mock_superadmin_user, + ) + + input_data = UpdateDeploymentPolicyInputGQL( + deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + strategy=DeploymentStrategy.ROLLING, + rolling_update=RollingUpdateConfigInputGQL(), + ) + + # When + resolver_fn = policy_resolver.admin_update_deployment_policy.base_resolver + result = await resolver_fn(input_data, mock_info) + + # Then + assert result.created is False + + async def test_mutation_requires_superadmin( + self, + mock_regular_user: MagicMock, + mock_upsert_processor: AsyncMock, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test that mutation requires superadmin privilege.""" + # Given + mock_info = _create_mock_info(mock_upsert_processor) + + monkeypatch.setattr( + gql_utils, + "current_user", + lambda: mock_regular_user, + ) + + input_data = UpdateDeploymentPolicyInputGQL( + deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + strategy=DeploymentStrategy.ROLLING, + rolling_update=RollingUpdateConfigInputGQL(), + ) + + # When / Then + resolver_fn = policy_resolver.admin_update_deployment_policy.base_resolver + with pytest.raises(web.HTTPForbidden): + await resolver_fn(input_data, mock_info) + + mock_upsert_processor.wait_for_complete.assert_not_called() From c8c7c816197cd8ef10cd6f5311831aa537e6b655 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Thu, 19 Mar 2026 10:46:16 +0900 Subject: [PATCH 07/10] docs: Added description --- .../graphql-reference/supergraph.graphql | 57 +++++++++++--- .../graphql-reference/v2-schema.graphql | 57 +++++++++++--- .../api/gql/deployment/resolver/policy.py | 12 ++- .../api/gql/deployment/types/policy.py | 75 ++++++++++++++++--- 4 files changed, 169 insertions(+), 32 deletions(-) diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 20a9f14697a..ea9f50d726c 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -1626,7 +1626,13 @@ to support integers outside the range of a signed 32-bit integer. scalar BigInt @join__type(graph: GRAPHENE) -"""Added in 25.19.0. Configuration for blue-green deployment strategy.""" +""" +Added in 25.19.0. +Input parameters for configuring a blue-green deployment strategy. +When auto_promote is true, traffic is automatically switched from the blue (old) +replica set to the green (new) set after promote_delay_seconds elapse. +When auto_promote is false (default), an explicit promotion action is required. +""" input BlueGreenConfigInput @join__type(graph: STRAWBERRY) { @@ -3646,7 +3652,14 @@ enum DeploymentOrderField NAME @join__enumValue(graph: STRAWBERRY) } -"""Added in 25.19.0. Deployment policy configuration.""" +""" +Added in 25.19.0. +Defines the deployment policy attached to a model deployment, +including the rollout strategy (rolling update or blue-green), +strategy-specific parameters, and whether to automatically roll back on failure. +Each deployment has at most one policy; creating a new policy for the same deployment +replaces the existing one (upsert semantics). +""" type DeploymentPolicy implements Node @join__implements(graph: STRAWBERRY, interface: "Node") @join__type(graph: STRAWBERRY) @@ -3717,7 +3730,10 @@ input DeploymentStrategyInput } """ -Added in 25.19.0. Base interface for deployment strategy specifications. +Added in 25.19.0. +Base interface for all deployment strategy specifications. +Each concrete implementation (RollingUpdateStrategySpec, BlueGreenStrategySpec) +carries strategy-specific parameters that control how replica transitions are performed. """ interface DeploymentStrategySpec @join__type(graph: STRAWBERRY) @@ -3726,7 +3742,10 @@ interface DeploymentStrategySpec } """ -Added in 25.19.0. This enum represents the deployment strategy type of a model deployment, indicating the strategy used for deployment. +Added in 25.19.0. +Determines how new revisions of a model deployment are rolled out to replace old ones. +ROLLING performs incremental replica replacement controlled by max_surge and max_unavailable, +while BLUE_GREEN provisions a complete new replica set before switching traffic. """ enum DeploymentStrategyType @join__type(graph: STRAWBERRY) @@ -7718,7 +7737,12 @@ type Mutation """Added in 25.16.0""" addModelRevision(input: AddRevisionInput!): AddRevisionPayload! @join__field(graph: STRAWBERRY) - """Added in 26.4.0""" + """ + Added in 26.4.0. + Create or update the deployment policy for a given deployment (upsert semantics). + Requires superadmin privileges. + If the deployment already has a policy, it is replaced entirely with the new configuration. + """ adminUpdateDeploymentPolicy(input: UpdateDeploymentPolicyInput!): UpdateDeploymentPolicyPayload! @join__field(graph: STRAWBERRY) """Create a new notification channel (admin only)""" @@ -11511,7 +11535,14 @@ enum RoleStatus DELETED @join__enumValue(graph: STRAWBERRY) } -"""Added in 25.19.0. Configuration for rolling update strategy.""" +""" +Added in 25.19.0. +Input parameters for configuring a rolling update strategy. +max_surge sets the maximum number of additional replicas that can be created +beyond the desired count during an update (default: 1). +max_unavailable sets the maximum number of replicas that can be unavailable +during the update (default: 0). At least one of these must be positive. +""" input RollingUpdateConfigInput @join__type(graph: STRAWBERRY) { @@ -12952,7 +12983,12 @@ type UpdateDeploymentPayload } """ -Added in 26.4.0. Input for updating a deployment policy. Internally upserts the policy. +Added in 26.4.0. +Input for creating or updating a deployment policy (upsert semantics). +Specify the target deployment_id and the desired strategy type. +Exactly one of rolling_update or blue_green must be provided, +matching the chosen strategy type. +If a policy already exists for the deployment, it is replaced entirely. """ input UpdateDeploymentPolicyInput @join__type(graph: STRAWBERRY) @@ -12964,12 +13000,15 @@ input UpdateDeploymentPolicyInput blueGreen: BlueGreenConfigInput = null } -"""Added in 26.4.0. Result of updating a deployment policy.""" +""" +Added in 26.4.0. +Result payload returned after creating or updating a deployment policy. +Contains the full deployment_policy object reflecting the applied configuration. +""" type UpdateDeploymentPolicyPayload @join__type(graph: STRAWBERRY) { deploymentPolicy: DeploymentPolicy! - created: Boolean! } """Added in 25.14.0""" diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 03ff449a2e5..04d59a39cb3 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -1083,7 +1083,13 @@ enum BgtaskEventType { FAILED } -"""Added in 25.19.0. Configuration for blue-green deployment strategy.""" +""" +Added in 25.19.0. +Input parameters for configuring a blue-green deployment strategy. +When auto_promote is true, traffic is automatically switched from the blue (old) +replica set to the green (new) set after promote_delay_seconds elapse. +When auto_promote is false (default), an explicit promotion action is required. +""" input BlueGreenConfigInput { autoPromote: Boolean! = false promoteDelaySeconds: Int! = 0 @@ -2074,7 +2080,14 @@ enum DeploymentOrderField { NAME } -"""Added in 25.19.0. Deployment policy configuration.""" +""" +Added in 25.19.0. +Defines the deployment policy attached to a model deployment, +including the rollout strategy (rolling update or blue-green), +strategy-specific parameters, and whether to automatically roll back on failure. +Each deployment has at most one policy; creating a new policy for the same deployment +replaces the existing one (upsert semantics). +""" type DeploymentPolicy implements Node { """The Globally Unique ID of this object""" id: ID! @@ -2130,14 +2143,20 @@ input DeploymentStrategyInput { } """ -Added in 25.19.0. Base interface for deployment strategy specifications. +Added in 25.19.0. +Base interface for all deployment strategy specifications. +Each concrete implementation (RollingUpdateStrategySpec, BlueGreenStrategySpec) +carries strategy-specific parameters that control how replica transitions are performed. """ interface DeploymentStrategySpec { strategy: DeploymentStrategyType! } """ -Added in 25.19.0. This enum represents the deployment strategy type of a model deployment, indicating the strategy used for deployment. +Added in 25.19.0. +Determines how new revisions of a model deployment are rolled out to replace old ones. +ROLLING performs incremental replica replacement controlled by max_surge and max_unavailable, +while BLUE_GREEN provisions a complete new replica set before switching traffic. """ enum DeploymentStrategyType { ROLLING @@ -4074,7 +4093,12 @@ type Mutation { """Added in 25.16.0""" addModelRevision(input: AddRevisionInput!): AddRevisionPayload! - """Added in 26.4.0""" + """ + Added in 26.4.0. + Create or update the deployment policy for a given deployment (upsert semantics). + Requires superadmin privileges. + If the deployment already has a policy, it is replaced entirely with the new configuration. + """ adminUpdateDeploymentPolicy(input: UpdateDeploymentPolicyInput!): UpdateDeploymentPolicyPayload! """Create a new notification channel (admin only)""" @@ -6901,7 +6925,14 @@ enum RoleStatus { DELETED } -"""Added in 25.19.0. Configuration for rolling update strategy.""" +""" +Added in 25.19.0. +Input parameters for configuring a rolling update strategy. +max_surge sets the maximum number of additional replicas that can be created +beyond the desired count during an update (default: 1). +max_unavailable sets the maximum number of replicas that can be unavailable +during the update (default: 0). At least one of these must be positive. +""" input RollingUpdateConfigInput { maxSurge: Int! = 1 maxUnavailable: Int! = 0 @@ -7895,7 +7926,12 @@ type UpdateDeploymentPayload { } """ -Added in 26.4.0. Input for updating a deployment policy. Internally upserts the policy. +Added in 26.4.0. +Input for creating or updating a deployment policy (upsert semantics). +Specify the target deployment_id and the desired strategy type. +Exactly one of rolling_update or blue_green must be provided, +matching the chosen strategy type. +If a policy already exists for the deployment, it is replaced entirely. """ input UpdateDeploymentPolicyInput { deploymentId: ID! @@ -7905,10 +7941,13 @@ input UpdateDeploymentPolicyInput { blueGreen: BlueGreenConfigInput = null } -"""Added in 26.4.0. Result of updating a deployment policy.""" +""" +Added in 26.4.0. +Result payload returned after creating or updating a deployment policy. +Contains the full deployment_policy object reflecting the applied configuration. +""" type UpdateDeploymentPolicyPayload { deploymentPolicy: DeploymentPolicy! - created: Boolean! } """Added in 25.14.0""" diff --git a/src/ai/backend/manager/api/gql/deployment/resolver/policy.py b/src/ai/backend/manager/api/gql/deployment/resolver/policy.py index 1cf58f46c32..83f03850f2f 100644 --- a/src/ai/backend/manager/api/gql/deployment/resolver/policy.py +++ b/src/ai/backend/manager/api/gql/deployment/resolver/policy.py @@ -11,13 +11,20 @@ UpdateDeploymentPolicyPayloadGQL, ) from ai.backend.manager.api.gql.types import StrawberryGQLContext -from ai.backend.manager.api.gql.utils import check_admin_only +from ai.backend.manager.api.gql.utils import check_admin_only, dedent_strip from ai.backend.manager.services.deployment.actions.deployment_policy.upsert_deployment_policy import ( UpsertDeploymentPolicyAction, ) -@strawberry.mutation(description="Added in 26.4.0") # type: ignore[misc] +@strawberry.mutation( # type: ignore[misc] + description=dedent_strip(""" + Added in 26.4.0. + Create or update the deployment policy for a given deployment (upsert semantics). + Requires superadmin privileges. + If the deployment already has a policy, it is replaced entirely with the new configuration. + """), +) async def admin_update_deployment_policy( input: UpdateDeploymentPolicyInputGQL, info: Info[StrawberryGQLContext], @@ -33,5 +40,4 @@ async def admin_update_deployment_policy( return UpdateDeploymentPolicyPayloadGQL( deployment_policy=DeploymentPolicyGQL.from_data(result.data), - created=result.created, ) diff --git a/src/ai/backend/manager/api/gql/deployment/types/policy.py b/src/ai/backend/manager/api/gql/deployment/types/policy.py index 9f92e699426..9cd633c8de4 100644 --- a/src/ai/backend/manager/api/gql/deployment/types/policy.py +++ b/src/ai/backend/manager/api/gql/deployment/types/policy.py @@ -11,8 +11,10 @@ from strawberry.relay import Node, NodeID from ai.backend.common.data.model_deployment.types import DeploymentStrategy +from ai.backend.manager.api.gql.utils import dedent_strip from ai.backend.manager.data.deployment.types import DeploymentPolicyData from ai.backend.manager.data.deployment.upserter import DeploymentPolicyUpserter +from ai.backend.manager.errors.api import InvalidAPIParameters from ai.backend.manager.errors.deployment import InvalidDeploymentStrategySpec from ai.backend.manager.models.deployment_policy import BlueGreenSpec, RollingUpdateSpec @@ -20,7 +22,12 @@ DeploymentStrategyTypeGQL: type[DeploymentStrategy] = strawberry.enum( DeploymentStrategy, name="DeploymentStrategyType", - description="Added in 25.19.0. This enum represents the deployment strategy type of a model deployment, indicating the strategy used for deployment.", + description=dedent_strip(""" + Added in 25.19.0. + Determines how new revisions of a model deployment are rolled out to replace old ones. + ROLLING performs incremental replica replacement controlled by max_surge and max_unavailable, + while BLUE_GREEN provisions a complete new replica set before switching traffic. + """), ) # ========== Output Types (Response) ========== @@ -28,7 +35,12 @@ @strawberry.interface( name="DeploymentStrategySpec", - description="Added in 25.19.0. Base interface for deployment strategy specifications.", + description=dedent_strip(""" + Added in 25.19.0. + Base interface for all deployment strategy specifications. + Each concrete implementation (RollingUpdateStrategySpec, BlueGreenStrategySpec) + carries strategy-specific parameters that control how replica transitions are performed. + """), ) class DeploymentStrategySpecGQL: strategy: DeploymentStrategyTypeGQL @@ -36,7 +48,13 @@ class DeploymentStrategySpecGQL: @strawberry.type( name="RollingUpdateStrategySpec", - description="Added in 25.19.0. Rolling update strategy specification.", + description=dedent_strip(""" + Added in 25.19.0. + Strategy specification for rolling updates. + Replicas are replaced incrementally: max_surge controls how many extra replicas + can be created above the desired count, and max_unavailable controls how many + existing replicas can be taken down simultaneously during the transition. + """), ) class RollingUpdateStrategySpecGQL(DeploymentStrategySpecGQL): max_surge: int @@ -45,7 +63,13 @@ class RollingUpdateStrategySpecGQL(DeploymentStrategySpecGQL): @strawberry.type( name="BlueGreenStrategySpec", - description="Added in 25.19.0. Blue-green deployment strategy specification.", + description=dedent_strip(""" + Added in 25.19.0. + Strategy specification for blue-green deployments. + A complete new replica set (green) is provisioned alongside the existing one (blue). + When auto_promote is true, traffic is automatically switched to the green set + after promote_delay_seconds; otherwise, manual promotion is required. + """), ) class BlueGreenStrategySpecGQL(DeploymentStrategySpecGQL): auto_promote: bool @@ -54,7 +78,14 @@ class BlueGreenStrategySpecGQL(DeploymentStrategySpecGQL): @strawberry.type( name="DeploymentPolicy", - description="Added in 25.19.0. Deployment policy configuration.", + description=dedent_strip(""" + Added in 25.19.0. + Defines the deployment policy attached to a model deployment, + including the rollout strategy (rolling update or blue-green), + strategy-specific parameters, and whether to automatically roll back on failure. + Each deployment has at most one policy; creating a new policy for the same deployment + replaces the existing one (upsert semantics). + """), ) class DeploymentPolicyGQL(Node): id: NodeID[str] @@ -105,7 +136,14 @@ def from_data(cls, data: DeploymentPolicyData) -> Self: @strawberry.input( name="RollingUpdateConfigInput", - description="Added in 25.19.0. Configuration for rolling update strategy.", + description=dedent_strip(""" + Added in 25.19.0. + Input parameters for configuring a rolling update strategy. + max_surge sets the maximum number of additional replicas that can be created + beyond the desired count during an update (default: 1). + max_unavailable sets the maximum number of replicas that can be unavailable + during the update (default: 0). At least one of these must be positive. + """), ) class RollingUpdateConfigInputGQL: max_surge: int = 1 @@ -120,7 +158,13 @@ def to_spec(self) -> RollingUpdateSpec: @strawberry.input( name="BlueGreenConfigInput", - description="Added in 25.19.0. Configuration for blue-green deployment strategy.", + description=dedent_strip(""" + Added in 25.19.0. + Input parameters for configuring a blue-green deployment strategy. + When auto_promote is true, traffic is automatically switched from the blue (old) + replica set to the green (new) set after promote_delay_seconds elapse. + When auto_promote is false (default), an explicit promotion action is required. + """), ) class BlueGreenConfigInputGQL: auto_promote: bool = False @@ -138,7 +182,14 @@ def to_spec(self) -> BlueGreenSpec: @strawberry.input( name="UpdateDeploymentPolicyInput", - description="Added in 26.4.0. Input for updating a deployment policy. Internally upserts the policy.", + description=dedent_strip(""" + Added in 26.4.0. + Input for creating or updating a deployment policy (upsert semantics). + Specify the target deployment_id and the desired strategy type. + Exactly one of rolling_update or blue_green must be provided, + matching the chosen strategy type. + If a policy already exists for the deployment, it is replaced entirely. + """), ) class UpdateDeploymentPolicyInputGQL: deployment_id: ID @@ -149,7 +200,6 @@ class UpdateDeploymentPolicyInputGQL: def to_upserter(self) -> DeploymentPolicyUpserter: """Convert to DeploymentPolicyUpserter for the service layer.""" - from ai.backend.manager.errors.api import InvalidAPIParameters strategy = DeploymentStrategy(self.strategy.value) strategy_spec: RollingUpdateSpec | BlueGreenSpec @@ -177,8 +227,11 @@ def to_upserter(self) -> DeploymentPolicyUpserter: @strawberry.type( name="UpdateDeploymentPolicyPayload", - description="Added in 26.4.0. Result of updating a deployment policy.", + description=dedent_strip(""" + Added in 26.4.0. + Result payload returned after creating or updating a deployment policy. + Contains the full deployment_policy object reflecting the applied configuration. + """), ) class UpdateDeploymentPolicyPayloadGQL: deployment_policy: DeploymentPolicyGQL - created: bool From c2bf0352f6b84575d35b6c42978e63f893556695 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Thu, 19 Mar 2026 10:52:36 +0900 Subject: [PATCH 08/10] test: Update --- .../test_update_deployment_policy.py | 231 ++++++++++-------- 1 file changed, 123 insertions(+), 108 deletions(-) diff --git a/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py b/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py index 7ad135ab529..f605ad9f157 100644 --- a/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py +++ b/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py @@ -3,6 +3,7 @@ from __future__ import annotations import uuid +from dataclasses import dataclass from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock @@ -26,6 +27,29 @@ UpsertDeploymentPolicyActionResult, ) +SAMPLE_DEPLOYMENT_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +# --- Test scenarios --- + + +@dataclass(frozen=True) +class StrategyConversionScenario: + """Input → expected upserter output for a valid strategy conversion.""" + + input: UpdateDeploymentPolicyInputGQL + expected_spec: RollingUpdateSpec | BlueGreenSpec + expected_rollback_on_failure: bool + + +@dataclass(frozen=True) +class MissingConfigScenario: + """Input that should raise due to missing strategy config.""" + + input: UpdateDeploymentPolicyInputGQL + expected_error_match: str + + # --- Fixtures --- @@ -51,13 +75,25 @@ def mock_upsert_processor() -> AsyncMock: return AsyncMock() -def _create_mock_info(processor: AsyncMock) -> MagicMock: +@pytest.fixture +def mock_info(mock_upsert_processor: AsyncMock) -> MagicMock: """Create mock strawberry.Info with deployment processors.""" info = MagicMock() - info.context.processors.deployment.upsert_deployment_policy = processor + info.context.processors.deployment.upsert_deployment_policy = mock_upsert_processor return info +@pytest.fixture +def rolling_update_input() -> UpdateDeploymentPolicyInputGQL: + """Input for ROLLING strategy with custom surge/unavailable.""" + return UpdateDeploymentPolicyInputGQL( + deployment_id=ID(SAMPLE_DEPLOYMENT_ID), + strategy=DeploymentStrategy.ROLLING, + rollback_on_failure=True, + rolling_update=RollingUpdateConfigInputGQL(max_surge=2, max_unavailable=1), + ) + + def _make_policy_data( *, strategy: DeploymentStrategy = DeploymentStrategy.ROLLING, @@ -80,83 +116,106 @@ def _make_policy_data( # --- Input type conversion tests --- -class TestUpdateDeploymentPolicyInputGQL: +class TestToUpserterConversion: """Tests for UpdateDeploymentPolicyInputGQL.to_upserter().""" - def test_rolling_strategy_with_config(self) -> None: - """Test conversion with ROLLING strategy and config provided.""" - input_gql = UpdateDeploymentPolicyInputGQL( - deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), - strategy=DeploymentStrategy.ROLLING, - rollback_on_failure=True, - rolling_update=RollingUpdateConfigInputGQL(max_surge=2, max_unavailable=1), - ) - upserter = input_gql.to_upserter() - - assert upserter.strategy == DeploymentStrategy.ROLLING - assert isinstance(upserter.strategy_spec, RollingUpdateSpec) - assert upserter.strategy_spec.max_surge == 2 - assert upserter.strategy_spec.max_unavailable == 1 - assert upserter.rollback_on_failure is True - - def test_blue_green_strategy_with_config(self) -> None: - """Test conversion with BLUE_GREEN strategy and config provided.""" - input_gql = UpdateDeploymentPolicyInputGQL( - deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), - strategy=DeploymentStrategy.BLUE_GREEN, - blue_green=BlueGreenConfigInputGQL(auto_promote=True, promote_delay_seconds=30), - ) - upserter = input_gql.to_upserter() - - assert upserter.strategy == DeploymentStrategy.BLUE_GREEN - assert isinstance(upserter.strategy_spec, BlueGreenSpec) - assert upserter.strategy_spec.auto_promote is True - assert upserter.strategy_spec.promote_delay_seconds == 30 - assert upserter.rollback_on_failure is False - - def test_rolling_strategy_missing_config_raises(self) -> None: - """Test that ROLLING strategy without rolling_update config raises.""" - input_gql = UpdateDeploymentPolicyInputGQL( - deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), - strategy=DeploymentStrategy.ROLLING, - ) - with pytest.raises(InvalidAPIParameters, match="rolling_update"): - input_gql.to_upserter() - - def test_blue_green_strategy_missing_config_raises(self) -> None: - """Test that BLUE_GREEN strategy without blue_green config raises.""" - input_gql = UpdateDeploymentPolicyInputGQL( - deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), - strategy=DeploymentStrategy.BLUE_GREEN, - ) - with pytest.raises(InvalidAPIParameters, match="blue_green"): - input_gql.to_upserter() + @pytest.mark.parametrize( + "scenario", + [ + pytest.param( + StrategyConversionScenario( + input=UpdateDeploymentPolicyInputGQL( + deployment_id=ID(SAMPLE_DEPLOYMENT_ID), + strategy=DeploymentStrategy.ROLLING, + rollback_on_failure=True, + rolling_update=RollingUpdateConfigInputGQL(max_surge=2, max_unavailable=1), + ), + expected_spec=RollingUpdateSpec(max_surge=2, max_unavailable=1), + expected_rollback_on_failure=True, + ), + id="rolling", + ), + pytest.param( + StrategyConversionScenario( + input=UpdateDeploymentPolicyInputGQL( + deployment_id=ID(SAMPLE_DEPLOYMENT_ID), + strategy=DeploymentStrategy.BLUE_GREEN, + blue_green=BlueGreenConfigInputGQL( + auto_promote=True, promote_delay_seconds=30 + ), + ), + expected_spec=BlueGreenSpec(auto_promote=True, promote_delay_seconds=30), + expected_rollback_on_failure=False, + ), + id="blue_green", + ), + ], + ) + def test_converts_gql_input_to_upserter(self, scenario: StrategyConversionScenario) -> None: + """Test that GQL input is correctly converted to DeploymentPolicyUpserter.""" + upserter = scenario.input.to_upserter() + + assert upserter.strategy == scenario.input.strategy + assert upserter.strategy_spec == scenario.expected_spec + assert upserter.rollback_on_failure is scenario.expected_rollback_on_failure + + @pytest.mark.parametrize( + "scenario", + [ + pytest.param( + MissingConfigScenario( + input=UpdateDeploymentPolicyInputGQL( + deployment_id=ID(SAMPLE_DEPLOYMENT_ID), + strategy=DeploymentStrategy.ROLLING, + ), + expected_error_match="rolling_update", + ), + id="rolling", + ), + pytest.param( + MissingConfigScenario( + input=UpdateDeploymentPolicyInputGQL( + deployment_id=ID(SAMPLE_DEPLOYMENT_ID), + strategy=DeploymentStrategy.BLUE_GREEN, + ), + expected_error_match="blue_green", + ), + id="blue_green", + ), + ], + ) + def test_raises_when_strategy_config_is_missing(self, scenario: MissingConfigScenario) -> None: + """Test that to_upserter() raises when matching strategy config is not provided.""" + with pytest.raises(InvalidAPIParameters, match=scenario.expected_error_match): + scenario.input.to_upserter() - def test_deployment_id_is_converted_to_uuid(self) -> None: - """Test that deployment_id string is correctly converted to UUID.""" + def test_converts_deployment_id_to_uuid(self) -> None: + """Test that string deployment_id is correctly parsed into UUID.""" input_gql = UpdateDeploymentPolicyInputGQL( - deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + deployment_id=ID(SAMPLE_DEPLOYMENT_ID), strategy=DeploymentStrategy.ROLLING, rolling_update=RollingUpdateConfigInputGQL(), ) upserter = input_gql.to_upserter() - assert str(upserter.deployment_id) == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assert str(upserter.deployment_id) == SAMPLE_DEPLOYMENT_ID # --- Resolver tests --- -class TestAdminUpdateDeploymentPolicyMutation: +class TestAdminUpdateDeploymentPolicyResolver: """Tests for admin_update_deployment_policy resolver.""" - async def test_mutation_calls_processor_with_correct_action( + async def test_delegates_upsert_action_to_processor( self, mock_superadmin_user: MagicMock, mock_upsert_processor: AsyncMock, + mock_info: MagicMock, + rolling_update_input: UpdateDeploymentPolicyInputGQL, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test that mutation calls processor with correct action parameters.""" + """Test that resolver delegates to processor and returns payload.""" # Given policy_data = _make_policy_data( strategy=DeploymentStrategy.ROLLING, @@ -166,7 +225,6 @@ async def test_mutation_calls_processor_with_correct_action( data=policy_data, created=True, ) - mock_info = _create_mock_info(mock_upsert_processor) monkeypatch.setattr( gql_utils, @@ -174,73 +232,30 @@ async def test_mutation_calls_processor_with_correct_action( lambda: mock_superadmin_user, ) - input_data = UpdateDeploymentPolicyInputGQL( - deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), - strategy=DeploymentStrategy.ROLLING, - rollback_on_failure=True, - rolling_update=RollingUpdateConfigInputGQL(max_surge=2, max_unavailable=1), - ) - # When resolver_fn = policy_resolver.admin_update_deployment_policy.base_resolver - result = await resolver_fn(input_data, mock_info) + result = await resolver_fn(rolling_update_input, mock_info) # Then mock_upsert_processor.wait_for_complete.assert_called_once() call_args = mock_upsert_processor.wait_for_complete.call_args action = call_args[0][0] - assert str(action.upserter.deployment_id) == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assert str(action.upserter.deployment_id) == SAMPLE_DEPLOYMENT_ID assert action.upserter.strategy == DeploymentStrategy.ROLLING assert action.upserter.rollback_on_failure is True assert isinstance(result, UpdateDeploymentPolicyPayloadGQL) - assert result.created is True - - async def test_mutation_returns_created_false_on_update( - self, - mock_superadmin_user: MagicMock, - mock_upsert_processor: AsyncMock, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Test that mutation returns created=False when policy already exists.""" - # Given - policy_data = _make_policy_data() - mock_upsert_processor.wait_for_complete.return_value = UpsertDeploymentPolicyActionResult( - data=policy_data, - created=False, - ) - mock_info = _create_mock_info(mock_upsert_processor) - - monkeypatch.setattr( - gql_utils, - "current_user", - lambda: mock_superadmin_user, - ) - - input_data = UpdateDeploymentPolicyInputGQL( - deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), - strategy=DeploymentStrategy.ROLLING, - rolling_update=RollingUpdateConfigInputGQL(), - ) - - # When - resolver_fn = policy_resolver.admin_update_deployment_policy.base_resolver - result = await resolver_fn(input_data, mock_info) - - # Then - assert result.created is False - async def test_mutation_requires_superadmin( + async def test_rejects_non_superadmin( self, mock_regular_user: MagicMock, mock_upsert_processor: AsyncMock, + mock_info: MagicMock, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test that mutation requires superadmin privilege.""" + """Test that non-superadmin user is rejected with HTTPForbidden.""" # Given - mock_info = _create_mock_info(mock_upsert_processor) - monkeypatch.setattr( gql_utils, "current_user", @@ -248,7 +263,7 @@ async def test_mutation_requires_superadmin( ) input_data = UpdateDeploymentPolicyInputGQL( - deployment_id=ID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + deployment_id=ID(SAMPLE_DEPLOYMENT_ID), strategy=DeploymentStrategy.ROLLING, rolling_update=RollingUpdateConfigInputGQL(), ) From 5146fa75f53a0bb64b0853f772b5d9bf5b40f63a Mon Sep 17 00:00:00 2001 From: jopemachine Date: Thu, 19 Mar 2026 11:08:47 +0900 Subject: [PATCH 09/10] `admin_update_deployment_policy` -> `update_deployment_policy` --- changes/10300.feature.md | 2 +- docs/manager/graphql-reference/supergraph.graphql | 3 +-- docs/manager/graphql-reference/v2-schema.graphql | 3 +-- src/ai/backend/manager/api/gql/deployment/__init__.py | 6 +++--- .../manager/api/gql/deployment/resolver/__init__.py | 4 ++-- .../backend/manager/api/gql/deployment/resolver/policy.py | 3 +-- src/ai/backend/manager/api/gql/schema.py | 4 ++-- .../api/gql/deployment/test_update_deployment_policy.py | 8 ++++---- 8 files changed, 15 insertions(+), 18 deletions(-) diff --git a/changes/10300.feature.md b/changes/10300.feature.md index df8dc67edd8..4efc92a5657 100644 --- a/changes/10300.feature.md +++ b/changes/10300.feature.md @@ -1 +1 @@ -Add `admin_update_deployment_policy` GQL mutation +Add `update_deployment_policy` GQL mutation diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index ea9f50d726c..01f47fa8058 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -7740,10 +7740,9 @@ type Mutation """ Added in 26.4.0. Create or update the deployment policy for a given deployment (upsert semantics). - Requires superadmin privileges. If the deployment already has a policy, it is replaced entirely with the new configuration. """ - adminUpdateDeploymentPolicy(input: UpdateDeploymentPolicyInput!): UpdateDeploymentPolicyPayload! @join__field(graph: STRAWBERRY) + updateDeploymentPolicy(input: UpdateDeploymentPolicyInput!): UpdateDeploymentPolicyPayload! @join__field(graph: STRAWBERRY) """Create a new notification channel (admin only)""" adminCreateNotificationChannel(input: CreateNotificationChannelInput!): CreateNotificationChannelPayload! @join__field(graph: STRAWBERRY) diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 04d59a39cb3..133d76f4431 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -4096,10 +4096,9 @@ type Mutation { """ Added in 26.4.0. Create or update the deployment policy for a given deployment (upsert semantics). - Requires superadmin privileges. If the deployment already has a policy, it is replaced entirely with the new configuration. """ - adminUpdateDeploymentPolicy(input: UpdateDeploymentPolicyInput!): UpdateDeploymentPolicyPayload! + updateDeploymentPolicy(input: UpdateDeploymentPolicyInput!): UpdateDeploymentPolicyPayload! """Create a new notification channel (admin only)""" adminCreateNotificationChannel(input: CreateNotificationChannelInput!): CreateNotificationChannelPayload! diff --git a/src/ai/backend/manager/api/gql/deployment/__init__.py b/src/ai/backend/manager/api/gql/deployment/__init__.py index 83a9053a544..31e5fe6c464 100644 --- a/src/ai/backend/manager/api/gql/deployment/__init__.py +++ b/src/ai/backend/manager/api/gql/deployment/__init__.py @@ -16,8 +16,6 @@ # Revision activate_deployment_revision, add_model_revision, - # Policy - admin_update_deployment_policy, # Access Token create_access_token, # Auto Scaling @@ -42,6 +40,8 @@ routes, sync_replicas, update_auto_scaling_rule, + # Policy + update_deployment_policy, update_model_deployment, update_route_traffic_status, ) @@ -275,7 +275,7 @@ "sync_replicas", "update_model_deployment", # Resolvers - Policy - "admin_update_deployment_policy", + "update_deployment_policy", # Resolvers - Replica "replica", "replica_status_changed", diff --git a/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py b/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py index 97e2f46a88d..56201b26565 100644 --- a/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py +++ b/src/ai/backend/manager/api/gql/deployment/resolver/__init__.py @@ -22,7 +22,7 @@ update_model_deployment, ) from .policy import ( - admin_update_deployment_policy, + update_deployment_policy, ) from .replica import ( replica, @@ -59,7 +59,7 @@ "sync_replicas", "deployment_status_changed", # Policy - "admin_update_deployment_policy", + "update_deployment_policy", # Replica "replicas", "replica", diff --git a/src/ai/backend/manager/api/gql/deployment/resolver/policy.py b/src/ai/backend/manager/api/gql/deployment/resolver/policy.py index 83f03850f2f..1eb9e9b8b99 100644 --- a/src/ai/backend/manager/api/gql/deployment/resolver/policy.py +++ b/src/ai/backend/manager/api/gql/deployment/resolver/policy.py @@ -21,11 +21,10 @@ description=dedent_strip(""" Added in 26.4.0. Create or update the deployment policy for a given deployment (upsert semantics). - Requires superadmin privileges. If the deployment already has a policy, it is replaced entirely with the new configuration. """), ) -async def admin_update_deployment_policy( +async def update_deployment_policy( input: UpdateDeploymentPolicyInputGQL, info: Info[StrawberryGQLContext], ) -> UpdateDeploymentPolicyPayloadGQL: diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 5ec93306b5c..559c0bbb705 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -52,7 +52,6 @@ # Revision activate_deployment_revision, add_model_revision, - admin_update_deployment_policy, # Access Token create_access_token, # Auto Scaling @@ -77,6 +76,7 @@ routes, sync_replicas, update_auto_scaling_rule, + update_deployment_policy, update_model_deployment, update_route_traffic_status, ) @@ -431,7 +431,7 @@ class Mutation: delete_model_deployment = delete_model_deployment sync_replicas = sync_replicas add_model_revision = add_model_revision - admin_update_deployment_policy = admin_update_deployment_policy + update_deployment_policy = update_deployment_policy # Notification - Admin APIs admin_create_notification_channel = admin_create_notification_channel admin_update_notification_channel = admin_update_notification_channel diff --git a/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py b/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py index f605ad9f157..7aa61cb910f 100644 --- a/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py +++ b/tests/unit/manager/api/gql/deployment/test_update_deployment_policy.py @@ -1,4 +1,4 @@ -"""Tests for admin_update_deployment_policy GQL mutation.""" +"""Tests for update_deployment_policy GQL mutation.""" from __future__ import annotations @@ -205,7 +205,7 @@ def test_converts_deployment_id_to_uuid(self) -> None: class TestAdminUpdateDeploymentPolicyResolver: - """Tests for admin_update_deployment_policy resolver.""" + """Tests for update_deployment_policy resolver.""" async def test_delegates_upsert_action_to_processor( self, @@ -233,7 +233,7 @@ async def test_delegates_upsert_action_to_processor( ) # When - resolver_fn = policy_resolver.admin_update_deployment_policy.base_resolver + resolver_fn = policy_resolver.update_deployment_policy.base_resolver result = await resolver_fn(rolling_update_input, mock_info) # Then @@ -269,7 +269,7 @@ async def test_rejects_non_superadmin( ) # When / Then - resolver_fn = policy_resolver.admin_update_deployment_policy.base_resolver + resolver_fn = policy_resolver.update_deployment_policy.base_resolver with pytest.raises(web.HTTPForbidden): await resolver_fn(input_data, mock_info) From 686a7265e35130c6ca32506b64194187bc2fc777 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Thu, 19 Mar 2026 17:45:47 +0900 Subject: [PATCH 10/10] wip --- .../graphql-reference/supergraph.graphql | 36 ++---------- .../graphql-reference/v2-schema.graphql | 36 ++---------- .../api/gql/deployment/types/policy.py | 56 +++---------------- 3 files changed, 17 insertions(+), 111 deletions(-) diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 01f47fa8058..1e67a3758b6 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -1626,13 +1626,7 @@ to support integers outside the range of a signed 32-bit integer. scalar BigInt @join__type(graph: GRAPHENE) -""" -Added in 25.19.0. -Input parameters for configuring a blue-green deployment strategy. -When auto_promote is true, traffic is automatically switched from the blue (old) -replica set to the green (new) set after promote_delay_seconds elapse. -When auto_promote is false (default), an explicit promotion action is required. -""" +"""Added in 25.19.0. Configuration for blue-green deployment strategy.""" input BlueGreenConfigInput @join__type(graph: STRAWBERRY) { @@ -3652,14 +3646,7 @@ enum DeploymentOrderField NAME @join__enumValue(graph: STRAWBERRY) } -""" -Added in 25.19.0. -Defines the deployment policy attached to a model deployment, -including the rollout strategy (rolling update or blue-green), -strategy-specific parameters, and whether to automatically roll back on failure. -Each deployment has at most one policy; creating a new policy for the same deployment -replaces the existing one (upsert semantics). -""" +"""Added in 25.19.0. Deployment policy configuration.""" type DeploymentPolicy implements Node @join__implements(graph: STRAWBERRY, interface: "Node") @join__type(graph: STRAWBERRY) @@ -3730,10 +3717,7 @@ input DeploymentStrategyInput } """ -Added in 25.19.0. -Base interface for all deployment strategy specifications. -Each concrete implementation (RollingUpdateStrategySpec, BlueGreenStrategySpec) -carries strategy-specific parameters that control how replica transitions are performed. +Added in 25.19.0. Base interface for deployment strategy specifications. """ interface DeploymentStrategySpec @join__type(graph: STRAWBERRY) @@ -3742,10 +3726,7 @@ interface DeploymentStrategySpec } """ -Added in 25.19.0. -Determines how new revisions of a model deployment are rolled out to replace old ones. -ROLLING performs incremental replica replacement controlled by max_surge and max_unavailable, -while BLUE_GREEN provisions a complete new replica set before switching traffic. +Added in 25.19.0. This enum represents the deployment strategy type of a model deployment, indicating the strategy used for deployment. """ enum DeploymentStrategyType @join__type(graph: STRAWBERRY) @@ -11534,14 +11515,7 @@ enum RoleStatus DELETED @join__enumValue(graph: STRAWBERRY) } -""" -Added in 25.19.0. -Input parameters for configuring a rolling update strategy. -max_surge sets the maximum number of additional replicas that can be created -beyond the desired count during an update (default: 1). -max_unavailable sets the maximum number of replicas that can be unavailable -during the update (default: 0). At least one of these must be positive. -""" +"""Added in 25.19.0. Configuration for rolling update strategy.""" input RollingUpdateConfigInput @join__type(graph: STRAWBERRY) { diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 133d76f4431..4612d288f81 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -1083,13 +1083,7 @@ enum BgtaskEventType { FAILED } -""" -Added in 25.19.0. -Input parameters for configuring a blue-green deployment strategy. -When auto_promote is true, traffic is automatically switched from the blue (old) -replica set to the green (new) set after promote_delay_seconds elapse. -When auto_promote is false (default), an explicit promotion action is required. -""" +"""Added in 25.19.0. Configuration for blue-green deployment strategy.""" input BlueGreenConfigInput { autoPromote: Boolean! = false promoteDelaySeconds: Int! = 0 @@ -2080,14 +2074,7 @@ enum DeploymentOrderField { NAME } -""" -Added in 25.19.0. -Defines the deployment policy attached to a model deployment, -including the rollout strategy (rolling update or blue-green), -strategy-specific parameters, and whether to automatically roll back on failure. -Each deployment has at most one policy; creating a new policy for the same deployment -replaces the existing one (upsert semantics). -""" +"""Added in 25.19.0. Deployment policy configuration.""" type DeploymentPolicy implements Node { """The Globally Unique ID of this object""" id: ID! @@ -2143,20 +2130,14 @@ input DeploymentStrategyInput { } """ -Added in 25.19.0. -Base interface for all deployment strategy specifications. -Each concrete implementation (RollingUpdateStrategySpec, BlueGreenStrategySpec) -carries strategy-specific parameters that control how replica transitions are performed. +Added in 25.19.0. Base interface for deployment strategy specifications. """ interface DeploymentStrategySpec { strategy: DeploymentStrategyType! } """ -Added in 25.19.0. -Determines how new revisions of a model deployment are rolled out to replace old ones. -ROLLING performs incremental replica replacement controlled by max_surge and max_unavailable, -while BLUE_GREEN provisions a complete new replica set before switching traffic. +Added in 25.19.0. This enum represents the deployment strategy type of a model deployment, indicating the strategy used for deployment. """ enum DeploymentStrategyType { ROLLING @@ -6924,14 +6905,7 @@ enum RoleStatus { DELETED } -""" -Added in 25.19.0. -Input parameters for configuring a rolling update strategy. -max_surge sets the maximum number of additional replicas that can be created -beyond the desired count during an update (default: 1). -max_unavailable sets the maximum number of replicas that can be unavailable -during the update (default: 0). At least one of these must be positive. -""" +"""Added in 25.19.0. Configuration for rolling update strategy.""" input RollingUpdateConfigInput { maxSurge: Int! = 1 maxUnavailable: Int! = 0 diff --git a/src/ai/backend/manager/api/gql/deployment/types/policy.py b/src/ai/backend/manager/api/gql/deployment/types/policy.py index 9cd633c8de4..1ea597c00bf 100644 --- a/src/ai/backend/manager/api/gql/deployment/types/policy.py +++ b/src/ai/backend/manager/api/gql/deployment/types/policy.py @@ -22,12 +22,7 @@ DeploymentStrategyTypeGQL: type[DeploymentStrategy] = strawberry.enum( DeploymentStrategy, name="DeploymentStrategyType", - description=dedent_strip(""" - Added in 25.19.0. - Determines how new revisions of a model deployment are rolled out to replace old ones. - ROLLING performs incremental replica replacement controlled by max_surge and max_unavailable, - while BLUE_GREEN provisions a complete new replica set before switching traffic. - """), + description="Added in 25.19.0. This enum represents the deployment strategy type of a model deployment, indicating the strategy used for deployment.", ) # ========== Output Types (Response) ========== @@ -35,12 +30,7 @@ @strawberry.interface( name="DeploymentStrategySpec", - description=dedent_strip(""" - Added in 25.19.0. - Base interface for all deployment strategy specifications. - Each concrete implementation (RollingUpdateStrategySpec, BlueGreenStrategySpec) - carries strategy-specific parameters that control how replica transitions are performed. - """), + description="Added in 25.19.0. Base interface for deployment strategy specifications.", ) class DeploymentStrategySpecGQL: strategy: DeploymentStrategyTypeGQL @@ -48,13 +38,7 @@ class DeploymentStrategySpecGQL: @strawberry.type( name="RollingUpdateStrategySpec", - description=dedent_strip(""" - Added in 25.19.0. - Strategy specification for rolling updates. - Replicas are replaced incrementally: max_surge controls how many extra replicas - can be created above the desired count, and max_unavailable controls how many - existing replicas can be taken down simultaneously during the transition. - """), + description="Added in 25.19.0. Rolling update strategy specification.", ) class RollingUpdateStrategySpecGQL(DeploymentStrategySpecGQL): max_surge: int @@ -63,13 +47,7 @@ class RollingUpdateStrategySpecGQL(DeploymentStrategySpecGQL): @strawberry.type( name="BlueGreenStrategySpec", - description=dedent_strip(""" - Added in 25.19.0. - Strategy specification for blue-green deployments. - A complete new replica set (green) is provisioned alongside the existing one (blue). - When auto_promote is true, traffic is automatically switched to the green set - after promote_delay_seconds; otherwise, manual promotion is required. - """), + description="Added in 25.19.0. Blue-green deployment strategy specification.", ) class BlueGreenStrategySpecGQL(DeploymentStrategySpecGQL): auto_promote: bool @@ -78,14 +56,7 @@ class BlueGreenStrategySpecGQL(DeploymentStrategySpecGQL): @strawberry.type( name="DeploymentPolicy", - description=dedent_strip(""" - Added in 25.19.0. - Defines the deployment policy attached to a model deployment, - including the rollout strategy (rolling update or blue-green), - strategy-specific parameters, and whether to automatically roll back on failure. - Each deployment has at most one policy; creating a new policy for the same deployment - replaces the existing one (upsert semantics). - """), + description="Added in 25.19.0. Deployment policy configuration.", ) class DeploymentPolicyGQL(Node): id: NodeID[str] @@ -136,14 +107,7 @@ def from_data(cls, data: DeploymentPolicyData) -> Self: @strawberry.input( name="RollingUpdateConfigInput", - description=dedent_strip(""" - Added in 25.19.0. - Input parameters for configuring a rolling update strategy. - max_surge sets the maximum number of additional replicas that can be created - beyond the desired count during an update (default: 1). - max_unavailable sets the maximum number of replicas that can be unavailable - during the update (default: 0). At least one of these must be positive. - """), + description="Added in 25.19.0. Configuration for rolling update strategy.", ) class RollingUpdateConfigInputGQL: max_surge: int = 1 @@ -158,13 +122,7 @@ def to_spec(self) -> RollingUpdateSpec: @strawberry.input( name="BlueGreenConfigInput", - description=dedent_strip(""" - Added in 25.19.0. - Input parameters for configuring a blue-green deployment strategy. - When auto_promote is true, traffic is automatically switched from the blue (old) - replica set to the green (new) set after promote_delay_seconds elapse. - When auto_promote is false (default), an explicit promotion action is required. - """), + description="Added in 25.19.0. Configuration for blue-green deployment strategy.", ) class BlueGreenConfigInputGQL: auto_promote: bool = False