From 1b154e8cfaa528f4531f46520e4e146d88e23577 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Sat, 25 Apr 2026 14:29:59 +0900 Subject: [PATCH 01/13] feat(BA-5829): add AppConfigFragment + AppConfig GQL surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the GraphQL exposure for AppConfigFragment and the merged AppConfig view (BEP-1052 §2 / §5). Service / processor additions (only what the GQL layer needs that is not already in BA-5827): - Merged-view actions: get_user_app_config, search_user_app_configs, admin_search_app_configs (single-row + paginated reads of the computed AppConfig view). - Service methods + processor wiring + supported_actions for those three actions. Adapter additions: - Merged-view methods: get_user_app_config / my_app_configs / admin_search_app_configs / public_app_config_fragments. `my_*` pulls the user from current_user() inside the adapter. - Bulk-only `my` mutations: my_bulk_create / my_bulk_update — bulk admin variants are wired in BA-5827 already. DTO additions: - common/dto/manager/v2/app_config/* — AppConfigNode, GetUserAppConfigInput / Payload, SearchAppConfigsInput / Payload, SearchMyAppConfigsInput, BulkCreate/UpdateMyAppConfigFragmentsPayload (the my-bulk payloads live here because they carry AppConfigNode). GraphQL: - app_config package: AppConfigGQL, my/admin/public root resolvers. - app_config_fragment package: scope-bound + admin queries, bulk-only mutations (admin + my variants), AppConfigFragmentGQL + AppConfigScopeTypeGQL + filter/order/key inputs. - DataLoader: app_config_fragment_loader for N+1 batching. - schema.py: register the new root queries / mutations. Resolves BA-5829. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dto/manager/v2/app_config/__init__.py | 39 +++ .../dto/manager/v2/app_config/request.py | 73 ++++++ .../dto/manager/v2/app_config/response.py | 84 +++++++ .../common/dto/manager/v2/app_config/types.py | 23 ++ .../api/adapters/app_config_fragment.py | 236 +++++++++++++++++- .../manager/api/gql/app_config/__init__.py | 25 ++ .../api/gql/app_config/resolver/__init__.py | 11 + .../api/gql/app_config/resolver/query.py | 140 +++++++++++ .../api/gql/app_config/types/__init__.py | 13 + .../api/gql/app_config/types/filters.py | 63 +++++ .../manager/api/gql/app_config/types/node.py | 39 +++ .../api/gql/app_config_fragment/__init__.py | 40 +++ .../app_config_fragment/resolver/__init__.py | 23 ++ .../app_config_fragment/resolver/mutation.py | 110 ++++++++ .../gql/app_config_fragment/resolver/query.py | 124 +++++++++ .../gql/app_config_fragment/types/__init__.py | 48 ++++ .../app_config_fragment/types/bulk_inputs.py | 120 +++++++++ .../types/bulk_payloads.py | 136 ++++++++++ .../gql/app_config_fragment/types/filters.py | 62 +++++ .../gql/app_config_fragment/types/inputs.py | 28 +++ .../api/gql/app_config_fragment/types/node.py | 48 ++++ .../api/gql/data_loader/data_loaders.py | 19 ++ src/ai/backend/manager/api/gql/schema.py | 31 ++- .../actions/admin_search_app_configs.py | 38 +++ .../actions/get_user_app_config.py | 34 +++ .../actions/search_user_app_configs.py | 40 +++ .../app_config_fragment/processors.py | 52 +++- .../services/app_config_fragment/service.py | 58 ++++- 28 files changed, 1731 insertions(+), 26 deletions(-) create mode 100644 src/ai/backend/common/dto/manager/v2/app_config/__init__.py create mode 100644 src/ai/backend/common/dto/manager/v2/app_config/request.py create mode 100644 src/ai/backend/common/dto/manager/v2/app_config/response.py create mode 100644 src/ai/backend/common/dto/manager/v2/app_config/types.py create mode 100644 src/ai/backend/manager/api/gql/app_config/__init__.py create mode 100644 src/ai/backend/manager/api/gql/app_config/resolver/__init__.py create mode 100644 src/ai/backend/manager/api/gql/app_config/resolver/query.py create mode 100644 src/ai/backend/manager/api/gql/app_config/types/__init__.py create mode 100644 src/ai/backend/manager/api/gql/app_config/types/filters.py create mode 100644 src/ai/backend/manager/api/gql/app_config/types/node.py create mode 100644 src/ai/backend/manager/api/gql/app_config_fragment/__init__.py create mode 100644 src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py create mode 100644 src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py create mode 100644 src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py create mode 100644 src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py create mode 100644 src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py create mode 100644 src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_payloads.py create mode 100644 src/ai/backend/manager/api/gql/app_config_fragment/types/filters.py create mode 100644 src/ai/backend/manager/api/gql/app_config_fragment/types/inputs.py create mode 100644 src/ai/backend/manager/api/gql/app_config_fragment/types/node.py create mode 100644 src/ai/backend/manager/services/app_config_fragment/actions/admin_search_app_configs.py create mode 100644 src/ai/backend/manager/services/app_config_fragment/actions/get_user_app_config.py create mode 100644 src/ai/backend/manager/services/app_config_fragment/actions/search_user_app_configs.py diff --git a/src/ai/backend/common/dto/manager/v2/app_config/__init__.py b/src/ai/backend/common/dto/manager/v2/app_config/__init__.py new file mode 100644 index 00000000000..a2fec176b3f --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config/__init__.py @@ -0,0 +1,39 @@ +""" +AppConfig (merged view) DTOs v2 for Manager API (BEP-1052 §5). +""" + +from .request import ( + AppConfigFilter, + AppConfigOrder, + GetUserAppConfigInput, + SearchAppConfigsInput, + SearchMyAppConfigsInput, +) +from .response import ( + AppConfigNode, + BulkCreateMyAppConfigFragmentsPayload, + BulkUpdateMyAppConfigFragmentsPayload, + GetUserAppConfigPayload, + SearchAppConfigsPayload, +) +from .types import ( + AppConfigOrderField, + AppConfigScopeType, + OrderDirection, +) + +__all__ = ( + "AppConfigFilter", + "AppConfigNode", + "AppConfigOrder", + "AppConfigOrderField", + "AppConfigScopeType", + "BulkCreateMyAppConfigFragmentsPayload", + "BulkUpdateMyAppConfigFragmentsPayload", + "GetUserAppConfigInput", + "GetUserAppConfigPayload", + "OrderDirection", + "SearchAppConfigsInput", + "SearchAppConfigsPayload", + "SearchMyAppConfigsInput", +) diff --git a/src/ai/backend/common/dto/manager/v2/app_config/request.py b/src/ai/backend/common/dto/manager/v2/app_config/request.py new file mode 100644 index 00000000000..c105039ca40 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config/request.py @@ -0,0 +1,73 @@ +""" +Request DTOs for AppConfig (merged view) DTO v2 (BEP-1052 §5). +""" + +from __future__ import annotations + +from uuid import UUID + +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 AppConfigOrderField, OrderDirection + +__all__ = ( + "AppConfigFilter", + "AppConfigOrder", + "GetUserAppConfigInput", + "SearchAppConfigsInput", + "SearchMyAppConfigsInput", +) + + +class GetUserAppConfigInput(BaseRequestModel): + """Input for reading a single merged AppConfig for a target user + (admin path — the `my` variant resolves the user internally).""" + + user_id: UUID = Field(description="Target user's UUID.") + name: str = Field(description="Policy / config name.") + + +class AppConfigFilter(BaseRequestModel): + """Filter for AppConfig merged-view search.""" + + name: StringFilter | None = Field(default=None, description="Filter by policy name.") + user_id: UUIDFilter | None = Field( + default=None, + description="Filter by target user id (admin cross-user search only).", + ) + + +class AppConfigOrder(BaseRequestModel): + """Order specification for AppConfig merged-view results.""" + + field: AppConfigOrderField = Field(description="Field to order by.") + direction: OrderDirection = Field(default=OrderDirection.ASC, description="Order direction.") + + +class _AppConfigSearchInputBase(BaseRequestModel): + filter: AppConfigFilter | None = Field(default=None, description="Filter conditions.") + order: list[AppConfigOrder] | 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.") + + +class SearchMyAppConfigsInput(_AppConfigSearchInputBase): + """Input for self-service merged-view search (`/v2/app-configs/my/search`). + + The adapter pins the caller as the user scope; no `user_id` argument + is accepted here (BEP-1052 §5 — `filter.userId` is ignored). + """ + + +class SearchAppConfigsInput(_AppConfigSearchInputBase): + """Input for admin cross-user merged-view search. + + Optional `filter.user_id` pins the search to a single user. + """ diff --git a/src/ai/backend/common/dto/manager/v2/app_config/response.py b/src/ai/backend/common/dto/manager/v2/app_config/response.py new file mode 100644 index 00000000000..80126da7acf --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config/response.py @@ -0,0 +1,84 @@ +""" +Response DTOs for AppConfig (merged view) DTO v2 (BEP-1052 §5). +""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseResponseModel +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AppConfigFragmentBulkError, + AppConfigFragmentNode, +) + +__all__ = ( + "AppConfigNode", + "BulkCreateMyAppConfigFragmentsPayload", + "BulkUpdateMyAppConfigFragmentsPayload", + "GetUserAppConfigPayload", + "SearchAppConfigsPayload", +) + + +class AppConfigNode(BaseResponseModel): + """Merged per-user AppConfig view (BEP-1052 §5). + + `fragments` are ordered low → high merge priority (matching the + policy's `scope_sources`). `config` is the deep-merged result, + projected to `None` when every contributing fragment is empty + (§3 null projection). + """ + + user_id: UUID = Field(description="Target user's UUID.") + name: str = Field(description="Policy / config name.") + fragments: list[AppConfigFragmentNode] = Field( + description="Contributing fragments in merge order (low → high).", + ) + config: dict[str, Any] | None = Field( + default=None, + description="Deep-merged configuration, or null when every fragment is empty.", + ) + + +class GetUserAppConfigPayload(BaseResponseModel): + """Payload for reading a single merged AppConfig.""" + + item: AppConfigNode | None = Field( + default=None, + description="Merged AppConfig, or null when no fragments exist for the user.", + ) + + +class SearchAppConfigsPayload(BaseResponseModel): + """Payload for paginated merged-view AppConfig search.""" + + items: list[AppConfigNode] = Field(description="AppConfigs matching the filter.") + total_count: int = Field(description="Total number of AppConfigs 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.") + + +class BulkCreateMyAppConfigFragmentsPayload(BaseResponseModel): + """Payload for `bulkCreateMyAppConfigFragments`. + + Each successfully created row produces a recomputed merged + `AppConfigNode`; failures are collected per-item (BEP-1052 §3). + """ + + created: list[AppConfigNode] = Field( + description="Recomputed merged AppConfig views for each created USER fragment.", + ) + failed: list[AppConfigFragmentBulkError] = Field(description="Per-item failures.") + + +class BulkUpdateMyAppConfigFragmentsPayload(BaseResponseModel): + """Payload for `bulkUpdateMyAppConfigFragments`.""" + + updated: list[AppConfigNode] = Field( + description="Recomputed merged AppConfig views for each updated USER fragment.", + ) + failed: list[AppConfigFragmentBulkError] = Field(description="Per-item failures.") diff --git a/src/ai/backend/common/dto/manager/v2/app_config/types.py b/src/ai/backend/common/dto/manager/v2/app_config/types.py new file mode 100644 index 00000000000..f2d8c5e6757 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config/types.py @@ -0,0 +1,23 @@ +""" +Common types for AppConfig (merged view) DTO v2 (BEP-1052 §5). +""" + +from __future__ import annotations + +from enum import StrEnum + +from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType +from ai.backend.common.dto.manager.v2.common import OrderDirection + +__all__ = ( + "AppConfigOrderField", + "AppConfigScopeType", + "OrderDirection", +) + + +class AppConfigOrderField(StrEnum): + """Fields available for ordering AppConfig merged-view results.""" + + USER_ID = "user_id" + NAME = "name" diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment.py b/src/ai/backend/manager/api/adapters/app_config_fragment.py index f587579a444..5762627ea9b 100644 --- a/src/ai/backend/manager/api/adapters/app_config_fragment.py +++ b/src/ai/backend/manager/api/adapters/app_config_fragment.py @@ -2,6 +2,24 @@ from __future__ import annotations +from ai.backend.common.contexts.user import current_user +from ai.backend.common.dto.manager.v2.app_config.request import ( + AppConfigFilter, + AppConfigOrder, + GetUserAppConfigInput, + SearchAppConfigsInput, + SearchMyAppConfigsInput, +) +from ai.backend.common.dto.manager.v2.app_config.response import ( + AppConfigNode, + BulkCreateMyAppConfigFragmentsPayload, + BulkUpdateMyAppConfigFragmentsPayload, + GetUserAppConfigPayload, + SearchAppConfigsPayload, +) +from ai.backend.common.dto.manager.v2.app_config.types import ( + AppConfigOrderField, +) from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( AdminBulkCreateAppConfigFragmentsInput, AdminBulkPurgeAppConfigFragmentsInput, @@ -9,6 +27,8 @@ AppConfigFragmentFilter, AppConfigFragmentKeyInput, AppConfigFragmentOrder, + BulkCreateMyAppConfigFragmentsInput, + BulkUpdateMyAppConfigFragmentsInput, SearchAppConfigFragmentsInput, ) from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( @@ -28,10 +48,14 @@ from ai.backend.common.dto.manager.v2.app_config_fragment.types import ( AppConfigScopeType as DTOAppConfigScopeType, ) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import OrderDirection +from ai.backend.common.exception import UnreachableError from ai.backend.manager.api.adapter_options.pagination.pagination import PaginationSpec +from ai.backend.manager.data.app_config.types import AppConfigData from ai.backend.manager.data.app_config_fragment.bulk_types import ( AppConfigFragmentBulkItem, AppConfigFragmentBulkItemError, + MyAppConfigFragmentBulkItem, ) from ai.backend.manager.data.app_config_fragment.types import ( AppConfigFragmentData, @@ -43,6 +67,7 @@ from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow from ai.backend.manager.repositories.app_config_fragment.types import ( AppConfigFragmentSearchScope, + UserAppConfigSearchScope, ) from ai.backend.manager.repositories.base import BatchQuerier, QueryCondition, QueryOrder from ai.backend.manager.services.app_config_fragment.actions.admin_bulk_create import ( @@ -57,10 +82,25 @@ from ai.backend.manager.services.app_config_fragment.actions.admin_search import ( AdminSearchAppConfigFragmentsAction, ) +from ai.backend.manager.services.app_config_fragment.actions.admin_search_app_configs import ( + AdminSearchAppConfigsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_create_my import ( + BulkCreateMyAppConfigFragmentsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_update_my import ( + BulkUpdateMyAppConfigFragmentsAction, +) from ai.backend.manager.services.app_config_fragment.actions.get import GetAppConfigFragmentAction +from ai.backend.manager.services.app_config_fragment.actions.get_user_app_config import ( + GetUserAppConfigAction, +) from ai.backend.manager.services.app_config_fragment.actions.search import ( SearchAppConfigFragmentsAction, ) +from ai.backend.manager.services.app_config_fragment.actions.search_user_app_configs import ( + SearchUserAppConfigsAction, +) from .base import BaseAdapter @@ -68,9 +108,8 @@ 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. + Writes are bulk-only (BEP-1052 §3); single-item create / update / + purge entry points are intentionally absent. """ async def get(self, key_input: AppConfigFragmentKeyInput) -> GetAppConfigFragmentPayload: @@ -222,7 +261,143 @@ def _data_to_dto(data: AppConfigFragmentData) -> AppConfigFragmentNode: updated_at=data.updated_at, ) - # ── Bulk mutations ───────────────────────────────────────────── + # ── Merged-view (AppConfig, BEP-1052 §5) ─────────────────────── + + async def my_app_config(self, name: str) -> GetUserAppConfigPayload: + """Read the caller's own merged AppConfig for `name`. + + Resolves the current user from the context; there is no way to + target another user through this method. + """ + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + result = await self._processors.app_config_fragment.get_user_app_config.wait_for_complete( + GetUserAppConfigAction(user_id=me.user_id, config_name=name) + ) + return GetUserAppConfigPayload(item=self._app_config_data_to_dto(result.app_config)) + + async def admin_get_user_app_config( + self, input: GetUserAppConfigInput + ) -> GetUserAppConfigPayload: + """Read a specific user's merged AppConfig (admin only).""" + result = await self._processors.app_config_fragment.get_user_app_config.wait_for_complete( + GetUserAppConfigAction(user_id=input.user_id, config_name=input.name) + ) + return GetUserAppConfigPayload(item=self._app_config_data_to_dto(result.app_config)) + + async def my_search_app_configs( + self, input: SearchMyAppConfigsInput + ) -> SearchAppConfigsPayload: + """Paginated merged-view search over the caller's own AppConfigs.""" + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + querier = self._build_app_config_querier(input) + result = ( + await self._processors.app_config_fragment.search_user_app_configs.wait_for_complete( + SearchUserAppConfigsAction( + scope=UserAppConfigSearchScope(user_id=me.user_id), + querier=querier, + ) + ) + ) + return SearchAppConfigsPayload( + items=[self._app_config_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_app_configs( + self, input: SearchAppConfigsInput + ) -> SearchAppConfigsPayload: + """Cross-user merged-view search (admin only). + + `filter.user_id` pins the query to a single user; otherwise + pagination walks across every user. + """ + querier = self._build_app_config_querier(input) + result = ( + await self._processors.app_config_fragment.admin_search_app_configs.wait_for_complete( + AdminSearchAppConfigsAction(querier=querier) + ) + ) + return SearchAppConfigsPayload( + items=[self._app_config_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, + ) + + def _build_app_config_querier( + self, + input: SearchMyAppConfigsInput | SearchAppConfigsInput, + ) -> BatchQuerier: + """Querier builder shared by `my_search_app_configs` and + `admin_search_app_configs`. + + The merged-view SQL resolves cursor/order internally via the + repository layer; this helper forwards only the filter / order / + pagination fields so cursor tiebreakers stay consistent with + the raw-fragment querier. + """ + conditions = self._convert_app_config_filter(input.filter) if input.filter else [] + orders = self._convert_app_config_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_app_config_filter(self, filter: AppConfigFilter) -> list[QueryCondition]: + conditions: list[QueryCondition] = [] + 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) + # `filter.user_id` handling lives inside the merged-view SQL + # (repository layer) rather than in a BatchQuerier condition — + # see `AppConfigFragmentDBSource.admin_search_app_configs`. + return conditions + + @staticmethod + def _convert_app_config_orders(orders: list[AppConfigOrder]) -> list[QueryOrder]: + result: list[QueryOrder] = [] + for order in orders: + ascending = order.direction == OrderDirection.ASC + match order.field: + case AppConfigOrderField.NAME: + result.append(AppConfigFragmentOrders.name(ascending)) + case AppConfigOrderField.USER_ID: + # USER_ID ordering is applied inside the merged-view SQL + # because the raw `app_config_fragments` row does not + # carry a user_id column directly. + continue + return result + + def _app_config_data_to_dto(self, data: AppConfigData) -> AppConfigNode: + return AppConfigNode( + user_id=data.user_id, + name=data.name, + fragments=[self._data_to_dto(fragment) for fragment in data.fragments], + config=dict(data.config) if data.config is not None else None, + ) + + # ── Bulk mutations (BEP-1052 §3) ─────────────────────────────── # # Each bulk processor returns a `BulkProcessResult[T]` whose # `.result` field is the underlying `*ActionResult` produced by the @@ -270,7 +445,7 @@ async def admin_bulk_update( async def admin_bulk_purge( self, input: AdminBulkPurgeAppConfigFragmentsInput ) -> AdminBulkPurgeAppConfigFragmentsPayload: - keys = [self._input_to_key(key) for key in input.keys] + keys = [self._input_to_key(key_input) for key_input in input.keys] wrapper = await self._processors.app_config_fragment.admin_bulk_purge.wait_for_complete( AdminBulkPurgeAppConfigFragmentsAction(entity_ids=[], keys=keys) ) @@ -287,8 +462,57 @@ async def admin_bulk_purge( failed=[self._bulk_error_to_dto(err) for err in result.failed], ) + async def my_bulk_create( + self, input: BulkCreateMyAppConfigFragmentsInput + ) -> BulkCreateMyAppConfigFragmentsPayload: + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + items = [ + MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) + for item in input.items + ] + wrapper = await self._processors.app_config_fragment.bulk_create_my.wait_for_complete( + BulkCreateMyAppConfigFragmentsAction( + entity_ids=[item.name for item in items], + user_id=me.user_id, + items=items, + ) + ) + result = wrapper.result + return BulkCreateMyAppConfigFragmentsPayload( + created=[self._app_config_data_to_dto(item) for item in result.created], + failed=[self._bulk_error_to_dto(err) for err in result.failed], + ) + + async def my_bulk_update( + self, input: BulkUpdateMyAppConfigFragmentsInput + ) -> BulkUpdateMyAppConfigFragmentsPayload: + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + items = [ + MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) + for item in input.items + ] + wrapper = await self._processors.app_config_fragment.bulk_update_my.wait_for_complete( + BulkUpdateMyAppConfigFragmentsAction( + entity_ids=[item.name for item in items], + user_id=me.user_id, + items=items, + ) + ) + result = wrapper.result + return BulkUpdateMyAppConfigFragmentsPayload( + updated=[self._app_config_data_to_dto(item) for item in result.updated], + failed=[self._bulk_error_to_dto(err) for err in result.failed], + ) + @staticmethod - def _bulk_error_to_dto(err: AppConfigFragmentBulkItemError) -> AppConfigFragmentBulkError: + def _bulk_error_to_dto( + err: AppConfigFragmentBulkItemError, + ) -> AppConfigFragmentBulkError: + """Convert the service-layer error dataclass to its DTO mirror.""" return AppConfigFragmentBulkError( index=err.index, scope_type=DTOAppConfigScopeType(err.scope_type), diff --git a/src/ai/backend/manager/api/gql/app_config/__init__.py b/src/ai/backend/manager/api/gql/app_config/__init__.py new file mode 100644 index 00000000000..f11c775bc6b --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/__init__.py @@ -0,0 +1,25 @@ +"""AppConfig (merged view) GraphQL API package (BEP-1052 §5).""" + +from .resolver import ( + admin_app_configs, + my_app_configs, + public_app_config_fragments, +) +from .types import ( + AppConfigFilterGQL, + AppConfigGQL, + AppConfigOrderByGQL, + AppConfigOrderFieldGQL, +) + +__all__ = [ + # Queries + "my_app_configs", + "admin_app_configs", + "public_app_config_fragments", + # Types + "AppConfigGQL", + "AppConfigFilterGQL", + "AppConfigOrderByGQL", + "AppConfigOrderFieldGQL", +] diff --git a/src/ai/backend/manager/api/gql/app_config/resolver/__init__.py b/src/ai/backend/manager/api/gql/app_config/resolver/__init__.py new file mode 100644 index 00000000000..5d16c074459 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/resolver/__init__.py @@ -0,0 +1,11 @@ +from .query import ( + admin_app_configs, + my_app_configs, + public_app_config_fragments, +) + +__all__ = [ + "admin_app_configs", + "my_app_configs", + "public_app_config_fragments", +] diff --git a/src/ai/backend/manager/api/gql/app_config/resolver/query.py b/src/ai/backend/manager/api/gql/app_config/resolver/query.py new file mode 100644 index 00000000000..4b567fc5d47 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/resolver/query.py @@ -0,0 +1,140 @@ +"""AppConfig (merged view) GQL query resolvers (BEP-1052 §5).""" + +from __future__ import annotations + +from strawberry import Info + +from ai.backend.common.dto.manager.v2.app_config.request import ( + SearchAppConfigsInput, + SearchMyAppConfigsInput, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + SearchAppConfigFragmentsInput, +) +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config.types import ( + AppConfigFilterGQL, + AppConfigGQL, + AppConfigOrderByGQL, +) +from ai.backend.manager.api.gql.app_config_fragment.types import ( + AppConfigFragmentFilterGQL, + AppConfigFragmentGQL, + AppConfigFragmentOrderByGQL, +) +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_root_field, +) +from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.api.gql.utils import check_admin_only +from ai.backend.manager.data.app_config_fragment.types import AppConfigScopeType + + +@gql_root_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Caller's own merged AppConfig list (auth required). Chain per policy " + "(BEP-1052 §5); the adapter pins `(USER, current_user)` internally." + ), + ) +) # type: ignore[misc] +async def my_app_configs( + info: Info[StrawberryGQLContext], + filter: AppConfigFilterGQL | None = None, + order_by: list[AppConfigOrderByGQL] | None = None, + first: int | None = None, + after: str | None = None, + last: int | None = None, + before: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> list[AppConfigGQL]: + payload = await info.context.adapters.app_config_fragment.my_search_app_configs( + SearchMyAppConfigsInput( + filter=filter.to_pydantic() if filter else None, + order=[o.to_pydantic() for o in order_by] if order_by else None, + first=first, + after=after, + last=last, + before=before, + limit=limit, + offset=offset, + ) + ) + return [AppConfigGQL.from_pydantic(node) for node in payload.items] + + +@gql_root_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Cross-user merged-view search (admin only). Resolves any user's AppConfig " + "for audit / support. Pin to a single user with `filter.userId`; otherwise " + "paginates across all users." + ), + ) +) # type: ignore[misc] +async def admin_app_configs( + info: Info[StrawberryGQLContext], + filter: AppConfigFilterGQL | None = None, + order_by: list[AppConfigOrderByGQL] | None = None, + first: int | None = None, + after: str | None = None, + last: int | None = None, + before: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> list[AppConfigGQL]: + check_admin_only() + payload = await info.context.adapters.app_config_fragment.admin_search_app_configs( + SearchAppConfigsInput( + filter=filter.to_pydantic() if filter else None, + order=[o.to_pydantic() for o in order_by] if order_by else None, + first=first, + after=after, + last=last, + before=before, + limit=limit, + offset=offset, + ) + ) + return [AppConfigGQL.from_pydantic(node) for node in payload.items] + + +@gql_root_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Public (no-auth) `PUBLIC`-scope app-config fragments — the subset of " + "raw fragments that carry no personally-scoped data (BEP-1052 §3)." + ), + ) +) # type: ignore[misc] +async def public_app_config_fragments( + info: Info[StrawberryGQLContext], + filter: AppConfigFragmentFilterGQL | None = None, + order_by: list[AppConfigFragmentOrderByGQL] | None = None, + first: int | None = None, + after: str | None = None, + last: int | None = None, + before: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> list[AppConfigFragmentGQL]: + payload = await info.context.adapters.app_config_fragment.search( + scope_type=AppConfigScopeType.PUBLIC, + scope_id="public", + input=SearchAppConfigFragmentsInput( + filter=filter.to_pydantic() if filter else None, + order=[o.to_pydantic() for o in order_by] if order_by else None, + first=first, + after=after, + last=last, + before=before, + limit=limit, + offset=offset, + ), + ) + return [AppConfigFragmentGQL.from_pydantic(node) for node in payload.items] diff --git a/src/ai/backend/manager/api/gql/app_config/types/__init__.py b/src/ai/backend/manager/api/gql/app_config/types/__init__.py new file mode 100644 index 00000000000..c38511359aa --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/types/__init__.py @@ -0,0 +1,13 @@ +from .filters import ( + AppConfigFilterGQL, + AppConfigOrderByGQL, + AppConfigOrderFieldGQL, +) +from .node import AppConfigGQL + +__all__ = [ + "AppConfigFilterGQL", + "AppConfigGQL", + "AppConfigOrderByGQL", + "AppConfigOrderFieldGQL", +] diff --git a/src/ai/backend/manager/api/gql/app_config/types/filters.py b/src/ai/backend/manager/api/gql/app_config/types/filters.py new file mode 100644 index 00000000000..a660f26d6f0 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/types/filters.py @@ -0,0 +1,63 @@ +"""AppConfig (merged view) GQL filter / order types.""" + +from __future__ import annotations + +from enum import StrEnum + +from ai.backend.common.dto.manager.v2.app_config.request import ( + AppConfigFilter as AppConfigFilterDTO, +) +from ai.backend.common.dto.manager.v2.app_config.request import ( + AppConfigOrder as AppConfigOrderDTO, +) +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.base import OrderDirection, StringFilter, UUIDFilter +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_enum, + gql_field, + gql_pydantic_input, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticInputMixin + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Filter input for querying merged AppConfigs.", + ), + name="AppConfigFilter", +) +class AppConfigFilterGQL(PydanticInputMixin[AppConfigFilterDTO]): + name: StringFilter | None = gql_field(description="Filter by policy name.", default=None) + user_id: UUIDFilter | None = gql_field( + description="Filter by target user id (admin cross-user search only).", + default=None, + ) + + +@gql_enum( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Fields available for ordering merged AppConfigs.", + ), + name="AppConfigOrderField", +) +class AppConfigOrderFieldGQL(StrEnum): + USER_ID = "user_id" + NAME = "name" + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Specifies ordering for merged AppConfig results.", + ), + name="AppConfigOrderBy", +) +class AppConfigOrderByGQL(PydanticInputMixin[AppConfigOrderDTO]): + field: AppConfigOrderFieldGQL = gql_field(description="The field to order by.") + direction: OrderDirection = gql_field( + description="Sort direction.", + default=OrderDirection.ASC, + ) diff --git a/src/ai/backend/manager/api/gql/app_config/types/node.py b/src/ai/backend/manager/api/gql/app_config/types/node.py new file mode 100644 index 00000000000..949bc4a2d6e --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/types/node.py @@ -0,0 +1,39 @@ +"""AppConfig (merged view) GQL output types (BEP-1052 §5).""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from ai.backend.common.dto.manager.v2.app_config.response import AppConfigNode +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config_fragment.types.node import AppConfigFragmentGQL +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_field, + gql_pydantic_type, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Merged per-user AppConfig view (BEP-1052 §5). `fragments` are ordered " + "low → high merge priority; `config` is the deep-merge result and is " + "null when every contributing fragment is empty." + ), + ), + model=AppConfigNode, + name="AppConfig", +) +class AppConfigGQL(PydanticOutputMixin[AppConfigNode]): + user_id: UUID = gql_field(description="Target user's UUID.") + name: str = gql_field(description="Policy / config name.") + fragments: list[AppConfigFragmentGQL] = gql_field( + description="Contributing fragments in merge order (low → high).", + ) + config: dict[str, Any] | None = gql_field( + description="Deep-merged configuration, or null when every fragment is empty.", + ) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py new file mode 100644 index 00000000000..6ad3c54f465 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py @@ -0,0 +1,40 @@ +"""AppConfigFragment GraphQL API package.""" + +from .resolver import ( + admin_app_config_fragments, + admin_bulk_create_app_config_fragments, + admin_bulk_purge_app_config_fragments, + admin_bulk_update_app_config_fragments, + app_config_fragment, + bulk_create_my_app_config_fragments, + bulk_update_my_app_config_fragments, + scoped_app_config_fragments, +) +from .types import ( + AppConfigFragmentFilterGQL, + AppConfigFragmentGQL, + AppConfigFragmentKeyInputGQL, + AppConfigFragmentOrderByGQL, + AppConfigFragmentOrderFieldGQL, + AppConfigScopeTypeGQL, +) + +__all__ = [ + # Queries + "app_config_fragment", + "scoped_app_config_fragments", + "admin_app_config_fragments", + # Bulk mutations (BEP-1052 §3 — bulk-only) + "admin_bulk_create_app_config_fragments", + "admin_bulk_update_app_config_fragments", + "admin_bulk_purge_app_config_fragments", + "bulk_create_my_app_config_fragments", + "bulk_update_my_app_config_fragments", + # Types + "AppConfigFragmentGQL", + "AppConfigScopeTypeGQL", + "AppConfigFragmentFilterGQL", + "AppConfigFragmentOrderByGQL", + "AppConfigFragmentOrderFieldGQL", + "AppConfigFragmentKeyInputGQL", +] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py new file mode 100644 index 00000000000..9dc335de709 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py @@ -0,0 +1,23 @@ +from .mutation import ( + admin_bulk_create_app_config_fragments, + admin_bulk_purge_app_config_fragments, + admin_bulk_update_app_config_fragments, + bulk_create_my_app_config_fragments, + bulk_update_my_app_config_fragments, +) +from .query import ( + admin_app_config_fragments, + app_config_fragment, + scoped_app_config_fragments, +) + +__all__ = [ + "admin_app_config_fragments", + "admin_bulk_create_app_config_fragments", + "admin_bulk_purge_app_config_fragments", + "admin_bulk_update_app_config_fragments", + "app_config_fragment", + "bulk_create_my_app_config_fragments", + "bulk_update_my_app_config_fragments", + "scoped_app_config_fragments", +] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py new file mode 100644 index 00000000000..9b7506d01a3 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py @@ -0,0 +1,110 @@ +"""AppConfigFragment GQL mutation resolvers (bulk-only, BEP-1052 §3).""" + +from __future__ import annotations + +from strawberry import Info + +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config_fragment.types import ( + AdminBulkCreateAppConfigFragmentInputGQL, + AdminBulkCreateAppConfigFragmentsPayloadGQL, + AdminBulkPurgeAppConfigFragmentInputGQL, + AdminBulkPurgeAppConfigFragmentsPayloadGQL, + AdminBulkUpdateAppConfigFragmentInputGQL, + AdminBulkUpdateAppConfigFragmentsPayloadGQL, + BulkCreateMyAppConfigFragmentInputGQL, + BulkCreateMyAppConfigFragmentsPayloadGQL, + BulkUpdateMyAppConfigFragmentInputGQL, + BulkUpdateMyAppConfigFragmentsPayloadGQL, +) +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_mutation, +) +from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.api.gql.utils import check_admin_only + + +@gql_mutation( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Strict insert across any scope; each item runs in its own transaction " + "and failures are collected per-item (admin only)." + ), + ) +) # type: ignore[misc] +async def admin_bulk_create_app_config_fragments( + info: Info[StrawberryGQLContext], + input: AdminBulkCreateAppConfigFragmentInputGQL, +) -> AdminBulkCreateAppConfigFragmentsPayloadGQL: + check_admin_only() + result = await info.context.adapters.app_config_fragment.admin_bulk_create(input.to_pydantic()) + return AdminBulkCreateAppConfigFragmentsPayloadGQL.from_pydantic(result) + + +@gql_mutation( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Wholesale JSON replacement; items with no existing row are returned as failures " + "(admin only)." + ), + ) +) # type: ignore[misc] +async def admin_bulk_update_app_config_fragments( + info: Info[StrawberryGQLContext], + input: AdminBulkUpdateAppConfigFragmentInputGQL, +) -> AdminBulkUpdateAppConfigFragmentsPayloadGQL: + check_admin_only() + result = await info.context.adapters.app_config_fragment.admin_bulk_update(input.to_pydantic()) + return AdminBulkUpdateAppConfigFragmentsPayloadGQL.from_pydantic(result) + + +@gql_mutation( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Cleanup-only deletion; absent keys are no-oped (admin only).", + ) +) # type: ignore[misc] +async def admin_bulk_purge_app_config_fragments( + info: Info[StrawberryGQLContext], + input: AdminBulkPurgeAppConfigFragmentInputGQL, +) -> AdminBulkPurgeAppConfigFragmentsPayloadGQL: + check_admin_only() + result = await info.context.adapters.app_config_fragment.admin_bulk_purge(input.to_pydantic()) + return AdminBulkPurgeAppConfigFragmentsPayloadGQL.from_pydantic(result) + + +@gql_mutation( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Strict insert on the caller's USER row; duplicates fail per-item. " + "Returns recomputed merged `AppConfig` views." + ), + ) +) # type: ignore[misc] +async def bulk_create_my_app_config_fragments( + info: Info[StrawberryGQLContext], + input: BulkCreateMyAppConfigFragmentInputGQL, +) -> BulkCreateMyAppConfigFragmentsPayloadGQL: + result = await info.context.adapters.app_config_fragment.my_bulk_create(input.to_pydantic()) + return BulkCreateMyAppConfigFragmentsPayloadGQL.from_pydantic(result) + + +@gql_mutation( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Wholesale replacement on the caller's USER row; missing rows are returned as " + "failures. Returns recomputed merged `AppConfig` views." + ), + ) +) # type: ignore[misc] +async def bulk_update_my_app_config_fragments( + info: Info[StrawberryGQLContext], + input: BulkUpdateMyAppConfigFragmentInputGQL, +) -> BulkUpdateMyAppConfigFragmentsPayloadGQL: + result = await info.context.adapters.app_config_fragment.my_bulk_update(input.to_pydantic()) + return BulkUpdateMyAppConfigFragmentsPayloadGQL.from_pydantic(result) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py new file mode 100644 index 00000000000..7c93ab1f3b5 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py @@ -0,0 +1,124 @@ +"""AppConfigFragment GQL query resolvers.""" + +from __future__ import annotations + +from strawberry import Info + +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AppConfigFragmentKeyInput, + SearchAppConfigFragmentsInput, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config_fragment.types import ( + AppConfigFragmentFilterGQL, + AppConfigFragmentGQL, + AppConfigFragmentOrderByGQL, +) +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_root_field, +) +from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.api.gql.utils import check_admin_only +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigScopeType as DataAppConfigScopeType, +) + + +@gql_root_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Get a single app-config fragment by natural key " + "`(scope_type, scope_id, name)`. Available to any authenticated user " + "— service-layer authorization gates cross-scope reads." + ), + ) +) # type: ignore[misc] +async def app_config_fragment( + info: Info[StrawberryGQLContext], + scope_type: AppConfigScopeType, + scope_id: str, + name: str, +) -> AppConfigFragmentGQL | None: + payload = await info.context.adapters.app_config_fragment.get( + AppConfigFragmentKeyInput(scope_type=scope_type, scope_id=scope_id, name=name) + ) + if payload.item is None: + return None + return AppConfigFragmentGQL.from_pydantic(payload.item) + + +@gql_root_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Scope-bound app-config fragment list. Caller pins " + "`(scope_type, scope_id)` so non-admin users only see fragments within " + "their own scope (BEP-1052 §2)." + ), + ) +) # type: ignore[misc] +async def scoped_app_config_fragments( + info: Info[StrawberryGQLContext], + scope_type: AppConfigScopeType, + scope_id: str, + filter: AppConfigFragmentFilterGQL | None = None, + order_by: list[AppConfigFragmentOrderByGQL] | None = None, + first: int | None = None, + after: str | None = None, + last: int | None = None, + before: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> list[AppConfigFragmentGQL]: + search_input = SearchAppConfigFragmentsInput( + filter=filter.to_pydantic() if filter else None, + order=[o.to_pydantic() for o in order_by] if order_by else None, + first=first, + after=after, + last=last, + before=before, + limit=limit, + offset=offset, + ) + payload = await info.context.adapters.app_config_fragment.search( + scope_type=DataAppConfigScopeType(scope_type.value), + scope_id=scope_id, + input=search_input, + ) + return [AppConfigFragmentGQL.from_pydantic(node) for node in payload.items] + + +@gql_root_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Cross-scope admin search across all app-config fragments (admin only).", + ) +) # type: ignore[misc] +async def admin_app_config_fragments( + info: Info[StrawberryGQLContext], + filter: AppConfigFragmentFilterGQL | None = None, + order_by: list[AppConfigFragmentOrderByGQL] | None = None, + first: int | None = None, + after: str | None = None, + last: int | None = None, + before: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> list[AppConfigFragmentGQL]: + check_admin_only() + payload = await info.context.adapters.app_config_fragment.admin_search( + SearchAppConfigFragmentsInput( + filter=filter.to_pydantic() if filter else None, + order=[o.to_pydantic() for o in order_by] if order_by else None, + first=first, + after=after, + last=last, + before=before, + limit=limit, + offset=offset, + ) + ) + return [AppConfigFragmentGQL.from_pydantic(node) for node in payload.items] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py new file mode 100644 index 00000000000..25df893733c --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py @@ -0,0 +1,48 @@ +from .bulk_inputs import ( + AdminAppConfigFragmentItemInputGQL, + AdminBulkCreateAppConfigFragmentInputGQL, + AdminBulkPurgeAppConfigFragmentInputGQL, + AdminBulkUpdateAppConfigFragmentInputGQL, + BulkCreateMyAppConfigFragmentInputGQL, + BulkUpdateMyAppConfigFragmentInputGQL, + MyAppConfigFragmentItemInputGQL, +) +from .bulk_payloads import ( + AdminBulkCreateAppConfigFragmentsPayloadGQL, + AdminBulkPurgeAppConfigFragmentsPayloadGQL, + AdminBulkUpdateAppConfigFragmentsPayloadGQL, + AppConfigFragmentBulkErrorGQL, + BulkCreateMyAppConfigFragmentsPayloadGQL, + BulkUpdateMyAppConfigFragmentsPayloadGQL, + PurgeAppConfigFragmentKeyGQL, +) +from .filters import ( + AppConfigFragmentFilterGQL, + AppConfigFragmentOrderByGQL, + AppConfigFragmentOrderFieldGQL, +) +from .inputs import AppConfigFragmentKeyInputGQL +from .node import AppConfigFragmentGQL, AppConfigScopeTypeGQL + +__all__ = [ + "AdminAppConfigFragmentItemInputGQL", + "AdminBulkCreateAppConfigFragmentInputGQL", + "AdminBulkCreateAppConfigFragmentsPayloadGQL", + "AdminBulkPurgeAppConfigFragmentInputGQL", + "AdminBulkPurgeAppConfigFragmentsPayloadGQL", + "AdminBulkUpdateAppConfigFragmentInputGQL", + "AdminBulkUpdateAppConfigFragmentsPayloadGQL", + "AppConfigFragmentBulkErrorGQL", + "AppConfigFragmentFilterGQL", + "AppConfigFragmentGQL", + "AppConfigFragmentKeyInputGQL", + "AppConfigFragmentOrderByGQL", + "AppConfigFragmentOrderFieldGQL", + "AppConfigScopeTypeGQL", + "BulkCreateMyAppConfigFragmentInputGQL", + "BulkCreateMyAppConfigFragmentsPayloadGQL", + "BulkUpdateMyAppConfigFragmentInputGQL", + "BulkUpdateMyAppConfigFragmentsPayloadGQL", + "MyAppConfigFragmentItemInputGQL", + "PurgeAppConfigFragmentKeyGQL", +] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py new file mode 100644 index 00000000000..80137c802d5 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py @@ -0,0 +1,120 @@ +"""AppConfigFragment bulk-mutation GQL input types (BEP-1052 §3).""" + +from __future__ import annotations + +from typing import Any + +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminAppConfigFragmentItemInput as AdminItemInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminBulkCreateAppConfigFragmentsInput as AdminBulkCreateInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminBulkPurgeAppConfigFragmentsInput as AdminBulkPurgeInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminBulkUpdateAppConfigFragmentsInput as AdminBulkUpdateInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + BulkCreateMyAppConfigFragmentsInput as BulkCreateMyInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + BulkUpdateMyAppConfigFragmentsInput as BulkUpdateMyInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + MyAppConfigFragmentItemInput as MyItemInputDTO, +) +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config_fragment.types.inputs import ( + AppConfigFragmentKeyInputGQL, +) +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_field, + gql_pydantic_input, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticInputMixin + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Per-item input for admin bulk create / update.", + ), + name="AdminAppConfigFragmentItemInput", +) +class AdminAppConfigFragmentItemInputGQL(PydanticInputMixin[AdminItemInputDTO]): + key: AppConfigFragmentKeyInputGQL = gql_field(description="Natural-key identifier.") + extra_config: dict[str, Any] = gql_field(description="Raw configuration payload.") + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Admin bulk create input — items carry any scope (BEP-1052 §3).", + ), + name="AdminBulkCreateAppConfigFragmentInput", +) +class AdminBulkCreateAppConfigFragmentInputGQL(PydanticInputMixin[AdminBulkCreateInputDTO]): + items: list[AdminAppConfigFragmentItemInputGQL] = gql_field(description="Rows to create.") + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Admin bulk update input.", + ), + name="AdminBulkUpdateAppConfigFragmentInput", +) +class AdminBulkUpdateAppConfigFragmentInputGQL(PydanticInputMixin[AdminBulkUpdateInputDTO]): + items: list[AdminAppConfigFragmentItemInputGQL] = gql_field(description="Rows to update.") + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Admin bulk purge input — keyed on `AppConfigFragmentKey`.", + ), + name="AdminBulkPurgeAppConfigFragmentInput", +) +class AdminBulkPurgeAppConfigFragmentInputGQL(PydanticInputMixin[AdminBulkPurgeInputDTO]): + keys: list[AppConfigFragmentKeyInputGQL] = gql_field(description="Natural keys to purge.") + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Per-item input for self-service (`my`) bulk.", + ), + name="MyAppConfigFragmentItemInput", +) +class MyAppConfigFragmentItemInputGQL(PydanticInputMixin[MyItemInputDTO]): + name: str = gql_field(description="Policy name.") + extra_config: dict[str, Any] = gql_field(description="Raw configuration payload.") + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Self-service bulk create — scope is `USER` / `current_user`.", + ), + name="BulkCreateMyAppConfigFragmentInput", +) +class BulkCreateMyAppConfigFragmentInputGQL(PydanticInputMixin[BulkCreateMyInputDTO]): + items: list[MyAppConfigFragmentItemInputGQL] = gql_field( + description="USER-scope rows to create.", + ) + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Self-service bulk update — scope is `USER` / `current_user`.", + ), + name="BulkUpdateMyAppConfigFragmentInput", +) +class BulkUpdateMyAppConfigFragmentInputGQL(PydanticInputMixin[BulkUpdateMyInputDTO]): + items: list[MyAppConfigFragmentItemInputGQL] = gql_field( + description="USER-scope rows to update.", + ) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_payloads.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_payloads.py new file mode 100644 index 00000000000..d17bfab9cd2 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_payloads.py @@ -0,0 +1,136 @@ +"""AppConfigFragment bulk-mutation GQL payload types (BEP-1052 §3).""" + +from __future__ import annotations + +from ai.backend.common.dto.manager.v2.app_config.response import ( + BulkCreateMyAppConfigFragmentsPayload as BulkCreateMyPayloadDTO, +) +from ai.backend.common.dto.manager.v2.app_config.response import ( + BulkUpdateMyAppConfigFragmentsPayload as BulkUpdateMyPayloadDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AdminBulkCreateAppConfigFragmentsPayload as AdminBulkCreatePayloadDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AdminBulkPurgeAppConfigFragmentsPayload as AdminBulkPurgePayloadDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AdminBulkUpdateAppConfigFragmentsPayload as AdminBulkUpdatePayloadDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AppConfigFragmentBulkError as AppConfigFragmentBulkErrorDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + PurgeAppConfigFragmentKey as PurgeAppConfigFragmentKeyDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config.types.node import AppConfigGQL +from ai.backend.manager.api.gql.app_config_fragment.types.node import AppConfigFragmentGQL +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_field, + gql_pydantic_type, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Per-item failure info for bulk Fragment mutations.", + ), + model=AppConfigFragmentBulkErrorDTO, + name="AppConfigFragmentBulkError", +) +class AppConfigFragmentBulkErrorGQL(PydanticOutputMixin[AppConfigFragmentBulkErrorDTO]): + index: int = gql_field(description="Original position in the input list.") + scope_type: AppConfigScopeType = gql_field(description="Scope type of the failed row.") + scope_id: str = gql_field(description="Scope id of the failed row.") + name: str = gql_field(description="Policy name of the failed row.") + message: str = gql_field(description="Reason for the failure.") + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Natural key of a purged fragment row.", + ), + model=PurgeAppConfigFragmentKeyDTO, + name="PurgeAppConfigFragmentKey", +) +class PurgeAppConfigFragmentKeyGQL(PydanticOutputMixin[PurgeAppConfigFragmentKeyDTO]): + scope_type: AppConfigScopeType = gql_field(description="Scope type.") + scope_id: str = gql_field(description="Scope id.") + name: str = gql_field(description="Policy name.") + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `adminBulkCreateAppConfigFragments`.", + ), + model=AdminBulkCreatePayloadDTO, + name="AdminBulkCreateAppConfigFragmentsPayload", +) +class AdminBulkCreateAppConfigFragmentsPayloadGQL(PydanticOutputMixin[AdminBulkCreatePayloadDTO]): + created: list[AppConfigFragmentGQL] = gql_field(description="Created fragments.") + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field(description="Per-item failures.") + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `adminBulkUpdateAppConfigFragments`.", + ), + model=AdminBulkUpdatePayloadDTO, + name="AdminBulkUpdateAppConfigFragmentsPayload", +) +class AdminBulkUpdateAppConfigFragmentsPayloadGQL(PydanticOutputMixin[AdminBulkUpdatePayloadDTO]): + updated: list[AppConfigFragmentGQL] = gql_field(description="Updated fragments.") + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field(description="Per-item failures.") + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `adminBulkPurgeAppConfigFragments`.", + ), + model=AdminBulkPurgePayloadDTO, + name="AdminBulkPurgeAppConfigFragmentsPayload", +) +class AdminBulkPurgeAppConfigFragmentsPayloadGQL(PydanticOutputMixin[AdminBulkPurgePayloadDTO]): + purged: list[PurgeAppConfigFragmentKeyGQL] = gql_field( + description="Keys of rows actually removed (absent keys are no-oped).", + ) + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field(description="Per-item failures.") + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `bulkCreateMyAppConfigFragments` (recomputed views).", + ), + model=BulkCreateMyPayloadDTO, + name="BulkCreateMyAppConfigFragmentsPayload", +) +class BulkCreateMyAppConfigFragmentsPayloadGQL(PydanticOutputMixin[BulkCreateMyPayloadDTO]): + created: list[AppConfigGQL] = gql_field( + description="Recomputed merged AppConfig views for each created USER fragment.", + ) + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field(description="Per-item failures.") + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `bulkUpdateMyAppConfigFragments` (recomputed views).", + ), + model=BulkUpdateMyPayloadDTO, + name="BulkUpdateMyAppConfigFragmentsPayload", +) +class BulkUpdateMyAppConfigFragmentsPayloadGQL(PydanticOutputMixin[BulkUpdateMyPayloadDTO]): + updated: list[AppConfigGQL] = gql_field( + description="Recomputed merged AppConfig views for each updated USER fragment.", + ) + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field(description="Per-item failures.") diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/filters.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/filters.py new file mode 100644 index 00000000000..44ed3b8d02e --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/filters.py @@ -0,0 +1,62 @@ +"""AppConfigFragment GQL filter / order types.""" + +from __future__ import annotations + +from enum import StrEnum + +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AppConfigFragmentFilter as AppConfigFragmentFilterDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AppConfigFragmentOrder as AppConfigFragmentOrderDTO, +) +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.base import OrderDirection, StringFilter +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_enum, + gql_field, + gql_pydantic_input, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticInputMixin + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Filter input for querying app-config fragments.", + ), + name="AppConfigFragmentFilter", +) +class AppConfigFragmentFilterGQL(PydanticInputMixin[AppConfigFragmentFilterDTO]): + name: StringFilter | None = gql_field(description="Filter by policy name.", default=None) + scope_id: StringFilter | None = gql_field(description="Filter by scope_id.", default=None) + + +@gql_enum( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Fields available for ordering app-config fragments.", + ), + name="AppConfigFragmentOrderField", +) +class AppConfigFragmentOrderFieldGQL(StrEnum): + SCOPE_TYPE = "scope_type" + SCOPE_ID = "scope_id" + NAME = "name" + CREATED_AT = "created_at" + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Specifies ordering for app-config fragment results.", + ), + name="AppConfigFragmentOrderBy", +) +class AppConfigFragmentOrderByGQL(PydanticInputMixin[AppConfigFragmentOrderDTO]): + field: AppConfigFragmentOrderFieldGQL = gql_field(description="The field to order by.") + direction: OrderDirection = gql_field( + description="Sort direction.", + default=OrderDirection.DESC, + ) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/inputs.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/inputs.py new file mode 100644 index 00000000000..d0a417b2c20 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/inputs.py @@ -0,0 +1,28 @@ +"""AppConfigFragment GQL natural-key input shared by bulk types.""" + +from __future__ import annotations + +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AppConfigFragmentKeyInput as AppConfigFragmentKeyInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_field, + gql_pydantic_input, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticInputMixin + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Natural key for an app-config fragment row.", + ), + name="AppConfigFragmentKeyInput", +) +class AppConfigFragmentKeyInputGQL(PydanticInputMixin[AppConfigFragmentKeyInputDTO]): + scope_type: AppConfigScopeType = gql_field(description="Scope type.") + scope_id: str = gql_field(description="Scope id.") + name: str = gql_field(description="Policy name.") diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py new file mode 100644 index 00000000000..e05c4fc2cdf --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py @@ -0,0 +1,48 @@ +"""AppConfigFragment GQL output type.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from ai.backend.common.dto.manager.v2.app_config_fragment.response import AppConfigFragmentNode +from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_enum, + gql_field, + gql_pydantic_type, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin + +# Register the shared DTO enum as a Strawberry type. +AppConfigScopeTypeGQL = gql_enum( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="App-config scope type (BEP-1052 §1).", + ), + AppConfigScopeType, + name="AppConfigScopeType", +) + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Raw per-scope app-config fragment (BEP-1052 §2).", + ), + model=AppConfigFragmentNode, + name="AppConfigFragment", +) +class AppConfigFragmentGQL(PydanticOutputMixin[AppConfigFragmentNode]): + id: UUID = gql_field(description="Fragment row UUID.") + scope_type: AppConfigScopeType = gql_field(description="Scope type.") + scope_id: str = gql_field(description="Scope id.") + name: str = gql_field(description="Policy name (FK to app_config_policies).") + extra_config: dict[str, Any] | None = gql_field( + description="Raw configuration payload, or null." + ) + created_at: datetime = gql_field(description="Creation timestamp.") + updated_at: datetime | None = gql_field(description="Last update timestamp.") diff --git a/src/ai/backend/manager/api/gql/data_loader/data_loaders.py b/src/ai/backend/manager/api/gql/data_loader/data_loaders.py index d03f365d20d..a9104348f6b 100644 --- a/src/ai/backend/manager/api/gql/data_loader/data_loaders.py +++ b/src/ai/backend/manager/api/gql/data_loader/data_loaders.py @@ -14,6 +14,9 @@ from ai.backend.common.dto.manager.v2.rbac.response import EntityNode # pants: no-infer-dep from ai.backend.manager.api.adapters.registry import Adapters # pants: no-infer-dep from ai.backend.manager.api.gql.agent.types import AgentV2GQL # pants: no-infer-dep + from ai.backend.manager.api.gql.app_config_fragment.types import ( # pants: no-infer-dep + AppConfigFragmentGQL, + ) from ai.backend.manager.api.gql.artifact.types import ( # pants: no-infer-dep ArtifactRevision, ) @@ -113,6 +116,22 @@ class DataLoaders: def __init__(self, adapters: Adapters) -> None: self._adapters = adapters + @cached_property + def app_config_fragment_loader( + self, + ) -> DataLoader[uuid.UUID, AppConfigFragmentGQL | None]: + adapter = self._adapters.app_config_fragment + + async def load_fn(ids: list[uuid.UUID]) -> list[AppConfigFragmentGQL | None]: + from ai.backend.manager.api.gql.app_config_fragment.types import ( # pants: no-infer-dep + AppConfigFragmentGQL as F, + ) + + dtos = await adapter.batch_load_by_ids(ids) + return [F.from_pydantic(dto) if dto is not None else None for dto in dtos] + + return DataLoader(load_fn=load_fn) + @cached_property def audit_log_loader( self, diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 55a50492e72..2bb8570d202 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -15,6 +15,21 @@ agent_stats, agents_v2, ) +from .app_config import ( + admin_app_configs, + my_app_configs, + public_app_config_fragments, +) +from .app_config_fragment import ( + admin_app_config_fragments, + admin_bulk_create_app_config_fragments, + admin_bulk_purge_app_config_fragments, + admin_bulk_update_app_config_fragments, + app_config_fragment, + bulk_create_my_app_config_fragments, + bulk_update_my_app_config_fragments, + scoped_app_config_fragments, +) from .app_config_policy import ( admin_bulk_create_app_config_policies, admin_bulk_purge_app_config_policies, @@ -515,6 +530,14 @@ class Query: # App Config Policy APIs (read available to any authenticated user) app_config_policy = app_config_policy app_config_policies = app_config_policies + # App Config Fragment APIs + app_config_fragment = app_config_fragment + scoped_app_config_fragments = scoped_app_config_fragments + admin_app_config_fragments = admin_app_config_fragments + public_app_config_fragments = public_app_config_fragments + # App Config merged view APIs + my_app_configs = my_app_configs + admin_app_configs = admin_app_configs # Prometheus Query Preset APIs (read available to any authenticated user) prometheus_query_preset = prometheus_query_preset prometheus_query_presets = prometheus_query_presets @@ -790,10 +813,16 @@ class Mutation: admin_unblock_user = admin_unblock_user # IP allowlist self-service mutation update_my_allowed_client_ip = update_my_allowed_client_ip - # App Config Policy - Bulk admin mutations (bulk-only) + # App Config Policy - Bulk admin mutations admin_bulk_create_app_config_policies = admin_bulk_create_app_config_policies admin_bulk_update_app_config_policies = admin_bulk_update_app_config_policies admin_bulk_purge_app_config_policies = admin_bulk_purge_app_config_policies + # App Config Fragment - Bulk mutations + admin_bulk_create_app_config_fragments = admin_bulk_create_app_config_fragments + admin_bulk_update_app_config_fragments = admin_bulk_update_app_config_fragments + admin_bulk_purge_app_config_fragments = admin_bulk_purge_app_config_fragments + bulk_create_my_app_config_fragments = bulk_create_my_app_config_fragments + bulk_update_my_app_config_fragments = bulk_update_my_app_config_fragments # Prometheus Query Preset - Admin APIs admin_create_prometheus_query_preset = admin_create_prometheus_query_preset admin_modify_prometheus_query_preset = admin_modify_prometheus_query_preset diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/admin_search_app_configs.py b/src/ai/backend/manager/services/app_config_fragment/actions/admin_search_app_configs.py new file mode 100644 index 00000000000..0b7dfbc4937 --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/admin_search_app_configs.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.types import AppConfigData +from ai.backend.manager.repositories.base import BatchQuerier +from ai.backend.manager.services.app_config_fragment.actions.base import AppConfigFragmentAction + + +@dataclass +class AdminSearchAppConfigsAction(AppConfigFragmentAction): + """Cross-user merged-view search (admin only, BEP-1052 §5).""" + + querier: BatchQuerier + + @override + def entity_id(self) -> str | None: + return None + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.SEARCH + + +@dataclass +class AdminSearchAppConfigsActionResult(BaseActionResult): + items: list[AppConfigData] + 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/get_user_app_config.py b/src/ai/backend/manager/services/app_config_fragment/actions/get_user_app_config.py new file mode 100644 index 00000000000..6899980f7e5 --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/get_user_app_config.py @@ -0,0 +1,34 @@ +import uuid +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.types import AppConfigData +from ai.backend.manager.services.app_config_fragment.actions.base import AppConfigFragmentAction + + +@dataclass +class GetUserAppConfigAction(AppConfigFragmentAction): + """Resolve a single per-user merged AppConfig (BEP-1052 §5).""" + + user_id: uuid.UUID + config_name: str + + @override + def entity_id(self) -> str | None: + return f"{self.user_id}:{self.config_name}" + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.GET + + +@dataclass +class GetUserAppConfigActionResult(BaseActionResult): + app_config: AppConfigData + + @override + def entity_id(self) -> str | None: + return f"{self.app_config.user_id}:{self.app_config.name}" diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/search_user_app_configs.py b/src/ai/backend/manager/services/app_config_fragment/actions/search_user_app_configs.py new file mode 100644 index 00000000000..f2f0b30dc1e --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/search_user_app_configs.py @@ -0,0 +1,40 @@ +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.types import AppConfigData +from ai.backend.manager.repositories.app_config_fragment.types import UserAppConfigSearchScope +from ai.backend.manager.repositories.base import BatchQuerier +from ai.backend.manager.services.app_config_fragment.actions.base import AppConfigFragmentAction + + +@dataclass +class SearchUserAppConfigsAction(AppConfigFragmentAction): + """Scope-bound merged-view search (per-user, BEP-1052 §5).""" + + scope: UserAppConfigSearchScope + querier: BatchQuerier + + @override + def entity_id(self) -> str | None: + return None + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.SEARCH + + +@dataclass +class SearchUserAppConfigsActionResult(BaseActionResult): + items: list[AppConfigData] + 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 index 616a7dd4dac..7dda51ccc81 100644 --- a/src/ai/backend/manager/services/app_config_fragment/processors.py +++ b/src/ai/backend/manager/services/app_config_fragment/processors.py @@ -21,22 +21,34 @@ AdminSearchAppConfigFragmentsAction, AdminSearchAppConfigFragmentsActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.admin_search_app_configs import ( + AdminSearchAppConfigsAction, + AdminSearchAppConfigsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_create_my import ( + BulkCreateMyAppConfigFragmentsAction, + BulkCreateMyAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_update_my import ( + BulkUpdateMyAppConfigFragmentsAction, + BulkUpdateMyAppConfigFragmentsActionResult, +) 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.get_user_app_config import ( + GetUserAppConfigAction, + GetUserAppConfigActionResult, ) from ai.backend.manager.services.app_config_fragment.actions.search import ( SearchAppConfigFragmentsAction, SearchAppConfigFragmentsActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.search_user_app_configs import ( + SearchUserAppConfigsAction, + SearchUserAppConfigsActionResult, +) from ai.backend.manager.services.app_config_fragment.service import AppConfigFragmentService @@ -46,10 +58,18 @@ class AppConfigFragmentProcessors(AbstractProcessorPackage): 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. + # Merged-view (AppConfig, BEP-1052 §5) + get_user_app_config: ActionProcessor[GetUserAppConfigAction, GetUserAppConfigActionResult] + search_user_app_configs: ActionProcessor[ + SearchUserAppConfigsAction, SearchUserAppConfigsActionResult + ] + admin_search_app_configs: ActionProcessor[ + AdminSearchAppConfigsAction, AdminSearchAppConfigsActionResult + ] + # Bulk mutations (BEP-1052 §3) — 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 ] @@ -75,6 +95,13 @@ def __init__( self.get = ActionProcessor(service.get, action_monitors) self.search = ActionProcessor(service.search, action_monitors) self.admin_search = ActionProcessor(service.admin_search, action_monitors) + self.get_user_app_config = ActionProcessor(service.get_user_app_config, action_monitors) + self.search_user_app_configs = ActionProcessor( + service.search_user_app_configs, action_monitors + ) + self.admin_search_app_configs = ActionProcessor( + service.admin_search_app_configs, 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) @@ -87,6 +114,9 @@ def supported_actions(self) -> list[ActionSpec]: GetAppConfigFragmentAction.spec(), SearchAppConfigFragmentsAction.spec(), AdminSearchAppConfigFragmentsAction.spec(), + GetUserAppConfigAction.spec(), + SearchUserAppConfigsAction.spec(), + AdminSearchAppConfigsAction.spec(), AdminBulkCreateAppConfigFragmentsAction.spec(), AdminBulkUpdateAppConfigFragmentsAction.spec(), AdminBulkPurgeAppConfigFragmentsAction.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 index 91bc46be13f..ab1d609b87a 100644 --- a/src/ai/backend/manager/services/app_config_fragment/service.py +++ b/src/ai/backend/manager/services/app_config_fragment/service.py @@ -32,22 +32,34 @@ AdminSearchAppConfigFragmentsAction, AdminSearchAppConfigFragmentsActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.admin_search_app_configs import ( + AdminSearchAppConfigsAction, + AdminSearchAppConfigsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_create_my import ( + BulkCreateMyAppConfigFragmentsAction, + BulkCreateMyAppConfigFragmentsActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_update_my import ( + BulkUpdateMyAppConfigFragmentsAction, + BulkUpdateMyAppConfigFragmentsActionResult, +) 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.get_user_app_config import ( + GetUserAppConfigAction, + GetUserAppConfigActionResult, ) from ai.backend.manager.services.app_config_fragment.actions.search import ( SearchAppConfigFragmentsAction, SearchAppConfigFragmentsActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.search_user_app_configs import ( + SearchUserAppConfigsAction, + SearchUserAppConfigsActionResult, +) log = BraceStyleAdapter(logging.getLogger(__spec__.name)) @@ -90,7 +102,37 @@ async def admin_search( has_previous_page=result.has_previous_page, ) - # ── Bulk mutations (per-item transaction) ───────────────────── + # ── Merged-view reads (AppConfig, BEP-1052 §5) ──────────────── + + async def get_user_app_config( + self, action: GetUserAppConfigAction + ) -> GetUserAppConfigActionResult: + app_config = await self._repository.app_config(action.user_id, action.config_name) + return GetUserAppConfigActionResult(app_config=app_config) + + async def search_user_app_configs( + self, action: SearchUserAppConfigsAction + ) -> SearchUserAppConfigsActionResult: + result = await self._repository.search_app_configs(action.scope, action.querier) + return SearchUserAppConfigsActionResult( + 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_app_configs( + self, action: AdminSearchAppConfigsAction + ) -> AdminSearchAppConfigsActionResult: + result = await self._admin_repository.admin_search_app_configs(action.querier) + return AdminSearchAppConfigsActionResult( + items=result.items, + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + # ── Bulk mutations (BEP-1052 §3, per-item transaction) ──────── async def admin_bulk_create( self, action: AdminBulkCreateAppConfigFragmentsAction From acacdc91e9f7db2ed87dabe4eaa945cc964448b6 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Sat, 25 Apr 2026 14:30:15 +0900 Subject: [PATCH 02/13] chore(BA-5829): add news fragment for #11285 --- changes/11285.feature.md | 1 + .../manager/api/gql/data_loader/data_loaders.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 changes/11285.feature.md diff --git a/changes/11285.feature.md b/changes/11285.feature.md new file mode 100644 index 00000000000..eb03039418d --- /dev/null +++ b/changes/11285.feature.md @@ -0,0 +1 @@ +Add `AppConfigFragment` Strawberry GraphQL surface — `appConfigFragment` (by natural key), `scopedAppConfigFragments`, `adminAppConfigFragments`, and admin `create` / `update` / `purge` mutations (BEP-1052 §2). diff --git a/src/ai/backend/manager/api/gql/data_loader/data_loaders.py b/src/ai/backend/manager/api/gql/data_loader/data_loaders.py index a9104348f6b..bf46fb12c0f 100644 --- a/src/ai/backend/manager/api/gql/data_loader/data_loaders.py +++ b/src/ai/backend/manager/api/gql/data_loader/data_loaders.py @@ -123,12 +123,24 @@ def app_config_fragment_loader( adapter = self._adapters.app_config_fragment async def load_fn(ids: list[uuid.UUID]) -> list[AppConfigFragmentGQL | None]: + from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( # pants: no-infer-dep + AppConfigFragmentFilter, + SearchAppConfigFragmentsInput, + ) from ai.backend.manager.api.gql.app_config_fragment.types import ( # pants: no-infer-dep AppConfigFragmentGQL as F, ) - dtos = await adapter.batch_load_by_ids(ids) - return [F.from_pydantic(dto) if dto is not None else None for dto in dtos] + if not ids: + return [] + payload = await adapter.admin_search( + SearchAppConfigFragmentsInput( + filter=AppConfigFragmentFilter(id_in=list(ids)), + limit=len(ids), + ), + ) + by_id = {dto.id: F.from_pydantic(dto) for dto in payload.items} + return [by_id.get(fragment_id) for fragment_id in ids] return DataLoader(load_fn=load_fn) From 3cbac2116cc0f222bc9d1d22a76749f570496cbd Mon Sep 17 00:00:00 2001 From: jopemachine Date: Sat, 25 Apr 2026 16:00:34 +0900 Subject: [PATCH 03/13] refactor(BA-5829): split AppConfig merged-view into its own adapter; drop scoped GQL root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on PR #11285: - Split the merged-view (AppConfig, BEP-1052 §5) surface and the self-service `my_bulk_*` writes out of `AppConfigFragmentAdapter` into a new `AppConfigAdapter`. Each adapter now handles a single domain DTO surface, matching the project convention. Both adapters share the same `app_config_fragment` service processors — splitting only the transport layer. - Drop the `scoped_app_config_fragments` GQL root resolver. BEP-1052 exposes the scope-bound list as child fields on `DomainV2.appConfigFragments` / `UserV2.appConfigFragments`, not as a root field. The scope-bound REST endpoint `POST /v2/app-config-fragments/{scope_type}/{scope_id}/search` continues to use `AppConfigFragmentAdapter.search()` directly. - Update the GQL resolvers (`my_app_configs`, `admin_app_configs`, `bulk_create_my_app_config_fragments`, `bulk_update_my_app_config_fragments`) to call the new `info.context.adapters.app_config` adapter. - Wire the new `app_config` field through `Adapters.__init__` and `Adapters.create()`. - DataLoader now uses `AppConfigFragmentFilter(id=UUIDFilter(...))` per the new filter shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../manager/api/adapters/app_config.py | 300 ++++++++++++++++++ .../api/adapters/app_config_fragment.py | 237 +------------- .../backend/manager/api/adapters/registry.py | 4 + .../api/gql/app_config/resolver/query.py | 4 +- .../api/gql/app_config_fragment/__init__.py | 4 +- .../app_config_fragment/resolver/__init__.py | 2 - .../app_config_fragment/resolver/mutation.py | 4 +- .../gql/app_config_fragment/resolver/query.py | 54 +--- .../api/gql/data_loader/data_loaders.py | 3 +- src/ai/backend/manager/api/gql/schema.py | 2 - 10 files changed, 330 insertions(+), 284 deletions(-) create mode 100644 src/ai/backend/manager/api/adapters/app_config.py diff --git a/src/ai/backend/manager/api/adapters/app_config.py b/src/ai/backend/manager/api/adapters/app_config.py new file mode 100644 index 00000000000..1c3f73e70fb --- /dev/null +++ b/src/ai/backend/manager/api/adapters/app_config.py @@ -0,0 +1,300 @@ +"""AppConfig (merged view) domain adapter — BEP-1052 §5. + +Reads the per-user merged AppConfig view and writes the underlying USER +fragments via the same `app_config_fragment` service processors. The +merged-view surface lives on its own adapter (separate from +`AppConfigFragmentAdapter`) so each adapter handles a single domain +DTO surface — convention in this repo. +""" + +from __future__ import annotations + +from ai.backend.common.contexts.user import current_user +from ai.backend.common.dto.manager.v2.app_config.request import ( + AppConfigFilter, + AppConfigOrder, + GetUserAppConfigInput, + SearchAppConfigsInput, + SearchMyAppConfigsInput, +) +from ai.backend.common.dto.manager.v2.app_config.response import ( + AppConfigNode, + BulkCreateMyAppConfigFragmentsPayload, + BulkUpdateMyAppConfigFragmentsPayload, + GetUserAppConfigPayload, + SearchAppConfigsPayload, +) +from ai.backend.common.dto.manager.v2.app_config.types import AppConfigOrderField, OrderDirection +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + BulkCreateMyAppConfigFragmentsInput, + BulkUpdateMyAppConfigFragmentsInput, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AppConfigFragmentBulkError, + AppConfigFragmentNode, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import ( + AppConfigScopeType as DTOAppConfigScopeType, +) +from ai.backend.common.exception import UnreachableError +from ai.backend.manager.api.adapter_options.pagination.pagination import PaginationSpec +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.app_config_fragment.bulk_types import ( + AppConfigFragmentBulkItemError, + MyAppConfigFragmentBulkItem, +) +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData +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 UserAppConfigSearchScope +from ai.backend.manager.repositories.base import BatchQuerier, QueryCondition, QueryOrder +from ai.backend.manager.services.app_config_fragment.actions.admin_search_app_configs import ( + AdminSearchAppConfigsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_create_my import ( + BulkCreateMyAppConfigFragmentsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_update_my import ( + BulkUpdateMyAppConfigFragmentsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.get_user_app_config import ( + GetUserAppConfigAction, +) +from ai.backend.manager.services.app_config_fragment.actions.search_user_app_configs import ( + SearchUserAppConfigsAction, +) + +from .base import BaseAdapter + + +class AppConfigAdapter(BaseAdapter): + """Adapter for the merged AppConfig view (BEP-1052 §5). + + Backed by the `app_config_fragment` service processors — the merged + view is computed from raw fragments — but exposed as a separate + transport-layer surface so the Fragment adapter stays focused on + raw-row operations. + """ + + # ── Reads ──────────────────────────────────────────────────────── + + async def my_app_config(self, name: str) -> GetUserAppConfigPayload: + """Read the caller's own merged AppConfig for `name`. + + Resolves the current user from the context; there is no way to + target another user through this method. + """ + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + result = await self._processors.app_config_fragment.get_user_app_config.wait_for_complete( + GetUserAppConfigAction(user_id=me.user_id, config_name=name) + ) + return GetUserAppConfigPayload(item=self._data_to_dto(result.app_config)) + + async def admin_get_user_app_config( + self, input: GetUserAppConfigInput + ) -> GetUserAppConfigPayload: + """Read a specific user's merged AppConfig (admin only).""" + result = await self._processors.app_config_fragment.get_user_app_config.wait_for_complete( + GetUserAppConfigAction(user_id=input.user_id, config_name=input.name) + ) + return GetUserAppConfigPayload(item=self._data_to_dto(result.app_config)) + + async def my_search_app_configs( + self, input: SearchMyAppConfigsInput + ) -> SearchAppConfigsPayload: + """Paginated merged-view search over the caller's own AppConfigs.""" + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + querier = self._build_querier_from_input(input) + result = ( + await self._processors.app_config_fragment.search_user_app_configs.wait_for_complete( + SearchUserAppConfigsAction( + scope=UserAppConfigSearchScope(user_id=me.user_id), + querier=querier, + ) + ) + ) + return SearchAppConfigsPayload( + 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_app_configs( + self, input: SearchAppConfigsInput + ) -> SearchAppConfigsPayload: + """Cross-user merged-view search (admin only). + + `filter.user_id` pins the query to a single user; otherwise + pagination walks across every user. + """ + querier = self._build_querier_from_input(input) + result = ( + await self._processors.app_config_fragment.admin_search_app_configs.wait_for_complete( + AdminSearchAppConfigsAction(querier=querier) + ) + ) + return SearchAppConfigsPayload( + 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, + ) + + # ── Self-service bulk writes (BEP-1052 §3) ─────────────────────── + # + # 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 my_bulk_create( + self, input: BulkCreateMyAppConfigFragmentsInput + ) -> BulkCreateMyAppConfigFragmentsPayload: + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + items = [ + MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) + for item in input.items + ] + wrapper = await self._processors.app_config_fragment.bulk_create_my.wait_for_complete( + BulkCreateMyAppConfigFragmentsAction( + entity_ids=[], + user_id=me.user_id, + items=items, + ) + ) + result = wrapper.result + return BulkCreateMyAppConfigFragmentsPayload( + created=[self._data_to_dto(item) for item in result.created], + failed=[self._bulk_error_to_dto(err) for err in result.failed], + ) + + async def my_bulk_update( + self, input: BulkUpdateMyAppConfigFragmentsInput + ) -> BulkUpdateMyAppConfigFragmentsPayload: + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + items = [ + MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) + for item in input.items + ] + wrapper = await self._processors.app_config_fragment.bulk_update_my.wait_for_complete( + BulkUpdateMyAppConfigFragmentsAction( + entity_ids=[], + user_id=me.user_id, + items=items, + ) + ) + result = wrapper.result + return BulkUpdateMyAppConfigFragmentsPayload( + updated=[self._data_to_dto(item) for item in result.updated], + failed=[self._bulk_error_to_dto(err) for err in result.failed], + ) + + # ── Querier / DTO helpers ──────────────────────────────────────── + + _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: SearchMyAppConfigsInput | SearchAppConfigsInput, + ) -> BatchQuerier: + """Querier builder for the merged-view searches. + + The merged-view SQL resolves cursor / order internally via the + repository layer; this helper forwards only the filter / order / + pagination fields so cursor tiebreakers stay consistent with + the raw-fragment querier. + """ + 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: AppConfigFilter) -> list[QueryCondition]: + conditions: list[QueryCondition] = [] + 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) + # `filter.user_id` handling lives inside the merged-view SQL + # (repository layer) rather than in a BatchQuerier condition — + # see `AppConfigFragmentDBSource.admin_search_app_configs`. + return conditions + + @staticmethod + def _convert_orders(orders: list[AppConfigOrder]) -> list[QueryOrder]: + result: list[QueryOrder] = [] + for order in orders: + ascending = order.direction == OrderDirection.ASC + match order.field: + case AppConfigOrderField.NAME: + result.append(AppConfigFragmentOrders.name(ascending)) + case AppConfigOrderField.USER_ID: + # USER_ID ordering is applied inside the merged-view SQL + # because the raw `app_config_fragments` row does not + # carry a user_id column directly. + continue + return result + + def _data_to_dto(self, data: AppConfigData) -> AppConfigNode: + return AppConfigNode( + user_id=data.user_id, + name=data.name, + fragments=[self._fragment_data_to_dto(fragment) for fragment in data.fragments], + config=dict(data.config) if data.config is not None else None, + ) + + @staticmethod + def _fragment_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, + extra_config=dict(data.extra_config) if data.extra_config is not None else None, + created_at=data.created_at, + updated_at=data.updated_at, + ) + + @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/app_config_fragment.py b/src/ai/backend/manager/api/adapters/app_config_fragment.py index 5762627ea9b..d4738852b2f 100644 --- a/src/ai/backend/manager/api/adapters/app_config_fragment.py +++ b/src/ai/backend/manager/api/adapters/app_config_fragment.py @@ -1,25 +1,11 @@ -"""AppConfigFragment domain adapter — Pydantic-in / Pydantic-out transport layer.""" +"""AppConfigFragment domain adapter — Pydantic-in / Pydantic-out transport layer. + +Raw fragment-row operations only. The merged-view (AppConfig) surface +and the self-service `my_bulk_*` writes live on `AppConfigAdapter`. +""" from __future__ import annotations -from ai.backend.common.contexts.user import current_user -from ai.backend.common.dto.manager.v2.app_config.request import ( - AppConfigFilter, - AppConfigOrder, - GetUserAppConfigInput, - SearchAppConfigsInput, - SearchMyAppConfigsInput, -) -from ai.backend.common.dto.manager.v2.app_config.response import ( - AppConfigNode, - BulkCreateMyAppConfigFragmentsPayload, - BulkUpdateMyAppConfigFragmentsPayload, - GetUserAppConfigPayload, - SearchAppConfigsPayload, -) -from ai.backend.common.dto.manager.v2.app_config.types import ( - AppConfigOrderField, -) from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( AdminBulkCreateAppConfigFragmentsInput, AdminBulkPurgeAppConfigFragmentsInput, @@ -27,8 +13,6 @@ AppConfigFragmentFilter, AppConfigFragmentKeyInput, AppConfigFragmentOrder, - BulkCreateMyAppConfigFragmentsInput, - BulkUpdateMyAppConfigFragmentsInput, SearchAppConfigFragmentsInput, ) from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( @@ -49,13 +33,10 @@ AppConfigScopeType as DTOAppConfigScopeType, ) from ai.backend.common.dto.manager.v2.app_config_fragment.types import OrderDirection -from ai.backend.common.exception import UnreachableError from ai.backend.manager.api.adapter_options.pagination.pagination import PaginationSpec -from ai.backend.manager.data.app_config.types import AppConfigData from ai.backend.manager.data.app_config_fragment.bulk_types import ( AppConfigFragmentBulkItem, AppConfigFragmentBulkItemError, - MyAppConfigFragmentBulkItem, ) from ai.backend.manager.data.app_config_fragment.types import ( AppConfigFragmentData, @@ -65,10 +46,7 @@ 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, - UserAppConfigSearchScope, -) +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, @@ -82,34 +60,21 @@ from ai.backend.manager.services.app_config_fragment.actions.admin_search import ( AdminSearchAppConfigFragmentsAction, ) -from ai.backend.manager.services.app_config_fragment.actions.admin_search_app_configs import ( - AdminSearchAppConfigsAction, -) -from ai.backend.manager.services.app_config_fragment.actions.bulk_create_my import ( - BulkCreateMyAppConfigFragmentsAction, -) -from ai.backend.manager.services.app_config_fragment.actions.bulk_update_my import ( - BulkUpdateMyAppConfigFragmentsAction, -) from ai.backend.manager.services.app_config_fragment.actions.get import GetAppConfigFragmentAction -from ai.backend.manager.services.app_config_fragment.actions.get_user_app_config import ( - GetUserAppConfigAction, -) from ai.backend.manager.services.app_config_fragment.actions.search import ( SearchAppConfigFragmentsAction, ) -from ai.backend.manager.services.app_config_fragment.actions.search_user_app_configs import ( - SearchUserAppConfigsAction, -) from .base import BaseAdapter class AppConfigFragmentAdapter(BaseAdapter): - """Adapter for AppConfigFragment domain operations. + """Adapter for AppConfigFragment raw-row operations (BEP-1052 §2). Writes are bulk-only (BEP-1052 §3); single-item create / update / - purge entry points are intentionally absent. + purge entry points are intentionally absent. Self-service my_bulk + writes (which return the recomputed merged view) live on + `AppConfigAdapter` alongside the merged-view reads. """ async def get(self, key_input: AppConfigFragmentKeyInput) -> GetAppConfigFragmentPayload: @@ -261,142 +226,6 @@ def _data_to_dto(data: AppConfigFragmentData) -> AppConfigFragmentNode: updated_at=data.updated_at, ) - # ── Merged-view (AppConfig, BEP-1052 §5) ─────────────────────── - - async def my_app_config(self, name: str) -> GetUserAppConfigPayload: - """Read the caller's own merged AppConfig for `name`. - - Resolves the current user from the context; there is no way to - target another user through this method. - """ - me = current_user() - if me is None: - raise UnreachableError("User context is not available") - result = await self._processors.app_config_fragment.get_user_app_config.wait_for_complete( - GetUserAppConfigAction(user_id=me.user_id, config_name=name) - ) - return GetUserAppConfigPayload(item=self._app_config_data_to_dto(result.app_config)) - - async def admin_get_user_app_config( - self, input: GetUserAppConfigInput - ) -> GetUserAppConfigPayload: - """Read a specific user's merged AppConfig (admin only).""" - result = await self._processors.app_config_fragment.get_user_app_config.wait_for_complete( - GetUserAppConfigAction(user_id=input.user_id, config_name=input.name) - ) - return GetUserAppConfigPayload(item=self._app_config_data_to_dto(result.app_config)) - - async def my_search_app_configs( - self, input: SearchMyAppConfigsInput - ) -> SearchAppConfigsPayload: - """Paginated merged-view search over the caller's own AppConfigs.""" - me = current_user() - if me is None: - raise UnreachableError("User context is not available") - querier = self._build_app_config_querier(input) - result = ( - await self._processors.app_config_fragment.search_user_app_configs.wait_for_complete( - SearchUserAppConfigsAction( - scope=UserAppConfigSearchScope(user_id=me.user_id), - querier=querier, - ) - ) - ) - return SearchAppConfigsPayload( - items=[self._app_config_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_app_configs( - self, input: SearchAppConfigsInput - ) -> SearchAppConfigsPayload: - """Cross-user merged-view search (admin only). - - `filter.user_id` pins the query to a single user; otherwise - pagination walks across every user. - """ - querier = self._build_app_config_querier(input) - result = ( - await self._processors.app_config_fragment.admin_search_app_configs.wait_for_complete( - AdminSearchAppConfigsAction(querier=querier) - ) - ) - return SearchAppConfigsPayload( - items=[self._app_config_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, - ) - - def _build_app_config_querier( - self, - input: SearchMyAppConfigsInput | SearchAppConfigsInput, - ) -> BatchQuerier: - """Querier builder shared by `my_search_app_configs` and - `admin_search_app_configs`. - - The merged-view SQL resolves cursor/order internally via the - repository layer; this helper forwards only the filter / order / - pagination fields so cursor tiebreakers stay consistent with - the raw-fragment querier. - """ - conditions = self._convert_app_config_filter(input.filter) if input.filter else [] - orders = self._convert_app_config_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_app_config_filter(self, filter: AppConfigFilter) -> list[QueryCondition]: - conditions: list[QueryCondition] = [] - 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) - # `filter.user_id` handling lives inside the merged-view SQL - # (repository layer) rather than in a BatchQuerier condition — - # see `AppConfigFragmentDBSource.admin_search_app_configs`. - return conditions - - @staticmethod - def _convert_app_config_orders(orders: list[AppConfigOrder]) -> list[QueryOrder]: - result: list[QueryOrder] = [] - for order in orders: - ascending = order.direction == OrderDirection.ASC - match order.field: - case AppConfigOrderField.NAME: - result.append(AppConfigFragmentOrders.name(ascending)) - case AppConfigOrderField.USER_ID: - # USER_ID ordering is applied inside the merged-view SQL - # because the raw `app_config_fragments` row does not - # carry a user_id column directly. - continue - return result - - def _app_config_data_to_dto(self, data: AppConfigData) -> AppConfigNode: - return AppConfigNode( - user_id=data.user_id, - name=data.name, - fragments=[self._data_to_dto(fragment) for fragment in data.fragments], - config=dict(data.config) if data.config is not None else None, - ) - # ── Bulk mutations (BEP-1052 §3) ─────────────────────────────── # # Each bulk processor returns a `BulkProcessResult[T]` whose @@ -462,52 +291,6 @@ async def admin_bulk_purge( failed=[self._bulk_error_to_dto(err) for err in result.failed], ) - async def my_bulk_create( - self, input: BulkCreateMyAppConfigFragmentsInput - ) -> BulkCreateMyAppConfigFragmentsPayload: - me = current_user() - if me is None: - raise UnreachableError("User context is not available") - items = [ - MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) - for item in input.items - ] - wrapper = await self._processors.app_config_fragment.bulk_create_my.wait_for_complete( - BulkCreateMyAppConfigFragmentsAction( - entity_ids=[item.name for item in items], - user_id=me.user_id, - items=items, - ) - ) - result = wrapper.result - return BulkCreateMyAppConfigFragmentsPayload( - created=[self._app_config_data_to_dto(item) for item in result.created], - failed=[self._bulk_error_to_dto(err) for err in result.failed], - ) - - async def my_bulk_update( - self, input: BulkUpdateMyAppConfigFragmentsInput - ) -> BulkUpdateMyAppConfigFragmentsPayload: - me = current_user() - if me is None: - raise UnreachableError("User context is not available") - items = [ - MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) - for item in input.items - ] - wrapper = await self._processors.app_config_fragment.bulk_update_my.wait_for_complete( - BulkUpdateMyAppConfigFragmentsAction( - entity_ids=[item.name for item in items], - user_id=me.user_id, - items=items, - ) - ) - result = wrapper.result - return BulkUpdateMyAppConfigFragmentsPayload( - updated=[self._app_config_data_to_dto(item) for item in result.updated], - failed=[self._bulk_error_to_dto(err) for err in result.failed], - ) - @staticmethod def _bulk_error_to_dto( err: AppConfigFragmentBulkItemError, diff --git a/src/ai/backend/manager/api/adapters/registry.py b/src/ai/backend/manager/api/adapters/registry.py index 460ff3f89ad..7bedef690b6 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 import AppConfigAdapter 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 @@ -73,6 +74,7 @@ class Adapters: def __init__( self, agent: AgentAdapter, + app_config: AppConfigAdapter, app_config_fragment: AppConfigFragmentAdapter, app_config_policy: AppConfigPolicyAdapter, artifact: ArtifactAdapter, @@ -115,6 +117,7 @@ def __init__( vfs_storage: VFSStorageAdapter, ) -> None: self.agent = agent + self.app_config = app_config self.app_config_fragment = app_config_fragment self.app_config_policy = app_config_policy self.artifact = artifact @@ -176,6 +179,7 @@ def create( """ return cls( agent=AgentAdapter(processors), + app_config=AppConfigAdapter(processors), app_config_fragment=AppConfigFragmentAdapter(processors), app_config_policy=AppConfigPolicyAdapter(processors), artifact=ArtifactAdapter(processors), diff --git a/src/ai/backend/manager/api/gql/app_config/resolver/query.py b/src/ai/backend/manager/api/gql/app_config/resolver/query.py index 4b567fc5d47..bbb2fd1fdb0 100644 --- a/src/ai/backend/manager/api/gql/app_config/resolver/query.py +++ b/src/ai/backend/manager/api/gql/app_config/resolver/query.py @@ -51,7 +51,7 @@ async def my_app_configs( limit: int | None = None, offset: int | None = None, ) -> list[AppConfigGQL]: - payload = await info.context.adapters.app_config_fragment.my_search_app_configs( + payload = await info.context.adapters.app_config.my_search_app_configs( SearchMyAppConfigsInput( filter=filter.to_pydantic() if filter else None, order=[o.to_pydantic() for o in order_by] if order_by else None, @@ -88,7 +88,7 @@ async def admin_app_configs( offset: int | None = None, ) -> list[AppConfigGQL]: check_admin_only() - payload = await info.context.adapters.app_config_fragment.admin_search_app_configs( + payload = await info.context.adapters.app_config.admin_search_app_configs( SearchAppConfigsInput( filter=filter.to_pydantic() if filter else None, order=[o.to_pydantic() for o in order_by] if order_by else None, diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py index 6ad3c54f465..41fdd5ca833 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py @@ -8,7 +8,6 @@ app_config_fragment, bulk_create_my_app_config_fragments, bulk_update_my_app_config_fragments, - scoped_app_config_fragments, ) from .types import ( AppConfigFragmentFilterGQL, @@ -20,9 +19,8 @@ ) __all__ = [ - # Queries + # Queries — scope-bound list belongs on DomainV2 / UserV2 child fields per BEP-1052 "app_config_fragment", - "scoped_app_config_fragments", "admin_app_config_fragments", # Bulk mutations (BEP-1052 §3 — bulk-only) "admin_bulk_create_app_config_fragments", diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py index 9dc335de709..88e7f3a3692 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py @@ -8,7 +8,6 @@ from .query import ( admin_app_config_fragments, app_config_fragment, - scoped_app_config_fragments, ) __all__ = [ @@ -19,5 +18,4 @@ "app_config_fragment", "bulk_create_my_app_config_fragments", "bulk_update_my_app_config_fragments", - "scoped_app_config_fragments", ] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py index 9b7506d01a3..fa8722678f7 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py @@ -89,7 +89,7 @@ async def bulk_create_my_app_config_fragments( info: Info[StrawberryGQLContext], input: BulkCreateMyAppConfigFragmentInputGQL, ) -> BulkCreateMyAppConfigFragmentsPayloadGQL: - result = await info.context.adapters.app_config_fragment.my_bulk_create(input.to_pydantic()) + result = await info.context.adapters.app_config.my_bulk_create(input.to_pydantic()) return BulkCreateMyAppConfigFragmentsPayloadGQL.from_pydantic(result) @@ -106,5 +106,5 @@ async def bulk_update_my_app_config_fragments( info: Info[StrawberryGQLContext], input: BulkUpdateMyAppConfigFragmentInputGQL, ) -> BulkUpdateMyAppConfigFragmentsPayloadGQL: - result = await info.context.adapters.app_config_fragment.my_bulk_update(input.to_pydantic()) + result = await info.context.adapters.app_config.my_bulk_update(input.to_pydantic()) return BulkUpdateMyAppConfigFragmentsPayloadGQL.from_pydantic(result) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py index 7c93ab1f3b5..d7e82491786 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py @@ -1,4 +1,12 @@ -"""AppConfigFragment GQL query resolvers.""" +"""AppConfigFragment GQL query resolvers. + +Per BEP-1052 §2 the scope-bound list is exposed via child fields on +`DomainV2.appConfigFragments` / `UserV2.appConfigFragments`, not as a +root resolver. Only the single-row read and the cross-scope admin +search live here. The scope-bound REST endpoint +`POST /v2/app-config-fragments/{scope_type}/{scope_id}/search` +continues to use the adapter's `search()` method directly. +""" from __future__ import annotations @@ -21,9 +29,6 @@ ) from ai.backend.manager.api.gql.types import StrawberryGQLContext from ai.backend.manager.api.gql.utils import check_admin_only -from ai.backend.manager.data.app_config_fragment.types import ( - AppConfigScopeType as DataAppConfigScopeType, -) @gql_root_field( @@ -50,47 +55,6 @@ async def app_config_fragment( return AppConfigFragmentGQL.from_pydantic(payload.item) -@gql_root_field( - BackendAIGQLMeta( - added_version=NEXT_RELEASE_VERSION, - description=( - "Scope-bound app-config fragment list. Caller pins " - "`(scope_type, scope_id)` so non-admin users only see fragments within " - "their own scope (BEP-1052 §2)." - ), - ) -) # type: ignore[misc] -async def scoped_app_config_fragments( - info: Info[StrawberryGQLContext], - scope_type: AppConfigScopeType, - scope_id: str, - filter: AppConfigFragmentFilterGQL | None = None, - order_by: list[AppConfigFragmentOrderByGQL] | None = None, - first: int | None = None, - after: str | None = None, - last: int | None = None, - before: str | None = None, - limit: int | None = None, - offset: int | None = None, -) -> list[AppConfigFragmentGQL]: - search_input = SearchAppConfigFragmentsInput( - filter=filter.to_pydantic() if filter else None, - order=[o.to_pydantic() for o in order_by] if order_by else None, - first=first, - after=after, - last=last, - before=before, - limit=limit, - offset=offset, - ) - payload = await info.context.adapters.app_config_fragment.search( - scope_type=DataAppConfigScopeType(scope_type.value), - scope_id=scope_id, - input=search_input, - ) - return [AppConfigFragmentGQL.from_pydantic(node) for node in payload.items] - - @gql_root_field( BackendAIGQLMeta( added_version=NEXT_RELEASE_VERSION, diff --git a/src/ai/backend/manager/api/gql/data_loader/data_loaders.py b/src/ai/backend/manager/api/gql/data_loader/data_loaders.py index bf46fb12c0f..07c4930e450 100644 --- a/src/ai/backend/manager/api/gql/data_loader/data_loaders.py +++ b/src/ai/backend/manager/api/gql/data_loader/data_loaders.py @@ -123,6 +123,7 @@ def app_config_fragment_loader( adapter = self._adapters.app_config_fragment async def load_fn(ids: list[uuid.UUID]) -> list[AppConfigFragmentGQL | None]: + from ai.backend.common.dto.manager.query import UUIDFilter # pants: no-infer-dep from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( # pants: no-infer-dep AppConfigFragmentFilter, SearchAppConfigFragmentsInput, @@ -135,7 +136,7 @@ async def load_fn(ids: list[uuid.UUID]) -> list[AppConfigFragmentGQL | None]: return [] payload = await adapter.admin_search( SearchAppConfigFragmentsInput( - filter=AppConfigFragmentFilter(id_in=list(ids)), + filter=AppConfigFragmentFilter(id=UUIDFilter.model_validate({"in": list(ids)})), limit=len(ids), ), ) diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 2bb8570d202..17027be2a3d 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -28,7 +28,6 @@ app_config_fragment, bulk_create_my_app_config_fragments, bulk_update_my_app_config_fragments, - scoped_app_config_fragments, ) from .app_config_policy import ( admin_bulk_create_app_config_policies, @@ -532,7 +531,6 @@ class Query: app_config_policies = app_config_policies # App Config Fragment APIs app_config_fragment = app_config_fragment - scoped_app_config_fragments = scoped_app_config_fragments admin_app_config_fragments = admin_app_config_fragments public_app_config_fragments = public_app_config_fragments # App Config merged view APIs From 5fa323320e2253c920ac3bc5d420944866dd7af5 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Sat, 25 Apr 2026 17:16:39 +0900 Subject: [PATCH 04/13] fix(BA-5829): clean BEP refs, drop unused type:ignore, break circular import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per #11285 review: - Strip the BEP-1052 §X annotations from descriptions, comments, and docstrings — the BEP number doesn't help future readers and clutters the schema. - Move `BulkCreate/UpdateMyAppConfigFragmentsPayloadGQL` from `app_config_fragment/types/bulk_payloads.py` into `app_config/types/bulk_payloads.py`. They referenced `AppConfigGQL` while `AppConfigGQL` already references `AppConfigFragmentGQL`, so splitting them keeps the import direction one-way and resolves the `tests/component/user/test_keypair_ops.py` collection error reported by `test-component`. - Drop unused `# type: ignore[misc]` on `gql_mutation` decorators (the helper preserves the wrapped function's signature, so mypy no longer treats the result as untyped). --- .../dto/manager/v2/app_config/__init__.py | 2 +- .../dto/manager/v2/app_config/request.py | 4 +- .../dto/manager/v2/app_config/response.py | 6 +- .../common/dto/manager/v2/app_config/types.py | 2 +- .../manager/api/adapters/app_config.py | 24 ++------ .../api/adapters/app_config_fragment.py | 6 +- .../manager/api/gql/app_config/__init__.py | 2 +- .../api/gql/app_config/resolver/query.py | 6 +- .../api/gql/app_config/types/__init__.py | 6 ++ .../api/gql/app_config/types/bulk_payloads.py | 55 +++++++++++++++++++ .../manager/api/gql/app_config/types/node.py | 4 +- .../api/gql/app_config_fragment/__init__.py | 4 +- .../app_config_fragment/resolver/mutation.py | 18 +++--- .../gql/app_config_fragment/resolver/query.py | 2 +- .../gql/app_config_fragment/types/__init__.py | 4 -- .../app_config_fragment/types/bulk_inputs.py | 4 +- .../types/bulk_payloads.py | 45 +++------------ .../api/gql/app_config_fragment/types/node.py | 4 +- .../actions/admin_search_app_configs.py | 2 +- .../actions/get_user_app_config.py | 2 +- .../actions/search_user_app_configs.py | 2 +- .../app_config_fragment/processors.py | 4 +- .../services/app_config_fragment/service.py | 4 +- 23 files changed, 113 insertions(+), 99 deletions(-) create mode 100644 src/ai/backend/manager/api/gql/app_config/types/bulk_payloads.py diff --git a/src/ai/backend/common/dto/manager/v2/app_config/__init__.py b/src/ai/backend/common/dto/manager/v2/app_config/__init__.py index a2fec176b3f..b83e6f70857 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config/__init__.py +++ b/src/ai/backend/common/dto/manager/v2/app_config/__init__.py @@ -1,5 +1,5 @@ """ -AppConfig (merged view) DTOs v2 for Manager API (BEP-1052 §5). +AppConfig (merged view) DTOs v2 for Manager API. """ from .request import ( diff --git a/src/ai/backend/common/dto/manager/v2/app_config/request.py b/src/ai/backend/common/dto/manager/v2/app_config/request.py index c105039ca40..93e101ba3fd 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config/request.py +++ b/src/ai/backend/common/dto/manager/v2/app_config/request.py @@ -1,5 +1,5 @@ """ -Request DTOs for AppConfig (merged view) DTO v2 (BEP-1052 §5). +Request DTOs for AppConfig (merged view) DTO v2. """ from __future__ import annotations @@ -62,7 +62,7 @@ class SearchMyAppConfigsInput(_AppConfigSearchInputBase): """Input for self-service merged-view search (`/v2/app-configs/my/search`). The adapter pins the caller as the user scope; no `user_id` argument - is accepted here (BEP-1052 §5 — `filter.userId` is ignored). + is accepted here. """ diff --git a/src/ai/backend/common/dto/manager/v2/app_config/response.py b/src/ai/backend/common/dto/manager/v2/app_config/response.py index 80126da7acf..987ddde51de 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config/response.py +++ b/src/ai/backend/common/dto/manager/v2/app_config/response.py @@ -1,5 +1,5 @@ """ -Response DTOs for AppConfig (merged view) DTO v2 (BEP-1052 §5). +Response DTOs for AppConfig (merged view) DTO v2. """ from __future__ import annotations @@ -25,7 +25,7 @@ class AppConfigNode(BaseResponseModel): - """Merged per-user AppConfig view (BEP-1052 §5). + """Merged per-user AppConfig view. `fragments` are ordered low → high merge priority (matching the policy's `scope_sources`). `config` is the deep-merged result, @@ -66,7 +66,7 @@ class BulkCreateMyAppConfigFragmentsPayload(BaseResponseModel): """Payload for `bulkCreateMyAppConfigFragments`. Each successfully created row produces a recomputed merged - `AppConfigNode`; failures are collected per-item (BEP-1052 §3). + `AppConfigNode`; failures are collected per-item. """ created: list[AppConfigNode] = Field( diff --git a/src/ai/backend/common/dto/manager/v2/app_config/types.py b/src/ai/backend/common/dto/manager/v2/app_config/types.py index f2d8c5e6757..84d255475c2 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config/types.py +++ b/src/ai/backend/common/dto/manager/v2/app_config/types.py @@ -1,5 +1,5 @@ """ -Common types for AppConfig (merged view) DTO v2 (BEP-1052 §5). +Common types for AppConfig (merged view) DTO v2. """ from __future__ import annotations diff --git a/src/ai/backend/manager/api/adapters/app_config.py b/src/ai/backend/manager/api/adapters/app_config.py index 1c3f73e70fb..996b665aa3b 100644 --- a/src/ai/backend/manager/api/adapters/app_config.py +++ b/src/ai/backend/manager/api/adapters/app_config.py @@ -1,4 +1,4 @@ -"""AppConfig (merged view) domain adapter — BEP-1052 §5. +"""AppConfig (merged view) domain adapter Reads the per-user merged AppConfig view and writes the underlying USER fragments via the same `app_config_fragment` service processors. The @@ -69,7 +69,7 @@ class AppConfigAdapter(BaseAdapter): - """Adapter for the merged AppConfig view (BEP-1052 §5). + """Adapter for the merged AppConfig view. Backed by the `app_config_fragment` service processors — the merged view is computed from raw fragments — but exposed as a separate @@ -146,7 +146,7 @@ async def admin_search_app_configs( has_previous_page=result.has_previous_page, ) - # ── Self-service bulk writes (BEP-1052 §3) ─────────────────────── + # ── Self-service bulk writes ─────────────────────── # # Each bulk processor returns a `BulkProcessResult[T]` whose # `.result` field is the underlying `*ActionResult` produced by the @@ -156,19 +156,12 @@ async def admin_search_app_configs( async def my_bulk_create( self, input: BulkCreateMyAppConfigFragmentsInput ) -> BulkCreateMyAppConfigFragmentsPayload: - me = current_user() - if me is None: - raise UnreachableError("User context is not available") items = [ MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) for item in input.items ] wrapper = await self._processors.app_config_fragment.bulk_create_my.wait_for_complete( - BulkCreateMyAppConfigFragmentsAction( - entity_ids=[], - user_id=me.user_id, - items=items, - ) + BulkCreateMyAppConfigFragmentsAction(entity_ids=[], items=items) ) result = wrapper.result return BulkCreateMyAppConfigFragmentsPayload( @@ -179,19 +172,12 @@ async def my_bulk_create( async def my_bulk_update( self, input: BulkUpdateMyAppConfigFragmentsInput ) -> BulkUpdateMyAppConfigFragmentsPayload: - me = current_user() - if me is None: - raise UnreachableError("User context is not available") items = [ MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) for item in input.items ] wrapper = await self._processors.app_config_fragment.bulk_update_my.wait_for_complete( - BulkUpdateMyAppConfigFragmentsAction( - entity_ids=[], - user_id=me.user_id, - items=items, - ) + BulkUpdateMyAppConfigFragmentsAction(entity_ids=[], items=items) ) result = wrapper.result return BulkUpdateMyAppConfigFragmentsPayload( diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment.py b/src/ai/backend/manager/api/adapters/app_config_fragment.py index d4738852b2f..08cc7cc41ee 100644 --- a/src/ai/backend/manager/api/adapters/app_config_fragment.py +++ b/src/ai/backend/manager/api/adapters/app_config_fragment.py @@ -69,9 +69,9 @@ class AppConfigFragmentAdapter(BaseAdapter): - """Adapter for AppConfigFragment raw-row operations (BEP-1052 §2). + """Adapter for AppConfigFragment raw-row operations. - Writes are bulk-only (BEP-1052 §3); single-item create / update / + Writes are bulk-only; single-item create / update / purge entry points are intentionally absent. Self-service my_bulk writes (which return the recomputed merged view) live on `AppConfigAdapter` alongside the merged-view reads. @@ -226,7 +226,7 @@ def _data_to_dto(data: AppConfigFragmentData) -> AppConfigFragmentNode: updated_at=data.updated_at, ) - # ── Bulk mutations (BEP-1052 §3) ─────────────────────────────── + # ── Bulk mutations ─────────────────────────────── # # Each bulk processor returns a `BulkProcessResult[T]` whose # `.result` field is the underlying `*ActionResult` produced by the diff --git a/src/ai/backend/manager/api/gql/app_config/__init__.py b/src/ai/backend/manager/api/gql/app_config/__init__.py index f11c775bc6b..ae6d637d8a0 100644 --- a/src/ai/backend/manager/api/gql/app_config/__init__.py +++ b/src/ai/backend/manager/api/gql/app_config/__init__.py @@ -1,4 +1,4 @@ -"""AppConfig (merged view) GraphQL API package (BEP-1052 §5).""" +"""AppConfig (merged view) GraphQL API package.""" from .resolver import ( admin_app_configs, diff --git a/src/ai/backend/manager/api/gql/app_config/resolver/query.py b/src/ai/backend/manager/api/gql/app_config/resolver/query.py index bbb2fd1fdb0..755ca4bdc0d 100644 --- a/src/ai/backend/manager/api/gql/app_config/resolver/query.py +++ b/src/ai/backend/manager/api/gql/app_config/resolver/query.py @@ -1,4 +1,4 @@ -"""AppConfig (merged view) GQL query resolvers (BEP-1052 §5).""" +"""AppConfig (merged view) GQL query resolvers.""" from __future__ import annotations @@ -36,7 +36,7 @@ added_version=NEXT_RELEASE_VERSION, description=( "Caller's own merged AppConfig list (auth required). Chain per policy " - "(BEP-1052 §5); the adapter pins `(USER, current_user)` internally." + "; the adapter pins `(USER, current_user)` internally." ), ) ) # type: ignore[misc] @@ -108,7 +108,7 @@ async def admin_app_configs( added_version=NEXT_RELEASE_VERSION, description=( "Public (no-auth) `PUBLIC`-scope app-config fragments — the subset of " - "raw fragments that carry no personally-scoped data (BEP-1052 §3)." + "raw fragments that carry no personally-scoped data." ), ) ) # type: ignore[misc] diff --git a/src/ai/backend/manager/api/gql/app_config/types/__init__.py b/src/ai/backend/manager/api/gql/app_config/types/__init__.py index c38511359aa..348d6812455 100644 --- a/src/ai/backend/manager/api/gql/app_config/types/__init__.py +++ b/src/ai/backend/manager/api/gql/app_config/types/__init__.py @@ -1,3 +1,7 @@ +from .bulk_payloads import ( + BulkCreateMyAppConfigFragmentsPayloadGQL, + BulkUpdateMyAppConfigFragmentsPayloadGQL, +) from .filters import ( AppConfigFilterGQL, AppConfigOrderByGQL, @@ -10,4 +14,6 @@ "AppConfigGQL", "AppConfigOrderByGQL", "AppConfigOrderFieldGQL", + "BulkCreateMyAppConfigFragmentsPayloadGQL", + "BulkUpdateMyAppConfigFragmentsPayloadGQL", ] diff --git a/src/ai/backend/manager/api/gql/app_config/types/bulk_payloads.py b/src/ai/backend/manager/api/gql/app_config/types/bulk_payloads.py new file mode 100644 index 00000000000..508867d3819 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/types/bulk_payloads.py @@ -0,0 +1,55 @@ +"""AppConfig (merged-view) GQL payloads for self-service bulk mutations.""" + +from __future__ import annotations + +from ai.backend.common.dto.manager.v2.app_config.response import ( + BulkCreateMyAppConfigFragmentsPayload as BulkCreateMyPayloadDTO, +) +from ai.backend.common.dto.manager.v2.app_config.response import ( + BulkUpdateMyAppConfigFragmentsPayload as BulkUpdateMyPayloadDTO, +) +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config.types.node import AppConfigGQL +from ai.backend.manager.api.gql.app_config_fragment.types.bulk_payloads import ( + AppConfigFragmentBulkErrorGQL, +) +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_field, + gql_pydantic_type, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `bulkCreateMyAppConfigFragments` (recomputed views).", + ), + model=BulkCreateMyPayloadDTO, + name="BulkCreateMyAppConfigFragmentsPayload", +) +class BulkCreateMyAppConfigFragmentsPayloadGQL(PydanticOutputMixin[BulkCreateMyPayloadDTO]): + created: list[AppConfigGQL] = gql_field( + description="Recomputed merged AppConfig views for each created USER fragment.", + ) + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field( + description="Per-item failures.", + ) + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `bulkUpdateMyAppConfigFragments` (recomputed views).", + ), + model=BulkUpdateMyPayloadDTO, + name="BulkUpdateMyAppConfigFragmentsPayload", +) +class BulkUpdateMyAppConfigFragmentsPayloadGQL(PydanticOutputMixin[BulkUpdateMyPayloadDTO]): + updated: list[AppConfigGQL] = gql_field( + description="Recomputed merged AppConfig views for each updated USER fragment.", + ) + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field( + description="Per-item failures.", + ) diff --git a/src/ai/backend/manager/api/gql/app_config/types/node.py b/src/ai/backend/manager/api/gql/app_config/types/node.py index 949bc4a2d6e..3e9ba59fa3c 100644 --- a/src/ai/backend/manager/api/gql/app_config/types/node.py +++ b/src/ai/backend/manager/api/gql/app_config/types/node.py @@ -1,4 +1,4 @@ -"""AppConfig (merged view) GQL output types (BEP-1052 §5).""" +"""AppConfig (merged view) GQL output types.""" from __future__ import annotations @@ -20,7 +20,7 @@ BackendAIGQLMeta( added_version=NEXT_RELEASE_VERSION, description=( - "Merged per-user AppConfig view (BEP-1052 §5). `fragments` are ordered " + "Merged per-user AppConfig view. `fragments` are ordered " "low → high merge priority; `config` is the deep-merge result and is " "null when every contributing fragment is empty." ), diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py index 41fdd5ca833..5598eb952ef 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py @@ -19,10 +19,10 @@ ) __all__ = [ - # Queries — scope-bound list belongs on DomainV2 / UserV2 child fields per BEP-1052 + # Queries — scope-bound list belongs on DomainV2 / UserV2 child fields "app_config_fragment", "admin_app_config_fragments", - # Bulk mutations (BEP-1052 §3 — bulk-only) + # Bulk mutations "admin_bulk_create_app_config_fragments", "admin_bulk_update_app_config_fragments", "admin_bulk_purge_app_config_fragments", diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py index fa8722678f7..6e230b8cb89 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py @@ -1,10 +1,14 @@ -"""AppConfigFragment GQL mutation resolvers (bulk-only, BEP-1052 §3).""" +"""AppConfigFragment GQL mutation resolvers (bulk-only).""" from __future__ import annotations from strawberry import Info from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config.types import ( + BulkCreateMyAppConfigFragmentsPayloadGQL, + BulkUpdateMyAppConfigFragmentsPayloadGQL, +) from ai.backend.manager.api.gql.app_config_fragment.types import ( AdminBulkCreateAppConfigFragmentInputGQL, AdminBulkCreateAppConfigFragmentsPayloadGQL, @@ -13,9 +17,7 @@ AdminBulkUpdateAppConfigFragmentInputGQL, AdminBulkUpdateAppConfigFragmentsPayloadGQL, BulkCreateMyAppConfigFragmentInputGQL, - BulkCreateMyAppConfigFragmentsPayloadGQL, BulkUpdateMyAppConfigFragmentInputGQL, - BulkUpdateMyAppConfigFragmentsPayloadGQL, ) from ai.backend.manager.api.gql.decorators import ( BackendAIGQLMeta, @@ -33,7 +35,7 @@ "and failures are collected per-item (admin only)." ), ) -) # type: ignore[misc] +) async def admin_bulk_create_app_config_fragments( info: Info[StrawberryGQLContext], input: AdminBulkCreateAppConfigFragmentInputGQL, @@ -51,7 +53,7 @@ async def admin_bulk_create_app_config_fragments( "(admin only)." ), ) -) # type: ignore[misc] +) async def admin_bulk_update_app_config_fragments( info: Info[StrawberryGQLContext], input: AdminBulkUpdateAppConfigFragmentInputGQL, @@ -66,7 +68,7 @@ async def admin_bulk_update_app_config_fragments( added_version=NEXT_RELEASE_VERSION, description="Cleanup-only deletion; absent keys are no-oped (admin only).", ) -) # type: ignore[misc] +) async def admin_bulk_purge_app_config_fragments( info: Info[StrawberryGQLContext], input: AdminBulkPurgeAppConfigFragmentInputGQL, @@ -84,7 +86,7 @@ async def admin_bulk_purge_app_config_fragments( "Returns recomputed merged `AppConfig` views." ), ) -) # type: ignore[misc] +) async def bulk_create_my_app_config_fragments( info: Info[StrawberryGQLContext], input: BulkCreateMyAppConfigFragmentInputGQL, @@ -101,7 +103,7 @@ async def bulk_create_my_app_config_fragments( "failures. Returns recomputed merged `AppConfig` views." ), ) -) # type: ignore[misc] +) async def bulk_update_my_app_config_fragments( info: Info[StrawberryGQLContext], input: BulkUpdateMyAppConfigFragmentInputGQL, diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py index d7e82491786..8648ccf0a69 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py @@ -1,6 +1,6 @@ """AppConfigFragment GQL query resolvers. -Per BEP-1052 §2 the scope-bound list is exposed via child fields on +Per the scope-bound list is exposed via child fields on `DomainV2.appConfigFragments` / `UserV2.appConfigFragments`, not as a root resolver. Only the single-row read and the cross-scope admin search live here. The scope-bound REST endpoint diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py index 25df893733c..2f914855a21 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py @@ -12,8 +12,6 @@ AdminBulkPurgeAppConfigFragmentsPayloadGQL, AdminBulkUpdateAppConfigFragmentsPayloadGQL, AppConfigFragmentBulkErrorGQL, - BulkCreateMyAppConfigFragmentsPayloadGQL, - BulkUpdateMyAppConfigFragmentsPayloadGQL, PurgeAppConfigFragmentKeyGQL, ) from .filters import ( @@ -40,9 +38,7 @@ "AppConfigFragmentOrderFieldGQL", "AppConfigScopeTypeGQL", "BulkCreateMyAppConfigFragmentInputGQL", - "BulkCreateMyAppConfigFragmentsPayloadGQL", "BulkUpdateMyAppConfigFragmentInputGQL", - "BulkUpdateMyAppConfigFragmentsPayloadGQL", "MyAppConfigFragmentItemInputGQL", "PurgeAppConfigFragmentKeyGQL", ] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py index 80137c802d5..d9ded6a81bb 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py @@ -1,4 +1,4 @@ -"""AppConfigFragment bulk-mutation GQL input types (BEP-1052 §3).""" +"""AppConfigFragment bulk-mutation GQL input types.""" from __future__ import annotations @@ -52,7 +52,7 @@ class AdminAppConfigFragmentItemInputGQL(PydanticInputMixin[AdminItemInputDTO]): @gql_pydantic_input( BackendAIGQLMeta( added_version=NEXT_RELEASE_VERSION, - description="Admin bulk create input — items carry any scope (BEP-1052 §3).", + description="Admin bulk create input — items carry any scope.", ), name="AdminBulkCreateAppConfigFragmentInput", ) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_payloads.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_payloads.py index d17bfab9cd2..25daaf10b4e 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_payloads.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_payloads.py @@ -1,13 +1,13 @@ -"""AppConfigFragment bulk-mutation GQL payload types (BEP-1052 §3).""" +"""AppConfigFragment bulk-mutation GQL payload types (admin only). + +Self-service `my_*` bulk payloads return recomputed merged AppConfig +views, so they live in `app_config/types/bulk_payloads.py` to keep the +import direction `app_config -> app_config_fragment` (one-way) and +avoid a circular import. +""" from __future__ import annotations -from ai.backend.common.dto.manager.v2.app_config.response import ( - BulkCreateMyAppConfigFragmentsPayload as BulkCreateMyPayloadDTO, -) -from ai.backend.common.dto.manager.v2.app_config.response import ( - BulkUpdateMyAppConfigFragmentsPayload as BulkUpdateMyPayloadDTO, -) from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( AdminBulkCreateAppConfigFragmentsPayload as AdminBulkCreatePayloadDTO, ) @@ -25,7 +25,6 @@ ) from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION -from ai.backend.manager.api.gql.app_config.types.node import AppConfigGQL from ai.backend.manager.api.gql.app_config_fragment.types.node import AppConfigFragmentGQL from ai.backend.manager.api.gql.decorators import ( BackendAIGQLMeta, @@ -104,33 +103,3 @@ class AdminBulkPurgeAppConfigFragmentsPayloadGQL(PydanticOutputMixin[AdminBulkPu description="Keys of rows actually removed (absent keys are no-oped).", ) failed: list[AppConfigFragmentBulkErrorGQL] = gql_field(description="Per-item failures.") - - -@gql_pydantic_type( - BackendAIGQLMeta( - added_version=NEXT_RELEASE_VERSION, - description="Payload for `bulkCreateMyAppConfigFragments` (recomputed views).", - ), - model=BulkCreateMyPayloadDTO, - name="BulkCreateMyAppConfigFragmentsPayload", -) -class BulkCreateMyAppConfigFragmentsPayloadGQL(PydanticOutputMixin[BulkCreateMyPayloadDTO]): - created: list[AppConfigGQL] = gql_field( - description="Recomputed merged AppConfig views for each created USER fragment.", - ) - failed: list[AppConfigFragmentBulkErrorGQL] = gql_field(description="Per-item failures.") - - -@gql_pydantic_type( - BackendAIGQLMeta( - added_version=NEXT_RELEASE_VERSION, - description="Payload for `bulkUpdateMyAppConfigFragments` (recomputed views).", - ), - model=BulkUpdateMyPayloadDTO, - name="BulkUpdateMyAppConfigFragmentsPayload", -) -class BulkUpdateMyAppConfigFragmentsPayloadGQL(PydanticOutputMixin[BulkUpdateMyPayloadDTO]): - updated: list[AppConfigGQL] = gql_field( - description="Recomputed merged AppConfig views for each updated USER fragment.", - ) - failed: list[AppConfigFragmentBulkErrorGQL] = gql_field(description="Per-item failures.") diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py index e05c4fc2cdf..f83fbb41c09 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py @@ -21,7 +21,7 @@ AppConfigScopeTypeGQL = gql_enum( BackendAIGQLMeta( added_version=NEXT_RELEASE_VERSION, - description="App-config scope type (BEP-1052 §1).", + description="App-config scope type.", ), AppConfigScopeType, name="AppConfigScopeType", @@ -31,7 +31,7 @@ @gql_pydantic_type( BackendAIGQLMeta( added_version=NEXT_RELEASE_VERSION, - description="Raw per-scope app-config fragment (BEP-1052 §2).", + description="Raw per-scope app-config fragment.", ), model=AppConfigFragmentNode, name="AppConfigFragment", diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/admin_search_app_configs.py b/src/ai/backend/manager/services/app_config_fragment/actions/admin_search_app_configs.py index 0b7dfbc4937..ce84afac2c9 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/admin_search_app_configs.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/admin_search_app_configs.py @@ -12,7 +12,7 @@ @dataclass class AdminSearchAppConfigsAction(AppConfigFragmentAction): - """Cross-user merged-view search (admin only, BEP-1052 §5).""" + """Cross-user merged-view search(admin only).""" querier: BatchQuerier diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/get_user_app_config.py b/src/ai/backend/manager/services/app_config_fragment/actions/get_user_app_config.py index 6899980f7e5..74923db4d3e 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/get_user_app_config.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/get_user_app_config.py @@ -10,7 +10,7 @@ @dataclass class GetUserAppConfigAction(AppConfigFragmentAction): - """Resolve a single per-user merged AppConfig (BEP-1052 §5).""" + """Resolve a single per-user merged AppConfig.""" user_id: uuid.UUID config_name: str diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/search_user_app_configs.py b/src/ai/backend/manager/services/app_config_fragment/actions/search_user_app_configs.py index f2f0b30dc1e..6dc1d28b85e 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/search_user_app_configs.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/search_user_app_configs.py @@ -13,7 +13,7 @@ @dataclass class SearchUserAppConfigsAction(AppConfigFragmentAction): - """Scope-bound merged-view search (per-user, BEP-1052 §5).""" + """Scope-bound merged-view search(per-user).""" scope: UserAppConfigSearchScope querier: BatchQuerier diff --git a/src/ai/backend/manager/services/app_config_fragment/processors.py b/src/ai/backend/manager/services/app_config_fragment/processors.py index 7dda51ccc81..ca6bc41734a 100644 --- a/src/ai/backend/manager/services/app_config_fragment/processors.py +++ b/src/ai/backend/manager/services/app_config_fragment/processors.py @@ -58,7 +58,7 @@ class AppConfigFragmentProcessors(AbstractProcessorPackage): admin_search: ActionProcessor[ AdminSearchAppConfigFragmentsAction, AdminSearchAppConfigFragmentsActionResult ] - # Merged-view (AppConfig, BEP-1052 §5) + # Merged-view (AppConfig) get_user_app_config: ActionProcessor[GetUserAppConfigAction, GetUserAppConfigActionResult] search_user_app_configs: ActionProcessor[ SearchUserAppConfigsAction, SearchUserAppConfigsActionResult @@ -66,7 +66,7 @@ class AppConfigFragmentProcessors(AbstractProcessorPackage): admin_search_app_configs: ActionProcessor[ AdminSearchAppConfigsAction, AdminSearchAppConfigsActionResult ] - # Bulk mutations (BEP-1052 §3) — wrapped by BulkActionProcessor so + # 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. diff --git a/src/ai/backend/manager/services/app_config_fragment/service.py b/src/ai/backend/manager/services/app_config_fragment/service.py index ab1d609b87a..bc6300e3f1b 100644 --- a/src/ai/backend/manager/services/app_config_fragment/service.py +++ b/src/ai/backend/manager/services/app_config_fragment/service.py @@ -102,7 +102,7 @@ async def admin_search( has_previous_page=result.has_previous_page, ) - # ── Merged-view reads (AppConfig, BEP-1052 §5) ──────────────── + # ── Merged-view reads (AppConfig) ──────────────── async def get_user_app_config( self, action: GetUserAppConfigAction @@ -132,7 +132,7 @@ async def admin_search_app_configs( has_previous_page=result.has_previous_page, ) - # ── Bulk mutations (BEP-1052 §3, per-item transaction) ──────── + # ── Bulk mutations ──────── async def admin_bulk_create( self, action: AdminBulkCreateAppConfigFragmentsAction From 414552c09973df55f23176927de3621c6748bfd3 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Sat, 25 Apr 2026 17:29:49 +0900 Subject: [PATCH 05/13] fix(BA-5829): break AppConfig <-> AppConfigFragment GQL import cycle `app_config/types/node.py` imports `AppConfigFragmentGQL` (AppConfig's `fragments` field), which loads `app_config_fragment/__init__.py`. That used to eagerly re-export the mutation resolvers, which then imported back from `app_config.types.bulk_payloads` -> `node` (still mid-load) -> ImportError. Three small surgeries break the cycle: - `app_config_fragment/__init__.py` no longer re-exports its resolver symbols; `schema.py` now imports them straight from the resolver submodules. - `app_config/types/__init__.py` no longer re-exports the My-bulk payload GQL types, so loading the package no longer drags in `bulk_payloads.py` before `node.py` finishes. - `mutation.py` imports the My-bulk payload types from the `bulk_payloads` submodule directly. `tests/component/user/test_keypair_ops.py` collection (and the `api-updated` schema-dump step) used to fail because of this cycle. --- .../api/gql/app_config/types/__init__.py | 6 --- .../api/gql/app_config_fragment/__init__.py | 45 ++++--------------- .../app_config_fragment/resolver/mutation.py | 2 +- src/ai/backend/manager/api/gql/schema.py | 8 ++-- 4 files changed, 14 insertions(+), 47 deletions(-) diff --git a/src/ai/backend/manager/api/gql/app_config/types/__init__.py b/src/ai/backend/manager/api/gql/app_config/types/__init__.py index 348d6812455..c38511359aa 100644 --- a/src/ai/backend/manager/api/gql/app_config/types/__init__.py +++ b/src/ai/backend/manager/api/gql/app_config/types/__init__.py @@ -1,7 +1,3 @@ -from .bulk_payloads import ( - BulkCreateMyAppConfigFragmentsPayloadGQL, - BulkUpdateMyAppConfigFragmentsPayloadGQL, -) from .filters import ( AppConfigFilterGQL, AppConfigOrderByGQL, @@ -14,6 +10,4 @@ "AppConfigGQL", "AppConfigOrderByGQL", "AppConfigOrderFieldGQL", - "BulkCreateMyAppConfigFragmentsPayloadGQL", - "BulkUpdateMyAppConfigFragmentsPayloadGQL", ] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py index 5598eb952ef..3ca89260fe6 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py @@ -1,38 +1,9 @@ -"""AppConfigFragment GraphQL API package.""" +"""AppConfigFragment GraphQL API package. -from .resolver import ( - admin_app_config_fragments, - admin_bulk_create_app_config_fragments, - admin_bulk_purge_app_config_fragments, - admin_bulk_update_app_config_fragments, - app_config_fragment, - bulk_create_my_app_config_fragments, - bulk_update_my_app_config_fragments, -) -from .types import ( - AppConfigFragmentFilterGQL, - AppConfigFragmentGQL, - AppConfigFragmentKeyInputGQL, - AppConfigFragmentOrderByGQL, - AppConfigFragmentOrderFieldGQL, - AppConfigScopeTypeGQL, -) - -__all__ = [ - # Queries — scope-bound list belongs on DomainV2 / UserV2 child fields - "app_config_fragment", - "admin_app_config_fragments", - # Bulk mutations - "admin_bulk_create_app_config_fragments", - "admin_bulk_update_app_config_fragments", - "admin_bulk_purge_app_config_fragments", - "bulk_create_my_app_config_fragments", - "bulk_update_my_app_config_fragments", - # Types - "AppConfigFragmentGQL", - "AppConfigScopeTypeGQL", - "AppConfigFragmentFilterGQL", - "AppConfigFragmentOrderByGQL", - "AppConfigFragmentOrderFieldGQL", - "AppConfigFragmentKeyInputGQL", -] +Resolver and type names are re-exported by ``schema.py`` directly via +their submodules to keep this package's ``__init__`` import-light: a +top-level ``from app_config_fragment import ...`` would otherwise drag +in the mutation resolvers, which back-import from +``app_config.types.bulk_payloads`` and form an import cycle when +``AppConfigGQL`` is loading. +""" diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py index 6e230b8cb89..7ababb48d15 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py @@ -5,7 +5,7 @@ from strawberry import Info from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION -from ai.backend.manager.api.gql.app_config.types import ( +from ai.backend.manager.api.gql.app_config.types.bulk_payloads import ( BulkCreateMyAppConfigFragmentsPayloadGQL, BulkUpdateMyAppConfigFragmentsPayloadGQL, ) diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 17027be2a3d..8050b63ee00 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -20,15 +20,17 @@ my_app_configs, public_app_config_fragments, ) -from .app_config_fragment import ( - admin_app_config_fragments, +from .app_config_fragment.resolver.mutation import ( admin_bulk_create_app_config_fragments, admin_bulk_purge_app_config_fragments, admin_bulk_update_app_config_fragments, - app_config_fragment, bulk_create_my_app_config_fragments, bulk_update_my_app_config_fragments, ) +from .app_config_fragment.resolver.query import ( + admin_app_config_fragments, + app_config_fragment, +) from .app_config_policy import ( admin_bulk_create_app_config_policies, admin_bulk_purge_app_config_policies, From 95b42240adb290de39ebc9c51376feaf88d0a898 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Sat, 25 Apr 2026 17:37:28 +0900 Subject: [PATCH 06/13] fix(BA-5829): use strawberry JSON scalar for opaque config payloads Strawberry's schema builder can't resolve `dict[str, Any]` to a GraphQL output/input type and fails the manager startup with `AppConfigFragment fields cannot be resolved. Unexpected type 'dict[str, typing.Any]'`. Switch the three opaque payload fields (`AppConfigGQL.config`, `AppConfigFragmentGQL.extra_config`, and the bulk-input `extra_config`) to `strawberry.scalars.JSON`, matching how `service_catalog`, `agent`, `deployment`, and `notification` already expose JSON-shaped fields. --- src/ai/backend/manager/api/gql/app_config/types/node.py | 5 +++-- .../api/gql/app_config_fragment/types/bulk_inputs.py | 6 +++--- .../manager/api/gql/app_config_fragment/types/node.py | 5 +++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/ai/backend/manager/api/gql/app_config/types/node.py b/src/ai/backend/manager/api/gql/app_config/types/node.py index 3e9ba59fa3c..14ca5c83035 100644 --- a/src/ai/backend/manager/api/gql/app_config/types/node.py +++ b/src/ai/backend/manager/api/gql/app_config/types/node.py @@ -2,9 +2,10 @@ from __future__ import annotations -from typing import Any from uuid import UUID +from strawberry.scalars import JSON + from ai.backend.common.dto.manager.v2.app_config.response import AppConfigNode from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION from ai.backend.manager.api.gql.app_config_fragment.types.node import AppConfigFragmentGQL @@ -34,6 +35,6 @@ class AppConfigGQL(PydanticOutputMixin[AppConfigNode]): fragments: list[AppConfigFragmentGQL] = gql_field( description="Contributing fragments in merge order (low → high).", ) - config: dict[str, Any] | None = gql_field( + config: JSON | None = gql_field( description="Deep-merged configuration, or null when every fragment is empty.", ) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py index d9ded6a81bb..31fa136cec3 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from strawberry.scalars import JSON from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( AdminAppConfigFragmentItemInput as AdminItemInputDTO, @@ -46,7 +46,7 @@ ) class AdminAppConfigFragmentItemInputGQL(PydanticInputMixin[AdminItemInputDTO]): key: AppConfigFragmentKeyInputGQL = gql_field(description="Natural-key identifier.") - extra_config: dict[str, Any] = gql_field(description="Raw configuration payload.") + extra_config: JSON = gql_field(description="Raw configuration payload.") @gql_pydantic_input( @@ -91,7 +91,7 @@ class AdminBulkPurgeAppConfigFragmentInputGQL(PydanticInputMixin[AdminBulkPurgeI ) class MyAppConfigFragmentItemInputGQL(PydanticInputMixin[MyItemInputDTO]): name: str = gql_field(description="Policy name.") - extra_config: dict[str, Any] = gql_field(description="Raw configuration payload.") + extra_config: JSON = gql_field(description="Raw configuration payload.") @gql_pydantic_input( diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py index f83fbb41c09..089338d34e6 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py @@ -3,9 +3,10 @@ from __future__ import annotations from datetime import datetime -from typing import Any from uuid import UUID +from strawberry.scalars import JSON + from ai.backend.common.dto.manager.v2.app_config_fragment.response import AppConfigFragmentNode from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION @@ -41,7 +42,7 @@ class AppConfigFragmentGQL(PydanticOutputMixin[AppConfigFragmentNode]): scope_type: AppConfigScopeType = gql_field(description="Scope type.") scope_id: str = gql_field(description="Scope id.") name: str = gql_field(description="Policy name (FK to app_config_policies).") - extra_config: dict[str, Any] | None = gql_field( + extra_config: JSON | None = gql_field( description="Raw configuration payload, or null." ) created_at: datetime = gql_field(description="Creation timestamp.") From 1230657d9ad96bf0bac6eea6fd84788b7c3b5e1b Mon Sep 17 00:00:00 2001 From: jopemachine Date: Sat, 25 Apr 2026 17:50:11 +0900 Subject: [PATCH 07/13] fix(BA-5829): drop explicit AppConfigScopeType GQL registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gql_enum(...)` returns a wrapped enum class, which Strawberry registers under `"AppConfigScopeType"`. Other GQL modules use the bare DTO enum as a field annotation, and Strawberry auto-registers that under the same name on first encounter — failing the schema build with `Type AppConfigScopeType is defined multiple times`. Drop the explicit registration and let Strawberry auto-register from field references; re-export the GQL alias as a plain rebind so existing `AppConfigScopeTypeGQL` imports keep working. --- .../api/gql/app_config_fragment/types/node.py | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py index 089338d34e6..d90e144c8cb 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py @@ -12,21 +12,17 @@ from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION from ai.backend.manager.api.gql.decorators import ( BackendAIGQLMeta, - gql_enum, gql_field, gql_pydantic_type, ) from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin -# Register the shared DTO enum as a Strawberry type. -AppConfigScopeTypeGQL = gql_enum( - BackendAIGQLMeta( - added_version=NEXT_RELEASE_VERSION, - description="App-config scope type.", - ), - AppConfigScopeType, - name="AppConfigScopeType", -) +# The shared DTO enum is auto-registered by Strawberry the first time it +# is referenced as a typed field. Re-export under the ``GQL`` suffix so +# other modules can write `from ... import AppConfigScopeTypeGQL`. Calling +# `strawberry.enum(...)` here would clash with that auto-registration +# under the same `"AppConfigScopeType"` name. +AppConfigScopeTypeGQL = AppConfigScopeType @gql_pydantic_type( @@ -42,8 +38,6 @@ class AppConfigFragmentGQL(PydanticOutputMixin[AppConfigFragmentNode]): scope_type: AppConfigScopeType = gql_field(description="Scope type.") scope_id: str = gql_field(description="Scope id.") name: str = gql_field(description="Policy name (FK to app_config_policies).") - extra_config: JSON | None = gql_field( - description="Raw configuration payload, or null." - ) + extra_config: JSON | None = gql_field(description="Raw configuration payload, or null.") created_at: datetime = gql_field(description="Creation timestamp.") updated_at: datetime | None = gql_field(description="Last update timestamp.") From c9937acd4848329b6cef2ba284e9cebcb7cf776f Mon Sep 17 00:00:00 2001 From: jopemachine Date: Sat, 25 Apr 2026 18:56:01 +0900 Subject: [PATCH 08/13] chore(BA-5829): regenerate api schema dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fragment + AppConfig GraphQL types added in this PR were never reflected in the committed dump — the live manager schema and the docs were out of sync. Re-running `dump-gql-schema` + `generate-supergraph` from this branch lands the new types (`AppConfigFragment`, `AppConfig`, `myAppConfigs`, `adminBulkCreate/Update/PurgeAppConfigFragments`, etc.) in the same PR that introduces them on the Python side. --- .../graphql-reference/supergraph.graphql | 381 ++++++++++++++++-- .../graphql-reference/v2-schema.graphql | 335 +++++++++++++-- 2 files changed, 670 insertions(+), 46 deletions(-) diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 650ed1f12a5..30892d1dd6b 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -191,10 +191,19 @@ type AddRevisionPayload revision: ModelRevision! } -""" -Added in UNRELEASED. Per-item input for admin bulk create — `config_name` + initial `scope_sources`. -""" -input AdminAppConfigPolicyCreateItemInput +"""Added in UNRELEASED. Per-item input for admin bulk create / update.""" +input AdminAppConfigFragmentItemInput + @join__type(graph: STRAWBERRY) +{ + """Natural-key identifier.""" + key: AppConfigFragmentKeyInput! + + """Raw configuration payload.""" + extraConfig: JSON! +} + +"""Added in UNRELEASED. Per-item input for admin bulk create / update.""" +input AdminAppConfigPolicyItemInput @join__type(graph: STRAWBERRY) { """Unique, immutable policy name.""" @@ -204,17 +213,23 @@ input AdminAppConfigPolicyCreateItemInput scopeSources: [String!]! } -""" -Added in UNRELEASED. Per-item input for admin bulk update — target row id + new `scope_sources`. -""" -input AdminAppConfigPolicyUpdateItemInput +"""Added in UNRELEASED. Admin bulk create input — items carry any scope.""" +input AdminBulkCreateAppConfigFragmentInput @join__type(graph: STRAWBERRY) { - """Policy row id.""" - id: UUID! + """Rows to create.""" + items: [AdminAppConfigFragmentItemInput!]! +} - """Ordered scope chain.""" - scopeSources: [String!]! +"""Added in UNRELEASED. Payload for `adminBulkCreateAppConfigFragments`.""" +type AdminBulkCreateAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Created fragments.""" + created: [AppConfigFragment!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! } """Added in UNRELEASED. Payload for `adminBulkCreateAppConfigPolicies`.""" @@ -233,28 +248,68 @@ input AdminBulkCreateAppConfigPolicyInput @join__type(graph: STRAWBERRY) { """Policies to create.""" - items: [AdminAppConfigPolicyCreateItemInput!]! + items: [AdminAppConfigPolicyItemInput!]! +} + +""" +Added in UNRELEASED. Admin bulk purge input — keyed on `AppConfigFragmentKey`. +""" +input AdminBulkPurgeAppConfigFragmentInput + @join__type(graph: STRAWBERRY) +{ + """Natural keys to purge.""" + keys: [AppConfigFragmentKeyInput!]! +} + +"""Added in UNRELEASED. Payload for `adminBulkPurgeAppConfigFragments`.""" +type AdminBulkPurgeAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Keys of rows actually removed (absent keys are no-oped).""" + purged: [PurgeAppConfigFragmentKey!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! } """Added in UNRELEASED. Payload for `adminBulkPurgeAppConfigPolicies`.""" type AdminBulkPurgeAppConfigPoliciesPayload @join__type(graph: STRAWBERRY) { - """Ids of policies actually removed (absent ids no-oped).""" - purgedIds: [UUID!]! + """`config_name`s of policies actually removed (absent names no-oped).""" + purgedConfigNames: [String!]! """Per-item failures.""" failed: [AppConfigPolicyBulkError!]! } """ -Added in UNRELEASED. Admin bulk purge input for app-config policies (keyed on row id). +Added in UNRELEASED. Admin bulk purge input for app-config policies (keyed on `config_name`). """ input AdminBulkPurgeAppConfigPolicyInput @join__type(graph: STRAWBERRY) { - """Policy row ids to purge.""" - ids: [UUID!]! + """`config_name`s to purge.""" + configNames: [String!]! +} + +"""Added in UNRELEASED. Admin bulk update input.""" +input AdminBulkUpdateAppConfigFragmentInput + @join__type(graph: STRAWBERRY) +{ + """Rows to update.""" + items: [AdminAppConfigFragmentItemInput!]! +} + +"""Added in UNRELEASED. Payload for `adminBulkUpdateAppConfigFragments`.""" +type AdminBulkUpdateAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Updated fragments.""" + updated: [AppConfigFragment!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! } """Added in UNRELEASED. Payload for `adminBulkUpdateAppConfigPolicies`.""" @@ -273,7 +328,7 @@ input AdminBulkUpdateAppConfigPolicyInput @join__type(graph: STRAWBERRY) { """Policies to update.""" - items: [AdminAppConfigPolicyUpdateItemInput!]! + items: [AdminAppConfigPolicyItemInput!]! } """Added in 26.4.2. Admin input for creating a keypair for a user.""" @@ -980,7 +1035,154 @@ type AllowedResourceGroupsPayload items: [String!]! } -"""Added in UNRELEASED. Scoped app-config policy.""" +""" +Added in UNRELEASED. Merged per-user AppConfig view. `fragments` are ordered low → high merge priority; `config` is the deep-merge result and is null when every contributing fragment is empty. +""" +type AppConfig + @join__type(graph: STRAWBERRY) +{ + """Target user's UUID.""" + userId: UUID! + + """Policy / config name.""" + name: String! + + """Contributing fragments in merge order (low → high).""" + fragments: [AppConfigFragment!]! + + """Deep-merged configuration, or null when every fragment is empty.""" + config: JSON +} + +"""Added in UNRELEASED. Filter input for querying merged AppConfigs.""" +input AppConfigFilter + @join__type(graph: STRAWBERRY) +{ + """Filter by policy name.""" + name: StringFilter = null + + """Filter by target user id (admin cross-user search only).""" + userId: UUIDFilter = null +} + +"""Added in UNRELEASED. Raw per-scope app-config fragment.""" +type AppConfigFragment + @join__type(graph: STRAWBERRY) +{ + """Row ID.""" + id: UUID! + + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name (FK target).""" + name: String! + + """Raw configuration payload, or null.""" + extraConfig: JSON + + """Creation timestamp.""" + createdAt: DateTime! + + """Last update timestamp.""" + updatedAt: DateTime +} + +""" +Added in UNRELEASED. Per-item failure info for bulk Fragment mutations. +""" +type AppConfigFragmentBulkError + @join__type(graph: STRAWBERRY) +{ + """Original position in the input list.""" + index: Int! + + """Scope type of the failed row.""" + scopeType: AppConfigScopeType! + + """Scope id of the failed row.""" + scopeId: String! + + """Policy name of the failed row.""" + name: String! + + """Reason for the failure.""" + message: String! +} + +"""Added in UNRELEASED. Filter input for querying app-config fragments.""" +input AppConfigFragmentFilter + @join__type(graph: STRAWBERRY) +{ + """Filter by policy name.""" + name: StringFilter = null + + """Filter by scope_id.""" + scopeId: StringFilter = null +} + +"""Added in UNRELEASED. Natural key for an app-config fragment row.""" +input AppConfigFragmentKeyInput + @join__type(graph: STRAWBERRY) +{ + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name.""" + name: String! +} + +""" +Added in UNRELEASED. Specifies ordering for app-config fragment results. +""" +input AppConfigFragmentOrderBy + @join__type(graph: STRAWBERRY) +{ + """The field to order by.""" + field: AppConfigFragmentOrderField! + + """Sort direction.""" + direction: OrderDirection! = DESC +} + +""" +Added in UNRELEASED. Fields available for ordering app-config fragments. +""" +enum AppConfigFragmentOrderField + @join__type(graph: STRAWBERRY) +{ + SCOPE_TYPE @join__enumValue(graph: STRAWBERRY) + SCOPE_ID @join__enumValue(graph: STRAWBERRY) + NAME @join__enumValue(graph: STRAWBERRY) + CREATED_AT @join__enumValue(graph: STRAWBERRY) +} + +"""Added in UNRELEASED. Specifies ordering for merged AppConfig results.""" +input AppConfigOrderBy + @join__type(graph: STRAWBERRY) +{ + """The field to order by.""" + field: AppConfigOrderField! + + """Sort direction.""" + direction: OrderDirection! = ASC +} + +"""Added in UNRELEASED. Fields available for ordering merged AppConfigs.""" +enum AppConfigOrderField + @join__type(graph: STRAWBERRY) +{ + USER_ID @join__enumValue(graph: STRAWBERRY) + NAME @join__enumValue(graph: STRAWBERRY) +} + +"""Added in UNRELEASED. Scoped app-config policy (BEP-1052 §1).""" type AppConfigPolicy implements Node @join__implements(graph: STRAWBERRY, interface: "Node") @join__type(graph: STRAWBERRY) @@ -1008,6 +1210,9 @@ type AppConfigPolicyBulkError """Original position in the input list.""" index: Int! + """`config_name` of the failed row.""" + configName: String! + """Reason for the failure.""" message: String! } @@ -1069,6 +1274,15 @@ enum AppConfigPolicyOrderField UPDATED_AT @join__enumValue(graph: STRAWBERRY) } +enum AppConfigScopeType + @join__type(graph: STRAWBERRY) +{ + PUBLIC @join__enumValue(graph: STRAWBERRY) + DOMAIN @join__enumValue(graph: STRAWBERRY) + DOMAIN_USER_DEFAULTS @join__enumValue(graph: STRAWBERRY) + USER @join__enumValue(graph: STRAWBERRY) +} + """ Added in 24.09.0. Input for approving an artifact revision. @@ -2050,6 +2264,29 @@ type BulkAssignRolePayload failed: [BulkAssignRoleError!]! } +""" +Added in UNRELEASED. Self-service bulk create — scope is `USER` / `current_user`. +""" +input BulkCreateMyAppConfigFragmentInput + @join__type(graph: STRAWBERRY) +{ + """USER-scope rows to create.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkCreateMyAppConfigFragments` (recomputed views). +""" +type BulkCreateMyAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Recomputed merged AppConfig views for each created USER fragment.""" + created: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """Added in 26.2.0. Payload for bulk user creation mutation.""" type BulkCreateUsersV2Payload @join__type(graph: STRAWBERRY) @@ -2202,6 +2439,29 @@ type BulkRevokeRolePayload failed: [BulkRevokeRoleError!]! } +""" +Added in UNRELEASED. Self-service bulk update — scope is `USER` / `current_user`. +""" +input BulkUpdateMyAppConfigFragmentInput + @join__type(graph: STRAWBERRY) +{ + """USER-scope rows to update.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkUpdateMyAppConfigFragments` (recomputed views). +""" +type BulkUpdateMyAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Recomputed merged AppConfig views for each updated USER fragment.""" + updated: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """Added in 26.3.0. Payload for bulk user update mutation.""" type BulkUpdateUsersV2Payload @join__type(graph: STRAWBERRY) @@ -11052,10 +11312,35 @@ type Mutation adminBulkUpdateAppConfigPolicies(input: AdminBulkUpdateAppConfigPolicyInput!): AdminBulkUpdateAppConfigPoliciesPayload! @join__field(graph: STRAWBERRY) """ - Added in UNRELEASED. Hard-delete policies by row id; rows still referenced by fragments surface in `failed`. Admin only. + Added in UNRELEASED. Rejects items whose `config_name` still has referencing fragment rows. Admin only. """ adminBulkPurgeAppConfigPolicies(input: AdminBulkPurgeAppConfigPolicyInput!): AdminBulkPurgeAppConfigPoliciesPayload! @join__field(graph: STRAWBERRY) + """ + Added in UNRELEASED. Strict insert across any scope; each item runs in its own transaction and failures are collected per-item (admin only). + """ + adminBulkCreateAppConfigFragments(input: AdminBulkCreateAppConfigFragmentInput!): AdminBulkCreateAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Wholesale JSON replacement; items with no existing row are returned as failures (admin only). + """ + adminBulkUpdateAppConfigFragments(input: AdminBulkUpdateAppConfigFragmentInput!): AdminBulkUpdateAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Cleanup-only deletion; absent keys are no-oped (admin only). + """ + adminBulkPurgeAppConfigFragments(input: AdminBulkPurgeAppConfigFragmentInput!): AdminBulkPurgeAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Strict insert on the caller's USER row; duplicates fail per-item. Returns recomputed merged `AppConfig` views. + """ + bulkCreateMyAppConfigFragments(input: BulkCreateMyAppConfigFragmentInput!): BulkCreateMyAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Wholesale replacement on the caller's USER row; missing rows are returned as failures. Returns recomputed merged `AppConfig` views. + """ + bulkUpdateMyAppConfigFragments(input: BulkUpdateMyAppConfigFragmentInput!): BulkUpdateMyAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + """Added in 26.3.0. Create a new query definition (admin only)""" adminCreatePrometheusQueryPreset(input: CreateQueryDefinitionInput!): CreateQueryDefinitionPayload! @join__field(graph: STRAWBERRY) @@ -11275,6 +11560,17 @@ type Mutation terminateProjectSessionsV2(scope: ProjectSessionV2Scope!, sessionIds: [ID!]!, forced: Boolean! = false): TerminateSessionsPayload! @join__field(graph: STRAWBERRY) } +"""Added in UNRELEASED. Per-item input for self-service (`my`) bulk.""" +input MyAppConfigFragmentItemInput + @join__type(graph: STRAWBERRY) +{ + """Policy name.""" + name: String! + + """Raw configuration payload.""" + extraConfig: JSON! +} + """ Added in 26.4.2. Query result returning the current client's IP address. """ @@ -12779,6 +13075,20 @@ input ProjectWeightInputItem weight: Decimal = null } +"""Added in UNRELEASED. Natural key of a purged fragment row.""" +type PurgeAppConfigFragmentKey + @join__type(graph: STRAWBERRY) +{ + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name.""" + name: String! +} + """ Completely delete domain from DB. @@ -13574,15 +13884,40 @@ type Query adminImageAliases(filter: ImageV2AliasFilter = null, orderBy: [ImageV2AliasOrderByGQL!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ImageV2AliasConnection @join__field(graph: STRAWBERRY) """ - Added in UNRELEASED. Get a single app-config policy by row id. Available to any authenticated user. + Added in UNRELEASED. Get a single app-config policy by `config_name`. Available to any authenticated user. """ - appConfigPolicy(id: UUID!): AppConfigPolicy @join__field(graph: STRAWBERRY) + appConfigPolicy(configName: String!): AppConfigPolicy @join__field(graph: STRAWBERRY) """ Added in UNRELEASED. List app-config policies with filtering and pagination. Available to any authenticated user. """ appConfigPolicies(filter: AppConfigPolicyFilter = null, orderBy: [AppConfigPolicyOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): AppConfigPolicyConnection! @join__field(graph: STRAWBERRY) + """ + Added in UNRELEASED. Get a single app-config fragment by natural key `(scope_type, scope_id, name)`. Available to any authenticated user — service-layer authorization gates cross-scope reads. + """ + appConfigFragment(scopeType: AppConfigScopeType!, scopeId: String!, name: String!): AppConfigFragment @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Cross-scope admin search across all app-config fragments (admin only). + """ + adminAppConfigFragments(filter: AppConfigFragmentFilter = null, orderBy: [AppConfigFragmentOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfigFragment!]! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Public (no-auth) `PUBLIC`-scope app-config fragments — the subset of raw fragments that carry no personally-scoped data. + """ + publicAppConfigFragments(filter: AppConfigFragmentFilter = null, orderBy: [AppConfigFragmentOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfigFragment!]! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Caller's own merged AppConfig list (auth required). Chain per policy ; the adapter pins `(USER, current_user)` internally. + """ + myAppConfigs(filter: AppConfigFilter = null, orderBy: [AppConfigOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfig!]! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Cross-user merged-view search (admin only). Resolves any user's AppConfig for audit / support. Pin to a single user with `filter.userId`; otherwise paginates across all users. + """ + adminAppConfigs(filter: AppConfigFilter = null, orderBy: [AppConfigOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfig!]! @join__field(graph: STRAWBERRY) + """ Added in 26.4.2. Get a single prometheus query preset by ID. Available to any authenticated user since presets are a shared catalog of metric query templates. """ diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index ed4b191cf9e..c4c679386ec 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -145,10 +145,17 @@ type AddRevisionPayload { revision: ModelRevision! } -""" -Added in UNRELEASED. Per-item input for admin bulk create — `config_name` + initial `scope_sources`. -""" -input AdminAppConfigPolicyCreateItemInput { +"""Added in UNRELEASED. Per-item input for admin bulk create / update.""" +input AdminAppConfigFragmentItemInput { + """Natural-key identifier.""" + key: AppConfigFragmentKeyInput! + + """Raw configuration payload.""" + extraConfig: JSON! +} + +"""Added in UNRELEASED. Per-item input for admin bulk create / update.""" +input AdminAppConfigPolicyItemInput { """Unique, immutable policy name.""" configName: String! @@ -156,15 +163,19 @@ input AdminAppConfigPolicyCreateItemInput { scopeSources: [String!]! } -""" -Added in UNRELEASED. Per-item input for admin bulk update — target row id + new `scope_sources`. -""" -input AdminAppConfigPolicyUpdateItemInput { - """Policy row id.""" - id: UUID! +"""Added in UNRELEASED. Admin bulk create input — items carry any scope.""" +input AdminBulkCreateAppConfigFragmentInput { + """Rows to create.""" + items: [AdminAppConfigFragmentItemInput!]! +} - """Ordered scope chain.""" - scopeSources: [String!]! +"""Added in UNRELEASED. Payload for `adminBulkCreateAppConfigFragments`.""" +type AdminBulkCreateAppConfigFragmentsPayload { + """Created fragments.""" + created: [AppConfigFragment!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! } """Added in UNRELEASED. Payload for `adminBulkCreateAppConfigPolicies`.""" @@ -179,24 +190,56 @@ type AdminBulkCreateAppConfigPoliciesPayload { """Added in UNRELEASED. Admin bulk create input for app-config policies.""" input AdminBulkCreateAppConfigPolicyInput { """Policies to create.""" - items: [AdminAppConfigPolicyCreateItemInput!]! + items: [AdminAppConfigPolicyItemInput!]! +} + +""" +Added in UNRELEASED. Admin bulk purge input — keyed on `AppConfigFragmentKey`. +""" +input AdminBulkPurgeAppConfigFragmentInput { + """Natural keys to purge.""" + keys: [AppConfigFragmentKeyInput!]! +} + +"""Added in UNRELEASED. Payload for `adminBulkPurgeAppConfigFragments`.""" +type AdminBulkPurgeAppConfigFragmentsPayload { + """Keys of rows actually removed (absent keys are no-oped).""" + purged: [PurgeAppConfigFragmentKey!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! } """Added in UNRELEASED. Payload for `adminBulkPurgeAppConfigPolicies`.""" type AdminBulkPurgeAppConfigPoliciesPayload { - """Ids of policies actually removed (absent ids no-oped).""" - purgedIds: [UUID!]! + """`config_name`s of policies actually removed (absent names no-oped).""" + purgedConfigNames: [String!]! """Per-item failures.""" failed: [AppConfigPolicyBulkError!]! } """ -Added in UNRELEASED. Admin bulk purge input for app-config policies (keyed on row id). +Added in UNRELEASED. Admin bulk purge input for app-config policies (keyed on `config_name`). """ input AdminBulkPurgeAppConfigPolicyInput { - """Policy row ids to purge.""" - ids: [UUID!]! + """`config_name`s to purge.""" + configNames: [String!]! +} + +"""Added in UNRELEASED. Admin bulk update input.""" +input AdminBulkUpdateAppConfigFragmentInput { + """Rows to update.""" + items: [AdminAppConfigFragmentItemInput!]! +} + +"""Added in UNRELEASED. Payload for `adminBulkUpdateAppConfigFragments`.""" +type AdminBulkUpdateAppConfigFragmentsPayload { + """Updated fragments.""" + updated: [AppConfigFragment!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! } """Added in UNRELEASED. Payload for `adminBulkUpdateAppConfigPolicies`.""" @@ -211,7 +254,7 @@ type AdminBulkUpdateAppConfigPoliciesPayload { """Added in UNRELEASED. Admin bulk update input for app-config policies.""" input AdminBulkUpdateAppConfigPolicyInput { """Policies to update.""" - items: [AdminAppConfigPolicyUpdateItemInput!]! + items: [AdminAppConfigPolicyItemInput!]! } """Added in 26.4.2. Admin input for creating a keypair for a user.""" @@ -678,7 +721,134 @@ type AllowedResourceGroupsPayload { items: [String!]! } -"""Added in UNRELEASED. Scoped app-config policy.""" +""" +Added in UNRELEASED. Merged per-user AppConfig view. `fragments` are ordered low → high merge priority; `config` is the deep-merge result and is null when every contributing fragment is empty. +""" +type AppConfig { + """Target user's UUID.""" + userId: UUID! + + """Policy / config name.""" + name: String! + + """Contributing fragments in merge order (low → high).""" + fragments: [AppConfigFragment!]! + + """Deep-merged configuration, or null when every fragment is empty.""" + config: JSON +} + +"""Added in UNRELEASED. Filter input for querying merged AppConfigs.""" +input AppConfigFilter { + """Filter by policy name.""" + name: StringFilter = null + + """Filter by target user id (admin cross-user search only).""" + userId: UUIDFilter = null +} + +"""Added in UNRELEASED. Raw per-scope app-config fragment.""" +type AppConfigFragment { + """Row ID.""" + id: UUID! + + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name (FK target).""" + name: String! + + """Raw configuration payload, or null.""" + extraConfig: JSON + + """Creation timestamp.""" + createdAt: DateTime! + + """Last update timestamp.""" + updatedAt: DateTime +} + +""" +Added in UNRELEASED. Per-item failure info for bulk Fragment mutations. +""" +type AppConfigFragmentBulkError { + """Original position in the input list.""" + index: Int! + + """Scope type of the failed row.""" + scopeType: AppConfigScopeType! + + """Scope id of the failed row.""" + scopeId: String! + + """Policy name of the failed row.""" + name: String! + + """Reason for the failure.""" + message: String! +} + +"""Added in UNRELEASED. Filter input for querying app-config fragments.""" +input AppConfigFragmentFilter { + """Filter by policy name.""" + name: StringFilter = null + + """Filter by scope_id.""" + scopeId: StringFilter = null +} + +"""Added in UNRELEASED. Natural key for an app-config fragment row.""" +input AppConfigFragmentKeyInput { + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name.""" + name: String! +} + +""" +Added in UNRELEASED. Specifies ordering for app-config fragment results. +""" +input AppConfigFragmentOrderBy { + """The field to order by.""" + field: AppConfigFragmentOrderField! + + """Sort direction.""" + direction: OrderDirection! = DESC +} + +""" +Added in UNRELEASED. Fields available for ordering app-config fragments. +""" +enum AppConfigFragmentOrderField { + SCOPE_TYPE + SCOPE_ID + NAME + CREATED_AT +} + +"""Added in UNRELEASED. Specifies ordering for merged AppConfig results.""" +input AppConfigOrderBy { + """The field to order by.""" + field: AppConfigOrderField! + + """Sort direction.""" + direction: OrderDirection! = ASC +} + +"""Added in UNRELEASED. Fields available for ordering merged AppConfigs.""" +enum AppConfigOrderField { + USER_ID + NAME +} + +"""Added in UNRELEASED. Scoped app-config policy (BEP-1052 §1).""" type AppConfigPolicy implements Node { """The Globally Unique ID of this object""" id: ID! @@ -701,6 +871,9 @@ type AppConfigPolicyBulkError { """Original position in the input list.""" index: Int! + """`config_name` of the failed row.""" + configName: String! + """Reason for the failure.""" message: String! } @@ -752,6 +925,13 @@ enum AppConfigPolicyOrderField { UPDATED_AT } +enum AppConfigScopeType { + PUBLIC + DOMAIN + DOMAIN_USER_DEFAULTS + USER +} + """ Added in 24.09.0. Input for approving an artifact revision. @@ -1421,6 +1601,25 @@ type BulkAssignRolePayload { failed: [BulkAssignRoleError!]! } +""" +Added in UNRELEASED. Self-service bulk create — scope is `USER` / `current_user`. +""" +input BulkCreateMyAppConfigFragmentInput { + """USER-scope rows to create.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkCreateMyAppConfigFragments` (recomputed views). +""" +type BulkCreateMyAppConfigFragmentsPayload { + """Recomputed merged AppConfig views for each created USER fragment.""" + created: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """Added in 26.2.0. Error information for a failed user in bulk creation.""" type BulkCreateUserV2Error { """Original position in the input list.""" @@ -1545,6 +1744,25 @@ type BulkRevokeRolePayload { failed: [BulkRevokeRoleError!]! } +""" +Added in UNRELEASED. Self-service bulk update — scope is `USER` / `current_user`. +""" +input BulkUpdateMyAppConfigFragmentInput { + """USER-scope rows to update.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkUpdateMyAppConfigFragments` (recomputed views). +""" +type BulkUpdateMyAppConfigFragmentsPayload { + """Recomputed merged AppConfig views for each updated USER fragment.""" + updated: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """Added in 26.3.0. Error information for a failed user in bulk update.""" type BulkUpdateUserV2Error { """UUID of the user that failed to update.""" @@ -6978,10 +7196,35 @@ type Mutation { adminBulkUpdateAppConfigPolicies(input: AdminBulkUpdateAppConfigPolicyInput!): AdminBulkUpdateAppConfigPoliciesPayload! """ - Added in UNRELEASED. Hard-delete policies by row id; rows still referenced by fragments surface in `failed`. Admin only. + Added in UNRELEASED. Rejects items whose `config_name` still has referencing fragment rows. Admin only. """ adminBulkPurgeAppConfigPolicies(input: AdminBulkPurgeAppConfigPolicyInput!): AdminBulkPurgeAppConfigPoliciesPayload! + """ + Added in UNRELEASED. Strict insert across any scope; each item runs in its own transaction and failures are collected per-item (admin only). + """ + adminBulkCreateAppConfigFragments(input: AdminBulkCreateAppConfigFragmentInput!): AdminBulkCreateAppConfigFragmentsPayload! + + """ + Added in UNRELEASED. Wholesale JSON replacement; items with no existing row are returned as failures (admin only). + """ + adminBulkUpdateAppConfigFragments(input: AdminBulkUpdateAppConfigFragmentInput!): AdminBulkUpdateAppConfigFragmentsPayload! + + """ + Added in UNRELEASED. Cleanup-only deletion; absent keys are no-oped (admin only). + """ + adminBulkPurgeAppConfigFragments(input: AdminBulkPurgeAppConfigFragmentInput!): AdminBulkPurgeAppConfigFragmentsPayload! + + """ + Added in UNRELEASED. Strict insert on the caller's USER row; duplicates fail per-item. Returns recomputed merged `AppConfig` views. + """ + bulkCreateMyAppConfigFragments(input: BulkCreateMyAppConfigFragmentInput!): BulkCreateMyAppConfigFragmentsPayload! + + """ + Added in UNRELEASED. Wholesale replacement on the caller's USER row; missing rows are returned as failures. Returns recomputed merged `AppConfig` views. + """ + bulkUpdateMyAppConfigFragments(input: BulkUpdateMyAppConfigFragmentInput!): BulkUpdateMyAppConfigFragmentsPayload! + """Added in 26.3.0. Create a new query definition (admin only)""" adminCreatePrometheusQueryPreset(input: CreateQueryDefinitionInput!): CreateQueryDefinitionPayload! @@ -7201,6 +7444,15 @@ type Mutation { terminateProjectSessionsV2(scope: ProjectSessionV2Scope!, sessionIds: [ID!]!, forced: Boolean! = false): TerminateSessionsPayload! } +"""Added in UNRELEASED. Per-item input for self-service (`my`) bulk.""" +input MyAppConfigFragmentItemInput { + """Policy name.""" + name: String! + + """Raw configuration payload.""" + extraConfig: JSON! +} + """ Added in 26.4.2. Query result returning the current client's IP address. """ @@ -8410,6 +8662,18 @@ input ProjectWeightInputItem { weight: Decimal = null } +"""Added in UNRELEASED. Natural key of a purged fragment row.""" +type PurgeAppConfigFragmentKey { + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name.""" + name: String! +} + """Added in 26.4.2. Payload for domain permanent deletion mutation.""" type PurgeDomainPayloadGQL { """Whether the purge was successful.""" @@ -8715,15 +8979,40 @@ type Query { adminImageAliases(filter: ImageV2AliasFilter = null, orderBy: [ImageV2AliasOrderByGQL!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ImageV2AliasConnection """ - Added in UNRELEASED. Get a single app-config policy by row id. Available to any authenticated user. + Added in UNRELEASED. Get a single app-config policy by `config_name`. Available to any authenticated user. """ - appConfigPolicy(id: UUID!): AppConfigPolicy + appConfigPolicy(configName: String!): AppConfigPolicy """ Added in UNRELEASED. List app-config policies with filtering and pagination. Available to any authenticated user. """ appConfigPolicies(filter: AppConfigPolicyFilter = null, orderBy: [AppConfigPolicyOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): AppConfigPolicyConnection! + """ + Added in UNRELEASED. Get a single app-config fragment by natural key `(scope_type, scope_id, name)`. Available to any authenticated user — service-layer authorization gates cross-scope reads. + """ + appConfigFragment(scopeType: AppConfigScopeType!, scopeId: String!, name: String!): AppConfigFragment + + """ + Added in UNRELEASED. Cross-scope admin search across all app-config fragments (admin only). + """ + adminAppConfigFragments(filter: AppConfigFragmentFilter = null, orderBy: [AppConfigFragmentOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfigFragment!]! + + """ + Added in UNRELEASED. Public (no-auth) `PUBLIC`-scope app-config fragments — the subset of raw fragments that carry no personally-scoped data. + """ + publicAppConfigFragments(filter: AppConfigFragmentFilter = null, orderBy: [AppConfigFragmentOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfigFragment!]! + + """ + Added in UNRELEASED. Caller's own merged AppConfig list (auth required). Chain per policy ; the adapter pins `(USER, current_user)` internally. + """ + myAppConfigs(filter: AppConfigFilter = null, orderBy: [AppConfigOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfig!]! + + """ + Added in UNRELEASED. Cross-user merged-view search (admin only). Resolves any user's AppConfig for audit / support. Pin to a single user with `filter.userId`; otherwise paginates across all users. + """ + adminAppConfigs(filter: AppConfigFilter = null, orderBy: [AppConfigOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfig!]! + """ Added in 26.4.2. Get a single prometheus query preset by ID. Available to any authenticated user since presets are a shared catalog of metric query templates. """ From f3dda5ba4fada31f1a0d6080338a6830c8aa5626 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Sun, 26 Apr 2026 20:51:39 +0900 Subject: [PATCH 09/13] refactor(BA-5829): propagate my_bulk rename + add merged-view timestamp filters/orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Propagate the `bulk_create_my` / `bulk_update_my` → `my_bulk_create` / `my_bulk_update` rename through the merged-view adapter, GQL resolvers, GQL types, and DTOs (now `MyBulkCreate*` / `MyBulkUpdate*` consistently). - Add `created_at` / `updated_at` filter fields to `AppConfigFilter` and corresponding `CREATED_AT` / `UPDATED_AT` enum values to `AppConfigOrderField` so callers can filter / order merged AppConfigs on timestamp boundaries. - Add `UPDATED_AT` to `AppConfigFragmentOrderFieldGQL` (paired with the existing DTO enum). - Refresh the GraphQL schema dump (v2 + supergraph) to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../graphql-reference/supergraph.graphql | 25 ++++++++---- .../graphql-reference/v2-schema.graphql | 25 ++++++++---- .../dto/manager/v2/app_config/__init__.py | 8 ++-- .../dto/manager/v2/app_config/request.py | 18 ++++++++- .../dto/manager/v2/app_config/response.py | 8 ++-- .../common/dto/manager/v2/app_config/types.py | 2 + .../manager/api/adapters/app_config.py | 40 +++++++++---------- .../api/adapters/app_config_fragment.py | 1 - .../api/gql/app_config/types/bulk_payloads.py | 16 ++++---- .../api/gql/app_config/types/filters.py | 17 +++++++- .../app_config_fragment/resolver/__init__.py | 8 ++-- .../app_config_fragment/resolver/mutation.py | 24 +++++------ .../gql/app_config_fragment/types/__init__.py | 8 ++-- .../app_config_fragment/types/bulk_inputs.py | 14 +++---- .../gql/app_config_fragment/types/filters.py | 1 + src/ai/backend/manager/api/gql/schema.py | 8 ++-- .../actions/my_bulk_update.py | 2 +- .../app_config_fragment/processors.py | 16 ++++---- .../services/app_config_fragment/service.py | 16 ++++---- 19 files changed, 153 insertions(+), 104 deletions(-) diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 30892d1dd6b..05a89dacfd6 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -1063,6 +1063,12 @@ input AppConfigFilter """Filter by target user id (admin cross-user search only).""" userId: UUIDFilter = null + + """Filter by the oldest contributing fragment's creation timestamp.""" + createdAt: DateTimeFilter = null + + """Filter by the latest contributing fragment's update timestamp.""" + updatedAt: DateTimeFilter = null } """Added in UNRELEASED. Raw per-scope app-config fragment.""" @@ -1161,6 +1167,7 @@ enum AppConfigFragmentOrderField SCOPE_ID @join__enumValue(graph: STRAWBERRY) NAME @join__enumValue(graph: STRAWBERRY) CREATED_AT @join__enumValue(graph: STRAWBERRY) + UPDATED_AT @join__enumValue(graph: STRAWBERRY) } """Added in UNRELEASED. Specifies ordering for merged AppConfig results.""" @@ -1180,6 +1187,8 @@ enum AppConfigOrderField { USER_ID @join__enumValue(graph: STRAWBERRY) NAME @join__enumValue(graph: STRAWBERRY) + CREATED_AT @join__enumValue(graph: STRAWBERRY) + UPDATED_AT @join__enumValue(graph: STRAWBERRY) } """Added in UNRELEASED. Scoped app-config policy (BEP-1052 §1).""" @@ -2267,7 +2276,7 @@ type BulkAssignRolePayload """ Added in UNRELEASED. Self-service bulk create — scope is `USER` / `current_user`. """ -input BulkCreateMyAppConfigFragmentInput +input MyBulkCreateAppConfigFragmentInput @join__type(graph: STRAWBERRY) { """USER-scope rows to create.""" @@ -2275,9 +2284,9 @@ input BulkCreateMyAppConfigFragmentInput } """ -Added in UNRELEASED. Payload for `bulkCreateMyAppConfigFragments` (recomputed views). +Added in UNRELEASED. Payload for `myBulkCreateAppConfigFragments` (recomputed views). """ -type BulkCreateMyAppConfigFragmentsPayload +type MyBulkCreateAppConfigFragmentsPayload @join__type(graph: STRAWBERRY) { """Recomputed merged AppConfig views for each created USER fragment.""" @@ -2442,7 +2451,7 @@ type BulkRevokeRolePayload """ Added in UNRELEASED. Self-service bulk update — scope is `USER` / `current_user`. """ -input BulkUpdateMyAppConfigFragmentInput +input MyBulkUpdateAppConfigFragmentInput @join__type(graph: STRAWBERRY) { """USER-scope rows to update.""" @@ -2450,9 +2459,9 @@ input BulkUpdateMyAppConfigFragmentInput } """ -Added in UNRELEASED. Payload for `bulkUpdateMyAppConfigFragments` (recomputed views). +Added in UNRELEASED. Payload for `myBulkUpdateAppConfigFragments` (recomputed views). """ -type BulkUpdateMyAppConfigFragmentsPayload +type MyBulkUpdateAppConfigFragmentsPayload @join__type(graph: STRAWBERRY) { """Recomputed merged AppConfig views for each updated USER fragment.""" @@ -11334,12 +11343,12 @@ type Mutation """ Added in UNRELEASED. Strict insert on the caller's USER row; duplicates fail per-item. Returns recomputed merged `AppConfig` views. """ - bulkCreateMyAppConfigFragments(input: BulkCreateMyAppConfigFragmentInput!): BulkCreateMyAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + myBulkCreateAppConfigFragments(input: MyBulkCreateAppConfigFragmentInput!): MyBulkCreateAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) """ Added in UNRELEASED. Wholesale replacement on the caller's USER row; missing rows are returned as failures. Returns recomputed merged `AppConfig` views. """ - bulkUpdateMyAppConfigFragments(input: BulkUpdateMyAppConfigFragmentInput!): BulkUpdateMyAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + myBulkUpdateAppConfigFragments(input: MyBulkUpdateAppConfigFragmentInput!): MyBulkUpdateAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) """Added in 26.3.0. Create a new query definition (admin only)""" adminCreatePrometheusQueryPreset(input: CreateQueryDefinitionInput!): CreateQueryDefinitionPayload! @join__field(graph: STRAWBERRY) diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index c4c679386ec..6a28153b8ec 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -745,6 +745,12 @@ input AppConfigFilter { """Filter by target user id (admin cross-user search only).""" userId: UUIDFilter = null + + """Filter by the oldest contributing fragment's creation timestamp.""" + createdAt: DateTimeFilter = null + + """Filter by the latest contributing fragment's update timestamp.""" + updatedAt: DateTimeFilter = null } """Added in UNRELEASED. Raw per-scope app-config fragment.""" @@ -831,6 +837,7 @@ enum AppConfigFragmentOrderField { SCOPE_ID NAME CREATED_AT + UPDATED_AT } """Added in UNRELEASED. Specifies ordering for merged AppConfig results.""" @@ -846,6 +853,8 @@ input AppConfigOrderBy { enum AppConfigOrderField { USER_ID NAME + CREATED_AT + UPDATED_AT } """Added in UNRELEASED. Scoped app-config policy (BEP-1052 §1).""" @@ -1604,15 +1613,15 @@ type BulkAssignRolePayload { """ Added in UNRELEASED. Self-service bulk create — scope is `USER` / `current_user`. """ -input BulkCreateMyAppConfigFragmentInput { +input MyBulkCreateAppConfigFragmentInput { """USER-scope rows to create.""" items: [MyAppConfigFragmentItemInput!]! } """ -Added in UNRELEASED. Payload for `bulkCreateMyAppConfigFragments` (recomputed views). +Added in UNRELEASED. Payload for `myBulkCreateAppConfigFragments` (recomputed views). """ -type BulkCreateMyAppConfigFragmentsPayload { +type MyBulkCreateAppConfigFragmentsPayload { """Recomputed merged AppConfig views for each created USER fragment.""" created: [AppConfig!]! @@ -1747,15 +1756,15 @@ type BulkRevokeRolePayload { """ Added in UNRELEASED. Self-service bulk update — scope is `USER` / `current_user`. """ -input BulkUpdateMyAppConfigFragmentInput { +input MyBulkUpdateAppConfigFragmentInput { """USER-scope rows to update.""" items: [MyAppConfigFragmentItemInput!]! } """ -Added in UNRELEASED. Payload for `bulkUpdateMyAppConfigFragments` (recomputed views). +Added in UNRELEASED. Payload for `myBulkUpdateAppConfigFragments` (recomputed views). """ -type BulkUpdateMyAppConfigFragmentsPayload { +type MyBulkUpdateAppConfigFragmentsPayload { """Recomputed merged AppConfig views for each updated USER fragment.""" updated: [AppConfig!]! @@ -7218,12 +7227,12 @@ type Mutation { """ Added in UNRELEASED. Strict insert on the caller's USER row; duplicates fail per-item. Returns recomputed merged `AppConfig` views. """ - bulkCreateMyAppConfigFragments(input: BulkCreateMyAppConfigFragmentInput!): BulkCreateMyAppConfigFragmentsPayload! + myBulkCreateAppConfigFragments(input: MyBulkCreateAppConfigFragmentInput!): MyBulkCreateAppConfigFragmentsPayload! """ Added in UNRELEASED. Wholesale replacement on the caller's USER row; missing rows are returned as failures. Returns recomputed merged `AppConfig` views. """ - bulkUpdateMyAppConfigFragments(input: BulkUpdateMyAppConfigFragmentInput!): BulkUpdateMyAppConfigFragmentsPayload! + myBulkUpdateAppConfigFragments(input: MyBulkUpdateAppConfigFragmentInput!): MyBulkUpdateAppConfigFragmentsPayload! """Added in 26.3.0. Create a new query definition (admin only)""" adminCreatePrometheusQueryPreset(input: CreateQueryDefinitionInput!): CreateQueryDefinitionPayload! diff --git a/src/ai/backend/common/dto/manager/v2/app_config/__init__.py b/src/ai/backend/common/dto/manager/v2/app_config/__init__.py index b83e6f70857..dd862cf2319 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config/__init__.py +++ b/src/ai/backend/common/dto/manager/v2/app_config/__init__.py @@ -11,9 +11,9 @@ ) from .response import ( AppConfigNode, - BulkCreateMyAppConfigFragmentsPayload, - BulkUpdateMyAppConfigFragmentsPayload, GetUserAppConfigPayload, + MyBulkCreateAppConfigFragmentsPayload, + MyBulkUpdateAppConfigFragmentsPayload, SearchAppConfigsPayload, ) from .types import ( @@ -28,8 +28,8 @@ "AppConfigOrder", "AppConfigOrderField", "AppConfigScopeType", - "BulkCreateMyAppConfigFragmentsPayload", - "BulkUpdateMyAppConfigFragmentsPayload", + "MyBulkCreateAppConfigFragmentsPayload", + "MyBulkUpdateAppConfigFragmentsPayload", "GetUserAppConfigInput", "GetUserAppConfigPayload", "OrderDirection", diff --git a/src/ai/backend/common/dto/manager/v2/app_config/request.py b/src/ai/backend/common/dto/manager/v2/app_config/request.py index 93e101ba3fd..8610dab5c59 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config/request.py +++ b/src/ai/backend/common/dto/manager/v2/app_config/request.py @@ -9,7 +9,7 @@ from pydantic import Field from ai.backend.common.api_handlers import BaseRequestModel -from ai.backend.common.dto.manager.query import StringFilter, UUIDFilter +from ai.backend.common.dto.manager.query import DateTimeFilter, StringFilter, UUIDFilter from .types import AppConfigOrderField, OrderDirection @@ -31,13 +31,27 @@ class GetUserAppConfigInput(BaseRequestModel): class AppConfigFilter(BaseRequestModel): - """Filter for AppConfig merged-view search.""" + """Filter for AppConfig merged-view search. + + `created_at` / `updated_at` filter against the **oldest** / + **latest** timestamp across the contributing fragments — the + natural projection of "when was this config first created" and + "when was it last touched". + """ name: StringFilter | None = Field(default=None, description="Filter by policy name.") user_id: UUIDFilter | None = Field( default=None, description="Filter by target user id (admin cross-user search only).", ) + created_at: DateTimeFilter | None = Field( + default=None, + description=("Filter by the oldest contributing fragment's creation timestamp."), + ) + updated_at: DateTimeFilter | None = Field( + default=None, + description=("Filter by the latest contributing fragment's update timestamp."), + ) class AppConfigOrder(BaseRequestModel): diff --git a/src/ai/backend/common/dto/manager/v2/app_config/response.py b/src/ai/backend/common/dto/manager/v2/app_config/response.py index 987ddde51de..7369aab242d 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config/response.py +++ b/src/ai/backend/common/dto/manager/v2/app_config/response.py @@ -17,8 +17,8 @@ __all__ = ( "AppConfigNode", - "BulkCreateMyAppConfigFragmentsPayload", - "BulkUpdateMyAppConfigFragmentsPayload", + "MyBulkCreateAppConfigFragmentsPayload", + "MyBulkUpdateAppConfigFragmentsPayload", "GetUserAppConfigPayload", "SearchAppConfigsPayload", ) @@ -62,7 +62,7 @@ class SearchAppConfigsPayload(BaseResponseModel): has_previous_page: bool = Field(default=False, description="Whether there is a previous page.") -class BulkCreateMyAppConfigFragmentsPayload(BaseResponseModel): +class MyBulkCreateAppConfigFragmentsPayload(BaseResponseModel): """Payload for `bulkCreateMyAppConfigFragments`. Each successfully created row produces a recomputed merged @@ -75,7 +75,7 @@ class BulkCreateMyAppConfigFragmentsPayload(BaseResponseModel): failed: list[AppConfigFragmentBulkError] = Field(description="Per-item failures.") -class BulkUpdateMyAppConfigFragmentsPayload(BaseResponseModel): +class MyBulkUpdateAppConfigFragmentsPayload(BaseResponseModel): """Payload for `bulkUpdateMyAppConfigFragments`.""" updated: list[AppConfigNode] = Field( diff --git a/src/ai/backend/common/dto/manager/v2/app_config/types.py b/src/ai/backend/common/dto/manager/v2/app_config/types.py index 84d255475c2..c8cfe46d209 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config/types.py +++ b/src/ai/backend/common/dto/manager/v2/app_config/types.py @@ -21,3 +21,5 @@ class AppConfigOrderField(StrEnum): USER_ID = "user_id" NAME = "name" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" diff --git a/src/ai/backend/manager/api/adapters/app_config.py b/src/ai/backend/manager/api/adapters/app_config.py index 996b665aa3b..80a01d62ff5 100644 --- a/src/ai/backend/manager/api/adapters/app_config.py +++ b/src/ai/backend/manager/api/adapters/app_config.py @@ -19,15 +19,15 @@ ) from ai.backend.common.dto.manager.v2.app_config.response import ( AppConfigNode, - BulkCreateMyAppConfigFragmentsPayload, - BulkUpdateMyAppConfigFragmentsPayload, GetUserAppConfigPayload, + MyBulkCreateAppConfigFragmentsPayload, + MyBulkUpdateAppConfigFragmentsPayload, SearchAppConfigsPayload, ) from ai.backend.common.dto.manager.v2.app_config.types import AppConfigOrderField, OrderDirection from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( - BulkCreateMyAppConfigFragmentsInput, - BulkUpdateMyAppConfigFragmentsInput, + MyBulkCreateAppConfigFragmentsInput, + MyBulkUpdateAppConfigFragmentsInput, ) from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( AppConfigFragmentBulkError, @@ -52,15 +52,15 @@ from ai.backend.manager.services.app_config_fragment.actions.admin_search_app_configs import ( AdminSearchAppConfigsAction, ) -from ai.backend.manager.services.app_config_fragment.actions.bulk_create_my import ( - BulkCreateMyAppConfigFragmentsAction, -) -from ai.backend.manager.services.app_config_fragment.actions.bulk_update_my import ( - BulkUpdateMyAppConfigFragmentsAction, -) from ai.backend.manager.services.app_config_fragment.actions.get_user_app_config import ( GetUserAppConfigAction, ) +from ai.backend.manager.services.app_config_fragment.actions.my_bulk_create import ( + MyBulkCreateAppConfigFragmentsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.my_bulk_update import ( + MyBulkUpdateAppConfigFragmentsAction, +) from ai.backend.manager.services.app_config_fragment.actions.search_user_app_configs import ( SearchUserAppConfigsAction, ) @@ -154,33 +154,33 @@ async def admin_search_app_configs( # reasons travel back through the per-item `failed` list. async def my_bulk_create( - self, input: BulkCreateMyAppConfigFragmentsInput - ) -> BulkCreateMyAppConfigFragmentsPayload: + self, input: MyBulkCreateAppConfigFragmentsInput + ) -> MyBulkCreateAppConfigFragmentsPayload: items = [ MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) for item in input.items ] - wrapper = await self._processors.app_config_fragment.bulk_create_my.wait_for_complete( - BulkCreateMyAppConfigFragmentsAction(entity_ids=[], items=items) + wrapper = await self._processors.app_config_fragment.my_bulk_create.wait_for_complete( + MyBulkCreateAppConfigFragmentsAction(entity_ids=[], items=items) ) result = wrapper.result - return BulkCreateMyAppConfigFragmentsPayload( + return MyBulkCreateAppConfigFragmentsPayload( created=[self._data_to_dto(item) for item in result.created], failed=[self._bulk_error_to_dto(err) for err in result.failed], ) async def my_bulk_update( - self, input: BulkUpdateMyAppConfigFragmentsInput - ) -> BulkUpdateMyAppConfigFragmentsPayload: + self, input: MyBulkUpdateAppConfigFragmentsInput + ) -> MyBulkUpdateAppConfigFragmentsPayload: items = [ MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) for item in input.items ] - wrapper = await self._processors.app_config_fragment.bulk_update_my.wait_for_complete( - BulkUpdateMyAppConfigFragmentsAction(entity_ids=[], items=items) + wrapper = await self._processors.app_config_fragment.my_bulk_update.wait_for_complete( + MyBulkUpdateAppConfigFragmentsAction(entity_ids=[], items=items) ) result = wrapper.result - return BulkUpdateMyAppConfigFragmentsPayload( + return MyBulkUpdateAppConfigFragmentsPayload( updated=[self._data_to_dto(item) for item in result.updated], failed=[self._bulk_error_to_dto(err) for err in result.failed], ) diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment.py b/src/ai/backend/manager/api/adapters/app_config_fragment.py index 08cc7cc41ee..293cbe2b292 100644 --- a/src/ai/backend/manager/api/adapters/app_config_fragment.py +++ b/src/ai/backend/manager/api/adapters/app_config_fragment.py @@ -32,7 +32,6 @@ from ai.backend.common.dto.manager.v2.app_config_fragment.types import ( AppConfigScopeType as DTOAppConfigScopeType, ) -from ai.backend.common.dto.manager.v2.app_config_fragment.types import OrderDirection from ai.backend.manager.api.adapter_options.pagination.pagination import PaginationSpec from ai.backend.manager.data.app_config_fragment.bulk_types import ( AppConfigFragmentBulkItem, diff --git a/src/ai/backend/manager/api/gql/app_config/types/bulk_payloads.py b/src/ai/backend/manager/api/gql/app_config/types/bulk_payloads.py index 508867d3819..f5e738bb818 100644 --- a/src/ai/backend/manager/api/gql/app_config/types/bulk_payloads.py +++ b/src/ai/backend/manager/api/gql/app_config/types/bulk_payloads.py @@ -3,10 +3,10 @@ from __future__ import annotations from ai.backend.common.dto.manager.v2.app_config.response import ( - BulkCreateMyAppConfigFragmentsPayload as BulkCreateMyPayloadDTO, + MyBulkCreateAppConfigFragmentsPayload as MyBulkCreatePayloadDTO, ) from ai.backend.common.dto.manager.v2.app_config.response import ( - BulkUpdateMyAppConfigFragmentsPayload as BulkUpdateMyPayloadDTO, + MyBulkUpdateAppConfigFragmentsPayload as MyBulkUpdatePayloadDTO, ) from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION from ai.backend.manager.api.gql.app_config.types.node import AppConfigGQL @@ -26,10 +26,10 @@ added_version=NEXT_RELEASE_VERSION, description="Payload for `bulkCreateMyAppConfigFragments` (recomputed views).", ), - model=BulkCreateMyPayloadDTO, - name="BulkCreateMyAppConfigFragmentsPayload", + model=MyBulkCreatePayloadDTO, + name="MyBulkCreateAppConfigFragmentsPayload", ) -class BulkCreateMyAppConfigFragmentsPayloadGQL(PydanticOutputMixin[BulkCreateMyPayloadDTO]): +class MyBulkCreateAppConfigFragmentsPayloadGQL(PydanticOutputMixin[MyBulkCreatePayloadDTO]): created: list[AppConfigGQL] = gql_field( description="Recomputed merged AppConfig views for each created USER fragment.", ) @@ -43,10 +43,10 @@ class BulkCreateMyAppConfigFragmentsPayloadGQL(PydanticOutputMixin[BulkCreateMyP added_version=NEXT_RELEASE_VERSION, description="Payload for `bulkUpdateMyAppConfigFragments` (recomputed views).", ), - model=BulkUpdateMyPayloadDTO, - name="BulkUpdateMyAppConfigFragmentsPayload", + model=MyBulkUpdatePayloadDTO, + name="MyBulkUpdateAppConfigFragmentsPayload", ) -class BulkUpdateMyAppConfigFragmentsPayloadGQL(PydanticOutputMixin[BulkUpdateMyPayloadDTO]): +class MyBulkUpdateAppConfigFragmentsPayloadGQL(PydanticOutputMixin[MyBulkUpdatePayloadDTO]): updated: list[AppConfigGQL] = gql_field( description="Recomputed merged AppConfig views for each updated USER fragment.", ) diff --git a/src/ai/backend/manager/api/gql/app_config/types/filters.py b/src/ai/backend/manager/api/gql/app_config/types/filters.py index a660f26d6f0..00616ee382c 100644 --- a/src/ai/backend/manager/api/gql/app_config/types/filters.py +++ b/src/ai/backend/manager/api/gql/app_config/types/filters.py @@ -11,7 +11,12 @@ AppConfigOrder as AppConfigOrderDTO, ) from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION -from ai.backend.manager.api.gql.base import OrderDirection, StringFilter, UUIDFilter +from ai.backend.manager.api.gql.base import ( + DateTimeFilter, + OrderDirection, + StringFilter, + UUIDFilter, +) from ai.backend.manager.api.gql.decorators import ( BackendAIGQLMeta, gql_enum, @@ -34,6 +39,14 @@ class AppConfigFilterGQL(PydanticInputMixin[AppConfigFilterDTO]): description="Filter by target user id (admin cross-user search only).", default=None, ) + created_at: DateTimeFilter | None = gql_field( + description="Filter by the oldest contributing fragment's creation timestamp.", + default=None, + ) + updated_at: DateTimeFilter | None = gql_field( + description="Filter by the latest contributing fragment's update timestamp.", + default=None, + ) @gql_enum( @@ -46,6 +59,8 @@ class AppConfigFilterGQL(PydanticInputMixin[AppConfigFilterDTO]): class AppConfigOrderFieldGQL(StrEnum): USER_ID = "user_id" NAME = "name" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" @gql_pydantic_input( diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py index 88e7f3a3692..68fabfd3943 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py @@ -2,8 +2,8 @@ admin_bulk_create_app_config_fragments, admin_bulk_purge_app_config_fragments, admin_bulk_update_app_config_fragments, - bulk_create_my_app_config_fragments, - bulk_update_my_app_config_fragments, + my_bulk_create_app_config_fragments, + my_bulk_update_app_config_fragments, ) from .query import ( admin_app_config_fragments, @@ -16,6 +16,6 @@ "admin_bulk_purge_app_config_fragments", "admin_bulk_update_app_config_fragments", "app_config_fragment", - "bulk_create_my_app_config_fragments", - "bulk_update_my_app_config_fragments", + "my_bulk_create_app_config_fragments", + "my_bulk_update_app_config_fragments", ] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py index 7ababb48d15..2f21360be1f 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py @@ -6,8 +6,8 @@ from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION from ai.backend.manager.api.gql.app_config.types.bulk_payloads import ( - BulkCreateMyAppConfigFragmentsPayloadGQL, - BulkUpdateMyAppConfigFragmentsPayloadGQL, + MyBulkCreateAppConfigFragmentsPayloadGQL, + MyBulkUpdateAppConfigFragmentsPayloadGQL, ) from ai.backend.manager.api.gql.app_config_fragment.types import ( AdminBulkCreateAppConfigFragmentInputGQL, @@ -16,8 +16,8 @@ AdminBulkPurgeAppConfigFragmentsPayloadGQL, AdminBulkUpdateAppConfigFragmentInputGQL, AdminBulkUpdateAppConfigFragmentsPayloadGQL, - BulkCreateMyAppConfigFragmentInputGQL, - BulkUpdateMyAppConfigFragmentInputGQL, + MyBulkCreateAppConfigFragmentInputGQL, + MyBulkUpdateAppConfigFragmentInputGQL, ) from ai.backend.manager.api.gql.decorators import ( BackendAIGQLMeta, @@ -87,12 +87,12 @@ async def admin_bulk_purge_app_config_fragments( ), ) ) -async def bulk_create_my_app_config_fragments( +async def my_bulk_create_app_config_fragments( info: Info[StrawberryGQLContext], - input: BulkCreateMyAppConfigFragmentInputGQL, -) -> BulkCreateMyAppConfigFragmentsPayloadGQL: + input: MyBulkCreateAppConfigFragmentInputGQL, +) -> MyBulkCreateAppConfigFragmentsPayloadGQL: result = await info.context.adapters.app_config.my_bulk_create(input.to_pydantic()) - return BulkCreateMyAppConfigFragmentsPayloadGQL.from_pydantic(result) + return MyBulkCreateAppConfigFragmentsPayloadGQL.from_pydantic(result) @gql_mutation( @@ -104,9 +104,9 @@ async def bulk_create_my_app_config_fragments( ), ) ) -async def bulk_update_my_app_config_fragments( +async def my_bulk_update_app_config_fragments( info: Info[StrawberryGQLContext], - input: BulkUpdateMyAppConfigFragmentInputGQL, -) -> BulkUpdateMyAppConfigFragmentsPayloadGQL: + input: MyBulkUpdateAppConfigFragmentInputGQL, +) -> MyBulkUpdateAppConfigFragmentsPayloadGQL: result = await info.context.adapters.app_config.my_bulk_update(input.to_pydantic()) - return BulkUpdateMyAppConfigFragmentsPayloadGQL.from_pydantic(result) + return MyBulkUpdateAppConfigFragmentsPayloadGQL.from_pydantic(result) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py index 2f914855a21..657b6b6bb3e 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py @@ -3,9 +3,9 @@ AdminBulkCreateAppConfigFragmentInputGQL, AdminBulkPurgeAppConfigFragmentInputGQL, AdminBulkUpdateAppConfigFragmentInputGQL, - BulkCreateMyAppConfigFragmentInputGQL, - BulkUpdateMyAppConfigFragmentInputGQL, MyAppConfigFragmentItemInputGQL, + MyBulkCreateAppConfigFragmentInputGQL, + MyBulkUpdateAppConfigFragmentInputGQL, ) from .bulk_payloads import ( AdminBulkCreateAppConfigFragmentsPayloadGQL, @@ -37,8 +37,8 @@ "AppConfigFragmentOrderByGQL", "AppConfigFragmentOrderFieldGQL", "AppConfigScopeTypeGQL", - "BulkCreateMyAppConfigFragmentInputGQL", - "BulkUpdateMyAppConfigFragmentInputGQL", + "MyBulkCreateAppConfigFragmentInputGQL", + "MyBulkUpdateAppConfigFragmentInputGQL", "MyAppConfigFragmentItemInputGQL", "PurgeAppConfigFragmentKeyGQL", ] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py index 31fa136cec3..48485cd7e9f 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py @@ -17,13 +17,13 @@ AdminBulkUpdateAppConfigFragmentsInput as AdminBulkUpdateInputDTO, ) from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( - BulkCreateMyAppConfigFragmentsInput as BulkCreateMyInputDTO, + MyAppConfigFragmentItemInput as MyItemInputDTO, ) from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( - BulkUpdateMyAppConfigFragmentsInput as BulkUpdateMyInputDTO, + MyBulkCreateAppConfigFragmentsInput as MyBulkCreateInputDTO, ) from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( - MyAppConfigFragmentItemInput as MyItemInputDTO, + MyBulkUpdateAppConfigFragmentsInput as MyBulkUpdateInputDTO, ) from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION from ai.backend.manager.api.gql.app_config_fragment.types.inputs import ( @@ -99,9 +99,9 @@ class MyAppConfigFragmentItemInputGQL(PydanticInputMixin[MyItemInputDTO]): added_version=NEXT_RELEASE_VERSION, description="Self-service bulk create — scope is `USER` / `current_user`.", ), - name="BulkCreateMyAppConfigFragmentInput", + name="MyBulkCreateAppConfigFragmentInput", ) -class BulkCreateMyAppConfigFragmentInputGQL(PydanticInputMixin[BulkCreateMyInputDTO]): +class MyBulkCreateAppConfigFragmentInputGQL(PydanticInputMixin[MyBulkCreateInputDTO]): items: list[MyAppConfigFragmentItemInputGQL] = gql_field( description="USER-scope rows to create.", ) @@ -112,9 +112,9 @@ class BulkCreateMyAppConfigFragmentInputGQL(PydanticInputMixin[BulkCreateMyInput added_version=NEXT_RELEASE_VERSION, description="Self-service bulk update — scope is `USER` / `current_user`.", ), - name="BulkUpdateMyAppConfigFragmentInput", + name="MyBulkUpdateAppConfigFragmentInput", ) -class BulkUpdateMyAppConfigFragmentInputGQL(PydanticInputMixin[BulkUpdateMyInputDTO]): +class MyBulkUpdateAppConfigFragmentInputGQL(PydanticInputMixin[MyBulkUpdateInputDTO]): items: list[MyAppConfigFragmentItemInputGQL] = gql_field( description="USER-scope rows to update.", ) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/filters.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/filters.py index 44ed3b8d02e..7c3d89dd699 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/filters.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/filters.py @@ -45,6 +45,7 @@ class AppConfigFragmentOrderFieldGQL(StrEnum): SCOPE_ID = "scope_id" NAME = "name" CREATED_AT = "created_at" + UPDATED_AT = "updated_at" @gql_pydantic_input( diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 8050b63ee00..030f3c53a3d 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -24,8 +24,8 @@ admin_bulk_create_app_config_fragments, admin_bulk_purge_app_config_fragments, admin_bulk_update_app_config_fragments, - bulk_create_my_app_config_fragments, - bulk_update_my_app_config_fragments, + my_bulk_create_app_config_fragments, + my_bulk_update_app_config_fragments, ) from .app_config_fragment.resolver.query import ( admin_app_config_fragments, @@ -821,8 +821,8 @@ class Mutation: admin_bulk_create_app_config_fragments = admin_bulk_create_app_config_fragments admin_bulk_update_app_config_fragments = admin_bulk_update_app_config_fragments admin_bulk_purge_app_config_fragments = admin_bulk_purge_app_config_fragments - bulk_create_my_app_config_fragments = bulk_create_my_app_config_fragments - bulk_update_my_app_config_fragments = bulk_update_my_app_config_fragments + my_bulk_create_app_config_fragments = my_bulk_create_app_config_fragments + my_bulk_update_app_config_fragments = my_bulk_update_app_config_fragments # Prometheus Query Preset - Admin APIs admin_create_prometheus_query_preset = admin_create_prometheus_query_preset admin_modify_prometheus_query_preset = admin_modify_prometheus_query_preset 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 index d7e3418734c..a1fd08cfd18 100644 --- 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 @@ -15,7 +15,7 @@ @dataclass class MyBulkUpdateAppConfigFragmentsAction(BaseBulkAction[MyAppConfigFragmentBulkItem]): - """Self-service bulk update — see `BulkCreateMyAppConfigFragmentsAction`.""" + """Self-service bulk update — see `MyBulkCreateAppConfigFragmentsAction`.""" items: list[MyAppConfigFragmentBulkItem] = field(default_factory=list) diff --git a/src/ai/backend/manager/services/app_config_fragment/processors.py b/src/ai/backend/manager/services/app_config_fragment/processors.py index ca6bc41734a..5c5deb8b844 100644 --- a/src/ai/backend/manager/services/app_config_fragment/processors.py +++ b/src/ai/backend/manager/services/app_config_fragment/processors.py @@ -25,14 +25,6 @@ AdminSearchAppConfigsAction, AdminSearchAppConfigsActionResult, ) -from ai.backend.manager.services.app_config_fragment.actions.bulk_create_my import ( - BulkCreateMyAppConfigFragmentsAction, - BulkCreateMyAppConfigFragmentsActionResult, -) -from ai.backend.manager.services.app_config_fragment.actions.bulk_update_my import ( - BulkUpdateMyAppConfigFragmentsAction, - BulkUpdateMyAppConfigFragmentsActionResult, -) from ai.backend.manager.services.app_config_fragment.actions.get import ( GetAppConfigFragmentAction, GetAppConfigFragmentActionResult, @@ -41,6 +33,14 @@ GetUserAppConfigAction, GetUserAppConfigActionResult, ) +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, diff --git a/src/ai/backend/manager/services/app_config_fragment/service.py b/src/ai/backend/manager/services/app_config_fragment/service.py index bc6300e3f1b..cbe0c9d6c28 100644 --- a/src/ai/backend/manager/services/app_config_fragment/service.py +++ b/src/ai/backend/manager/services/app_config_fragment/service.py @@ -36,14 +36,6 @@ AdminSearchAppConfigsAction, AdminSearchAppConfigsActionResult, ) -from ai.backend.manager.services.app_config_fragment.actions.bulk_create_my import ( - BulkCreateMyAppConfigFragmentsAction, - BulkCreateMyAppConfigFragmentsActionResult, -) -from ai.backend.manager.services.app_config_fragment.actions.bulk_update_my import ( - BulkUpdateMyAppConfigFragmentsAction, - BulkUpdateMyAppConfigFragmentsActionResult, -) from ai.backend.manager.services.app_config_fragment.actions.get import ( GetAppConfigFragmentAction, GetAppConfigFragmentActionResult, @@ -52,6 +44,14 @@ GetUserAppConfigAction, GetUserAppConfigActionResult, ) +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 9b2613c16e484085d67792cdb54a45ed9b155de4 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Sun, 26 Apr 2026 12:12:43 +0000 Subject: [PATCH 10/13] chore: update api schema dump Co-authored-by: octodog --- .../graphql-reference/supergraph.graphql | 94 +++++++++---------- .../graphql-reference/v2-schema.graphql | 78 +++++++-------- 2 files changed, 86 insertions(+), 86 deletions(-) diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 05a89dacfd6..cc036863b8f 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -1191,7 +1191,7 @@ enum AppConfigOrderField UPDATED_AT @join__enumValue(graph: STRAWBERRY) } -"""Added in UNRELEASED. Scoped app-config policy (BEP-1052 §1).""" +"""Added in UNRELEASED. Scoped app-config policy.""" type AppConfigPolicy implements Node @join__implements(graph: STRAWBERRY, interface: "Node") @join__type(graph: STRAWBERRY) @@ -2273,29 +2273,6 @@ type BulkAssignRolePayload failed: [BulkAssignRoleError!]! } -""" -Added in UNRELEASED. Self-service bulk create — scope is `USER` / `current_user`. -""" -input MyBulkCreateAppConfigFragmentInput - @join__type(graph: STRAWBERRY) -{ - """USER-scope rows to create.""" - items: [MyAppConfigFragmentItemInput!]! -} - -""" -Added in UNRELEASED. Payload for `myBulkCreateAppConfigFragments` (recomputed views). -""" -type MyBulkCreateAppConfigFragmentsPayload - @join__type(graph: STRAWBERRY) -{ - """Recomputed merged AppConfig views for each created USER fragment.""" - created: [AppConfig!]! - - """Per-item failures.""" - failed: [AppConfigFragmentBulkError!]! -} - """Added in 26.2.0. Payload for bulk user creation mutation.""" type BulkCreateUsersV2Payload @join__type(graph: STRAWBERRY) @@ -2448,29 +2425,6 @@ type BulkRevokeRolePayload failed: [BulkRevokeRoleError!]! } -""" -Added in UNRELEASED. Self-service bulk update — scope is `USER` / `current_user`. -""" -input MyBulkUpdateAppConfigFragmentInput - @join__type(graph: STRAWBERRY) -{ - """USER-scope rows to update.""" - items: [MyAppConfigFragmentItemInput!]! -} - -""" -Added in UNRELEASED. Payload for `myBulkUpdateAppConfigFragments` (recomputed views). -""" -type MyBulkUpdateAppConfigFragmentsPayload - @join__type(graph: STRAWBERRY) -{ - """Recomputed merged AppConfig views for each updated USER fragment.""" - updated: [AppConfig!]! - - """Per-item failures.""" - failed: [AppConfigFragmentBulkError!]! -} - """Added in 26.3.0. Payload for bulk user update mutation.""" type BulkUpdateUsersV2Payload @join__type(graph: STRAWBERRY) @@ -11580,6 +11534,52 @@ input MyAppConfigFragmentItemInput extraConfig: JSON! } +""" +Added in UNRELEASED. Self-service bulk create — scope is `USER` / `current_user`. +""" +input MyBulkCreateAppConfigFragmentInput + @join__type(graph: STRAWBERRY) +{ + """USER-scope rows to create.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkCreateMyAppConfigFragments` (recomputed views). +""" +type MyBulkCreateAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Recomputed merged AppConfig views for each created USER fragment.""" + created: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + +""" +Added in UNRELEASED. Self-service bulk update — scope is `USER` / `current_user`. +""" +input MyBulkUpdateAppConfigFragmentInput + @join__type(graph: STRAWBERRY) +{ + """USER-scope rows to update.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkUpdateMyAppConfigFragments` (recomputed views). +""" +type MyBulkUpdateAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Recomputed merged AppConfig views for each updated USER fragment.""" + updated: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """ Added in 26.4.2. Query result returning the current client's IP address. """ diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 6a28153b8ec..b3e8e8afdf5 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -857,7 +857,7 @@ enum AppConfigOrderField { UPDATED_AT } -"""Added in UNRELEASED. Scoped app-config policy (BEP-1052 §1).""" +"""Added in UNRELEASED. Scoped app-config policy.""" type AppConfigPolicy implements Node { """The Globally Unique ID of this object""" id: ID! @@ -1610,25 +1610,6 @@ type BulkAssignRolePayload { failed: [BulkAssignRoleError!]! } -""" -Added in UNRELEASED. Self-service bulk create — scope is `USER` / `current_user`. -""" -input MyBulkCreateAppConfigFragmentInput { - """USER-scope rows to create.""" - items: [MyAppConfigFragmentItemInput!]! -} - -""" -Added in UNRELEASED. Payload for `myBulkCreateAppConfigFragments` (recomputed views). -""" -type MyBulkCreateAppConfigFragmentsPayload { - """Recomputed merged AppConfig views for each created USER fragment.""" - created: [AppConfig!]! - - """Per-item failures.""" - failed: [AppConfigFragmentBulkError!]! -} - """Added in 26.2.0. Error information for a failed user in bulk creation.""" type BulkCreateUserV2Error { """Original position in the input list.""" @@ -1753,25 +1734,6 @@ type BulkRevokeRolePayload { failed: [BulkRevokeRoleError!]! } -""" -Added in UNRELEASED. Self-service bulk update — scope is `USER` / `current_user`. -""" -input MyBulkUpdateAppConfigFragmentInput { - """USER-scope rows to update.""" - items: [MyAppConfigFragmentItemInput!]! -} - -""" -Added in UNRELEASED. Payload for `myBulkUpdateAppConfigFragments` (recomputed views). -""" -type MyBulkUpdateAppConfigFragmentsPayload { - """Recomputed merged AppConfig views for each updated USER fragment.""" - updated: [AppConfig!]! - - """Per-item failures.""" - failed: [AppConfigFragmentBulkError!]! -} - """Added in 26.3.0. Error information for a failed user in bulk update.""" type BulkUpdateUserV2Error { """UUID of the user that failed to update.""" @@ -7462,6 +7424,44 @@ input MyAppConfigFragmentItemInput { extraConfig: JSON! } +""" +Added in UNRELEASED. Self-service bulk create — scope is `USER` / `current_user`. +""" +input MyBulkCreateAppConfigFragmentInput { + """USER-scope rows to create.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkCreateMyAppConfigFragments` (recomputed views). +""" +type MyBulkCreateAppConfigFragmentsPayload { + """Recomputed merged AppConfig views for each created USER fragment.""" + created: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + +""" +Added in UNRELEASED. Self-service bulk update — scope is `USER` / `current_user`. +""" +input MyBulkUpdateAppConfigFragmentInput { + """USER-scope rows to update.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkUpdateMyAppConfigFragments` (recomputed views). +""" +type MyBulkUpdateAppConfigFragmentsPayload { + """Recomputed merged AppConfig views for each updated USER fragment.""" + updated: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """ Added in 26.4.2. Query result returning the current client's IP address. """ From b133b124132577b5e6f2012f91fabaae339f1965 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Sun, 26 Apr 2026 21:27:16 +0900 Subject: [PATCH 11/13] refactor(BA-5829): rename extra_config -> config across GQL surface and merged-view adapter Carry the BA-5827 column rename through: - AppConfigFragment GQL Node and bulk-input field names - AppConfigAdapter merged-view + my_bulk pass-through - The GraphQL schema dump (v2 + supergraph) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../graphql-reference/supergraph.graphql | 6 +++--- .../manager/graphql-reference/v2-schema.graphql | 6 +++--- .../backend/manager/api/adapters/app_config.py | 6 +++--- .../manager/api/gql/app_config/types/node.py | 17 +++++++++++++++-- .../app_config_fragment/types/bulk_inputs.py | 4 ++-- .../api/gql/app_config_fragment/types/node.py | 2 +- 6 files changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index cc036863b8f..a80e61210fc 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -199,7 +199,7 @@ input AdminAppConfigFragmentItemInput key: AppConfigFragmentKeyInput! """Raw configuration payload.""" - extraConfig: JSON! + config: JSON! } """Added in UNRELEASED. Per-item input for admin bulk create / update.""" @@ -1088,7 +1088,7 @@ type AppConfigFragment name: String! """Raw configuration payload, or null.""" - extraConfig: JSON + config: JSON """Creation timestamp.""" createdAt: DateTime! @@ -11531,7 +11531,7 @@ input MyAppConfigFragmentItemInput name: String! """Raw configuration payload.""" - extraConfig: JSON! + config: JSON! } """ diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index b3e8e8afdf5..db83a46b71d 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -151,7 +151,7 @@ input AdminAppConfigFragmentItemInput { key: AppConfigFragmentKeyInput! """Raw configuration payload.""" - extraConfig: JSON! + config: JSON! } """Added in UNRELEASED. Per-item input for admin bulk create / update.""" @@ -768,7 +768,7 @@ type AppConfigFragment { name: String! """Raw configuration payload, or null.""" - extraConfig: JSON + config: JSON """Creation timestamp.""" createdAt: DateTime! @@ -7421,7 +7421,7 @@ input MyAppConfigFragmentItemInput { name: String! """Raw configuration payload.""" - extraConfig: JSON! + config: JSON! } """ diff --git a/src/ai/backend/manager/api/adapters/app_config.py b/src/ai/backend/manager/api/adapters/app_config.py index 80a01d62ff5..62182e9cd87 100644 --- a/src/ai/backend/manager/api/adapters/app_config.py +++ b/src/ai/backend/manager/api/adapters/app_config.py @@ -157,7 +157,7 @@ async def my_bulk_create( self, input: MyBulkCreateAppConfigFragmentsInput ) -> MyBulkCreateAppConfigFragmentsPayload: items = [ - MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) + MyAppConfigFragmentBulkItem(name=item.name, config=dict(item.config)) for item in input.items ] wrapper = await self._processors.app_config_fragment.my_bulk_create.wait_for_complete( @@ -173,7 +173,7 @@ async def my_bulk_update( self, input: MyBulkUpdateAppConfigFragmentsInput ) -> MyBulkUpdateAppConfigFragmentsPayload: items = [ - MyAppConfigFragmentBulkItem(name=item.name, extra_config=dict(item.extra_config)) + MyAppConfigFragmentBulkItem(name=item.name, config=dict(item.config)) for item in input.items ] wrapper = await self._processors.app_config_fragment.my_bulk_update.wait_for_complete( @@ -268,7 +268,7 @@ def _fragment_data_to_dto(data: AppConfigFragmentData) -> AppConfigFragmentNode: scope_type=DTOAppConfigScopeType(data.scope_type.value), scope_id=data.scope_id, name=data.name, - extra_config=dict(data.extra_config) if data.extra_config is not None else None, + config=dict(data.config) if data.config is not None else None, created_at=data.created_at, updated_at=data.updated_at, ) diff --git a/src/ai/backend/manager/api/gql/app_config/types/node.py b/src/ai/backend/manager/api/gql/app_config/types/node.py index 14ca5c83035..8a1ecbf5bcf 100644 --- a/src/ai/backend/manager/api/gql/app_config/types/node.py +++ b/src/ai/backend/manager/api/gql/app_config/types/node.py @@ -2,13 +2,14 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Annotated from uuid import UUID +import strawberry from strawberry.scalars import JSON from ai.backend.common.dto.manager.v2.app_config.response import AppConfigNode from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION -from ai.backend.manager.api.gql.app_config_fragment.types.node import AppConfigFragmentGQL from ai.backend.manager.api.gql.decorators import ( BackendAIGQLMeta, gql_field, @@ -16,6 +17,9 @@ ) from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin +if TYPE_CHECKING: + from ai.backend.manager.api.gql.app_config_fragment.types.node import AppConfigFragmentGQL + @gql_pydantic_type( BackendAIGQLMeta( @@ -32,7 +36,16 @@ class AppConfigGQL(PydanticOutputMixin[AppConfigNode]): user_id: UUID = gql_field(description="Target user's UUID.") name: str = gql_field(description="Policy / config name.") - fragments: list[AppConfigFragmentGQL] = gql_field( + # Use `strawberry.lazy()` to break the import cycle between + # `app_config.types.node` and `app_config_fragment.types.node`: + # the fragment package's `__init__.py` eagerly loads its resolver, + # which imports `MyBulkCreate*` payloads back from `app_config.types`. + fragments: list[ + Annotated[ + AppConfigFragmentGQL, + strawberry.lazy("ai.backend.manager.api.gql.app_config_fragment.types.node"), + ] + ] = gql_field( description="Contributing fragments in merge order (low → high).", ) config: JSON | None = gql_field( diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py index 48485cd7e9f..aef8bdaf36e 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py @@ -46,7 +46,7 @@ ) class AdminAppConfigFragmentItemInputGQL(PydanticInputMixin[AdminItemInputDTO]): key: AppConfigFragmentKeyInputGQL = gql_field(description="Natural-key identifier.") - extra_config: JSON = gql_field(description="Raw configuration payload.") + config: JSON = gql_field(description="Raw configuration payload.") @gql_pydantic_input( @@ -91,7 +91,7 @@ class AdminBulkPurgeAppConfigFragmentInputGQL(PydanticInputMixin[AdminBulkPurgeI ) class MyAppConfigFragmentItemInputGQL(PydanticInputMixin[MyItemInputDTO]): name: str = gql_field(description="Policy name.") - extra_config: JSON = gql_field(description="Raw configuration payload.") + config: JSON = gql_field(description="Raw configuration payload.") @gql_pydantic_input( diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py index d90e144c8cb..fa1e1369201 100644 --- a/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py @@ -38,6 +38,6 @@ class AppConfigFragmentGQL(PydanticOutputMixin[AppConfigFragmentNode]): scope_type: AppConfigScopeType = gql_field(description="Scope type.") scope_id: str = gql_field(description="Scope id.") name: str = gql_field(description="Policy name (FK to app_config_policies).") - extra_config: JSON | None = gql_field(description="Raw configuration payload, or null.") + config: JSON | None = gql_field(description="Raw configuration payload, or null.") created_at: datetime = gql_field(description="Creation timestamp.") updated_at: datetime | None = gql_field(description="Last update timestamp.") From bdbfe133810994297ea2deadc4472c93d2ea19cd Mon Sep 17 00:00:00 2001 From: Gyubong Date: Mon, 27 Apr 2026 11:00:08 +0900 Subject: [PATCH 12/13] chore(BA-5829): drop BEP-1052 ref from 11285 news fragment --- changes/11285.feature.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/11285.feature.md b/changes/11285.feature.md index eb03039418d..05558be5bec 100644 --- a/changes/11285.feature.md +++ b/changes/11285.feature.md @@ -1 +1 @@ -Add `AppConfigFragment` Strawberry GraphQL surface — `appConfigFragment` (by natural key), `scopedAppConfigFragments`, `adminAppConfigFragments`, and admin `create` / `update` / `purge` mutations (BEP-1052 §2). +Add `AppConfigFragment` Strawberry GraphQL surface — `appConfigFragment` (by natural key), `scopedAppConfigFragments`, `adminAppConfigFragments`, and admin `create` / `update` / `purge` mutations. From 9ef6be11a57c0aedeef7bdded5a81b2903a95f6f Mon Sep 17 00:00:00 2001 From: Gyubong Date: Mon, 27 Apr 2026 11:41:58 +0900 Subject: [PATCH 13/13] chore(BA-5829): regenerate schema dump after BA-5815 id-keyed refactor Pulls in id-keyed Policy GQL surface (AdminAppConfigPolicyCreateItemInput / AdminAppConfigPolicyUpdateItemInput / purgedIds) propagated from PR #11269. --- .../graphql-reference/supergraph.graphql | 42 ++++++++++++------- .../graphql-reference/v2-schema.graphql | 40 +++++++++++------- 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index a80e61210fc..b015d56e2ed 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -202,8 +202,10 @@ input AdminAppConfigFragmentItemInput config: JSON! } -"""Added in UNRELEASED. Per-item input for admin bulk create / update.""" -input AdminAppConfigPolicyItemInput +""" +Added in UNRELEASED. Per-item input for admin bulk create — `config_name` + initial `scope_sources`. +""" +input AdminAppConfigPolicyCreateItemInput @join__type(graph: STRAWBERRY) { """Unique, immutable policy name.""" @@ -213,6 +215,19 @@ input AdminAppConfigPolicyItemInput scopeSources: [String!]! } +""" +Added in UNRELEASED. Per-item input for admin bulk update — target row id + new `scope_sources`. +""" +input AdminAppConfigPolicyUpdateItemInput + @join__type(graph: STRAWBERRY) +{ + """Policy row id.""" + id: UUID! + + """Ordered scope chain.""" + scopeSources: [String!]! +} + """Added in UNRELEASED. Admin bulk create input — items carry any scope.""" input AdminBulkCreateAppConfigFragmentInput @join__type(graph: STRAWBERRY) @@ -248,7 +263,7 @@ input AdminBulkCreateAppConfigPolicyInput @join__type(graph: STRAWBERRY) { """Policies to create.""" - items: [AdminAppConfigPolicyItemInput!]! + items: [AdminAppConfigPolicyCreateItemInput!]! } """ @@ -276,21 +291,21 @@ type AdminBulkPurgeAppConfigFragmentsPayload type AdminBulkPurgeAppConfigPoliciesPayload @join__type(graph: STRAWBERRY) { - """`config_name`s of policies actually removed (absent names no-oped).""" - purgedConfigNames: [String!]! + """Ids of policies actually removed (absent ids no-oped).""" + purgedIds: [UUID!]! """Per-item failures.""" failed: [AppConfigPolicyBulkError!]! } """ -Added in UNRELEASED. Admin bulk purge input for app-config policies (keyed on `config_name`). +Added in UNRELEASED. Admin bulk purge input for app-config policies (keyed on row id). """ input AdminBulkPurgeAppConfigPolicyInput @join__type(graph: STRAWBERRY) { - """`config_name`s to purge.""" - configNames: [String!]! + """Policy row ids to purge.""" + ids: [UUID!]! } """Added in UNRELEASED. Admin bulk update input.""" @@ -328,7 +343,7 @@ input AdminBulkUpdateAppConfigPolicyInput @join__type(graph: STRAWBERRY) { """Policies to update.""" - items: [AdminAppConfigPolicyItemInput!]! + items: [AdminAppConfigPolicyUpdateItemInput!]! } """Added in 26.4.2. Admin input for creating a keypair for a user.""" @@ -1219,9 +1234,6 @@ type AppConfigPolicyBulkError """Original position in the input list.""" index: Int! - """`config_name` of the failed row.""" - configName: String! - """Reason for the failure.""" message: String! } @@ -11275,7 +11287,7 @@ type Mutation adminBulkUpdateAppConfigPolicies(input: AdminBulkUpdateAppConfigPolicyInput!): AdminBulkUpdateAppConfigPoliciesPayload! @join__field(graph: STRAWBERRY) """ - Added in UNRELEASED. Rejects items whose `config_name` still has referencing fragment rows. Admin only. + Added in UNRELEASED. Hard-delete policies by row id; rows still referenced by fragments surface in `failed`. Admin only. """ adminBulkPurgeAppConfigPolicies(input: AdminBulkPurgeAppConfigPolicyInput!): AdminBulkPurgeAppConfigPoliciesPayload! @join__field(graph: STRAWBERRY) @@ -13893,9 +13905,9 @@ type Query adminImageAliases(filter: ImageV2AliasFilter = null, orderBy: [ImageV2AliasOrderByGQL!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ImageV2AliasConnection @join__field(graph: STRAWBERRY) """ - Added in UNRELEASED. Get a single app-config policy by `config_name`. Available to any authenticated user. + Added in UNRELEASED. Get a single app-config policy by row id. Available to any authenticated user. """ - appConfigPolicy(configName: String!): AppConfigPolicy @join__field(graph: STRAWBERRY) + appConfigPolicy(id: UUID!): AppConfigPolicy @join__field(graph: STRAWBERRY) """ Added in UNRELEASED. List app-config policies with filtering and pagination. Available to any authenticated user. diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index db83a46b71d..d73edf37a13 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -154,8 +154,10 @@ input AdminAppConfigFragmentItemInput { config: JSON! } -"""Added in UNRELEASED. Per-item input for admin bulk create / update.""" -input AdminAppConfigPolicyItemInput { +""" +Added in UNRELEASED. Per-item input for admin bulk create — `config_name` + initial `scope_sources`. +""" +input AdminAppConfigPolicyCreateItemInput { """Unique, immutable policy name.""" configName: String! @@ -163,6 +165,17 @@ input AdminAppConfigPolicyItemInput { scopeSources: [String!]! } +""" +Added in UNRELEASED. Per-item input for admin bulk update — target row id + new `scope_sources`. +""" +input AdminAppConfigPolicyUpdateItemInput { + """Policy row id.""" + id: UUID! + + """Ordered scope chain.""" + scopeSources: [String!]! +} + """Added in UNRELEASED. Admin bulk create input — items carry any scope.""" input AdminBulkCreateAppConfigFragmentInput { """Rows to create.""" @@ -190,7 +203,7 @@ type AdminBulkCreateAppConfigPoliciesPayload { """Added in UNRELEASED. Admin bulk create input for app-config policies.""" input AdminBulkCreateAppConfigPolicyInput { """Policies to create.""" - items: [AdminAppConfigPolicyItemInput!]! + items: [AdminAppConfigPolicyCreateItemInput!]! } """ @@ -212,19 +225,19 @@ type AdminBulkPurgeAppConfigFragmentsPayload { """Added in UNRELEASED. Payload for `adminBulkPurgeAppConfigPolicies`.""" type AdminBulkPurgeAppConfigPoliciesPayload { - """`config_name`s of policies actually removed (absent names no-oped).""" - purgedConfigNames: [String!]! + """Ids of policies actually removed (absent ids no-oped).""" + purgedIds: [UUID!]! """Per-item failures.""" failed: [AppConfigPolicyBulkError!]! } """ -Added in UNRELEASED. Admin bulk purge input for app-config policies (keyed on `config_name`). +Added in UNRELEASED. Admin bulk purge input for app-config policies (keyed on row id). """ input AdminBulkPurgeAppConfigPolicyInput { - """`config_name`s to purge.""" - configNames: [String!]! + """Policy row ids to purge.""" + ids: [UUID!]! } """Added in UNRELEASED. Admin bulk update input.""" @@ -254,7 +267,7 @@ type AdminBulkUpdateAppConfigPoliciesPayload { """Added in UNRELEASED. Admin bulk update input for app-config policies.""" input AdminBulkUpdateAppConfigPolicyInput { """Policies to update.""" - items: [AdminAppConfigPolicyItemInput!]! + items: [AdminAppConfigPolicyUpdateItemInput!]! } """Added in 26.4.2. Admin input for creating a keypair for a user.""" @@ -880,9 +893,6 @@ type AppConfigPolicyBulkError { """Original position in the input list.""" index: Int! - """`config_name` of the failed row.""" - configName: String! - """Reason for the failure.""" message: String! } @@ -7167,7 +7177,7 @@ type Mutation { adminBulkUpdateAppConfigPolicies(input: AdminBulkUpdateAppConfigPolicyInput!): AdminBulkUpdateAppConfigPoliciesPayload! """ - Added in UNRELEASED. Rejects items whose `config_name` still has referencing fragment rows. Admin only. + Added in UNRELEASED. Hard-delete policies by row id; rows still referenced by fragments surface in `failed`. Admin only. """ adminBulkPurgeAppConfigPolicies(input: AdminBulkPurgeAppConfigPolicyInput!): AdminBulkPurgeAppConfigPoliciesPayload! @@ -8988,9 +8998,9 @@ type Query { adminImageAliases(filter: ImageV2AliasFilter = null, orderBy: [ImageV2AliasOrderByGQL!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ImageV2AliasConnection """ - Added in UNRELEASED. Get a single app-config policy by `config_name`. Available to any authenticated user. + Added in UNRELEASED. Get a single app-config policy by row id. Available to any authenticated user. """ - appConfigPolicy(configName: String!): AppConfigPolicy + appConfigPolicy(id: UUID!): AppConfigPolicy """ Added in UNRELEASED. List app-config policies with filtering and pagination. Available to any authenticated user.