From 0e0749935b2a8aa81a31b1d99e665e7f33d638ab Mon Sep 17 00:00:00 2001 From: Gyubong Date: Thu, 16 Jul 2026 17:51:49 +0900 Subject: [PATCH 1/7] feat(BA-6921): AppConfig fragment write REST v2 API (CRUD) Raw app_config_fragment write surface as REST v2: create / get / update / purge, backed by a dedicated AppConfigFragmentAdapter. Writes and single-fragment reads are open to any authenticated user and gated by RBAC at the processor (scope / single-entity / bulk validators): a user acts on their own user-scope, a domain admin on their domain's, a superadmin on any (public is superadmin-only). Wires the RBAC validators into AppConfigFragmentProcessors (previously ungated). Base of the split; the fragment search API and the merged-read (resolve) API (#12377) stack on top and reuse this entity's AppConfigFragmentNode DTO. --- changes/12928.feature.md | 1 + docs/manager/rest-reference/openapi.json | 283 ++++++++++++++++++ .../v2/app_config_fragment/__init__.py | 0 .../manager/v2/app_config_fragment/request.py | 74 +++++ .../v2/app_config_fragment/response.py | 79 +++++ .../adapters/app_config_fragment/__init__.py | 0 .../adapters/app_config_fragment/adapter.py | 167 +++++++++++ .../backend/manager/api/adapters/registry.py | 6 + .../rest/v2/app_config_fragment/__init__.py | 0 .../rest/v2/app_config_fragment/handler.py | 83 +++++ .../rest/v2/app_config_fragment/registry.py | 43 +++ .../manager/api/rest/v2/path_params.py | 4 + src/ai/backend/manager/api/rest/v2/tree.py | 6 + 13 files changed, 746 insertions(+) create mode 100644 changes/12928.feature.md create mode 100644 src/ai/backend/common/dto/manager/v2/app_config_fragment/__init__.py create mode 100644 src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py create mode 100644 src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py create mode 100644 src/ai/backend/manager/api/adapters/app_config_fragment/__init__.py create mode 100644 src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py create mode 100644 src/ai/backend/manager/api/rest/v2/app_config_fragment/__init__.py create mode 100644 src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py create mode 100644 src/ai/backend/manager/api/rest/v2/app_config_fragment/registry.py diff --git a/changes/12928.feature.md b/changes/12928.feature.md new file mode 100644 index 00000000000..d99680891d6 --- /dev/null +++ b/changes/12928.feature.md @@ -0,0 +1 @@ +Add the AppConfig fragment write REST v2 API: create / get / update / purge plus bulk update and bulk delete of raw config fragments, open to any authenticated user and gated by RBAC per scope (BEP-1052). diff --git a/docs/manager/rest-reference/openapi.json b/docs/manager/rest-reference/openapi.json index b466a7c980f..6233e88b7ac 100644 --- a/docs/manager/rest-reference/openapi.json +++ b/docs/manager/rest-reference/openapi.json @@ -557,6 +557,110 @@ "title": "AppConfigScopeType", "type": "string" }, + "CreateAppConfigFragmentInput": { + "description": "Input for creating a new app config fragment at a given scope.", + "properties": { + "config_name": { + "description": "Registered config name (FK to app_config_definitions).", + "maxLength": 128, + "minLength": 1, + "title": "Config Name", + "type": "string" + }, + "scope_type": { + "$ref": "#/components/schemas/AppConfigScopeType", + "description": "Scope the fragment is written at (public | domain | user)." + }, + "scope_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Scope identifier: the domain id (domain scope) or the user id (user scope). Null for public scope, which has no owner.", + "title": "Scope Id" + }, + "config": { + "additionalProperties": true, + "description": "The fragment's JSON config document.", + "title": "Config", + "type": "object" + } + }, + "required": [ + "config_name", + "scope_type", + "config" + ], + "title": "CreateAppConfigFragmentInput", + "type": "object" + }, + "UpdateAppConfigFragmentInput": { + "description": "Input for updating an app config fragment's config document.", + "properties": { + "id": { + "description": "App config fragment id to update.", + "format": "uuid", + "title": "Id", + "type": "string" + }, + "config": { + "additionalProperties": true, + "description": "The replacement JSON config document.", + "title": "Config", + "type": "object" + } + }, + "required": [ + "id", + "config" + ], + "title": "UpdateAppConfigFragmentInput", + "type": "object" + }, + "BulkUpdateAppConfigFragmentInput": { + "description": "Input for updating many fragments' config documents (per-item partial success).", + "properties": { + "items": { + "description": "Fragments to update, each identified by its id.", + "items": { + "$ref": "#/components/schemas/UpdateAppConfigFragmentInput" + }, + "minItems": 1, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "BulkUpdateAppConfigFragmentInput", + "type": "object" + }, + "BulkPurgeAppConfigFragmentInput": { + "description": "Input for purging many fragments (per-item partial success).", + "properties": { + "ids": { + "description": "Fragment ids to purge.", + "items": { + "format": "uuid", + "type": "string" + }, + "minItems": 1, + "title": "Ids", + "type": "array" + } + }, + "required": [ + "ids" + ], + "title": "BulkPurgeAppConfigFragmentInput", + "type": "object" + }, "CreateAppConfigAllowListInput": { "description": "Input for registering a new app config allow-list entry.", "properties": { @@ -39583,6 +39687,185 @@ "description": "Retrieve aggregate resource capacity/usage across all agents (superadmin only).\n\n**Preconditions:**\n* Superadmin privilege required.\n" } }, + "/v2/app-config-fragments/": { + "post": { + "operationId": "v2/app-config-fragments.create", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAppConfigFragmentInput" + } + } + } + }, + "parameters": [], + "description": "Create a fragment at the caller's authorized scope (auth required, RBAC-gated).\n\n**Preconditions:**\n* User privilege required.\n" + } + }, + "/v2/app-config-fragments/bulk-update": { + "post": { + "operationId": "v2/app-config-fragments.bulk_update", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUpdateAppConfigFragmentInput" + } + } + } + }, + "parameters": [], + "description": "Update many fragments' configs by id, with per-item partial success (auth, RBAC).\n\n**Preconditions:**\n* User privilege required.\n" + } + }, + "/v2/app-config-fragments/bulk-delete": { + "post": { + "operationId": "v2/app-config-fragments.bulk_purge", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkPurgeAppConfigFragmentInput" + } + } + } + }, + "parameters": [], + "description": "Purge many fragments by id, with per-item partial success (auth, RBAC).\n\n**Preconditions:**\n* User privilege required.\n" + } + }, + "/v2/app-config-fragments/{fragment_id}": { + "get": { + "operationId": "v2/app-config-fragments.get", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "parameters": [ + { + "name": "fragment_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "description": "Get a fragment by id (auth required, RBAC-gated).\n\n**Preconditions:**\n* User privilege required.\n" + }, + "patch": { + "operationId": "v2/app-config-fragments.update", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAppConfigFragmentInput" + } + } + } + }, + "parameters": [ + { + "name": "fragment_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "description": "Update a fragment's config document by id (auth required, RBAC-gated).\n\n**Preconditions:**\n* User privilege required.\n" + }, + "delete": { + "operationId": "v2/app-config-fragments.purge", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "parameters": [ + { + "name": "fragment_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "description": "Purge a fragment by id (auth required, RBAC-gated).\n\n**Preconditions:**\n* User privilege required.\n" + } + }, "/v2/app-config-allow-list/": { "post": { "operationId": "v2/app-config-allow-list.admin_create", diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/__init__.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py new file mode 100644 index 00000000000..3d8954358b4 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py @@ -0,0 +1,74 @@ +"""Request DTOs for app_config_fragment v2.""" + +from __future__ import annotations + +from typing import Any, Self +from uuid import UUID + +from pydantic import Field, model_validator + +from ai.backend.common.api_handlers import BaseRequestModel +from ai.backend.common.data.app_config.types import AppConfigScopeType + +__all__ = ( + "BulkPurgeAppConfigFragmentInput", + "BulkUpdateAppConfigFragmentInput", + "CreateAppConfigFragmentInput", + "PurgeAppConfigFragmentInput", + "UpdateAppConfigFragmentInput", +) + + +class CreateAppConfigFragmentInput(BaseRequestModel): + """Input for creating a new app config fragment at a given scope.""" + + config_name: str = Field( + min_length=1, + max_length=128, + description="Registered config name (FK to app_config_definitions).", + ) + scope_type: AppConfigScopeType = Field( + description="Scope the fragment is written at (public | domain | user)." + ) + scope_id: str | None = Field( + default=None, + description="Scope identifier: the domain id (domain scope) or the user id (user scope). " + "Null for public scope, which has no owner.", + ) + config: dict[str, Any] = Field(description="The fragment's JSON config document.") + + @model_validator(mode="after") + def _check_scope_id(self) -> Self: + if self.scope_type is AppConfigScopeType.PUBLIC: + if self.scope_id is not None: + raise ValueError("scope_id must be null for public scope.") + elif not self.scope_id: + raise ValueError("scope_id is required for domain and user scopes.") + return self + + +class UpdateAppConfigFragmentInput(BaseRequestModel): + """Input for updating an app config fragment's config document.""" + + id: UUID = Field(description="App config fragment id to update.") + config: dict[str, Any] = Field(description="The replacement JSON config document.") + + +class PurgeAppConfigFragmentInput(BaseRequestModel): + """Input for purging an app config fragment.""" + + id: UUID = Field(description="App config fragment id to purge.") + + +class BulkUpdateAppConfigFragmentInput(BaseRequestModel): + """Input for updating many fragments' config documents (per-item partial success).""" + + items: list[UpdateAppConfigFragmentInput] = Field( + min_length=1, description="Fragments to update, each identified by its id." + ) + + +class BulkPurgeAppConfigFragmentInput(BaseRequestModel): + """Input for purging many fragments (per-item partial success).""" + + ids: list[UUID] = Field(min_length=1, description="Fragment ids to purge.") diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py new file mode 100644 index 00000000000..f4c3c1abf57 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py @@ -0,0 +1,79 @@ +"""Response DTOs for app_config_fragment v2.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseResponseModel +from ai.backend.common.data.app_config.types import AppConfigScopeType + +__all__ = ( + "AppConfigFragmentBulkErrorInfo", + "AppConfigFragmentNode", + "BulkPurgeAppConfigFragmentPayload", + "BulkUpdateAppConfigFragmentPayload", + "CreateAppConfigFragmentPayload", + "PurgeAppConfigFragmentPayload", + "UpdateAppConfigFragmentPayload", +) + + +class AppConfigFragmentNode(BaseResponseModel): + """Node model representing one app config fragment.""" + + id: UUID = Field(description="App config fragment UUID.") + config_name: str = Field(description="Config name the fragment belongs to.") + scope_type: AppConfigScopeType = Field(description="Scope the fragment is written at.") + scope_id: str | None = Field( + description="Scope identifier: the domain id or user id; null for public scope." + ) + config: dict[str, Any] = Field(description="The fragment's JSON config document.") + created_at: datetime = Field(description="Creation timestamp (UTC).") + updated_at: datetime = Field(description="Last update timestamp (UTC).") + + +class CreateAppConfigFragmentPayload(BaseResponseModel): + """Payload for app config fragment creation.""" + + app_config_fragment: AppConfigFragmentNode = Field(description="Created app config fragment.") + + +class UpdateAppConfigFragmentPayload(BaseResponseModel): + """Payload for app config fragment update.""" + + app_config_fragment: AppConfigFragmentNode = Field(description="Updated app config fragment.") + + +class PurgeAppConfigFragmentPayload(BaseResponseModel): + """Payload for app config fragment purge.""" + + id: UUID = Field(description="UUID of the purged app config fragment.") + + +class AppConfigFragmentBulkErrorInfo(BaseResponseModel): + """One failed item of a partial-success bulk mutation.""" + + index: int = Field(description="Zero-based index of the failed item in the request batch.") + message: str = Field(description="Reason the item failed.") + + +class BulkUpdateAppConfigFragmentPayload(BaseResponseModel): + """Partial-success payload for a bulk fragment update.""" + + succeeded: list[AppConfigFragmentNode] = Field(description="Successfully updated fragments.") + failed: list[AppConfigFragmentBulkErrorInfo] = Field( + description="Per-item failures with their batch index." + ) + + +class BulkPurgeAppConfigFragmentPayload(BaseResponseModel): + """Partial-success payload for a bulk fragment purge.""" + + purged_ids: list[UUID] = Field(description="Ids of successfully purged fragments.") + failed: list[AppConfigFragmentBulkErrorInfo] = Field( + description="Per-item failures with their batch index." + ) diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment/__init__.py b/src/ai/backend/manager/api/adapters/app_config_fragment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py new file mode 100644 index 00000000000..09e437c355a --- /dev/null +++ b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py @@ -0,0 +1,167 @@ +"""App config fragment adapter bridging v2 DTOs and the fragment write Processors.""" + +from __future__ import annotations + +from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigScopeType as AppConfigScopeTypeDTO +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + BulkPurgeAppConfigFragmentInput, + BulkUpdateAppConfigFragmentInput, + CreateAppConfigFragmentInput, + PurgeAppConfigFragmentInput, + UpdateAppConfigFragmentInput, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AppConfigFragmentBulkErrorInfo, + AppConfigFragmentNode, + BulkPurgeAppConfigFragmentPayload, + BulkUpdateAppConfigFragmentPayload, + CreateAppConfigFragmentPayload, + PurgeAppConfigFragmentPayload, + UpdateAppConfigFragmentPayload, +) +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID +from ai.backend.manager.api.adapters.base import BaseAdapter +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentData, +) +from ai.backend.manager.repositories.app_config_fragment.creators import ( + AppConfigFragmentCreatorSpec, +) +from ai.backend.manager.repositories.app_config_fragment.purgers import ( + AppConfigFragmentPurgerSpec, +) +from ai.backend.manager.repositories.app_config_fragment.updaters import ( + AppConfigFragmentUpdaterSpec, +) +from ai.backend.manager.repositories.base import ( + Updater, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_purge import ( + BulkPurgeAppConfigFragmentAction, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_update import ( + BulkUpdateAppConfigFragmentAction, +) +from ai.backend.manager.services.app_config_fragment.actions.create import ( + CreateAppConfigFragmentAction, +) +from ai.backend.manager.services.app_config_fragment.actions.get import ( + GetAppConfigFragmentAction, +) +from ai.backend.manager.services.app_config_fragment.actions.purge import ( + PurgeAppConfigFragmentAction, +) +from ai.backend.manager.services.app_config_fragment.actions.update import ( + UpdateAppConfigFragmentAction, +) +from ai.backend.manager.types import OptionalState + +# Public fragments have no scope owner; the non-null scope_id column stores this empty +# sentinel. Public is identified by scope_type, never by this value. +_PUBLIC_SCOPE_ID = "" + + +class AppConfigFragmentAdapter(BaseAdapter): + """Adapter for raw app config fragment write operations.""" + + # --- fragment CRUD (RBAC-gated at the processor) --- + + async def create(self, input: CreateAppConfigFragmentInput) -> CreateAppConfigFragmentPayload: + # scope_id is None only for public (enforced by the DTO validator); persist the empty + # sentinel since the column is non-null. Domain/user carry the id. + spec = AppConfigFragmentCreatorSpec( + config_name=input.config_name, + scope_type=AppConfigScopeType(input.scope_type.value), + scope_id=input.scope_id or _PUBLIC_SCOPE_ID, + config=input.config, + ) + action_result = await self._processors.app_config_fragment.create.wait_for_complete( + CreateAppConfigFragmentAction(creator_spec=spec) + ) + return CreateAppConfigFragmentPayload( + app_config_fragment=self._fragment_to_node(action_result.fragment), + ) + + async def get(self, fragment_id: AppConfigFragmentID) -> AppConfigFragmentNode: + action_result = await self._processors.app_config_fragment.get.wait_for_complete( + GetAppConfigFragmentAction(fragment_id=fragment_id) + ) + return self._fragment_to_node(action_result.fragment) + + async def update(self, input: UpdateAppConfigFragmentInput) -> UpdateAppConfigFragmentPayload: + updater = Updater( + spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update(input.config)), + pk_value=AppConfigFragmentID(input.id), + ) + # No allow-list gate: a fragment row exists only while its entry does (FK with + # cascade), so an existing fragment is always writable at its own scope. + action_result = await self._processors.app_config_fragment.update.wait_for_complete( + UpdateAppConfigFragmentAction(updater=updater) + ) + return UpdateAppConfigFragmentPayload( + app_config_fragment=self._fragment_to_node(action_result.fragment), + ) + + async def purge(self, input: PurgeAppConfigFragmentInput) -> PurgeAppConfigFragmentPayload: + fragment_id = AppConfigFragmentID(input.id) + purger_spec = AppConfigFragmentPurgerSpec(fragment_id=fragment_id) + # No allow-list gate — see ``update``. + action_result = await self._processors.app_config_fragment.purge.wait_for_complete( + PurgeAppConfigFragmentAction(purger_spec=purger_spec) + ) + return PurgeAppConfigFragmentPayload(id=action_result.fragment.id) + + async def bulk_update( + self, input: BulkUpdateAppConfigFragmentInput + ) -> BulkUpdateAppConfigFragmentPayload: + updaters = [ + Updater( + spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update(item.config)), + pk_value=AppConfigFragmentID(item.id), + ) + for item in input.items + ] + action_result = await self._processors.app_config_fragment.bulk_update.wait_for_complete( + BulkUpdateAppConfigFragmentAction(updaters=updaters) + ) + return BulkUpdateAppConfigFragmentPayload( + succeeded=[self._fragment_to_node(fragment) for fragment in action_result.succeeded], + failed=[ + AppConfigFragmentBulkErrorInfo(index=error.index, message=error.message) + for error in action_result.failed + ], + ) + + async def bulk_purge( + self, input: BulkPurgeAppConfigFragmentInput + ) -> BulkPurgeAppConfigFragmentPayload: + purger_specs = [ + AppConfigFragmentPurgerSpec(fragment_id=AppConfigFragmentID(fragment_id)) + for fragment_id in input.ids + ] + action_result = await self._processors.app_config_fragment.bulk_purge.wait_for_complete( + BulkPurgeAppConfigFragmentAction(purger_specs=purger_specs) + ) + return BulkPurgeAppConfigFragmentPayload( + purged_ids=[fragment.id for fragment in action_result.succeeded], + failed=[ + AppConfigFragmentBulkErrorInfo(index=error.index, message=error.message) + for error in action_result.failed + ], + ) + + # --- converters --- + + @staticmethod + def _fragment_to_node(data: AppConfigFragmentData) -> AppConfigFragmentNode: + return AppConfigFragmentNode( + id=data.id, + config_name=data.config_name, + scope_type=AppConfigScopeTypeDTO(data.scope_type.value), + # public has no owner — expose null rather than the stored empty sentinel. + scope_id=None if data.scope_type is AppConfigScopeType.PUBLIC else data.scope_id, + config=data.config, + created_at=data.created_at, + updated_at=data.updated_at, + ) diff --git a/src/ai/backend/manager/api/adapters/registry.py b/src/ai/backend/manager/api/adapters/registry.py index 7a24399448d..c73590bf25b 100644 --- a/src/ai/backend/manager/api/adapters/registry.py +++ b/src/ai/backend/manager/api/adapters/registry.py @@ -11,6 +11,9 @@ from ai.backend.manager.api.adapters.app_config_definition.adapter import ( AppConfigDefinitionAdapter, ) +from ai.backend.manager.api.adapters.app_config_fragment.adapter import ( + AppConfigFragmentAdapter, +) from ai.backend.manager.api.adapters.artifact.adapter import ArtifactAdapter from ai.backend.manager.api.adapters.artifact_registry.adapter import ArtifactRegistryAdapter from ai.backend.manager.api.adapters.audit_log.adapter import AuditLogAdapter @@ -79,6 +82,7 @@ class Adapters: def __init__( self, agent: AgentAdapter, + app_config_fragment: AppConfigFragmentAdapter, app_config_allow_list: AppConfigAllowListAdapter, app_config_definition: AppConfigDefinitionAdapter, artifact: ArtifactAdapter, @@ -123,6 +127,7 @@ def __init__( vfs_storage: VFSStorageAdapter, ) -> None: self.agent = agent + self.app_config_fragment = app_config_fragment self.app_config_allow_list = app_config_allow_list self.app_config_definition = app_config_definition self.artifact = artifact @@ -186,6 +191,7 @@ def create( """ return cls( agent=AgentAdapter(processors), + app_config_fragment=AppConfigFragmentAdapter(processors), app_config_allow_list=AppConfigAllowListAdapter(processors), app_config_definition=AppConfigDefinitionAdapter(processors), artifact=ArtifactAdapter(processors), diff --git a/src/ai/backend/manager/api/rest/v2/app_config_fragment/__init__.py b/src/ai/backend/manager/api/rest/v2/app_config_fragment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py b/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py new file mode 100644 index 00000000000..1de7eb57f9f --- /dev/null +++ b/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py @@ -0,0 +1,83 @@ +"""REST v2 handler for the app config fragment domain.""" + +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_fragment.request import ( + BulkPurgeAppConfigFragmentInput, + BulkUpdateAppConfigFragmentInput, + CreateAppConfigFragmentInput, + PurgeAppConfigFragmentInput, + UpdateAppConfigFragmentInput, +) +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID +from ai.backend.logging import BraceStyleAdapter +from ai.backend.manager.api.rest.v2.path_params import AppConfigFragmentIdPathParam + +if TYPE_CHECKING: + from ai.backend.manager.api.adapters.app_config_fragment.adapter import ( + AppConfigFragmentAdapter, + ) + +log: Final = BraceStyleAdapter(logging.getLogger(__spec__.name)) + + +class V2AppConfigFragmentHandler: + """REST v2 handler for raw app config fragment operations.""" + + def __init__(self, *, adapter: AppConfigFragmentAdapter) -> None: + self._adapter = adapter + + async def create( + self, + body: BodyParam[CreateAppConfigFragmentInput], + ) -> APIResponse: + """Create a fragment at the caller's authorized scope (auth required, RBAC-gated).""" + result = await self._adapter.create(body.parsed) + return APIResponse.build(status_code=HTTPStatus.CREATED, response_model=result) + + async def get( + self, + path: PathParam[AppConfigFragmentIdPathParam], + ) -> APIResponse: + """Get a fragment by id (auth required, RBAC-gated).""" + result = await self._adapter.get(AppConfigFragmentID(path.parsed.fragment_id)) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def update( + self, + path: PathParam[AppConfigFragmentIdPathParam], + body: BodyParam[UpdateAppConfigFragmentInput], + ) -> APIResponse: + """Update a fragment's config document by id (auth required, RBAC-gated).""" + merged = body.parsed.model_copy(update={"id": path.parsed.fragment_id}) + result = await self._adapter.update(merged) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def purge( + self, + path: PathParam[AppConfigFragmentIdPathParam], + ) -> APIResponse: + """Purge a fragment by id (auth required, RBAC-gated).""" + result = await self._adapter.purge(PurgeAppConfigFragmentInput(id=path.parsed.fragment_id)) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def bulk_update( + self, + body: BodyParam[BulkUpdateAppConfigFragmentInput], + ) -> APIResponse: + """Update many fragments' configs by id, with per-item partial success (auth, RBAC).""" + result = await self._adapter.bulk_update(body.parsed) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def bulk_purge( + self, + body: BodyParam[BulkPurgeAppConfigFragmentInput], + ) -> APIResponse: + """Purge many fragments by id, with per-item partial success (auth, RBAC).""" + result = await self._adapter.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_fragment/registry.py b/src/ai/backend/manager/api/rest/v2/app_config_fragment/registry.py new file mode 100644 index 00000000000..b6e549b8341 --- /dev/null +++ b/src/ai/backend/manager/api/rest/v2/app_config_fragment/registry.py @@ -0,0 +1,43 @@ +"""Route registry for REST v2 app config fragment endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ai.backend.manager.api.rest.middleware.auth import auth_required +from ai.backend.manager.api.rest.routing import RouteRegistry + +from .handler import V2AppConfigFragmentHandler + +if TYPE_CHECKING: + from ai.backend.manager.api.rest.types import RouteDeps + + +def register_v2_app_config_fragment_routes( + handler: V2AppConfigFragmentHandler, + route_deps: RouteDeps, +) -> RouteRegistry: + """Register all REST v2 app config fragment write routes. + + Writes and single-fragment reads are open to any authenticated user and gated by RBAC + at the processor (a user acts on their own user-scope, a domain admin on their domain's, + a superadmin on any; public is superadmin-only). + + Layout: + POST / create a fragment (auth, RBAC) + POST /bulk-update update many by id (auth, RBAC) + POST /bulk-delete purge many by id (auth, RBAC) + GET /{fragment_id} get by id (auth, RBAC) + PATCH /{fragment_id} update config by id (auth, RBAC) + DELETE /{fragment_id} purge by id (auth, RBAC) + """ + registry = RouteRegistry.create("app-config-fragments", route_deps.cors_options) + + registry.add("POST", "/", handler.create, middlewares=[auth_required]) + registry.add("POST", "/bulk-update", handler.bulk_update, middlewares=[auth_required]) + registry.add("POST", "/bulk-delete", handler.bulk_purge, middlewares=[auth_required]) + registry.add("GET", "/{fragment_id}", handler.get, middlewares=[auth_required]) + registry.add("PATCH", "/{fragment_id}", handler.update, middlewares=[auth_required]) + registry.add("DELETE", "/{fragment_id}", handler.purge, middlewares=[auth_required]) + + return registry 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 f90faac77ee..86cff7e27ae 100644 --- a/src/ai/backend/manager/api/rest/v2/path_params.py +++ b/src/ai/backend/manager/api/rest/v2/path_params.py @@ -94,6 +94,10 @@ class AppConfigDefinitionIdPathParam(BaseRequestModel): app_config_definition_id: UUID = Field(description="App config definition UUID") +class AppConfigFragmentIdPathParam(BaseRequestModel): + fragment_id: UUID = Field(description="App config fragment UUID") + + class ReplicaIdPathParam(BaseRequestModel): replica_id: UUID = Field(description="Replica UUID") diff --git a/src/ai/backend/manager/api/rest/v2/tree.py b/src/ai/backend/manager/api/rest/v2/tree.py index 6836f74ef01..e314da5d0e0 100644 --- a/src/ai/backend/manager/api/rest/v2/tree.py +++ b/src/ai/backend/manager/api/rest/v2/tree.py @@ -32,6 +32,8 @@ def build_v2_routes( from .app_config_allow_list.registry import register_v2_app_config_allow_list_routes from .app_config_definition.handler import V2AppConfigDefinitionHandler from .app_config_definition.registry import register_v2_app_config_definition_routes + from .app_config_fragment.handler import V2AppConfigFragmentHandler + from .app_config_fragment.registry import register_v2_app_config_fragment_routes from .artifact.handler import V2ArtifactHandler from .artifact.registry import register_v2_artifact_routes from .artifact_registry.handler import V2ArtifactRegistryHandler @@ -123,6 +125,7 @@ def build_v2_routes( # Build all handlers (each takes its individual adapter) agent_handler = V2AgentHandler(adapter=adapters.agent) + app_config_fragment_handler = V2AppConfigFragmentHandler(adapter=adapters.app_config_fragment) app_config_allow_list_handler = V2AppConfigAllowListHandler( adapter=adapters.app_config_allow_list ) @@ -187,6 +190,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_fragment_routes(app_config_fragment_handler, route_deps) + ) v2_reg.add_subregistry( register_v2_app_config_allow_list_routes(app_config_allow_list_handler, route_deps) ) From 8bd777dc05c8fd2adec7dccc172b9123f6f85b1c Mon Sep 17 00:00:00 2001 From: Gyubong Date: Tue, 21 Jul 2026 13:27:03 +0900 Subject: [PATCH 2/7] fix(BA-6921): take the fragment id from the path for single update/purge Merging the path id into the body DTO via model_copy() bypassed validation and declared a required body field that was always overwritten, so a mismatched id in the body was silently ignored and the OpenAPI spec advertised a field the handler discards. Pass the id as its own argument instead. The bulk item still carries its own id and is split out as AppConfigFragmentUpdateItem, leaving UpdateAppConfigFragmentInput as the single-fragment body. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/manager/rest-reference/openapi.json | 24 +++++++++++++++---- .../manager/v2/app_config_fragment/request.py | 21 ++++++++++------ .../adapters/app_config_fragment/adapter.py | 10 ++++---- .../rest/v2/app_config_fragment/handler.py | 8 +++---- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/docs/manager/rest-reference/openapi.json b/docs/manager/rest-reference/openapi.json index 6233e88b7ac..9289184db86 100644 --- a/docs/manager/rest-reference/openapi.json +++ b/docs/manager/rest-reference/openapi.json @@ -599,8 +599,8 @@ "title": "CreateAppConfigFragmentInput", "type": "object" }, - "UpdateAppConfigFragmentInput": { - "description": "Input for updating an app config fragment's config document.", + "AppConfigFragmentUpdateItem": { + "description": "One item of a bulk update, carrying its own target id.\n\nBulk requests address many fragments in a single call, so the id belongs in the body\nhere — unlike the single-fragment :class:`UpdateAppConfigFragmentInput`.", "properties": { "id": { "description": "App config fragment id to update.", @@ -619,7 +619,7 @@ "id", "config" ], - "title": "UpdateAppConfigFragmentInput", + "title": "AppConfigFragmentUpdateItem", "type": "object" }, "BulkUpdateAppConfigFragmentInput": { @@ -628,7 +628,7 @@ "items": { "description": "Fragments to update, each identified by its id.", "items": { - "$ref": "#/components/schemas/UpdateAppConfigFragmentInput" + "$ref": "#/components/schemas/AppConfigFragmentUpdateItem" }, "minItems": 1, "title": "Items", @@ -661,6 +661,22 @@ "title": "BulkPurgeAppConfigFragmentInput", "type": "object" }, + "UpdateAppConfigFragmentInput": { + "description": "Input for updating one app config fragment's config document.\n\nThe target fragment is identified by the request path, not by this body.", + "properties": { + "config": { + "additionalProperties": true, + "description": "The replacement JSON config document.", + "title": "Config", + "type": "object" + } + }, + "required": [ + "config" + ], + "title": "UpdateAppConfigFragmentInput", + "type": "object" + }, "CreateAppConfigAllowListInput": { "description": "Input for registering a new app config allow-list entry.", "properties": { diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py index 3d8954358b4..fd40ce330f4 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py @@ -11,10 +11,10 @@ from ai.backend.common.data.app_config.types import AppConfigScopeType __all__ = ( + "AppConfigFragmentUpdateItem", "BulkPurgeAppConfigFragmentInput", "BulkUpdateAppConfigFragmentInput", "CreateAppConfigFragmentInput", - "PurgeAppConfigFragmentInput", "UpdateAppConfigFragmentInput", ) @@ -48,22 +48,29 @@ def _check_scope_id(self) -> Self: class UpdateAppConfigFragmentInput(BaseRequestModel): - """Input for updating an app config fragment's config document.""" + """Input for updating one app config fragment's config document. + + The target fragment is identified by the request path, not by this body. + """ - id: UUID = Field(description="App config fragment id to update.") config: dict[str, Any] = Field(description="The replacement JSON config document.") -class PurgeAppConfigFragmentInput(BaseRequestModel): - """Input for purging an app config fragment.""" +class AppConfigFragmentUpdateItem(BaseRequestModel): + """One item of a bulk update, carrying its own target id. + + Bulk requests address many fragments in a single call, so the id belongs in the body + here — unlike the single-fragment :class:`UpdateAppConfigFragmentInput`. + """ - id: UUID = Field(description="App config fragment id to purge.") + id: UUID = Field(description="App config fragment id to update.") + config: dict[str, Any] = Field(description="The replacement JSON config document.") class BulkUpdateAppConfigFragmentInput(BaseRequestModel): """Input for updating many fragments' config documents (per-item partial success).""" - items: list[UpdateAppConfigFragmentInput] = Field( + items: list[AppConfigFragmentUpdateItem] = Field( min_length=1, description="Fragments to update, each identified by its id." ) diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py index 09e437c355a..a2448e0d707 100644 --- a/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py +++ b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py @@ -8,7 +8,6 @@ BulkPurgeAppConfigFragmentInput, BulkUpdateAppConfigFragmentInput, CreateAppConfigFragmentInput, - PurgeAppConfigFragmentInput, UpdateAppConfigFragmentInput, ) from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( @@ -89,10 +88,12 @@ async def get(self, fragment_id: AppConfigFragmentID) -> AppConfigFragmentNode: ) return self._fragment_to_node(action_result.fragment) - async def update(self, input: UpdateAppConfigFragmentInput) -> UpdateAppConfigFragmentPayload: + async def update( + self, fragment_id: AppConfigFragmentID, input: UpdateAppConfigFragmentInput + ) -> UpdateAppConfigFragmentPayload: updater = Updater( spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update(input.config)), - pk_value=AppConfigFragmentID(input.id), + pk_value=fragment_id, ) # No allow-list gate: a fragment row exists only while its entry does (FK with # cascade), so an existing fragment is always writable at its own scope. @@ -103,8 +104,7 @@ async def update(self, input: UpdateAppConfigFragmentInput) -> UpdateAppConfigFr app_config_fragment=self._fragment_to_node(action_result.fragment), ) - async def purge(self, input: PurgeAppConfigFragmentInput) -> PurgeAppConfigFragmentPayload: - fragment_id = AppConfigFragmentID(input.id) + async def purge(self, fragment_id: AppConfigFragmentID) -> PurgeAppConfigFragmentPayload: purger_spec = AppConfigFragmentPurgerSpec(fragment_id=fragment_id) # No allow-list gate — see ``update``. action_result = await self._processors.app_config_fragment.purge.wait_for_complete( diff --git a/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py b/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py index 1de7eb57f9f..819c4afc34f 100644 --- a/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py +++ b/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py @@ -11,7 +11,6 @@ BulkPurgeAppConfigFragmentInput, BulkUpdateAppConfigFragmentInput, CreateAppConfigFragmentInput, - PurgeAppConfigFragmentInput, UpdateAppConfigFragmentInput, ) from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID @@ -54,8 +53,9 @@ async def update( body: BodyParam[UpdateAppConfigFragmentInput], ) -> APIResponse: """Update a fragment's config document by id (auth required, RBAC-gated).""" - merged = body.parsed.model_copy(update={"id": path.parsed.fragment_id}) - result = await self._adapter.update(merged) + result = await self._adapter.update( + AppConfigFragmentID(path.parsed.fragment_id), body.parsed + ) return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) async def purge( @@ -63,7 +63,7 @@ async def purge( path: PathParam[AppConfigFragmentIdPathParam], ) -> APIResponse: """Purge a fragment by id (auth required, RBAC-gated).""" - result = await self._adapter.purge(PurgeAppConfigFragmentInput(id=path.parsed.fragment_id)) + result = await self._adapter.purge(AppConfigFragmentID(path.parsed.fragment_id)) return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) async def bulk_update( From 5b1b10d1a48edb4a0bbf53403b0f6ae3563d0c2b Mon Sep 17 00:00:00 2001 From: Gyubong Date: Tue, 21 Jul 2026 13:42:19 +0900 Subject: [PATCH 3/7] test(BA-6921): cover the app_config_fragment v2 request DTOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope_type/scope_id agreement is the one piece of real logic in these DTOs: public must name no owner, domain and user must name one, and a blank id is as unusable as a missing one. Enumerate the full scope enum on both the accepted and the rejected side so a new scope variant cannot slip through untested. Also pin the update body split — the single-fragment body carries no id, while the bulk item does — and the min_length on both bulk batches. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dto/manager/v2/app_config_fragment/BUILD | 3 + .../v2/app_config_fragment/__init__.py | 0 .../v2/app_config_fragment/test_request.py | 161 ++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 tests/unit/common/dto/manager/v2/app_config_fragment/BUILD create mode 100644 tests/unit/common/dto/manager/v2/app_config_fragment/__init__.py create mode 100644 tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py diff --git a/tests/unit/common/dto/manager/v2/app_config_fragment/BUILD b/tests/unit/common/dto/manager/v2/app_config_fragment/BUILD new file mode 100644 index 00000000000..57341b1358b --- /dev/null +++ b/tests/unit/common/dto/manager/v2/app_config_fragment/BUILD @@ -0,0 +1,3 @@ +python_tests( + name="tests", +) diff --git a/tests/unit/common/dto/manager/v2/app_config_fragment/__init__.py b/tests/unit/common/dto/manager/v2/app_config_fragment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py b/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py new file mode 100644 index 00000000000..ddd97e79b56 --- /dev/null +++ b/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py @@ -0,0 +1,161 @@ +"""Tests for ai.backend.common.dto.manager.v2.app_config_fragment.request module.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import Any + +import pytest +from pydantic import ValidationError + +from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AppConfigFragmentUpdateItem, + BulkPurgeAppConfigFragmentInput, + BulkUpdateAppConfigFragmentInput, + CreateAppConfigFragmentInput, + UpdateAppConfigFragmentInput, +) +from ai.backend.common.exception import BackendAISchemaValidationFailed + +_SCOPE_ID = "11111111-1111-1111-1111-111111111111" + + +@dataclass(frozen=True) +class _ScopeCase: + scope_type: AppConfigScopeType + scope_id: str | None + + +@pytest.fixture +def config_document() -> dict[str, Any]: + return {"theme": {"mode": "dark"}, "banner": "hello"} + + +class TestCreateAppConfigFragmentInput: + """Tests for the scope_id / scope_type agreement enforced by CreateAppConfigFragmentInput.""" + + @pytest.mark.parametrize( + "case", + [ + _ScopeCase(scope_type=AppConfigScopeType.PUBLIC, scope_id=None), + _ScopeCase(scope_type=AppConfigScopeType.DOMAIN, scope_id=_SCOPE_ID), + _ScopeCase(scope_type=AppConfigScopeType.USER, scope_id=_SCOPE_ID), + ], + ids=lambda case: case.scope_type.value, + ) + def test_scope_id_matching_its_scope_type_is_accepted( + self, case: _ScopeCase, config_document: dict[str, Any] + ) -> None: + req = CreateAppConfigFragmentInput( + config_name="theme", + scope_type=case.scope_type, + scope_id=case.scope_id, + config=config_document, + ) + + assert req.scope_type is case.scope_type + assert req.scope_id == case.scope_id + + @pytest.mark.parametrize( + "case", + [ + # public has no owner, so naming one is a contradiction. + _ScopeCase(scope_type=AppConfigScopeType.PUBLIC, scope_id=_SCOPE_ID), + # domain and user both require an owner; absent and blank are equally unusable. + _ScopeCase(scope_type=AppConfigScopeType.DOMAIN, scope_id=None), + _ScopeCase(scope_type=AppConfigScopeType.DOMAIN, scope_id=""), + _ScopeCase(scope_type=AppConfigScopeType.USER, scope_id=None), + _ScopeCase(scope_type=AppConfigScopeType.USER, scope_id=""), + ], + ids=lambda case: f"{case.scope_type.value}-{case.scope_id!r}", + ) + def test_scope_id_disagreeing_with_its_scope_type_is_rejected( + self, case: _ScopeCase, config_document: dict[str, Any] + ) -> None: + with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): + CreateAppConfigFragmentInput( + config_name="theme", + scope_type=case.scope_type, + scope_id=case.scope_id, + config=config_document, + ) + + def test_scope_id_defaults_to_none(self, config_document: dict[str, Any]) -> None: + req = CreateAppConfigFragmentInput( + config_name="theme", + scope_type=AppConfigScopeType.PUBLIC, + config=config_document, + ) + + assert req.scope_id is None + + @pytest.mark.parametrize("config_name", ["", "x" * 129], ids=["empty", "too-long"]) + def test_config_name_outside_its_length_bounds_is_rejected( + self, config_name: str, config_document: dict[str, Any] + ) -> None: + with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): + CreateAppConfigFragmentInput( + config_name=config_name, + scope_type=AppConfigScopeType.PUBLIC, + config=config_document, + ) + + +class TestUpdateAppConfigFragmentInput: + """The single-fragment update body carries no id — the request path identifies the target.""" + + def test_config_alone_is_a_complete_body(self, config_document: dict[str, Any]) -> None: + req = UpdateAppConfigFragmentInput(config=config_document) + + assert req.config == config_document + assert not hasattr(req, "id") + + def test_config_is_required(self) -> None: + # Validated from a mapping rather than the constructor: a body arriving without + # ``config`` is a runtime payload, not a call the type checker would ever allow. + with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): + UpdateAppConfigFragmentInput.model_validate({}) + + +class TestAppConfigFragmentUpdateItem: + """The bulk item does carry an id, since one request addresses many fragments.""" + + def test_id_and_config_are_both_required(self, config_document: dict[str, Any]) -> None: + fragment_id = uuid.uuid4() + + item = AppConfigFragmentUpdateItem(id=fragment_id, config=config_document) + + assert item.id == fragment_id + assert item.config == config_document + + def test_omitting_id_is_rejected(self, config_document: dict[str, Any]) -> None: + with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): + AppConfigFragmentUpdateItem.model_validate({"config": config_document}) + + +class TestBulkAppConfigFragmentInputs: + """Both bulk bodies reject an empty batch rather than treating it as a no-op.""" + + def test_bulk_update_accepts_items(self, config_document: dict[str, Any]) -> None: + req = BulkUpdateAppConfigFragmentInput( + items=[AppConfigFragmentUpdateItem(id=uuid.uuid4(), config=config_document)] + ) + + assert len(req.items) == 1 + + def test_bulk_update_rejects_an_empty_batch(self) -> None: + with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): + BulkUpdateAppConfigFragmentInput(items=[]) + + def test_bulk_purge_accepts_ids(self) -> None: + fragment_id = uuid.uuid4() + + req = BulkPurgeAppConfigFragmentInput(ids=[fragment_id]) + + assert req.ids == [fragment_id] + + def test_bulk_purge_rejects_an_empty_batch(self) -> None: + with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): + BulkPurgeAppConfigFragmentInput(ids=[]) From f1d1f386d3489931fb681fe48e577d9fcc67df38 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Tue, 21 Jul 2026 14:09:25 +0900 Subject: [PATCH 4/7] feat(BA-6921): accept and return scope_id as a UUID on the v2 API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the column becoming UUID NULL in BA-6948: the request and response DTOs take UUID | None, so a non-UUID scope_id is rejected at the boundary instead of reaching a row no scope query can find. The adapter no longer maps public to an empty-string sentinel in either direction — None is what the column stores. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/manager/rest-reference/openapi.json | 1 + .../manager/v2/app_config_fragment/request.py | 4 +-- .../v2/app_config_fragment/response.py | 2 +- .../adapters/app_config_fragment/adapter.py | 13 +++------ .../v2/app_config_fragment/test_request.py | 29 +++++++++++++++---- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/docs/manager/rest-reference/openapi.json b/docs/manager/rest-reference/openapi.json index 9289184db86..dc2da8c0ef5 100644 --- a/docs/manager/rest-reference/openapi.json +++ b/docs/manager/rest-reference/openapi.json @@ -574,6 +574,7 @@ "scope_id": { "anyOf": [ { + "format": "uuid", "type": "string" }, { diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py index fd40ce330f4..e59848757bf 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py @@ -30,7 +30,7 @@ class CreateAppConfigFragmentInput(BaseRequestModel): scope_type: AppConfigScopeType = Field( description="Scope the fragment is written at (public | domain | user)." ) - scope_id: str | None = Field( + scope_id: UUID | None = Field( default=None, description="Scope identifier: the domain id (domain scope) or the user id (user scope). " "Null for public scope, which has no owner.", @@ -42,7 +42,7 @@ def _check_scope_id(self) -> Self: if self.scope_type is AppConfigScopeType.PUBLIC: if self.scope_id is not None: raise ValueError("scope_id must be null for public scope.") - elif not self.scope_id: + elif self.scope_id is None: raise ValueError("scope_id is required for domain and user scopes.") return self diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py index f4c3c1abf57..edbeef14011 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py @@ -28,7 +28,7 @@ class AppConfigFragmentNode(BaseResponseModel): id: UUID = Field(description="App config fragment UUID.") config_name: str = Field(description="Config name the fragment belongs to.") scope_type: AppConfigScopeType = Field(description="Scope the fragment is written at.") - scope_id: str | None = Field( + scope_id: UUID | None = Field( description="Scope identifier: the domain id or user id; null for public scope." ) config: dict[str, Any] = Field(description="The fragment's JSON config document.") diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py index a2448e0d707..ae0f127899f 100644 --- a/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py +++ b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py @@ -56,10 +56,6 @@ ) from ai.backend.manager.types import OptionalState -# Public fragments have no scope owner; the non-null scope_id column stores this empty -# sentinel. Public is identified by scope_type, never by this value. -_PUBLIC_SCOPE_ID = "" - class AppConfigFragmentAdapter(BaseAdapter): """Adapter for raw app config fragment write operations.""" @@ -67,12 +63,12 @@ class AppConfigFragmentAdapter(BaseAdapter): # --- fragment CRUD (RBAC-gated at the processor) --- async def create(self, input: CreateAppConfigFragmentInput) -> CreateAppConfigFragmentPayload: - # scope_id is None only for public (enforced by the DTO validator); persist the empty - # sentinel since the column is non-null. Domain/user carry the id. + # scope_id is None exactly for public (enforced by the DTO validator), which is what + # the column stores for an ownerless fragment. spec = AppConfigFragmentCreatorSpec( config_name=input.config_name, scope_type=AppConfigScopeType(input.scope_type.value), - scope_id=input.scope_id or _PUBLIC_SCOPE_ID, + scope_id=input.scope_id, config=input.config, ) action_result = await self._processors.app_config_fragment.create.wait_for_complete( @@ -159,8 +155,7 @@ def _fragment_to_node(data: AppConfigFragmentData) -> AppConfigFragmentNode: id=data.id, config_name=data.config_name, scope_type=AppConfigScopeTypeDTO(data.scope_type.value), - # public has no owner — expose null rather than the stored empty sentinel. - scope_id=None if data.scope_type is AppConfigScopeType.PUBLIC else data.scope_id, + scope_id=data.scope_id, config=data.config, created_at=data.created_at, updated_at=data.updated_at, diff --git a/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py b/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py index ddd97e79b56..30d0d27a848 100644 --- a/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py +++ b/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py @@ -19,13 +19,13 @@ ) from ai.backend.common.exception import BackendAISchemaValidationFailed -_SCOPE_ID = "11111111-1111-1111-1111-111111111111" +_SCOPE_ID = uuid.UUID("11111111-1111-1111-1111-111111111111") @dataclass(frozen=True) class _ScopeCase: scope_type: AppConfigScopeType - scope_id: str | None + scope_id: uuid.UUID | None @pytest.fixture @@ -63,13 +63,11 @@ def test_scope_id_matching_its_scope_type_is_accepted( [ # public has no owner, so naming one is a contradiction. _ScopeCase(scope_type=AppConfigScopeType.PUBLIC, scope_id=_SCOPE_ID), - # domain and user both require an owner; absent and blank are equally unusable. + # domain and user both require an owner. _ScopeCase(scope_type=AppConfigScopeType.DOMAIN, scope_id=None), - _ScopeCase(scope_type=AppConfigScopeType.DOMAIN, scope_id=""), _ScopeCase(scope_type=AppConfigScopeType.USER, scope_id=None), - _ScopeCase(scope_type=AppConfigScopeType.USER, scope_id=""), ], - ids=lambda case: f"{case.scope_type.value}-{case.scope_id!r}", + ids=lambda case: f"{case.scope_type.value}-{case.scope_id}", ) def test_scope_id_disagreeing_with_its_scope_type_is_rejected( self, case: _ScopeCase, config_document: dict[str, Any] @@ -82,6 +80,25 @@ def test_scope_id_disagreeing_with_its_scope_type_is_rejected( config=config_document, ) + @pytest.mark.parametrize( + "scope_type", + [AppConfigScopeType.DOMAIN, AppConfigScopeType.USER], + ids=lambda scope_type: scope_type.value, + ) + @pytest.mark.parametrize("scope_id", ["", "not-a-uuid"], ids=["empty", "malformed"]) + def test_scope_id_that_is_not_a_uuid_is_rejected( + self, scope_type: AppConfigScopeType, scope_id: str, config_document: dict[str, Any] + ) -> None: + # Validated from a mapping because the field is typed UUID: a string only ever + # reaches it from a request payload, never from a type-checked call. + with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): + CreateAppConfigFragmentInput.model_validate({ + "config_name": "theme", + "scope_type": scope_type, + "scope_id": scope_id, + "config": config_document, + }) + def test_scope_id_defaults_to_none(self, config_document: dict[str, Any]) -> None: req = CreateAppConfigFragmentInput( config_name="theme", From 052dfecfdc4c4081871a98b20b69ac78d1ecb046 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Tue, 21 Jul 2026 14:44:06 +0900 Subject: [PATCH 5/7] fix(BA-6921): address REST v2 review feedback - Report a failed bulk item by the fragment id it targeted rather than its batch position. Every bulk item names its own fragment, so the id is what the caller can act on; an index makes them correlate the failure back by hand. - Declare _adapter at class scope, per the class-field rule in the root AGENTS.md. - Drop the FK detail from the config_name description. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/manager/rest-reference/openapi.json | 2 +- .../common/dto/manager/v2/app_config_fragment/request.py | 2 +- .../common/dto/manager/v2/app_config_fragment/response.py | 6 +++--- .../manager/api/adapters/app_config_fragment/adapter.py | 4 ++-- .../manager/api/rest/v2/app_config_fragment/handler.py | 2 ++ src/ai/backend/manager/data/app_config_fragment/types.py | 8 ++++++-- .../app_config_fragment/db_source/db_source.py | 7 +++++-- .../repositories/app_config_fragment/test_repository.py | 8 ++++---- 8 files changed, 24 insertions(+), 15 deletions(-) diff --git a/docs/manager/rest-reference/openapi.json b/docs/manager/rest-reference/openapi.json index dc2da8c0ef5..78aa184bac5 100644 --- a/docs/manager/rest-reference/openapi.json +++ b/docs/manager/rest-reference/openapi.json @@ -561,7 +561,7 @@ "description": "Input for creating a new app config fragment at a given scope.", "properties": { "config_name": { - "description": "Registered config name (FK to app_config_definitions).", + "description": "Registered config name.", "maxLength": 128, "minLength": 1, "title": "Config Name", diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py index e59848757bf..26982fbdd1a 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py @@ -25,7 +25,7 @@ class CreateAppConfigFragmentInput(BaseRequestModel): config_name: str = Field( min_length=1, max_length=128, - description="Registered config name (FK to app_config_definitions).", + description="Registered config name.", ) scope_type: AppConfigScopeType = Field( description="Scope the fragment is written at (public | domain | user)." diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py index edbeef14011..c3d74c363d6 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py @@ -57,7 +57,7 @@ class PurgeAppConfigFragmentPayload(BaseResponseModel): class AppConfigFragmentBulkErrorInfo(BaseResponseModel): """One failed item of a partial-success bulk mutation.""" - index: int = Field(description="Zero-based index of the failed item in the request batch.") + id: UUID = Field(description="Id of the fragment the failed item targeted.") message: str = Field(description="Reason the item failed.") @@ -66,7 +66,7 @@ class BulkUpdateAppConfigFragmentPayload(BaseResponseModel): succeeded: list[AppConfigFragmentNode] = Field(description="Successfully updated fragments.") failed: list[AppConfigFragmentBulkErrorInfo] = Field( - description="Per-item failures with their batch index." + description="Per-item failures, each naming the fragment it targeted." ) @@ -75,5 +75,5 @@ class BulkPurgeAppConfigFragmentPayload(BaseResponseModel): purged_ids: list[UUID] = Field(description="Ids of successfully purged fragments.") failed: list[AppConfigFragmentBulkErrorInfo] = Field( - description="Per-item failures with their batch index." + description="Per-item failures, each naming the fragment it targeted." ) diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py index ae0f127899f..2073e36b2ce 100644 --- a/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py +++ b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py @@ -124,7 +124,7 @@ async def bulk_update( return BulkUpdateAppConfigFragmentPayload( succeeded=[self._fragment_to_node(fragment) for fragment in action_result.succeeded], failed=[ - AppConfigFragmentBulkErrorInfo(index=error.index, message=error.message) + AppConfigFragmentBulkErrorInfo(id=error.id, message=error.message) for error in action_result.failed ], ) @@ -142,7 +142,7 @@ async def bulk_purge( return BulkPurgeAppConfigFragmentPayload( purged_ids=[fragment.id for fragment in action_result.succeeded], failed=[ - AppConfigFragmentBulkErrorInfo(index=error.index, message=error.message) + AppConfigFragmentBulkErrorInfo(id=error.id, message=error.message) for error in action_result.failed ], ) diff --git a/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py b/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py index 819c4afc34f..3f1c96f134f 100644 --- a/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py +++ b/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py @@ -28,6 +28,8 @@ class V2AppConfigFragmentHandler: """REST v2 handler for raw app config fragment operations.""" + _adapter: AppConfigFragmentAdapter + def __init__(self, *, adapter: AppConfigFragmentAdapter) -> None: self._adapter = adapter diff --git a/src/ai/backend/manager/data/app_config_fragment/types.py b/src/ai/backend/manager/data/app_config_fragment/types.py index dfc79f78b79..e5e48c78795 100644 --- a/src/ai/backend/manager/data/app_config_fragment/types.py +++ b/src/ai/backend/manager/data/app_config_fragment/types.py @@ -34,9 +34,13 @@ class AppConfigFragmentSearchResult: @dataclass(frozen=True) class AppConfigFragmentBulkItemError: - """One failed item of a partial bulk mutation: its batch position and a reason.""" + """One failed item of a partial bulk mutation: the fragment it targeted and a reason. - index: int + Every bulk item names its own fragment, so the id is what the caller can act on — a + batch position would make them correlate the failure back by hand. + """ + + id: AppConfigFragmentID message: str diff --git a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py index 84ecb8ad724..273bd2ab73a 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Sequence +from typing import cast import sqlalchemy as sa @@ -138,7 +139,9 @@ async def bulk_update( # A missing PK is skipped by the partial op (no row, no error); report as not-found. failed = [ AppConfigFragmentBulkItemError( - index=index, + # Updater types pk_value generically; this repository only ever + # builds fragment updaters. + id=cast(AppConfigFragmentID, updater.pk_value), message=errors_by_index.get( index, f"App config fragment {updater.pk_value} not found" ), @@ -170,7 +173,7 @@ async def bulk_purge( # A missing PK is skipped by the partial op (no row, no error); report as not-found. failed = [ AppConfigFragmentBulkItemError( - index=index, + id=spec.fragment_id, message=errors_by_index.get( index, f"App config fragment {spec.fragment_id} not found" ), diff --git a/tests/unit/manager/repositories/app_config_fragment/test_repository.py b/tests/unit/manager/repositories/app_config_fragment/test_repository.py index 493f9925b36..822be6f6385 100644 --- a/tests/unit/manager/repositories/app_config_fragment/test_repository.py +++ b/tests/unit/manager/repositories/app_config_fragment/test_repository.py @@ -535,9 +535,9 @@ async def test_partial_when_one_missing( pk_value=missing_id, # missing -> reported ), ]) - # partial: the existing fragment is updated; the missing one (index 1) is reported + # partial: the existing fragment is updated; the missing one is reported by its id assert [u.config for u in result.succeeded] == [{"x": 1}] - assert [f.index for f in result.failed] == [1] + assert [f.id for f in result.failed] == [missing_id] assert (await repository.get_by_id(two_fragments[0].id)).config == {"x": 1} @@ -566,9 +566,9 @@ async def test_partial_when_one_missing( AppConfigFragmentPurgerSpec(fragment_id=two_fragments[0].id), AppConfigFragmentPurgerSpec(fragment_id=missing_id), # missing -> reported ]) - # partial: the existing fragment is purged; the missing one (index 1) is reported + # partial: the existing fragment is purged; the missing one is reported by its id assert [p.id for p in result.succeeded] == [two_fragments[0].id] - assert [f.index for f in result.failed] == [1] + assert [f.id for f in result.failed] == [missing_id] with pytest.raises(AppConfigFragmentNotFound): await repository.get_by_id(two_fragments[0].id) From 51eaa4b0d621c6824e50afa7d4fb2d4c28f1972e Mon Sep 17 00:00:00 2001 From: Gyubong Date: Tue, 21 Jul 2026 16:39:05 +0900 Subject: [PATCH 6/7] fix(BA-6921): read scope_id as the owner kind its scope_type names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request body carries scope_id as a plain UUID, but the creator spec now takes an AppConfigScopeIdentifier — only scope_type says whether that UUID is a domain or a user. The adapter is the boundary that knows both, so it does the reading. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adapters/app_config_fragment/adapter.py | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py index 2073e36b2ce..c29ae0ab4d3 100644 --- a/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py +++ b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py @@ -2,6 +2,8 @@ from __future__ import annotations +from uuid import UUID + from ai.backend.common.data.app_config.types import AppConfigScopeType from ai.backend.common.data.app_config.types import AppConfigScopeType as AppConfigScopeTypeDTO from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( @@ -19,11 +21,15 @@ PurgeAppConfigFragmentPayload, UpdateAppConfigFragmentPayload, ) +from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID +from ai.backend.common.identifier.domain import DomainID +from ai.backend.common.identifier.user import UserID from ai.backend.manager.api.adapters.base import BaseAdapter from ai.backend.manager.data.app_config_fragment.types import ( AppConfigFragmentData, ) +from ai.backend.manager.errors.api import InvalidAPIParameters from ai.backend.manager.repositories.app_config_fragment.creators import ( AppConfigFragmentCreatorSpec, ) @@ -57,18 +63,35 @@ from ai.backend.manager.types import OptionalState +def _scope_owner(scope_type: AppConfigScopeType, scope_id: UUID | None) -> AppConfigScopeIdentifier: + """Read a request's raw ``scope_id`` as the kind of owner its ``scope_type`` calls for. + + A request body carries ``scope_id`` as a plain UUID; only ``scope_type`` says whether it + names a domain or a user. The DTO validator already rejects the mismatched combinations, + so the final branch is a boundary guard rather than a reachable path. + """ + match scope_type, scope_id: + case AppConfigScopeType.PUBLIC, None: + return None + case AppConfigScopeType.DOMAIN, UUID() as owner: + return DomainID(owner) + case AppConfigScopeType.USER, UUID() as owner: + return UserID(owner) + case _: + raise InvalidAPIParameters(f"scope_id does not match the {scope_type.value} scope.") + + class AppConfigFragmentAdapter(BaseAdapter): """Adapter for raw app config fragment write operations.""" # --- fragment CRUD (RBAC-gated at the processor) --- async def create(self, input: CreateAppConfigFragmentInput) -> CreateAppConfigFragmentPayload: - # scope_id is None exactly for public (enforced by the DTO validator), which is what - # the column stores for an ownerless fragment. + scope_type = AppConfigScopeType(input.scope_type.value) spec = AppConfigFragmentCreatorSpec( config_name=input.config_name, - scope_type=AppConfigScopeType(input.scope_type.value), - scope_id=input.scope_id, + scope_type=scope_type, + scope_id=_scope_owner(scope_type, input.scope_id), config=input.config, ) action_result = await self._processors.app_config_fragment.create.wait_for_complete( From 2ff4f12ce8dad53ba37cf6bdaf307cf3544c61f1 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Tue, 21 Jul 2026 17:02:45 +0900 Subject: [PATCH 7/7] test(BA-6921): assert the DTO rejection every request actually gets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseRequestModel only maps a pydantic ValidationError onto BackendAISchemaValidationFailed inside model_validate, and every production entry point — BodyParam, QueryParam, PathParam, headers — parses that way. The raw ValidationError the direct constructor raises is therefore unreachable in service, so accepting either exception let the tests pass on a rejection no caller can observe. Reject through model_validate and assert the one error. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backend/common/data/app_config/types.py | 4 +- .../manager/v2/app_config_fragment/request.py | 9 +-- .../v2/app_config_fragment/response.py | 15 ++--- .../backend/common/identifier/app_config.py | 4 +- .../adapters/app_config_fragment/adapter.py | 42 +++---------- .../manager/data/app_config_fragment/types.py | 4 +- .../manager/models/app_config_fragment/row.py | 6 +- .../app_config_fragment/creators.py | 4 +- .../db_source/db_source.py | 2 - .../v2/app_config_fragment/test_request.py | 63 +++++++++---------- .../app_config_fragment/test_repository.py | 18 +++--- .../services/app_config/test_service.py | 10 +-- .../app_config_fragment/test_create_action.py | 10 +-- .../app_config_fragment/test_service.py | 8 +-- 14 files changed, 85 insertions(+), 114 deletions(-) diff --git a/src/ai/backend/common/data/app_config/types.py b/src/ai/backend/common/data/app_config/types.py index 9a30ecb3eb0..02e4a0adb99 100644 --- a/src/ai/backend/common/data/app_config/types.py +++ b/src/ai/backend/common/data/app_config/types.py @@ -5,7 +5,7 @@ import enum from ai.backend.common.data.permission.types import RBACElementType, ScopeType -from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier +from ai.backend.common.identifier.app_config import AppConfigScopeID __all__ = ("AppConfigScopeType",) @@ -50,7 +50,7 @@ def to_rbac_element_type(self) -> RBACElementType | None: case AppConfigScopeType.USER: return RBACElementType.USER - def to_rbac_scope_id(self, scope_id: AppConfigScopeIdentifier | None) -> str: + def to_rbac_scope_id(self, scope_id: AppConfigScopeID | None) -> str: """The RBAC scope id for a write at this fragment scope, in RBAC's string form. ``public`` is system-wide and names no owner. diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py index 26982fbdd1a..3ab061ce0c1 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py @@ -3,12 +3,13 @@ from __future__ import annotations from typing import Any, Self -from uuid import UUID from pydantic import Field, model_validator from ai.backend.common.api_handlers import BaseRequestModel from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.identifier.app_config import AppConfigScopeID +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID __all__ = ( "AppConfigFragmentUpdateItem", @@ -30,7 +31,7 @@ class CreateAppConfigFragmentInput(BaseRequestModel): scope_type: AppConfigScopeType = Field( description="Scope the fragment is written at (public | domain | user)." ) - scope_id: UUID | None = Field( + scope_id: AppConfigScopeID | None = Field( default=None, description="Scope identifier: the domain id (domain scope) or the user id (user scope). " "Null for public scope, which has no owner.", @@ -63,7 +64,7 @@ class AppConfigFragmentUpdateItem(BaseRequestModel): here — unlike the single-fragment :class:`UpdateAppConfigFragmentInput`. """ - id: UUID = Field(description="App config fragment id to update.") + id: AppConfigFragmentID = Field(description="App config fragment id to update.") config: dict[str, Any] = Field(description="The replacement JSON config document.") @@ -78,4 +79,4 @@ class BulkUpdateAppConfigFragmentInput(BaseRequestModel): class BulkPurgeAppConfigFragmentInput(BaseRequestModel): """Input for purging many fragments (per-item partial success).""" - ids: list[UUID] = Field(min_length=1, description="Fragment ids to purge.") + ids: list[AppConfigFragmentID] = Field(min_length=1, description="Fragment ids to purge.") diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py index c3d74c363d6..d68290cec1e 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py @@ -4,12 +4,13 @@ from datetime import datetime from typing import Any -from uuid import UUID from pydantic import Field from ai.backend.common.api_handlers import BaseResponseModel from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.identifier.app_config import AppConfigScopeID +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID __all__ = ( "AppConfigFragmentBulkErrorInfo", @@ -25,10 +26,10 @@ class AppConfigFragmentNode(BaseResponseModel): """Node model representing one app config fragment.""" - id: UUID = Field(description="App config fragment UUID.") + id: AppConfigFragmentID = Field(description="App config fragment id.") config_name: str = Field(description="Config name the fragment belongs to.") scope_type: AppConfigScopeType = Field(description="Scope the fragment is written at.") - scope_id: UUID | None = Field( + scope_id: AppConfigScopeID | None = Field( description="Scope identifier: the domain id or user id; null for public scope." ) config: dict[str, Any] = Field(description="The fragment's JSON config document.") @@ -51,20 +52,20 @@ class UpdateAppConfigFragmentPayload(BaseResponseModel): class PurgeAppConfigFragmentPayload(BaseResponseModel): """Payload for app config fragment purge.""" - id: UUID = Field(description="UUID of the purged app config fragment.") + id: AppConfigFragmentID = Field(description="Id of the purged app config fragment.") class AppConfigFragmentBulkErrorInfo(BaseResponseModel): """One failed item of a partial-success bulk mutation.""" - id: UUID = Field(description="Id of the fragment the failed item targeted.") + id: AppConfigFragmentID = Field(description="Id of the fragment the failed item targeted.") message: str = Field(description="Reason the item failed.") class BulkUpdateAppConfigFragmentPayload(BaseResponseModel): """Partial-success payload for a bulk fragment update.""" - succeeded: list[AppConfigFragmentNode] = Field(description="Successfully updated fragments.") + items: list[AppConfigFragmentNode] = Field(description="Successfully updated fragments.") failed: list[AppConfigFragmentBulkErrorInfo] = Field( description="Per-item failures, each naming the fragment it targeted." ) @@ -73,7 +74,7 @@ class BulkUpdateAppConfigFragmentPayload(BaseResponseModel): class BulkPurgeAppConfigFragmentPayload(BaseResponseModel): """Partial-success payload for a bulk fragment purge.""" - purged_ids: list[UUID] = Field(description="Ids of successfully purged fragments.") + items: list[AppConfigFragmentID] = Field(description="Ids of successfully purged fragments.") failed: list[AppConfigFragmentBulkErrorInfo] = Field( description="Per-item failures, each naming the fragment it targeted." ) diff --git a/src/ai/backend/common/identifier/app_config.py b/src/ai/backend/common/identifier/app_config.py index 76855dbc9f8..ab882e97847 100644 --- a/src/ai/backend/common/identifier/app_config.py +++ b/src/ai/backend/common/identifier/app_config.py @@ -1,10 +1,10 @@ from typing import NewType from uuid import UUID -__all__ = ("AppConfigScopeIdentifier",) +__all__ = ("AppConfigScopeID",) # Who an app config fragment belongs to. Polymorphic across scope kinds (domain/user); the # concrete kind is discriminated by the accompanying ``AppConfigScopeType``, and ``public`` # has no owner at all, so its absence is spelled ``| None`` at each use. -AppConfigScopeIdentifier = NewType("AppConfigScopeIdentifier", UUID) +AppConfigScopeID = NewType("AppConfigScopeID", UUID) diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py index c29ae0ab4d3..595609c9dd7 100644 --- a/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py +++ b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py @@ -2,8 +2,6 @@ from __future__ import annotations -from uuid import UUID - from ai.backend.common.data.app_config.types import AppConfigScopeType from ai.backend.common.data.app_config.types import AppConfigScopeType as AppConfigScopeTypeDTO from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( @@ -21,15 +19,11 @@ PurgeAppConfigFragmentPayload, UpdateAppConfigFragmentPayload, ) -from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID -from ai.backend.common.identifier.domain import DomainID -from ai.backend.common.identifier.user import UserID from ai.backend.manager.api.adapters.base import BaseAdapter from ai.backend.manager.data.app_config_fragment.types import ( AppConfigFragmentData, ) -from ai.backend.manager.errors.api import InvalidAPIParameters from ai.backend.manager.repositories.app_config_fragment.creators import ( AppConfigFragmentCreatorSpec, ) @@ -63,35 +57,16 @@ from ai.backend.manager.types import OptionalState -def _scope_owner(scope_type: AppConfigScopeType, scope_id: UUID | None) -> AppConfigScopeIdentifier: - """Read a request's raw ``scope_id`` as the kind of owner its ``scope_type`` calls for. - - A request body carries ``scope_id`` as a plain UUID; only ``scope_type`` says whether it - names a domain or a user. The DTO validator already rejects the mismatched combinations, - so the final branch is a boundary guard rather than a reachable path. - """ - match scope_type, scope_id: - case AppConfigScopeType.PUBLIC, None: - return None - case AppConfigScopeType.DOMAIN, UUID() as owner: - return DomainID(owner) - case AppConfigScopeType.USER, UUID() as owner: - return UserID(owner) - case _: - raise InvalidAPIParameters(f"scope_id does not match the {scope_type.value} scope.") - - class AppConfigFragmentAdapter(BaseAdapter): """Adapter for raw app config fragment write operations.""" # --- fragment CRUD (RBAC-gated at the processor) --- async def create(self, input: CreateAppConfigFragmentInput) -> CreateAppConfigFragmentPayload: - scope_type = AppConfigScopeType(input.scope_type.value) spec = AppConfigFragmentCreatorSpec( config_name=input.config_name, - scope_type=scope_type, - scope_id=_scope_owner(scope_type, input.scope_id), + scope_type=AppConfigScopeType(input.scope_type.value), + scope_id=input.scope_id, config=input.config, ) action_result = await self._processors.app_config_fragment.create.wait_for_complete( @@ -114,8 +89,7 @@ async def update( spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update(input.config)), pk_value=fragment_id, ) - # No allow-list gate: a fragment row exists only while its entry does (FK with - # cascade), so an existing fragment is always writable at its own scope. + # No allow-list gate: the entry's FK cascade means an existing fragment is writable. action_result = await self._processors.app_config_fragment.update.wait_for_complete( UpdateAppConfigFragmentAction(updater=updater) ) @@ -125,7 +99,6 @@ async def update( async def purge(self, fragment_id: AppConfigFragmentID) -> PurgeAppConfigFragmentPayload: purger_spec = AppConfigFragmentPurgerSpec(fragment_id=fragment_id) - # No allow-list gate — see ``update``. action_result = await self._processors.app_config_fragment.purge.wait_for_complete( PurgeAppConfigFragmentAction(purger_spec=purger_spec) ) @@ -137,7 +110,7 @@ async def bulk_update( updaters = [ Updater( spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update(item.config)), - pk_value=AppConfigFragmentID(item.id), + pk_value=item.id, ) for item in input.items ] @@ -145,7 +118,7 @@ async def bulk_update( BulkUpdateAppConfigFragmentAction(updaters=updaters) ) return BulkUpdateAppConfigFragmentPayload( - succeeded=[self._fragment_to_node(fragment) for fragment in action_result.succeeded], + items=[self._fragment_to_node(fragment) for fragment in action_result.succeeded], failed=[ AppConfigFragmentBulkErrorInfo(id=error.id, message=error.message) for error in action_result.failed @@ -156,14 +129,13 @@ async def bulk_purge( self, input: BulkPurgeAppConfigFragmentInput ) -> BulkPurgeAppConfigFragmentPayload: purger_specs = [ - AppConfigFragmentPurgerSpec(fragment_id=AppConfigFragmentID(fragment_id)) - for fragment_id in input.ids + AppConfigFragmentPurgerSpec(fragment_id=fragment_id) for fragment_id in input.ids ] action_result = await self._processors.app_config_fragment.bulk_purge.wait_for_complete( BulkPurgeAppConfigFragmentAction(purger_specs=purger_specs) ) return BulkPurgeAppConfigFragmentPayload( - purged_ids=[fragment.id for fragment in action_result.succeeded], + items=[fragment.id for fragment in action_result.succeeded], failed=[ AppConfigFragmentBulkErrorInfo(id=error.id, message=error.message) for error in action_result.failed diff --git a/src/ai/backend/manager/data/app_config_fragment/types.py b/src/ai/backend/manager/data/app_config_fragment/types.py index e5e48c78795..6a0af20d77e 100644 --- a/src/ai/backend/manager/data/app_config_fragment/types.py +++ b/src/ai/backend/manager/data/app_config_fragment/types.py @@ -5,7 +5,7 @@ from typing import Any from ai.backend.common.data.app_config.types import AppConfigScopeType -from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier +from ai.backend.common.identifier.app_config import AppConfigScopeID from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID @@ -16,7 +16,7 @@ class AppConfigFragmentData: id: AppConfigFragmentID config_name: str scope_type: AppConfigScopeType - scope_id: AppConfigScopeIdentifier | None + scope_id: AppConfigScopeID | None config: dict[str, Any] created_at: datetime updated_at: datetime diff --git a/src/ai/backend/manager/models/app_config_fragment/row.py b/src/ai/backend/manager/models/app_config_fragment/row.py index 3205c018ebb..5a17ab5601e 100644 --- a/src/ai/backend/manager/models/app_config_fragment/row.py +++ b/src/ai/backend/manager/models/app_config_fragment/row.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column from ai.backend.common.data.app_config.types import AppConfigScopeType -from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier +from ai.backend.common.identifier.app_config import AppConfigScopeID from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.manager.data.app_config_fragment.types import ( AppConfigFragmentData, @@ -70,9 +70,9 @@ class AppConfigFragmentRow(LifecycleTimestampsMixin, Base): # type: ignore[misc nullable=False, ) # NULL is public, which has no owner; domain and user carry their owner's id. - scope_id: Mapped[AppConfigScopeIdentifier | None] = mapped_column( + scope_id: Mapped[AppConfigScopeID | None] = mapped_column( "scope_id", - GUID(AppConfigScopeIdentifier), + GUID(AppConfigScopeID), nullable=True, ) config: Mapped[dict[str, Any]] = mapped_column( diff --git a/src/ai/backend/manager/repositories/app_config_fragment/creators.py b/src/ai/backend/manager/repositories/app_config_fragment/creators.py index d7f5c503527..fbb821c6a33 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/creators.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/creators.py @@ -7,7 +7,7 @@ from typing import Any, override from ai.backend.common.data.app_config.types import AppConfigScopeType -from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier +from ai.backend.common.identifier.app_config import AppConfigScopeID from ai.backend.manager.errors.app_config import AppConfigFragmentWriteNotAllowed from ai.backend.manager.errors.repository import ForeignKeyViolationError from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow @@ -24,7 +24,7 @@ class AppConfigFragmentCreatorSpec(CreatorSpec[AppConfigFragmentRow]): config_name: str scope_type: AppConfigScopeType - scope_id: AppConfigScopeIdentifier | None + scope_id: AppConfigScopeID | None config: dict[str, Any] @property diff --git a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py index 273bd2ab73a..e3c7733df53 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py @@ -139,8 +139,6 @@ async def bulk_update( # A missing PK is skipped by the partial op (no row, no error); report as not-found. failed = [ AppConfigFragmentBulkItemError( - # Updater types pk_value generically; this repository only ever - # builds fragment updaters. id=cast(AppConfigFragmentID, updater.pk_value), message=errors_by_index.get( index, f"App config fragment {updater.pk_value} not found" diff --git a/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py b/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py index 30d0d27a848..2c68d9ade08 100644 --- a/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py +++ b/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py @@ -7,7 +7,6 @@ from typing import Any import pytest -from pydantic import ValidationError from ai.backend.common.data.app_config.types import AppConfigScopeType from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( @@ -18,14 +17,16 @@ UpdateAppConfigFragmentInput, ) from ai.backend.common.exception import BackendAISchemaValidationFailed +from ai.backend.common.identifier.app_config import AppConfigScopeID +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID -_SCOPE_ID = uuid.UUID("11111111-1111-1111-1111-111111111111") +_SCOPE_ID = AppConfigScopeID(uuid.UUID("11111111-1111-1111-1111-111111111111")) @dataclass(frozen=True) class _ScopeCase: scope_type: AppConfigScopeType - scope_id: uuid.UUID | None + scope_id: AppConfigScopeID | None @pytest.fixture @@ -61,9 +62,7 @@ def test_scope_id_matching_its_scope_type_is_accepted( @pytest.mark.parametrize( "case", [ - # public has no owner, so naming one is a contradiction. _ScopeCase(scope_type=AppConfigScopeType.PUBLIC, scope_id=_SCOPE_ID), - # domain and user both require an owner. _ScopeCase(scope_type=AppConfigScopeType.DOMAIN, scope_id=None), _ScopeCase(scope_type=AppConfigScopeType.USER, scope_id=None), ], @@ -72,13 +71,13 @@ def test_scope_id_matching_its_scope_type_is_accepted( def test_scope_id_disagreeing_with_its_scope_type_is_rejected( self, case: _ScopeCase, config_document: dict[str, Any] ) -> None: - with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): - CreateAppConfigFragmentInput( - config_name="theme", - scope_type=case.scope_type, - scope_id=case.scope_id, - config=config_document, - ) + with pytest.raises(BackendAISchemaValidationFailed): + CreateAppConfigFragmentInput.model_validate({ + "config_name": "theme", + "scope_type": case.scope_type, + "scope_id": case.scope_id, + "config": config_document, + }) @pytest.mark.parametrize( "scope_type", @@ -89,9 +88,7 @@ def test_scope_id_disagreeing_with_its_scope_type_is_rejected( def test_scope_id_that_is_not_a_uuid_is_rejected( self, scope_type: AppConfigScopeType, scope_id: str, config_document: dict[str, Any] ) -> None: - # Validated from a mapping because the field is typed UUID: a string only ever - # reaches it from a request payload, never from a type-checked call. - with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): + with pytest.raises(BackendAISchemaValidationFailed): CreateAppConfigFragmentInput.model_validate({ "config_name": "theme", "scope_type": scope_type, @@ -112,12 +109,12 @@ def test_scope_id_defaults_to_none(self, config_document: dict[str, Any]) -> Non def test_config_name_outside_its_length_bounds_is_rejected( self, config_name: str, config_document: dict[str, Any] ) -> None: - with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): - CreateAppConfigFragmentInput( - config_name=config_name, - scope_type=AppConfigScopeType.PUBLIC, - config=config_document, - ) + with pytest.raises(BackendAISchemaValidationFailed): + CreateAppConfigFragmentInput.model_validate({ + "config_name": config_name, + "scope_type": AppConfigScopeType.PUBLIC, + "config": config_document, + }) class TestUpdateAppConfigFragmentInput: @@ -130,9 +127,7 @@ def test_config_alone_is_a_complete_body(self, config_document: dict[str, Any]) assert not hasattr(req, "id") def test_config_is_required(self) -> None: - # Validated from a mapping rather than the constructor: a body arriving without - # ``config`` is a runtime payload, not a call the type checker would ever allow. - with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): + with pytest.raises(BackendAISchemaValidationFailed): UpdateAppConfigFragmentInput.model_validate({}) @@ -140,7 +135,7 @@ class TestAppConfigFragmentUpdateItem: """The bulk item does carry an id, since one request addresses many fragments.""" def test_id_and_config_are_both_required(self, config_document: dict[str, Any]) -> None: - fragment_id = uuid.uuid4() + fragment_id = AppConfigFragmentID(uuid.uuid4()) item = AppConfigFragmentUpdateItem(id=fragment_id, config=config_document) @@ -148,7 +143,7 @@ def test_id_and_config_are_both_required(self, config_document: dict[str, Any]) assert item.config == config_document def test_omitting_id_is_rejected(self, config_document: dict[str, Any]) -> None: - with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): + with pytest.raises(BackendAISchemaValidationFailed): AppConfigFragmentUpdateItem.model_validate({"config": config_document}) @@ -157,22 +152,26 @@ class TestBulkAppConfigFragmentInputs: def test_bulk_update_accepts_items(self, config_document: dict[str, Any]) -> None: req = BulkUpdateAppConfigFragmentInput( - items=[AppConfigFragmentUpdateItem(id=uuid.uuid4(), config=config_document)] + items=[ + AppConfigFragmentUpdateItem( + id=AppConfigFragmentID(uuid.uuid4()), config=config_document + ) + ] ) assert len(req.items) == 1 def test_bulk_update_rejects_an_empty_batch(self) -> None: - with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): - BulkUpdateAppConfigFragmentInput(items=[]) + with pytest.raises(BackendAISchemaValidationFailed): + BulkUpdateAppConfigFragmentInput.model_validate({"items": []}) def test_bulk_purge_accepts_ids(self) -> None: - fragment_id = uuid.uuid4() + fragment_id = AppConfigFragmentID(uuid.uuid4()) req = BulkPurgeAppConfigFragmentInput(ids=[fragment_id]) assert req.ids == [fragment_id] def test_bulk_purge_rejects_an_empty_batch(self) -> None: - with pytest.raises((BackendAISchemaValidationFailed, ValidationError)): - BulkPurgeAppConfigFragmentInput(ids=[]) + with pytest.raises(BackendAISchemaValidationFailed): + BulkPurgeAppConfigFragmentInput.model_validate({"ids": []}) diff --git a/tests/unit/manager/repositories/app_config_fragment/test_repository.py b/tests/unit/manager/repositories/app_config_fragment/test_repository.py index 822be6f6385..d08eca7d3d7 100644 --- a/tests/unit/manager/repositories/app_config_fragment/test_repository.py +++ b/tests/unit/manager/repositories/app_config_fragment/test_repository.py @@ -12,7 +12,7 @@ from ai.backend.common.data.app_config.types import AppConfigScopeType from ai.backend.common.data.filter_specs import UUIDEqualMatchSpec from ai.backend.common.data.permission.types import EntityType, ScopeType -from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier +from ai.backend.common.identifier.app_config import AppConfigScopeID from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.common.identifier.domain import DomainID from ai.backend.common.identifier.user import UserID @@ -66,10 +66,10 @@ _OTHER_USER_ID = UserID(uuid.uuid4()) # The same owners seen as a fragment's scope_id, which is polymorphic over scope kinds. -_DOMAIN_SCOPE_ID = AppConfigScopeIdentifier(_DOMAIN_ID) -_USER_SCOPE_ID = AppConfigScopeIdentifier(_USER_ID) -_OTHER_DOMAIN_SCOPE_ID = AppConfigScopeIdentifier(_OTHER_DOMAIN_ID) -_OTHER_USER_SCOPE_ID = AppConfigScopeIdentifier(_OTHER_USER_ID) +_DOMAIN_SCOPE_ID = AppConfigScopeID(_DOMAIN_ID) +_USER_SCOPE_ID = AppConfigScopeID(_USER_ID) +_OTHER_DOMAIN_SCOPE_ID = AppConfigScopeID(_OTHER_DOMAIN_ID) +_OTHER_USER_SCOPE_ID = AppConfigScopeID(_OTHER_USER_ID) @pytest.fixture @@ -149,7 +149,7 @@ async def fragment_at_every_scope( database: ExtendedAsyncSAEngine, theme_registered: None ) -> dict[AppConfigScopeType, AppConfigFragmentData]: """Situation: ``theme`` already holds one fragment at each scope, keyed by that scope.""" - owners: dict[AppConfigScopeType, AppConfigScopeIdentifier | None] = { + owners: dict[AppConfigScopeType, AppConfigScopeID | None] = { AppConfigScopeType.PUBLIC: None, AppConfigScopeType.DOMAIN: _DOMAIN_SCOPE_ID, AppConfigScopeType.USER: _USER_SCOPE_ID, @@ -535,7 +535,7 @@ async def test_partial_when_one_missing( pk_value=missing_id, # missing -> reported ), ]) - # partial: the existing fragment is updated; the missing one is reported by its id + # partial: the missing fragment is reported by its id assert [u.config for u in result.succeeded] == [{"x": 1}] assert [f.id for f in result.failed] == [missing_id] assert (await repository.get_by_id(two_fragments[0].id)).config == {"x": 1} @@ -566,7 +566,7 @@ async def test_partial_when_one_missing( AppConfigFragmentPurgerSpec(fragment_id=two_fragments[0].id), AppConfigFragmentPurgerSpec(fragment_id=missing_id), # missing -> reported ]) - # partial: the existing fragment is purged; the missing one is reported by its id + # partial: the missing fragment is reported by its id assert [p.id for p in result.succeeded] == [two_fragments[0].id] assert [f.id for f in result.failed] == [missing_id] with pytest.raises(AppConfigFragmentNotFound): @@ -724,7 +724,7 @@ class _FragmentScopeCase: """ scope_type: AppConfigScopeType - scope_id: AppConfigScopeIdentifier | None + scope_id: AppConfigScopeID | None expected_bindings: list[_ScopeBinding] = field(default_factory=list) diff --git a/tests/unit/manager/services/app_config/test_service.py b/tests/unit/manager/services/app_config/test_service.py index ffc5ab065fa..e74adb38584 100644 --- a/tests/unit/manager/services/app_config/test_service.py +++ b/tests/unit/manager/services/app_config/test_service.py @@ -11,7 +11,7 @@ import pytest from ai.backend.common.data.app_config.types import AppConfigScopeType -from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier +from ai.backend.common.identifier.app_config import AppConfigScopeID from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.common.identifier.domain import DomainID from ai.backend.common.identifier.user import UserID @@ -31,13 +31,13 @@ _DOMAIN_ID = DomainID(uuid.uuid4()) # The same owners seen as a fragment's scope_id, which is polymorphic over scope kinds. -_USER_SCOPE_ID = AppConfigScopeIdentifier(_USER_ID) -_DOMAIN_SCOPE_ID = AppConfigScopeIdentifier(_DOMAIN_ID) +_USER_SCOPE_ID = AppConfigScopeID(_USER_ID) +_DOMAIN_SCOPE_ID = AppConfigScopeID(_DOMAIN_ID) _NOW = datetime.now(UTC) _SCOPE_ARGS = AppConfigScopeArguments(domain_id=_DOMAIN_ID) FragmentFactory = Callable[ - [str, dict[str, Any], AppConfigScopeType, AppConfigScopeIdentifier | None], + [str, dict[str, Any], AppConfigScopeType, AppConfigScopeID | None], AppConfigFragmentData, ] @@ -53,7 +53,7 @@ def _make( config_name: str, config: dict[str, Any], scope_type: AppConfigScopeType, - scope_id: AppConfigScopeIdentifier | None, + scope_id: AppConfigScopeID | None, ) -> AppConfigFragmentData: return AppConfigFragmentData( id=AppConfigFragmentID(uuid.uuid4()), diff --git a/tests/unit/manager/services/app_config_fragment/test_create_action.py b/tests/unit/manager/services/app_config_fragment/test_create_action.py index 8a9e82b35fb..1d4b163946d 100644 --- a/tests/unit/manager/services/app_config_fragment/test_create_action.py +++ b/tests/unit/manager/services/app_config_fragment/test_create_action.py @@ -14,7 +14,7 @@ from ai.backend.common.data.app_config.types import AppConfigScopeType from ai.backend.common.data.permission.types import RBACElementType, ScopeType -from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier +from ai.backend.common.identifier.app_config import AppConfigScopeID from ai.backend.common.identifier.domain import DomainID from ai.backend.common.identifier.user import UserID from ai.backend.manager.data.permission.types import RBACElementRef @@ -27,14 +27,14 @@ _VICTIM_USER_ID = UserID(uuid.uuid4()) _DOMAIN_ID = DomainID(uuid.uuid4()) -_VICTIM_USER_SCOPE_ID = AppConfigScopeIdentifier(_VICTIM_USER_ID) -_DOMAIN_SCOPE_ID = AppConfigScopeIdentifier(_DOMAIN_ID) +_VICTIM_USER_SCOPE_ID = AppConfigScopeID(_VICTIM_USER_ID) +_DOMAIN_SCOPE_ID = AppConfigScopeID(_DOMAIN_ID) def _make_action( *, scope_type: AppConfigScopeType, - scope_id: AppConfigScopeIdentifier | None, + scope_id: AppConfigScopeID | None, ) -> CreateAppConfigFragmentAction: return CreateAppConfigFragmentAction( creator_spec=AppConfigFragmentCreatorSpec( @@ -51,7 +51,7 @@ class _ScopeTarget: """A fragment scope, and the RBAC scope a create at it must authorize against.""" scope_type: AppConfigScopeType - scope_id: AppConfigScopeIdentifier | None + scope_id: AppConfigScopeID | None expected_element: RBACElementRef expected_scope_type: ScopeType diff --git a/tests/unit/manager/services/app_config_fragment/test_service.py b/tests/unit/manager/services/app_config_fragment/test_service.py index 6f91383a17f..0001efd211f 100644 --- a/tests/unit/manager/services/app_config_fragment/test_service.py +++ b/tests/unit/manager/services/app_config_fragment/test_service.py @@ -11,7 +11,7 @@ from ai.backend.common.data.app_config.types import AppConfigScopeType from ai.backend.common.data.permission.types import ScopeType -from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier +from ai.backend.common.identifier.app_config import AppConfigScopeID from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.common.identifier.domain import DomainID from ai.backend.common.identifier.user import UserID @@ -71,8 +71,8 @@ _DOMAIN_ID = DomainID(uuid.uuid4()) # The same owners seen as a fragment's scope_id, which is polymorphic over scope kinds. -_USER_SCOPE_ID = AppConfigScopeIdentifier(_USER_ID) -_DOMAIN_SCOPE_ID = AppConfigScopeIdentifier(_DOMAIN_ID) +_USER_SCOPE_ID = AppConfigScopeID(_USER_ID) +_DOMAIN_SCOPE_ID = AppConfigScopeID(_DOMAIN_ID) @dataclass(frozen=True) @@ -84,7 +84,7 @@ class _RBACScopeCase: """ scope_type: AppConfigScopeType - scope_id: AppConfigScopeIdentifier | None + scope_id: AppConfigScopeID | None expected_scope_type: ScopeType expected_scope_id: str