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

1import logging 

2 

3import openstack 

4from fastapi import APIRouter, Depends, HTTPException, status 

5from pydantic import BaseModel 

6from sqlalchemy.orm import Session 

7 

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 

12 

13logger = logging.getLogger(__name__) 

14 

15router = APIRouter() 

16 

17 

18class QuotaItem(BaseModel): 

19 used: int 

20 limit: int 

21 available: int 

22 unit: str | None = None 

23 

24 

25class ComputeQuotas(BaseModel): 

26 instances: QuotaItem 

27 vcpus: QuotaItem 

28 ram: QuotaItem 

29 

30 

31class StorageQuotas(BaseModel): 

32 volumes: QuotaItem 

33 snapshots: QuotaItem 

34 gigabytes: QuotaItem 

35 

36 

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 

44 

45 

46class QuotaOverviewResponse(BaseModel): 

47 compute: ComputeQuotas 

48 storage: StorageQuotas 

49 network: NetworkQuotas 

50 

51 

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 

76 

77 

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) 

84 

85 

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

96 

97 

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. 

106 

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. 

112 

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 

119 

120 # === COMPUTE QUOTAS === 

121 try: 

122 compute_limits = conn.compute.get_quota_set(project_id) 

123 compute_usage = conn.compute.get_limits() 

124 

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

142 

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) 

148 

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 ) 

167 

168 # === NETWORK QUOTAS === 

169 network_limits = conn.network.get_quota(project_id) 

170 

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

177 

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 ) 

183 

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 ) 

192 

193 return QuotaOverviewResponse(compute=compute, storage=storage, network=network) 

194 

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 )