Coverage for app/main.py: 68.85%

61 statements  

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

1import asyncio 

2import logging 

3import os 

4import threading 

5from contextlib import asynccontextmanager 

6 

7from fastapi import FastAPI 

8from fastapi.middleware.cors import CORSMiddleware 

9 

10from app.config import settings 

11from app.routers import ( 

12 admin_apps, 

13 apps, 

14 auth_keycloak, 

15 courses, 

16 dashboard, 

17 deployments, 

18 openstack_credentials, 

19 openstack_resources, 

20 quotas, 

21 tasks, 

22 teams, 

23 users, 

24) 

25from app.services.celery_event_listener import start_event_listener 

26from app.services.deployment_pubsub import pubsub 

27from app.services.reconciler import run_reconciler 

28 

29logger = logging.getLogger(__name__) 

30 

31 

32# ``DISABLE_BACKGROUND_TASKS`` short-circuits the lifespan body so the 

33# app is fully wired but the Celery listener and reconciler are not 

34# started. Used by the test suite, where per-TestClient lifespans would 

35# otherwise stack daemon threads and exhaust the DB connection pool. 

36def _background_tasks_disabled() -> bool: 

37 return os.getenv("DISABLE_BACKGROUND_TASKS", "").lower() in ("1", "true", "yes") 

38 

39 

40# ---------------------------------------------------------------- 

41# STARTUP/SHUTDOWN 

42# ---------------------------------------------------------------- 

43@asynccontextmanager 

44async def lifespan(app: FastAPI): 

45 # Startup 

46 logger.info("=== Application Starting ===") 

47 logger.info("ℹ️ Use 'alembic upgrade head' to apply database migrations") 

48 

49 if _background_tasks_disabled(): 

50 # Test path: keep ``app`` fully functional but skip the Celery 

51 # listener + reconciler. 

52 logger.info( 

53 "DISABLE_BACKGROUND_TASKS set — skipping Celery listener " 

54 "and reconciler (test mode)" 

55 ) 

56 try: 

57 yield 

58 finally: 

59 logger.info("=== Application Shutting Down (test mode) ===") 

60 return 

61 

62 # Bind the FastAPI event loop to the deployment pubsub *before* 

63 # spawning the Celery listener. The listener thread pushes into 

64 # the pubsub from a non-asyncio thread; without a loop reference 

65 # those pushes would be silently dropped. 

66 pubsub.set_loop(asyncio.get_running_loop()) 

67 logger.info("Deployment pubsub bound to event loop") 

68 

69 # Start Celery event listener in background thread 

70 listener_thread = threading.Thread(target=start_event_listener, daemon=True) 

71 listener_thread.start() 

72 logger.info("Celery event listener started in background") 

73 

74 # Reconciler is the safety net for events the listener missed (lost 

75 # event, backend restart during dispatch, broker hiccups). It runs 

76 # as an asyncio task so we can cancel it cleanly on shutdown. 

77 reconciler_task = asyncio.create_task(run_reconciler()) 

78 logger.info("Reconciler loop scheduled") 

79 

80 logger.info("Application started") 

81 

82 try: 

83 yield 

84 finally: 

85 # Shutdown 

86 logger.info("=== Application Shutting Down ===") 

87 reconciler_task.cancel() 

88 try: 

89 await reconciler_task 

90 except asyncio.CancelledError: 

91 pass 

92 except Exception: 

93 logger.exception("Reconciler task raised on shutdown") 

94 logger.info("Shutdown complete") 

95 

96 

97# ---------------------------------------------------------------- 

98# FASTAPI APP 

99# ---------------------------------------------------------------- 

100app = FastAPI( 

101 title="Backend API", 

102 description="FastAPI Backend with Auth, Git & Celery Integration", 

103 version="1.0.0", 

104 lifespan=lifespan 

105) 

106 

107# ---------------------------------------------------------------- 

108# CORS 

109# ---------------------------------------------------------------- 

110app.add_middleware( 

111 CORSMiddleware, 

112 allow_origins=settings.CORS_ORIGINS, 

113 allow_credentials=True, 

114 allow_methods=["*"], 

115 allow_headers=["*"], 

116) 

117 

118# ---------------------------------------------------------------- 

119# ROUTERS 

120# ---------------------------------------------------------------- 

121app.include_router(auth_keycloak.router, prefix="/auth", tags=["Authentication"]) 

122app.include_router(users.router, prefix="/users", tags=["Users"]) 

123app.include_router(courses.router, prefix="/courses", tags=["Courses"]) 

124app.include_router(apps.router, prefix="/apps", tags=["Apps"]) 

125app.include_router(admin_apps.router, prefix="/admin", tags=["Admin"]) 

126app.include_router(deployments.router, prefix="/deployments", tags=["Deployments"]) 

127app.include_router(tasks.router, prefix="/tasks", tags=["Tasks"]) 

128app.include_router(teams.router, prefix="/teams", tags=["Teams"]) 

129app.include_router(quotas.router, prefix="/quotas", tags=["Quotas"]) 

130app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"]) 

131app.include_router(openstack_credentials.router, tags=["OpenStack Credentials"]) 

132# Read API for OpenStack resources (Networks, Flavors, Images, ...), 

133# used by the wizard's value-help dropdowns so users don't have to type 

134# UUIDs from Horizon. 

135app.include_router( 

136 openstack_resources.router, 

137 prefix="/me/openstack/resources", 

138 tags=["OpenStack Resources"], 

139) 

140 

141 

142# ---------------------------------------------------------------- 

143# HEALTH CHECK 

144# ---------------------------------------------------------------- 

145@app.get("/health") 

146def health_check(): 

147 return { 

148 "status": "healthy", 

149 "service": "backend-api", 

150 "version": "1.0.0" 

151 }