Coverage for app/routers/openstack_resources.py: 56.59%

129 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-25 15:51 +0000

1""" 

2Read API for the OpenStack resources of the calling user. 

3 

4Used by the wizard so the user no longer has to copy UUIDs from the 

5Horizon dashboard. Each endpoint: 

6 

7- Authenticates via Keycloak token 

8- Obtains an OpenStack connection from the service layer (per-request) 

9- Caches the response 60 s process-locally (see ``services/openstack_client``) 

10- Reduces the SDK object to a flat dict — only the fields that the 

11 frontend needs for display + selection. We do not want to leak SDK 

12 structure (sensitive fields, unwanted size). 

13 

14Error strategy: 502 for OpenStack-side failures (no 500 — that is 

15reserved for "backend bug"). The frontend then renders a banner 

16"OpenStack not reachable, enter ID manually". 412 if credentials are missing. 

17""" 

18 

19from __future__ import annotations 

20 

21import logging 

22from typing import Any 

23 

24from fastapi import APIRouter, Depends, HTTPException, Query, status 

25from sqlalchemy.orm import Session 

26 

27from app.database import get_db 

28from app.models import User 

29from app.services import openstack_client 

30from app.utils.keycloak_auth import get_current_user_keycloak 

31 

32logger = logging.getLogger(__name__) 

33 

34router = APIRouter() 

35 

36 

37# ---------------------------------------------------------------- 

38# Helpers 

39# ---------------------------------------------------------------- 

40def _safe_get(obj: Any, *names: str, default: Any = None) -> Any: 

41 """ 

42 SDK objects are partly dict-like, partly property objects. 

43 We try all passed names in order. 

44 """ 

45 for n in names: 

46 try: 

47 v = getattr(obj, n, None) 

48 if v is not None: 

49 return v 

50 except Exception: # noqa: BLE001 — some properties raise lazily 

51 continue 

52 return default 

53 

54 

55def _list_with_oserror( 

56 user: User, 

57 kind: str, 

58 filters: dict | None, 

59 fetch_fn, 

60) -> list[dict]: 

61 """ 

62 Wrapper that runs ``fetch_fn`` through the TTL cache and 

63 translates OpenStack exceptions into 502s. 

64 """ 

65 try: 

66 return openstack_client.cached_list( 

67 user_id=user.userId, 

68 kind=kind, 

69 filters=filters, 

70 fetch=fetch_fn, 

71 ) 

72 except HTTPException: 

73 raise 

74 except Exception as exc: # noqa: BLE001 

75 logger.warning( 

76 "OpenStack list %s failed for user %s: %s", kind, user.userId, exc 

77 ) 

78 raise HTTPException( 

79 status_code=status.HTTP_502_BAD_GATEWAY, 

80 detail={"reason": "openstack_list_failed", "kind": kind, "message": str(exc)}, 

81 ) 

82 

83 

84# ---------------------------------------------------------------- 

85# Cache-Refresh 

86# ---------------------------------------------------------------- 

87@router.post("/refresh", status_code=status.HTTP_204_NO_CONTENT) 

88def refresh_cache( 

89 kind: str | None = Query(default=None, description="Optional: nur diese Resource-Art invalidieren"), 

90 current_user: User = Depends(get_current_user_keycloak), 

91): 

92 """ 

93 Cache bust for the calling user. Triggered by a click on the 

94 "Refresh" button next to a picker — the user has just created a 

95 new resource in Horizon and wants to see it. 

96 """ 

97 removed = openstack_client.invalidate_user(current_user.userId, kind) 

98 logger.info("Cache invalidated for user %s (kind=%s, %d entries removed)", 

99 current_user.userId, kind, removed) 

100 return None 

101 

102 

103# ---------------------------------------------------------------- 

104# Networks 

105# ---------------------------------------------------------------- 

106@router.get("/networks") 

107def list_networks( 

108 db: Session = Depends(get_db), 

109 current_user: User = Depends(get_current_user_keycloak), 

110): 

