Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/11506.fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Honor `AND`/`OR`/`NOT` clauses in `myDeployments` and `projectDeployments` GraphQL filters, which were previously ignored and caused multi-condition deployment queries to return unfiltered results.
116 changes: 2 additions & 114 deletions src/ai/backend/manager/api/adapters/deployment/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,63 +597,7 @@ async def my_search(
raise RuntimeError("No authenticated user in context")
conditions: list[QueryCondition] = []
if input.filter:
f = input.filter
if f.name is not None:
condition = self.convert_string_filter(
f.name,
contains_factory=DeploymentConditions.by_name_contains,
equals_factory=DeploymentConditions.by_name_equals,
starts_with_factory=DeploymentConditions.by_name_starts_with,
ends_with_factory=DeploymentConditions.by_name_ends_with,
in_factory=DeploymentConditions.by_name_in,
)
if condition is not None:
conditions.append(condition)
if f.status is not None:
if f.status.equals is not None:
lifecycles = _status_to_lifecycles(ModelDeploymentStatus(f.status.equals))
if lifecycles:
conditions.append(DeploymentConditions.by_status_in(lifecycles))
if f.status.in_ is not None:
lifecycles = _statuses_to_lifecycles([
ModelDeploymentStatus(s) for s in f.status.in_
])
if lifecycles:
conditions.append(DeploymentConditions.by_status_in(lifecycles))
if f.status.not_equals is not None:
lifecycles = _status_to_lifecycles(ModelDeploymentStatus(f.status.not_equals))
if lifecycles:
conditions.append(DeploymentConditions.by_status_not_in(lifecycles))
if f.status.not_in is not None:
lifecycles = _statuses_to_lifecycles([
ModelDeploymentStatus(s) for s in f.status.not_in
])
if lifecycles:
conditions.append(DeploymentConditions.by_status_not_in(lifecycles))
if f.open_to_public is not None:
conditions.append(DeploymentConditions.by_open_to_public(f.open_to_public))
if f.tags is not None:
condition = self.convert_string_filter(
f.tags,
contains_factory=DeploymentConditions.by_tag_contains,
equals_factory=DeploymentConditions.by_tag_equals,
starts_with_factory=DeploymentConditions.by_tag_starts_with,
ends_with_factory=DeploymentConditions.by_tag_ends_with,
in_factory=DeploymentConditions.by_tag_in,
)
if condition is not None:
conditions.append(condition)
if f.endpoint_url is not None:
condition = self.convert_string_filter(
f.endpoint_url,
contains_factory=DeploymentConditions.by_url_contains,
equals_factory=DeploymentConditions.by_url_equals,
starts_with_factory=DeploymentConditions.by_url_starts_with,
ends_with_factory=DeploymentConditions.by_url_ends_with,
in_factory=DeploymentConditions.by_url_in,
)
if condition is not None:
conditions.append(condition)
conditions.extend(self._convert_deployment_filter(input.filter))
orders: list[QueryOrder] = (
self._convert_deployment_orders(input.order) if input.order else []
)
Expand Down Expand Up @@ -691,63 +635,7 @@ async def project_search(
"""Search deployments within a specific project."""
conditions: list[QueryCondition] = []
if input.filter:
f = input.filter
if f.name is not None:
condition = self.convert_string_filter(
f.name,
contains_factory=DeploymentConditions.by_name_contains,
equals_factory=DeploymentConditions.by_name_equals,
starts_with_factory=DeploymentConditions.by_name_starts_with,
ends_with_factory=DeploymentConditions.by_name_ends_with,
in_factory=DeploymentConditions.by_name_in,
)
if condition is not None:
conditions.append(condition)
if f.status is not None:
if f.status.equals is not None:
lifecycles = _status_to_lifecycles(ModelDeploymentStatus(f.status.equals))
if lifecycles:
conditions.append(DeploymentConditions.by_status_in(lifecycles))
if f.status.in_ is not None:
lifecycles = _statuses_to_lifecycles([
ModelDeploymentStatus(s) for s in f.status.in_
])
if lifecycles:
conditions.append(DeploymentConditions.by_status_in(lifecycles))
if f.status.not_equals is not None:
lifecycles = _status_to_lifecycles(ModelDeploymentStatus(f.status.not_equals))
if lifecycles:
conditions.append(DeploymentConditions.by_status_not_in(lifecycles))
if f.status.not_in is not None:
lifecycles = _statuses_to_lifecycles([
ModelDeploymentStatus(s) for s in f.status.not_in
])
if lifecycles:
conditions.append(DeploymentConditions.by_status_not_in(lifecycles))
if f.open_to_public is not None:
conditions.append(DeploymentConditions.by_open_to_public(f.open_to_public))
if f.tags is not None:
condition = self.convert_string_filter(
f.tags,
contains_factory=DeploymentConditions.by_tag_contains,
equals_factory=DeploymentConditions.by_tag_equals,
starts_with_factory=DeploymentConditions.by_tag_starts_with,
ends_with_factory=DeploymentConditions.by_tag_ends_with,
in_factory=DeploymentConditions.by_tag_in,
)
if condition is not None:
conditions.append(condition)
if f.endpoint_url is not None:
condition = self.convert_string_filter(
f.endpoint_url,
contains_factory=DeploymentConditions.by_url_contains,
equals_factory=DeploymentConditions.by_url_equals,
starts_with_factory=DeploymentConditions.by_url_starts_with,
ends_with_factory=DeploymentConditions.by_url_ends_with,
in_factory=DeploymentConditions.by_url_in,
)
if condition is not None:
conditions.append(condition)
conditions.extend(self._convert_deployment_filter(input.filter))
orders: list[QueryOrder] = (
self._convert_deployment_orders(input.order) if input.order else []
)
Expand Down
231 changes: 231 additions & 0 deletions tests/component/deployment/test_deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@

import secrets
import uuid
from typing import TYPE_CHECKING
from unittest.mock import MagicMock

import pytest

from ai.backend.client.v2.exceptions import NotFoundError
from ai.backend.client.v2.registry import BackendAIClientRegistry
from ai.backend.common.config import ModelDefinitionDraft
from ai.backend.common.contexts.user import with_user
from ai.backend.common.data.endpoint.types import EndpointLifecycle
from ai.backend.common.data.model_deployment.types import DeploymentStrategy, ModelDeploymentStatus
from ai.backend.common.data.user.types import UserData, UserRole
from ai.backend.common.dto.manager.deployment import (
CreateDeploymentRequest,
DeactivateRevisionResponse,
Expand All @@ -38,10 +42,22 @@
)
from ai.backend.common.dto.manager.deployment.request import ClusterConfigInput
from ai.backend.common.dto.manager.query import StringFilter
from ai.backend.common.dto.manager.v2.deployment.request import (
AdminSearchDeploymentsInput,
)
from ai.backend.common.dto.manager.v2.deployment.request import (
Comment on lines +47 to +48
DeploymentFilter as DeploymentFilterV2,
)
from ai.backend.common.identifier.image import ImageID
from ai.backend.common.identifier.vfolder import VFolderUUID
from ai.backend.common.types import ClusterMode
from ai.backend.manager.api.adapters.deployment.adapter import DeploymentAdapter
from ai.backend.manager.services.deployment.processors import DeploymentProcessors
from ai.backend.manager.services.deployment.service import _map_lifecycle_to_status
from ai.backend.manager.services.processors import Processors

if TYPE_CHECKING:
from tests.component.conftest import UserFixtureData


class TestSearchDeployments:
Expand Down Expand Up @@ -315,6 +331,221 @@ async def test_deactivate_revision_stub(
assert result.success is True


class TestDeploymentAdapterFilter:
"""Verify the GQL adapter honors AND/OR/NOT in DeploymentFilter.

The adapter's ``my_search`` and ``project_search`` previously inlined
the filter conversion and silently dropped nested ``AND``/``OR``/``NOT``
clauses, so multi-condition filters degenerated into "no filter at all".
These tests pin the corrected behavior.
"""

@pytest.fixture
def deployment_adapter(
self,
deployment_processors: DeploymentProcessors,
) -> DeploymentAdapter:
processors_mock = MagicMock(spec=Processors)
processors_mock.deployment = deployment_processors
return DeploymentAdapter(processors_mock, deployment_coordinator=MagicMock())

@staticmethod
def _admin_user_data(user_uuid: uuid.UUID, domain: str) -> UserData:
return UserData(
user_id=user_uuid,
is_authorized=True,
is_admin=True,
is_superadmin=True,
role=UserRole.SUPERADMIN,
domain_name=domain,
)

@staticmethod
async def _create_deployment_with_tags(
admin_registry: BackendAIClientRegistry,
project_id: uuid.UUID,
domain: str,
scaling_group: str,
seed_data: tuple[ImageID, VFolderUUID],
tags: list[str],
) -> uuid.UUID:
image_id, vfolder_id = seed_data
request = CreateDeploymentRequest(
metadata=DeploymentMetadataInput(
project_id=project_id,
domain_name=domain,
name=f"test-deployment-{secrets.token_hex(4)}",
tags=tags,
),
network_access=NetworkAccessInput(open_to_public=False),
default_deployment_strategy=DeploymentStrategyInput(
type=DeploymentStrategy.ROLLING,
),
replica_count=1,
initial_revision=RevisionInput(
name="v1",
cluster_config=ClusterConfigInput(mode=ClusterMode.SINGLE_NODE, size=1),
resource_config=ResourceConfigInput(
resource_group=scaling_group,
resource_slots={"cpu": "2", "mem": "2147483648"},
),
image=ImageInput(id=image_id),
model_runtime_config=ModelRuntimeConfigInput(),
model_mount_config=ModelMountConfigInput(
vfolder_id=vfolder_id,
mount_destination="/models",
definition_path="model-definition.yaml",
),
model_definition=ModelDefinitionDraft(),
),
)
response = await admin_registry.deployment.create_deployment(request)
return response.deployment.id

async def test_my_search_and_filter_returns_intersection(
self,
admin_registry: BackendAIClientRegistry,
admin_user_fixture: UserFixtureData,
deployment_adapter: DeploymentAdapter,
group_fixture: uuid.UUID,
domain_fixture: str,
scaling_group_fixture: str,
deployment_seed_data: tuple[ImageID, VFolderUUID],
) -> None:
"""AND clause must narrow results to deployments matching every nested filter."""
await self._create_deployment_with_tags(
admin_registry,
group_fixture,
domain_fixture,
scaling_group_fixture,
deployment_seed_data,
["alpha", "production"],
)
await self._create_deployment_with_tags(
admin_registry,
group_fixture,
domain_fixture,
scaling_group_fixture,
deployment_seed_data,
["beta", "production"],
)
target_id = await self._create_deployment_with_tags(
admin_registry,
group_fixture,
domain_fixture,
scaling_group_fixture,
deployment_seed_data,
["alpha", "beta"],
)

filter_input = DeploymentFilterV2(
AND=[
DeploymentFilterV2(tags=StringFilter(i_contains="alpha")),
DeploymentFilterV2(tags=StringFilter(i_contains="beta")),
],
)
with with_user(self._admin_user_data(admin_user_fixture.user_uuid, domain_fixture)):
payload = await deployment_adapter.my_search(
AdminSearchDeploymentsInput(filter=filter_input, limit=50),
)

assert payload.total_count == 1
assert [item.id for item in payload.items] == [target_id]

async def test_my_search_or_filter_returns_union(
self,
admin_registry: BackendAIClientRegistry,
admin_user_fixture: UserFixtureData,
deployment_adapter: DeploymentAdapter,
group_fixture: uuid.UUID,
domain_fixture: str,
scaling_group_fixture: str,
deployment_seed_data: tuple[ImageID, VFolderUUID],
) -> None:
"""OR clause must widen results to deployments matching any nested filter."""
alpha_id = await self._create_deployment_with_tags(
admin_registry,
group_fixture,
domain_fixture,
scaling_group_fixture,
deployment_seed_data,
["alpha"],
)
beta_id = await self._create_deployment_with_tags(
admin_registry,
group_fixture,
domain_fixture,
scaling_group_fixture,
deployment_seed_data,
["beta"],
)
await self._create_deployment_with_tags(
admin_registry,
group_fixture,
domain_fixture,
scaling_group_fixture,
deployment_seed_data,
["gamma"],
)

filter_input = DeploymentFilterV2(
OR=[
DeploymentFilterV2(tags=StringFilter(i_contains="alpha")),
DeploymentFilterV2(tags=StringFilter(i_contains="beta")),
],
)
Comment on lines +492 to +497
with with_user(self._admin_user_data(admin_user_fixture.user_uuid, domain_fixture)):
payload = await deployment_adapter.my_search(
AdminSearchDeploymentsInput(filter=filter_input, limit=50),
)

assert payload.total_count == 2
assert {item.id for item in payload.items} == {alpha_id, beta_id}

async def test_project_search_and_filter_returns_intersection(
self,
admin_registry: BackendAIClientRegistry,
admin_user_fixture: UserFixtureData,
deployment_adapter: DeploymentAdapter,
group_fixture: uuid.UUID,
domain_fixture: str,
scaling_group_fixture: str,
deployment_seed_data: tuple[ImageID, VFolderUUID],
) -> None:
"""project_search must also honor AND across nested filters."""
await self._create_deployment_with_tags(
admin_registry,
group_fixture,
domain_fixture,
scaling_group_fixture,
deployment_seed_data,
["alpha"],
)
target_id = await self._create_deployment_with_tags(
admin_registry,
group_fixture,
domain_fixture,
scaling_group_fixture,
deployment_seed_data,
["alpha", "beta"],
)

filter_input = DeploymentFilterV2(
AND=[
DeploymentFilterV2(tags=StringFilter(i_contains="alpha")),
DeploymentFilterV2(tags=StringFilter(i_contains="beta")),
],
)
with with_user(self._admin_user_data(admin_user_fixture.user_uuid, domain_fixture)):
payload = await deployment_adapter.project_search(
group_fixture,
AdminSearchDeploymentsInput(filter=filter_input, limit=50),
)

assert payload.total_count == 1
assert [item.id for item in payload.items] == [target_id]


class TestStatusMapping:
def test_lifecycle_to_status_mapping(self) -> None:
"""Verify EndpointLifecycle maps correctly to ModelDeploymentStatus.
Expand Down
Loading