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

1"""Shared OpenStack client layer for FastAPI endpoints. 

2 

3Three responsibilities: 

4 

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""" 

14 

15from __future__ import annotations 

16 

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 

24 

25import openstack 

26from fastapi import HTTPException, status 

27from sqlalchemy.orm import Session 

28 

29from app.crud import openstack_credentials as crud_creds 

30from app.models import User 

31 

32logger = logging.getLogger(__name__) 

33 

34 

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 

71 

72 

73@contextmanager 

74def user_connection(db: Session, user: User) -> Iterator[Any]: 

75 """Yield an ``openstack.Connection`` for the user. 

76 

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 ) 

88 

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() 

108 

109 

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 

119 

120 

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) 

124 

125 

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() 

139 

140 with _cache_lock: 

141 cached = _cache.get(key) 

142 if cached and cached[0] > now: 

143 return cached[1] 

144 

145 # Two concurrent requests may both fetch here; inefficient but not 

146 # incorrect, and simpler than a per-key lock map. 

147 data = fetch() 

148 

149 with _cache_lock: 

150 _cache[key] = (now + _TTL_SECONDS, data) 

151 

152 return data 

153 

154 

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