111 """Lists all networks that the user can see in their project. 

112 

113 ``shared`` and ``router_external`` are included so the frontend can 

114 render "External Network" hints. 

115 """ 

116 def fetch() -> list[dict]: 

117 with openstack_client.user_connection(db, current_user) as conn: 

118 return [ 

119 { 

120 "id": _safe_get(n, "id"), 

121 "name": _safe_get(n, "name") or "", 

122 "description": _safe_get(n, "description") or "", 

123 "shared": bool(_safe_get(n, "is_shared", "shared", default=False)), 

124 "external": bool(_safe_get(n, "is_router_external", "router:external", default=False)), 

125 "status": _safe_get(n, "status") or "", 

126 } 

127 for n in conn.network.networks() 

128 ] 

129 

130 return _list_with_oserror(current_user, "networks", None, fetch) 

131 

132 

133# ---------------------------------------------------------------- 

134# Subnets — optionally filtered by network 

135# ---------------------------------------------------------------- 

136@router.get("/subnets") 

137def list_subnets( 

138 network_id: str | None = Query(default=None, description="Filter: nur Subnets in diesem Network"), 

139 db: Session = Depends(get_db), 

140 current_user: User = Depends(get_current_user_keycloak), 

141): 

142 """ 

143 With ``network_id``: subnets of that network. Without: all subnets in 

144 the project. Filtering happens server-side (OpenStack API), not only 

145 after the cache — otherwise we would have a separate cache key per 

146 network, and the unfiltered list cache would never help. 

147 """ 

148 filters: dict[str, Any] = {} 

149 if network_id: 

150 filters["network_id"] = network_id 

151 

152 def fetch() -> list[dict]: 

153 with openstack_client.user_connection(db, current_user) as conn: 

154 kwargs: dict[str, Any] = {} 

155 if network_id: 

156 kwargs["network_id"] = network_id 

157 return [ 

158 { 

159 "id": _safe_get(s, "id"), 

160 "name": _safe_get(s, "name") or "", 

161 "cidr": _safe_get(s, "cidr") or "", 

162 "ip_version": _safe_get(s, "ip_version", default=4), 

163 "network_id": _safe_get(s, "network_id"), 

164 "gateway_ip": _safe_get(s, "gateway_ip") or "", 

165 } 

166 for s in conn.network.subnets(**kwargs) 

167 ] 

168 

169 return _list_with_oserror(current_user, "subnets", filters or None, fetch) 

170 

171 

172# ---------------------------------------------------------------- 

173# Flavors 

174# ---------------------------------------------------------------- 

175@router.get("/flavors") 

176def list_flavors( 

177 db: Session = Depends(get_db), 

178 current_user: User = Depends(get_current_user_keycloak), 

179): 

180 """ 

181 Compute flavors with the three spec fields the user really wants 

182 (CPU/RAM/Disk). ``is_public=False`` means private — we still emit 

183 it, the frontend can render a note. 

184 """ 

185 def fetch() -> list[dict]: 

186 with openstack_client.user_connection(db, current_user) as conn: 

187 out: list[dict] = [] 

188 for f in conn.compute.flavors(get_extra_specs=False): 

189 out.append({ 

190 "id": _safe_get(f, "id"), 

191 "name": _safe_get(f, "name") or "", 

192 "vcpus": _safe_get(f, "vcpus", default=0) or 0, 

193 "ram": _safe_get(f, "ram", default=0) or 0, # MB 

194 "disk": _safe_get(f, "disk", default=0) or 0, # GB 

195 "is_public": bool(_safe_get(f, "is_public", default=True)), 

196 }) 

197 return out 

198 

199 return _list_with_oserror(current_user, "flavors", None, fetch) 

200 

201 

202# ---------------------------------------------------------------- 

203# Images 

204# ---------------------------------------------------------------- 

205@router.get("/images") 

206def list_images( 

207 status_filter: str = Query( 

208 default="active", 

209 alias="status", 

210 description="OS Image Status (default: active). Alle Stati: 'all'", 

211 ), 

212 db: Session = Depends(get_db), 

213 current_user: User = Depends(get_current_user_keycloak), 

214): 

215 """ 

216 Default ``status=active`` — we do not want to show "queued" or 

217 "deleted" images in the picker. ``status=all`` for power users who 

218 really want to see everything. 

219 """ 

220 filters = {"status": status_filter} 

221 

222 def fetch() -> list[dict]: 

223 with openstack_client.user_connection(db, current_user) as conn: 

224 kwargs: dict[str, Any] = {} 

225 if status_filter and status_filter != "all": 

226 kwargs["status"] = status_filter 

227 out: list[dict] = [] 

228 for img in conn.image.images(**kwargs): 

229 out.append({ 

230 "id": _safe_get(img, "id"), 

231 "name": _safe_get(img, "name") or "", 

232 "status": _safe_get(img, "status") or "", 

233 "visibility": _safe_get(img, "visibility") or "", 

234 "size": _safe_get(img, "size") or 0, # bytes 

235 "disk_format": _safe_get(img, "disk_format") or "", 

236 }) 

237 return out 

238 

239 return _list_with_oserror(current_user, "images", filters, fetch) 

240 

241 

242# ---------------------------------------------------------------- 

243# Keypairs 

244# ---------------------------------------------------------------- 

245@router.get("/keypairs") 

246def list_keypairs( 

247 db: Session = Depends(get_db), 

248 current_user: User = Depends(get_current_user_keycloak), 

249): 

250 """ 

251 SSH keypairs of the user. Here the identity is always the ``name``, 

252 never the ID — Keystone keypairs do have IDs but Terraform modules 

253 use the name. 

254 """ 

255 def fetch() -> list[dict]: 

256 with openstack_client.user_connection(db, current_user) as conn: 

257 return [ 

258 { 

259 "name": _safe_get(k, "name") or "", 

260 "fingerprint": _safe_get(k, "fingerprint") or "", 

261 "type": _safe_get(k, "type") or "ssh", 

262 # ``id`` equals the name for a keypair — we duplicate this 

263 # intentionally so the picker can uniformly read ``id``. 

264 "id": _safe_get(k, "name") or "", 

265 } 

266 for k in conn.compute.keypairs() 

267 ] 

268 

269 return _list_with_oserror(current_user, "keypairs", None, fetch) 

270 

271 

272# ---------------------------------------------------------------- 

273# Security Groups 

274# ---------------------------------------------------------------- 

275@router.get("/security-groups") 

276def list_security_groups( 

277 db: Session = Depends(get_db), 

278 current_user: User = Depends(get_current_user_keycloak), 

279): 

280 def fetch() -> list[dict]: 

281 with openstack_client.user_connection(db, current_user) as conn: 

282 return [ 

283 { 

284 "id": _safe_get(sg, "id"), 

285 "name": _safe_get(sg, "name") or "", 

286 "description": _safe_get(sg, "description") or "", 

287 } 

288 for sg in conn.network.security_groups() 

289 ] 

290 

291 return _list_with_oserror(current_user, "security_groups", None, fetch) 

292 

293 

294# ---------------------------------------------------------------- 

295# Floating IP Pools (External Networks) 

296# ---------------------------------------------------------------- 

297@router.get("/floating-ip-pools") 

298def list_floating_ip_pools( 

299 db: Session = Depends(get_db), 

300 current_user: User = Depends(get_current_user_keycloak), 

301): 

302 """ 

303 There is no dedicated ``Pool`` resource in OpenStack — pools are 

304 networks with ``router:external = true``. Terraform modules usually 

305 expect the **name** of the external network. 

306 """ 

307 def fetch() -> list[dict]: 

308 with openstack_client.user_connection(db, current_user) as conn: 

309 out: list[dict] = [] 

310 for n in conn.network.networks(): 

311 is_ext = bool(_safe_get(n, "is_router_external", "router:external", default=False)) 

