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
« 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.
4Used by the wizard so the user no longer has to copy UUIDs from the
5Horizon dashboard. Each endpoint:
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).
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"""
19from __future__ import annotations
21import logging
22from typing import Any
24from fastapi import APIRouter, Depends, HTTPException, Query, status
25from sqlalchemy.orm import Session
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
32logger = logging.getLogger(__name__)
34router = APIRouter()
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
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 )
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
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.
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 ]
130 return _list_with_oserror(current_user, "networks", None, fetch)
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
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 ]
169 return _list_with_oserror(current_user, "subnets", filters or None, fetch)
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
199 return _list_with_oserror(current_user, "flavors", None, fetch)
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}
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
239 return _list_with_oserror(current_user, "images", filters, fetch)
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 ]
269 return _list_with_oserror(current_user, "keypairs", None, fetch)
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 ]
291 return _list_with_oserror(current_user, "security_groups", None, fetch)
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
321 return _list_with_oserror(current_user, "floating_ip_pools", None, fetch)
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 ]
351 return _list_with_oserror(current_user, "volumes", None, fetch)
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 ]
374 return _list_with_oserror(current_user, "routers", None, fetch)
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}
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
421 return _list_with_oserror(current_user, f"availability_zones_{service}", filters, fetch)