From 219cf14217513b77ce15d4b5265fc1a81352c9ec Mon Sep 17 00:00:00 2001 From: Mincheol Kang Date: Fri, 17 Jan 2025 19:29:57 +0900 Subject: [PATCH 1/2] refactor(BA-502): Add Draft of Skeleton Interface for Storage-Proxy (#3434) --- .../backend/storage/api/vfolder/__init__.py | 0 src/ai/backend/storage/api/vfolder/client.py | 58 ++++++++++ .../storage/api/vfolder/manager_handler.py | 108 ++++++++++++++++++ .../storage/api/vfolder/manager_service.py | 83 ++++++++++++++ src/ai/backend/storage/api/vfolder/types.py | 77 +++++++++++++ 5 files changed, 326 insertions(+) create mode 100644 src/ai/backend/storage/api/vfolder/__init__.py create mode 100644 src/ai/backend/storage/api/vfolder/client.py create mode 100644 src/ai/backend/storage/api/vfolder/manager_handler.py create mode 100644 src/ai/backend/storage/api/vfolder/manager_service.py create mode 100644 src/ai/backend/storage/api/vfolder/types.py diff --git a/src/ai/backend/storage/api/vfolder/__init__.py b/src/ai/backend/storage/api/vfolder/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/storage/api/vfolder/client.py b/src/ai/backend/storage/api/vfolder/client.py new file mode 100644 index 00000000000..a994bf1f1f6 --- /dev/null +++ b/src/ai/backend/storage/api/vfolder/client.py @@ -0,0 +1,58 @@ +from pathlib import Path +from typing import List, Protocol + +import aiohttp_cors +from aiohttp import web +from pydantic import BaseModel + +from ai.backend.common.types import VFolderID + + +class DownloadTokenData(BaseModel): + volume: str + vfid: VFolderID + relpath: str + archive: bool + unmanaged_path: str | None + + +class UploadTokenData(BaseModel): + volume: str + vfid: VFolderID + relpath: str + session: str + size: int + + +class AbstractVolumeClient(Protocol): + async def download(self, token: DownloadTokenData) -> Path: + ... + + async def upload(self, token: UploadTokenData) -> None: + ... + + async def get_volumes(self) -> List[str]: + ... + + +class VolumeClient(AbstractVolumeClient): + async def download(self, token: DownloadTokenData) -> Path: + ... + + async def upload(self, token: UploadTokenData) -> None: + ... + + async def get_volumes(self) -> List[str]: + ... + + +class VolumeClientHandler(AbstractVolumeClient): + def __init__(self, volume_client: AbstractVolumeClient): + self.volume_client = volume_client + + +async def init_client_app() -> web.Application: + app = web.Application() + cors = aiohttp_cors.setup(app) + + return app diff --git a/src/ai/backend/storage/api/vfolder/manager_handler.py b/src/ai/backend/storage/api/vfolder/manager_handler.py new file mode 100644 index 00000000000..2914dd769a1 --- /dev/null +++ b/src/ai/backend/storage/api/vfolder/manager_handler.py @@ -0,0 +1,108 @@ +from typing import AsyncContextManager, Type, TypeVar, cast + +import weakref + +from aiohttp import web + + +from ai.backend.storage.api.manager import token_auth_middleware +from ai.backend.storage.api.vfolder.manager_service import VFolderService +from ai.backend.storage.context import RootContext, PrivateContext + + +T = TypeVar("T") + + +class VFolderHandler: + def __init__(self, storage_service: VFolderService) -> None: + self.storage_service = storage_service + + async def get_volume(self, request: web.Request) -> web.Response: + return web.Response(text="Volume info") + + async def get_volumes(self, request: web.Request) -> web.Response: + return web.Response(text="Volumes list") + + async def create_vfolder(self, request: web.Request) -> web.Response: + return web.Response(text="VFolder created", status=204) + + async def clone_vfolder(self, request: web.Request) -> web.Response: + return web.Response(text="VFolder cloned", status=204) + + async def get_vfolders(self, request: web.Request) -> web.Response: + return web.Response(text="VFolders list") + + async def get_vfolder_info(self, request: web.Request) -> web.Response: + return web.Response(text="VFolder info") + + async def update_vfolder_options(self, request: web.Request) -> web.Response: + return web.Response(text="VFolder updated") + + async def delete_vfolder(self, request: web.Request) -> web.Response: + return web.Response(text="VFolder deleted", status=202) + + # async def _extract_params(self, request: web.Request, schema: Type[T]) -> AsyncContextManager[T]: + # """ + # pydantic에서 자주 활용되는 방식 찾아보기 + # middleware에서 처리하는 방식도 고려해보기""" + # data = await request.json() + # try: + # params = schema(**data) + # except TypeError as e: + # raise web.HTTPBadRequest( # Backend.AI의 Exception 패키지 확인하기 + # reason=f"Invalid request parameters: {str(e)}" + # ) + # # 데이터 검증을 위에서 같이 진행해서 check_params 제외함 + # return cast(AsyncContextManager[T], params) + + +async def init_manager_app(ctx: RootContext) -> web.Application: + storage_service = VFolderService(ctx) + storage_handler = VFolderHandler(storage_service) + + app = web.Application( + middlewares=[ + token_auth_middleware, + ], + ) + app["ctx"] = ctx + app["app_ctx"] = PrivateContext( + deletion_tasks=weakref.WeakValueDictionary()) + + # Volume + app.router.add_route( + "POST", "/volumes/{volume_id}", storage_handler.get_volume) + app.router.add_route( + "GET", "/volumes", storage_handler.get_volumes) + # VFolder + app.router.add_route( + "POST", "/volumes/{volume_id}/vfolders/", storage_handler.create_vfolder + ) + app.router.add_route( + "POST", "/volumes/{volume_id}/vfolders/{vfolder_id}/clone", storage_handler.clone_vfolder) + app.router.add_route( + "GET", "/volumes/{volume_id}/vfolders", storage_handler.get_vfolders + ) + app.router.add_route( + "GET", "/volumes/{volume_id}/vfolders/{vfolder_id}", storage_handler.get_vfolder_info + ) + app.router.add_route( + "PUT", "/volumes/{volume_id}/vfolders/{vfolder_id}", storage_handler.update_vfolder_options + ) + app.router.add_route( + "DELETE", "/volumes/{volume_id}/vfolders/{vfolder_id}", storage_handler.delete_vfolder + ) + + # evd = ctx.event_dispatcher + # evd.subscribe( + # DoVolumeMountEvent, + # storage_service.handle_volume_mount, + # name="storage.volume.mount" + # ) + # evd.subscribe( + # DoVolumeUnmountEvent, + # storage_service.handle_volume_umount, + # name="storage.volume.umount" + # ) + + return app diff --git a/src/ai/backend/storage/api/vfolder/manager_service.py b/src/ai/backend/storage/api/vfolder/manager_service.py new file mode 100644 index 00000000000..ba2d3346b64 --- /dev/null +++ b/src/ai/backend/storage/api/vfolder/manager_service.py @@ -0,0 +1,83 @@ +import asyncio +from pathlib import Path +from typing import AsyncIterator, Mapping, Protocol, Dict, Any +import weakref + +from ai.backend.common.events import DoVolumeMountEvent, DoVolumeUnmountEvent +from ai.backend.common.types import VFolderID +from ai.backend.storage.abc import AbstractVolume +from ai.backend.storage.api.vfolder.types import VFolderData, VolumeBaseData +from ai.backend.storage.context import RootContext +from ai.backend.storage.types import VolumeInfo + + +class VFolderServiceProtocol(Protocol): + async def get_volume(self, volume_data: VolumeBaseData) -> AsyncIterator[AbstractVolume]: + """by volume_id""" + ... + + async def get_volumes(self) -> Mapping[str, VolumeInfo]: + ... + + async def create_vfolder(self, volume_id: str, vfolder_id: VFolderID, options: VFolderOptions) -> None: + ... + + async def clone_vfolder(self, volume_id: str, vfolder_id: VFolderID, new_vfolder_id: VFolderID, options: VFolderOptions) -> None: + ... + + async def get_vfolders(self, volume_id: str) -> list[Dict[str, Any]]: + ... + + async def get_vfolder_info(self, volume_id: str, vfolder_id: VFolderID) -> Dict[str, Any]: + ... + + """TODO: options type 정의 필요 + create 시와 필드가 겹친다면 따로 정의 X""" + + async def update_vfolder_options(self, volume_id: str, vfolder_id: VFolderID, options: ...) -> None: + ... + + async def delete_vfolder(self, volume_id: str, vfolder_id: VFolderID) -> None: + ... + + +class VFolderService(VFolderServiceProtocol): + def __init__(self, ctx: RootContext) -> None: + self.ctx = ctx + + async def get_volume(self, volume_data: VolumeBaseData) -> AsyncIterator[AbstractVolume]: + ... + + async def get_volumes(self) -> Mapping[str, VolumeInfo]: + ... + + async def handle_volume_mount(self, event: DoVolumeMountEvent) -> None: + ... + + async def handle_volume_umount(self, event: DoVolumeUnmountEvent) -> None: + ... + + async def create_vfolder(self, vfolder_data: VFolderData) -> None: + ... + + async def clone_vfolder(self, vfolder_data: VFolderData, new_vfolder_id: VFolderID) -> None: + ... + + async def get_vfolders(self, volume_id: str) -> list[Dict[str, Any]]: + ... + + async def get_vfolder_info(self, volume_id: str, vfolder_id: VFolderID) -> Dict[str, Any]: + ... + + async def update_vfolder_options(self, volume_id: str, vfolder_id: VFolderID, options: ...) -> None: + ... + + async def _delete_vfolder( + self, + vfolder_data: VFolderData, + task_map: weakref.WeakValueDictionary[VFolderID, asyncio.Task] + ) -> None: + ... + + async def delete_vfolder(self, vfolder_data: VFolderData) -> None: + ... diff --git a/src/ai/backend/storage/api/vfolder/types.py b/src/ai/backend/storage/api/vfolder/types.py new file mode 100644 index 00000000000..1f2d9e69719 --- /dev/null +++ b/src/ai/backend/storage/api/vfolder/types.py @@ -0,0 +1,77 @@ +from pathlib import Path, PurePath +from typing import Dict, Any, TypeAlias, TypeVar +import uuid +import weakref + +import attrs +from pydantic import BaseModel +from ai.backend.common.types import VFolderID + + +VolumeID: TypeAlias = uuid.UUID +StorageID: TypeAlias = uuid.UUID + + +@attrs.define(slots=True) +class PrivateContext: + deletion_tasks: weakref.WeakValueDictionary[VFolderID, asyncio.Task] + + +@attrs.define(auto_attribs=True, slots=True, frozen=True) +class VolumeBaseData: + volume_id: uuid.UUID + # VolumeID, StorageID는 단순한 Type alias였어서 그냥 uuid.UUID로 바꿔도 될 거 같음 + + +@attrs.define(auto_attribs=True, slots=True, frozen=True) +class VolumeData(VolumeBaseData): # 이미 storage.types에 있는데 굳이 따로 정의? + backend: str + path: Path + mount_path: Path + fsprefix: PurePath | None + options: Dict[str, Any] | None # Dict 구체화 고민 더 해보기 + # update_option할 때는 evolve로 처리해야 함! (frozen 때문) -> 자주 수정되지 않을 거라고 판단했음 + + +@attrs.define(auto_attribs=True, slots=True, frozen=True) +class VFolderData(VolumeBaseData): + vid: VFolderID + # options에 deprecate 표시가 있는데 필요한가? + # QuotaConfig 사용됨 (limit_bytes: int) + + +@attrs.define(auto_attribs=True, slots=True, frozen=True) +class CloneVFolderData(VFolderData): + dst_vfid: VFolderID + + +@attrs.define(auto_attribs=True, slots=True, frozen=True) +class VolumeConfig: + ... + + +class SpaceInfo(BaseModel): + available: int + used: int + size: int + + +# 파이단틱 필드 사용법 적용하기 +class SVMInfo(BaseModel): + # Storage Virtual Machine + svm_id: uuid.UUID # 기존에 netappclient.py에서는 uuid라고 해놓고 str로 받아왔는데 이렇게 바꾸는 게 맞는지 확인 필요 + svm_name: str + + +class VolumeInfo(BaseModel): + name: str + volume_id: uuid.UUID + path: Path + space: SpaceInfo | None + statistics: Dict[str, Any] | None + svm: SVMInfo | None + + +class VFolderOptions: + def __init__(self, ): + ... From 6bd349bc37ce013f9ee73a1028f3d9fb4f8a5442 Mon Sep 17 00:00:00 2001 From: Mincheol Kang Date: Wed, 22 Jan 2025 15:02:36 +0900 Subject: [PATCH 2/2] refactor(BA-502): Add the skeleton interface of vfolder CRUD handlers in storage-proxy (#3434) --- src/ai/backend/storage/api/vfolder/BUILD | 1 + src/ai/backend/storage/api/vfolder/client.py | 58 ---- .../storage/api/vfolder/manager_handler.py | 154 ++++----- .../storage/api/vfolder/manager_service.py | 119 +++---- src/ai/backend/storage/api/vfolder/types.py | 299 +++++++++++++++--- tests/storage-proxy/vfolder/BUILD | 3 + tests/storage-proxy/vfolder/__init__.py | 0 tests/storage-proxy/vfolder/conftest.py | 141 +++++++++ tests/storage-proxy/vfolder/test_handler.py | 113 +++++++ 9 files changed, 636 insertions(+), 252 deletions(-) create mode 100644 src/ai/backend/storage/api/vfolder/BUILD delete mode 100644 src/ai/backend/storage/api/vfolder/client.py create mode 100644 tests/storage-proxy/vfolder/BUILD create mode 100644 tests/storage-proxy/vfolder/__init__.py create mode 100644 tests/storage-proxy/vfolder/conftest.py create mode 100644 tests/storage-proxy/vfolder/test_handler.py diff --git a/src/ai/backend/storage/api/vfolder/BUILD b/src/ai/backend/storage/api/vfolder/BUILD new file mode 100644 index 00000000000..2181f04b38f --- /dev/null +++ b/src/ai/backend/storage/api/vfolder/BUILD @@ -0,0 +1 @@ +python_sources() \ No newline at end of file diff --git a/src/ai/backend/storage/api/vfolder/client.py b/src/ai/backend/storage/api/vfolder/client.py deleted file mode 100644 index a994bf1f1f6..00000000000 --- a/src/ai/backend/storage/api/vfolder/client.py +++ /dev/null @@ -1,58 +0,0 @@ -from pathlib import Path -from typing import List, Protocol - -import aiohttp_cors -from aiohttp import web -from pydantic import BaseModel - -from ai.backend.common.types import VFolderID - - -class DownloadTokenData(BaseModel): - volume: str - vfid: VFolderID - relpath: str - archive: bool - unmanaged_path: str | None - - -class UploadTokenData(BaseModel): - volume: str - vfid: VFolderID - relpath: str - session: str - size: int - - -class AbstractVolumeClient(Protocol): - async def download(self, token: DownloadTokenData) -> Path: - ... - - async def upload(self, token: UploadTokenData) -> None: - ... - - async def get_volumes(self) -> List[str]: - ... - - -class VolumeClient(AbstractVolumeClient): - async def download(self, token: DownloadTokenData) -> Path: - ... - - async def upload(self, token: UploadTokenData) -> None: - ... - - async def get_volumes(self) -> List[str]: - ... - - -class VolumeClientHandler(AbstractVolumeClient): - def __init__(self, volume_client: AbstractVolumeClient): - self.volume_client = volume_client - - -async def init_client_app() -> web.Application: - app = web.Application() - cors = aiohttp_cors.setup(app) - - return app diff --git a/src/ai/backend/storage/api/vfolder/manager_handler.py b/src/ai/backend/storage/api/vfolder/manager_handler.py index 2914dd769a1..0ed8befaea9 100644 --- a/src/ai/backend/storage/api/vfolder/manager_handler.py +++ b/src/ai/backend/storage/api/vfolder/manager_handler.py @@ -1,16 +1,16 @@ -from typing import AsyncContextManager, Type, TypeVar, cast - -import weakref +import uuid from aiohttp import web - -from ai.backend.storage.api.manager import token_auth_middleware from ai.backend.storage.api.vfolder.manager_service import VFolderService -from ai.backend.storage.context import RootContext, PrivateContext - - -T = TypeVar("T") +from ai.backend.storage.api.vfolder.types import ( + QuotaConfigModel, + QuotaIDModel, + VFolderCloneModel, + VFolderIDModel, + VFolderInfoRequestModel, + VolumeIDModel, +) class VFolderHandler: @@ -18,91 +18,71 @@ def __init__(self, storage_service: VFolderService) -> None: self.storage_service = storage_service async def get_volume(self, request: web.Request) -> web.Response: - return web.Response(text="Volume info") + data = await request.json() + data["volume_id"] = uuid.UUID(data["volume_id"]) + req = VolumeIDModel(**data) + result = await self.storage_service.get_volume(req) + return web.json_response(result) async def get_volumes(self, request: web.Request) -> web.Response: - return web.Response(text="Volumes list") + result = await self.storage_service.get_volumes() + # Assume that the volume_dict is a dictionary of VolumeInfoModel objects + volumes_dict = result.volumes + volumes_dict = {k: v for k, v in volumes_dict.items()} + return web.json_response(volumes_dict) + + async def create_quota_scope(self, request: web.Request) -> web.Response: + data = await request.json() + data["volume_id"] = uuid.UUID(data["volume_id"]) + req = QuotaConfigModel(**data) + await self.storage_service.create_quota_scope(req) + return web.Response(status=204) + + async def get_quota_scope(self, request: web.Request) -> web.Response: + data = await request.json() + data["volume_id"] = uuid.UUID(data["volume_id"]) + req = QuotaIDModel(**data) + result = await self.storage_service.get_quota_scope(req) + return web.json_response(result) + + async def update_quota_scope(self, request: web.Request) -> web.Response: + data = await request.json() + data["volume_id"] = uuid.UUID(data["volume_id"]) + req = QuotaConfigModel(**data) + await self.storage_service.update_quota_scope(req) + return web.Response(status=204) + + async def delete_quota_scope(self, request: web.Request) -> web.Response: + data = await request.json() + data["volume_id"] = uuid.UUID(data["volume_id"]) + req = QuotaIDModel(**data) + await self.storage_service.delete_quota_scope(req) + return web.Response(status=204) async def create_vfolder(self, request: web.Request) -> web.Response: - return web.Response(text="VFolder created", status=204) + data = await request.json() + data["volume_id"] = uuid.UUID(data["volume_id"]) + req = VFolderIDModel(**data) + await self.storage_service.create_vfolder(req) + return web.Response(status=204) async def clone_vfolder(self, request: web.Request) -> web.Response: - return web.Response(text="VFolder cloned", status=204) - - async def get_vfolders(self, request: web.Request) -> web.Response: - return web.Response(text="VFolders list") + data = await request.json() + data["volume_id"] = uuid.UUID(data["volume_id"]) + req = VFolderCloneModel(**data) + await self.storage_service.clone_vfolder(req) + return web.Response(status=204) async def get_vfolder_info(self, request: web.Request) -> web.Response: - return web.Response(text="VFolder info") - - async def update_vfolder_options(self, request: web.Request) -> web.Response: - return web.Response(text="VFolder updated") + data = await request.json() + data["volume_id"] = uuid.UUID(data["volume_id"]) + req = VFolderInfoRequestModel(**data) + result = await self.storage_service.get_vfolder_info(req) + return web.json_response(result) async def delete_vfolder(self, request: web.Request) -> web.Response: - return web.Response(text="VFolder deleted", status=202) - - # async def _extract_params(self, request: web.Request, schema: Type[T]) -> AsyncContextManager[T]: - # """ - # pydantic에서 자주 활용되는 방식 찾아보기 - # middleware에서 처리하는 방식도 고려해보기""" - # data = await request.json() - # try: - # params = schema(**data) - # except TypeError as e: - # raise web.HTTPBadRequest( # Backend.AI의 Exception 패키지 확인하기 - # reason=f"Invalid request parameters: {str(e)}" - # ) - # # 데이터 검증을 위에서 같이 진행해서 check_params 제외함 - # return cast(AsyncContextManager[T], params) - - -async def init_manager_app(ctx: RootContext) -> web.Application: - storage_service = VFolderService(ctx) - storage_handler = VFolderHandler(storage_service) - - app = web.Application( - middlewares=[ - token_auth_middleware, - ], - ) - app["ctx"] = ctx - app["app_ctx"] = PrivateContext( - deletion_tasks=weakref.WeakValueDictionary()) - - # Volume - app.router.add_route( - "POST", "/volumes/{volume_id}", storage_handler.get_volume) - app.router.add_route( - "GET", "/volumes", storage_handler.get_volumes) - # VFolder - app.router.add_route( - "POST", "/volumes/{volume_id}/vfolders/", storage_handler.create_vfolder - ) - app.router.add_route( - "POST", "/volumes/{volume_id}/vfolders/{vfolder_id}/clone", storage_handler.clone_vfolder) - app.router.add_route( - "GET", "/volumes/{volume_id}/vfolders", storage_handler.get_vfolders - ) - app.router.add_route( - "GET", "/volumes/{volume_id}/vfolders/{vfolder_id}", storage_handler.get_vfolder_info - ) - app.router.add_route( - "PUT", "/volumes/{volume_id}/vfolders/{vfolder_id}", storage_handler.update_vfolder_options - ) - app.router.add_route( - "DELETE", "/volumes/{volume_id}/vfolders/{vfolder_id}", storage_handler.delete_vfolder - ) - - # evd = ctx.event_dispatcher - # evd.subscribe( - # DoVolumeMountEvent, - # storage_service.handle_volume_mount, - # name="storage.volume.mount" - # ) - # evd.subscribe( - # DoVolumeUnmountEvent, - # storage_service.handle_volume_umount, - # name="storage.volume.umount" - # ) - - return app + data = await request.json() + data["volume_id"] = uuid.UUID(data["volume_id"]) + req = VFolderIDModel(**data) + await self.storage_service.delete_vfolder(req) + return web.Response(status=202) diff --git a/src/ai/backend/storage/api/vfolder/manager_service.py b/src/ai/backend/storage/api/vfolder/manager_service.py index ba2d3346b64..e1c48275c92 100644 --- a/src/ai/backend/storage/api/vfolder/manager_service.py +++ b/src/ai/backend/storage/api/vfolder/manager_service.py @@ -1,83 +1,90 @@ -import asyncio -from pathlib import Path -from typing import AsyncIterator, Mapping, Protocol, Dict, Any -import weakref - -from ai.backend.common.events import DoVolumeMountEvent, DoVolumeUnmountEvent -from ai.backend.common.types import VFolderID -from ai.backend.storage.abc import AbstractVolume -from ai.backend.storage.api.vfolder.types import VFolderData, VolumeBaseData -from ai.backend.storage.context import RootContext -from ai.backend.storage.types import VolumeInfo +from pathlib import Path, PurePath +from typing import Protocol + +from ai.backend.common.types import BinarySize +from ai.backend.storage.api.vfolder.types import ( + QuotaConfigModel, + QuotaIDModel, + QuotaScopeInfoModel, + VFolderCloneModel, + VFolderIDModel, + VFolderInfoModel, + VFolderInfoRequestModel, + VolumeIDModel, + VolumeInfoListModel, + VolumeInfoModel, +) +from ai.backend.storage.types import CapacityUsage, TreeUsage class VFolderServiceProtocol(Protocol): - async def get_volume(self, volume_data: VolumeBaseData) -> AsyncIterator[AbstractVolume]: + async def get_volume(self, volume_data: VolumeIDModel) -> VolumeInfoModel: """by volume_id""" ... - async def get_volumes(self) -> Mapping[str, VolumeInfo]: - ... + async def get_volumes(self) -> VolumeInfoListModel: ... - async def create_vfolder(self, volume_id: str, vfolder_id: VFolderID, options: VFolderOptions) -> None: - ... + async def create_quota_scope(self, quota_config_data: QuotaConfigModel) -> None: ... - async def clone_vfolder(self, volume_id: str, vfolder_id: VFolderID, new_vfolder_id: VFolderID, options: VFolderOptions) -> None: - ... + async def get_quota_scope(self, quota_data: QuotaIDModel) -> QuotaScopeInfoModel: ... - async def get_vfolders(self, volume_id: str) -> list[Dict[str, Any]]: - ... + async def update_quota_scope(self, quota_config_data: QuotaConfigModel) -> None: ... - async def get_vfolder_info(self, volume_id: str, vfolder_id: VFolderID) -> Dict[str, Any]: + async def delete_quota_scope(self, quota_data: QuotaIDModel) -> None: + """Previous: unset_quota""" ... - """TODO: options type 정의 필요 - create 시와 필드가 겹친다면 따로 정의 X""" + async def create_vfolder(self, vfolder_data: VFolderIDModel) -> None: ... - async def update_vfolder_options(self, volume_id: str, vfolder_id: VFolderID, options: ...) -> None: - ... + async def clone_vfolder(self, vfolder_clone_data: VFolderCloneModel) -> None: ... - async def delete_vfolder(self, volume_id: str, vfolder_id: VFolderID) -> None: + async def get_vfolder_info(self, vfolder_info: VFolderInfoRequestModel) -> VFolderInfoModel: + # Integration: vfolder_mount, metadata, vfolder_usage, vfolder_used_bytes, vfolder_fs_usage ... + async def delete_vfolder(self, vfolder_data: VFolderIDModel) -> None: ... -class VFolderService(VFolderServiceProtocol): - def __init__(self, ctx: RootContext) -> None: - self.ctx = ctx - - async def get_volume(self, volume_data: VolumeBaseData) -> AsyncIterator[AbstractVolume]: - ... - async def get_volumes(self) -> Mapping[str, VolumeInfo]: - ... +class VFolderService: + async def get_volume(self, volume_data: VolumeIDModel) -> VolumeInfoModel: + return VolumeInfoModel( + volume_id=volume_data.volume_id, + backend="default-backend", + path=Path("/default/path"), + fsprefix=PurePath("/fsprefix"), + capabilities=["read", "write"], + options={"option1": "value1"}, + ) - async def handle_volume_mount(self, event: DoVolumeMountEvent) -> None: - ... + async def get_volumes(self) -> VolumeInfoListModel: + return VolumeInfoListModel(volumes={}) - async def handle_volume_umount(self, event: DoVolumeUnmountEvent) -> None: - ... + async def create_quota_scope(self, quota_config_data: QuotaConfigModel) -> None: + return None - async def create_vfolder(self, vfolder_data: VFolderData) -> None: - ... + async def get_quota_scope(self, quota_data: QuotaIDModel) -> QuotaScopeInfoModel: + return QuotaScopeInfoModel(used_bytes=0, limit_bytes=0) - async def clone_vfolder(self, vfolder_data: VFolderData, new_vfolder_id: VFolderID) -> None: - ... + async def update_quota_scope(self, quota_config_data: QuotaConfigModel) -> None: + return None - async def get_vfolders(self, volume_id: str) -> list[Dict[str, Any]]: - ... + async def delete_quota_scope(self, quota_data: QuotaIDModel) -> None: + return None - async def get_vfolder_info(self, volume_id: str, vfolder_id: VFolderID) -> Dict[str, Any]: - ... + async def create_vfolder(self, vfolder_data: VFolderIDModel) -> None: + return None - async def update_vfolder_options(self, volume_id: str, vfolder_id: VFolderID, options: ...) -> None: - ... + async def clone_vfolder(self, vfolder_clone_data: VFolderCloneModel) -> None: + return None - async def _delete_vfolder( - self, - vfolder_data: VFolderData, - task_map: weakref.WeakValueDictionary[VFolderID, asyncio.Task] - ) -> None: - ... + async def get_vfolder_info(self, vfolder_info: VFolderInfoRequestModel) -> VFolderInfoModel: + return VFolderInfoModel( + vfolder_mount=Path("/mount/point"), + vfolder_metadata=b"", + vfolder_usage=TreeUsage(file_count=0, used_bytes=0), + vfolder_used_bytes=BinarySize(0), + vfolder_fs_usage=CapacityUsage(used_bytes=0, capacity_bytes=0), + ) - async def delete_vfolder(self, vfolder_data: VFolderData) -> None: - ... + async def delete_vfolder(self, vfolder_data: VFolderIDModel) -> None: + return None diff --git a/src/ai/backend/storage/api/vfolder/types.py b/src/ai/backend/storage/api/vfolder/types.py index 1f2d9e69719..57a9c90f6ba 100644 --- a/src/ai/backend/storage/api/vfolder/types.py +++ b/src/ai/backend/storage/api/vfolder/types.py @@ -1,77 +1,274 @@ -from pathlib import Path, PurePath -from typing import Dict, Any, TypeAlias, TypeVar import uuid -import weakref +from pathlib import Path, PurePath, PurePosixPath +from typing import Any, Mapping, TypeAlias -import attrs -from pydantic import BaseModel -from ai.backend.common.types import VFolderID +from pydantic import AliasChoices, Field, model_validator +from pydantic import BaseModel as PydanticBaseModel + +from ai.backend.common.types import BinarySize, QuotaConfig, QuotaScopeID, VFolderID +from ai.backend.storage.types import CapacityUsage, TreeUsage + +__all__ = ( + "VolumeIDModel", + "VolumeInfoModel", + "VolumeInfoListModel", + "VFolderIDModel", + "VFolderInfoRequestModel", + "VFolderInfoModel", + "VFolderCloneModel", + "QuotaIDModel", + "QuotaScopeInfoModel", + "QuotaConfigModel", +) + + +class BaseModel(PydanticBaseModel): + """Base model for all models in this module""" + + model_config = {"arbitrary_types_allowed": True} VolumeID: TypeAlias = uuid.UUID -StorageID: TypeAlias = uuid.UUID -@attrs.define(slots=True) -class PrivateContext: - deletion_tasks: weakref.WeakValueDictionary[VFolderID, asyncio.Task] +# Common fields for VolumeID and VFolderID +VOLUME_ID_FIELD = Field( + ..., + validation_alias=AliasChoices( + "vid", "volumeid", "volume_id", "VolumeID", "Volume_Id", "Volumeid" + ), +) +VFOLDER_ID_FIELD = Field( + ..., + validation_alias=AliasChoices( + "vfid", "vfolderid", "vfolder_id", "VFolderID", "VFolder_Id", "VFolderid" + ), +) +QUOTA_SCOPE_ID_FIELD = Field( + ..., + validation_alias=AliasChoices( + "qsid", + "quotascopeid", + "quota_scope_id", + "QuotaScopeID", + "Quota_Scope_Id", + "QuotaScopeid", + "Quota_ScopeID", + "Quota_Scopeid", + "quotaScopeID", + "quotaScopeid", + ), +) + +class VolumeIDModel(BaseModel): + volume_id: VolumeID = VOLUME_ID_FIELD -@attrs.define(auto_attribs=True, slots=True, frozen=True) -class VolumeBaseData: - volume_id: uuid.UUID - # VolumeID, StorageID는 단순한 Type alias였어서 그냥 uuid.UUID로 바꿔도 될 거 같음 + @model_validator(mode="before") + def validate_all_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + if "volume_id" in values and not isinstance(values["volume_id"], uuid.UUID): + raise ValueError("volume_id must be a UUID") + return values -@attrs.define(auto_attribs=True, slots=True, frozen=True) -class VolumeData(VolumeBaseData): # 이미 storage.types에 있는데 굳이 따로 정의? - backend: str - path: Path - mount_path: Path +class VolumeInfoModel(BaseModel): + """For `get_volume`, `get_volumes` requests""" + + volume_id: VolumeID = VOLUME_ID_FIELD + backend: str = Field(...) + path: Path = Field(...) fsprefix: PurePath | None - options: Dict[str, Any] | None # Dict 구체화 고민 더 해보기 - # update_option할 때는 evolve로 처리해야 함! (frozen 때문) -> 자주 수정되지 않을 거라고 판단했음 + capabilities: list[str] = Field(...) + options: Mapping[str, Any] | None + + @model_validator(mode="before") + def validate_all_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + if "volume_id" in values and not isinstance(values["volume_id"], uuid.UUID): + raise ValueError("volume_id must be a UUID") + if "backend" in values and not isinstance(values["backend"], str): + raise ValueError("backend must be a string") + if "path" in values and not isinstance(values["path"], Path): + raise ValueError("path must be a Path object") + if values.get("fsprefix") is not None and not isinstance(values["fsprefix"], PurePath): + raise ValueError("fsprefix must be a PurePath or None") + if "capabilities" in values and not isinstance(values["capabilities"], list): + raise ValueError("capabilities must be a list of strings") + if values.get("options") is not None and not isinstance(values["options"], Mapping): + raise ValueError("options must be a mapping or None") + return values + + +class VolumeInfoListModel(BaseModel): + """For `get_volumes` response""" + + volumes: dict[VolumeID, VolumeInfoModel] = Field(...) + + @model_validator(mode="before") + def validate_all_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + if "volumes" in values and not isinstance(values["volumes"], dict): + raise ValueError("volumes must be a dictionary") + for k, v in values.get("volumes", {}).items(): + if not isinstance(k, uuid.UUID): + raise ValueError("keys in volumes must be UUIDs") + if not isinstance(v, VolumeInfoModel): + raise ValueError("values in volumes must be VolumeInfoModel instances") + return values + + +class VFolderIDModel(BaseModel): + volume_id: VolumeID = VOLUME_ID_FIELD + vfolder_id: VFolderID = VFOLDER_ID_FIELD + + @model_validator(mode="before") + def validate_all_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + if "volume_id" in values and not isinstance(values["volume_id"], uuid.UUID): + raise ValueError("volume_id must be a UUID") + if "vfolder_id" in values and not isinstance(values["vfolder_id"], VFolderID): + raise ValueError("vfolder_id must be a VFolderID") + return values + + +class VFolderInfoRequestModel(BaseModel): + """For `get_vfolder_info` request""" + + volume_id: VolumeID = VOLUME_ID_FIELD + vfolder_id: VFolderID = VFOLDER_ID_FIELD + subpath: PurePosixPath = Field(...) + + @model_validator(mode="before") + def validate_all_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + if "volume_id" in values and not isinstance(values["volume_id"], uuid.UUID): + raise ValueError("volume_id must be a UUID") + if "vfolder_id" in values and not isinstance(values["vfolder_id"], VFolderID): + raise ValueError("vfolder_id must be a VFolderID") + if "subpath" in values and not isinstance(values["subpath"], PurePosixPath): + raise ValueError("subpath must be a PurePosixPath") + return values + + +class VFolderInfoModel(BaseModel): + """For `get_vfolder_info` response""" + vfolder_mount: Path = Field(...) + vfolder_metadata: bytes = Field(...) # 실제로 쓰이는지 확인 필요 + vfolder_usage: TreeUsage = Field(...) + vfolder_used_bytes: BinarySize = Field(...) + vfolder_fs_usage: CapacityUsage = Field(...) -@attrs.define(auto_attribs=True, slots=True, frozen=True) -class VFolderData(VolumeBaseData): - vid: VFolderID - # options에 deprecate 표시가 있는데 필요한가? - # QuotaConfig 사용됨 (limit_bytes: int) + @model_validator(mode="before") + def validate_all_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + if "vfolder_mount" in values and not isinstance(values["vfolder_mount"], Path): + raise ValueError("vfolder_mount must be a Path object") + if "vfolder_metadata" in values and not isinstance(values["vfolder_metadata"], bytes): + raise ValueError("vfolder_metadata must be bytes") + if "vfolder_usage" in values and not isinstance(values["vfolder_usage"], TreeUsage): + raise ValueError("vfolder_usage must be a TreeUsage object") + if "vfolder_used_bytes" in values and not isinstance( + values["vfolder_used_bytes"], BinarySize + ): + raise ValueError("vfolder_used_bytes must be a BinarySize object") + if "vfolder_fs_usage" in values and not isinstance( + values["vfolder_fs_usage"], CapacityUsage + ): + raise ValueError("vfolder_fs_usage must be a CapacityUsage object") + return values -@attrs.define(auto_attribs=True, slots=True, frozen=True) -class CloneVFolderData(VFolderData): - dst_vfid: VFolderID +class VFolderCloneModel(BaseModel): + volume_id: VolumeID = VOLUME_ID_FIELD # source volume + src_vfolder_id: VFolderID = Field( + ..., + validation_alias=AliasChoices( + "src_vfid", + "src_vfolderid", + "src_vfolder_id", + "source", + "src", + "src_vfolderid", + "source_vfid", + "source_vfolderid", + "source_vfolder_id", + "SrcVfid", + "SrcVfolderid", + "Source", + "Src", + "SrcVfolderid", + "SourceVfid", + "SourceVfolderid", + ), + ) + dst_vfolder_id: VFolderID = Field( + ..., + validation_alias=AliasChoices( + "dst_vfid", + "dst_vfolderid", + "destination", + "dst", + "dst_vfolderid", + "dst_vfolder_id", + "destination_vfid", + "destination_vfolderid", + "destination_vfolder_id", + "DstVfid", + "DstVfolderid", + "Destination", + "Dst", + "DstVfolderid", + "DestinationVfid", + "DestinationVfolderid", + ), + ) + @model_validator(mode="before") + def validate_all_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + if "volume_id" in values and not isinstance(values["volume_id"], uuid.UUID): + raise ValueError("volume_id must be a UUID") + if "src_vfolder_id" in values and not isinstance(values["src_vfolder_id"], VFolderID): + raise ValueError("src_vfolder_id must be a VFolderID") + if "dst_vfolder_id" in values and not isinstance(values["dst_vfolder_id"], VFolderID): + raise ValueError("dst_vfolder_id must be a VFolderID") + return values -@attrs.define(auto_attribs=True, slots=True, frozen=True) -class VolumeConfig: - ... +class QuotaIDModel(BaseModel): + volume_id: VolumeID = VOLUME_ID_FIELD + quota_scope_id: QuotaScopeID = QUOTA_SCOPE_ID_FIELD -class SpaceInfo(BaseModel): - available: int - used: int - size: int + @model_validator(mode="before") + def validate_all_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + if "volume_id" in values and not isinstance(values["volume_id"], uuid.UUID): + raise ValueError("volume_id must be a UUID") + if "quota_scope_id" in values and not isinstance(values["quota_scope_id"], QuotaScopeID): + raise ValueError("quota_scope_id must be a QuotaScopeID") + return values -# 파이단틱 필드 사용법 적용하기 -class SVMInfo(BaseModel): - # Storage Virtual Machine - svm_id: uuid.UUID # 기존에 netappclient.py에서는 uuid라고 해놓고 str로 받아왔는데 이렇게 바꾸는 게 맞는지 확인 필요 - svm_name: str +class QuotaScopeInfoModel(BaseModel): + used_bytes: int | None + limit_bytes: int | None + @model_validator(mode="before") + def validate_all_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + for field in ["used_bytes", "limit_bytes"]: + value = values.get(field) + if value is None: + values[field] = 0 + elif not isinstance(value, int): + raise ValueError(f"{field} must be an integer or None") + return values -class VolumeInfo(BaseModel): - name: str - volume_id: uuid.UUID - path: Path - space: SpaceInfo | None - statistics: Dict[str, Any] | None - svm: SVMInfo | None +class QuotaConfigModel(BaseModel): + volume_id: VolumeID = VOLUME_ID_FIELD + quota_scope_id: QuotaScopeID = QUOTA_SCOPE_ID_FIELD + options: QuotaConfig | None -class VFolderOptions: - def __init__(self, ): - ... + @model_validator(mode="before") + def validate_all_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + if "volume_id" in values and not isinstance(values["volume_id"], uuid.UUID): + raise ValueError("volume_id must be a UUID") + if "quota_scope_id" in values and not isinstance(values["quota_scope_id"], QuotaScopeID): + raise ValueError("quota_scope_id must be a QuotaScopeID") + if values.get("options") is not None and not isinstance(values["options"], QuotaConfig): + raise ValueError("options must be a QuotaConfig or None") + return values diff --git a/tests/storage-proxy/vfolder/BUILD b/tests/storage-proxy/vfolder/BUILD new file mode 100644 index 00000000000..5b439136ebc --- /dev/null +++ b/tests/storage-proxy/vfolder/BUILD @@ -0,0 +1,3 @@ +python_test_utils() + +python_tests(name="tests") diff --git a/tests/storage-proxy/vfolder/__init__.py b/tests/storage-proxy/vfolder/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/storage-proxy/vfolder/conftest.py b/tests/storage-proxy/vfolder/conftest.py new file mode 100644 index 00000000000..5bc3a6d8461 --- /dev/null +++ b/tests/storage-proxy/vfolder/conftest.py @@ -0,0 +1,141 @@ +import uuid + +import pytest + +from ai.backend.storage.api.vfolder.manager_handler import VFolderHandler +from ai.backend.storage.api.vfolder.types import ( + QuotaConfigModel, + QuotaScopeInfoModel, + VFolderCloneModel, + VFolderIDModel, + VFolderInfoRequestModel, + VolumeIDModel, +) + +UUID = "8067790d-9a2e-4daf-8278-614ded1dc7f8" + + +@pytest.mark.asyncio +async def test_get_volume(mock_vfolder_service, mock_request): + handler = VFolderHandler(storage_service=mock_vfolder_service) + response = await handler.get_volume(mock_request) + + mock_vfolder_service.get_volume.assert_called_once_with( + VolumeIDModel(volume_id=uuid.UUID(UUID)) + ) + result = await response.json() + assert result["volume_id"] == str(uuid.UUID(UUID)) + assert result["backend"] == "default-backend" + assert result["path"] == "/example/path" + + +@pytest.mark.asyncio +async def test_get_volumes(mock_vfolder_service): + handler = VFolderHandler(storage_service=mock_vfolder_service) + response = await handler.get_volumes(None) + + mock_vfolder_service.get_volumes.assert_called_once() + result = await response.json() + assert str(uuid.UUID(UUID)) in result + assert result[str(uuid.UUID(UUID))]["backend"] == "default-backend" + assert result[str(uuid.UUID(UUID))]["path"] == "/example/path" + + +@pytest.mark.asyncio +async def test_create_quota_scope(mock_vfolder_service, mock_request): + handler = VFolderHandler(storage_service=mock_vfolder_service) + response = await handler.create_quota_scope(mock_request) + + mock_vfolder_service.create_quota_scope.assert_called_once_with( + QuotaConfigModel(volume_id=uuid.UUID(UUID), quota_scope_id="test-scope", options=None) + ) + assert response.status == 204 + + +@pytest.mark.asyncio +async def test_get_quota_scope(mock_vfolder_service, mock_request): + handler = VFolderHandler(storage_service=mock_vfolder_service) + response = await handler.get_quota_scope(mock_request) + + mock_vfolder_service.get_quota_scope.assert_called_once_with( + QuotaScopeInfoModel(used_bytes=1024, limit_bytes=2048) + ) + result = await response.json() + assert result["used_bytes"] == 1024 + assert result["limit_bytes"] == 2048 + + +@pytest.mark.asyncio +async def test_update_quota_scope(mock_vfolder_service, mock_request): + handler = VFolderHandler(storage_service=mock_vfolder_service) + response = await handler.update_quota_scope(mock_request) + + mock_vfolder_service.update_quota_scope.assert_called_once_with( + QuotaConfigModel(volume_id=uuid.UUID(UUID), quota_scope_id="test-scope", options=None) + ) + assert response.status == 204 + + +@pytest.mark.asyncio +async def test_delete_quota_scope(mock_vfolder_service, mock_request): + handler = VFolderHandler(storage_service=mock_vfolder_service) + response = await handler.delete_quota_scope(mock_request) + + mock_vfolder_service.delete_quota_scope.assert_called_once_with( + QuotaScopeInfoModel(used_bytes=1024, limit_bytes=2048) + ) + assert response.status == 204 + + +@pytest.mark.asyncio +async def test_create_vfolder(mock_vfolder_service, mock_request): + handler = VFolderHandler(storage_service=mock_vfolder_service) + response = await handler.create_vfolder(mock_request) + + mock_vfolder_service.create_vfolder.assert_called_once_with( + VFolderIDModel(volume_id=uuid.UUID(UUID), vfolder_id="test-folder-id") + ) + assert response.status == 204 + + +@pytest.mark.asyncio +async def test_clone_vfolder(mock_vfolder_service, mock_request): + handler = VFolderHandler(storage_service=mock_vfolder_service) + response = await handler.clone_vfolder(mock_request) + + mock_vfolder_service.clone_vfolder.assert_called_once_with( + VFolderCloneModel( + volume_id=uuid.UUID(UUID), + src_vfolder_id="source-folder", + dst_vfolder_id="destination-folder", + ) + ) + assert response.status == 204 + + +@pytest.mark.asyncio +async def test_get_vfolder_info(mock_vfolder_service, mock_request): + handler = VFolderHandler(storage_service=mock_vfolder_service) + response = await handler.get_vfolder_info(mock_request) + + mock_vfolder_service.get_vfolder_info.assert_called_once_with( + VFolderInfoRequestModel( + volume_id=uuid.UUID(UUID), + vfolder_id="test-folder-id", + subpath="/test/path", + ) + ) + result = await response.json() + assert result["vfolder_mount"] == "/mount/point" + assert result["vfolder_used_bytes"] == 2048 + + +@pytest.mark.asyncio +async def test_delete_vfolder(mock_vfolder_service, mock_request): + handler = VFolderHandler(storage_service=mock_vfolder_service) + response = await handler.delete_vfolder(mock_request) + + mock_vfolder_service.delete_vfolder.assert_called_once_with( + VFolderIDModel(volume_id=uuid.UUID(UUID), vfolder_id="test-folder-id") + ) + assert response.status == 202 diff --git a/tests/storage-proxy/vfolder/test_handler.py b/tests/storage-proxy/vfolder/test_handler.py new file mode 100644 index 00000000000..23fa04be779 --- /dev/null +++ b/tests/storage-proxy/vfolder/test_handler.py @@ -0,0 +1,113 @@ +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aiohttp.web import Request + +from ai.backend.storage.api.vfolder.manager_service import VFolderService +from ai.backend.storage.api.vfolder.types import ( + QuotaScopeInfoModel, + VFolderCloneModel, + VFolderIDModel, + VFolderInfoRequestModel, +) + +UUID = "8067790d-9a2e-4daf-8278-614ded1dc7f8" + + +@pytest.fixture +def mock_vfolder_service(): + service = AsyncMock(spec=VFolderService) + + # Mocked return value for get_volume + service.get_volume.return_value = { + "volume_id": str(uuid.UUID(UUID)), # UUID를 문자열로 반환 + "backend": "default-backend", + "path": "/example/path", # Path 객체 대신 문자열 + "fsprefix": "/fsprefix", + "capabilities": ["read", "write"], + "options": {"option1": "value1"}, + } + + # Mocked return value for get_volumes + service.get_volumes.return_value = { + str(uuid.UUID(UUID)): { # UUID 키를 문자열로 변환 + "volume_id": str(uuid.UUID(UUID)), + "backend": "default-backend", + "path": "/example/path", + "fsprefix": "/fsprefix", + "capabilities": ["read", "write"], + "options": {"option1": "value1"}, + } + } + + # Mocked return value for get_quota_scope + service.get_quota_scope.return_value = { + "used_bytes": 1024, + "limit_bytes": 2048, + } + + # Mocked return values for quota scope actions + service.create_quota_scope.return_value = None + service.update_quota_scope.return_value = None + service.delete_quota_scope.return_value = None + + # Mocked return value for create_vfolder + service.create_vfolder.return_value = None + + # Mocked return value for clone_vfolder + service.clone_vfolder.return_value = None + + # Mocked return value for get_vfolder_info + service.get_vfolder_info.return_value = { + "vfolder_mount": "/mount/point", # Path 대신 문자열 + "vfolder_metadata": "metadata", # bytes 대신 문자열 + "vfolder_usage": {"tree": {}, "usage": 1024}, + "vfolder_used_bytes": 2048, + "vfolder_fs_usage": {"capacity": 4096}, + } + + # Mocked return value for delete_vfolder + service.delete_vfolder.return_value = None + + return service + + +@pytest.fixture +def mock_request(): + """Mocked request object.""" + request = MagicMock(spec=Request) + request.json = AsyncMock(return_value={"volume_id": UUID}) # 문자열로 반환 + return request + + +@pytest.fixture +def mock_quota_scope_request(): + """Mocked request for quota scope.""" + return QuotaScopeInfoModel(used_bytes=1024, limit_bytes=2048) + + +@pytest.fixture +def mock_vfolder_id_request(): + """Mocked request for VFolder ID.""" + return VFolderIDModel(volume_id=UUID, vfolder_id="test-folder-id") + + +@pytest.fixture +def mock_vfolder_clone_request(): + """Mocked request for cloning a VFolder.""" + return VFolderCloneModel( + volume_id=UUID, + src_vfolder_id="source-folder", + dst_vfolder_id="destination-folder", + ) + + +@pytest.fixture +def mock_vfolder_info_request(): + """Mocked request for VFolder info.""" + return VFolderInfoRequestModel( + volume_id=UUID, + vfolder_id="test-folder-id", + subpath="/test/path", + )