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..78aa184bac5 100644 --- a/docs/manager/rest-reference/openapi.json +++ b/docs/manager/rest-reference/openapi.json @@ -557,6 +557,127 @@ "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.", + "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": [ + { + "format": "uuid", + "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" + }, + "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.", + "format": "uuid", + "title": "Id", + "type": "string" + }, + "config": { + "additionalProperties": true, + "description": "The replacement JSON config document.", + "title": "Config", + "type": "object" + } + }, + "required": [ + "id", + "config" + ], + "title": "AppConfigFragmentUpdateItem", + "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/AppConfigFragmentUpdateItem" + }, + "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" + }, + "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": { @@ -39583,6 +39704,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/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/__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..3ab061ce0c1 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py @@ -0,0 +1,82 @@ +"""Request DTOs for app_config_fragment v2.""" + +from __future__ import annotations + +from typing import Any, Self + +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", + "BulkPurgeAppConfigFragmentInput", + "BulkUpdateAppConfigFragmentInput", + "CreateAppConfigFragmentInput", + "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.", + ) + scope_type: AppConfigScopeType = Field( + description="Scope the fragment is written at (public | domain | user)." + ) + 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.", + ) + 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 self.scope_id is None: + raise ValueError("scope_id is required for domain and user scopes.") + return self + + +class UpdateAppConfigFragmentInput(BaseRequestModel): + """Input for updating one app config fragment's config document. + + The target fragment is identified by the request path, not by this body. + """ + + config: dict[str, Any] = Field(description="The replacement JSON config document.") + + +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: AppConfigFragmentID = 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[AppConfigFragmentUpdateItem] = 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[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 new file mode 100644 index 00000000000..d68290cec1e --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py @@ -0,0 +1,80 @@ +"""Response DTOs for app_config_fragment v2.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +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", + "AppConfigFragmentNode", + "BulkPurgeAppConfigFragmentPayload", + "BulkUpdateAppConfigFragmentPayload", + "CreateAppConfigFragmentPayload", + "PurgeAppConfigFragmentPayload", + "UpdateAppConfigFragmentPayload", +) + + +class AppConfigFragmentNode(BaseResponseModel): + """Node model representing one app config fragment.""" + + 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: 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.") + 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: AppConfigFragmentID = Field(description="Id of the purged app config fragment.") + + +class AppConfigFragmentBulkErrorInfo(BaseResponseModel): + """One failed item of a partial-success bulk mutation.""" + + 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.""" + + items: list[AppConfigFragmentNode] = Field(description="Successfully updated fragments.") + failed: list[AppConfigFragmentBulkErrorInfo] = Field( + description="Per-item failures, each naming the fragment it targeted." + ) + + +class BulkPurgeAppConfigFragmentPayload(BaseResponseModel): + """Partial-success payload for a bulk fragment purge.""" + + 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/__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..595609c9dd7 --- /dev/null +++ b/src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py @@ -0,0 +1,157 @@ +"""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, + 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 + + +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: + spec = AppConfigFragmentCreatorSpec( + config_name=input.config_name, + 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( + 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, fragment_id: AppConfigFragmentID, input: UpdateAppConfigFragmentInput + ) -> UpdateAppConfigFragmentPayload: + updater = Updater( + spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update(input.config)), + pk_value=fragment_id, + ) + # 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) + ) + return UpdateAppConfigFragmentPayload( + app_config_fragment=self._fragment_to_node(action_result.fragment), + ) + + async def purge(self, fragment_id: AppConfigFragmentID) -> PurgeAppConfigFragmentPayload: + purger_spec = AppConfigFragmentPurgerSpec(fragment_id=fragment_id) + 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=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( + 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 + ], + ) + + async def bulk_purge( + self, input: BulkPurgeAppConfigFragmentInput + ) -> BulkPurgeAppConfigFragmentPayload: + purger_specs = [ + 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( + items=[fragment.id for fragment in action_result.succeeded], + failed=[ + AppConfigFragmentBulkErrorInfo(id=error.id, 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), + scope_id=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..3f1c96f134f --- /dev/null +++ b/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py @@ -0,0 +1,85 @@ +"""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, + 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.""" + + _adapter: AppConfigFragmentAdapter + + 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).""" + 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( + self, + path: PathParam[AppConfigFragmentIdPathParam], + ) -> APIResponse: + """Purge a fragment by id (auth required, RBAC-gated).""" + result = await self._adapter.purge(AppConfigFragmentID(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) ) 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..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 @@ -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/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 84ecb8ad724..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 @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Sequence +from typing import cast import sqlalchemy as sa @@ -138,7 +139,7 @@ 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, + id=cast(AppConfigFragmentID, updater.pk_value), message=errors_by_index.get( index, f"App config fragment {updater.pk_value} not found" ), @@ -170,7 +171,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/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..2c68d9ade08 --- /dev/null +++ b/tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py @@ -0,0 +1,177 @@ +"""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 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 +from ai.backend.common.identifier.app_config import AppConfigScopeID +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID + +_SCOPE_ID = AppConfigScopeID(uuid.UUID("11111111-1111-1111-1111-111111111111")) + + +@dataclass(frozen=True) +class _ScopeCase: + scope_type: AppConfigScopeType + scope_id: AppConfigScopeID | 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", + [ + _ScopeCase(scope_type=AppConfigScopeType.PUBLIC, scope_id=_SCOPE_ID), + _ScopeCase(scope_type=AppConfigScopeType.DOMAIN, scope_id=None), + _ScopeCase(scope_type=AppConfigScopeType.USER, scope_id=None), + ], + 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] + ) -> None: + 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", + [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: + with pytest.raises(BackendAISchemaValidationFailed): + 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", + 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): + CreateAppConfigFragmentInput.model_validate({ + "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: + with pytest.raises(BackendAISchemaValidationFailed): + 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 = AppConfigFragmentID(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): + 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=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): + BulkUpdateAppConfigFragmentInput.model_validate({"items": []}) + + def test_bulk_purge_accepts_ids(self) -> None: + 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): + 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 493f9925b36..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,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 missing fragment 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 missing fragment 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) @@ -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