Coverage for app/services/openstack_client.py: 86.76%
68 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1"""Shared OpenStack client layer for FastAPI endpoints.
3Three responsibilities:
51. Build auth kwargs from the encrypted user credentials (single
6 source of truth).
72. Open one connection per request, exposed as a context manager so
8 endpoints can close it cleanly.
93. A process-local TTL cache for list responses: the wizard fires
10 several GETs in quick succession, and a 60s cache avoids a Keystone
11 token refresh per click. A frontend refresh button triggers
12 ``invalidate_user``.
13"""
15from __future__ import annotations
17import logging
18import threading
19import time
20from collections.abc import Callable, Iterator
21from contextlib import contextmanager, suppress
22from typing import Any
23from uuid import UUID
25import openstack
26from fastapi import HTTPException, status
27from sqlalchemy.orm import Session
29from app.crud import openstack_credentials as crud_creds
30from app.models import User
32logger = logging.getLogger(__name__)
35# ----------------------------------------------------------------
36# Connection-Bau
37# ----------------------------------------------------------------
38def _build_connect_kwargs(creds: dict) -> dict:
39 """Build the kwargs dict for ``openstack.connect`` from decrypted
40 user credentials. Supports password and application-credential
41 (v3applicationcredential) auth.
42 """
43 base = {
44 "auth_url": creds["auth_url"],
45 "region_name": creds.get("region_name"),
46 "interface": creds.get("interface") or "public",
47 "identity_api_version": creds.get("identity_api_version") or "3",
48 }
49 if creds["auth_type"] == "v3applicationcredential":
50 base.update(
51 {
52 "auth_type": "v3applicationcredential",
53 "application_credential_id": creds["identifier"],
54 "application_credential_secret": creds["secret"],
55 }
56 )
57 else:
58 base.update(
59 {
60 "auth_type": "password",
61 "username": creds["identifier"],
62 "password": creds["secret"],
63 "project_id": creds.get("project_id"),
64 "project_name": creds.get("project_name"),
65 "user_domain_name": creds.get("user_domain_name"),
66 "project_domain_name": creds.get("project_domain_name")
67 or creds.get("user_domain_name"),
68 }
69 )
70 return base
73@contextmanager
74def user_connection(db: Session, user: User) -> Iterator[Any]:
75 """Yield an ``openstack.Connection`` for the user.
77 - 412 if no credentials are stored (frontend shows a CTA banner)
78 - 502 for a transient OpenStack error on connect (500s are reserved
79 for backend bugs)
80 """
81 try:
82 creds = crud_creds.get_decrypted_for_backend(db, user.userId)
83 except crud_creds.NoCredentialError:
84 raise HTTPException(
85 status_code=status.HTTP_412_PRECONDITION_FAILED,
86 detail={"reason": "openstack_credentials_missing"},
87 )
89 conn = None
90 try:
91 conn = openstack.connect(**_build_connect_kwargs(creds))
92 yield conn
93 except HTTPException:
94 raise
95 except Exception as exc: # noqa: BLE001 — SDK raises many types
96 logger.warning(
97 "OpenStack connect failed for user %s: %s", user.userId, exc
98 )
99 raise HTTPException(
100 status_code=status.HTTP_502_BAD_GATEWAY,
101 detail={"reason": "openstack_unavailable", "message": str(exc)},
102 )
103 finally:
104 # Close politely; the SDK tolerates leaving it to GC. Ignore errors.
105 if conn is not None:
106 with suppress(Exception):
107 conn.close()
110# ----------------------------------------------------------------
111# TTL-Cache für Resource-Listen
112# ----------------------------------------------------------------
113# Key = (user_id, resource_kind, frozenset of filter items)
114# Value = (expiry_epoch, data)
115_CacheKey = tuple[str, str, frozenset]
116_cache: dict[_CacheKey, tuple[float, list[dict]]] = {}
117_cache_lock = threading.Lock()
118_TTL_SECONDS = 60.0
121def _make_key(user_id: UUID, kind: str, filters: dict | None) -> _CacheKey:
122 items: frozenset = frozenset((filters or {}).items())
123 return (str(user_id), kind, items)
126def cached_list(
127 user_id: UUID,
128 kind: str,
129 filters: dict | None,
130 fetch: Callable[[], list[dict]],
131) -> list[dict]:
132 """TTL-cache wrapper. ``fetch`` is called only when no valid entry
133 exists. The cache is process-local and in-memory; multiple backend
134 instances run independent caches, which is fine since the data is
135 allowed to be up to 60s stale.
136 """
137 key = _make_key(user_id, kind, filters)
138 now = time.monotonic()
140 with _cache_lock:
141 cached = _cache.get(key)
142 if cached and cached[0] > now:
143 return cached[1]
145 # Two concurrent requests may both fetch here; inefficient but not
146 # incorrect, and simpler than a per-key lock map.
147 data = fetch()
149 with _cache_lock:
150 _cache[key] = (now + _TTL_SECONDS, data)
152 return data
155def invalidate_user(user_id: UUID, kind: str | None = None) -> int:
156 """Invalidate the cache for one user (triggered by the frontend
157 refresh button). Without ``kind`` all resource types for the user
158 are removed. Returns the number of removed entries.
159 """
160 user_str = str(user_id)
161 removed = 0
162 with _cache_lock:
163 for key in list(_cache.keys()):
164 if key[0] != user_str:
165 continue
166 if kind is not None and key[1] != kind:
167 continue
168 del _cache[key]
169 removed += 1
170 return removed