diff --git a/changes/11296.feature.md b/changes/11296.feature.md new file mode 100644 index 00000000000..8856b13f6db --- /dev/null +++ b/changes/11296.feature.md @@ -0,0 +1 @@ +Add the `AppConfigFragment` service / adapter / DTO surface — bulk-only writes (admin + self-service) with partial-success semantics, scope-bound + admin search reads. Layers on top of the foundation in #11282. diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/__init__.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/__init__.py new file mode 100644 index 00000000000..ce4ca37a4c7 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/__init__.py @@ -0,0 +1,53 @@ +from .request import ( + AdminAppConfigFragmentItemInput, + AdminBulkCreateAppConfigFragmentsInput, + AdminBulkPurgeAppConfigFragmentsInput, + AdminBulkUpdateAppConfigFragmentsInput, + AppConfigFragmentFilter, + AppConfigFragmentKeyInput, + AppConfigFragmentOrder, + MyAppConfigFragmentItemInput, + MyBulkCreateAppConfigFragmentsInput, + MyBulkUpdateAppConfigFragmentsInput, + SearchAppConfigFragmentsInput, +) +from .response import ( + AdminBulkCreateAppConfigFragmentsPayload, + AdminBulkPurgeAppConfigFragmentsPayload, + AdminBulkUpdateAppConfigFragmentsPayload, + AppConfigFragmentBulkError, + AppConfigFragmentNode, + GetAppConfigFragmentPayload, + PurgeAppConfigFragmentKey, + SearchAppConfigFragmentsPayload, +) +from .types import ( + AppConfigFragmentOrderField, + AppConfigScopeType, + OrderDirection, +) + +__all__ = ( + "AdminAppConfigFragmentItemInput", + "AdminBulkCreateAppConfigFragmentsInput", + "AdminBulkCreateAppConfigFragmentsPayload", + "AdminBulkPurgeAppConfigFragmentsInput", + "AdminBulkPurgeAppConfigFragmentsPayload", + "AdminBulkUpdateAppConfigFragmentsInput", + "AdminBulkUpdateAppConfigFragmentsPayload", + "AppConfigFragmentBulkError", + "AppConfigFragmentFilter", + "AppConfigFragmentKeyInput", + "AppConfigFragmentNode", + "AppConfigFragmentOrder", + "AppConfigFragmentOrderField", + "AppConfigScopeType", + "MyBulkCreateAppConfigFragmentsInput", + "MyBulkUpdateAppConfigFragmentsInput", + "GetAppConfigFragmentPayload", + "MyAppConfigFragmentItemInput", + "OrderDirection", + "PurgeAppConfigFragmentKey", + "SearchAppConfigFragmentsInput", + "SearchAppConfigFragmentsPayload", +) diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py new file mode 100644 index 00000000000..897ce1c59bc --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py @@ -0,0 +1,121 @@ +""" +Request DTOs for app_config_fragment DTO v2. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseRequestModel +from ai.backend.common.dto.manager.query import StringFilter, UUIDFilter + +from .types import AppConfigFragmentOrderField, AppConfigScopeType, OrderDirection + +__all__ = ( + "AdminAppConfigFragmentItemInput", + "AdminBulkCreateAppConfigFragmentsInput", + "AdminBulkPurgeAppConfigFragmentsInput", + "AdminBulkUpdateAppConfigFragmentsInput", + "AppConfigFragmentFilter", + "AppConfigFragmentKeyInput", + "AppConfigFragmentOrder", + "MyBulkCreateAppConfigFragmentsInput", + "MyBulkUpdateAppConfigFragmentsInput", + "MyAppConfigFragmentItemInput", + "SearchAppConfigFragmentsInput", +) + + +class AppConfigFragmentKeyInput(BaseRequestModel): + """Natural-key identifier for a single fragment row.""" + + scope_type: AppConfigScopeType = Field(description="Scope type.") + scope_id: str = Field(description="Scope id (e.g., domain name, user id, or `public`).") + name: str = Field( + min_length=1, + max_length=128, + description="Policy name.", + ) + + +class AppConfigFragmentFilter(BaseRequestModel): + """Filter for app-config fragment search.""" + + id: UUIDFilter | None = Field(default=None, description="Filter by row id.") + name: StringFilter | None = Field(default=None, description="Filter by policy name.") + scope_type: AppConfigScopeType | None = Field(default=None, description="Filter by scope_type.") + scope_id: StringFilter | None = Field(default=None, description="Filter by scope_id.") + + +class AppConfigFragmentOrder(BaseRequestModel): + """Order specification for app-config fragments.""" + + field: AppConfigFragmentOrderField = Field(description="Field to order by.") + direction: OrderDirection = Field(default=OrderDirection.ASC, description="Order direction.") + + +# ── Bulk mutation inputs ───────────────────────────────────────── + + +class AdminAppConfigFragmentItemInput(BaseRequestModel): + """Per-item input for admin bulk create / update (natural key + payload).""" + + key: AppConfigFragmentKeyInput = Field(description="Natural-key identifier.") + config: dict[str, Any] = Field( + default_factory=dict, + description="Raw configuration payload (empty dict clears the row).", + ) + + +class AdminBulkCreateAppConfigFragmentsInput(BaseRequestModel): + items: list[AdminAppConfigFragmentItemInput] = Field(description="Rows to create.") + + +class AdminBulkUpdateAppConfigFragmentsInput(BaseRequestModel): + items: list[AdminAppConfigFragmentItemInput] = Field(description="Rows to update.") + + +class AdminBulkPurgeAppConfigFragmentsInput(BaseRequestModel): + keys: list[AppConfigFragmentKeyInput] = Field(description="Natural keys to purge.") + + +class MyAppConfigFragmentItemInput(BaseRequestModel): + """Per-item input for self-service (`my`) bulk — `scope_type` + / `scope_id` are server-injected, so `name` is the only identifier. + """ + + name: str = Field(description="Policy name.") + config: dict[str, Any] = Field( + default_factory=dict, + description="Raw configuration payload (empty dict clears the row).", + ) + + +class MyBulkCreateAppConfigFragmentsInput(BaseRequestModel): + items: list[MyAppConfigFragmentItemInput] = Field(description="USER-scope rows to create.") + + +class MyBulkUpdateAppConfigFragmentsInput(BaseRequestModel): + items: list[MyAppConfigFragmentItemInput] = Field(description="USER-scope rows to update.") + + +class SearchAppConfigFragmentsInput(BaseRequestModel): + """Input for searching fragments (raw rows) with filter / order / pagination. + + Supports two pagination modes (mutually exclusive): + - Cursor-based: first/after (forward) or last/before (backward) + - Offset-based: limit/offset + """ + + filter: AppConfigFragmentFilter | None = Field(default=None, description="Filter conditions.") + order: list[AppConfigFragmentOrder] | None = Field( + default=None, description="Order specifications." + ) + first: int | None = Field(default=None, ge=1, description="Number of items from the start.") + after: str | None = Field(default=None, description="Cursor to paginate forward from.") + last: int | None = Field(default=None, ge=1, description="Number of items from the end.") + before: str | None = Field(default=None, description="Cursor to paginate backward from.") + limit: int | None = Field(default=None, ge=1, le=1000, description="Maximum items to return.") + offset: int | None = Field(default=None, ge=0, description="Number of items to skip.") diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py new file mode 100644 index 00000000000..d841e47d74c --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py @@ -0,0 +1,106 @@ +""" +Response DTOs for app_config_fragment DTO v2. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseResponseModel + +from .types import AppConfigScopeType + +__all__ = ( + "AdminBulkCreateAppConfigFragmentsPayload", + "AdminBulkPurgeAppConfigFragmentsPayload", + "AdminBulkUpdateAppConfigFragmentsPayload", + "AppConfigFragmentBulkError", + "AppConfigFragmentNode", + "GetAppConfigFragmentPayload", + "PurgeAppConfigFragmentKey", + "SearchAppConfigFragmentsPayload", +) + + +class AppConfigFragmentNode(BaseResponseModel): + """Node representing a single fragment row (raw per-scope payload).""" + + id: UUID = Field(description="Row ID.") + scope_type: AppConfigScopeType = Field(description="Scope type.") + scope_id: str = Field(description="Scope id.") + name: str = Field(description="Policy name (FK target).") + config: dict[str, Any] | None = Field( + default=None, description="Raw configuration payload, or null." + ) + created_at: datetime = Field(description="Creation timestamp.") + updated_at: datetime | None = Field(default=None, description="Last update timestamp.") + + +class GetAppConfigFragmentPayload(BaseResponseModel): + """Payload returned after reading a single fragment by natural key.""" + + item: AppConfigFragmentNode | None = Field(default=None, description="Fragment data, or null.") + + +class SearchAppConfigFragmentsPayload(BaseResponseModel): + """Payload for paginated fragment search results.""" + + items: list[AppConfigFragmentNode] = Field(description="Fragments matching the filter.") + total_count: int = Field(description="Total number of fragments matching the filter.") + has_next_page: bool = Field(default=False, description="Whether there is a next page.") + has_previous_page: bool = Field(default=False, description="Whether there is a previous page.") + + +# ── Bulk mutation payloads (bulk-only writes) ──────────────────── + + +class AppConfigFragmentBulkError(BaseResponseModel): + """Per-item failure information for bulk Fragment mutations.""" + + index: int = Field(description="Original position in the input list.") + scope_type: AppConfigScopeType = Field(description="Scope type of the failed row.") + scope_id: str = Field(description="Scope id of the failed row.") + name: str = Field(description="Policy name of the failed row.") + message: str = Field(description="Reason for the failure.") + + +class PurgeAppConfigFragmentKey(BaseResponseModel): + """Natural-key identifier returned by bulk purge payloads.""" + + scope_type: AppConfigScopeType = Field(description="Scope type.") + scope_id: str = Field(description="Scope id.") + name: str = Field(description="Policy name.") + + +class AdminBulkCreateAppConfigFragmentsPayload(BaseResponseModel): + """Payload for `adminBulkCreateAppConfigFragments`.""" + + created: list[AppConfigFragmentNode] = Field(description="Created fragments.") + failed: list[AppConfigFragmentBulkError] = Field(description="Per-item failures.") + + +class AdminBulkUpdateAppConfigFragmentsPayload(BaseResponseModel): + """Payload for `adminBulkUpdateAppConfigFragments`.""" + + updated: list[AppConfigFragmentNode] = Field(description="Updated fragments.") + failed: list[AppConfigFragmentBulkError] = Field(description="Per-item failures.") + + +class AdminBulkPurgeAppConfigFragmentsPayload(BaseResponseModel): + """Payload for `adminBulkPurgeAppConfigFragments`.""" + + purged: list[PurgeAppConfigFragmentKey] = Field( + description="Keys of rows actually removed (absent keys are no-oped).", + ) + failed: list[AppConfigFragmentBulkError] = Field(description="Per-item failures.") + + +# `MyBulkCreateAppConfigFragmentsPayload` / `MyBulkUpdateAppConfigFragmentsPayload` +# return recomputed merged `AppConfig` views — they live in +# `common/dto/manager/v2/app_config/response.py` (added with the +# merged-view DTO in the GQL/REST layer) to keep `AppConfigNode` as the +# single source of truth and avoid a circular import. diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/types.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/types.py new file mode 100644 index 00000000000..f90a46f216d --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/types.py @@ -0,0 +1,34 @@ +""" +Common types for app_config_fragment DTO v2. +""" + +from __future__ import annotations + +from enum import StrEnum + +from ai.backend.common.dto.manager.v2.common import OrderDirection + +__all__ = ( + "AppConfigFragmentOrderField", + "AppConfigScopeType", + "OrderDirection", +) + + +class AppConfigScopeType(StrEnum): + """Scope types for app-config fragments.""" + + PUBLIC = "public" + DOMAIN = "domain" + DOMAIN_USER_DEFAULTS = "domain_user_defaults" + USER = "user" + + +class AppConfigFragmentOrderField(StrEnum): + """Fields available for ordering app-config fragments.""" + + SCOPE_TYPE = "scope_type" + SCOPE_ID = "scope_id" + NAME = "name" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment.py b/src/ai/backend/manager/api/adapters/app_config_fragment.py new file mode 100644 index 00000000000..f587579a444 --- /dev/null +++ b/src/ai/backend/manager/api/adapters/app_config_fragment.py @@ -0,0 +1,298 @@ +"""AppConfigFragment domain adapter — Pydantic-in / Pydantic-out transport layer.""" + +from __future__ import annotations + +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminBulkCreateAppConfigFragmentsInput, + AdminBulkPurgeAppConfigFragmentsInput, + AdminBulkUpdateAppConfigFragmentsInput, + AppConfigFragmentFilter, + AppConfigFragmentKeyInput, + AppConfigFragmentOrder, + SearchAppConfigFragmentsInput, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AdminBulkCreateAppConfigFragmentsPayload, + AdminBulkPurgeAppConfigFragmentsPayload, + AdminBulkUpdateAppConfigFragmentsPayload, + AppConfigFragmentBulkError, + AppConfigFragmentNode, + GetAppConfigFragmentPayload, + PurgeAppConfigFragmentKey, + SearchAppConfigFragmentsPayload, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import ( + AppConfigFragmentOrderField, + OrderDirection, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import ( + AppConfigScopeType as DTOAppConfigScopeType, +) +from ai.backend.manager.api.adapter_options.pagination.pagination import PaginationSpec +from ai.backend.manager.data.app_config_fragment.bulk_types import ( + AppConfigFragmentBulkItem, + AppConfigFragmentBulkItemError, +) +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentData, + AppConfigFragmentKey, + AppConfigScopeType, +) +from ai.backend.manager.models.app_config_fragment.conditions import AppConfigFragmentConditions +from ai.backend.manager.models.app_config_fragment.orders import AppConfigFragmentOrders +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.repositories.app_config_fragment.types import ( + AppConfigFragmentSearchScope, +) +from ai.backend.manager.repositories.base import BatchQuerier, QueryCondition, QueryOrder +from ai.backend.manager.services.app_config_fragment.actions.admin_bulk_create import ( + AdminBulkCreateAppConfigFragmentsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.admin_bulk_purge import ( + AdminBulkPurgeAppConfigFragmentsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.admin_bulk_update import ( + AdminBulkUpdateAppConfigFragmentsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.admin_search import ( + AdminSearchAppConfigFragmentsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.get import GetAppConfigFragmentAction +from ai.backend.manager.services.app_config_fragment.actions.search import ( + SearchAppConfigFragmentsAction, +) + +from .base import BaseAdapter + + +class AppConfigFragmentAdapter(BaseAdapter): + """Adapter for AppConfigFragment domain operations. + + Writes are bulk-only; single-item create / update / purge entry + points are intentionally absent. Self-service my_bulk methods are + added with the merged-view DTOs in BA-5829. + """ + + async def get(self, key_input: AppConfigFragmentKeyInput) -> GetAppConfigFragmentPayload: + key = self._input_to_key(key_input) + result = await self._processors.app_config_fragment.get.wait_for_complete( + GetAppConfigFragmentAction(key=key) + ) + return GetAppConfigFragmentPayload( + item=self._data_to_dto(result.fragment) if result.fragment is not None else None, + ) + + async def search( + self, + scope_type: AppConfigScopeType, + scope_id: str, + input: SearchAppConfigFragmentsInput, + ) -> SearchAppConfigFragmentsPayload: + """Scope-bound search — caller pins `(scope_type, scope_id)` so + non-admin users only see fragments within their own scope. + """ + querier = self._build_querier_from_input(input) + result = await self._processors.app_config_fragment.search.wait_for_complete( + SearchAppConfigFragmentsAction( + scope=AppConfigFragmentSearchScope( + scope_type=scope_type, + scope_id=scope_id, + ), + querier=querier, + ) + ) + return SearchAppConfigFragmentsPayload( + items=[self._data_to_dto(item) for item in result.items], + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + async def admin_search( + self, input: SearchAppConfigFragmentsInput + ) -> SearchAppConfigFragmentsPayload: + """Cross-scope admin search — authorization is enforced upstream.""" + querier = self._build_querier_from_input(input) + result = await self._processors.app_config_fragment.admin_search.wait_for_complete( + AdminSearchAppConfigFragmentsAction(querier=querier) + ) + return SearchAppConfigFragmentsPayload( + items=[self._data_to_dto(item) for item in result.items], + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + _PAGINATION_SPEC = PaginationSpec( + forward_order=AppConfigFragmentOrders.created_at(ascending=False), + backward_order=AppConfigFragmentOrders.created_at(ascending=True), + forward_condition_factory=AppConfigFragmentConditions.by_cursor_forward, + backward_condition_factory=AppConfigFragmentConditions.by_cursor_backward, + tiebreaker_order=AppConfigFragmentRow.id.asc(), + ) + + def _build_querier_from_input(self, input: SearchAppConfigFragmentsInput) -> BatchQuerier: + conditions = self._convert_filter(input.filter) if input.filter else [] + orders = self._convert_orders(input.order) if input.order else [] + return self._build_querier( + conditions=conditions, + orders=orders, + pagination_spec=self._PAGINATION_SPEC, + first=input.first, + after=input.after, + last=input.last, + before=input.before, + limit=input.limit, + offset=input.offset, + ) + + def _convert_filter(self, filter: AppConfigFragmentFilter) -> list[QueryCondition]: + conditions: list[QueryCondition] = [] + if filter.id is not None: + condition = self.convert_uuid_filter( + filter.id, + equals_factory=AppConfigFragmentConditions.by_id_equals, + in_factory=AppConfigFragmentConditions.by_id_in, + ) + if condition is not None: + conditions.append(condition) + if filter.name is not None: + condition = self.convert_string_filter( + filter.name, + contains_factory=AppConfigFragmentConditions.by_name_contains, + equals_factory=AppConfigFragmentConditions.by_name_equals, + starts_with_factory=AppConfigFragmentConditions.by_name_starts_with, + ends_with_factory=AppConfigFragmentConditions.by_name_ends_with, + in_factory=AppConfigFragmentConditions.by_name_in, + ) + if condition is not None: + conditions.append(condition) + if filter.scope_type is not None: + conditions.append( + AppConfigFragmentConditions.by_scope_type_equals(filter.scope_type.value) + ) + if filter.scope_id is not None: + condition = self.convert_string_filter( + filter.scope_id, + contains_factory=AppConfigFragmentConditions.by_scope_id_contains, + equals_factory=AppConfigFragmentConditions.by_scope_id_equals, + starts_with_factory=AppConfigFragmentConditions.by_scope_id_starts_with, + ends_with_factory=AppConfigFragmentConditions.by_scope_id_ends_with, + in_factory=AppConfigFragmentConditions.by_scope_id_in, + ) + if condition is not None: + conditions.append(condition) + return conditions + + @staticmethod + def _convert_orders(orders: list[AppConfigFragmentOrder]) -> list[QueryOrder]: + result: list[QueryOrder] = [] + for order in orders: + ascending = order.direction == OrderDirection.ASC + match order.field: + case AppConfigFragmentOrderField.SCOPE_TYPE: + result.append(AppConfigFragmentOrders.scope_type(ascending)) + case AppConfigFragmentOrderField.SCOPE_ID: + result.append(AppConfigFragmentOrders.scope_id(ascending)) + case AppConfigFragmentOrderField.NAME: + result.append(AppConfigFragmentOrders.name(ascending)) + case AppConfigFragmentOrderField.CREATED_AT: + result.append(AppConfigFragmentOrders.created_at(ascending)) + case AppConfigFragmentOrderField.UPDATED_AT: + result.append(AppConfigFragmentOrders.updated_at(ascending)) + return result + + @staticmethod + def _input_to_key(key_input: AppConfigFragmentKeyInput) -> AppConfigFragmentKey: + return AppConfigFragmentKey( + scope_type=AppConfigScopeType(key_input.scope_type.value), + scope_id=key_input.scope_id, + name=key_input.name, + ) + + @staticmethod + def _data_to_dto(data: AppConfigFragmentData) -> AppConfigFragmentNode: + return AppConfigFragmentNode( + id=data.id, + scope_type=DTOAppConfigScopeType(data.scope_type.value), + scope_id=data.scope_id, + name=data.name, + config=dict(data.config) if data.config is not None else None, + created_at=data.created_at, + updated_at=data.updated_at, + ) + + # ── Bulk mutations ───────────────────────────────────────────── + # + # Each bulk processor returns a `BulkProcessResult[T]` whose + # `.result` field is the underlying `*ActionResult` produced by the + # service. We discard the validator-decision trail here — RBAC + # reasons travel back through the per-item `failed` list. + + async def admin_bulk_create( + self, input: AdminBulkCreateAppConfigFragmentsInput + ) -> AdminBulkCreateAppConfigFragmentsPayload: + items = [ + AppConfigFragmentBulkItem( + key=self._input_to_key(item.key), + config=dict(item.config), + ) + for item in input.items + ] + wrapper = await self._processors.app_config_fragment.admin_bulk_create.wait_for_complete( + AdminBulkCreateAppConfigFragmentsAction(entity_ids=[], items=items) + ) + result = wrapper.result + return AdminBulkCreateAppConfigFragmentsPayload( + created=[self._data_to_dto(fragment) for fragment in result.created], + failed=[self._bulk_error_to_dto(err) for err in result.failed], + ) + + async def admin_bulk_update( + self, input: AdminBulkUpdateAppConfigFragmentsInput + ) -> AdminBulkUpdateAppConfigFragmentsPayload: + items = [ + AppConfigFragmentBulkItem( + key=self._input_to_key(item.key), + config=dict(item.config), + ) + for item in input.items + ] + wrapper = await self._processors.app_config_fragment.admin_bulk_update.wait_for_complete( + AdminBulkUpdateAppConfigFragmentsAction(entity_ids=[], items=items) + ) + result = wrapper.result + return AdminBulkUpdateAppConfigFragmentsPayload( + updated=[self._data_to_dto(fragment) for fragment in result.updated], + failed=[self._bulk_error_to_dto(err) for err in result.failed], + ) + + async def admin_bulk_purge( + self, input: AdminBulkPurgeAppConfigFragmentsInput + ) -> AdminBulkPurgeAppConfigFragmentsPayload: + keys = [self._input_to_key(key) for key in input.keys] + wrapper = await self._processors.app_config_fragment.admin_bulk_purge.wait_for_complete( + AdminBulkPurgeAppConfigFragmentsAction(entity_ids=[], keys=keys) + ) + result = wrapper.result + return AdminBulkPurgeAppConfigFragmentsPayload( + purged=[ + PurgeAppConfigFragmentKey( + scope_type=DTOAppConfigScopeType(key.scope_type.value), + scope_id=key.scope_id, + name=key.name, + ) + for key in result.purged + ], + failed=[self._bulk_error_to_dto(err) for err in result.failed], + ) + + @staticmethod + def _bulk_error_to_dto(err: AppConfigFragmentBulkItemError) -> AppConfigFragmentBulkError: + return AppConfigFragmentBulkError( + index=err.index, + scope_type=DTOAppConfigScopeType(err.scope_type), + scope_id=err.scope_id, + name=err.name, + message=err.message, + ) diff --git a/src/ai/backend/manager/api/adapters/registry.py b/src/ai/backend/manager/api/adapters/registry.py index 7e69c345b1e..460ff3f89ad 100644 --- a/src/ai/backend/manager/api/adapters/registry.py +++ b/src/ai/backend/manager/api/adapters/registry.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from ai.backend.manager.api.adapters.agent.adapter import AgentAdapter +from ai.backend.manager.api.adapters.app_config_fragment import AppConfigFragmentAdapter from ai.backend.manager.api.adapters.app_config_policy import AppConfigPolicyAdapter from ai.backend.manager.api.adapters.artifact.adapter import ArtifactAdapter from ai.backend.manager.api.adapters.artifact_registry.adapter import ArtifactRegistryAdapter @@ -72,6 +73,7 @@ class Adapters: def __init__( self, agent: AgentAdapter, + app_config_fragment: AppConfigFragmentAdapter, app_config_policy: AppConfigPolicyAdapter, artifact: ArtifactAdapter, artifact_registry: ArtifactRegistryAdapter, @@ -113,6 +115,7 @@ def __init__( vfs_storage: VFSStorageAdapter, ) -> None: self.agent = agent + self.app_config_fragment = app_config_fragment self.app_config_policy = app_config_policy self.artifact = artifact self.artifact_registry = artifact_registry @@ -173,6 +176,7 @@ def create( """ return cls( agent=AgentAdapter(processors), + app_config_fragment=AppConfigFragmentAdapter(processors), app_config_policy=AppConfigPolicyAdapter(processors), artifact=ArtifactAdapter(processors), artifact_registry=ArtifactRegistryAdapter(processors), diff --git a/src/ai/backend/manager/data/app_config_fragment/bulk_types.py b/src/ai/backend/manager/data/app_config_fragment/bulk_types.py new file mode 100644 index 00000000000..d2f82cd4cfc --- /dev/null +++ b/src/ai/backend/manager/data/app_config_fragment/bulk_types.py @@ -0,0 +1,44 @@ +"""Bulk-mutation service-layer dataclasses for app_config_fragments.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentKey + + +@dataclass(frozen=True) +class AppConfigFragmentBulkItem: + """One item for `adminBulkCreate/Update` — natural key + payload.""" + + key: AppConfigFragmentKey + config: Mapping[str, Any] + + +@dataclass(frozen=True) +class MyAppConfigFragmentBulkItem: + """One item for `bulkCreate/UpdateMy` — `name` + payload. + + `scope_type` is always `USER` and `scope_id` is resolved from the + current user at the adapter layer. + """ + + name: str + config: Mapping[str, Any] + + +@dataclass(frozen=True) +class AppConfigFragmentBulkItemError: + """Per-item failure carried through bulk action results. + + `scope_type` / `scope_id` / `name` identify which input row failed; + `index` preserves the caller's original list position. + """ + + index: int + scope_type: str + scope_id: str + name: str + message: str diff --git a/src/ai/backend/manager/models/app_config_fragment/conditions.py b/src/ai/backend/manager/models/app_config_fragment/conditions.py new file mode 100644 index 00000000000..4a8de071906 --- /dev/null +++ b/src/ai/backend/manager/models/app_config_fragment/conditions.py @@ -0,0 +1,191 @@ +"""Query conditions for the app_config_fragment domain.""" + +from __future__ import annotations + +import uuid +from collections.abc import Collection + +import sqlalchemy as sa + +from ai.backend.common.data.filter_specs import ( + StringMatchSpec, + UUIDEqualMatchSpec, + UUIDInMatchSpec, +) +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.models.condition_utils import make_string_in_factory +from ai.backend.manager.repositories.base import QueryCondition + + +class AppConfigFragmentConditions: + """QueryCondition factories for app-config fragment filtering.""" + + @staticmethod + def by_ids(fragment_ids: Collection[uuid.UUID]) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + return AppConfigFragmentRow.id.in_(fragment_ids) + + return inner + + @staticmethod + def by_id_equals(spec: UUIDEqualMatchSpec) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + condition = AppConfigFragmentRow.id == spec.value + if spec.negated: + condition = sa.not_(condition) + return condition + + return inner + + @staticmethod + def by_id_in(spec: UUIDInMatchSpec) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + condition = AppConfigFragmentRow.id.in_(spec.values) + if spec.negated: + condition = sa.not_(condition) + return condition + + return inner + + @staticmethod + def by_name_contains(spec: StringMatchSpec) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + if spec.case_insensitive: + condition = AppConfigFragmentRow.name.ilike(f"%{spec.value}%") + else: + condition = AppConfigFragmentRow.name.like(f"%{spec.value}%") + if spec.negated: + condition = sa.not_(condition) + return condition + + return inner + + @staticmethod + def by_name_equals(spec: StringMatchSpec) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + if spec.case_insensitive: + condition = sa.func.lower(AppConfigFragmentRow.name) == spec.value.lower() + else: + condition = AppConfigFragmentRow.name == spec.value + if spec.negated: + condition = sa.not_(condition) + return condition + + return inner + + @staticmethod + def by_name_starts_with(spec: StringMatchSpec) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + if spec.case_insensitive: + condition = AppConfigFragmentRow.name.ilike(f"{spec.value}%") + else: + condition = AppConfigFragmentRow.name.like(f"{spec.value}%") + if spec.negated: + condition = sa.not_(condition) + return condition + + return inner + + @staticmethod + def by_name_ends_with(spec: StringMatchSpec) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + if spec.case_insensitive: + condition = AppConfigFragmentRow.name.ilike(f"%{spec.value}") + else: + condition = AppConfigFragmentRow.name.like(f"%{spec.value}") + if spec.negated: + condition = sa.not_(condition) + return condition + + return inner + + by_name_in = staticmethod(make_string_in_factory(AppConfigFragmentRow.name)) + + @staticmethod + def by_scope_id_contains(spec: StringMatchSpec) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + if spec.case_insensitive: + condition = AppConfigFragmentRow.scope_id.ilike(f"%{spec.value}%") + else: + condition = AppConfigFragmentRow.scope_id.like(f"%{spec.value}%") + if spec.negated: + condition = sa.not_(condition) + return condition + + return inner + + @staticmethod + def by_scope_id_equals(spec: StringMatchSpec) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + if spec.case_insensitive: + condition = sa.func.lower(AppConfigFragmentRow.scope_id) == spec.value.lower() + else: + condition = AppConfigFragmentRow.scope_id == spec.value + if spec.negated: + condition = sa.not_(condition) + return condition + + return inner + + @staticmethod + def by_scope_id_starts_with(spec: StringMatchSpec) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + if spec.case_insensitive: + condition = AppConfigFragmentRow.scope_id.ilike(f"{spec.value}%") + else: + condition = AppConfigFragmentRow.scope_id.like(f"{spec.value}%") + if spec.negated: + condition = sa.not_(condition) + return condition + + return inner + + @staticmethod + def by_scope_id_ends_with(spec: StringMatchSpec) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + if spec.case_insensitive: + condition = AppConfigFragmentRow.scope_id.ilike(f"%{spec.value}") + else: + condition = AppConfigFragmentRow.scope_id.like(f"%{spec.value}") + if spec.negated: + condition = sa.not_(condition) + return condition + + return inner + + by_scope_id_in = staticmethod(make_string_in_factory(AppConfigFragmentRow.scope_id)) + + @staticmethod + def by_scope_type_equals(scope_type: str) -> QueryCondition: + def inner() -> sa.ColumnElement[bool]: + return AppConfigFragmentRow.scope_type == scope_type + + return inner + + @staticmethod + def by_cursor_forward(cursor_id: str) -> QueryCondition: + cursor_uuid = uuid.UUID(cursor_id) + + def inner() -> sa.ColumnElement[bool]: + subquery = ( + sa.select(AppConfigFragmentRow.created_at) + .where(AppConfigFragmentRow.id == cursor_uuid) + .scalar_subquery() + ) + return AppConfigFragmentRow.created_at < subquery + + return inner + + @staticmethod + def by_cursor_backward(cursor_id: str) -> QueryCondition: + cursor_uuid = uuid.UUID(cursor_id) + + def inner() -> sa.ColumnElement[bool]: + subquery = ( + sa.select(AppConfigFragmentRow.created_at) + .where(AppConfigFragmentRow.id == cursor_uuid) + .scalar_subquery() + ) + return AppConfigFragmentRow.created_at > subquery + + return inner diff --git a/src/ai/backend/manager/models/app_config_fragment/orders.py b/src/ai/backend/manager/models/app_config_fragment/orders.py new file mode 100644 index 00000000000..13756845b93 --- /dev/null +++ b/src/ai/backend/manager/models/app_config_fragment/orders.py @@ -0,0 +1,40 @@ +"""Query orders for the app_config_fragment domain.""" + +from __future__ import annotations + +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.repositories.base import QueryOrder + + +class AppConfigFragmentOrders: + """QueryOrder factories for app-config fragment sorting.""" + + @staticmethod + def scope_type(ascending: bool = True) -> QueryOrder: + if ascending: + return AppConfigFragmentRow.scope_type.asc() + return AppConfigFragmentRow.scope_type.desc() + + @staticmethod + def scope_id(ascending: bool = True) -> QueryOrder: + if ascending: + return AppConfigFragmentRow.scope_id.asc() + return AppConfigFragmentRow.scope_id.desc() + + @staticmethod + def name(ascending: bool = True) -> QueryOrder: + if ascending: + return AppConfigFragmentRow.name.asc() + return AppConfigFragmentRow.name.desc() + + @staticmethod + def created_at(ascending: bool = True) -> QueryOrder: + if ascending: + return AppConfigFragmentRow.created_at.asc() + return AppConfigFragmentRow.created_at.desc() + + @staticmethod + def updated_at(ascending: bool = True) -> QueryOrder: + if ascending: + return AppConfigFragmentRow.updated_at.asc() + return AppConfigFragmentRow.updated_at.desc() diff --git a/src/ai/backend/manager/services/app_config_fragment/__init__.py b/src/ai/backend/manager/services/app_config_fragment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/__init__.py b/src/ai/backend/manager/services/app_config_fragment/actions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/admin_bulk_create.py b/src/ai/backend/manager/services/app_config_fragment/actions/admin_bulk_create.py new file mode 100644 index 00000000000..decca8f3758 --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/admin_bulk_create.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import override + +from ai.backend.common.data.permission.types import EntityType +from ai.backend.manager.actions.action.bulk import BaseBulkAction, BaseBulkActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config_fragment.bulk_types import ( + AppConfigFragmentBulkItem, + AppConfigFragmentBulkItemError, +) +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData + + +@dataclass +class AdminBulkCreateAppConfigFragmentsAction(BaseBulkAction[AppConfigFragmentBulkItem]): + """Bulk-create rows. `items` carries the per-item payloads. + + `entity_ids` is empty: row ids do not exist at action-creation time + and we do not substitute the natural key (the natural key is not + what the framework's RBAC validators expect). Validators that need + to filter creates would have to operate on `items` directly. + """ + + items: list[AppConfigFragmentBulkItem] = field(default_factory=list) + + @override + def typed_entity_ids(self) -> list[AppConfigFragmentBulkItem]: + return list(self.items) + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.APP_CONFIG + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.CREATE + + +@dataclass +class AdminBulkCreateAppConfigFragmentsActionResult(BaseBulkActionResult): + created: list[AppConfigFragmentData] + failed: list[AppConfigFragmentBulkItemError] + + @override + def entity_ids(self) -> list[str]: + return [str(fragment.id) for fragment in self.created] diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/admin_bulk_purge.py b/src/ai/backend/manager/services/app_config_fragment/actions/admin_bulk_purge.py new file mode 100644 index 00000000000..f92bd8e674c --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/admin_bulk_purge.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import override + +from ai.backend.common.data.permission.types import EntityType +from ai.backend.manager.actions.action.bulk import BaseBulkAction, BaseBulkActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config_fragment.bulk_types import ( + AppConfigFragmentBulkItemError, +) +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentKey + + +@dataclass +class AdminBulkPurgeAppConfigFragmentsAction(BaseBulkAction[AppConfigFragmentKey]): + """`keys` carries the parsed natural keys. + + `entity_ids` is empty — see + `AdminBulkCreateAppConfigFragmentsAction` for the convention. + """ + + keys: list[AppConfigFragmentKey] = field(default_factory=list) + + @override + def typed_entity_ids(self) -> list[AppConfigFragmentKey]: + return list(self.keys) + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.APP_CONFIG + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.PURGE + + +@dataclass +class AdminBulkPurgeAppConfigFragmentsActionResult(BaseBulkActionResult): + purged: list[AppConfigFragmentKey] + failed: list[AppConfigFragmentBulkItemError] + + @override + def entity_ids(self) -> list[str]: + return [] diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/admin_bulk_update.py b/src/ai/backend/manager/services/app_config_fragment/actions/admin_bulk_update.py new file mode 100644 index 00000000000..31c678f0f19 --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/admin_bulk_update.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import override + +from ai.backend.common.data.permission.types import EntityType +from ai.backend.manager.actions.action.bulk import BaseBulkAction, BaseBulkActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config_fragment.bulk_types import ( + AppConfigFragmentBulkItem, + AppConfigFragmentBulkItemError, +) +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData + + +@dataclass +class AdminBulkUpdateAppConfigFragmentsAction(BaseBulkAction[AppConfigFragmentBulkItem]): + """See `AdminBulkCreateAppConfigFragmentsAction` for the + `entity_ids` / `items` convention.""" + + items: list[AppConfigFragmentBulkItem] = field(default_factory=list) + + @override + def typed_entity_ids(self) -> list[AppConfigFragmentBulkItem]: + return list(self.items) + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.APP_CONFIG + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.UPDATE + + +@dataclass +class AdminBulkUpdateAppConfigFragmentsActionResult(BaseBulkActionResult): + updated: list[AppConfigFragmentData] + failed: list[AppConfigFragmentBulkItemError] + + @override + def entity_ids(self) -> list[str]: + return [str(fragment.id) for fragment in self.updated] diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/admin_search.py b/src/ai/backend/manager/services/app_config_fragment/actions/admin_search.py new file mode 100644 index 00000000000..7fcd3b543e9 --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/admin_search.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData +from ai.backend.manager.repositories.base import BatchQuerier +from ai.backend.manager.services.app_config_fragment.actions.base import AppConfigFragmentAction + + +@dataclass +class AdminSearchAppConfigFragmentsAction(AppConfigFragmentAction): + """Cross-scope raw-row search (admin only).""" + + querier: BatchQuerier + + @override + def entity_id(self) -> str | None: + return None + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.SEARCH + + +@dataclass +class AdminSearchAppConfigFragmentsActionResult(BaseActionResult): + items: list[AppConfigFragmentData] + total_count: int + has_next_page: bool + has_previous_page: bool + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/base.py b/src/ai/backend/manager/services/app_config_fragment/actions/base.py new file mode 100644 index 00000000000..54a8e99ba86 --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/base.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from typing import override + +from ai.backend.common.data.permission.types import EntityType +from ai.backend.manager.actions.action import BaseAction + + +@dataclass +class AppConfigFragmentAction(BaseAction): + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.APP_CONFIG diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/get.py b/src/ai/backend/manager/services/app_config_fragment/actions/get.py new file mode 100644 index 00000000000..380586f6a34 --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/get.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentData, + AppConfigFragmentKey, +) +from ai.backend.manager.services.app_config_fragment.actions.base import AppConfigFragmentAction + + +@dataclass +class GetAppConfigFragmentAction(AppConfigFragmentAction): + key: AppConfigFragmentKey + + @override + def entity_id(self) -> str | None: + # Row id is not known at action time (lookup is by natural key). + return None + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.GET + + +@dataclass +class GetAppConfigFragmentActionResult(BaseActionResult): + fragment: AppConfigFragmentData | None + + @override + def entity_id(self) -> str | None: + if self.fragment is None: + return None + return str(self.fragment.id) diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/my_bulk_create.py b/src/ai/backend/manager/services/app_config_fragment/actions/my_bulk_create.py new file mode 100644 index 00000000000..496211e65ce --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/my_bulk_create.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import override + +from ai.backend.common.data.permission.types import EntityType +from ai.backend.manager.actions.action.bulk import BaseBulkAction, BaseBulkActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.app_config_fragment.bulk_types import ( + AppConfigFragmentBulkItemError, + MyAppConfigFragmentBulkItem, +) + + +@dataclass +class MyBulkCreateAppConfigFragmentsAction(BaseBulkAction[MyAppConfigFragmentBulkItem]): + """Self-service bulk create — scope is `USER` / `current_user.user_id`. + + The owning `user_id` is resolved from the request `ContextVar` + (`current_user()`) inside the service, never carried on the action. + `entity_ids` carries the per-item `name`s (USER-scope, so name + alone is unique inside a single user). + """ + + items: list[MyAppConfigFragmentBulkItem] = field(default_factory=list) + + @override + def typed_entity_ids(self) -> list[MyAppConfigFragmentBulkItem]: + return list(self.items) + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.APP_CONFIG + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.CREATE + + +@dataclass +class MyBulkCreateAppConfigFragmentsActionResult(BaseBulkActionResult): + """`created` carries the recomputed merged view per successfully + created fragment; `failed` carries per-item errors. + """ + + created: list[AppConfigData] + failed: list[AppConfigFragmentBulkItemError] + + @override + def entity_ids(self) -> list[str]: + return [item.name for item in self.created] diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/my_bulk_update.py b/src/ai/backend/manager/services/app_config_fragment/actions/my_bulk_update.py new file mode 100644 index 00000000000..d7e3418734c --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/my_bulk_update.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import override + +from ai.backend.common.data.permission.types import EntityType +from ai.backend.manager.actions.action.bulk import BaseBulkAction, BaseBulkActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.app_config_fragment.bulk_types import ( + AppConfigFragmentBulkItemError, + MyAppConfigFragmentBulkItem, +) + + +@dataclass +class MyBulkUpdateAppConfigFragmentsAction(BaseBulkAction[MyAppConfigFragmentBulkItem]): + """Self-service bulk update — see `BulkCreateMyAppConfigFragmentsAction`.""" + + items: list[MyAppConfigFragmentBulkItem] = field(default_factory=list) + + @override + def typed_entity_ids(self) -> list[MyAppConfigFragmentBulkItem]: + return list(self.items) + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.APP_CONFIG + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.UPDATE + + +@dataclass +class MyBulkUpdateAppConfigFragmentsActionResult(BaseBulkActionResult): + updated: list[AppConfigData] + failed: list[AppConfigFragmentBulkItemError] + + @override + def entity_ids(self) -> list[str]: + return [item.name for item in self.updated] diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/search.py b/src/ai/backend/manager/services/app_config_fragment/actions/search.py new file mode 100644 index 00000000000..c4d7f5ece4d --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/search.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData +from ai.backend.manager.repositories.app_config_fragment.types import ( + AppConfigFragmentSearchScope, +) +from ai.backend.manager.repositories.base import BatchQuerier +from ai.backend.manager.services.app_config_fragment.actions.base import AppConfigFragmentAction + + +@dataclass +class SearchAppConfigFragmentsAction(AppConfigFragmentAction): + """Scope-bound raw-row search (single `(scope_type, scope_id)` slice).""" + + scope: AppConfigFragmentSearchScope + querier: BatchQuerier + + @override + def entity_id(self) -> str | None: + return None + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.SEARCH + + +@dataclass +class SearchAppConfigFragmentsActionResult(BaseActionResult): + items: list[AppConfigFragmentData] + total_count: int + has_next_page: bool + has_previous_page: bool + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/app_config_fragment/processors.py b/src/ai/backend/manager/services/app_config_fragment/processors.py new file mode 100644 index 00000000000..616a7dd4dac --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/processors.py @@ -0,0 +1,95 @@ +from typing import override + +from ai.backend.manager.actions.monitors.monitor import ActionMonitor +from ai.backend.manager.actions.processor import ActionProcessor +from ai.backend.manager.actions.processor.bulk import BulkActionProcessor +from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec +from ai.backend.manager.actions.validators import ActionValidators +from ai.backend.manager.services.app_config_fragment.actions.admin_bulk_create import ( + AdminBulkCreateAppConfigFragmentsAction, + AdminBulkCreateAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.admin_bulk_purge import ( + AdminBulkPurgeAppConfigFragmentsAction, + AdminBulkPurgeAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.admin_bulk_update import ( + AdminBulkUpdateAppConfigFragmentsAction, + AdminBulkUpdateAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.admin_search import ( + AdminSearchAppConfigFragmentsAction, + AdminSearchAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.get import ( + GetAppConfigFragmentAction, + GetAppConfigFragmentActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.my_bulk_create import ( + MyBulkCreateAppConfigFragmentsAction, + MyBulkCreateAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.my_bulk_update import ( + MyBulkUpdateAppConfigFragmentsAction, + MyBulkUpdateAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.search import ( + SearchAppConfigFragmentsAction, + SearchAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.service import AppConfigFragmentService + + +class AppConfigFragmentProcessors(AbstractProcessorPackage): + get: ActionProcessor[GetAppConfigFragmentAction, GetAppConfigFragmentActionResult] + search: ActionProcessor[SearchAppConfigFragmentsAction, SearchAppConfigFragmentsActionResult] + admin_search: ActionProcessor[ + AdminSearchAppConfigFragmentsAction, AdminSearchAppConfigFragmentsActionResult + ] + # Bulk mutations — wrapped by BulkActionProcessor so validators + # (RBAC, etc.) can filter entity_ids per-item before the service + # runs. No bulk validators are wired today; the processor simply + # forwards to the service. + admin_bulk_create: BulkActionProcessor[ + AdminBulkCreateAppConfigFragmentsAction, AdminBulkCreateAppConfigFragmentsActionResult + ] + admin_bulk_update: BulkActionProcessor[ + AdminBulkUpdateAppConfigFragmentsAction, AdminBulkUpdateAppConfigFragmentsActionResult + ] + admin_bulk_purge: BulkActionProcessor[ + AdminBulkPurgeAppConfigFragmentsAction, AdminBulkPurgeAppConfigFragmentsActionResult + ] + my_bulk_create: BulkActionProcessor[ + MyBulkCreateAppConfigFragmentsAction, MyBulkCreateAppConfigFragmentsActionResult + ] + my_bulk_update: BulkActionProcessor[ + MyBulkUpdateAppConfigFragmentsAction, MyBulkUpdateAppConfigFragmentsActionResult + ] + + def __init__( + self, + service: AppConfigFragmentService, + action_monitors: list[ActionMonitor], + validators: ActionValidators, + ) -> None: + self.get = ActionProcessor(service.get, action_monitors) + self.search = ActionProcessor(service.search, action_monitors) + self.admin_search = ActionProcessor(service.admin_search, action_monitors) + self.admin_bulk_create = BulkActionProcessor(service.admin_bulk_create, action_monitors) + self.admin_bulk_update = BulkActionProcessor(service.admin_bulk_update, action_monitors) + self.admin_bulk_purge = BulkActionProcessor(service.admin_bulk_purge, action_monitors) + self.my_bulk_create = BulkActionProcessor(service.my_bulk_create, action_monitors) + self.my_bulk_update = BulkActionProcessor(service.my_bulk_update, action_monitors) + + @override + def supported_actions(self) -> list[ActionSpec]: + return [ + GetAppConfigFragmentAction.spec(), + SearchAppConfigFragmentsAction.spec(), + AdminSearchAppConfigFragmentsAction.spec(), + AdminBulkCreateAppConfigFragmentsAction.spec(), + AdminBulkUpdateAppConfigFragmentsAction.spec(), + AdminBulkPurgeAppConfigFragmentsAction.spec(), + MyBulkCreateAppConfigFragmentsAction.spec(), + MyBulkUpdateAppConfigFragmentsAction.spec(), + ] diff --git a/src/ai/backend/manager/services/app_config_fragment/service.py b/src/ai/backend/manager/services/app_config_fragment/service.py new file mode 100644 index 00000000000..91bc46be13f --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/service.py @@ -0,0 +1,236 @@ +import logging + +from ai.backend.common.contexts.user import current_user +from ai.backend.common.exception import UnreachableError +from ai.backend.logging import BraceStyleAdapter +from ai.backend.manager.data.app_config_fragment.bulk_types import ( + AppConfigFragmentBulkItemError, +) +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentKey, + AppConfigScopeType, +) +from ai.backend.manager.repositories.app_config_fragment.admin_repository import ( + AppConfigFragmentAdminRepository, +) +from ai.backend.manager.repositories.app_config_fragment.repository import ( + AppConfigFragmentRepository, +) +from ai.backend.manager.services.app_config_fragment.actions.admin_bulk_create import ( + AdminBulkCreateAppConfigFragmentsAction, + AdminBulkCreateAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.admin_bulk_purge import ( + AdminBulkPurgeAppConfigFragmentsAction, + AdminBulkPurgeAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.admin_bulk_update import ( + AdminBulkUpdateAppConfigFragmentsAction, + AdminBulkUpdateAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.admin_search import ( + AdminSearchAppConfigFragmentsAction, + AdminSearchAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.get import ( + GetAppConfigFragmentAction, + GetAppConfigFragmentActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.my_bulk_create import ( + MyBulkCreateAppConfigFragmentsAction, + MyBulkCreateAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.my_bulk_update import ( + MyBulkUpdateAppConfigFragmentsAction, + MyBulkUpdateAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.search import ( + SearchAppConfigFragmentsAction, + SearchAppConfigFragmentsActionResult, +) + +log = BraceStyleAdapter(logging.getLogger(__spec__.name)) + + +class AppConfigFragmentService: + _repository: AppConfigFragmentRepository + _admin_repository: AppConfigFragmentAdminRepository + + def __init__( + self, + repository: AppConfigFragmentRepository, + admin_repository: AppConfigFragmentAdminRepository, + ) -> None: + self._repository = repository + self._admin_repository = admin_repository + + async def get(self, action: GetAppConfigFragmentAction) -> GetAppConfigFragmentActionResult: + fragment = await self._repository.get(action.key) + return GetAppConfigFragmentActionResult(fragment=fragment) + + async def search( + self, action: SearchAppConfigFragmentsAction + ) -> SearchAppConfigFragmentsActionResult: + result = await self._repository.search(action.scope, action.querier) + return SearchAppConfigFragmentsActionResult( + items=result.items, + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + async def admin_search( + self, action: AdminSearchAppConfigFragmentsAction + ) -> AdminSearchAppConfigFragmentsActionResult: + result = await self._admin_repository.admin_search(action.querier) + return AdminSearchAppConfigFragmentsActionResult( + items=result.items, + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + # ── Bulk mutations (per-item transaction) ───────────────────── + + async def admin_bulk_create( + self, action: AdminBulkCreateAppConfigFragmentsAction + ) -> AdminBulkCreateAppConfigFragmentsActionResult: + """Strict insert across any scope; each item in its own + transaction so failures are collected per-item.""" + created = [] + failed: list[AppConfigFragmentBulkItemError] = [] + for index, item in enumerate(action.items): + try: + fragment = await self._admin_repository.create(item.key, item.config) + created.append(fragment) + except Exception as e: + log.warning("admin_bulk_create item {} failed: {}", index, e) + failed.append( + AppConfigFragmentBulkItemError( + index=index, + scope_type=item.key.scope_type.value, + scope_id=item.key.scope_id, + name=item.key.name, + message=str(e), + ) + ) + return AdminBulkCreateAppConfigFragmentsActionResult(created=created, failed=failed) + + async def admin_bulk_update( + self, action: AdminBulkUpdateAppConfigFragmentsAction + ) -> AdminBulkUpdateAppConfigFragmentsActionResult: + """Wholesale JSON replacement; items without an existing row + are collected as failures (not auto-inserted).""" + updated = [] + failed: list[AppConfigFragmentBulkItemError] = [] + for index, item in enumerate(action.items): + try: + fragment = await self._admin_repository.update(item.key, item.config) + updated.append(fragment) + except Exception as e: + log.warning("admin_bulk_update item {} failed: {}", index, e) + failed.append( + AppConfigFragmentBulkItemError( + index=index, + scope_type=item.key.scope_type.value, + scope_id=item.key.scope_id, + name=item.key.name, + message=str(e), + ) + ) + return AdminBulkUpdateAppConfigFragmentsActionResult(updated=updated, failed=failed) + + async def admin_bulk_purge( + self, action: AdminBulkPurgeAppConfigFragmentsAction + ) -> AdminBulkPurgeAppConfigFragmentsActionResult: + """Cleanup-only deletion; absent keys are no-oped.""" + purged: list[AppConfigFragmentKey] = [] + failed: list[AppConfigFragmentBulkItemError] = [] + for index, key in enumerate(action.keys): + try: + ok = await self._admin_repository.purge(key) + if ok: + purged.append(key) + # Absent keys are intentionally no-oped (no failure entry). + except Exception as e: + log.warning("admin_bulk_purge item {} failed: {}", index, e) + failed.append( + AppConfigFragmentBulkItemError( + index=index, + scope_type=key.scope_type.value, + scope_id=key.scope_id, + name=key.name, + message=str(e), + ) + ) + return AdminBulkPurgeAppConfigFragmentsActionResult(purged=purged, failed=failed) + + async def my_bulk_create( + self, action: MyBulkCreateAppConfigFragmentsAction + ) -> MyBulkCreateAppConfigFragmentsActionResult: + """Self-service bulk create on the caller's `USER` row; each + success recomputes the merged `AppConfig` view. + """ + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + user_id = me.user_id + user_id_str = str(user_id) + created = [] + failed: list[AppConfigFragmentBulkItemError] = [] + for index, item in enumerate(action.items): + key = AppConfigFragmentKey( + scope_type=AppConfigScopeType.USER, + scope_id=user_id_str, + name=item.name, + ) + try: + await self._admin_repository.create(key, item.config) + merged = await self._repository.app_config(user_id, item.name) + created.append(merged) + except Exception as e: + log.warning("my_bulk_create item {} failed: {}", index, e) + failed.append( + AppConfigFragmentBulkItemError( + index=index, + scope_type=AppConfigScopeType.USER.value, + scope_id=user_id_str, + name=item.name, + message=str(e), + ) + ) + return MyBulkCreateAppConfigFragmentsActionResult(created=created, failed=failed) + + async def my_bulk_update( + self, action: MyBulkUpdateAppConfigFragmentsAction + ) -> MyBulkUpdateAppConfigFragmentsActionResult: + """Self-service bulk update on the caller's `USER` row.""" + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + user_id = me.user_id + user_id_str = str(user_id) + updated = [] + failed: list[AppConfigFragmentBulkItemError] = [] + for index, item in enumerate(action.items): + key = AppConfigFragmentKey( + scope_type=AppConfigScopeType.USER, + scope_id=user_id_str, + name=item.name, + ) + try: + await self._admin_repository.update(key, item.config) + merged = await self._repository.app_config(user_id, item.name) + updated.append(merged) + except Exception as e: + log.warning("my_bulk_update item {} failed: {}", index, e) + failed.append( + AppConfigFragmentBulkItemError( + index=index, + scope_type=AppConfigScopeType.USER.value, + scope_id=user_id_str, + name=item.name, + message=str(e), + ) + ) + return MyBulkUpdateAppConfigFragmentsActionResult(updated=updated, failed=failed) diff --git a/src/ai/backend/manager/services/factory.py b/src/ai/backend/manager/services/factory.py index abb04d5c3a7..489404cf1e4 100644 --- a/src/ai/backend/manager/services/factory.py +++ b/src/ai/backend/manager/services/factory.py @@ -6,6 +6,8 @@ ) from ai.backend.manager.services.agent.processors import AgentProcessors from ai.backend.manager.services.agent.service import AgentService +from ai.backend.manager.services.app_config_fragment.processors import AppConfigFragmentProcessors +from ai.backend.manager.services.app_config_fragment.service import AppConfigFragmentService from ai.backend.manager.services.app_config_policy.processors import AppConfigPolicyProcessors from ai.backend.manager.services.app_config_policy.service import AppConfigPolicyService from ai.backend.manager.services.artifact.processors import ArtifactProcessors @@ -162,6 +164,10 @@ def create_services(args: ServiceArgs) -> Services: args.event_producer, args.agent_cache, ), + app_config_fragment=AppConfigFragmentService( + repository=repositories.app_config_fragment.repository, + admin_repository=repositories.app_config_fragment.admin_repository, + ), app_config_policy=AppConfigPolicyService( repository=repositories.app_config_policy.repository, admin_repository=repositories.app_config_policy.admin_repository, @@ -420,6 +426,9 @@ def create_processors( services = create_services(args.service_args) return Processors( agent=AgentProcessors(services.agent, action_monitors, validators), + app_config_fragment=AppConfigFragmentProcessors( + services.app_config_fragment, action_monitors, validators + ), app_config_policy=AppConfigPolicyProcessors( services.app_config_policy, action_monitors, validators ), diff --git a/src/ai/backend/manager/services/processors.py b/src/ai/backend/manager/services/processors.py index 6de37be2787..dc276c91c27 100644 --- a/src/ai/backend/manager/services/processors.py +++ b/src/ai/backend/manager/services/processors.py @@ -44,6 +44,12 @@ ) from ai.backend.manager.services.agent.processors import AgentProcessors # pants: no-infer-dep from ai.backend.manager.services.agent.service import AgentService # pants: no-infer-dep + from ai.backend.manager.services.app_config_fragment.processors import ( + AppConfigFragmentProcessors, # pants: no-infer-dep + ) + from ai.backend.manager.services.app_config_fragment.service import ( + AppConfigFragmentService, # pants: no-infer-dep + ) from ai.backend.manager.services.app_config_policy.processors import ( AppConfigPolicyProcessors, # pants: no-infer-dep ) @@ -357,6 +363,7 @@ class ServiceArgs: @dataclass class Services: agent: AgentService + app_config_fragment: AppConfigFragmentService app_config_policy: AppConfigPolicyService domain: DomainService dotfile: DotfileService @@ -422,6 +429,7 @@ class ProcessorArgs: @dataclass class Processors(AbstractProcessorPackage): agent: AgentProcessors + app_config_fragment: AppConfigFragmentProcessors app_config_policy: AppConfigPolicyProcessors domain: DomainProcessors dotfile: DotfileProcessors @@ -480,6 +488,7 @@ class Processors(AbstractProcessorPackage): def supported_actions(self) -> list[ActionSpec]: return [ *self.agent.supported_actions(), + *self.app_config_fragment.supported_actions(), *self.app_config_policy.supported_actions(), *self.domain.supported_actions(), *self.dotfile.supported_actions(),