freeleaps-ops/apps/gitea-webhook-ambassador-python/app/main.py
Nicolas f6c515157c feat: 添加 Python 版本的 Gitea Webhook Ambassador
- 新增完整的 Python 实现,替代 Go 版本
- 添加 Web 登录界面和仪表板
- 实现 JWT 认证和 API 密钥管理
- 添加数据库存储功能
- 保持与 Go 版本一致的目录结构和启动脚本
- 包含完整的文档和测试脚本
2025-07-20 21:17:10 +08:00

383 lines
11 KiB
Python

"""
FastAPI 应用主入口
集成 Webhook 处理、防抖、队列管理等服务
"""
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 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
# 路由导入将在运行时动态处理
# 配置结构化日志
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()
# 监控指标
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"
)
# 全局服务实例
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):
"""应用生命周期管理"""
global dedup_service, jenkins_service, webhook_service, celery_app, redis_client
# 启动时初始化
logger.info("Starting Gitea Webhook Ambassador")
try:
# 初始化 Redis 连接
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
)
# 测试 Redis 连接
await redis_client.ping()
logger.info("Redis connection established")
# 初始化 Celery
celery_app = get_celery_app()
# 初始化服务
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:
# 关闭时清理
logger.info("Shutting down Gitea Webhook Ambassador")
if redis_client:
await redis_client.close()
logger.info("Redis connection closed")
# 创建 FastAPI 应用
app = FastAPI(
title="Gitea Webhook Ambassador",
description="高性能的 Gitea 到 Jenkins 的 Webhook 服务",
version="1.0.0",
lifespan=lifespan
)
# 添加 CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境应该限制具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 依赖注入
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
# 中间件
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""请求日志中间件"""
start_time = asyncio.get_event_loop().time()
# 记录请求开始
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)
# 记录请求完成
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:
# 记录请求错误
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):
"""添加安全头"""
response = await call_next(request)
# 添加安全相关的 HTTP 头
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
# 异常处理器
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""全局异常处理器"""
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"
}
)
# 健康检查端点
@app.get("/health")
async def health_check():
"""基础健康检查"""
try:
# 检查 Redis 连接
if redis_client:
await redis_client.ping()
redis_healthy = True
else:
redis_healthy = False
# 检查 Celery 连接
if celery_app:
inspect = celery_app.control.inspect()
celery_healthy = bool(inspect.active() is not None)
else:
celery_healthy = False
return {
"status": "healthy" if redis_healthy and celery_healthy else "unhealthy",
"timestamp": asyncio.get_event_loop().time(),
"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():
"""队列健康检查"""
try:
if celery_app is None:
return JSONResponse(
status_code=503,
content={"status": "unhealthy", "error": "Celery not available"}
)
inspect = celery_app.control.inspect()
# 获取队列统计
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 {})
# 更新监控指标
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)
}
)
# 监控指标端点
@app.get("/metrics")
async def metrics():
"""Prometheus 监控指标"""
return Response(
content=generate_latest(),
media_type=CONTENT_TYPE_LATEST
)
# 包含路由模块
try:
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"]
)
except ImportError as e:
# 如果模块不存在,记录警告但不中断应用启动
logger.warning(f"Some handlers not available: {e}")
# 根路径
@app.get("/")
async def root():
"""根路径"""
return {
"name": "Gitea Webhook Ambassador",
"version": "1.0.0",
"description": "高性能的 Gitea 到 Jenkins 的 Webhook 服务",
"endpoints": {
"webhook": "/webhook/gitea",
"health": "/health",
"metrics": "/metrics",
"admin": "/admin"
}
}
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()
)