From 581e756e333e2871eeacc28ae4bc6a7b940268dd Mon Sep 17 00:00:00 2001 From: Gyubong Date: Sun, 26 Apr 2026 22:17:58 +0900 Subject: [PATCH 1/3] feat(BA-5844): AppConfigPolicy REST v2 surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the REST v2 endpoints for AppConfigPolicy on top of the GraphQL- only BA-5815. Mirrors the BA-5829 / BA-5830 split for the AppConfig- Fragment stack. - `api/rest/v2/app_config_policy/handler.py` — `V2AppConfigPolicyHandler` exposing `get`, `search`, and admin bulk-create / bulk-update / bulk-purge endpoints, all delegating to `AppConfigPolicyAdapter`. - `api/rest/v2/app_config_policy/registry.py` — route registrar for the `app-config-policies` sub-tree (reads via `auth_required`, bulk writes via `superadmin_required`). - `api/rest/v2/path_params.py` — `AppConfigPolicyConfigNamePathParam` for path-bound `config_name` lookups. - `api/rest/v2/tree.py` — wire up the new handler / registrar. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/rest/v2/app_config_policy/__init__.py | 0 .../api/rest/v2/app_config_policy/handler.py | 78 +++++++++++++++++++ .../api/rest/v2/app_config_policy/registry.py | 36 +++++++++ .../manager/api/rest/v2/path_params.py | 4 + src/ai/backend/manager/api/rest/v2/tree.py | 6 ++ 5 files changed, 124 insertions(+) create mode 100644 src/ai/backend/manager/api/rest/v2/app_config_policy/__init__.py create mode 100644 src/ai/backend/manager/api/rest/v2/app_config_policy/handler.py create mode 100644 src/ai/backend/manager/api/rest/v2/app_config_policy/registry.py diff --git a/src/ai/backend/manager/api/rest/v2/app_config_policy/__init__.py b/src/ai/backend/manager/api/rest/v2/app_config_policy/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/api/rest/v2/app_config_policy/handler.py b/src/ai/backend/manager/api/rest/v2/app_config_policy/handler.py new file mode 100644 index 00000000000..721b56ca0a3 --- /dev/null +++ b/src/ai/backend/manager/api/rest/v2/app_config_policy/handler.py @@ -0,0 +1,78 @@ +"""REST v2 handler for the app-config policy domain. + +Writes are **bulk-only** — the single-item create / update / purge +endpoints were removed in favour of `/bulk-create`, `/bulk-update`, +`/bulk-purge` (admin-only). +""" + +from __future__ import annotations + +import logging +from http import HTTPStatus +from typing import TYPE_CHECKING, Final + +from ai.backend.common.api_handlers import APIResponse, BodyParam, PathParam +from ai.backend.common.dto.manager.v2.app_config_policy.request import ( + AdminBulkCreateAppConfigPoliciesInput, + AdminBulkPurgeAppConfigPoliciesInput, + AdminBulkUpdateAppConfigPoliciesInput, + SearchAppConfigPoliciesInput, +) +from ai.backend.logging import BraceStyleAdapter +from ai.backend.manager.api.rest.v2.path_params import AppConfigPolicyConfigNamePathParam + +if TYPE_CHECKING: + from ai.backend.manager.api.adapters.app_config_policy import AppConfigPolicyAdapter + +log: Final = BraceStyleAdapter(logging.getLogger(__spec__.name)) + + +class V2AppConfigPolicyHandler: + """REST v2 handler for app-config policy operations.""" + + def __init__(self, *, adapter: AppConfigPolicyAdapter) -> None: + self._adapter = adapter + + # ── Reads ──────────────────────────────────────────────────── + + async def get( + self, + path: PathParam[AppConfigPolicyConfigNamePathParam], + ) -> APIResponse: + """Read a single policy by `config_name` (any authenticated user).""" + result = await self._adapter.get(path.parsed.config_name) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def search( + self, + body: BodyParam[SearchAppConfigPoliciesInput], + ) -> APIResponse: + """Paginated policy search (any authenticated user).""" + result = await self._adapter.search(body.parsed) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + # ── Admin bulk writes ──────────────────────────────────────── + + async def admin_bulk_create( + self, + body: BodyParam[AdminBulkCreateAppConfigPoliciesInput], + ) -> APIResponse: + """Strict insert; per-item transactions (admin only).""" + result = await self._adapter.admin_bulk_create(body.parsed) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def admin_bulk_update( + self, + body: BodyParam[AdminBulkUpdateAppConfigPoliciesInput], + ) -> APIResponse: + """Replace `scope_sources` (admin only). `config_name` is immutable.""" + result = await self._adapter.admin_bulk_update(body.parsed) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def admin_bulk_purge( + self, + body: BodyParam[AdminBulkPurgeAppConfigPoliciesInput], + ) -> APIResponse: + """Hard-delete (admin only); referenced `config_name`s fail per-item.""" + result = await self._adapter.admin_bulk_purge(body.parsed) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) diff --git a/src/ai/backend/manager/api/rest/v2/app_config_policy/registry.py b/src/ai/backend/manager/api/rest/v2/app_config_policy/registry.py new file mode 100644 index 00000000000..321c8c64b1a --- /dev/null +++ b/src/ai/backend/manager/api/rest/v2/app_config_policy/registry.py @@ -0,0 +1,36 @@ +"""Route registration for v2 app-config policy endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ai.backend.manager.api.rest.middleware.auth import auth_required, superadmin_required +from ai.backend.manager.api.rest.routing import RouteRegistry + +from .handler import V2AppConfigPolicyHandler + +if TYPE_CHECKING: + from ai.backend.manager.api.rest.types import RouteDeps + + +def register_v2_app_config_policy_routes( + handler: V2AppConfigPolicyHandler, + route_deps: RouteDeps, +) -> RouteRegistry: + """Register all v2 app-config policy routes. + + Reads (`GET /{config_name}`, `POST /search`) are available to any + authenticated user. Writes are bulk-only and admin-only — + `/bulk-create`, `/bulk-update`, `/bulk-purge`. + """ + reg = RouteRegistry.create("app-config-policies", route_deps.cors_options) + + # Reads + reg.add("POST", "/search", handler.search, middlewares=[auth_required]) + reg.add("GET", "/{config_name}", handler.get, middlewares=[auth_required]) + # Admin bulk writes + reg.add("POST", "/bulk-create", handler.admin_bulk_create, middlewares=[superadmin_required]) + reg.add("POST", "/bulk-update", handler.admin_bulk_update, middlewares=[superadmin_required]) + reg.add("POST", "/bulk-purge", handler.admin_bulk_purge, middlewares=[superadmin_required]) + + return reg diff --git a/src/ai/backend/manager/api/rest/v2/path_params.py b/src/ai/backend/manager/api/rest/v2/path_params.py index 88cfc7b59cf..d610bbeda92 100644 --- a/src/ai/backend/manager/api/rest/v2/path_params.py +++ b/src/ai/backend/manager/api/rest/v2/path_params.py @@ -9,6 +9,10 @@ from ai.backend.common.api_handlers import BaseRequestModel +class AppConfigPolicyConfigNamePathParam(BaseRequestModel): + config_name: str = Field(description="App-config policy `config_name`") + + class DomainNamePathParam(BaseRequestModel): domain_name: str = Field(description="Domain name") diff --git a/src/ai/backend/manager/api/rest/v2/tree.py b/src/ai/backend/manager/api/rest/v2/tree.py index 34bc31b8744..b794551f77f 100644 --- a/src/ai/backend/manager/api/rest/v2/tree.py +++ b/src/ai/backend/manager/api/rest/v2/tree.py @@ -28,6 +28,8 @@ def build_v2_routes( # Lazy imports to avoid circular dependencies at module level from .agent.handler import V2AgentHandler from .agent.registry import register_v2_agent_routes + from .app_config_policy.handler import V2AppConfigPolicyHandler + from .app_config_policy.registry import register_v2_app_config_policy_routes from .artifact.handler import V2ArtifactHandler from .artifact.registry import register_v2_artifact_routes from .artifact_registry.handler import V2ArtifactRegistryHandler @@ -115,6 +117,7 @@ def build_v2_routes( # Build all handlers (each takes its individual adapter) agent_handler = V2AgentHandler(adapter=adapters.agent) + app_config_policy_handler = V2AppConfigPolicyHandler(adapter=adapters.app_config_policy) artifact_handler = V2ArtifactHandler(adapter=adapters.artifact) artifact_registry_handler = V2ArtifactRegistryHandler(adapter=adapters.artifact_registry) audit_log_handler = V2AuditLogHandler(adapter=adapters.audit_log) @@ -171,6 +174,9 @@ def build_v2_routes( # Add all domain sub-registries v2_reg.add_subregistry(register_v2_agent_routes(agent_handler, route_deps)) + v2_reg.add_subregistry( + register_v2_app_config_policy_routes(app_config_policy_handler, route_deps) + ) v2_reg.add_subregistry(register_v2_artifact_routes(artifact_handler, route_deps)) v2_reg.add_subregistry( register_v2_artifact_registry_routes(artifact_registry_handler, route_deps) From 66c4b52d9d001b0da7cccb506aa62c05cc6d94f9 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Sun, 26 Apr 2026 22:18:57 +0900 Subject: [PATCH 2/3] chore(BA-5844): add news fragment for #11312 --- changes/11312.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/11312.feature.md diff --git a/changes/11312.feature.md b/changes/11312.feature.md new file mode 100644 index 00000000000..dd0c7ee7591 --- /dev/null +++ b/changes/11312.feature.md @@ -0,0 +1 @@ +Add `AppConfigPolicy` REST v2 surface (`POST /v2/app-config-policies/search`, `GET /v2/app-config-policies/{config_name}`, admin `bulk-create` / `bulk-update` / `bulk-purge`) — pairs with the GraphQL surface from BA-5815. From 481b114294416f9101c72842e01af076b181f89f Mon Sep 17 00:00:00 2001 From: Gyubong Date: Mon, 27 Apr 2026 11:38:09 +0900 Subject: [PATCH 3/3] refactor(BA-5844): switch REST v2 Policy surface to id-keyed routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track BA-5814 / BA-5815 DTO refactor: • GET /v2/app-config-policies/{config_name} → GET /v2/app-config-policies/{policy_id} • Replace AppConfigPolicyConfigNamePathParam with AppConfigPolicyIdPathParam (policy_id: UUID). • Update bulk-purge handler docstring (purge keyed on row id). --- changes/11312.feature.md | 2 +- .../manager/api/rest/v2/app_config_policy/handler.py | 10 +++++----- .../manager/api/rest/v2/app_config_policy/registry.py | 4 ++-- src/ai/backend/manager/api/rest/v2/path_params.py | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/changes/11312.feature.md b/changes/11312.feature.md index dd0c7ee7591..74faa4053dc 100644 --- a/changes/11312.feature.md +++ b/changes/11312.feature.md @@ -1 +1 @@ -Add `AppConfigPolicy` REST v2 surface (`POST /v2/app-config-policies/search`, `GET /v2/app-config-policies/{config_name}`, admin `bulk-create` / `bulk-update` / `bulk-purge`) — pairs with the GraphQL surface from BA-5815. +Add `AppConfigPolicy` REST v2 surface (`POST /v2/app-config-policies/search`, `GET /v2/app-config-policies/{policy_id}`, admin `bulk-create` / `bulk-update` / `bulk-purge`) — pairs with the GraphQL surface from BA-5815. diff --git a/src/ai/backend/manager/api/rest/v2/app_config_policy/handler.py b/src/ai/backend/manager/api/rest/v2/app_config_policy/handler.py index 721b56ca0a3..81e0fb5a402 100644 --- a/src/ai/backend/manager/api/rest/v2/app_config_policy/handler.py +++ b/src/ai/backend/manager/api/rest/v2/app_config_policy/handler.py @@ -19,7 +19,7 @@ SearchAppConfigPoliciesInput, ) from ai.backend.logging import BraceStyleAdapter -from ai.backend.manager.api.rest.v2.path_params import AppConfigPolicyConfigNamePathParam +from ai.backend.manager.api.rest.v2.path_params import AppConfigPolicyIdPathParam if TYPE_CHECKING: from ai.backend.manager.api.adapters.app_config_policy import AppConfigPolicyAdapter @@ -37,10 +37,10 @@ def __init__(self, *, adapter: AppConfigPolicyAdapter) -> None: async def get( self, - path: PathParam[AppConfigPolicyConfigNamePathParam], + path: PathParam[AppConfigPolicyIdPathParam], ) -> APIResponse: - """Read a single policy by `config_name` (any authenticated user).""" - result = await self._adapter.get(path.parsed.config_name) + """Read a single policy by row id (any authenticated user).""" + result = await self._adapter.get(path.parsed.policy_id) return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) async def search( @@ -73,6 +73,6 @@ async def admin_bulk_purge( self, body: BodyParam[AdminBulkPurgeAppConfigPoliciesInput], ) -> APIResponse: - """Hard-delete (admin only); referenced `config_name`s fail per-item.""" + """Hard-delete by row id (admin only); rows still referenced by fragments fail per-item.""" result = await self._adapter.admin_bulk_purge(body.parsed) return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) diff --git a/src/ai/backend/manager/api/rest/v2/app_config_policy/registry.py b/src/ai/backend/manager/api/rest/v2/app_config_policy/registry.py index 321c8c64b1a..13fc8409a07 100644 --- a/src/ai/backend/manager/api/rest/v2/app_config_policy/registry.py +++ b/src/ai/backend/manager/api/rest/v2/app_config_policy/registry.py @@ -19,7 +19,7 @@ def register_v2_app_config_policy_routes( ) -> RouteRegistry: """Register all v2 app-config policy routes. - Reads (`GET /{config_name}`, `POST /search`) are available to any + Reads (`GET /{policy_id}`, `POST /search`) are available to any authenticated user. Writes are bulk-only and admin-only — `/bulk-create`, `/bulk-update`, `/bulk-purge`. """ @@ -27,7 +27,7 @@ def register_v2_app_config_policy_routes( # Reads reg.add("POST", "/search", handler.search, middlewares=[auth_required]) - reg.add("GET", "/{config_name}", handler.get, middlewares=[auth_required]) + reg.add("GET", "/{policy_id}", handler.get, middlewares=[auth_required]) # Admin bulk writes reg.add("POST", "/bulk-create", handler.admin_bulk_create, middlewares=[superadmin_required]) reg.add("POST", "/bulk-update", handler.admin_bulk_update, middlewares=[superadmin_required]) diff --git a/src/ai/backend/manager/api/rest/v2/path_params.py b/src/ai/backend/manager/api/rest/v2/path_params.py index d610bbeda92..1b3ccbaff75 100644 --- a/src/ai/backend/manager/api/rest/v2/path_params.py +++ b/src/ai/backend/manager/api/rest/v2/path_params.py @@ -9,8 +9,8 @@ from ai.backend.common.api_handlers import BaseRequestModel -class AppConfigPolicyConfigNamePathParam(BaseRequestModel): - config_name: str = Field(description="App-config policy `config_name`") +class AppConfigPolicyIdPathParam(BaseRequestModel): + policy_id: UUID = Field(description="App-config policy row UUID") class DomainNamePathParam(BaseRequestModel):