Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/12359.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the app_config service layer: resolve a user's merged AppConfig for a config name by rank-ordering the applicable public / domain / user fragments and deep-merging them.
79 changes: 79 additions & 0 deletions docs/manager/rest-reference/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,19 @@
"scope_type": {
"$ref": "#/components/schemas/AppConfigScopeType",
"description": "Scope at which fragments may be written (public | domain | user)."
},
"rank": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "Merge rank applied to fragments under this entry (low to high; higher wins). Defaults to the scope type's default rank (public=100, domain=200, user=300).",
"title": "Rank"
}
},
"required": [
Expand Down Expand Up @@ -705,6 +718,7 @@
"enum": [
"config_name",
"scope_type",
"rank",
"created_at",
"updated_at"
],
Expand Down Expand Up @@ -954,6 +968,35 @@
"title": "SearchAppConfigAllowListInput",
"type": "object"
},
"UpdateAppConfigAllowListInput": {
"description": "Input for updating an app config allow-list entry.\n\nOnly ``rank`` is updatable β€” the identity pair (``config_name``, ``scope_type``)\nis immutable (purge and recreate to change it).",
"properties": {
"id": {
"description": "App config allow-list entry id to update.",
"format": "uuid",
"title": "Id",
"type": "string"
},
"rank": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "New merge rank applied to fragments under this entry (low to high; higher wins). Omit to leave unchanged.",
"title": "Rank"
}
},
"required": [
"id"
],
"title": "UpdateAppConfigAllowListInput",
"type": "object"
},
"CreateAppConfigDefinitionInput": {
"description": "Input for registering a new app config definition.",
"properties": {
Expand Down Expand Up @@ -38611,6 +38654,42 @@
],
"description": "Get an app config allow-list entry by id (superadmin only).\n\n**Preconditions:**\n* Superadmin privilege required.\n"
},
"patch": {
"operationId": "v2/app-config-allow-list.admin_update",
"tags": [
"v2/app-config-allow-list"
],
"responses": {
"200": {
"description": "Successful response"
}
},
"security": [
{
"TokenAuth": []
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateAppConfigAllowListInput"
}
}
}
},
"parameters": [
{
"name": "app_config_allow_list_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"description": "Update an app config allow-list entry's rank by id (superadmin only).\n\n**Preconditions:**\n* Superadmin privilege required.\n"
},
"delete": {
"operationId": "v2/app-config-allow-list.admin_purge",
"tags": [
Expand Down
Empty file.
19 changes: 19 additions & 0 deletions src/ai/backend/manager/data/app_config/types.py
Original file line number Diff line number Diff line change
@@ -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`` (BEP-1052).

The ordered contributing ``fragments`` (rank low -> high) plus their deep-merged
``config``. ``config`` is ``None`` when the merge of every fragment is empty.
"""

config_name: str
fragments: list[AppConfigFragmentData]
config: dict[str, Any] | None
40 changes: 40 additions & 0 deletions src/ai/backend/manager/models/app_config_fragment/conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,23 @@
AppConfigFragmentWriteNotAllowed,
)
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.scopes import SearchScope
from ai.backend.manager.repositories.app_config_fragment.creators import (
AppConfigFragmentCreatorSpec,
GatedAppConfigFragmentCreate,
)
from ai.backend.manager.repositories.app_config_fragment.types import (
AppConfigResolveScope,
)
from ai.backend.manager.repositories.base import (
BatchQuerier,
BulkCreator,
Creator,
CreatorSpec,
ExistsQuerier,
NoPagination,
Purger,
Querier,
Updater,
Expand Down Expand Up @@ -165,9 +170,7 @@ async def bulk_create(
),
)
)
result = await w.bulk_create_partial(
BulkCreator(specs=[spec for _, spec in allowed])
)
result = await w.bulk_create_partial(BulkCreator(specs=[spec for _, spec in allowed]))
failed.extend(
AppConfigFragmentBulkItemError(
index=allowed[error.index][0], message=str(error.exception)
Expand Down Expand Up @@ -269,3 +272,72 @@ async def scoped_search(
has_next_page=result.has_next_page,
has_previous_page=result.has_previous_page,
)

@staticmethod
def _visible_fragments_selector() -> sa.Select[tuple[AppConfigFragmentRow]]:
"""Fragments joined to their allow-list entry, which carries the merge ``rank``.

The join is on the indexed ``(config_name, scope_type)`` FK pair; ordering by
``AppConfigAllowListRow.rank`` yields the merge order.
"""
return sa.select(AppConfigFragmentRow).join(
AppConfigAllowListRow,
sa.and_(
AppConfigAllowListRow.config_name == AppConfigFragmentRow.config_name,
AppConfigAllowListRow.scope_type == AppConfigFragmentRow.scope_type,
),
)

@app_config_fragment_db_source_resilience.apply()
async def list_visible_fragments(
self, config_name: str, scope: AppConfigResolveScope
) -> list[AppConfigFragmentData]:
"""Fragments visible to ``scope`` for ``config_name``, rank-ordered, in one query.

Filters on the fragment's own ``scope_type``/``scope_id`` columns: the public OR the
scope's domain OR the scope's user fragment. Merge priority lives on the allow-list
entry, so the query joins it and orders by its ``rank``. Each ``(config_name,
scope_type, scope_id)`` is unique, so the result is bounded (one per scope_type),
ready to deep-merge in order.
"""
public = AppConfigFragmentConditions.by_public_visibility(config_name)
domain = AppConfigFragmentConditions.by_domain_visibility(config_name, str(scope.domain_id))
user = AppConfigFragmentConditions.by_user_visibility(config_name, str(scope.user_id))
querier = BatchQuerier(
pagination=NoPagination(),
conditions=[lambda: sa.or_(public(), domain(), user())],
orders=[AppConfigAllowListRow.rank.asc()],
)
async with self._ops.read_ops() as r:
result = await r.batch_query_in_global(self._visible_fragments_selector(), querier)
return [row.AppConfigFragmentRow.to_data() for row in result.rows]

@app_config_fragment_db_source_resilience.apply()
async def list_visible_fragments_bulk(
self, config_names: list[str], scope: AppConfigResolveScope
) -> list[AppConfigFragmentData]:
"""Visible fragments for several ``config_names`` at once, in a single query.

The bulk form of :meth:`list_visible_fragments`: per name, the public OR the scope's
domain OR the scope's user fragment. 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 []
visibility = []
for config_name in config_names:
visibility.append(AppConfigFragmentConditions.by_public_visibility(config_name))
visibility.append(
AppConfigFragmentConditions.by_domain_visibility(config_name, str(scope.domain_id))
)
visibility.append(
AppConfigFragmentConditions.by_user_visibility(config_name, str(scope.user_id))
)
querier = BatchQuerier(
pagination=NoPagination(),
conditions=[lambda: sa.or_(*(condition() for condition in visibility))],
orders=[AppConfigFragmentRow.config_name.asc(), AppConfigAllowListRow.rank.asc()],
)
async with self._ops.read_ops() as r:
result = await r.batch_query_in_global(self._visible_fragments_selector(), querier)
return [row.AppConfigFragmentRow.to_data() for row in result.rows]
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
from ai.backend.manager.repositories.app_config_fragment.db_source import (
AppConfigFragmentDBSource,
)
from ai.backend.manager.repositories.app_config_fragment.types import (
AppConfigResolveScope,
)
from ai.backend.manager.repositories.base import (
BatchQuerier,
ExistsQuerier,
Expand Down Expand Up @@ -111,3 +114,15 @@ async def bulk_purge(
purgers: Sequence[Purger[AppConfigFragmentRow]],
) -> AppConfigFragmentBulkWriteResult:
return await self._db_source.bulk_purge(purgers)

@app_config_fragment_repository_resilience.apply()
async def list_visible_fragments(
self, config_name: str, scope: AppConfigResolveScope
) -> 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: AppConfigResolveScope
) -> list[AppConfigFragmentData]:
return await self._db_source.list_visible_fragments_bulk(config_names, scope)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Types for app config fragment repository operations (search scopes)."""
"""Types for app config fragment repository operations (search scopes, resolve scope)."""

from __future__ import annotations

Expand All @@ -16,11 +16,25 @@
from ai.backend.manager.models.scopes import ExistenceCheck, SearchScope

__all__ = (
"AppConfigResolveScope",
"DomainAppConfigFragmentSearchScope",
"UserAppConfigFragmentSearchScope",
)


@dataclass(frozen=True)
class AppConfigResolveScope:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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``).
Expand Down
Empty file.
Empty file.
19 changes: 19 additions & 0 deletions src/ai/backend/manager/services/app_config/actions/base.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading