Skip to content

Commit 32d8e69

Browse files
jopemachineclaude
andauthored
fix(BA-6022): restore legacy 'vfname/subpath' mount syntax in session creation API (#11582)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5d73b8c commit 32d8e69

3 files changed

Lines changed: 263 additions & 73 deletions

File tree

changes/11582.fix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Restore session creation REST API support for the legacy `mounts=['vfname/subpath']` form, including the v1 CLI `-v vfname/sub:/dest` shape that routes the destination through `mount_map`.

src/ai/backend/manager/api/rest/session/handler.py

Lines changed: 135 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
import asyncio
1212
import logging
1313
from collections.abc import Mapping, Sequence
14+
from dataclasses import dataclass
1415
from datetime import timedelta
1516
from http import HTTPStatus
17+
from itertools import chain
1618
from typing import TYPE_CHECKING, Any, Final, cast
1719
from uuid import UUID
1820

@@ -233,18 +235,56 @@ def _validate_creation_config(
233235
) from e
234236

235237

238+
@dataclass(frozen=True, slots=True)
239+
class LegacyMountResolution:
240+
"""One resolved legacy name-keyed mount entry.
241+
242+
Produced by :meth:`SessionHandler._resolve_legacy_name_mounts` and
243+
consumed by :func:`_merge_resolved_legacy_mounts`. Carries both the
244+
resolved vfolder UUID and any subpath suffix peeled off the legacy
245+
``"name/subpath"`` key so the merge step needs a single map keyed by
246+
vfolder name.
247+
"""
248+
249+
vfid: UUID
250+
subpath: str | None = None
251+
252+
253+
def _split_legacy_mount_key(key: str) -> tuple[str, str | None]:
254+
"""Peel an optional ``/<subpath>`` suffix off a legacy mount key.
255+
256+
The v1 CLI flattens ``-v vfname/sub:/dest`` into ``source="vfname/sub"``
257+
+ a separate ``target="/dest"``; the REST payload then carries the
258+
``vfname/sub`` source verbatim across ``mounts`` AND the ``mount_map``
259+
/ ``mount_options`` keys. This helper recovers the bare vfolder name
260+
so name resolution and dst/opts lookup all key off the same string.
261+
"""
262+
name, sep, subpath = key.partition("/")
263+
if sep and subpath:
264+
return name, subpath
265+
return key, None
266+
267+
236268
def _route_legacy_uuid_mounts(creation_config: dict[str, Any]) -> dict[str, Any]:
237-
"""Lift UUID-shaped strings from legacy mount buckets onto the UUID-keyed
238-
buckets, leaving only name-shaped entries for the name resolver.
269+
"""Split mixed legacy mount input by key shape so legacy buckets
270+
(``mounts`` / ``mount_map``) carry only names and id buckets
271+
(``mount_ids`` / ``mount_id_map``) carry only UUIDs.
272+
273+
Each entry's key may be a vfolder name or a UUID-shaped string;
274+
UUID-shaped keys are parsed to ``UUID`` and lifted into the id
275+
buckets, names stay in the legacy buckets for the async resolver.
276+
``mount_options`` is the one exception — both halves coexist on
277+
output until :func:`_merge_resolved_legacy_mounts` re-keys the
278+
name half onto the resolved UUIDs.
239279
"""
240280
legacy_mounts = creation_config.get("mounts") or ()
241281
legacy_mount_map = creation_config.get("mount_map") or {}
242282
legacy_mount_options = creation_config.get("mount_options") or {}
243283

244-
mount_ids: list[Any] = list(creation_config.get("mount_ids") or [])
245-
mount_id_map: dict[Any, str] = dict(creation_config.get("mount_id_map") or {})
246-
mount_options: dict[Any, Any] = {}
247-
name_mounts: list[Any] = []
284+
mount_ids: list[UUID] = list(creation_config.get("mount_ids") or [])
285+
mount_id_map: dict[UUID, str] = dict(creation_config.get("mount_id_map") or {})
286+
mount_options: dict[str | UUID, Any] = {}
287+
name_mounts: list[str] = []
248288
name_mount_map: dict[str, str] = {}
249289

250290
legacy_mounts_keys = {str(m) for m in legacy_mounts}
@@ -284,44 +324,77 @@ def _route_legacy_uuid_mounts(creation_config: dict[str, Any]) -> dict[str, Any]
284324

285325
def _merge_resolved_legacy_mounts(
286326
creation_config: dict[str, Any],
287-
name_to_id: dict[str, UUID],
327+
resolutions: Mapping[str, LegacyMountResolution],
288328
) -> dict[str, Any]:
289-
"""Merge a ``name → UUID`` resolution into the UUID-keyed buckets of
290-
``creation_config`` and drop the now-resolved name-keyed legacy keys.
329+
"""Merge resolved legacy name-keyed mounts into the UUID-keyed buckets
330+
of ``creation_config`` and drop the now-resolved name-keyed legacy keys.
291331
292332
The resolved ids are appended to ``mount_ids`` (skipping duplicates),
293333
and entries from the name-keyed ``mount_map`` / ``mount_options``
294334
dicts are re-keyed onto the resolved UUIDs without overwriting an
295335
explicit UUID-keyed entry the caller already supplied.
336+
337+
When a resolution carries a ``subpath`` (the caller passed a legacy
338+
``mounts`` entry in the ``"vfname/subdir"`` shape), it is injected
339+
into ``mount_options[uuid]["subpath"]`` so the downstream UUID-keyed
340+
consumer (``prepare_vfolder_mounts``) sees the same shape as a
341+
modern caller supplying ``mount_options`` directly.
296342
"""
297-
if not name_to_id:
343+
if not resolutions:
298344
return creation_config
299345

300-
merged_mount_ids: list[Any] = list(creation_config.get("mount_ids") or [])
301-
existing_uuid_set: set[UUID] = set()
302-
for rid in merged_mount_ids:
303-
try:
304-
existing_uuid_set.add(UUID(str(rid)))
305-
except (ValueError, TypeError):
306-
continue
307-
merged_mount_id_map: dict[Any, str] = dict(creation_config.get("mount_id_map") or {})
308-
merged_mount_options: dict[Any, Any] = dict(creation_config.get("mount_options") or {})
346+
# ``mount_ids``/``mount_id_map`` are Pydantic-validated to ``list[UUID]``
347+
# / ``dict[UUID, str]`` upstream and ``_route_legacy_uuid_mounts`` only
348+
# ever appends ``UUID`` instances, so trust the typing here.
349+
merged_mount_ids: list[UUID] = list(creation_config.get("mount_ids") or [])
350+
existing_uuid_set: set[UUID] = set(merged_mount_ids)
351+
merged_mount_id_map: dict[UUID, str] = dict(creation_config.get("mount_id_map") or {})
352+
merged_mount_options: dict[str | UUID, Any] = dict(creation_config.get("mount_options") or {})
309353
legacy_mount_map = creation_config.get("mount_map") or {}
310354
legacy_mount_options = creation_config.get("mount_options") or {}
311355

312-
for name, vfid in name_to_id.items():
356+
# Re-key legacy mount_map/mount_options entries onto the bare vfolder
357+
# name so a CLI-shaped ``{"vfname/sub": "/dest"}`` resolves alongside
358+
# the bare-name lookup. First-wins if both shapes appear for one name.
359+
mount_map_by_name: dict[str, str] = {}
360+
for raw, dst in legacy_mount_map.items():
361+
name, _ = _split_legacy_mount_key(str(raw))
362+
mount_map_by_name.setdefault(name, dst)
363+
364+
mount_options_by_name: dict[str, Any] = {}
365+
for raw, opts in legacy_mount_options.items():
366+
name, _ = _split_legacy_mount_key(str(raw))
367+
mount_options_by_name.setdefault(name, opts)
368+
369+
for name, res in resolutions.items():
370+
vfid = res.vfid
313371
if vfid not in existing_uuid_set:
314372
merged_mount_ids.append(vfid)
315373
existing_uuid_set.add(vfid)
316-
if (dst := legacy_mount_map.get(name)) and vfid not in merged_mount_id_map:
374+
if (dst := mount_map_by_name.get(name)) and vfid not in merged_mount_id_map:
317375
merged_mount_id_map[vfid] = dst
318-
if (opts := legacy_mount_options.get(name)) and vfid not in merged_mount_options:
376+
if (opts := mount_options_by_name.get(name)) and vfid not in merged_mount_options:
319377
merged_mount_options[vfid] = opts
378+
if res.subpath is not None:
379+
existing_opts = merged_mount_options.get(vfid) or {}
380+
# Do not clobber a subpath explicitly supplied via the modern
381+
# UUID-keyed ``mount_options`` surface for the same vfolder —
382+
# the explicit value wins over the legacy name/subpath form.
383+
if "subpath" not in existing_opts:
384+
merged_mount_options[vfid] = {**existing_opts, "subpath": res.subpath}
385+
386+
# Drop any leftover name-keyed entries from ``mount_options`` — once
387+
# they have been re-keyed onto the resolved UUID, the original
388+
# name-keyed copy is dead weight that downstream consumers (which
389+
# look up by UUID only) would never read.
390+
uuid_keyed_mount_options: dict[UUID, Any] = {
391+
k: v for k, v in merged_mount_options.items() if isinstance(k, UUID)
392+
}
320393

321394
next_config = dict(creation_config)
322395
next_config["mount_ids"] = merged_mount_ids
323396
next_config["mount_id_map"] = merged_mount_id_map
324-
next_config["mount_options"] = merged_mount_options
397+
next_config["mount_options"] = uuid_keyed_mount_options
325398
next_config.pop("mounts", None)
326399
next_config.pop("mount_map", None)
327400
return next_config
@@ -357,74 +430,62 @@ async def _normalize_legacy_mounts(self, validated_config: dict[str, Any]) -> di
357430
if not (legacy_mounts or legacy_mount_map or legacy_mount_options):
358431
return validated_config
359432
validated_config = _route_legacy_uuid_mounts(validated_config)
360-
name_to_id = await self._resolve_legacy_name_mounts(
433+
resolutions = await self._resolve_legacy_name_mounts(
361434
validated_config.get("mounts") or (),
362435
validated_config.get("mount_map") or {},
363436
validated_config.get("mount_options") or {},
364437
)
365-
return _merge_resolved_legacy_mounts(validated_config, name_to_id)
438+
return _merge_resolved_legacy_mounts(validated_config, resolutions)
366439

367440
async def _resolve_legacy_name_mounts(
368441
self,
369442
mounts: Sequence[str],
370443
mount_map: Mapping[str, str],
371444
mount_options: Mapping[str, Any],
372-
) -> dict[str, UUID]:
373-
"""Resolve legacy v1 CLI name-keyed mount surfaces into a unified
374-
``name → UUID`` mapping.
375-
376-
``mounts`` (list of names), ``mount_map`` (dict keyed by name), and
377-
``mount_options`` (dict keyed by name) are all name-based legacy
378-
inputs populated together by ``-v`` on the v1 CLI; they must be
379-
re-keyed onto UUIDs together so a name that appears only in
380-
``mount_map`` or ``mount_options`` is not silently dropped
381-
downstream.
382-
383-
Subpath syntax (``name/subdir``) is rejected here because
384-
:class:`MountInfoEntry` carries no subpath field; silently dropping
385-
it would mount the wrong directory. Subpath mounts must use the
386-
UUID-keyed surface where the storage proxy handles vfsubpath
387-
separately.
388-
389-
Merging the resolved ids/maps back into ``creation_config`` is the
390-
caller's responsibility — see :func:`_merge_resolved_legacy_mounts`.
445+
) -> dict[str, LegacyMountResolution]:
446+
"""Resolve legacy v1 CLI name-keyed mount surfaces into a map
447+
``name → LegacyMountResolution(vfid, subpath_or_None)``.
448+
449+
``mounts`` (list of names, optionally ``"<name>/<subpath>"``),
450+
``mount_map`` (dict keyed by name, optionally with the same
451+
``"<name>/<subpath>"`` suffix), and ``mount_options`` (same key
452+
shape) are all name-based legacy inputs populated together by
453+
``-v vfname[/sub][:dst][,opt]`` on the v1 CLI; the CLI keeps the
454+
slash-with-subpath in the source string verbatim, so the same
455+
``"<name>/<sub>"`` key can appear across all three surfaces.
456+
457+
We peel that suffix off every key, resolve only the bare vfolder
458+
name, and attach the parsed subpath to the resolution so
459+
:func:`_merge_resolved_legacy_mounts` can inject it into
460+
``mount_options[uuid]["subpath"]``. Escape validation on the
461+
subpath value is performed downstream by ``prepare_vfolder_mounts``.
391462
"""
392463
if not mounts and not mount_map and not mount_options:
393464
return {}
394465

395-
subpath_entries = [m for m in mounts if "/" in str(m)]
396-
if subpath_entries:
397-
raise InvalidAPIParameters(
398-
"Legacy 'mounts' field with subpath syntax "
399-
f"({subpath_entries}) is not supported. "
400-
"Use UUID-keyed 'mount_ids' / 'mount_id_map' instead."
401-
)
402-
403466
names_to_resolve: list[str] = []
404467
seen: set[str] = set()
405-
406-
# ``mounts`` and ``mount_map`` are strictly name-keyed legacy
407-
# surfaces — every entry is treated as a vfolder name.
408-
for raw in list(mounts) + list(mount_map.keys()):
409-
name = str(raw)
410-
if name in seen:
411-
continue
412-
seen.add(name)
413-
names_to_resolve.append(name)
414-
415-
# ``mount_options`` is the single polymorphic field shared by
416-
# both legacy (name-keyed) and modern (UUID-string-keyed)
417-
# callers; skip keys that already parse as UUID strings since
418-
# they are not vfolder names to resolve.
419-
for raw in mount_options.keys():
420-
name = str(raw)
421-
if name in seen:
422-
continue
468+
subpath_by_name: dict[str, str] = {}
469+
470+
# Walk the three legacy name-keyed surfaces, peel any
471+
# ``"name/subpath"`` suffix, and skip keys whose bare-name parses
472+
# as a UUID — those represent UUID-keyed entries that
473+
# ``_route_legacy_uuid_mounts`` should already have lifted, or
474+
# malformed ``<UUID>/<sub>`` inputs outside the legacy contract.
475+
for raw in chain(mounts, mount_map.keys(), mount_options.keys()):
476+
name, subpath = _split_legacy_mount_key(str(raw))
423477
try:
424478
UUID(name)
425479
continue
426480
except (ValueError, TypeError):
427481
pass
482+
if subpath is not None:
483+
# The same ``-v`` flag populates mounts/mount_map/mount_options
484+
# with the same source, so the subpath should agree across all
485+
# three; if a caller supplies divergent forms, first-wins.
486+
subpath_by_name.setdefault(name, subpath)
487+
if name in seen:
488+
continue
428489
seen.add(name)
429490
names_to_resolve.append(name)
430491

@@ -434,7 +495,10 @@ async def _resolve_legacy_name_mounts(
434495
result = await self._vfolder.resolve_vfolder_ids_by_names.wait_for_complete(
435496
ResolveIdsByNamesAction(vfolder_names=names_to_resolve)
436497
)
437-
return dict(result.name_to_id)
498+
return {
499+
name: LegacyMountResolution(vfid=vfid, subpath=subpath_by_name.get(name))
500+
for name, vfid in result.name_to_id.items()
501+
}
438502

439503
# ------------------------------------------------------------------
440504
# create_from_template (POST /_/create-from-template)

0 commit comments

Comments
 (0)