Skip to content

Commit 1542e4b

Browse files
jopemachineclaude
andcommitted
feat(BA-5832): add v2 SDK + CLI for AppConfigPolicy / Fragment / merged AppConfig
SDK (`client/v2/domains_v2/`): - New `V2AppConfigPolicyClient` — `get`, `search`, `admin_bulk_create / update / purge` (BEP-1052 §3, bulk-only writes). - New `V2AppConfigFragmentClient` — `get` (POST /get with body-key), `scope_search`, `admin_search`, `admin_bulk_create / update / purge`. - Replace legacy `V2AppConfigClient` with the merged-view (BEP-1052 §5) surface — `my_get`, `my_search`, `admin_get`, `admin_search`, `my_bulk_create / update`. Old `upsert_*` / `delete_*` paths are removed (their backing endpoints were dropped in BA-5822). - Register all three clients in `V2ClientRegistry`. CLI (`client/cli/v2/`): - `bai v2 app-config-policy` — `get`, `search`. - `bai v2 admin app-config-policy` — `bulk-create / update / purge`, with `--items '{json}'` or `@file.json`. - `bai v2 app-config-fragment` — `get`, `scope-search`. - `bai v2 admin app-config-fragment` — `search`, `bulk-create / update / purge`. - `bai v2 app-config` (merged-view) — `my-get`. - `bai v2 my app-config` — `search`, `bulk-create`, `bulk-update`. - `bai v2 admin app-config` — `get`, `search`. - Replace the legacy `bai v2 app-config` commands (`get-domain` / `delete-domain` / `get-user` / `delete-user` / `get-merged`) — those REST endpoints are gone post BA-5822. All bulk write commands accept JSON list inputs via `--items` / `--keys` / `--config-names`, with `@path/to/file.json` shorthand for file-loaded payloads. The CLI surface follows `bai admin {entity}` for superadmin-only operations, `bai my {entity}` for self-service writes that act on the caller's own data, and `bai {entity}` for any-authenticated-user reads — mirrors the manager-side adapter convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 969b873 commit 1542e4b

16 files changed

Lines changed: 1113 additions & 0 deletions

File tree

src/ai/backend/client/cli/v2/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,30 @@ def notification() -> None:
251251
"""Notification commands."""
252252

253253

