diff --git a/changes/12359.feature.md b/changes/12359.feature.md new file mode 100644 index 00000000000..8299e0a50f2 --- /dev/null +++ b/changes/12359.feature.md @@ -0,0 +1 @@ +Add the app_config read service: resolve a user's merged AppConfig for one or many config names by rank-ordering the visible public / domain / user fragments and deep-merging them. diff --git a/src/ai/backend/manager/data/app_config/__init__.py b/src/ai/backend/manager/data/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/data/app_config/types.py b/src/ai/backend/manager/data/app_config/types.py new file mode 100644 index 00000000000..152160caedf --- /dev/null +++ b/src/ai/backend/manager/data/app_config/types.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData + + +@dataclass(frozen=True) +class AppConfigData: + """Merged per-user view of one ``config_name``. + + The ordered contributing ``fragments`` (rank low -> high) plus their deep-merged + ``config`` — an empty ``{}`` when there is nothing to merge. + """ + + config_name: str + fragments: list[AppConfigFragmentData] + config: dict[str, Any] diff --git a/src/ai/backend/manager/models/app_config_fragment/conditions.py b/src/ai/backend/manager/models/app_config_fragment/conditions.py index 53d3d792131..2dd1863820c 100644 --- a/src/ai/backend/manager/models/app_config_fragment/conditions.py +++ b/src/ai/backend/manager/models/app_config_fragment/conditions.py @@ -111,6 +111,46 @@ def inner() -> sa.sql.expression.ColumnElement[bool]: return inner + # --- per-scope visibility filters (one scope_type each) for a config_name --- + + @staticmethod + def by_public_visibility(config_name: str) -> QueryCondition: + """The ``public`` fragment of ``config_name`` (public has no per-entity scope_id).""" + + def inner() -> sa.sql.expression.ColumnElement[bool]: + return sa.and_( + AppConfigFragmentRow.config_name == config_name, + AppConfigFragmentRow.scope_type == AppConfigScopeType.PUBLIC, + ) + + return inner + + @staticmethod + def by_domain_visibility(config_name: str, domain: str) -> QueryCondition: + """The ``domain`` fragment of ``config_name`` for ``domain``.""" + + def inner() -> sa.sql.expression.ColumnElement[bool]: + return sa.and_( + AppConfigFragmentRow.config_name == config_name, + AppConfigFragmentRow.scope_type == AppConfigScopeType.DOMAIN, + AppConfigFragmentRow.scope_id == domain, + ) + + return inner + + @staticmethod + def by_user_visibility(config_name: str, user_id: str) -> QueryCondition: + """The ``user`` fragment of ``config_name`` for ``user_id``.""" + + def inner() -> sa.sql.expression.ColumnElement[bool]: + return sa.and_( + AppConfigFragmentRow.config_name == config_name, + AppConfigFragmentRow.scope_type == AppConfigScopeType.USER, + AppConfigFragmentRow.scope_id == user_id, + ) + + return inner + # --- created_at / updated_at datetime filters --- @staticmethod diff --git a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py index ecdfedbb71c..1aee577dd0e 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py @@ -21,12 +21,19 @@ from ai.backend.manager.errors.app_config import ( AppConfigFragmentNotFound, ) +from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow +from ai.backend.manager.models.app_config_fragment.conditions import AppConfigFragmentConditions from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.models.clauses import QueryCondition, QueryOrder from ai.backend.manager.models.scopes import SearchScope +from ai.backend.manager.repositories.app_config_fragment.types import ( + AppConfigScopeArguments, +) from ai.backend.manager.repositories.base import ( BatchQuerier, BulkCreator, Creator, + NoPagination, Purger, Querier, Updater, @@ -193,3 +200,72 @@ async def scoped_search( has_next_page=result.has_next_page, has_previous_page=result.has_previous_page, ) + + async def _list_visible_fragments( + self, + config_names: Sequence[str], + scope: AppConfigScopeArguments, + orders: list[QueryOrder], + ) -> list[AppConfigFragmentData]: + """Visible fragments for ``config_names`` in one query, ordered by ``orders``. + + Per name, the public OR the scope's domain OR the scope's user fragment; merge + priority (``rank``) lives on the joined allow-list entry. Shared by the single- and + bulk-name public methods, which own the resilience policy and supply the ordering. + """ + conditions: list[QueryCondition] = [] + for config_name in config_names: + conditions.append(AppConfigFragmentConditions.by_public_visibility(config_name)) + conditions.append( + AppConfigFragmentConditions.by_domain_visibility(config_name, str(scope.domain_id)) + ) + conditions.append( + AppConfigFragmentConditions.by_user_visibility(config_name, str(scope.user_id)) + ) + querier = BatchQuerier( + pagination=NoPagination(), + conditions=[lambda: sa.or_(*(condition() for condition in conditions))], + orders=orders, + ) + # Join each fragment to its allow-list entry (indexed ``(config_name, scope_type)`` FK + # pair), which carries the merge ``rank`` used by ``orders``. + selector = sa.select(AppConfigFragmentRow).join( + AppConfigAllowListRow, + sa.and_( + AppConfigAllowListRow.config_name == AppConfigFragmentRow.config_name, + AppConfigAllowListRow.scope_type == AppConfigFragmentRow.scope_type, + ), + ) + async with self._ops.read_ops() as r: + result = await r.batch_query_in_global(selector, querier) + return [row.AppConfigFragmentRow.to_data() for row in result.rows] + + @app_config_fragment_db_source_resilience.apply() + async def list_visible_fragments( + self, config_name: str, scope: AppConfigScopeArguments + ) -> list[AppConfigFragmentData]: + """Fragments visible to ``scope`` for one ``config_name``, rank-ordered, in one query. + + Each ``(config_name, scope_type, scope_id)`` is unique, so the result is bounded + (one per scope_type), ready to deep-merge in ``rank`` order. + """ + return await self._list_visible_fragments( + [config_name], scope, orders=[AppConfigAllowListRow.rank.asc()] + ) + + @app_config_fragment_db_source_resilience.apply() + async def list_visible_fragments_bulk( + self, config_names: list[str], scope: AppConfigScopeArguments + ) -> list[AppConfigFragmentData]: + """Visible fragments for several ``config_names`` at once, in a single query. + + The bulk form of :meth:`list_visible_fragments`: ordered by ``(config_name, + allow-list rank)`` so the caller can group by name and deep-merge each in order. + """ + if not config_names: + return [] + return await self._list_visible_fragments( + config_names, + scope, + orders=[AppConfigFragmentRow.config_name.asc(), AppConfigAllowListRow.rank.asc()], + ) diff --git a/src/ai/backend/manager/repositories/app_config_fragment/repository.py b/src/ai/backend/manager/repositories/app_config_fragment/repository.py index 0ade21b67fd..a7ebdf6b135 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/repository.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/repository.py @@ -18,6 +18,9 @@ from ai.backend.manager.repositories.app_config_fragment.db_source import ( AppConfigFragmentDBSource, ) +from ai.backend.manager.repositories.app_config_fragment.types import ( + AppConfigScopeArguments, +) from ai.backend.manager.repositories.base import ( BatchQuerier, BulkCreator, @@ -103,3 +106,15 @@ async def bulk_purge( purgers: Sequence[Purger[AppConfigFragmentRow]], ) -> AppConfigFragmentBulkResult: return await self._db_source.bulk_purge(purgers) + + @app_config_fragment_repository_resilience.apply() + async def list_visible_fragments( + self, config_name: str, scope: AppConfigScopeArguments + ) -> list[AppConfigFragmentData]: + return await self._db_source.list_visible_fragments(config_name, scope) + + @app_config_fragment_repository_resilience.apply() + async def list_visible_fragments_bulk( + self, config_names: list[str], scope: AppConfigScopeArguments + ) -> list[AppConfigFragmentData]: + return await self._db_source.list_visible_fragments_bulk(config_names, scope) diff --git a/src/ai/backend/manager/repositories/app_config_fragment/types.py b/src/ai/backend/manager/repositories/app_config_fragment/types.py index 190dd9def78..39e7e3c7750 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/types.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/types.py @@ -1,4 +1,4 @@ -"""Types for app config fragment repository operations (search scopes).""" +"""Types for app config fragment repository operations (search scopes, resolve arguments).""" from __future__ import annotations @@ -16,11 +16,25 @@ from ai.backend.manager.models.scopes import ExistenceCheck, SearchScope __all__ = ( + "AppConfigScopeArguments", "DomainAppConfigFragmentSearchScope", "UserAppConfigFragmentSearchScope", ) +@dataclass(frozen=True) +class AppConfigScopeArguments: + """The principal an ``AppConfig`` is resolved for: the resolving user and its domain. + + Bundles the scope-identifying arguments so they travel together (add new principal + dimensions here rather than growing method signatures). Plain value object — not a + :class:`SearchScope`. + """ + + domain_id: DomainID + user_id: UserID + + @dataclass(frozen=True) class DomainAppConfigFragmentSearchScope(SearchScope): """Fragments written at one domain scope (``scope_type == domain``, ``scope_id == domain_id``). diff --git a/src/ai/backend/manager/services/app_config/__init__.py b/src/ai/backend/manager/services/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/services/app_config/actions/__init__.py b/src/ai/backend/manager/services/app_config/actions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/services/app_config/actions/base.py b/src/ai/backend/manager/services/app_config/actions/base.py new file mode 100644 index 00000000000..d134ddd3700 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/base.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import override + +from ai.backend.common.data.permission.types import EntityType +from ai.backend.manager.actions.action.scope import BaseScopeAction, BaseScopeActionResult + + +class AppConfigScopeAction(BaseScopeAction): + """Base for scope-level merged app config actions (resolve).""" + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.APP_CONFIG + + +class AppConfigScopeActionResult(BaseScopeActionResult): + pass diff --git a/src/ai/backend/manager/services/app_config/actions/resolve.py b/src/ai/backend/manager/services/app_config/actions/resolve.py new file mode 100644 index 00000000000..4e8b9eb4572 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/resolve.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override + +from ai.backend.common.data.permission.types import RBACElementType, ScopeType +from ai.backend.common.identifier.domain import DomainID +from ai.backend.common.identifier.user import UserID +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.permission.types import RBACElementRef +from ai.backend.manager.services.app_config.actions.base import ( + AppConfigScopeAction, + AppConfigScopeActionResult, +) + + +@dataclass +class ResolveAppConfigAction(AppConfigScopeAction): + """Resolve the merged ``AppConfig`` for one ``(user, config_name)``.""" + + config_name: str + domain_id: DomainID + user_id: UserID + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.GET + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER + + @override + def scope_id(self) -> str: + return str(self.user_id) + + @override + def target_element(self) -> RBACElementRef: + return RBACElementRef(RBACElementType.APP_CONFIG, "") + + +@dataclass +class ResolveAppConfigActionResult(AppConfigScopeActionResult): + app_config: AppConfigData + _user_id: UserID + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER + + @override + def scope_id(self) -> str: + return str(self._user_id) diff --git a/src/ai/backend/manager/services/app_config/actions/resolve_bulk.py b/src/ai/backend/manager/services/app_config/actions/resolve_bulk.py new file mode 100644 index 00000000000..844bbb8daac --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/resolve_bulk.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override + +from ai.backend.common.data.permission.types import RBACElementType, ScopeType +from ai.backend.common.identifier.domain import DomainID +from ai.backend.common.identifier.user import UserID +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.permission.types import RBACElementRef +from ai.backend.manager.services.app_config.actions.base import ( + AppConfigScopeAction, + AppConfigScopeActionResult, +) + + +@dataclass +class ResolveBulkAppConfigAction(AppConfigScopeAction): + """Resolve several merged ``AppConfig``s for one ``(user, domain)`` principal at once.""" + + config_names: list[str] + domain_id: DomainID + user_id: UserID + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.GET + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER + + @override + def scope_id(self) -> str: + return str(self.user_id) + + @override + def target_element(self) -> RBACElementRef: + return RBACElementRef(RBACElementType.APP_CONFIG, "") + + +@dataclass +class ResolveBulkAppConfigActionResult(AppConfigScopeActionResult): + app_configs: list[AppConfigData] + _user_id: UserID + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER + + @override + def scope_id(self) -> str: + return str(self._user_id) diff --git a/src/ai/backend/manager/services/app_config/processors.py b/src/ai/backend/manager/services/app_config/processors.py new file mode 100644 index 00000000000..5ce9c6e1b76 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/processors.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import override + +from ai.backend.manager.actions.monitors.monitor import ActionMonitor +from ai.backend.manager.actions.processor.scope import ScopeActionProcessor +from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec +from ai.backend.manager.services.app_config.actions.resolve import ( + ResolveAppConfigAction, + ResolveAppConfigActionResult, +) +from ai.backend.manager.services.app_config.actions.resolve_bulk import ( + ResolveBulkAppConfigAction, + ResolveBulkAppConfigActionResult, +) +from ai.backend.manager.services.app_config.service import AppConfigService + + +class AppConfigProcessors(AbstractProcessorPackage): + resolve_app_config: ScopeActionProcessor[ResolveAppConfigAction, ResolveAppConfigActionResult] + resolve_app_config_bulk: ScopeActionProcessor[ + ResolveBulkAppConfigAction, ResolveBulkAppConfigActionResult + ] + + def __init__( + self, + service: AppConfigService, + action_monitors: list[ActionMonitor], + ) -> None: + self.resolve_app_config = ScopeActionProcessor(service.resolve_app_config, action_monitors) + self.resolve_app_config_bulk = ScopeActionProcessor( + service.resolve_app_config_bulk, action_monitors + ) + + @override + def supported_actions(self) -> list[ActionSpec]: + return [ + ResolveAppConfigAction.spec(), + ResolveBulkAppConfigAction.spec(), + ] diff --git a/src/ai/backend/manager/services/app_config/service.py b/src/ai/backend/manager/services/app_config/service.py new file mode 100644 index 00000000000..cd543c8910f --- /dev/null +++ b/src/ai/backend/manager/services/app_config/service.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from ai.backend.common.utils import deep_merge +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData +from ai.backend.manager.repositories.app_config_fragment.repository import ( + AppConfigFragmentRepository, +) +from ai.backend.manager.repositories.app_config_fragment.types import AppConfigScopeArguments +from ai.backend.manager.services.app_config.actions.resolve import ( + ResolveAppConfigAction, + ResolveAppConfigActionResult, +) +from ai.backend.manager.services.app_config.actions.resolve_bulk import ( + ResolveBulkAppConfigAction, + ResolveBulkAppConfigActionResult, +) + +__all__ = ("AppConfigService",) + + +class AppConfigService: + """Read-side service for the merged ``AppConfig`` view.""" + + _fragment_repository: AppConfigFragmentRepository + + def __init__(self, fragment_repository: AppConfigFragmentRepository) -> None: + self._fragment_repository = fragment_repository + + @staticmethod + def _merge_configs(fragments: Sequence[AppConfigFragmentData]) -> dict[str, Any]: + """Deep-merge fragment configs (pre-ordered low -> high) in ``rank`` order.""" + return dict(deep_merge(*(fragment.config for fragment in fragments))) + + async def resolve_app_config( + self, action: ResolveAppConfigAction + ) -> ResolveAppConfigActionResult: + """Resolve the merged ``AppConfig`` for ``(user, config_name)``.""" + fragments = await self._fragment_repository.list_visible_fragments( + action.config_name, + AppConfigScopeArguments(domain_id=action.domain_id, user_id=action.user_id), + ) + app_config = AppConfigData( + config_name=action.config_name, + fragments=fragments, + config=self._merge_configs(fragments), + ) + return ResolveAppConfigActionResult(app_config=app_config, _user_id=action.user_id) + + async def resolve_app_config_bulk( + self, action: ResolveBulkAppConfigAction + ) -> ResolveBulkAppConfigActionResult: + """Resolve several merged ``AppConfig``s for one principal in a single query.""" + scope = AppConfigScopeArguments(domain_id=action.domain_id, user_id=action.user_id) + fragments = await self._fragment_repository.list_visible_fragments_bulk( + action.config_names, scope + ) + grouped: dict[str, list[AppConfigFragmentData]] = {} + for fragment in fragments: + grouped.setdefault(fragment.config_name, []).append(fragment) + app_configs = [ + AppConfigData( + config_name=config_name, + fragments=grouped.get(config_name, []), + config=self._merge_configs(grouped.get(config_name, [])), + ) + for config_name in action.config_names + ] + return ResolveBulkAppConfigActionResult(app_configs=app_configs, _user_id=action.user_id) diff --git a/tests/unit/manager/repositories/app_config_fragment/test_repository.py b/tests/unit/manager/repositories/app_config_fragment/test_repository.py index 78300007086..6963c68f3b8 100644 --- a/tests/unit/manager/repositories/app_config_fragment/test_repository.py +++ b/tests/unit/manager/repositories/app_config_fragment/test_repository.py @@ -31,6 +31,7 @@ AppConfigFragmentRepository, ) from ai.backend.manager.repositories.app_config_fragment.types import ( + AppConfigScopeArguments, DomainAppConfigFragmentSearchScope, UserAppConfigFragmentSearchScope, ) @@ -569,3 +570,139 @@ async def test_partial_when_one_missing( assert [f.index for f in result.failed] == [1] with pytest.raises(AppConfigFragmentNotFound): await repository.get_by_id(two_fragments[0].id) + + +class TestVisibilityConditions: + async def test_public_visibility_selects_only_public( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + result = await repository.admin_search( + BatchQuerier( + pagination=OffsetPagination(limit=10, offset=0), + conditions=[AppConfigFragmentConditions.by_public_visibility("theme")], + ) + ) + expected = { + f.id + for f in fragments_across_scopes + if f.config_name == "theme" and f.scope_type is AppConfigScopeType.PUBLIC + } + assert {item.id for item in result.items} == expected + + async def test_domain_visibility_selects_only_that_domain( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + result = await repository.admin_search( + BatchQuerier( + pagination=OffsetPagination(limit=10, offset=0), + conditions=[AppConfigFragmentConditions.by_domain_visibility("theme", _DOMAIN_ID)], + ) + ) + expected = { + f.id + for f in fragments_across_scopes + if f.config_name == "theme" + and f.scope_type is AppConfigScopeType.DOMAIN + and f.scope_id == _DOMAIN_ID + } + assert {item.id for item in result.items} == expected + + async def test_user_visibility_selects_only_that_user( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + result = await repository.admin_search( + BatchQuerier( + pagination=OffsetPagination(limit=10, offset=0), + conditions=[AppConfigFragmentConditions.by_user_visibility("theme", _USER_ID)], + ) + ) + expected = { + f.id + for f in fragments_across_scopes + if f.config_name == "theme" + and f.scope_type is AppConfigScopeType.USER + and f.scope_id == _USER_ID + } + assert {item.id for item in result.items} == expected + + +class TestApplicableFragments: + async def test_one_query_returns_public_domain_user_rank_ordered( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + applicable = await repository.list_visible_fragments( + "theme", + AppConfigScopeArguments(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ) + # public + the caller's domain + the caller's own user fragment, ordered by the + # allow-list entries' ranks (scope-type defaults: public < domain < user). + expected = [ + f + for f in fragments_across_scopes + if f.config_name == "theme" + and ( + f.scope_type is AppConfigScopeType.PUBLIC + or (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID) + or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID) + ) + ] + assert [f.id for f in applicable] == [f.id for f in expected] + assert [f.scope_type for f in applicable] == [ + AppConfigScopeType.PUBLIC, + AppConfigScopeType.DOMAIN, + AppConfigScopeType.USER, + ] + + async def test_unknown_config_name_returns_empty( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + applicable = await repository.list_visible_fragments( + "unregistered", + AppConfigScopeArguments(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ) + assert applicable == [] + + async def test_bulk_returns_visible_fragments_for_all_names_ordered( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + applicable = await repository.list_visible_fragments_bulk( + ["theme", "menu"], + AppConfigScopeArguments(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ) + # public + the caller's domain + the caller's own user fragment, for both names. + expected = { + f.id + for f in fragments_across_scopes + if f.config_name in ("theme", "menu") + and ( + f.scope_type is AppConfigScopeType.PUBLIC + or (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID) + or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID) + ) + } + assert {f.id for f in applicable} == expected + # Grouped by config_name, rank-ordered within each. + assert [f.config_name for f in applicable] == sorted(f.config_name for f in applicable) + + async def test_bulk_empty_names_returns_empty( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + applicable = await repository.list_visible_fragments_bulk( + [], + AppConfigScopeArguments(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ) + assert applicable == [] diff --git a/tests/unit/manager/services/app_config/BUILD b/tests/unit/manager/services/app_config/BUILD new file mode 100644 index 00000000000..57341b1358b --- /dev/null +++ b/tests/unit/manager/services/app_config/BUILD @@ -0,0 +1,3 @@ +python_tests( + name="tests", +) diff --git a/tests/unit/manager/services/app_config/__init__.py b/tests/unit/manager/services/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/manager/services/app_config/test_service.py b/tests/unit/manager/services/app_config/test_service.py new file mode 100644 index 00000000000..051102812aa --- /dev/null +++ b/tests/unit/manager/services/app_config/test_service.py @@ -0,0 +1,97 @@ +"""Tests for AppConfigService (merged AppConfig resolution) with a mocked repository.""" + +from __future__ import annotations + +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai.backend.common.identifier.domain import DomainID +from ai.backend.common.identifier.user import UserID +from ai.backend.manager.repositories.app_config_fragment.repository import ( + AppConfigFragmentRepository, +) +from ai.backend.manager.repositories.app_config_fragment.types import AppConfigScopeArguments +from ai.backend.manager.services.app_config.actions.resolve import ResolveAppConfigAction +from ai.backend.manager.services.app_config.actions.resolve_bulk import ( + ResolveBulkAppConfigAction, +) +from ai.backend.manager.services.app_config.service import AppConfigService + +_USER_ID = UserID(uuid.uuid4()) +_DOMAIN_ID = DomainID(uuid.uuid4()) + + +class TestAppConfigService: + @pytest.fixture + def mock_fragment_repository(self) -> MagicMock: + return MagicMock(spec=AppConfigFragmentRepository) + + @pytest.fixture + def service(self, mock_fragment_repository: MagicMock) -> AppConfigService: + return AppConfigService(fragment_repository=mock_fragment_repository) + + async def test_resolve_deep_merges_applicable_fragments( + self, service: AppConfigService, mock_fragment_repository: MagicMock + ) -> None: + # Fragments come rank-ordered (low -> high), so the later one overrides on merge. + # The service only reads ``config``/``config_name``, so a stub with those suffices. + public = SimpleNamespace(config_name="theme", config={"theme": "light", "lang": "en"}) + user = SimpleNamespace(config_name="theme", config={"theme": "dark"}) + mock_fragment_repository.list_visible_fragments = AsyncMock(return_value=[public, user]) + + result = await service.resolve_app_config( + ResolveAppConfigAction(config_name="theme", domain_id=_DOMAIN_ID, user_id=_USER_ID) + ) + + assert result.app_config.config_name == "theme" + assert result.app_config.fragments == [public, user] + assert result.app_config.config == {"theme": "dark", "lang": "en"} + assert result.scope_id() == str(_USER_ID) + mock_fragment_repository.list_visible_fragments.assert_called_once_with( + "theme", AppConfigScopeArguments(domain_id=_DOMAIN_ID, user_id=_USER_ID) + ) + + async def test_resolve_empty_projects_config_to_empty_dict( + self, service: AppConfigService, mock_fragment_repository: MagicMock + ) -> None: + mock_fragment_repository.list_visible_fragments = AsyncMock(return_value=[]) + + result = await service.resolve_app_config( + ResolveAppConfigAction(config_name="unknown", domain_id=_DOMAIN_ID, user_id=_USER_ID) + ) + + assert result.app_config.fragments == [] + assert result.app_config.config == {} + + async def test_resolve_bulk_groups_by_name_and_merges_each( + self, service: AppConfigService, mock_fragment_repository: MagicMock + ) -> None: + # Repo returns visible fragments for both names, (config_name, rank)-ordered. + theme_public = SimpleNamespace(config_name="theme", config={"theme": "light", "lang": "en"}) + theme_user = SimpleNamespace(config_name="theme", config={"theme": "dark"}) + menu_public = SimpleNamespace(config_name="menu", config={"items": ["a"]}) + mock_fragment_repository.list_visible_fragments_bulk = AsyncMock( + return_value=[theme_public, theme_user, menu_public] + ) + + result = await service.resolve_app_config_bulk( + ResolveBulkAppConfigAction( + config_names=["theme", "menu", "unknown"], + domain_id=_DOMAIN_ID, + user_id=_USER_ID, + ) + ) + + # One AppConfigData per requested name, in request order. + assert [c.config_name for c in result.app_configs] == ["theme", "menu", "unknown"] + assert result.app_configs[0].config == {"theme": "dark", "lang": "en"} + assert result.app_configs[1].config == {"items": ["a"]} + assert result.app_configs[2].config == {} # unregistered -> empty merge + assert result.scope_id() == str(_USER_ID) + mock_fragment_repository.list_visible_fragments_bulk.assert_called_once_with( + ["theme", "menu", "unknown"], + AppConfigScopeArguments(domain_id=_DOMAIN_ID, user_id=_USER_ID), + )