Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/10426.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add blue-green deployment infrastructure and promote API
22 changes: 22 additions & 0 deletions docs/manager/graphql-reference/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -10207,6 +10207,11 @@ type Mutation
"""
activateDeploymentRevision(input: ActivateRevisionInput!): ActivateRevisionPayload! @join__field(graph: STRAWBERRY)

"""
Added in UNRELEASED. Manually promote a blue-green deployment awaiting promotion. When `auto_promote=false`, the deployment stays in AWAITING_PROMOTION once all Green routes are healthy until an operator triggers this mutation, which atomically switches traffic (Green→ACTIVE, Blue→INACTIVE→TERMINATING).
"""
promoteDeployment(input: PromoteDeploymentInput!): PromoteDeploymentPayload! @join__field(graph: STRAWBERRY)

"""Added in 25.19.0. Update the traffic status of a route"""
updateRouteTrafficStatus(input: UpdateRouteTrafficStatusInput!): UpdateRouteTrafficStatusPayload! @join__field(graph: STRAWBERRY)

Expand Down Expand Up @@ -12140,6 +12145,23 @@ input ProjectWeightInputItem
weight: Decimal = null
}

"""
Added in UNRELEASED. Input for manually promoting a blue-green deployment.
"""
input PromoteDeploymentInput
@join__type(graph: STRAWBERRY)
{
deploymentId: ID!
}

"""Added in UNRELEASED. Result of manually promoting a deployment."""
type PromoteDeploymentPayload
@join__type(graph: STRAWBERRY)
{
"""The promoted deployment"""
deployment: ModelDeployment!
}

"""
Completely delete domain from DB.

Expand Down
18 changes: 18 additions & 0 deletions docs/manager/graphql-reference/v2-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -6209,6 +6209,11 @@ type Mutation {
"""
activateDeploymentRevision(input: ActivateRevisionInput!): ActivateRevisionPayload!

"""
Added in UNRELEASED. Manually promote a blue-green deployment awaiting promotion. When `auto_promote=false`, the deployment stays in AWAITING_PROMOTION once all Green routes are healthy until an operator triggers this mutation, which atomically switches traffic (Green→ACTIVE, Blue→INACTIVE→TERMINATING).
"""
promoteDeployment(input: PromoteDeploymentInput!): PromoteDeploymentPayload!

"""Added in 25.19.0. Update the traffic status of a route"""
updateRouteTrafficStatus(input: UpdateRouteTrafficStatusInput!): UpdateRouteTrafficStatusPayload!

Expand Down Expand Up @@ -7849,6 +7854,19 @@ input ProjectWeightInputItem {
weight: Decimal = null
}

"""
Added in UNRELEASED. Input for manually promoting a blue-green deployment.
"""
input PromoteDeploymentInput {
deploymentId: ID!
}

"""Added in UNRELEASED. Result of manually promoting a deployment."""
type PromoteDeploymentPayload {
"""The promoted deployment"""
deployment: ModelDeployment!
}

"""Added in 26.4.2. Payload for domain permanent deletion mutation."""
type PurgeDomainPayloadGQL {
"""Whether the purge was successful."""
Expand Down
22 changes: 22 additions & 0 deletions src/ai/backend/client/cli/v2/deployment/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,28 @@ async def _run() -> None:
asyncio.run(_run())


@deployment.command()
@click.argument("deployment_id", type=str)
def promote(deployment_id: str) -> None:
"""Manually promote a blue-green deployment awaiting promotion."""

from ai.backend.common.dto.manager.v2.deployment.request import (
PromoteDeploymentInput,
)

body = PromoteDeploymentInput(deployment_id=UUID(deployment_id))

async def _run() -> None:
registry = await create_v2_registry(load_v2_config())
try:
result = await registry.deployment.promote(body)
print_result(result)
finally:
await registry.close()

asyncio.run(_run())


@deployment.command()
@click.argument("deployment_id", type=str)
def delete(deployment_id: str) -> None:
Expand Down
13 changes: 13 additions & 0 deletions src/ai/backend/client/v2/domains_v2/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
CreateDeploymentInput,
DeleteAccessTokenInput,
DeleteDeploymentInput,
PromoteDeploymentInput,
SearchAccessTokensInput,
SearchAutoScalingRulesInput,
SearchDeploymentPoliciesInput,
Expand Down Expand Up @@ -48,6 +49,7 @@
GetAccessTokenPayload,
GetAutoScalingRulePayload,
GetDeploymentPolicyPayload,
PromoteDeploymentPayload,
ReplicaNode,
RevisionNode,
SearchAccessTokensPayload,
Expand Down Expand Up @@ -233,6 +235,17 @@ async def activate_revision(
response_model=ActivateRevisionPayload,
)

async def promote(
self,
body: PromoteDeploymentInput,
) -> PromoteDeploymentPayload:
"""Manually promote a blue-green deployment awaiting promotion."""
return await self._client.typed_request(
"POST",
_PATH + f"/{body.deployment_id}/promote",
response_model=PromoteDeploymentPayload,
)

# ------------------------------------------------------------------
# Replica operations
# ------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions src/ai/backend/common/dto/manager/v2/deployment/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,12 @@ class ActivateRevisionInput(BaseRequestModel):
revision_id: UUID = Field(description="Revision ID to activate")


class PromoteDeploymentInput(BaseRequestModel):
"""Input for manually promoting a blue-green deployment."""

deployment_id: UUID = Field(description="Deployment ID to promote")


class UpdateRouteTrafficStatusInput(BaseRequestModel):
"""Input for updating a route's traffic status."""

Expand Down
6 changes: 6 additions & 0 deletions src/ai/backend/common/dto/manager/v2/deployment/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,12 @@ class ActivateRevisionPayload(BaseResponseModel):
)