254+
@v2.group(cls=LazyGroup, import_name="ai.backend.client.cli.v2.app_config:app_config")
255+
def app_config() -> None:
256+
"""App config (merged-view) commands."""
257+
258+
259+
@v2.group(
260+
cls=LazyGroup,
261+
import_name="ai.backend.client.cli.v2.app_config_fragment:app_config_fragment",
262+
name="app-config-fragment",
263+
)
264+
def app_config_fragment() -> None:
265+
"""App config fragment commands."""
266+
267+
268+
@v2.group(
269+
cls=LazyGroup,
270+
import_name="ai.backend.client.cli.v2.app_config_policy:app_config_policy",
271+
name="app-config-policy",
272+
)
273+
def app_config_policy() -> None:
274+
"""App config policy commands."""
275+
276+
277+
254278
@v2.group(
255279
cls=LazyGroup,
256280
import_name="ai.backend.client.cli.v2.prometheus_query_preset:prometheus_query_preset",

src/ai/backend/client/cli/v2/admin/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,30 @@ def scheduling_handler() -> None:
203203
)
204204
def invitation() -> None:
205205
"""Admin role invitation commands."""
206+
207+
208+
@admin.group(
209+
cls=LazyGroup,
210+
import_name="ai.backend.client.cli.v2.admin.app_config:app_config",
211+
name="app-config",
212+
)
213+
def app_config() -> None:
214+
"""Admin merged AppConfig commands (BEP-1052 §5)."""
215+
216+
217+
@admin.group(
218+
cls=LazyGroup,
219+
import_name="ai.backend.client.cli.v2.admin.app_config_fragment:app_config_fragment",
220+
name="app-config-fragment",
221+
)
222+
def app_config_fragment() -> None:
223+
"""Admin AppConfigFragment commands (cross-scope search + bulk-only writes)."""
224+
225+
226+
@admin.group(
227+
cls=LazyGroup,
228+
import_name="ai.backend.client.cli.v2.admin.app_config_policy:app_config_policy",
229+
name="app-config-policy",
230+
)
231+
def app_config_policy() -> None:
232+
"""Admin AppConfigPolicy commands (bulk-only writes)."""
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Admin CLI commands for the merged AppConfig view (BEP-1052 §5)."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from uuid import UUID
7+
8+
import click
9+
10+
from ai.backend.client.cli.v2.helpers import (
11+
create_v2_registry,
12+
load_v2_config,
13+
parse_order_options,
14+
print_result,
15+
)
16+
17+
18+
@click.group(name="app-config")
19+
def app_config() -> None:
20+
"""Admin merged AppConfig commands."""
21+
22+
23+
@app_config.command()
24+
@click.argument("user_id", type=str)
25+
@click.argument("name", type=str)
26+
def get(user_id: str, name: str) -> None:
27+
"""Read a specific user's merged AppConfig (admin only)."""
28+
29+
async def _run() -> None:
30+
registry = await create_v2_registry(load_v2_config())
31+
try:
32+
result = await registry.app_config.admin_get(UUID(user_id), name)
33+
print_result(result)
34+
finally:
35+
await registry.close()
36+
37+
asyncio.run(_run())
38+
39+
40+
@app_config.command()
41+
@click.option("--limit", type=int, default=None, help="Maximum items to return.")
42+
@click.option("--offset", type=int, default=None, help="Number of items to skip.")
43+
@click.option("--name-contains", type=str, default=None, help="Filter `name` by substring.")
44+
@click.option("--user-id", type=str, default=None, help="Pin to a single user (UUID).")
45+
@click.option(
46+
"--order-by",
47+
multiple=True,
48+
help="Order by field:direction. Fields: name, user_id.",
49+
)
50+
def search(
51+
limit: int | None,
52+
offset: int | None,
53+
name_contains: str | None,
54+
user_id: str | None,
55+
order_by: tuple[str, ...],
56+
) -> None:
57+
"""Cross-user merged-view search (superadmin only)."""
58+
from ai.backend.common.dto.manager.query import StringFilter
59+
from ai.backend.common.dto.manager.v2.app_config.request import (
60+
AppConfigFilter,
61+
AppConfigOrder,
62+
SearchAppConfigsInput,
63+
)
64+
from ai.backend.common.dto.manager.v2.app_config.types import AppConfigOrderField
65+
66+
filter_dto: AppConfigFilter | None = None
67+
if name_contains is not None or user_id is not None:
68+
filter_dto = AppConfigFilter(
69+
name=StringFilter(contains=name_contains) if name_contains is not None else None,
70+
user_id=UUID(user_id) if user_id is not None else None,
71+
)
72+
73+
orders = (
74+
parse_order_options(order_by, AppConfigOrderField, AppConfigOrder)
75+
if order_by
76+
else None
77+
)
78+
79+
async def _run() -> None:
80+
registry = await create_v2_registry(load_v2_config())
81+
try:
82+
result = await registry.app_config.admin_search(
83+
SearchAppConfigsInput(
84+
filter=filter_dto,
85+
order=orders,
86+
limit=limit,
87+
offset=offset,
88+
),
89+
)
90+
print_result(result)
91+
finally:
92+
await registry.close()
93+
94+
asyncio.run(_run())
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Admin CLI commands for AppConfigFragment (cross-scope search + bulk-only writes)."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import json
7+
from pathlib import Path
8+
9+
import click
10+
11+
from ai.backend.client.cli.v2.helpers import (
12+
create_v2_registry,
13+
load_v2_config,
14+
parse_order_options,
15+
print_result,
16+
)
17+
18+
19+
@click.group(name="app-config-fragment")
20+
def app_config_fragment() -> None:
21+
"""Admin AppConfigFragment commands (cross-scope search + bulk-only writes)."""
22+
23+
24+
def _load_items(items_arg: str) -> list[dict]:
25+
"""Accept JSON string or `@file.json` path."""
26+
if items_arg.startswith("@"):
27+
return json.loads(Path(items_arg[1:]).read_text())
28+
return json.loads(items_arg)
29+
30+
31+
@app_config_fragment.command()
32+
@click.option("--limit", type=int, default=None, help="Maximum items to return.")
33+
@click.option("--offset", type=int, default=None, help="Number of items to skip.")
34+
@click.option("--name-contains", type=str, default=None, help="Filter `name` by substring.")
35+
@click.option("--scope-type", type=str, default=None, help="Filter by scope_type.")
36+
@click.option("--scope-id-contains", type=str, default=None, help="Filter `scope_id` by substring.")
37+
@click.option(
38+
"--order-by",
39+
multiple=True,
40+
help="Order by field:direction. Fields: scope_type, scope_id, name, created_at, updated_at.",
41+
)
42+
def search(
43+
limit: int | None,
44+
offset: int | None,
45+
name_contains: str | None,
46+
scope_type: str | None,
47+
scope_id_contains: str | None,
48+
order_by: tuple[str, ...],
49+
) -> None:
50+
"""Cross-scope fragment search (superadmin only)."""
51+
from ai.backend.common.dto.manager.query import StringFilter
52+
from ai.backend.common.dto.manager.v2.app_config_fragment.request import (
53+
AppConfigFragmentFilter,
54+
AppConfigFragmentOrder,
55+
SearchAppConfigFragmentsInput,
56+
)
57+
from ai.backend.common.dto.manager.v2.app_config_fragment.types import (
58+
AppConfigFragmentOrderField,
59+
AppConfigScopeType,
60+
)
61+
62+
filter_dto: AppConfigFragmentFilter | None = None
63+
if any([name_contains, scope_type, scope_id_contains]):
64+
filter_dto = AppConfigFragmentFilter(
65+
name=StringFilter(contains=name_contains) if name_contains is not None else None,
66+
scope_type=AppConfigScopeType(scope_type) if scope_type is not None else None,
67+
scope_id=(
68+
StringFilter(contains=scope_id_contains)
69+
if scope_id_contains is not None
70+
else None
71+
),
72+
)
73+
74+
orders = (
75+
parse_order_options(order_by, AppConfigFragmentOrderField, AppConfigFragmentOrder)
76+
if order_by
77+
else None
78+
)
79+
80+
async def _run() -> None:
81+
registry = await create_v2_registry(load_v2_config())
82+
try:
83+
result = await registry.app_config_fragment.admin_search(
84+
SearchAppConfigFragmentsInput(
85+
filter=filter_dto,
86+
order=orders,
87+
limit=limit,
88+
offset=offset,
89+
),
90+
)
91+
print_result(result)
92+
finally:
93+
await registry.close()
94+
95+
asyncio.run(_run())
96+
97+
98+
@app_config_fragment.command(name="bulk-create")
99+
@click.option(
100+
"--items",
101+
required=True,
102+
help=(
103+
"JSON list of `{key: {scope_type, scope_id, name}, extra_config}` items, "
104+
"or `@path/to/items.json`."
105+
),
106+
)
107+
def bulk_create(items: str) -> None:
108+
"""Bulk-create fragments (partial-success semantics)."""
109+
from ai.backend.common.dto.manager.v2.app_config_fragment.request import (
110+
AdminAppConfigFragmentItemInput,
111+
AdminBulkCreateAppConfigFragmentsInput,
112+
)
113+
114+
parsed = [AdminAppConfigFragmentItemInput.model_validate(item) for item in _load_items(items)]
115+
116+
async def _run() -> None:
117+
registry = await create_v2_registry(load_v2_config())
118+
try:
119+
result = await registry.app_config_fragment.admin_bulk_create(
120+
AdminBulkCreateAppConfigFragmentsInput(items=parsed),
121+
)
122+
print_result(result)
123+
finally:
124+
await registry.close()
125+
126+
asyncio.run(_run())
127+
128+
129+
@app_config_fragment.command(name="bulk-update")
130+
@click.option(
131+
"--items",
132+
required=True,
133+
help="Same shape as `bulk-create`; replaces `extra_config` wholesale.",
134+
)
135+
def bulk_update(items: str) -> None:
136+
"""Bulk-update fragments (partial-success semantics)."""
137+
from ai.backend.common.dto.manager.v2.app_config_fragment.request import (
138+
AdminAppConfigFragmentItemInput,
139+
AdminBulkUpdateAppConfigFragmentsInput,
140+
)
141+
142+
parsed = [AdminAppConfigFragmentItemInput.model_validate(item) for item in _load_items(items)]
143+
144+
async def _run() -> None:
145+
registry = await create_v2_registry(load_v2_config())
146+
try:
147+
result = await registry.app_config_fragment.admin_bulk_update(
148+
AdminBulkUpdateAppConfigFragmentsInput(items=parsed),
149+
)
150+
print_result(result)
151+
finally:
152+
await registry.close()
153+
154+
asyncio.run(_run())
155+
156+
157+
@app_config_fragment.command(name="bulk-purge")
158+
@click.option(
159+
"--keys",
160+
required=True,
161+
help="JSON list of `{scope_type, scope_id, name}` keys, or `@path/to/keys.json`.",
162+
)
163+
def bulk_purge(keys: str) -> None:
164+
"""Bulk-purge fragments by natural key (partial-success semantics)."""
165+
from ai.backend.common.dto.manager.v2.app_config_fragment.request import (
166+
AdminBulkPurgeAppConfigFragmentsInput,
167+
AppConfigFragmentKeyInput,
168+
)
169+
170+
parsed = [AppConfigFragmentKeyInput.model_validate(item) for item in _load_items(keys)]
171+
172+
async def _run() -> None:
173+
registry = await create_v2_registry(load_v2_config())
174+
try:
175+
result = await registry.app_config_fragment.admin_bulk_purge(
176+
AdminBulkPurgeAppConfigFragmentsInput(keys=parsed),
177+
)
178+
print_result(result)
179+
finally:
180+
await registry.close()
181+
182+
asyncio.run(_run())

0 commit comments

Comments
 (0)