diff --git a/changes/11295.feature.md b/changes/11295.feature.md new file mode 100644 index 00000000000..c7325f37078 --- /dev/null +++ b/changes/11295.feature.md @@ -0,0 +1 @@ +Add v2 SDK + CLI for the AppConfig surface — `app-config-policy` (get / search / bulk admin writes), `app-config-fragment` (get / scope-search / admin search / bulk admin writes), and the merged `app-config` view (`my` reads + writes, admin reads). Replaces the legacy domain/user upsert SDK + CLI whose endpoints were dropped in BA-5822. diff --git a/src/ai/backend/client/cli/v2/__init__.py b/src/ai/backend/client/cli/v2/__init__.py index 6b2c64b2c2e..1711db04ed2 100644 --- a/src/ai/backend/client/cli/v2/__init__.py +++ b/src/ai/backend/client/cli/v2/__init__.py @@ -251,6 +251,11 @@ def notification() -> None: """Notification commands.""" +@v2.group(cls=LazyGroup, import_name="ai.backend.client.cli.v2.app_config:app_config") +def app_config() -> None: + """App config (merged-view) commands.""" + + @v2.group( cls=LazyGroup, import_name="ai.backend.client.cli.v2.prometheus_query_preset:prometheus_query_preset", diff --git a/src/ai/backend/client/cli/v2/admin/__init__.py b/src/ai/backend/client/cli/v2/admin/__init__.py index 0c94ad2e63f..1051d1f37aa 100644 --- a/src/ai/backend/client/cli/v2/admin/__init__.py +++ b/src/ai/backend/client/cli/v2/admin/__init__.py @@ -203,3 +203,30 @@ def scheduling_handler() -> None: ) def invitation() -> None: """Admin role invitation commands.""" + + +@admin.group( + cls=LazyGroup, + import_name="ai.backend.client.cli.v2.admin.app_config:app_config", + name="app-config", +) +def app_config() -> None: + """Admin merged AppConfig commands.""" + + +@admin.group( + cls=LazyGroup, + import_name="ai.backend.client.cli.v2.admin.app_config_fragment:app_config_fragment", + name="app-config-fragment", +) +def app_config_fragment() -> None: + """Admin AppConfigFragment commands (cross-scope search + bulk-only writes).""" + + +@admin.group( + cls=LazyGroup, + import_name="ai.backend.client.cli.v2.admin.app_config_policy:app_config_policy", + name="app-config-policy", +) +def app_config_policy() -> None: + """Admin AppConfigPolicy commands (bulk-only writes).""" diff --git a/src/ai/backend/client/cli/v2/admin/app_config.py b/src/ai/backend/client/cli/v2/admin/app_config.py new file mode 100644 index 00000000000..01c2a48b06a --- /dev/null +++ b/src/ai/backend/client/cli/v2/admin/app_config.py @@ -0,0 +1,92 @@ +"""Admin CLI commands for the merged AppConfig view.""" + +from __future__ import annotations + +import asyncio +from uuid import UUID + +import click + +from ai.backend.client.cli.v2.helpers import ( + create_v2_registry, + load_v2_config, + parse_order_options, + print_result, +) + + +@click.group(name="app-config") +def app_config() -> None: + """Admin merged AppConfig commands.""" + + +@app_config.command() +@click.argument("user_id", type=click.UUID) +@click.argument("name", type=str) +def get(user_id: UUID, name: str) -> None: + """Read a specific user's merged AppConfig (admin only).""" + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config.admin_get(user_id, name) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) + + +@app_config.command() +@click.option("--limit", type=int, default=None, help="Maximum items to return.") +@click.option("--offset", type=int, default=None, help="Number of items to skip.") +@click.option("--name-contains", type=str, default=None, help="Filter `name` by substring.") +@click.option("--user-id", type=click.UUID, default=None, help="Pin to a single user (UUID).") +@click.option( + "--order-by", + multiple=True, + help="Order by field:direction. Fields: name, user_id.", +) +def search( + limit: int | None, + offset: int | None, + name_contains: str | None, + user_id: UUID | None, + order_by: tuple[str, ...], +) -> None: + """Cross-user merged-view search (superadmin only).""" + from ai.backend.common.dto.manager.query import StringFilter, UUIDFilter + from ai.backend.common.dto.manager.v2.app_config.request import ( + AppConfigFilter, + AppConfigOrder, + SearchAppConfigsInput, + ) + from ai.backend.common.dto.manager.v2.app_config.types import AppConfigOrderField + + filter_dto: AppConfigFilter | None = None + if name_contains is not None or user_id is not None: + filter_dto = AppConfigFilter( + name=StringFilter(contains=name_contains) if name_contains is not None else None, + user_id=UUIDFilter(equals=user_id) if user_id is not None else None, + ) + + orders = ( + parse_order_options(order_by, AppConfigOrderField, AppConfigOrder) if order_by else None + ) + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config.admin_search( + SearchAppConfigsInput( + filter=filter_dto, + order=orders, + limit=limit, + offset=offset, + ), + ) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) diff --git a/src/ai/backend/client/cli/v2/admin/app_config_fragment.py b/src/ai/backend/client/cli/v2/admin/app_config_fragment.py new file mode 100644 index 00000000000..4aa80c50a64 --- /dev/null +++ b/src/ai/backend/client/cli/v2/admin/app_config_fragment.py @@ -0,0 +1,181 @@ +"""Admin CLI commands for AppConfigFragment (cross-scope search + bulk-only writes).""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any, cast + +import click + +from ai.backend.client.cli.v2.helpers import ( + create_v2_registry, + load_v2_config, + parse_order_options, + print_result, +) + + +@click.group(name="app-config-fragment") +def app_config_fragment() -> None: + """Admin AppConfigFragment commands (cross-scope search + bulk-only writes).""" + + +def _load_items(items_arg: str) -> list[dict[str, Any]]: + """Accept JSON string or `@file.json` path.""" + if items_arg.startswith("@"): + return cast("list[dict[str, Any]]", json.loads(Path(items_arg[1:]).read_text())) + return cast("list[dict[str, Any]]", json.loads(items_arg)) + + +@app_config_fragment.command() +@click.option("--limit", type=int, default=None, help="Maximum items to return.") +@click.option("--offset", type=int, default=None, help="Number of items to skip.") +@click.option("--name-contains", type=str, default=None, help="Filter `name` by substring.") +@click.option("--scope-type", type=str, default=None, help="Filter by scope_type.") +@click.option("--scope-id-contains", type=str, default=None, help="Filter `scope_id` by substring.") +@click.option( + "--order-by", + multiple=True, + help="Order by field:direction. Fields: scope_type, scope_id, name, created_at, updated_at.", +) +def search( + limit: int | None, + offset: int | None, + name_contains: str | None, + scope_type: str | None, + scope_id_contains: str | None, + order_by: tuple[str, ...], +) -> None: + """Cross-scope fragment search (superadmin only).""" + from ai.backend.common.dto.manager.query import StringFilter + from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AppConfigFragmentFilter, + AppConfigFragmentOrder, + SearchAppConfigFragmentsInput, + ) + from ai.backend.common.dto.manager.v2.app_config_fragment.types import ( + AppConfigFragmentOrderField, + AppConfigScopeType, + ) + + filter_dto: AppConfigFragmentFilter | None = None + if any([name_contains, scope_type, scope_id_contains]): + filter_dto = AppConfigFragmentFilter( + name=StringFilter(contains=name_contains) if name_contains is not None else None, + scope_type=AppConfigScopeType(scope_type) if scope_type is not None else None, + scope_id=( + StringFilter(contains=scope_id_contains) if scope_id_contains is not None else None + ), + ) + + orders = ( + parse_order_options(order_by, AppConfigFragmentOrderField, AppConfigFragmentOrder) + if order_by + else None + ) + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config_fragment.admin_search( + SearchAppConfigFragmentsInput( + filter=filter_dto, + order=orders, + limit=limit, + offset=offset, + ), + ) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) + + +@app_config_fragment.command(name="bulk-create") +@click.option( + "--items", + required=True, + help=( + "JSON list of `{key: {scope_type, scope_id, name}, config}` items, " + "or `@path/to/items.json`." + ), +) +def bulk_create(items: str) -> None: + """Bulk-create fragments (partial-success semantics).""" + from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminAppConfigFragmentItemInput, + AdminBulkCreateAppConfigFragmentsInput, + ) + + parsed = [AdminAppConfigFragmentItemInput.model_validate(item) for item in _load_items(items)] + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config_fragment.admin_bulk_create( + AdminBulkCreateAppConfigFragmentsInput(items=parsed), + ) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) + + +@app_config_fragment.command(name="bulk-update") +@click.option( + "--items", + required=True, + help="Same shape as `bulk-create`; replaces `config` wholesale.", +) +def bulk_update(items: str) -> None: + """Bulk-update fragments (partial-success semantics).""" + from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminAppConfigFragmentItemInput, + AdminBulkUpdateAppConfigFragmentsInput, + ) + + parsed = [AdminAppConfigFragmentItemInput.model_validate(item) for item in _load_items(items)] + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config_fragment.admin_bulk_update( + AdminBulkUpdateAppConfigFragmentsInput(items=parsed), + ) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) + + +@app_config_fragment.command(name="bulk-purge") +@click.option( + "--keys", + required=True, + help="JSON list of `{scope_type, scope_id, name}` keys, or `@path/to/keys.json`.", +) +def bulk_purge(keys: str) -> None: + """Bulk-purge fragments by natural key (partial-success semantics).""" + from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminBulkPurgeAppConfigFragmentsInput, + AppConfigFragmentKeyInput, + ) + + parsed = [AppConfigFragmentKeyInput.model_validate(item) for item in _load_items(keys)] + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config_fragment.admin_bulk_purge( + AdminBulkPurgeAppConfigFragmentsInput(keys=parsed), + ) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) diff --git a/src/ai/backend/client/cli/v2/admin/app_config_policy.py b/src/ai/backend/client/cli/v2/admin/app_config_policy.py new file mode 100644 index 00000000000..741cddb969f --- /dev/null +++ b/src/ai/backend/client/cli/v2/admin/app_config_policy.py @@ -0,0 +1,119 @@ +"""Admin CLI commands for AppConfigPolicy (bulk-only writes).""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any, cast +from uuid import UUID + +import click + +from ai.backend.client.cli.v2.helpers import create_v2_registry, load_v2_config, print_result + + +@click.group(name="app-config-policy") +def app_config_policy() -> None: + """Admin AppConfigPolicy commands (bulk-only).""" + + +def _load_items(items_arg: str) -> list[dict[str, Any]]: + """Accept JSON string or `@file.json` path.""" + if items_arg.startswith("@"): + return cast("list[dict[str, Any]]", json.loads(Path(items_arg[1:]).read_text())) + return cast("list[dict[str, Any]]", json.loads(items_arg)) + + +@app_config_policy.command(name="bulk-create") +@click.option( + "--items", + required=True, + help=( + "JSON list of `{config_name, scope_sources}` items, " + "or `@path/to/items.json` to load from file." + ), +) +def bulk_create(items: str) -> None: + """Bulk-create policies (partial-success semantics).""" + from ai.backend.common.dto.manager.v2.app_config_policy.request import ( + AdminAppConfigPolicyCreateItemInput, + AdminBulkCreateAppConfigPoliciesInput, + ) + + parsed = [ + AdminAppConfigPolicyCreateItemInput.model_validate(item) for item in _load_items(items) + ] + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config_policy.admin_bulk_create( + AdminBulkCreateAppConfigPoliciesInput(items=parsed), + ) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) + + +@app_config_policy.command(name="bulk-update") +@click.option( + "--items", + required=True, + help=("JSON list of `{id, scope_sources}` items, or `@path/to/items.json` to load from file."), +) +def bulk_update(items: str) -> None: + """Bulk-update policy scope chains (partial-success semantics).""" + from ai.backend.common.dto.manager.v2.app_config_policy.request import ( + AdminAppConfigPolicyUpdateItemInput, + AdminBulkUpdateAppConfigPoliciesInput, + ) + + parsed = [ + AdminAppConfigPolicyUpdateItemInput.model_validate(item) for item in _load_items(items) + ] + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config_policy.admin_bulk_update( + AdminBulkUpdateAppConfigPoliciesInput(items=parsed), + ) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) + + +@app_config_policy.command(name="bulk-purge") +@click.option( + "--ids", + required=True, + help="Comma-separated policy row UUIDs, or `@path/to/ids.json` for a JSON list.", +) +def bulk_purge(ids: str) -> None: + """Bulk-purge policies by row id (partial-success semantics).""" + from ai.backend.common.dto.manager.v2.app_config_policy.request import ( + AdminBulkPurgeAppConfigPoliciesInput, + ) + + if ids.startswith("@"): + raw_ids = json.loads(Path(ids[1:]).read_text()) + else: + raw_ids = [s.strip() for s in ids.split(",") if s.strip()] + parsed_ids = [UUID(s) for s in raw_ids] + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config_policy.admin_bulk_purge( + AdminBulkPurgeAppConfigPoliciesInput(ids=parsed_ids), + ) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) diff --git a/src/ai/backend/client/cli/v2/app_config/__init__.py b/src/ai/backend/client/cli/v2/app_config/__init__.py new file mode 100644 index 00000000000..ce37633c8c3 --- /dev/null +++ b/src/ai/backend/client/cli/v2/app_config/__init__.py @@ -0,0 +1,3 @@ +from .commands import app_config as app_config + +__all__ = ("app_config",) diff --git a/src/ai/backend/client/cli/v2/app_config/commands.py b/src/ai/backend/client/cli/v2/app_config/commands.py new file mode 100644 index 00000000000..bb135fa325f --- /dev/null +++ b/src/ai/backend/client/cli/v2/app_config/commands.py @@ -0,0 +1,35 @@ +"""CLI commands for the merged AppConfig view. + +Public entrypoint exposes only the read paths that any authenticated +user can hit. Self-service writes live under `bai my app-config`; +admin operations live under `bai admin app-config`. +""" + +from __future__ import annotations + +import asyncio + +import click + +from ai.backend.client.cli.v2.helpers import create_v2_registry, load_v2_config, print_result + + +@click.group(name="app-config") +def app_config() -> None: + """Merged AppConfig commands (per-policy resolved view).""" + + +@app_config.command(name="my-get") +@click.argument("name", type=str) +def my_get(name: str) -> None: + """Read the caller's own merged AppConfig for `name`.""" + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config.my_get(name) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) diff --git a/src/ai/backend/client/cli/v2/my/__init__.py b/src/ai/backend/client/cli/v2/my/__init__.py index 2633b937160..38e658986a8 100644 --- a/src/ai/backend/client/cli/v2/my/__init__.py +++ b/src/ai/backend/client/cli/v2/my/__init__.py @@ -82,3 +82,12 @@ def resource_policy() -> None: ) def storage_host() -> None: """My storage host commands.""" + + +@my.group( + cls=LazyGroup, + import_name="ai.backend.client.cli.v2.my.app_config:app_config", + name="app-config", +) +def app_config() -> None: + """My merged AppConfig commands.""" diff --git a/src/ai/backend/client/cli/v2/my/app_config.py b/src/ai/backend/client/cli/v2/my/app_config.py new file mode 100644 index 00000000000..b968df0c058 --- /dev/null +++ b/src/ai/backend/client/cli/v2/my/app_config.py @@ -0,0 +1,135 @@ +"""Self-service CLI commands for the merged AppConfig view.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any, cast + +import click + +from ai.backend.client.cli.v2.helpers import ( + create_v2_registry, + load_v2_config, + parse_order_options, + print_result, +) + + +@click.group(name="app-config") +def app_config() -> None: + """Self-service AppConfig commands for the current user.""" + + +@app_config.command() +@click.option("--limit", type=int, default=None, help="Maximum items to return.") +@click.option("--offset", type=int, default=None, help="Number of items to skip.") +@click.option("--name-contains", type=str, default=None, help="Filter `name` by substring.") +@click.option( + "--order-by", + multiple=True, + help="Order by field:direction. Fields: name, user_id.", +) +def search( + limit: int | None, + offset: int | None, + name_contains: str | None, + order_by: tuple[str, ...], +) -> None: + """Paginated merged-view search over the caller's own AppConfigs.""" + from ai.backend.common.dto.manager.query import StringFilter + from ai.backend.common.dto.manager.v2.app_config.request import ( + AppConfigFilter, + AppConfigOrder, + SearchMyAppConfigsInput, + ) + from ai.backend.common.dto.manager.v2.app_config.types import AppConfigOrderField + + filter_dto: AppConfigFilter | None = None + if name_contains is not None: + filter_dto = AppConfigFilter(name=StringFilter(contains=name_contains)) + + orders = ( + parse_order_options(order_by, AppConfigOrderField, AppConfigOrder) if order_by else None + ) + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config.my_search( + SearchMyAppConfigsInput( + filter=filter_dto, + order=orders, + limit=limit, + offset=offset, + ), + ) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) + + +def _load_items(items_arg: str) -> list[dict[str, Any]]: + """Accept JSON string or `@file.json` path.""" + if items_arg.startswith("@"): + return cast("list[dict[str, Any]]", json.loads(Path(items_arg[1:]).read_text())) + return cast("list[dict[str, Any]]", json.loads(items_arg)) + + +@app_config.command(name="bulk-create") +@click.option( + "--items", + required=True, + help="JSON list of `{name, config}` items, or `@path/to/items.json`.", +) +def bulk_create(items: str) -> None: + """Bulk-create USER-scope fragments; returns recomputed merged views.""" + from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + MyAppConfigFragmentItemInput, + MyBulkCreateAppConfigFragmentsInput, + ) + + parsed = [MyAppConfigFragmentItemInput.model_validate(item) for item in _load_items(items)] + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config.my_bulk_create( + MyBulkCreateAppConfigFragmentsInput(items=parsed), + ) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) + + +@app_config.command(name="bulk-update") +@click.option( + "--items", + required=True, + help="Same shape as `bulk-create`; replaces `config` wholesale.", +) +def bulk_update(items: str) -> None: + """Bulk-update USER-scope fragments; returns recomputed merged views.""" + from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + MyAppConfigFragmentItemInput, + MyBulkUpdateAppConfigFragmentsInput, + ) + + parsed = [MyAppConfigFragmentItemInput.model_validate(item) for item in _load_items(items)] + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.app_config.my_bulk_update( + MyBulkUpdateAppConfigFragmentsInput(items=parsed), + ) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) diff --git a/src/ai/backend/client/v2/domains_v2/app_config.py b/src/ai/backend/client/v2/domains_v2/app_config.py new file mode 100644 index 00000000000..b2536310b9b --- /dev/null +++ b/src/ai/backend/client/v2/domains_v2/app_config.py @@ -0,0 +1,89 @@ +"""V2 SDK client for the merged AppConfig view. + +Replaces the legacy upsert-style domain/user app-config SDK; the new +surface is bulk-only writes against `USER`-scope fragments plus +merged-view reads. +""" + +from __future__ import annotations + +from uuid import UUID + +from ai.backend.client.v2.base_domain import BaseDomainClient +from ai.backend.common.dto.manager.v2.app_config.request import ( + SearchAppConfigsInput, + SearchMyAppConfigsInput, +) +from ai.backend.common.dto.manager.v2.app_config.response import ( + GetUserAppConfigPayload, + MyBulkCreateAppConfigFragmentsPayload, + MyBulkUpdateAppConfigFragmentsPayload, + SearchAppConfigsPayload, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + MyBulkCreateAppConfigFragmentsInput, + MyBulkUpdateAppConfigFragmentsInput, +) + +_PATH = "/v2/app-configs" +_FRAGMENT_PATH = "/v2/app-config-fragments" + + +class V2AppConfigClient(BaseDomainClient): + """SDK client for the merged AppConfig view + self-service writes.""" + + async def my_get(self, name: str) -> GetUserAppConfigPayload: + """Read the caller's own merged AppConfig for `name`.""" + return await self._client.typed_request( + "GET", + f"{_PATH}/my/{name}", + response_model=GetUserAppConfigPayload, + ) + + async def my_search(self, request: SearchMyAppConfigsInput) -> SearchAppConfigsPayload: + """Paginated merged-view search over the caller's own AppConfigs.""" + return await self._client.typed_request( + "POST", + f"{_PATH}/my/search", + request=request, + response_model=SearchAppConfigsPayload, + ) + + async def admin_get(self, user_id: UUID, name: str) -> GetUserAppConfigPayload: + """Read a specific user's merged AppConfig (admin only).""" + return await self._client.typed_request( + "GET", + f"{_PATH}/{user_id}/{name}", + response_model=GetUserAppConfigPayload, + ) + + async def admin_search(self, request: SearchAppConfigsInput) -> SearchAppConfigsPayload: + """Cross-user merged-view search (admin only).""" + return await self._client.typed_request( + "POST", + f"{_PATH}/search", + request=request, + response_model=SearchAppConfigsPayload, + ) + + async def my_bulk_create( + self, request: MyBulkCreateAppConfigFragmentsInput + ) -> MyBulkCreateAppConfigFragmentsPayload: + """Bulk-create USER-scope fragments; returns recomputed merged views.""" + return await self._client.typed_request( + "POST", + f"{_FRAGMENT_PATH}/my/bulk-create", + request=request, + response_model=MyBulkCreateAppConfigFragmentsPayload, + ) + + async def my_bulk_update( + self, request: MyBulkUpdateAppConfigFragmentsInput + ) -> MyBulkUpdateAppConfigFragmentsPayload: + """Bulk-update USER-scope fragments; returns recomputed merged views.""" + return await self._client.typed_request( + "POST", + f"{_FRAGMENT_PATH}/my/bulk-update", + request=request, + response_model=MyBulkUpdateAppConfigFragmentsPayload, + ) diff --git a/src/ai/backend/client/v2/domains_v2/app_config_fragment.py b/src/ai/backend/client/v2/domains_v2/app_config_fragment.py new file mode 100644 index 00000000000..3dbb7808bdc --- /dev/null +++ b/src/ai/backend/client/v2/domains_v2/app_config_fragment.py @@ -0,0 +1,76 @@ +"""V2 SDK client for the app-config fragment domain. + +Fragments are an admin-only surface — end users interact with the merged +``AppConfig`` view (``V2AppConfigClient``) instead. Self-service +``my_bulk_*`` writes that return the recomputed merged view also live on +``V2AppConfigClient`` alongside the merged-view reads. +""" + +from __future__ import annotations + +from ai.backend.client.v2.base_domain import BaseDomainClient +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminBulkCreateAppConfigFragmentsInput, + AdminBulkPurgeAppConfigFragmentsInput, + AdminBulkUpdateAppConfigFragmentsInput, + SearchAppConfigFragmentsInput, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AdminBulkCreateAppConfigFragmentsPayload, + AdminBulkPurgeAppConfigFragmentsPayload, + AdminBulkUpdateAppConfigFragmentsPayload, + SearchAppConfigFragmentsPayload, +) + +_PATH = "/v2/app-config-fragments" + + +class V2AppConfigFragmentClient(BaseDomainClient): + """SDK client for AppConfigFragment admin operations. + + Writes are bulk-only. + """ + + async def admin_search( + self, request: SearchAppConfigFragmentsInput + ) -> SearchAppConfigFragmentsPayload: + """Cross-scope admin search.""" + return await self._client.typed_request( + "POST", + f"{_PATH}/search", + request=request, + response_model=SearchAppConfigFragmentsPayload, + ) + + async def admin_bulk_create( + self, request: AdminBulkCreateAppConfigFragmentsInput + ) -> AdminBulkCreateAppConfigFragmentsPayload: + """Bulk-create fragments (admin only, partial-success semantics).""" + return await self._client.typed_request( + "POST", + f"{_PATH}/bulk-create", + request=request, + response_model=AdminBulkCreateAppConfigFragmentsPayload, + ) + + async def admin_bulk_update( + self, request: AdminBulkUpdateAppConfigFragmentsInput + ) -> AdminBulkUpdateAppConfigFragmentsPayload: + """Bulk-update fragments (admin only, partial-success semantics).""" + return await self._client.typed_request( + "POST", + f"{_PATH}/bulk-update", + request=request, + response_model=AdminBulkUpdateAppConfigFragmentsPayload, + ) + + async def admin_bulk_purge( + self, request: AdminBulkPurgeAppConfigFragmentsInput + ) -> AdminBulkPurgeAppConfigFragmentsPayload: + """Bulk-purge fragments (admin only, partial-success semantics).""" + return await self._client.typed_request( + "POST", + f"{_PATH}/bulk-purge", + request=request, + response_model=AdminBulkPurgeAppConfigFragmentsPayload, + ) diff --git a/src/ai/backend/client/v2/domains_v2/app_config_policy.py b/src/ai/backend/client/v2/domains_v2/app_config_policy.py new file mode 100644 index 00000000000..d2ce65d2cea --- /dev/null +++ b/src/ai/backend/client/v2/domains_v2/app_config_policy.py @@ -0,0 +1,63 @@ +"""V2 SDK client for the app-config policy domain. + +Policies are an admin-only surface — end users observe their effects +through the merged ``AppConfig`` view (``V2AppConfigClient``), not by +reading raw policy rows. +""" + +from __future__ import annotations + +from ai.backend.client.v2.base_domain import BaseDomainClient +from ai.backend.common.dto.manager.v2.app_config_policy.request import ( + AdminBulkCreateAppConfigPoliciesInput, + AdminBulkPurgeAppConfigPoliciesInput, + AdminBulkUpdateAppConfigPoliciesInput, +) +from ai.backend.common.dto.manager.v2.app_config_policy.response import ( + AdminBulkCreateAppConfigPoliciesPayload, + AdminBulkPurgeAppConfigPoliciesPayload, + AdminBulkUpdateAppConfigPoliciesPayload, +) + +_PATH = "/v2/app-config-policies" + + +class V2AppConfigPolicyClient(BaseDomainClient): + """SDK client for AppConfigPolicy admin operations. + + Writes are bulk-only; single-item create / update / + purge are intentionally absent. + """ + + async def admin_bulk_create( + self, request: AdminBulkCreateAppConfigPoliciesInput + ) -> AdminBulkCreateAppConfigPoliciesPayload: + """Bulk-create policies (admin only, partial-success semantics).""" + return await self._client.typed_request( + "POST", + f"{_PATH}/bulk-create", + request=request, + response_model=AdminBulkCreateAppConfigPoliciesPayload, + ) + + async def admin_bulk_update( + self, request: AdminBulkUpdateAppConfigPoliciesInput + ) -> AdminBulkUpdateAppConfigPoliciesPayload: + """Bulk-update policies (admin only, partial-success semantics).""" + return await self._client.typed_request( + "POST", + f"{_PATH}/bulk-update", + request=request, + response_model=AdminBulkUpdateAppConfigPoliciesPayload, + ) + + async def admin_bulk_purge( + self, request: AdminBulkPurgeAppConfigPoliciesInput + ) -> AdminBulkPurgeAppConfigPoliciesPayload: + """Bulk-purge policies (admin only, partial-success semantics).""" + return await self._client.typed_request( + "POST", + f"{_PATH}/bulk-purge", + request=request, + response_model=AdminBulkPurgeAppConfigPoliciesPayload, + ) diff --git a/src/ai/backend/client/v2/v2_registry.py b/src/ai/backend/client/v2/v2_registry.py index 5836da239aa..e37bdd4ae6d 100644 --- a/src/ai/backend/client/v2/v2_registry.py +++ b/src/ai/backend/client/v2/v2_registry.py @@ -16,6 +16,9 @@ if TYPE_CHECKING: from .domains_v2.agent import V2AgentClient + from .domains_v2.app_config import V2AppConfigClient + from .domains_v2.app_config_fragment import V2AppConfigFragmentClient + from .domains_v2.app_config_policy import V2AppConfigPolicyClient from .domains_v2.artifact import V2ArtifactClient from .domains_v2.artifact_registry import V2ArtifactRegistryClient from .domains_v2.audit_log import V2AuditLogClient @@ -88,6 +91,24 @@ def agent(self) -> V2AgentClient: return V2AgentClient(self._client) + @cached_property + def app_config(self) -> V2AppConfigClient: + from .domains_v2.app_config import V2AppConfigClient + + return V2AppConfigClient(self._client) + + @cached_property + def app_config_fragment(self) -> V2AppConfigFragmentClient: + from .domains_v2.app_config_fragment import V2AppConfigFragmentClient + + return V2AppConfigFragmentClient(self._client) + + @cached_property + def app_config_policy(self) -> V2AppConfigPolicyClient: + from .domains_v2.app_config_policy import V2AppConfigPolicyClient + + return V2AppConfigPolicyClient(self._client) + @cached_property def artifact(self) -> V2ArtifactClient: from .domains_v2.artifact import V2ArtifactClient