Skip to content

Commit d2e3a45

Browse files
jopemachineclaude
andcommitted
feat(BA-6556): AppConfig REST v2 API (fragments + merged read)
DI wiring (Repositories/Services/Processors/factory), the public/anonymous resolve (resolve_public + list_public_fragments), and the REST v2 surface: admin fragment CRUD + admin_search (superadmin), scoped-search keyed by domain/user scope (auth), merged resolve (auth, with the temporary user_id==caller guard), and anonymous public read. Scoped-search uses the domain/user scope targets from BA-6554 (audit_log pattern): the request carries a scope of domain ids and/or user ids, OR-combined — no config_name or RBAC validator (per the BA-6614 decision). Squash of the BA-6556 development history, re-based onto the updated BA-6555. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 82bc893 commit d2e3a45

29 files changed

Lines changed: 1092 additions & 0 deletions

File tree

changes/12377.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add the AppConfig REST v2 API: admin CRUD and paginated search of raw config fragments, principal-scoped fragment search, the merged `(user, config_name)` resolve, and an anonymous public-only merged read (BEP-1052).

src/ai/backend/common/dto/manager/v2/app_config/__init__.py

Whitespace-only changes.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Request DTOs for the merged app_config v2 read."""
2+
3+
from __future__ import annotations
4+
5+
from uuid import UUID
6+
7+
from pydantic import Field
8+
9+
from ai.backend.common.api_handlers import BaseRequestModel
10+
11+
__all__ = ("ResolveAppConfigInput",)
12+
13+
14+
class ResolveAppConfigInput(BaseRequestModel):
15+
"""Input for resolving the merged AppConfig for one ``(user, config_name)``.
16+
17+
``user_id`` must match the authenticated caller (enforced by the adapter until an RBAC
18+
validator is in place).
19+
"""
20+
21+
config_name: str = Field(
22+
min_length=1, max_length=128, description="Config name to resolve the merged view for."
23+
)
24+
domain_id: UUID = Field(description="The resolving user's domain id.")
25+
user_id: UUID = Field(description="The resolving user's id (must be the caller).")
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Response DTOs for the merged app_config v2 read."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from pydantic import Field
8+
9+
from ai.backend.common.api_handlers import BaseResponseModel
10+
from ai.backend.common.dto.manager.v2.app_config_fragment.response import AppConfigFragmentNode
11+
12+
__all__ = (
13+
"AppConfigNode",
14+
"ResolveAppConfigPayload",
15+
)
16+
17+
18+
class AppConfigNode(BaseResponseModel):
19+
"""The merged AppConfig view for one config name."""
20+
21+
config_name: str = Field(description="Config name this merged view is for.")
22+
config: dict[str, Any] | None = Field(
23+
description="Deep-merged config in ascending rank order; null when no fragments apply."
24+
)
25+
fragments: list[AppConfigFragmentNode] = Field(
26+
description="The fragments that contributed to the merge, in ascending rank order."
27+
)
28+
29+
30+
class ResolveAppConfigPayload(BaseResponseModel):
31+
"""Payload for a merged AppConfig resolve."""
32+
33+
app_config: AppConfigNode = Field(description="The merged AppConfig view.")

src/ai/backend/common/dto/manager/v2/app_config_fragment/__init__.py

Whitespace-only changes.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Request DTOs for app_config_fragment v2."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, Self
6+
from uuid import UUID
7+
8+
from pydantic import Field, model_validator
9+
10+
from ai.backend.common.api_handlers import BaseRequestModel
11+
from ai.backend.common.data.app_config.types import AppConfigScopeType
12+
from ai.backend.common.dto.manager.query import DateTimeFilter, StringFilter
13+
from ai.backend.common.dto.manager.v2.app_config_fragment.types import (
14+
AppConfigFragmentOrderField,
15+
AppConfigScopeTypeFilter,
16+
)
17+
from ai.backend.common.dto.manager.v2.common import OrderDirection
18+
19+
__all__ = (
20+
"AppConfigFragmentFilter",
21+
"AppConfigFragmentOrder",
22+
"AppConfigFragmentScope",
23+
"CreateAppConfigFragmentInput",
24+
"PurgeAppConfigFragmentInput",
25+
"ScopedSearchAppConfigFragmentInput",
26+
"SearchAppConfigFragmentInput",
27+
"UpdateAppConfigFragmentInput",
28+
)
29+
30+
31+
class CreateAppConfigFragmentInput(BaseRequestModel):
32+
"""Input for creating a new app config fragment at a given scope (superadmin only)."""
33+
34+
config_name: str = Field(
35+
min_length=1,
36+
max_length=128,
37+
description="Registered config name (FK to app_config_definitions).",
38+
)
39+
scope_type: AppConfigScopeType = Field(
40+
description="Scope the fragment is written at (public | domain | user)."
41+
)
42+
scope_id: str = Field(
43+
description="Scope identifier: 'public' for public, the domain name, or the user id.",
44+
)
45+
config: dict[str, Any] = Field(description="The fragment's JSON config document.")
46+
47+
48+
class UpdateAppConfigFragmentInput(BaseRequestModel):
49+
"""Input for updating an app config fragment's config document."""
50+
51+
config: dict[str, Any] = Field(description="The replacement JSON config document.")
52+
53+
54+
class PurgeAppConfigFragmentInput(BaseRequestModel):
55+
"""Input for purging an app config fragment."""
56+
57+
id: UUID = Field(description="App config fragment id to purge.")
58+
59+
60+
class AppConfigFragmentFilter(BaseRequestModel):
61+
"""Filter for app config fragment search."""
62+
63+
config_name: StringFilter | None = Field(default=None, description="Filter by config name.")
64+
scope_type: AppConfigScopeTypeFilter | None = Field(
65+
default=None, description="Filter by scope type."
66+
)
67+
created_at: DateTimeFilter | None = Field(
68+
default=None, description="Filter by creation datetime."
69+
)
70+
updated_at: DateTimeFilter | None = Field(
71+
default=None, description="Filter by last update datetime."
72+
)
73+
74+
75+
class AppConfigFragmentOrder(BaseRequestModel):
76+
"""Order specifier for app config fragment search."""
77+
78+
field: AppConfigFragmentOrderField = Field(description="Field to order by.")
79+
direction: OrderDirection = Field(default=OrderDirection.ASC, description="Order direction.")
80+
81+
82+
class SearchAppConfigFragmentInput(BaseRequestModel):
83+
"""Input for paginated app config fragment search (superadmin only)."""
84+
85+
filter: AppConfigFragmentFilter | None = Field(default=None, description="Filter conditions.")
86+
order: list[AppConfigFragmentOrder] | None = Field(
87+
default=None, description="Order specifiers, applied in sequence."
88+
)
89+
first: int | None = Field(default=None, ge=1, description="Cursor-forward page size.")
90+
after: str | None = Field(default=None, description="Cursor-forward start cursor.")
91+
last: int | None = Field(default=None, ge=1, description="Cursor-backward page size.")
92+
before: str | None = Field(default=None, description="Cursor-backward end cursor.")
93+
limit: int | None = Field(
94+
default=None, ge=1, description="Offset-based: maximum number of results."
95+
)
96+
offset: int | None = Field(
97+
default=None, ge=0, description="Offset-based: number of results to skip."
98+
)
99+
100+
101+
class AppConfigFragmentScope(BaseRequestModel):
102+
"""Scope for a scoped fragment search — OR across all domain/user items.
103+
104+
Raises if every category is empty.
105+
"""
106+
107+
domain: list[UUID] | None = Field(
108+
default=None, description="Domain ids whose fragments to search."
109+
)
110+
user: list[UUID] | None = Field(default=None, description="User ids whose fragments to search.")
111+
112+
@model_validator(mode="after")
113+
def _require_non_empty(self) -> Self:
114+
if not self.domain and not self.user:
115+
raise ValueError(
116+
"AppConfigFragmentScope requires a non-empty value for 'domain' or 'user'"
117+
)
118+
return self
119+
120+
121+
class ScopedSearchAppConfigFragmentInput(BaseRequestModel):
122+
"""Input for a scoped fragment search keyed by domain/user scope (OR-combined)."""
123+
124+
scope: AppConfigFragmentScope = Field(description="Scope (OR across all items).")
125+
order: list[AppConfigFragmentOrder] | None = Field(
126+
default=None, description="Order specifiers, applied in sequence."
127+
)
128+
first: int | None = Field(default=None, ge=1, description="Cursor-forward page size.")
129+
after: str | None = Field(default=None, description="Cursor-forward start cursor.")
130+
last: int | None = Field(default=None, ge=1, description="Cursor-backward page size.")
131+
before: str | None = Field(default=None, description="Cursor-backward end cursor.")
132+
limit: int | None = Field(
133+
default=None, ge=1, description="Offset-based: maximum number of results."
134+
)
135+
offset: int | None = Field(
136+
default=None, ge=0, description="Offset-based: number of results to skip."
137+
)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Response DTOs for app_config_fragment v2."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime
6+
from typing import Any
7+
from uuid import UUID
8+
9+
from pydantic import Field
10+
11+
from ai.backend.common.api_handlers import BaseResponseModel
12+
from ai.backend.common.data.app_config.types import AppConfigScopeType
13+
14+
__all__ = (
15+
"AppConfigFragmentNode",
16+
"CreateAppConfigFragmentPayload",
17+
"PurgeAppConfigFragmentPayload",
18+
"SearchAppConfigFragmentPayload",
19+
"UpdateAppConfigFragmentPayload",
20+
)
21+
22+
23+
class AppConfigFragmentNode(BaseResponseModel):
24+
"""Node model representing one app config fragment."""
25+
26+
id: UUID = Field(description="App config fragment UUID.")
27+
config_name: str = Field(description="Config name the fragment belongs to.")
28+
scope_type: AppConfigScopeType = Field(description="Scope the fragment is written at.")
29+
scope_id: str = Field(description="Scope identifier (public / domain name / user id).")
30+
rank: int = Field(description="Merge rank (lower merges first, higher overrides).")
31+
config: dict[str, Any] = Field(description="The fragment's JSON config document.")
32+
created_at: datetime = Field(description="Creation timestamp (UTC).")
33+
updated_at: datetime = Field(description="Last update timestamp (UTC).")
34+
35+
36+
class CreateAppConfigFragmentPayload(BaseResponseModel):
37+
"""Payload for app config fragment creation."""
38+
39+
app_config_fragment: AppConfigFragmentNode = Field(description="Created app config fragment.")
40+
41+
42+
class UpdateAppConfigFragmentPayload(BaseResponseModel):
43+
"""Payload for app config fragment update."""
44+
45+
app_config_fragment: AppConfigFragmentNode = Field(description="Updated app config fragment.")
46+
47+
48+
class PurgeAppConfigFragmentPayload(BaseResponseModel):
49+
"""Payload for app config fragment purge."""
50+
51+
id: UUID = Field(description="UUID of the purged app config fragment.")
52+
53+
54+
class SearchAppConfigFragmentPayload(BaseResponseModel):
55+
"""Payload for paginated app config fragment search results."""
56+
57+
items: list[AppConfigFragmentNode] = Field(description="App config fragment nodes.")
58+
total_count: int = Field(description="Total count matching the query.")
59+
has_next_page: bool = Field(description="Whether there is a next page.")
60+
has_previous_page: bool = Field(description="Whether there is a previous page.")
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Enum types and filters for app_config_fragment v2 DTOs."""
2+
3+
from __future__ import annotations
4+
5+
from enum import StrEnum
6+
7+
from pydantic import Field
8+
9+
from ai.backend.common.api_handlers import BaseRequestModel
10+
from ai.backend.common.data.app_config.types import AppConfigScopeType
11+
12+
__all__ = (
13+
"AppConfigScopeTypeFilter",
14+
"AppConfigFragmentOrderField",
15+
)
16+
17+
18+
class AppConfigScopeTypeFilter(BaseRequestModel):
19+
"""Filter for the scope_type enum field."""
20+
21+
equals: AppConfigScopeType | None = Field(default=None, description="Exact scope type match.")
22+
in_: list[AppConfigScopeType] | None = Field(
23+
default=None, alias="in", description="Match any of the provided scope types."
24+
)
25+
not_equals: AppConfigScopeType | None = Field(
26+
default=None, description="Exclude exact scope type match."
27+
)
28+
not_in: list[AppConfigScopeType] | None = Field(
29+
default=None, description="Exclude any of the provided scope types."
30+
)
31+
32+
33+
class AppConfigFragmentOrderField(StrEnum):
34+
CONFIG_NAME = "config_name"
35+
SCOPE_TYPE = "scope_type"
36+
RANK = "rank"
37+
CREATED_AT = "created_at"
38+
UPDATED_AT = "updated_at"

src/ai/backend/manager/api/adapters/app_config/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)