import asyncio import time from typing import Any, Optional from fastapi import APIRouter, Depends from decnet.env import DECNET_DEVELOPER from decnet.telemetry import traced as _traced from decnet.web.dependencies import require_viewer, repo from decnet.web.db.models import UserResponse router = APIRouter() _DEFAULT_DEPLOYMENT_LIMIT = 10 _DEFAULT_MUTATION_INTERVAL = "30m" # Cache config_limits / config_globals reads — these change on rare admin # writes but get polled constantly by the UI and locust. _STATE_TTL = 5.0 _state_cache: dict[str, tuple[Optional[dict[str, Any]], float]] = {} _state_locks: dict[str, asyncio.Lock] = {} # Admin branch fetched repo.list_users() on every /config call — cache 5s, # invalidate on user create/update/delete so the admin UI stays consistent. _USERS_TTL = 5.0 _users_cache: tuple[Optional[list[dict[str, Any]]], float] = (None, 0.0) _users_lock: Optional[asyncio.Lock] = None def _reset_state_cache() -> None: """Reset cached config state — used by tests.""" global _users_cache, _users_lock _state_cache.clear() # Drop any locks bound to the previous event loop — reusing one from # a dead loop deadlocks the next test. _state_locks.clear() _users_cache = (None, 0.0) _users_lock = None def invalidate_list_users_cache() -> None: global _users_cache _users_cache = (None, 0.0) async def _get_list_users_cached() -> list[dict[str, Any]]: global _users_cache, _users_lock value, ts = _users_cache now = time.monotonic() if value is not None and now - ts < _USERS_TTL: return value if _users_lock is None: _users_lock = asyncio.Lock() async with _users_lock: value, ts = _users_cache now = time.monotonic() if value is not None and now - ts < _USERS_TTL: return value value = await repo.list_users() _users_cache = (value, time.monotonic()) return value async def _get_state_cached(name: str) -> Optional[dict[str, Any]]: entry = _state_cache.get(name) now = time.monotonic() if entry is not None and now - entry[1] < _STATE_TTL: return entry[0] lock = _state_locks.setdefault(name, asyncio.Lock()) async with lock: entry = _state_cache.get(name) now = time.monotonic() if entry is not None and now - entry[1] < _STATE_TTL: return entry[0] value = await repo.get_state(name) _state_cache[name] = (value, time.monotonic()) return value @router.get( "/config", tags=["Configuration"], responses={ 401: {"description": "Could not validate credentials"}, 403: {"description": "Insufficient permissions"}, }, ) @_traced("api.get_config") async def api_get_config(user: dict = Depends(require_viewer)) -> dict: limits_state = await _get_state_cached("config_limits") globals_state = await _get_state_cached("config_globals") deployment_limit = ( limits_state.get("deployment_limit", _DEFAULT_DEPLOYMENT_LIMIT) if limits_state else _DEFAULT_DEPLOYMENT_LIMIT ) global_mutation_interval = ( globals_state.get("global_mutation_interval", _DEFAULT_MUTATION_INTERVAL) if globals_state else _DEFAULT_MUTATION_INTERVAL ) base = { "role": user["role"], "deployment_limit": deployment_limit, "global_mutation_interval": global_mutation_interval, } if user["role"] == "admin": all_users = await _get_list_users_cached() base["users"] = [ UserResponse( uuid=u["uuid"], username=u["username"], role=u["role"], must_change_password=u["must_change_password"], ).model_dump() for u in all_users ] if DECNET_DEVELOPER: base["developer_mode"] = True return base