class PromoteDeploymentPayload(BaseResponseModel):
"""Payload for promote deployment mutation result."""

deployment: DeploymentNode = Field(description="The promoted deployment")


class UpdateRouteTrafficStatusPayload(BaseResponseModel):
"""Payload for update route traffic status mutation result."""

Expand Down
12 changes: 12 additions & 0 deletions src/ai/backend/manager/api/adapters/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
DeleteAccessTokenInput,
DeleteDeploymentInput,
DeploymentOrder,
PromoteDeploymentInput,
ReplicaOrder,
RevisionOrder,
RouteOrder,
Expand Down Expand Up @@ -70,6 +71,7 @@
GetAccessTokenPayload,
GetAutoScalingRulePayload,
GetDeploymentPolicyPayload,
PromoteDeploymentPayload,
ReplicaNode,
RevisionNode,
RouteNode,
Expand Down Expand Up @@ -276,6 +278,7 @@
)
from ai.backend.manager.services.deployment.actions.revision_operations import (
ActivateRevisionAction,
PromoteDeploymentAction,
)
from ai.backend.manager.services.deployment.actions.route.search_routes import SearchRoutesAction
from ai.backend.manager.services.deployment.actions.route.update_route_traffic_status import (
Expand Down Expand Up @@ -814,6 +817,15 @@ async def activate_revision(self, input: ActivateRevisionInput) -> ActivateRevis
deployment_policy=self._policy_data_to_dto(action_result.deployment_policy),
)

async def promote_deployment(self, input: PromoteDeploymentInput) -> PromoteDeploymentPayload:
"""Manually promote a blue-green deployment."""
action_result = await self._processors.deployment.promote_deployment.wait_for_complete(
PromoteDeploymentAction(deployment_id=input.deployment_id)
)
return PromoteDeploymentPayload(
deployment=self._deployment_data_to_dto(action_result.deployment),
)

