Skip to content

Commit 5b75d9e

Browse files
jopemachineclaude
andcommitted
feat(BA-6555): AppConfig merge engine + resolve service (service layer)
Resolve the merged AppConfig for a (user, config_name) on top of the repository's visible-fragments query: - AppConfigData: the merged per-user view (ordered fragments + deep-merged config; None when no fragment contributes) - deep-merge in rank order — nested dicts recurse, lists/scalars are replaced wholesale by the higher-rank fragment - resolve / resolve_bulk actions + processors (bulk repeats duplicate names in order) - mocked-repository service tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1a6cd37 commit 5b75d9e

18 files changed

Lines changed: 624 additions & 15 deletions

File tree

changes/12359.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

src/ai/backend/manager/data/app_config/__init__.py

Whitespace-only changes.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData
7+
8+
9+
@dataclass(frozen=True)
10+
class AppConfigData:
11+
"""Merged per-user view of one ``config_name``.
12+
13+
The ordered contributing ``fragments`` (rank low -> high) plus their deep-merged
14+
``merged_config``. ``merged_config`` is ``None`` when no fragment contributes (the config
15+
name is defined but unconfigured for this scope) — distinct from a fragment that merges
16+
to an empty ``{}``.
17+
"""
18+
19+
config_name: str
20+
fragments: list[AppConfigFragmentData]
21+
merged_config: dict[str, Any] | None

