Skip to content

Commit 9d78a05

Browse files
committed
feat: Assign unmanaged vfolders to noop storage host
1 parent f41635e commit 9d78a05

File tree

5 files changed

+109
-57
lines changed

5 files changed

+109
-57
lines changed

changes/3630.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Assign the noop storage host to unmanaged vfolders

docs/manager/rest-reference/openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"info": {
44
"title": "Backend.AI Manager API",
55
"description": "Backend.AI Manager REST API specification",
6-
"version": "25.1.1",
6+
"version": "25.2.0",
77
"contact": {
88
"name": "Lablup Inc.",
99
"url": "https://docs.backend.ai",

src/ai/backend/manager/api/vfolder.py

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
from ai.backend.common import msgpack, redis_helper
5050
from ai.backend.common import typed_validators as tv
5151
from ai.backend.common import validators as tx
52-
from ai.backend.common.defs import VFOLDER_GROUP_PERMISSION_MODE
52+
from ai.backend.common.defs import NOOP_STORAGE_VOLUME_NAME, VFOLDER_GROUP_PERMISSION_MODE
5353
from ai.backend.common.types import (
5454
QuotaScopeID,
5555
QuotaScopeType,
@@ -408,19 +408,21 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon
408408
)
409409
folder_host = params.folder_host
410410
unmanaged_path = params.unmanaged_path
411+
# Resolve host for the new virtual folder.
412+
if not folder_host:
413+
folder_host = await root_ctx.shared_config.etcd.get("volumes/default_host")
414+
if not folder_host:
415+
raise InvalidAPIParameters(
416+
"You must specify the vfolder host because the default host is not configured."
417+
)
411418
# Check if user is trying to created unmanaged vFolder
412419
if unmanaged_path:
413420
# Approve only if user is Admin or Superadmin
414421
if user_role not in (UserRole.ADMIN, UserRole.SUPERADMIN):
415422
raise GenericForbidden("Insufficient permission")
416-
else:
417-
# Resolve host for the new virtual folder.
418-
if not folder_host:
419-
folder_host = await root_ctx.shared_config.etcd.get("volumes/default_host")
420-
if not folder_host:
421-
raise InvalidAPIParameters(
422-
"You must specify the vfolder host because the default host is not configured."
423-
)
423+
# Assign ghost host to unmanaged vfolder
424+
proxy, _ = root_ctx.storage_manager.split_host(folder_host)
425+
folder_host = root_ctx.storage_manager.parse_host(proxy, NOOP_STORAGE_VOLUME_NAME)
424426

425427
allowed_vfolder_types = await root_ctx.shared_config.get_vfolder_types()
426428

@@ -531,18 +533,16 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon
531533
)
532534

533535
async with root_ctx.db.begin() as conn:
534-
if not unmanaged_path:
535-
assert folder_host is not None
536-
await ensure_host_permission_allowed(
537-
conn,
538-
folder_host,
539-
allowed_vfolder_types=allowed_vfolder_types,
540-
user_uuid=user_uuid,
541-
resource_policy=keypair_resource_policy,
542-
domain_name=domain_name,
543-
group_id=group_uuid,
544-
permission=VFolderHostPermission.CREATE,
545-
)
536+
await ensure_host_permission_allowed(
537+
conn,
538+
folder_host,
539+
allowed_vfolder_types=allowed_vfolder_types,
540+
user_uuid=user_uuid,
541+
resource_policy=keypair_resource_policy,
542+
domain_name=domain_name,
543+
group_id=group_uuid,
544+
permission=VFolderHostPermission.CREATE,
545+
)
546546

547547
# Check resource policy's max_vfolder_count
548548
if max_vfolder_count > 0:
@@ -611,7 +611,6 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon
611611
# },
612612
# ):
613613
# pass
614-
assert folder_host is not None
615614
options = {}
616615
if max_quota_scope_size and max_quota_scope_size > 0:
617616
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
651650
"ownership_type": VFolderOwnershipType(ownership_type),
652651
"user": user_uuid if ownership_type == "user" else None,
653652
"group": group_uuid if ownership_type == "group" else None,
654-
"unmanaged_path": "",
653+
"unmanaged_path": unmanaged_path,
655654
"cloneable": params.cloneable,
656655
"status": VFolderOperationStatus.READY,
657656
}
@@ -671,10 +670,6 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon
671670
"status": VFolderOperationStatus.READY,
672671
}
673672
if unmanaged_path:
674-
insert_values.update({
675-
"host": "",
676-
"unmanaged_path": unmanaged_path,
677-
})
678673
resp["unmanaged_path"] = unmanaged_path
679674
try:
680675
query = sa.insert(vfolders, insert_values)

src/ai/backend/manager/models/storage.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from sqlalchemy.ext.asyncio import AsyncSession as SASession
3333
from sqlalchemy.orm import joinedload, load_only, selectinload
3434

