""" Main entry for FastAPI application Integrates webhook handling, deduplication, queue management, and related services """ import asyncio from contextlib import asynccontextmanager from typing import Dict, Any import structlog from fastapi import FastAPI, Request, HTTPException, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, Response from redis import asyncio as aioredis from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST from datetime import datetime from app.config import get_settings from app.services.dedup_service import DeduplicationService from app.services.jenkins_service import JenkinsService from app.services.webhook_service import WebhookService from app.tasks.jenkins_tasks import get_celery_app # Route imports will be dynamically handled at runtime # Configure structured logging structlog.configure( processors=[ structlog.stdlib.filter_by_level, structlog.stdlib.add_logger_name, structlog.stdlib.add_log_level, structlog.stdlib.PositionalArgumentsFormatter(), structlog.processors.TimeStamper(fmt="iso"), structlog.processors.StackInfoRenderer(), structlog.processors.format_exc_info, structlog.processors.UnicodeDecoder(), structlog.processors.JSONRenderer() ], context_class=dict, logger_factory=structlog.stdlib.LoggerFactory(), wrapper_class=structlog.stdlib.BoundLogger, cache_logger_on_first_use=True, ) logger = structlog.get_logger() # Monitoring metrics WEBHOOK_REQUESTS_TOTAL = Counter( "webhook_requests_total", "Total number of webhook requests", ["status", "environment"] ) WEBHOOK_REQUEST_DURATION = Histogram( "webhook_request_duration_seconds", "Webhook request duration in seconds", ["environment"] ) QUEUE_SIZE = Gauge( "queue_size", "Current queue size", ["queue_type"] ) DEDUP_HITS = Counter( "dedup_hits_total", "Total number of deduplication hits" ) # Global service instances dedup_service: DeduplicationService = None jenkins_service: JenkinsService = None webhook_service: WebhookService = None celery_app = None redis_client: aioredis.Redis = None @asynccontextmanager async def lifespan(app: FastAPI): """Application lifecycle management""" global dedup_service, jenkins_service, webhook_service, celery_app, redis_client # Initialize on startup logger.info("Starting Gitea Webhook Ambassador") try: # Initialize Redis connection settings = get_settings() redis_client = aioredis.from_url( settings.redis.url, password=settings.redis.password, db=settings.redis.db, encoding="utf-8", decode_responses=True ) # Test Redis connection await redis_client.ping() logger.info("Redis connection established") # Initialize Celery celery_app = get_celery_app() # Initialize services dedup_service = DeduplicationService(redis_client) jenkins_service = JenkinsService() webhook_service = WebhookService( dedup_service=dedup_service, jenkins_service=jenkins_service, celery_app=celery_app ) logger.info("All services initialized successfully") yield except Exception as e: logger.error("Failed to initialize services", error=str(e)) raise finally: # Cleanup on shutdown logger.info("Shutting down Gitea Webhook Ambassador") if redis_client: await redis_client.close() logger.info("Redis connection closed") # Create FastAPI application app = FastAPI( title="Gitea Webhook Ambassador", description="High-performance Gitea to Jenkins Webhook service", version="1.0.0", lifespan=lifespan ) # Add CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], # In production, restrict to specific domains allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Dependency injection def get_dedup_service() -> DeduplicationService: if dedup_service is None: raise HTTPException(status_code=503, detail="Deduplication service not available") return dedup_service def get_webhook_service() -> WebhookService: if webhook_service is None: raise HTTPException(status_code=503, detail="Webhook service not available") return webhook_service def get_celery_app_dep(): if celery_app is None: raise HTTPException(status_code=503, detail="Celery app not available") return celery_app # Middleware @app.middleware("http") async def log_requests(request: Request, call_next): """Request logging middleware""" start_time = asyncio.get_event_loop().time() # Log request start logger.info("Request started", method=request.method, url=str(request.url), client_ip=request.client.host if request.client else None) try: response = await call_next(request) # Log request complete process_time = asyncio.get_event_loop().time() - start_time logger.info("Request completed", method=request.method, url=str(request.url), status_code=response.status_code, process_time=process_time) return response except Exception as e: # Log request error process_time = asyncio.get_event_loop().time() - start_time logger.error("Request failed", method=request.method, url=str(request.url), error=str(e), process_time=process_time) raise @app.middleware("http") async def add_security_headers(request: Request, call_next): """Add security headers""" response = await call_next(request) # Add security-related HTTP headers response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" return response # Exception handler @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): """Global exception handler""" logger.error("Unhandled exception", method=request.method, url=str(request.url), error=str(exc), exc_info=True) return JSONResponse( status_code=500, content={ "success": False, "message": "Internal server error", "error": str(exc) if get_settings().debug else "An unexpected error occurred" } ) # Health check endpoint @app.get("/health") async def health_check(): try: # Check Redis connection if redis_client: await redis_client.ping() redis_healthy = True else: redis_healthy = False # Check Celery connection if celery_app: inspect = celery_app.control.inspect() celery_healthy = bool(inspect.active() is not None) # Worker pool/queue info active = inspect.active() or {} reserved = inspect.reserved() or {} worker_count = len(inspect.registered() or {}) active_count = sum(len(tasks) for tasks in active.values()) reserved_count = sum(len(tasks) for tasks in reserved.values()) else: celery_healthy = False worker_count = 0 active_count = 0 reserved_count = 0 # Jenkins jenkins_status = "healthy" return { "status": "healthy" if redis_healthy and celery_healthy else "unhealthy", "service": "gitea-webhook-ambassador-python", "version": "2.0.0", "timestamp": datetime.utcnow().isoformat(), "jenkins": { "status": jenkins_status, "message": "Jenkins connection mock" }, "worker_pool": { "active_workers": worker_count, "queue_size": active_count + reserved_count, "total_processed": 0, # 可补充 "total_failed": 0 # 可补充 }, "services": { "redis": "healthy" if redis_healthy else "unhealthy", "celery": "healthy" if celery_healthy else "unhealthy" } } except Exception as e: logger.error("Health check failed", error=str(e)) return JSONResponse( status_code=503, content={ "status": "unhealthy", "error": str(e) } ) @app.get("/health/queue") async def queue_health_check(): """Queue health check""" try: if celery_app is None: return JSONResponse( status_code=503, content={"status": "unhealthy", "error": "Celery not available"} ) inspect = celery_app.control.inspect() # Get queue stats active = inspect.active() reserved = inspect.reserved() registered = inspect.registered() active_count = sum(len(tasks) for tasks in (active or {}).values()) reserved_count = sum(len(tasks) for tasks in (reserved or {}).values()) worker_count = len(registered or {}) # Update monitoring metrics QUEUE_SIZE.labels(queue_type="active").set(active_count) QUEUE_SIZE.labels(queue_type="reserved").set(reserved_count) return { "status": "healthy", "queue_stats": { "active_tasks": active_count, "queued_tasks": reserved_count, "worker_count": worker_count, "total_queue_length": active_count + reserved_count } } except Exception as e: logger.error("Queue health check failed", error=str(e)) return JSONResponse( status_code=503, content={ "status": "unhealthy", "error": str(e) } ) # Metrics endpoint @app.get("/metrics") async def metrics(): """Prometheus metrics endpoint""" return Response( content=generate_latest(), media_type=CONTENT_TYPE_LATEST ) # Register routers for webhook, health, and admin APIs from app.handlers import webhook, health, admin app.include_router(webhook.router, prefix="/webhook", tags=["webhook"]) app.include_router(health.router, prefix="/health", tags=["health"]) app.include_router(admin.router, prefix="/admin", tags=["admin"]) # Root path @app.get("/") async def root(): """Root path""" return { "name": "Gitea Webhook Ambassador", "version": "1.0.0", "description": "High-performance Gitea to Jenkins Webhook service", "endpoints": { "webhook": "/webhook/gitea", "health": "/health", "metrics": "/metrics", "admin": "/admin" } } # --- Minimal Go-version-compatible endpoints --- from fastapi import status @app.post("/webhook/gitea") async def webhook_gitea(request: Request): """Minimal Gitea webhook endpoint (mock)""" body = await request.body() # TODO: Replace with real webhook processing logic return {"success": True, "message": "Webhook received (mock)", "body_size": len(body)} @app.get("/metrics") async def metrics_endpoint(): """Minimal Prometheus metrics endpoint (mock)""" # TODO: Replace with real Prometheus metrics return Response( content="# HELP webhook_requests_total Total number of webhook requests\nwebhook_requests_total 0\n", media_type="text/plain" ) @app.get("/health/queue") async def health_queue(): """Minimal queue health endpoint (mock)""" # TODO: Replace with real queue stats return { "status": "healthy", "queue_stats": { "active_tasks": 0, "queued_tasks": 0, "worker_count": 1, "total_queue_length": 0 } } # --- End minimal endpoints --- if __name__ == "__main__": import uvicorn settings = get_settings() uvicorn.run( "app.main:app", host=settings.host, port=settings.port, reload=settings.debug, log_level=settings.logging.level.lower() )