src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -210,24 +210,22 @@ async def scoped_search(
210210

211211
@app_config_fragment_db_source_resilience.apply()
212212
async def list_visible_fragments_bulk(
213-
self, config_names: list[str], scope: AppConfigScopeArguments
213+
self, config_names: list[str], scope: AppConfigScopeArguments | None = None
214214
) -> list[AppConfigFragmentData]:
215-
"""Visible fragments for several ``config_names`` at once, in a single query.
216-
217-
Selects the requested names AND any one of the principal's visible scopes (public,
218-
its domain, or its own user). The scope filter is name-independent, so it is a single
219-
OR group AND-combined with the name membership. Merge priority (``rank``) lives on the
220-
joined allow-list entry; the result is always ordered by ascending ``rank`` so the
221-
caller can group by name (each name's subset stays rank-ordered) and deep-merge each
222-
name's fragments in order.
215+
"""Visible fragments for several ``config_names`` in one query, ordered by ascending ``rank``.
216+
217+
``public`` always contributes; a ``scope`` additionally admits its domain and user
218+
overlay, while ``scope=None`` (anonymous) sees only ``public``. Rank-ordered so the
219+
caller can group by name and deep-merge each name's fragments in order.
223220
"""
224221
if not config_names:
225222
return []
226-
scope_visibility = [
227-
AppConfigFragmentConditions.by_public_visibility(),
228-
AppConfigFragmentConditions.by_domain_visibility(str(scope.domain_id)),
229-
AppConfigFragmentConditions.by_user_visibility(str(scope.user_id)),
230-
]
223+
scope_visibility = [AppConfigFragmentConditions.by_public_visibility()]
224+
if scope is not None:
225+
scope_visibility += [
226+
AppConfigFragmentConditions.by_domain_visibility(str(scope.domain_id)),
227+
AppConfigFragmentConditions.by_user_visibility(str(scope.user_id)),
228+
]
231229
querier = BatchQuerier(
232230
pagination=NoPagination(),
233231
conditions=[

src/ai/backend/manager/repositories/app_config_fragment/repository.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,6 @@ async def bulk_purge(
105105

106106
@app_config_fragment_repository_resilience.apply()
107107
async def list_visible_fragments_bulk(
108-
self, config_names: list[str], scope: AppConfigScopeArguments
108+
self, config_names: list[str], scope: AppConfigScopeArguments | None = None
109109
) -> list[AppConfigFragmentData]:
110110
return await self._db_source.list_visible_fragments_bulk(config_names, scope)

src/ai/backend/manager/repositories/repositories.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
from ai.backend.manager.repositories.app_config_definition.repositories import (
99
AppConfigDefinitionRepositories,
1010
)
11+
from ai.backend.manager.repositories.app_config_fragment.repositories import (
12+
AppConfigFragmentRepositories,
13+
)
1114
from ai.backend.manager.repositories.artifact.repositories import ArtifactRepositories
1215
from ai.backend.manager.repositories.artifact_registry.repositories import (
1316
ArtifactRegistryRepositories,
@@ -94,6 +97,7 @@ class Repositories:
9497
agent: AgentRepositories
9598
app_config_allow_list: AppConfigAllowListRepositories
9699
app_config_definition: AppConfigDefinitionRepositories
100+
app_config_fragment: AppConfigFragmentRepositories
97101
auth: AuthRepositories
98102
container_registry: ContainerRegistryRepositories
99103
deployment: DeploymentRepositories
@@ -148,6 +152,7 @@ def create(cls, args: RepositoryArgs) -> Self:
148152
agent_repositories = AgentRepositories.create(args)
149153
app_config_allow_list_repositories = AppConfigAllowListRepositories.create(args)
150154
app_config_definition_repositories = AppConfigDefinitionRepositories.create(args)
155+
app_config_fragment_repositories = AppConfigFragmentRepositories.create(args)
151156
auth_repositories = AuthRepositories.create(args)
152157
container_registry_repositories = ContainerRegistryRepositories.create(args)
153158
deployment_repositories = DeploymentRepositories.create(args)
@@ -203,6 +208,7 @@ def create(cls, args: RepositoryArgs) -> Self:
203208
agent=agent_repositories,
204209
app_config_allow_list=app_config_allow_list_repositories,
205210
app_config_definition=app_config_definition_repositories,
211+
app_config_fragment=app_config_fragment_repositories,
206212
auth=auth_repositories,
207213
container_registry=container_registry_repositories,
208214
deployment=deployment_repositories,

src/ai/backend/manager/services/app_config/__init__.py

Whitespace-only changes.

src/ai/backend/manager/services/app_config/actions/__init__.py

Whitespace-only changes.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from __future__ import annotations
2+
3+
from typing import override
4+
5+
from ai.backend.common.data.permission.types import EntityType
6+
from ai.backend.manager.actions.action.scope import BaseScopeAction, BaseScopeActionResult
7+
8+
9+
class AppConfigScopeAction(BaseScopeAction):
10+
"""Base for scope-level merged app config actions (resolve)."""
11+
12+
@override
13+
@classmethod
14+
def entity_type(cls) -> EntityType:
15+
return EntityType.APP_CONFIG
16+
17+
18+
class AppConfigScopeActionResult(BaseScopeActionResult):
19+
pass
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import override
5+
6+
from ai.backend.common.data.permission.types import RBACElementType, ScopeType
7+
from ai.backend.common.identifier.user import UserID
8+
from ai.backend.manager.actions.types import ActionOperationType
9+
from ai.backend.manager.data.app_config.types import AppConfigData
10+
from ai.backend.manager.data.permission.types import RBACElementRef
11+
from ai.backend.manager.repositories.app_config_fragment.types import AppConfigScopeArguments
12+
from ai.backend.manager.services.app_config.actions.base import (
13+
AppConfigScopeAction,
14+
AppConfigScopeActionResult,
15+
)
16+
17+
18+
@dataclass
19+
class ResolveAppConfigAction(AppConfigScopeAction):
20+
"""Resolve the merged ``AppConfig`` for one ``config_name``.
21+
22+
``scope`` carries the resolving principal ``(user, domain)``. When it is ``None`` — the
23+
anonymous, pre-login read — only ``public``-scope fragments contribute and the action is
24+
a ``GLOBAL`` read attributable to no user.
25+
"""
26+
27+
config_name: str
28+
scope_arguments: AppConfigScopeArguments | None = None
29+
30+
@override
31+
@classmethod
32+
def operation_type(cls) -> ActionOperationType:
33+
return ActionOperationType.GET
34+
35+
@override
36+
def scope_type(self) -> ScopeType:
37+
return ScopeType.USER if self.scope_arguments is not None else ScopeType.GLOBAL
38+
39+
@override
40+
def scope_id(self) -> str:
41+
return str(self.scope_arguments.user_id) if self.scope_arguments is not None else ""
42+
43+
@override
44+
def target_element(self) -> RBACElementRef:
45+
return RBACElementRef(RBACElementType.APP_CONFIG, "")
46+
47+
48+
@dataclass
49+
class ResolveAppConfigActionResult(AppConfigScopeActionResult):
50+
app_config: AppConfigData
51+
_user_id: UserID | None
52+
53+
@override
54+
def scope_type(self) -> ScopeType:
55+
return ScopeType.USER if self._user_id is not None else ScopeType.GLOBAL
56+
57+
@override
58+
def scope_id(self) -> str:
59+
return str(self._user_id) if self._user_id is not None else ""

0 commit comments

Comments
 (0)