312 if not is_ext: 

313 continue 

314 out.append({ 

315 "id": _safe_get(n, "id"), 

316 "name": _safe_get(n, "name") or "", 

317 "description": _safe_get(n, "description") or "", 

318 }) 

319 return out 

320 

321 return _list_with_oserror(current_user, "floating_ip_pools", None, fetch) 

322 

323 

324# ---------------------------------------------------------------- 

325# Volumes 

326# ---------------------------------------------------------------- 

327@router.get("/volumes") 

328def list_volumes( 

329 db: Session = Depends(get_db), 

330 current_user: User = Depends(get_current_user_keycloak), 

331): 

332 """ 

333 Cinder volumes. The frontend filters ``status`` itself if needed 

334 — we emit all of them, because a user can well attach a second 

335 instance to an ``in-use`` volume. 

336 """ 

337 def fetch() -> list[dict]: 

338 with openstack_client.user_connection(db, current_user) as conn: 

339 return [ 

340 { 

341 "id": _safe_get(v, "id"), 

342 "name": _safe_get(v, "name") or "", 

343 "size": _safe_get(v, "size") or 0, # GB 

344 "status": _safe_get(v, "status") or "", 

345 "volume_type": _safe_get(v, "volume_type") or "", 

346 "bootable": bool(_safe_get(v, "is_bootable", "bootable", default=False)), 

347 } 

348 for v in conn.volume.volumes() 

349 ] 

350 

351 return _list_with_oserror(current_user, "volumes", None, fetch) 

352 

353 

354# ---------------------------------------------------------------- 

355# Routers 

356# ---------------------------------------------------------------- 

357@router.get("/routers") 

358def list_routers( 

359 db: Session = Depends(get_db), 

360 current_user: User = Depends(get_current_user_keycloak), 

361): 

362 def fetch() -> list[dict]: 

363 with openstack_client.user_connection(db, current_user) as conn: 

364 return [ 

365 { 

366 "id": _safe_get(r, "id"), 

367 "name": _safe_get(r, "name") or "", 

368 "status": _safe_get(r, "status") or "", 

369 "external_gateway_info": _safe_get(r, "external_gateway_info") or None, 

370 } 

371 for r in conn.network.routers() 

372 ] 

373 

374 return _list_with_oserror(current_user, "routers", None, fetch) 

375 

376 

377# ---------------------------------------------------------------- 

378# Availability Zones 

379# ---------------------------------------------------------------- 

380@router.get("/availability-zones") 

381def list_availability_zones( 

382 service: str = Query( 

383 default="compute", 

384 description="OpenStack-Service: compute (Nova), network (Neutron), volume (Cinder)", 

385 ), 

386 db: Session = Depends(get_db), 

387 current_user: User = Depends(get_current_user_keycloak), 

388): 

389 """ 

390 AZs differ per service. Default: compute (Nova), because that is 

391 the most common use case (VM placement). 

392 """ 

393 filters = {"service": service} 

394 

395 def fetch() -> list[dict]: 

396 with openstack_client.user_connection(db, current_user) as conn: 

397 if service == "compute": 

398 source = conn.compute.availability_zones() 

399 elif service == "network": 

400 source = conn.network.availability_zones() 

401 elif service == "volume": 

402 source = conn.volume.availability_zones() 

403 else: 

404 raise HTTPException( 

405 status_code=status.HTTP_400_BAD_REQUEST, 

406 detail=f"Unknown service '{service}' (compute|network|volume)", 

407 ) 

408 out: list[dict] = [] 

409 for az in source: 

410 name = _safe_get(az, "name") or "" 

411 if not name: 

412 continue 

413 out.append({ 

414 # AZs have no UUID — the name IS the ID. 

415 "id": name, 

416 "name": name, 

417 "state": _safe_get(az, "state", "zoneState") or "", 

418 }) 

419 return out 

420 

421 return _list_with_oserror(current_user, f"availability_zones_{service}", filters, fetch)