Coverage for app/routers/quotas.py: 86.42%
81 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
1import logging
3import openstack
4from fastapi import APIRouter, Depends, HTTPException, status
5from pydantic import BaseModel
6from sqlalchemy.orm import Session
8from app.crud import openstack_credentials as crud_creds
9from app.database import get_db
10from app.models import User
11from app.utils.keycloak_auth import get_current_user_keycloak
13logger = logging.getLogger(__name__)
15router = APIRouter()
18class QuotaItem(BaseModel):
19 used: int
20 limit: int
21 available: int
22 unit: str | None = None
25class ComputeQuotas(BaseModel):
26 instances: QuotaItem
27 vcpus: QuotaItem
28 ram: QuotaItem
31class StorageQuotas(BaseModel):
32 volumes: QuotaItem
33 snapshots: QuotaItem
34 gigabytes: QuotaItem
37class NetworkQuotas(BaseModel):
38 floating_ips: QuotaItem
39 security_groups: QuotaItem
40 security_group_rules: QuotaItem
41 networks: QuotaItem
42 ports: QuotaItem
43 routers: QuotaItem
46class QuotaOverviewResponse(BaseModel):
47 compute: ComputeQuotas
48 storage: StorageQuotas
49 network: NetworkQuotas
52def _build_connect_kwargs(creds: dict) -> dict:
53 base = {
54 "auth_url": creds["auth_url"],
55 "region_name": creds.get("region_name"),
56 "interface": creds.get("interface") or "public",
57 "identity_api_version": creds.get("identity_api_version") or "3",
58 }
59 if creds["auth_type"] == "v3applicationcredential":
60 base.update({
61 "auth_type": "v3applicationcredential",
62 "application_credential_id": creds["identifier"],
63 "application_credential_secret": creds["secret"],
64 })
65 else:
66 base.update({
67 "auth_type": "password",
68 "username": creds["identifier"],
69 "password": creds["secret"],
70 "project_id": creds.get("project_id"),
71 "project_name": creds.get("project_name"),
72 "user_domain_name": creds.get("user_domain_name"),
73 "project_domain_name": creds.get("project_domain_name") or creds.get("user_domain_name"),
74 })
75 return base
78def _quota_item(used: int, obj, attr: str, default: int, unit: str | None = None) -> QuotaItem:
79 """Build a QuotaItem, reading the limit from ``obj.attr`` (with ``default``)
80 exactly once. ``available`` is ``limit - used`` and is intentionally not
81 clamped, matching the existing behavior."""
82 limit = getattr(obj, attr, default)
83 return QuotaItem(used=used, limit=limit, available=limit - used, unit=unit)
86def _get_openstack_conn_for_user(db: Session, user: User):
87 """Build a per-user OpenStack connection from the stored credential row."""
88 try:
89 creds = crud_creds.get_decrypted_for_backend(db, user.userId)
90 except crud_creds.NoCredentialError:
91 raise HTTPException(
92 status_code=status.HTTP_412_PRECONDITION_FAILED,
93 detail={"reason": "openstack_credentials_missing"},
94 )
95 return openstack.connect(**_build_connect_kwargs(creds))
98@router.get("/overview", response_model=QuotaOverviewResponse)
99def get_quota_overview(
100 db: Session = Depends(get_db),
101 current_user: User = Depends(get_current_user_keycloak),
102):
103 """
104 Fetch the OpenStack quota overview for compute, storage and network
105 from the user's **personal** OpenStack project.
107 Declared sync (not async) on purpose: every call inside hits the
108 OpenStack SDK with blocking network I/O. A sync def runs in
109 Starlette's threadpool, so a slow OpenStack response doesn't stall
110 the event loop and starve other endpoints (e.g. /dashboard/stats,
111 /users/me) that are mounted on the same dashboard view.
113 Returns:
114 QuotaOverviewResponse with used/limit/available for all resources
115 """
116 try:
117 conn = _get_openstack_conn_for_user(db, current_user)
118 project_id = conn.current_project_id
120 # === COMPUTE QUOTAS ===
121 try:
122 compute_limits = conn.compute.get_quota_set(project_id)
123 compute_usage = conn.compute.get_limits()
125 compute = ComputeQuotas(
126 instances=_quota_item(
127 getattr(compute_usage.absolute, 'total_instances_used', 0),
128 compute_limits, 'instances', 0,
129 ),
130 vcpus=_quota_item(
131 getattr(compute_usage.absolute, 'total_cores_used', 0),
132 compute_limits, 'cores', 0,
133 ),
134 ram=_quota_item(
135 getattr(compute_usage.absolute, 'total_ram_used', 0),
136 compute_limits, 'ram', 0, unit="MB",
137 )
138 )
139 except Exception:
140 logger.exception("Failed to fetch compute quotas for user")
141 raise HTTPException(status_code=500, detail="Failed to fetch compute quotas")
143 # === STORAGE QUOTAS ===
144 volume_limits = conn.volume.get_quota_set(project_id)
145 volumes = list(conn.volume.volumes())
146 snapshots = list(conn.volume.snapshots())
147 total_gb_used = sum(v.size for v in volumes)
149 storage = StorageQuotas(
150 volumes=QuotaItem(
151 used=len(volumes),
152 limit=volume_limits.volumes,
153 available=volume_limits.volumes - len(volumes)
154 ),
155 snapshots=QuotaItem(
156 used=len(snapshots),
157 limit=volume_limits.snapshots,
158 available=volume_limits.snapshots - len(snapshots)
159 ),
160 gigabytes=QuotaItem(
161 used=total_gb_used,
162 limit=volume_limits.gigabytes,
163 available=volume_limits.gigabytes - total_gb_used,
164 unit="GB"
165 )
166 )
168 # === NETWORK QUOTAS ===
169 network_limits = conn.network.get_quota(project_id)
171 # Count the actual resource usage.
172 floating_ips_used = len(list(conn.network.ips()))
173 security_groups_used = len(list(conn.network.security_groups()))
174 networks_used = len(list(conn.network.networks()))
175 ports_used = len(list(conn.network.ports()))
176 routers_used = len(list(conn.network.routers()))
178 # Count security group rules across all security groups.
179 sg_rules_used = sum(
180 len(list(conn.network.security_group_rules(security_group_id=sg.id)))
181 for sg in conn.network.security_groups()
182 )
184 network = NetworkQuotas(
185 floating_ips=_quota_item(floating_ips_used, network_limits, 'floatingip', 50),
186 security_groups=_quota_item(security_groups_used, network_limits, 'security_group', 10),
187 security_group_rules=_quota_item(sg_rules_used, network_limits, 'security_group_rule', 100),
188 networks=_quota_item(networks_used, network_limits, 'network', 100),
189 ports=_quota_item(ports_used, network_limits, 'port', 500),
190 routers=_quota_item(routers_used, network_limits, 'router', 10)
191 )
193 return QuotaOverviewResponse(compute=compute, storage=storage, network=network)
195 except HTTPException:
196 # Preserve the 412 Precondition Failed when credentials are missing.
197 raise
198 except Exception:
199 logger.exception("Failed to fetch quotas for user")
200 raise HTTPException(
201 status_code=500,
202 detail="Failed to fetch quotas",
203 )