Skip to content

Commit

Permalink
feat: Assign unmanaged vfolders to noop storage host
Browse files Browse the repository at this point in the history
  • Loading branch information
fregataa committed Feb 10, 2025
1 parent f41635e commit 9d78a05
Show file tree
Hide file tree
Showing 5 changed files with 109 additions and 57 deletions.
1 change: 1 addition & 0 deletions changes/3630.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Assign the noop storage host to unmanaged vfolders
2 changes: 1 addition & 1 deletion docs/manager/rest-reference/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 22 additions & 27 deletions src/ai/backend/manager/api/vfolder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions src/ai/backend/manager/models/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
105 changes: 76 additions & 29 deletions src/ai/backend/manager/models/vfolder.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
Sequence,
TypeAlias,
cast,
overload,
override,
)

Expand Down Expand Up @@ -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,
Expand All @@ -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)
]
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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.")
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
Expand Down

0 comments on commit 9d78a05

Please sign in to comment.