async def delete(self, input: DeleteDeploymentInput) -> DeleteDeploymentPayload:
"""Delete a deployment."""
await self._processors.deployment.destroy_deployment.wait_for_complete(
Expand Down
6 changes: 6 additions & 0 deletions src/ai/backend/manager/api/gql/deployment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
inference_runtime_configs,
my_deployments,
project_deployments,
promote_deployment,
# Replica
replica,
replica_status_changed,
Expand Down Expand Up @@ -126,6 +127,8 @@
ModelRuntimeConfigInput,
MountPermission,
ProjectDeploymentScopeGQL,
PromoteDeploymentInputGQL,
PromoteDeploymentPayloadGQL,
ReadinessStatus,
ReplicaFilter,
ReplicaOrderBy,
Expand Down Expand Up @@ -236,6 +239,8 @@
# Revision Types
"ActivateRevisionInputGQL",
"ActivateRevisionPayloadGQL",
"PromoteDeploymentInputGQL",
"PromoteDeploymentPayloadGQL",
"AddRevisionInput",
"AddRevisionPayload",
"ClusterConfig",
Expand Down Expand Up @@ -295,6 +300,7 @@
# Resolvers - Revision
"activate_deployment_revision",
"add_model_revision",
"promote_deployment",
"inference_runtime_config",
"inference_runtime_configs",
"revision",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
add_model_revision,
inference_runtime_config,
inference_runtime_configs,
promote_deployment,
revision,
revisions,
)
Expand Down Expand Up @@ -84,6 +85,7 @@
"inference_runtime_configs",
"add_model_revision",
"activate_deployment_revision",
"promote_deployment",
# Route
"routes",
"route",
Expand Down
25 changes: 25 additions & 0 deletions src/ai/backend/manager/api/gql/deployment/resolver/revision.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ai.backend.common.dto.manager.v2.deployment.request import (
AdminSearchRevisionsInput,
)
from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION
from ai.backend.manager.api.gql.base import encode_cursor, resolve_global_id
from ai.backend.manager.api.gql.decorators import (
BackendAIGQLMeta,
Expand All @@ -34,6 +35,8 @@
ModelRevisionEdge,
ModelRevisionFilter,
ModelRevisionOrderBy,
PromoteDeploymentInputGQL,
PromoteDeploymentPayloadGQL,
)
from ai.backend.manager.api.gql.types import StrawberryGQLContext
from ai.backend.manager.data.deployment.inference_runtime_config import (
Expand Down Expand Up @@ -181,3 +184,25 @@ async def activate_deployment_revision(
activated_revision_id=ID(str(payload.activated_revision_id)),
deployment_policy=DeploymentPolicyGQL.from_pydantic(payload.deployment_policy),
)


@gql_mutation(
BackendAIGQLMeta(
added_version=NEXT_RELEASE_VERSION,
description=(
"Manually promote a blue-green deployment awaiting promotion. "
"When `auto_promote=false`, the deployment stays in AWAITING_PROMOTION "
"once all Green routes are healthy until an operator triggers this mutation, "
"which atomically switches traffic (Green→ACTIVE, Blue→INACTIVE→TERMINATING)."
),
)
) # type: ignore[misc]
async def promote_deployment(
input: PromoteDeploymentInputGQL,
info: Info[StrawberryGQLContext, None],
) -> PromoteDeploymentPayloadGQL:
"""Promote a deployment that is in AWAITING_PROMOTION state."""
payload = await info.context.adapters.deployment.promote_deployment(input.to_pydantic())
return PromoteDeploymentPayloadGQL(
deployment=ModelDeployment.from_pydantic(payload.deployment),
)
4 changes: 4 additions & 0 deletions src/ai/backend/manager/api/gql/deployment/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@
ModelRuntimeConfig,
ModelRuntimeConfigInput,
MountPermission,
PromoteDeploymentInputGQL,
PromoteDeploymentPayloadGQL,
ResourceConfig,
ResourceConfigInput,
ResourceGroupInput,
Expand Down Expand Up @@ -214,6 +216,8 @@
# Revision
"ActivateRevisionInputGQL",
"ActivateRevisionPayloadGQL",
"PromoteDeploymentInputGQL",
"PromoteDeploymentPayloadGQL",
"AddRevisionInput",
"AddRevisionPayload",
"ClusterConfig",
Expand Down
30 changes: 30 additions & 0 deletions src/ai/backend/manager/api/gql/deployment/types/revision.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
from ai.backend.common.dto.manager.v2.deployment.request import (
ModelRuntimeConfigInput as ModelRuntimeConfigInputDTO,
)
from ai.backend.common.dto.manager.v2.deployment.request import (
PromoteDeploymentInput as PromoteDeploymentInputDTO,
)
from ai.backend.common.dto.manager.v2.deployment.request import (
ResourceConfigInput as ResourceConfigInputDTO,
)
Expand All @@ -83,6 +86,9 @@
from ai.backend.common.dto.manager.v2.deployment.response import (
AddRevisionPayload as AddRevisionPayloadDTO,
)
from ai.backend.common.dto.manager.v2.deployment.response import (
PromoteDeploymentPayload as PromoteDeploymentPayloadDTO,
)
from ai.backend.common.dto.manager.v2.deployment.response import (
RevisionNode as RevisionNodeDTO,
)
Expand All @@ -101,6 +107,7 @@
PreStartActionInfoDTO,
ResourceConfigInfoDTO,
)
from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION
from ai.backend.common.types import MountPermission as CommonMountPermission
from ai.backend.manager.api.gql.base import (
OrderDirection,
Expand Down Expand Up @@ -576,6 +583,29 @@ class ActivateRevisionPayloadGQL:
deployment_policy: Annotated[DeploymentPolicyGQL, strawberry.lazy(".policy")]


@gql_pydantic_input(
BackendAIGQLMeta(
description="Input for manually promoting a blue-green deployment.",
added_version=NEXT_RELEASE_VERSION,
),
name="PromoteDeploymentInput",
)
class PromoteDeploymentInputGQL(PydanticInputMixin[PromoteDeploymentInputDTO]):
deployment_id: ID


@gql_pydantic_type(
BackendAIGQLMeta(
added_version=NEXT_RELEASE_VERSION,
description="Result of manually promoting a deployment.",
),
model=PromoteDeploymentPayloadDTO,
name="PromoteDeploymentPayload",
)
class PromoteDeploymentPayloadGQL:
deployment: Annotated[ModelDeployment, strawberry.lazy(".deployment")]


# Input Types
@gql_pydantic_input(
BackendAIGQLMeta(description="", added_version="25.19.0"),
Expand Down
2 changes: 2 additions & 0 deletions src/ai/backend/manager/api/gql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
inference_runtime_configs,
my_deployments,
project_deployments,
promote_deployment,
# Replica
replica,
replica_status_changed,
Expand Down Expand Up @@ -704,6 +705,7 @@ class Mutation:
reject_artifact_revision = reject_artifact_revision
create_access_token = create_access_token
activate_deployment_revision = activate_deployment_revision
promote_deployment = promote_deployment
update_route_traffic_status = update_route_traffic_status
# Fair Share - Admin APIs
admin_upsert_domain_fair_share_weight = admin_upsert_domain_fair_share_weight
Expand Down
Loading
Loading