e4dcfc49aa
Tests / Lint and Format (push) Waiting to run
Tests / Web Node Tests (push) Waiting to run
Tests / Import Check (Python 3.11) (push) Waiting to run
Tests / Import Check (Python 3.12) (push) Waiting to run
Tests / Import Check (Python 3.13) (push) Waiting to run
Tests / Import Check (Python 3.14) (push) Waiting to run
Tests / Python Tests (Python 3.11) (push) Blocked by required conditions
Tests / Python Tests (Python 3.12) (push) Blocked by required conditions
Tests / Python Tests (Python 3.13) (push) Blocked by required conditions
Tests / Python Tests (Python 3.14) (push) Blocked by required conditions
Tests / Test Summary (push) Blocked by required conditions
163 lines
5.4 KiB
Python
163 lines
5.4 KiB
Python
"""Path resolution for admin-local and per-user workspaces.
|
|
|
|
Everything lives under ``<runtime-home>/data`` so a deployment has exactly
|
|
one tree to mount and back up:
|
|
|
|
* ``data/user`` — the admin workspace (admin scope root is ``data/``)
|
|
* ``data/users/<uid>`` — one workspace per non-admin user
|
|
* ``data/partners/<id>`` — partner (synthetic-user) workspaces
|
|
* ``data/system`` — deployment state: accounts, grants, audit. Never
|
|
mounted into the sandbox runner — see ``docker-compose.yml``.
|
|
|
|
Deployments upgraded from the sibling ``multi-user/`` layout are migrated
|
|
in place by :func:`migrate_legacy_multi_user_tree`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
import logging
|
|
from pathlib import Path
|
|
import shutil
|
|
import threading
|
|
from typing import Iterator
|
|
|
|
from deeptutor.runtime.home import get_runtime_home
|
|
from deeptutor.services.path_service import PathService
|
|
|
|
from .models import LOCAL_ADMIN_ID, LOCAL_ADMIN_USERNAME, CurrentUser, UserScope
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROJECT_ROOT = get_runtime_home()
|
|
ADMIN_WORKSPACE_ROOT = PROJECT_ROOT / "data"
|
|
USERS_ROOT = ADMIN_WORKSPACE_ROOT / "users"
|
|
SYSTEM_ROOT = ADMIN_WORKSPACE_ROOT / "system"
|
|
LEGACY_MULTI_USER_ROOT = PROJECT_ROOT / "multi-user"
|
|
|
|
_path_services: dict[str, PathService] = {}
|
|
|
|
_legacy_migration_lock = threading.Lock()
|
|
_legacy_migration_done = False
|
|
|
|
|
|
def migrate_legacy_multi_user_tree() -> None:
|
|
"""One-time move of the pre-v1.5 sibling ``multi-user/`` tree into ``data/``.
|
|
|
|
``multi-user/_system`` becomes ``data/system``; every other child is a
|
|
user id directory and becomes ``data/users/<uid>``. Existing targets are
|
|
never overwritten — leftovers stay in place and are logged so an operator
|
|
can reconcile by hand. Idempotent and cheap once migrated (one existence
|
|
check), so callers on the auth/grants/workspace read paths can invoke it
|
|
unconditionally.
|
|
"""
|
|
global _legacy_migration_done
|
|
if _legacy_migration_done:
|
|
return
|
|
with _legacy_migration_lock:
|
|
if _legacy_migration_done:
|
|
return
|
|
_legacy_migration_done = True
|
|
legacy = LEGACY_MULTI_USER_ROOT
|
|
if not legacy.is_dir():
|
|
return
|
|
leftovers: list[str] = []
|
|
for child in sorted(legacy.iterdir()):
|
|
target = SYSTEM_ROOT if child.name == "_system" else USERS_ROOT / child.name
|
|
if target.exists():
|
|
leftovers.append(child.name)
|
|
continue
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.move(str(child), str(target))
|
|
logger.info("Migrated legacy multi-user path %s -> %s", child, target)
|
|
if leftovers:
|
|
logger.warning(
|
|
"Legacy multi-user tree partially migrated; reconcile by hand: %s",
|
|
", ".join(str(legacy / name) for name in leftovers),
|
|
)
|
|
return
|
|
try:
|
|
legacy.rmdir()
|
|
except OSError:
|
|
logger.warning("Could not remove legacy multi-user root %s", legacy)
|
|
|
|
|
|
def admin_scope() -> UserScope:
|
|
return UserScope(kind="admin", user_id=LOCAL_ADMIN_ID, root=ADMIN_WORKSPACE_ROOT.resolve())
|
|
|
|
|
|
def local_admin_user() -> CurrentUser:
|
|
return CurrentUser(
|
|
id=LOCAL_ADMIN_ID,
|
|
username=LOCAL_ADMIN_USERNAME,
|
|
role="admin",
|
|
scope=admin_scope(),
|
|
)
|
|
|
|
|
|
def scope_for_user(user_id: str, *, is_admin: bool) -> UserScope:
|
|
if is_admin:
|
|
return admin_scope()
|
|
migrate_legacy_multi_user_tree()
|
|
return UserScope(kind="user", user_id=user_id, root=(USERS_ROOT / user_id).resolve())
|
|
|
|
|
|
def ensure_user_workspace(user_id: str) -> Path:
|
|
return ensure_scope_workspace(scope_for_user(user_id, is_admin=False))
|
|
|
|
|
|
def ensure_scope_workspace(scope: UserScope) -> Path:
|
|
"""Create the workspace tree for *scope* at its own root.
|
|
|
|
Resolving from ``scope.root`` (instead of recomputing ``USERS_ROOT /
|
|
user_id``) keeps this correct for synthetic scopes whose root lives
|
|
elsewhere — e.g. partner workspaces under ``data/partners/<id>/workspace``.
|
|
For regular users both paths are identical.
|
|
"""
|
|
root = scope.root.resolve()
|
|
PathService(workspace_root=root).ensure_all_directories()
|
|
(root / "knowledge_bases").mkdir(parents=True, exist_ok=True)
|
|
(root / "memory").mkdir(parents=True, exist_ok=True)
|
|
return root
|
|
|
|
|
|
def ensure_system_dirs() -> None:
|
|
migrate_legacy_multi_user_tree()
|
|
for child in ("auth", "grants", "audit", "indexes"):
|
|
(SYSTEM_ROOT / child).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def get_path_service_for_scope(scope: UserScope) -> PathService:
|
|
key = scope.cache_key
|
|
service = _path_services.get(key)
|
|
if service is None:
|
|
service = PathService(workspace_root=scope.root)
|
|
_path_services[key] = service
|
|
return service
|
|
|
|
|
|
def get_admin_path_service() -> PathService:
|
|
return get_path_service_for_scope(admin_scope())
|
|
|
|
|
|
def get_current_path_service() -> PathService:
|
|
from .context import get_current_user_or_none
|
|
|
|
user = get_current_user_or_none()
|
|
if user is None:
|
|
return PathService.get_instance()
|
|
if user.scope.kind == "user":
|
|
ensure_scope_workspace(user.scope)
|
|
return get_path_service_for_scope(user.scope)
|
|
|
|
|
|
@contextmanager
|
|
def user_context(user: CurrentUser) -> Iterator[None]:
|
|
from .context import reset_current_user, set_current_user
|
|
|
|
token = set_current_user(user)
|
|
try:
|
|
yield
|
|
finally:
|
|
reset_current_user(token)
|