35+
from ai.backend.common.defs import NOOP_STORAGE_VOLUME_NAME
3536
from ai.backend.common.types import (
3637
HardwareMetadata,
3738
VFolderHostPermission,
@@ -122,6 +123,14 @@ def split_host(vfolder_host: str) -> Tuple[str, str]:
122123
proxy_name, _, volume_name = vfolder_host.partition(":")
123124
return proxy_name, volume_name
124125

126+
@staticmethod
127+
def parse_host(proxy_name: str, volume_name: str) -> str:
128+
return f"{proxy_name}:{volume_name}"
129+
130+
@classmethod
131+
def is_noop_host(cls, vfolder_host: str) -> bool:
132+
return cls.split_host(vfolder_host)[1] == NOOP_STORAGE_VOLUME_NAME
133+
125134
async def get_all_volumes(self) -> Iterable[Tuple[str, VolumeInfo]]:
126135
"""
127136
Returns a list of tuple

src/ai/backend/manager/models/vfolder.py

Lines changed: 76 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
Sequence,
2222
TypeAlias,
2323
cast,
24+
overload,
2425
override,
2526
)
2627

@@ -838,6 +839,29 @@ async def get_allowed_vfolder_hosts_by_user(
838839
return allowed_hosts
839840

840841

842+
@overload
843+
def check_overlapping_mounts(mounts: Iterable[str]) -> None:
844+
pass
845+
846+
847+
@overload
848+
def check_overlapping_mounts(mounts: Iterable[PurePosixPath]) -> None:
849+
pass
850+
851+
852+
def check_overlapping_mounts(mounts: Iterable[str] | Iterable[PurePosixPath]) -> None:
853+
for p1 in mounts:
854+
for p2 in mounts:
855+
_p1 = PurePosixPath(p1)
856+
_p2 = PurePosixPath(p2)
857+
if _p1 == _p2:
858+
continue
859+
if _p1.is_relative_to(_p2):
860+
raise InvalidAPIParameters(
861+
f"VFolder path '{_p1}' overlaps with '{_p2}'",
862+
)
863+
864+
841865
async def prepare_vfolder_mounts(
842866
conn: SAConnection,
843867
storage_manager: StorageSessionManager,
@@ -852,6 +876,9 @@ async def prepare_vfolder_mounts(
852876
Determine the actual mount information from the requested vfolder lists,
853877
vfolder configurations, and the given user scope.
854878
"""
879+
# TODO: Refactor the whole function:
880+
# - Replace 'requested_mount_references', 'requested_mount_reference_map' and 'requested_mount_reference_options' with one mapping parameter.
881+
# - DO NOT validate value of subdirectories here.
855882
requested_mounts: list[str] = [
856883
name for name in requested_mount_references if isinstance(name, str)
857884
]
@@ -867,24 +894,12 @@ async def prepare_vfolder_mounts(
867894
vfolder_ids_to_resolve = [
868895
vfid for vfid in requested_mount_references if isinstance(vfid, uuid.UUID)
869896
]
870-
query = (
871-
sa.select([vfolders.c.id, vfolders.c.name])
872-
.select_from(vfolders)
873-
.where(vfolders.c.id.in_(vfolder_ids_to_resolve))
874-
)
875-
result = await conn.execute(query)
876-
877-
for vfid, name in result.fetchall():
878-
requested_mounts.append(name)
879-
if path := requested_mount_reference_map.get(vfid):
880-
requested_mount_map[name] = path
881-
if options := requested_mount_reference_options.get(vfid):
882-
requested_mount_options[name] = options
883897

884898
requested_vfolder_names: dict[str, str] = {}
885899
requested_vfolder_subpaths: dict[str, str] = {}
886900
requested_vfolder_dstpaths: dict[str, str] = {}
887901
matched_vfolder_mounts: list[VFolderMount] = []
902+
_already_resolved: set[str] = set()
888903

889904
# Split the vfolder name and subpaths
890905
for key in requested_mounts:
@@ -895,6 +910,7 @@ async def prepare_vfolder_mounts(
895910
)
896911
requested_vfolder_names[key] = name
897912
requested_vfolder_subpaths[key] = os.path.normpath(subpath)
913+
_already_resolved.add(name)
898914
for key, value in requested_mount_map.items():
899915
requested_vfolder_dstpaths[key] = value
900916

@@ -911,14 +927,17 @@ async def prepare_vfolder_mounts(
911927
# Query the accessible vfolders that satisfy either:
912928
# - the name matches with the requested vfolder name, or
913929
# - the name starts with a dot (dot-prefixed vfolder) for automatic mounting.
914-
extra_vf_conds = vfolders.c.name.startswith(".") & vfolders.c.status.not_in(
915-
DEAD_VFOLDER_STATUSES
916-
)
930+
extra_vf_conds = vfolders.c.name.startswith(".")
917931
if requested_vfolder_names:
918-
extra_vf_conds = extra_vf_conds | (
919-
vfolders.c.name.in_(requested_vfolder_names.values())
920-
& vfolders.c.status.not_in(DEAD_VFOLDER_STATUSES)
932+
extra_vf_conds = sa.or_(
933+
extra_vf_conds, vfolders.c.name.in_(requested_vfolder_names.values())
934+
)
935+
if vfolder_ids_to_resolve:
936+
extra_vf_conds = sa.or_(
937+
extra_vf_conds,
938+
VFolderRow.id.in_(vfolder_ids_to_resolve),
921939
)
940+
extra_vf_conds = sa.and_(extra_vf_conds, VFolderRow.status.not_in(DEAD_VFOLDER_STATUSES))
922941
accessible_vfolders = await query_accessible_vfolders(
923942
conn,
924943
user_scope.user_uuid,
@@ -934,7 +953,19 @@ async def prepare_vfolder_mounts(
934953
raise VFolderNotFound("There is no accessible vfolders at all.")
935954
else:
936955
return []
937-
accessible_vfolders_map = {vfolder["name"]: vfolder for vfolder in accessible_vfolders}
956+
for row in accessible_vfolders:
957+
vfid = row["id"]
958+
name = row["name"]
959+
if name in _already_resolved:
960+
continue
961+
requested_mounts.append(name)
962+
if path := requested_mount_reference_map.get(vfid):
963+
requested_mount_map[name] = path
964+
if options := requested_mount_reference_options.get(vfid):
965+
requested_mount_options[name] = options
966+
967+
# Check if there are overlapping mount sources
968+
check_overlapping_mounts(requested_mounts)
938969

939970
# add automount folder list into requested_vfolder_names
940971
# and requested_vfolder_subpath
@@ -944,6 +975,7 @@ async def prepare_vfolder_mounts(
944975
requested_vfolder_subpaths.setdefault(_vfolder["name"], ".")
945976

946977
# for vfolder in accessible_vfolders:
978+
accessible_vfolders_map = {vfolder["name"]: vfolder for vfolder in accessible_vfolders}
947979
for key, vfolder_name in requested_vfolder_names.items():
948980
if not (vfolder := accessible_vfolders_map.get(vfolder_name)):
949981
raise VFolderNotFound(f"VFolder {vfolder_name} is not found or accessible.")
@@ -957,6 +989,24 @@ async def prepare_vfolder_mounts(
957989
group_id=user_scope.group_id,
958990
permission=VFolderHostPermission.MOUNT_IN_SESSION,
959991
)
992+
if unmanaged_path := cast(Optional[str], vfolder["unmanaged_path"]):
993+
kernel_path_raw = requested_vfolder_dstpaths.get(key)
994+
if kernel_path_raw is None:
995+
kernel_path = PurePosixPath(f"/home/work/{vfolder['name']}")
996+
else:
997+
kernel_path = PurePosixPath(kernel_path_raw)
998+
matched_vfolder_mounts.append(
999+
VFolderMount(
1000+
name=vfolder["name"],
1001+
vfid=VFolderID(vfolder["quota_scope_id"], vfolder["id"]),
1002+
vfsubpath=PurePosixPath("."),
1003+
host_path=PurePosixPath(unmanaged_path),
1004+
kernel_path=kernel_path,
1005+
mount_perm=vfolder["permission"],
1006+
usage_mode=vfolder["usage_mode"],
1007+
)
1008+
)
1009+
continue
9601010
if vfolder["group"] is not None and vfolder["group"] != str(user_scope.group_id):
9611011
# User's accessible group vfolders should not be mounted
9621012
# if they do not belong to the execution kernel.
@@ -1033,14 +1083,7 @@ async def prepare_vfolder_mounts(
10331083
)
10341084

10351085
# Check if there are overlapping mount targets
1036-
for vf1 in matched_vfolder_mounts:
1037-
for vf2 in matched_vfolder_mounts:
1038-
if vf1.name == vf2.name:
1039-
continue
1040-
if vf1.kernel_path.is_relative_to(vf2.kernel_path):
1041-
raise InvalidAPIParameters(
1042-
f"VFolder mount path {vf1.kernel_path} overlaps with {vf2.kernel_path}",
1043-
)
1086+
check_overlapping_mounts([trgt.kernel_path for trgt in matched_vfolder_mounts])
10441087

10451088
return matched_vfolder_mounts
10461089

@@ -1123,6 +1166,10 @@ async def ensure_host_permission_allowed(
11231166
domain_name: str,
11241167
group_id: Optional[uuid.UUID] = None,
11251168
) -> None:
1169+
from .storage import StorageSessionManager
1170+
1171+
if StorageSessionManager.is_noop_host(folder_host):
1172+
return
11261173
allowed_hosts = await filter_host_allowed_permission(
11271174
db_conn,
11281175
allowed_vfolder_types=allowed_vfolder_types,
@@ -1203,7 +1250,7 @@ async def _insert_vfolder() -> None:
12031250
"ownership_type": VFolderOwnershipType("user"),
12041251
"user": vfolder_info.user_id,
12051252
"group": None,
1206-
"unmanaged_path": "",
1253+
"unmanaged_path": None,
12071254
"cloneable": vfolder_info.cloneable,
12081255
"quota_scope_id": vfolder_info.source_vfolder_id.quota_scope_id,
12091256
}

0 commit comments

Comments
 (0)