diff --git a/changes/3630.feature.md b/changes/3630.feature.md new file mode 100644 index 0000000000..b389c92dc7 --- /dev/null +++ b/changes/3630.feature.md @@ -0,0 +1 @@ +Assign the noop storage host to unmanaged vfolders diff --git a/docs/manager/rest-reference/openapi.json b/docs/manager/rest-reference/openapi.json index 03b8c65b89..248d84cf02 100644 --- a/docs/manager/rest-reference/openapi.json +++ b/docs/manager/rest-reference/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Backend.AI Manager API", "description": "Backend.AI Manager REST API specification", - "version": "25.1.1", + "version": "25.2.0", "contact": { "name": "Lablup Inc.", "url": "https://docs.backend.ai", diff --git a/src/ai/backend/manager/api/vfolder.py b/src/ai/backend/manager/api/vfolder.py index aa184a069b..6911888b92 100644 --- a/src/ai/backend/manager/api/vfolder.py +++ b/src/ai/backend/manager/api/vfolder.py @@ -49,7 +49,7 @@ from ai.backend.common import msgpack, redis_helper from ai.backend.common import typed_validators as tv from ai.backend.common import validators as tx -from ai.backend.common.defs import VFOLDER_GROUP_PERMISSION_MODE +from ai.backend.common.defs import NOOP_STORAGE_VOLUME_NAME, VFOLDER_GROUP_PERMISSION_MODE from ai.backend.common.types import ( QuotaScopeID, QuotaScopeType, @@ -408,19 +408,21 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon ) folder_host = params.folder_host unmanaged_path = params.unmanaged_path + # Resolve host for the new virtual folder. + if not folder_host: + folder_host = await root_ctx.shared_config.etcd.get("volumes/default_host") + if not folder_host: + raise InvalidAPIParameters( + "You must specify the vfolder host because the default host is not configured." + ) # Check if user is trying to created unmanaged vFolder if unmanaged_path: # Approve only if user is Admin or Superadmin if user_role not in (UserRole.ADMIN, UserRole.SUPERADMIN): raise GenericForbidden("Insufficient permission") - else: - # Resolve host for the new virtual folder. - if not folder_host: - folder_host = await root_ctx.shared_config.etcd.get("volumes/default_host") - if not folder_host: - raise InvalidAPIParameters( - "You must specify the vfolder host because the default host is not configured." - ) + # Assign ghost host to unmanaged vfolder + proxy, _ = root_ctx.storage_manager.split_host(folder_host) + folder_host = root_ctx.storage_manager.parse_host(proxy, NOOP_STORAGE_VOLUME_NAME) allowed_vfolder_types = await root_ctx.shared_config.get_vfolder_types() @@ -531,18 +533,16 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon ) async with root_ctx.db.begin() as conn: - if not unmanaged_path: - assert folder_host is not None - await ensure_host_permission_allowed( - conn, - folder_host, - allowed_vfolder_types=allowed_vfolder_types, - user_uuid=user_uuid, - resource_policy=keypair_resource_policy, - domain_name=domain_name, - group_id=group_uuid, - permission=VFolderHostPermission.CREATE, - ) + await ensure_host_permission_allowed( + conn, + folder_host, + allowed_vfolder_types=allowed_vfolder_types, + user_uuid=user_uuid, + resource_policy=keypair_resource_policy, + domain_name=domain_name, + group_id=group_uuid, + permission=VFolderHostPermission.CREATE, + ) # Check resource policy's max_vfolder_count if max_vfolder_count > 0: @@ -611,7 +611,6 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon # }, # ): # pass - assert folder_host is not None options = {} if max_quota_scope_size and max_quota_scope_size > 0: options["initial_max_size_for_quota_scope"] = max_quota_scope_size @@ -651,7 +650,7 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon "ownership_type": VFolderOwnershipType(ownership_type), "user": user_uuid if ownership_type == "user" else None, "group": group_uuid if ownership_type == "group" else None, - "unmanaged_path": "", + "unmanaged_path": unmanaged_path, "cloneable": params.cloneable, "status": VFolderOperationStatus.READY, } @@ -671,10 +670,6 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon "status": VFolderOperationStatus.READY, } if unmanaged_path: - insert_values.update({ - "host": "", - "unmanaged_path": unmanaged_path, - }) resp["unmanaged_path"] = unmanaged_path try: query = sa.insert(vfolders, insert_values) diff --git a/src/ai/backend/manager/models/storage.py b/src/ai/backend/manager/models/storage.py index fa03091658..ce95f41360 100644 --- a/src/ai/backend/manager/models/storage.py +++ b/src/ai/backend/manager/models/storage.py @@ -32,6 +32,7 @@ from sqlalchemy.ext.asyncio import AsyncSession as SASession from sqlalchemy.orm import joinedload, load_only, selectinload +from ai.backend.common.defs import NOOP_STORAGE_VOLUME_NAME from ai.backend.common.types import ( HardwareMetadata, VFolderHostPermission, @@ -122,6 +123,14 @@ def split_host(vfolder_host: str) -> Tuple[str, str]: proxy_name, _, volume_name = vfolder_host.partition(":") return proxy_name, volume_name + @staticmethod + def parse_host(proxy_name: str, volume_name: str) -> str: + return f"{proxy_name}:{volume_name}" + + @classmethod + def is_noop_host(cls, vfolder_host: str) -> bool: + return cls.split_host(vfolder_host)[1] == NOOP_STORAGE_VOLUME_NAME + async def get_all_volumes(self) -> Iterable[Tuple[str, VolumeInfo]]: """ Returns a list of tuple diff --git a/src/ai/backend/manager/models/vfolder.py b/src/ai/backend/manager/models/vfolder.py index 1a74190b6a..a74e12830c 100644 --- a/src/ai/backend/manager/models/vfolder.py +++ b/src/ai/backend/manager/models/vfolder.py @@ -21,6 +21,7 @@ Sequence, TypeAlias, cast, + overload, override, ) @@ -838,6 +839,29 @@ async def get_allowed_vfolder_hosts_by_user( return allowed_hosts +@overload +def check_overlapping_mounts(mounts: Iterable[str]) -> None: + pass + + +@overload +def check_overlapping_mounts(mounts: Iterable[PurePosixPath]) -> None: + pass + + +def check_overlapping_mounts(mounts: Iterable[str] | Iterable[PurePosixPath]) -> None: + for p1 in mounts: + for p2 in mounts: + _p1 = PurePosixPath(p1) + _p2 = PurePosixPath(p2) + if _p1 == _p2: + continue + if _p1.is_relative_to(_p2): + raise InvalidAPIParameters( + f"VFolder path '{_p1}' overlaps with '{_p2}'", + ) + + async def prepare_vfolder_mounts( conn: SAConnection, storage_manager: StorageSessionManager, @@ -852,6 +876,9 @@ async def prepare_vfolder_mounts( Determine the actual mount information from the requested vfolder lists, vfolder configurations, and the given user scope. """ + # TODO: Refactor the whole function: + # - Replace 'requested_mount_references', 'requested_mount_reference_map' and 'requested_mount_reference_options' with one mapping parameter. + # - DO NOT validate value of subdirectories here. requested_mounts: list[str] = [ name for name in requested_mount_references if isinstance(name, str) ] @@ -867,24 +894,12 @@ async def prepare_vfolder_mounts( vfolder_ids_to_resolve = [ vfid for vfid in requested_mount_references if isinstance(vfid, uuid.UUID) ] - query = ( - sa.select([vfolders.c.id, vfolders.c.name]) - .select_from(vfolders) - .where(vfolders.c.id.in_(vfolder_ids_to_resolve)) - ) - result = await conn.execute(query) - - for vfid, name in result.fetchall(): - requested_mounts.append(name) - if path := requested_mount_reference_map.get(vfid): - requested_mount_map[name] = path - if options := requested_mount_reference_options.get(vfid): - requested_mount_options[name] = options requested_vfolder_names: dict[str, str] = {} requested_vfolder_subpaths: dict[str, str] = {} requested_vfolder_dstpaths: dict[str, str] = {} matched_vfolder_mounts: list[VFolderMount] = [] + _already_resolved: set[str] = set() # Split the vfolder name and subpaths for key in requested_mounts: @@ -895,6 +910,7 @@ async def prepare_vfolder_mounts( ) requested_vfolder_names[key] = name requested_vfolder_subpaths[key] = os.path.normpath(subpath) + _already_resolved.add(name) for key, value in requested_mount_map.items(): requested_vfolder_dstpaths[key] = value @@ -911,14 +927,17 @@ async def prepare_vfolder_mounts( # Query the accessible vfolders that satisfy either: # - the name matches with the requested vfolder name, or # - the name starts with a dot (dot-prefixed vfolder) for automatic mounting. - extra_vf_conds = vfolders.c.name.startswith(".") & vfolders.c.status.not_in( - DEAD_VFOLDER_STATUSES - ) + extra_vf_conds = vfolders.c.name.startswith(".") if requested_vfolder_names: - extra_vf_conds = extra_vf_conds | ( - vfolders.c.name.in_(requested_vfolder_names.values()) - & vfolders.c.status.not_in(DEAD_VFOLDER_STATUSES) + extra_vf_conds = sa.or_( + extra_vf_conds, vfolders.c.name.in_(requested_vfolder_names.values()) + ) + if vfolder_ids_to_resolve: + extra_vf_conds = sa.or_( + extra_vf_conds, + VFolderRow.id.in_(vfolder_ids_to_resolve), ) + extra_vf_conds = sa.and_(extra_vf_conds, VFolderRow.status.not_in(DEAD_VFOLDER_STATUSES)) accessible_vfolders = await query_accessible_vfolders( conn, user_scope.user_uuid, @@ -934,7 +953,19 @@ async def prepare_vfolder_mounts( raise VFolderNotFound("There is no accessible vfolders at all.") else: return [] - accessible_vfolders_map = {vfolder["name"]: vfolder for vfolder in accessible_vfolders} + for row in accessible_vfolders: + vfid = row["id"] + name = row["name"] + if name in _already_resolved: + continue + requested_mounts.append(name) + if path := requested_mount_reference_map.get(vfid): + requested_mount_map[name] = path + if options := requested_mount_reference_options.get(vfid): + requested_mount_options[name] = options + + # Check if there are overlapping mount sources + check_overlapping_mounts(requested_mounts) # add automount folder list into requested_vfolder_names # and requested_vfolder_subpath @@ -944,6 +975,7 @@ async def prepare_vfolder_mounts( requested_vfolder_subpaths.setdefault(_vfolder["name"], ".") # for vfolder in accessible_vfolders: + accessible_vfolders_map = {vfolder["name"]: vfolder for vfolder in accessible_vfolders} for key, vfolder_name in requested_vfolder_names.items(): if not (vfolder := accessible_vfolders_map.get(vfolder_name)): raise VFolderNotFound(f"VFolder {vfolder_name} is not found or accessible.") @@ -957,6 +989,24 @@ async def prepare_vfolder_mounts( group_id=user_scope.group_id, permission=VFolderHostPermission.MOUNT_IN_SESSION, ) + if unmanaged_path := cast(Optional[str], vfolder["unmanaged_path"]): + kernel_path_raw = requested_vfolder_dstpaths.get(key) + if kernel_path_raw is None: + kernel_path = PurePosixPath(f"/home/work/{vfolder['name']}") + else: + kernel_path = PurePosixPath(kernel_path_raw) + matched_vfolder_mounts.append( + VFolderMount( + name=vfolder["name"], + vfid=VFolderID(vfolder["quota_scope_id"], vfolder["id"]), + vfsubpath=PurePosixPath("."), + host_path=PurePosixPath(unmanaged_path), + kernel_path=kernel_path, + mount_perm=vfolder["permission"], + usage_mode=vfolder["usage_mode"], + ) + ) + continue if vfolder["group"] is not None and vfolder["group"] != str(user_scope.group_id): # User's accessible group vfolders should not be mounted # if they do not belong to the execution kernel. @@ -1033,14 +1083,7 @@ async def prepare_vfolder_mounts( ) # Check if there are overlapping mount targets - for vf1 in matched_vfolder_mounts: - for vf2 in matched_vfolder_mounts: - if vf1.name == vf2.name: - continue - if vf1.kernel_path.is_relative_to(vf2.kernel_path): - raise InvalidAPIParameters( - f"VFolder mount path {vf1.kernel_path} overlaps with {vf2.kernel_path}", - ) + check_overlapping_mounts([trgt.kernel_path for trgt in matched_vfolder_mounts]) return matched_vfolder_mounts @@ -1123,6 +1166,10 @@ async def ensure_host_permission_allowed( domain_name: str, group_id: Optional[uuid.UUID] = None, ) -> None: + from .storage import StorageSessionManager + + if StorageSessionManager.is_noop_host(folder_host): + return allowed_hosts = await filter_host_allowed_permission( db_conn, allowed_vfolder_types=allowed_vfolder_types, @@ -1203,7 +1250,7 @@ async def _insert_vfolder() -> None: "ownership_type": VFolderOwnershipType("user"), "user": vfolder_info.user_id, "group": None, - "unmanaged_path": "", + "unmanaged_path": None, "cloneable": vfolder_info.cloneable, "quota_scope_id": vfolder_info.source_vfolder_id.quota_scope_id, }